C++对象模型的那些事儿之四:拷贝构造函数

时间:2023-03-08 17:22:55

前言

对于一个没有实例化的空类,编译器不会给它默认生成任何函数,当实例化一个空类后,编译器会根据需要生成相应的函数。这类函数包括一下几个:

  • 构造函数
  • 拷贝构造函数
  • 析构函数
  • 赋值运算符

在上一篇博文C++对象模型的那些事儿之三:默认构造函数中讲到,编译器在需要的时候会合成一个空构造函数。本篇博文中就重点来介绍一下第二主角:拷贝构造函数。

引子

正如Linus Torvalds说的一句话:“Talk is cheap,Show me the code”。在程序员的世界里,讲再多都不如直接给看代码。就比如一道算法题,别人跟你讲半天思路,你懂了,但真正要你码出代码来实现时,你可能花的时间比理解思路还要多,更别提后面的调试时间了。所以,一如本系列文章的风格,从代码的角度来观“对象模型”,再合适也不过呢。

拷贝构造函数,就是以一个对象作为另一个类对象初值的构造函数。在下面三种情况下会调用拷贝构造函数:

class Animal{};
//--------------------第一种情况-------------------------//
//对一个对象对另一个对象进行显示的初始化
Animal animal_one;
Animal animal_two = animal_one; 

//--------------------第二种情况-------------------------//
//一个对象作为函数参数,以值传递的方式传进函数
void getName(Animal a){}
getName(animal_one);

//--------------------第三种情况-------------------------//
//一个对象作为函数返回值,以值传递的方式从函数返回
Animal setName(){
    Animal animal;
    //....
    return animal;
}

说了这么多,拷贝构造函数到底该怎么写呢?请继续阅读下面的代码。

//单参数拷贝构造函数
Animal::Animal(const Animal& _animal){
    //....
}
//多参数拷贝构造函数,其第二参数即后继参数以一个默认值供应
Animal::Animal(const Animal& _animal, int =0){
    //.....
}

有了如上的理解之后,还是如默认构造函数那样,接下来就来讨论trivial和non-trivial构造函数以及什么时候编译器会产生non-trivial构造函数。

位逐次拷贝

位逐次拷贝是由“Bitwise Copy Semantics”翻译而来,就是按bit位来拷贝对象。如下面的代码:

class Animal{
    //没有提供显示的拷贝构造函数
    int age;
    char* name;
};
Animal animal_one;
Animal animal_two = animal_one;

这种情况下会采用位逐次拷贝,只是简简单单按位把animal_one的内存中存的值赋给animal_two,这类拷贝也称为浅拷贝。正如大家熟知,这类拷贝是不安全的。

C++对象模型的那些事儿之四:拷贝构造函数

上图就是位逐次拷贝后的对象示意图,现在animal_two的name指针指向了animal_one::name指向的字符串,如果animal_one被析构,animal_two::name就成空悬指针,当animal_two析构的时候,就会释放一个已经释放的内存,会造成不可预知的错误。

如果我们把char* name换成string name,再执行拷贝构造函数后,其对象示意如下:

C++对象模型的那些事儿之四:拷贝构造函数

因为string函数有显式的拷贝构造函数,所以在执行拷贝构造函数的时候是为animal_two::name重新分配一块内存,然后对其赋值,自然就不会存在两个指针指向同一块内存的情况了。

对于上述两种情况,我们可以将拷贝构造函数划分为trivial和non-trivial:

  • trivial:直接进行位逐次拷贝
  • non-trivial:不进行位逐次拷贝

那么,编译器在什么时候不会展现出位逐次拷贝的能力,即会合成一个non-trivial拷贝构造函数呢?下面就分四种情况来讨论。

带有拷贝构造函数的成员类对象

如果一个类中有带有拷贝构造函数的类成员,或是编译器会为其合成一个拷贝构造函数,那么这个类就不会展现出位逐次拷贝的能力。

class Animal{
 public:
    Animal(){}
    Animal(const Animal& animal){
            cout<<"Animal's Copy Constructor"<<endl;
        }
};
class Dog{
public:
    Dog(){}
    Animal animal;
};

int main(){
    Dog dog1;
    Dog dog2 = dog1;
}

如上述的代码,执行之后会输出:Animal’s Copy Constructor,编译器为dog2合成的拷贝构造函数不是简单的进行位逐次拷贝,而是调用了Animal的拷贝构造函数,重新构造一个dog2::animal。

带有拷贝构造函数的基类

如果一个类继承自一个带有拷贝构造函数的基类的话,那么编译器在为其合成拷贝构造函数的时候会调用基类的拷贝构造函数。简单的以以下代码来测试一下:

class Animal{
 public:
    Animal(){}
    Animal(const Animal& animal){
            cout<<"Animal's Copy Constructor"<<endl;
        }
};
class Dog : Animal{
public:
    Dog(){}
};

int main(){
    Dog dog1;
    Dog dog2 = dog1;
}

同样的,上述代码会输出Animal’s Copy Constructor,显示调用了基类的拷贝构造函数。

带有虚函数的类对象

如果一个类带有虚函数,想想在上一篇讲默认构造函数的时候,编译期间会执行下面两个操作

  • 增加一个虚函数表,内含每一个有作用的虚函数的地址
  • 一个指向虚表的指针,安插在每一个类对象内

涉及到虚函数的类在合成拷贝构造函数的时候,有点复杂,我们先看看如下测试代码:

class Animal{
 public:
    virtual void eat(){}
};
class Dog : public Animal{
public:
    virtual void eat(){}
};
int main(){
    Dog dog1;
    Dog dog2 = dog1;
    cout<<"dog1::vptr"<<(long long *)*(long long*)&dog1<<endl;
    cout<<"dog1::vptr"<<(long long *)*(long long*)&dog2<<endl;
}

在上述测试代码中,我提取出dog1和dog2对象中的虚表地址,观察输出如下:

dog1::vptr:0x400c60
dog2::vptr:0x400c60

由于虚表是在编译的时候创建的,所以,将dog2的虚表指针指向dog1的虚表这样是安全的,这里使用位逐次拷贝是没有问题的。

Tips:对于带有虚函数的类,用同类型的对象初始化时,采用位逐次拷贝完全够用,不会合成拷贝构造函数。

这里可以对比,将两个指针同时指向同一个字符常量的情况,这样是安全的。

但是,如果执行如下代码:

Dog dog;
Animal animal = dog;
cout<<"dog::vptr:"<<(long long *)*(long long*)&dog<<endl;
cout<<"animal::vptr:"<<(long long *)*(long long*)&animal<<endl;

此时,将一个父类用子类初始化,这时候输出如下:

dog::vptr:0x400c30
animal::vptr:0x400c48

可见,这时候就不能采用位逐次拷贝了,父类的拷贝构造函数需要重新设定自己的虚指针指向Animal类的虚表,而不是直接将dog::vptr直接赋给animal::vptr。

带有虚基类的子类对象

同样,对于带有虚基类的子类,情况也比较复杂,我们先来看看如下继承关系:

C++对象模型的那些事儿之四:拷贝构造函数

在上图中,Canidae由Animal类虚拟派生出来,Dog由Canidae类派生出来,在Canidae和Dog类中都有一个虚基类的指针,指向每个类中的虚基类。因此,在执行以下操作时,位逐次拷贝也会失效,编译器必须合成一个拷贝构造函数,来重新设定指向虚基类的指针。

Dog dog;
Canidae canidae=dog;
cout<<(long long *)*(long long*)&canidae<<endl;
cout<<(long long *)*(long long*)&dog<<endl;

以上测试代码输出:

0x400c20
0x400be0

总结

本篇博客讨论了编译器会合成一个拷贝构造函数的四种情况,现总结如下:

  • 带有拷贝构造函数的成员类对象
  • 带有拷贝构造函数的基类对象
  • 带有虚函数的类对象
  • 带有虚基类的子类对象

其中,需要注意的是:对于带有虚函数的类对象和带有虚基类的子类对象这两种情况中,如果是以同类型的对象作为初始对象的话,是不会合成拷贝构造函数的,仅仅使用位逐次拷贝就能完成。

About Me

由于本人也是初学,在写作过程中,难免有错误的地方,读者如果发现,请在下面留言指出。

最后,如有疑惑或需要讨论的地方,可以联系我,联系方式见我的个人博客about页面,地址:About Me

另外,本人的第一本gitbook书已整理完,关于leetcode刷题题解的,点此进入One day One Leetcode

欢迎持续关注!Thx!