你好,C++(34)有一只叫做多利的羊 6.2.4 拷贝构造函数

6.2.4  拷贝构造函数

在C++世界中,除了需要使用构造函数直接创建一个新的对象之外,有时还需要根据已经存在的某个对象创建它的一个副本,就像那只叫做多利的羊一样,我们希望根据一只羊创建出来另外一只一模一样的羊。例如:

// 调用构造函数创建一个新对象shMother
Sheep shMother;
// 对shMother进行一些操作…
// 利用shMother对象创建一个一模一样的新对象shDolly作为其副本
Sheep shDolly(shMother);

在这里,首先创建了一个Sheep类的新对象shMother,然后对它进行了一些操作改变其成员变量等,接着用这个对象作为Sheep类的构造函数的参数,创建一个与shMother对象一模一样的副本shDolly。我们将这种可以接受某个对象作为参数并创建一个新对象作为其副本的构造函数称为拷贝构造函数。拷贝构造函数实际上是构造函数的表亲,在语法格式上,两者基本相似,两者拥有相同的函数名,只是拷贝构造函数的参数是这个类对象的引用,而它所创建得到的对象,是对那个作为参数的对象的一个拷贝,是它的一个副本。跟构造函数相似,默认情况下,如果一个类没有显式地定义其拷贝构造函数,编译器会为其创建一个默认的拷贝构造函数,以内存拷贝的方式将旧有对象内存空间中的数据拷贝到新对象的内存空间,以此来完成新对象的创建。因为我们上面的Sheep类没有定义拷贝构造函数,上面代码中shDolly这个对象的创建就是通过这种默认的拷贝构造函数的方式完成的。

使用default和delete关键字控制类的默认行为

为了提高开发效率,对于类中必需的某些特殊函数,比如构造函数、析构函数、赋值操作符等,如果我们没有在类当中显式地定义这些特殊函数,编译器就会为我们生成这些函数的默认版本。虽然这种机制可以为我们节省很多编写特殊函数的时间,但是在某些特殊情况下,比如我们不希望对象被复制,就不需要编译器生成的默认拷贝构造函数。这时候这种机制反倒成了画蛇添足,多此一举了。

为了取消编译器的这种默认行为,我们可以使用delete关键字来禁用某一个默认的特殊函数。比如,在默认情况下,即使我们没在类当中定义拷贝构造函数,编译器也会为我们生成默认的拷贝构造函数进而可以通过它完成对象的拷贝复制。而有的时候,我们不希望某个对象被复制,那就需要用delete禁用类当中的拷贝构造函数和赋值操作符,防止编译器为其生成默认函数:

class Sheep
{
    // ...

    // 禁用类的默认赋值操作符
    Sheep& operator = (const Sheep&) = delete;
// 禁用类的默认拷贝构造函数
    Sheep(const Sheep&) = delete; 
};

现在,Sheep类就没有默认的赋值操作符和拷贝构造函数的实现了。如果这时还想对对象进行复制,就会导致编译错误,从而达到禁止对象被复制的目的。例如:

// 错误的对象复制行为
Sheep shDolly(shMother);  // 错误:Sheep类的拷贝构造函数被禁用
Sheep shDolly = shMother; // 错误:Sheep类的赋值操作符被禁用

与delete关键字禁用默认函数相反地,我们也可以使用default关键字,显式地表明我们希望使用编译器为这些特殊函数生成的默认版本。还是上面的例子,如果我们希望对象可以以默认方式被复制:

class Sheep
{
    // ...
    // 使用默认的赋值操作符和拷贝构造函数
    Sheep& operator = (const Sheep&) = default;    
    Sheep(const Sheep&) = default;
};

显式地使用default关键字来表明使用类的默认行为,对于编译器来说显然是多余的,因为即使我们不说明,它也会那么干。但是对于代码的阅读者而言,使用default关键字显式地表明使用特殊函数的默认版本,则意味着我们已经考虑过,这些特殊函数的默认版本已经满足我们的要求,无需自己另外定义。而将默认的操作留给编译器去实现,不仅可以节省时间提高效率,更重要的是,减少错误发生的机会,并且通常会产生更好的目标代码。

在大多数情况下,默认版本的拷贝构造函数已经能够满足我们拷贝复制对象的需要了,我们无需显式地定义拷贝构造函数。但在某些特殊情况下,特别是类当中有指针类型的成员变量的时候,以拷贝内存方式实现的默认拷贝构造函数只能复制指针成员变量的值,而不能复制指针所指向的内容,这样,新旧两个对象中不同的两个指针却指向了相同的内容,这显然是不合理的。默认的拷贝构造函数无法正确地完成这类对象的拷贝。在这种情况下,就需要自己定义类的拷贝构造函数,以自定义的方式完成像指针成员变量这样的需要特殊处理的内容的拷贝工作。例如,有一个Computer类,它有一个指针类型的成员变量m_pKeyboard,指向的是一个独立的Keboard对象,这时就需要定义Compuer类的拷贝构造函数来完成特殊的复制工作:

// 键盘类,因为结构简单,我们使用struct来定义
struct Keyboard
{
    // 键盘的型
    string m_strModel;
};

// 定义了拷贝构造函数的电脑类
class Computer
{
public:
    // 默认构造函数
    Computer()
        : m_pKeyboard(nullptr),m_strModel("")
    {}

    // 拷贝构造函数,参数是const修饰的Computer类的引用
    Computer(const Computer& com)
        : m_strModel(com.m_strModel)   // 直接使用初始化属性列表完成对象类型成员变量m_strModel的复制

    {

        // 创建新对象,完成指针类型成员变量m_pKeyboard的复制
       // 获得已有对象com的指针类型成员变量m_pKeyboard
        Keyboard* pOldKeyboard = com.GetKeyboard();
        // 以pOldKeyboard所指向的Keyboard对象为蓝本,
        // 创建一个新的Keyboard对象,并让m_Keyboard指向这个对象
        if( nullptr != pOldKeyboard )
            // 这里Keyboard对象的复制使用的是Keyboard类的默认拷贝构造函数
            m_pKeyboard = new Keyboard(*(pOldKeyboard));
        else
            m_pKeyboard = nullptr; // 如果没有键盘
    }

// 析构函数,
// 对于对象类型的成员变量m_strModel,会被自动销毁,无需在析构函数中进行处理
// 对于指针类型的成员变量m_pKeyboard,则需要在析构函数中主动销毁
~Computer()
{
      delete m_pKeyboard;
      m_pKeyboard = nullptr;
}

    // 成员函数,设置或获得键盘对象指针
    void SetKeyboard(Keyboard* pKeyboard)
    {
        m_pKeyboard = pKeyboard;
    }

    Keyboard* GetKeyboard() const
    {
        return m_pKeyboard;
    }

private:
    // 指针类型的成员变量
    Keyboard* m_pKeyboard;
    // 对象类型的成员变量
    string m_strModel;
};

在这段代码中,我们为Computer类创建了一个自定义的拷贝构造函数。在这个拷贝构造函数中,对于对象类型的成员变量m_strModel,我们直接使用初始化属性列表就完成了成员变量的拷贝。而对于指针类型成员变量m_pKeyboard而言,它的拷贝并不是拷贝这个指针的值本身,而应该拷贝的是这个指针所指向的对象。所以,对于指针类型的成员变量,并不能直接采用内存拷贝的形式完成拷贝,那样只是拷贝了指针的值,而指针所指向的内容并没有得到拷贝。要完成指针类型成员变量的拷贝,首先应该获得已有对象的指针类型成员变量,进而通过它获得它所指向的对象,然后再创建一个副本并将新对象中相应的指针类型成员变量指向这个对象,比如我们这里的“m_pKeyboard = new Keyboard(*(pOldKeyboard));”,这样才完成了指针类型成员变量的复制。这个我们自己定义的拷贝构造函数不仅能够拷贝Computer类的对象类型成员变量m_strModel,也能够正确地完成指针类型成员变量m_pKeyboard的拷贝,最终才能完成对Computer对象的拷贝。例如:

// 引入断言所在的头文件

#include <assert.h>

//// 创建一个Computer对象oldcom

Computer oldcom;

// 创建oldcom的Keyboard对象并修改其属性

Keyboard keyboard;

keyboard.m_strModel = "Microsoft-101";

// 将键盘组装到oldcom上

oldcom.SetKeyboard(&keyboard);

// 以oldcom为蓝本,利用Computer类的拷贝构造函数创建新对象newcom

// 新的newcom对象是oldcom对象的一个副本
Computer newcom(oldcom);

// 使用断言判断两个Computer对象是否相同,
// 电脑型号应该相同
assert(newcom.GetModel() == oldcom.GetModel());
// 不同的Computer对象应该拥有不同的Keyboard对象
assert( newcom.GetKeyboard() != oldcom.GetKeyboard() );
// 因为是复制,不同的Keyboard对象应该是相同的型号
assert( newcom.GetKeyboard()->m_strModel
    == oldcom.GetKeyboard()->m_strModel );

在C++中,除了使用拷贝构造函数创建对象的副本作为新的对象之外,在创建一个新对象之后,还常常将一个已有的对象直接赋值给它来完成新对象的初始化。例如:

// 创建一个新的对象
Computer newcom;
// 利用一个已有的对象对其进行赋值,完成初始化
newcom = oldcom;

赋值的过程,实际上也是一个拷贝的过程,就是将等号右边的对象拷贝到等号左边的对象。跟类的拷贝构造函数相似,如果没有显式地为类定义赋值操作符,编译器也会为其生成一个默认的赋值操作符,以内存拷贝的方式完成对象的赋值操作。因为同样是以内存拷贝的方式完成对象的复制,所以当类中有指针型成员变量时,也同样会遇到只能拷贝指针的值而无法拷贝指针所指向的内容的问题。因此,要完成带有指针型成员变量的类对象的赋值,必须对类的赋值操作符进行自定义,在其中以自定义的方式来完成指针型成员变量的复制。例如,Computer类中含有指针型成员变量m_pKeybard,可以这样自定义它的赋值操作符来完成其赋值操作。

// 定义了赋值操作符“=”的电脑类
class Computer
{   
public:
    // 自定义的赋值操作符
    Computer& operator = (const Computer& com)
    {
        // 判断是否是自己给自己赋值
        // 如果是自赋值,则直接返回对象本身 
// 这里的this指针,是类当中隐含的一个指向自身对象的指针。
        if( this == &com ) return *this;
       
        // 直接完成对象型成员变量的赋值
        m_strModel = com.m_strModel;

        // 创建旧有对象的指针型成员变量所指对象的副本
       // 并将被赋值对象相应的指针型成员变量指向这个副本对象
        m_pKeyboard = new Keyboard(*(com.GetKeyboard()));
    }

    //
};

在上面的赋值操作符函数中,我们首先判断这是不是一个自赋值操作。所谓自赋值,就是自己给自己赋值。例如:

// 用newcom给newcom赋值
newcom = newcom;

严格意义上说,这种自赋值操作是没有意义的,应该算是程序员的一个失误。但作为一个设计良好的赋值操作符,应该可以检测出这种失误并给予恰当的处理,将程序从程序员的失误中解救出来:

// 判断是否是自赋值操作
// 将this指针与传递进来的指向com对象的指针进行比较
// 如果相等,就是自赋值操作,直接返回这个对象本身
if( this == &com) return *this;

在赋值操作符函数中如果检测到这种自赋值操作,它就直接返回这个对象本身从而避免后面的复制操作。如果不是自赋值操作,对于对象型成员变量,使用“=”操作符直接完成其赋值;而对于指针型成员变量,则采用跟拷贝构造函数相似的方式,通过创建它所指向的对象的副本,并将左侧对象的相应指针型成员变量指向这个副本对象来完成其赋值。

另外值得注意的一点是,赋值操作符的返回值类型并不一定是这个类的引用,我们使用void代替也是可以的。例如:

class Computer
{   
public:
    // 以void作为返回值类型的赋值操作符
    void operator = (const Computer& com)
    {
        //
    }
    //
};

以上的代码虽然在语法上正确,也能够实现单个对象的赋值操作,但是却无法实现如下形式的连续赋值操作:

Computer oldcom;
//
Computer newcom1,newcom2;
// 连续赋值
newcom1 = newcom2 = oldcom;

连续的赋值操作符是从右向左开始进行赋值的,所以,上面的代码实际上就是:

newcom1 = (newcom2 = oldcom );

也就是先将oldcom赋值给newcom2(如果返回值类型是void,这一步是可以完成的),然后将“newcom2 = oldcom”的运算结果赋值给newcom1,而如果赋值操作符的返回值类型是void,也就意味着“newcom2 = oldcom”的运算结果是void类型,我们显然是不能将一个void类型数据赋值给一个Computer对象的。所以,为了实现上面这种形式的连续赋值,我们通常以这个类的引用(Computer&)作为赋值操作符的返回值类型,并在其中返回这个对象本身(“return *this”),已备用做下一步的继续赋值操作。

初始化列表构造函数

除了我们在上文中介绍的普通构造函数和拷贝构造函数之外,为了让对象的创建形式更加灵活,C++还提供了一种可以接受一个初始化列表(initializer list)为参数的构造函数,因此这种构造函数也被称为初始化列表构造函数。初始化列表由一对大括号(“{}”)构造而成,可以包含任意多个相同类型的数据元素。如果我们希望可以通过不定个数的相同类型数据来创建某个对象,比如,一个工资对象管理着不定个数的工资项目,包括基本工资,奖金,提成,补贴等等,有的人只有基本工资,而有的人全都有,为了创建工资对象形式上的统一,我们就希望这些不定个数的工资项目都可以用来创建工资对象。这时,我们就需要通过实现工资类的初始化列表构造函数来完成这一任务:

#include <iostream>
#include <vector>
#include <initializer_list>  // 引入初始化列表所在头文件

using namespace std;
 
// 工资类
class Salary
{
public:
// 初始化列表构造函数
// 工资数据为int类型,所以其参数类型为initializer_list<int>
    Salary(initializer_list<int> s)
    {
    // 以容器的形式访问初始化列表
    // 获取其中的工资项目保存到工资类的vector容器
        for(int i : s)
         m_vecSalary.push_back(i);
}

// ..

// 获取工资总数
int GetTotal()
{
    int nTotal = 0;
    for(int i : m_vecSalary)
         nTotal += i;

    return nTotal;
}

private:
    // 保存工资数据的vector容器
    vector<int> m_vecSalary;
};

int main()
{
// 陈老师只有基本工资,“{}”表示初始化列表
    Salary sChen{2200};
    // 王老师既有基本工资还有奖金和补贴
Salary sWang{5000,9500,1003};
   // 输出结果
cout<<"陈老师的工资:"<<sChen.GetTotal()<<endl;
   cout<<"王老师的工资:"<<sWang.GetTotal()<<endl;

    return 0;
}

从这里可以看到,虽然陈老师和王老师的工资项目各不相同,但是通过初始化列表构造函数,他们的工资对象都可以以统一的形式创建。而这正是初始化列表的意义所在,它可以让不同个数的同类型数据以相同的形式作为函数参数。换句话说,如果我们希望某个函数可以接受不定个数的同类型数据为参数,就可以用初始化列表作为参数类型。例如,我们可以为Salary类添加一个AddSalary()函数,用初始化列表作为参数,它就可以向Salary对象添加不定个数的工资项目:

// 以初始化列表为参数的普通函数
void AddSalary(initializer_list<int> s)
{
    for(int i : s)
         m_vecSalary.push_back(i);
}

//// 后来发现是陈老师的奖金和补贴忘了计算了,给他加上
// 这里的大括号{}就构成初始化列表
sChen.AddSalary({8200,6500});

 

6.2.5  操作符重载

如果要想对两个对象进行操作,比如两个对象相加,最直观的方式就是像数学式子一样,用表示相应意义的操作符来连接两个对象,以此表达对这两个对象的操作。在本质上,操作符就相当于一个函数,它有自己的参数,可以用来接收操作符所操作的数据;也有自己的函数名,就是操作符号;同时也有返回值,用于返回结果数据。而在使用上,只需要用操作符连接被操作的两个对象,比函数调用简单直观得多,代码的可读性更好。所以在表达一些常见的操作时,比如对两个对象的加减操作,我们往往通过重载这个类的相应意义的操作符来完成。在C++中有许多内置的数据类型,包括int、char、string等,而这些内置的数据类型都有许多已经定义的操作符可以用来表达它们之间的操作。比如,我们可以用“+”操作符来表达两个对象之间的“加和”操作,用它连接两个int对象,得到的“加和”操作结果就是这两个数的和,而用它连接两个string对象,得到的“加和”操作结果就是将两个字符串连接到一起。例如:

int a = 3;
int b = 4;
// 使用加法操作符“+”获得两个int类型变量的和
int c = a + b;
cout<<a<<" + "<<b<< " = "<<c<<endl;

string strSub1("Hello ");
string strSub2("C++");
// 使用加法操作符“+”获得两个string类型变量的连接结果
string strCombin = strSub1 + strSub2;
cout<<strSub1<<"+ "<<strSub2<<" = "<<strCombin<<endl;

这种用操作符来表达对象之间的操作关系的方式,用抽象性的操作符表达了具体的操作过程,从而隐藏了操作过程的具体细节,既直观又自然,也就更便于使用。对于内置的数据类型,C++已经提供了丰富的操作符供我们选择使用以完成常见的操作,比如表示数学运算的“+”(加)、“-”(减)、“*”(乘)、“/”(除)。但是对于我们新定义的类而言,其两个对象之间是不能用这些操作符直接进行操作的。比如,分别定义了Father类和Mother类的两个对象,我们希望可以用加法操作符“+”连接这两个对象,进而通过运算得出一个Baby类的对象:

// 分别定义Father类和Mother类的对象
Father father;
Mother mother;

// 用加法操作符“+”连接两个对象,运算得到Baby类的对象
Baby baby = father + mother;

以上语句所表达的是一件显而易见的事情,但是,如果没有对Father类的加法操作符“+”进行定义,Father类是不知道如何和一个Mother类的对象加起来创建一个Baby类对象的,这样的语句会出现编译错误,一件显而易见的事情在C++中却行不通。但幸运的是,C++允许我们对这些操作符进行重载,让我们可以对操作符的行为进行自定义。既然是自定义,自然是想干啥就干啥,自然也就可以让Father类对象加上Mother类对象得到Baby类对象,让上面的代码成为可能。

在功能上,重载操作符等同于类的成员函数,两者并无本质上的差别,可以简单地将重载操作符看成是一类比较特殊的成员函数。虽然成员函数可以提供跟操作符相同的功能,但是运用操作符可以让语句更加自然简洁,也更具可读性。比如,“a.add(b)”调用函数add()以实现两个对象a和b相加,但是表达相同意义的“a + b”语句,远比“a.add(b)”更直观也更容易让人理解。

在C++中,声明重载操作符的语法格式如下:

class 类名
{
public:
    返回值类型 operator 操作符 (参数列表)
    {
        // 操作符的具体运算过程
    }
};

从这里可以看到,重载操作符和类的成员函数在本质上虽然相同,但在形式上还是存在一些细微的差别。普通成员函数以标识符(不以数字为首的字符串)作为函数名,而重载操作符以“operator 操作符”作为函数名。其中的“operator”表示这是一个重载的操作符函数,而其后的操作符就是我们要定义的符号。

在使用上,当使用操作符连接两个对象进行运算时,实际上相当于调用第一个对象的操作符函数,而第二个对象则作为这个操作符函数的参数。例如,使用加法操作符对两个对象进行运算:

a + b;

这条语句实际上等同于:

a.operator + (b);

“a + b”表示调用的是对象a的操作符“operator +”,而对象b则是这个操作符函数的参数。理解了这些,要想让“father + mother”得到baby对象,只需要定义Father类的“+”操作符函数(因为father位于操作符之前,所以我们定义father所属的Father类的操作符),使其可以接受一个Mother类的对象作为参数,并返回一个Baby类的对象就可以了:

// 母亲类

class Mother

{

// 省略具体定义

};

// 孩子类

class Baby

{

public:

    // 孩子类的构造函数

    Baby(string strName)

        : m_strName(strName)

    {}

private:

    // 孩子的名字

    string m_strName;

};

// 父亲类

class Father
{
public:
    // 重载操作符“+”,返回值为Baby类型,参数为Mother类型
    Baby operator + (const Mother& mom)
    { 
// 创建一个Baby对象并返回,省略创建过程…
          return Baby("MiaoMiao");
    }
};

在Father类的重载操作符“+”中,它可以接受一个Mother类的对象作为参数,并在其中创建一个Baby类的对象作为操作符的返回值。这样就完整地表达了一个Father类的对象加上一个Mother类的对象得到一个Baby类的对象的意义,现在就可以方便地使用操作符“+”将Father类的对象和Mother类的对象相加而得到一个Baby类的对象了。需要注意的是,这里我们只是定义了Father类的“+”操作符,所以在用它计算的时候,只能是Father类的对象放在“+”之前,而如果希望Mother类的对象也可以放在“+”之前,相应地就同样需要定义Mother类的“+”操作符。

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。