深度解读《深度探索C++对象模型》之拷贝构造函数

接下来我将持续更新“深度解读《深度探索C++对象模型》”系列,敬请期待,欢迎关注!也可以关注公众号:iShare爱分享,自动获得推文。

写作不易,请有心人到我的公众号上点点赞支持一下,增加一下热度,也好让更多的人能看到,公众号里有完整的文章列表可供阅读。

有以下三种情况,一个类对象的初始化是以同一类型的另一个对象为初值。

第一种情况,定义一个类对象时以另一个对象作为初始值,如下:

class Foo {};
Foo a;
Foo b = a;

第二种情况,当调用一个函数时,这个函数的参数要求传入一个类对象:

class Foo {};
void Bar(Foo obj) {}
Foo a;
Bar(a);

第三种情况,是函数里返回一个类的对象:

class Foo {};
Foo Bar() {
    Foo x;
	// ...
    return x;
}

这几种情况都是用一个类对象做为另一个对象的初值,假如这个类中有定义了拷贝构造函数,那么这时就会调用这个类的拷贝构造函数。但是如果类中没有定义拷贝构造函数,那么又会是怎样?很多人可能会认为编译器会生成一个拷贝构造函数来拷贝其中的内容,那么事实是否如此呢?

C++标准里描述到,如果一个类没有定义拷贝构造函数,那么编译器就会隐式地声明一个拷贝构造函数,它会判断这个拷贝构造函数是nontrivial(有用的、不平凡的)还是trivial(无用的、平凡的),只有nontrivial的才会显式地生成出来。那么怎么判断是trivial还是nontrivial的呢?编译器是根据这个类是否展现出有逐位/逐成员拷贝的语意,那什么是有逐位/逐成员拷贝的语意?来看看下面的例子。

有逐位拷贝语意的情形

#include <string.h>
#include <stdio.h>

class String {
public:
    String(const char* s) {
        if (s) {
            len = strlen(s);
            str = new char[len + 1];
            memcpy(str, s, len);
            str[len] = '\0';
        }
    }
    void print() {
        printf("str=%s, len=%d\n", str, len);
    }
private:
    char* str;
    int len;
};

int main() {
    String a("hello");
    a.print();
    String b = a;
    b.print();
    
    return 0;
}

在上面代码中,是否需要为String类生成一个显式的拷贝构造函数,以便在第25行代码构造对象b时调用它?我们可以来看看上面代码生成的汇编代码,节选main函数部分:

main:								# @main
    push    rbp
    mov     rbp, rsp
    sub     rsp, 48
    mov     dword ptr [rbp - 4], 0
    lea     rdi, [rbp - 24]
    lea     rsi, [rip + .L.str]
    call    String::String(char const*) [base object constructor]
    lea     rdi, [rbp - 24]
    call    String::print()
    mov     rax, qword ptr [rbp - 24]
    mov     qword ptr [rbp - 40], rax
    mov     rax, qword ptr [rbp - 16]
    mov     qword ptr [rbp - 32], rax
    lea     rdi, [rbp - 40]
    call    String::print()
    xor     eax, eax
    add     rsp, 48
    pop     rbp
    ret

从汇编代码中看到,除了在第8行调用了带一个参数的构造函数String(const char* s)之外,并没有调用其他的拷贝构造函数,当然全部的汇编代码中也没有见到生成的拷贝构造函数。说明这种简单的情形只需要进行逐位拷贝类对象的内容即可,不需要生成一个拷贝构造函数来做这个事情。看看程序运行输出结果:

str=hello, len=5
str=hello, len=5

这两行输出内容是上面代码第24行和第26行调用的输出,说明这时对象a和对象b的内容是一模一样的,也就是说对象b的内容完全拷贝了对象a的内容。简单解释一下上面的汇编代码,第4行是在main函数里开辟了48字节的栈空间,用于存放局部变量a和b,[rbp - 24]是对象a的起始地址,[rbp - 40]是对象b的起始地址。第11、12行就是将对象a的第一个成员先拷贝到rax寄存器,然后再拷贝给对象b的第一个成员。第13、14行就是将对象a的第2个成员(对象a的地址偏移8字节)拷贝到rax,然后再拷贝给对象b的第2个成员(对象b的地址偏移8字节)。

编译器认为这种情形只需要逐成员的拷贝对应的内容即可,不需要生成一个拷贝构造函数来完成,而且生成一个拷贝构造函数然后调用它,效率要比直接拷贝内容更低下,这种在不会产生副作用的情况下,不生成拷贝构造函数是一种更高效的做法。

上面的结果从编译器的角度来看是没有问题的,而且是合理的,它认为只需要逐成员拷贝就足够了。但是从程序的角度来看,它是有问题的。你能否看出问题出在哪里?首先我们在构造函数中申请了内存,所以需要一个析构函数来在对象销毁的时候来释放申请的内存,我们加上析构函数:

~String() {
    printf("destructor\n");
    delete[] str;
}

加上析构函数之后再运行,发现程序崩溃了。原因在于内存被双重释放了,对象a中的str指针赋值给对象b的str,这时对象a和对象b的str成员都指向同一块内存,在main函数结束后对象a和对象b先后销毁而调用了析构函数,析构函数里释放了这一块内存,所以导致了重复释放内存引起程序崩溃。这就是浅拷贝与深拷贝的问题,编译器只会做它认为正确的事情,而逻辑上是否正确是程序员应该考虑的事情,所以从逻辑上来看是否需要明确写出拷贝构造函数是程序员的责任,但是如果你认为没有必要明确定义一个拷贝构造函数,比如说不需要申请和释放内存,或者其它需要获取和释放资源的情况,只是简单地对成员进行赋值的话,那就没有必要写出一个拷贝构造函数,编译器会在背后为你做这些事情,效率还更高一些。

为了程序的正确性,我们显式地为String类定义了一个拷贝构造函数,加上之后程序运行就正常了:

// 下面代码暂时忽略了对象中str原本已经申请过内存的情况。
String(const String& rhs) {
    printf("copy constructor\n");
    if (rhs.str && rhs.len != 0) {
        len = rhs.len;
        str = new char[len + 1];
        memcpy(str, rhs.str, len);
        str[len] = '\0';
    }
}

运行输出如下,说明自定义的拷贝构造函数被调用了:

str=hello, len=5
copy constructor
str=hello, len=5
destructor
destructor

上面举例了具有逐位拷贝语意的情形,那么有哪些情形是不具有逐位拷贝语意的呢?那就是在编译器需要插入代码去做一些事情的时候以及扩展了类的内容的时候,如以下的这些情况:

  1. 类中含有类类型的成员,并且它定义了拷贝构造函数;
  2. 继承的父类中定义了拷贝构造函数;
  3. 类中定义了一个以上的虚函数或者从父类中继承了虚函数;
  4. 继承链上有一个父类是virtual base class。

下面我们按照这几种情况来一一探究。

需要调用类类型成员或者父类的拷贝构造函数的情形

如果一个类里面含有一个或以上的类类型的成员,并且这个成员的类定义中有一个拷贝构造函数;或者一个类继承了父类,父类定义了拷贝构造函数,那么如果这个类没有定义拷贝构造函数的话,编译器就会为它生成一个拷贝构造函数,用来调用类对象成员或者父类的拷贝构造函数,由于这两种情况差不多,所以放在一起分析。

如在上面的代码中,新增一个Object类,类里含有String类型的成员,见下面的代码:

// String类的定义同上

class Object {
public:
    Object(): s("default"), num(10) {}
    void print() {
        s.print();
        printf("num=%d\n", num);
    }
private:
    String s;
    int num;
};

// main函数改成如下
int main() {
    Object a;
    a.print();
    Object b = a;
    b.print();
    
    return 0;
}

运行结果如下:

str=default, len=7
num=10
copy constructor
str=default, len=7
num=10
destructor
destructor

从结果中可以看出最重要的两点:

  1. String类的拷贝构造函数被调用了;
  2. 对象b的成员num被赋予正确的值。

我们来进一步,首先看一下生成的汇编代码:

main:																		# @main
    push    rbp
    mov     rbp, rsp
    sub     rsp, 80
    mov     dword ptr [rbp - 4], 0
    lea     rdi, [rbp - 32]
    mov     qword ptr [rbp - 80], rdi		# 8-byte Spill
    call    Object::Object() [base object constructor]
    mov     rdi, qword ptr [rbp - 80]		# 8-byte Reload
    call    Object::print()
    jmp     .LBB0_1
.LBB0_1:
    lea     rdi, [rbp - 72]
    lea     rsi, [rbp - 32]
    call    Object::Object(Object const&) [base object constructor]
    jmp     .LBB0_2
.LBB0_2:
    lea     rdi, [rbp - 72]
    call    Object::print()
    jmp     .LBB0_3
.LBB0_3:
 # 以下代码省略

上面是节选main函数的部分汇编代码,执行析构函数部分省略掉。第10行对应的是main函数里的第18行a.print();,编译器会把它转换成print(&a),参数就是对象a的地址,也就是[rbp - 80],把它放到rdi寄存器中作为参数,从上面的代码中知道[rbp - 80]其实等于[rbp - 32],[rbp - 32]就是对象a的地址。第15行代码对应的就是Object b = a;这一行的代码,可见它调用了Object::Object(Object const&)这个拷贝构造函数,但C++的代码中我们并没有显式地定义这个函数,这个函数是由编译器自动生成出来的。它有两个参数,第一个参数是对象b的地址,即[rbp - 72],存放在rdi寄存器,第二个参数是对象a的地址,即[rbp - 32],存放在rsi寄存器。编译器会把上面的调用转换成Object::Object(&b, &a);。

接下来看看编译器生成的拷贝构造函数的汇编代码:

Object::Object(Object const&) [base object constructor]:	# @Object::Object(Object const&) [base object constructor]
    push    rbp
    mov     rbp, rsp
    sub     rsp, 32
    mov     qword ptr [rbp - 8], rdi
    mov     qword ptr [rbp - 16], rsi
    mov     rdi, qword ptr [rbp - 8]
    mov     qword ptr [rbp - 24], rdi       # 8-byte Spill
    mov     rsi, qword ptr [rbp - 16]
    call    String::String(String const&) [base object constructor]
    mov     rax, qword ptr [rbp - 24]       # 8-byte Reload
    mov     rcx, qword ptr [rbp - 16]
    mov     ecx, dword ptr [rcx + 16]
    mov     dword ptr [rax + 16], ecx
    add     rsp, 32
    pop     rbp
    ret

第5、6行是把对象b的地址(rdi寄存器)存放到[rbp - 8]中,把对象a的地址(rsi寄存器)存放到[rbp - 16]中。第10行代码就是去调用String类的拷贝构造函数了。第11到14行代码是用对象a中的num成员的值给对象b的num成员赋值,[rbp - 16]是对象a的起始地址,存放到rcx寄存器中,然后再加16字节的偏移量就是num成员的地址,加16字节的偏移量是为了跳过前面的String类型的成员s,它的大小为16字节。rax寄存器存放的是对象b的起始地址,[rax + 16]就是对象b中的num成员的地址。

从这里可以得出一个结论:编译器生成的拷贝构造函数除了会去调用类类型成员的拷贝构造函数之外,还会拷贝其它的数据成员,包括整形数据、指针和数组等等,它和生成的默认构造函数不一样,生成的默认构造函数不会去初始化这些数据成员。

如果类类型成员里没有定义拷贝构造函数,比如把String类中的拷贝构造函数注释掉,这时编译器就不会生成一个拷贝构造函数,因为不需要,这时它会实行逐成员拷贝的方式,若遇到成员是类类型的,则递归地执行逐成员拷贝的操作。

含有虚函数的情形

从前面的文章中我们知道,当一个类定义了一个或以上的虚函数时,或者继承链上的父类中有定义了虚函数的话,那么编译器就会为他们生成虚函数表,并会扩充类对象的内存布局,在类对象的起始位置插入虚函数表指针,以指向虚函数表。这个虚函数表指针很重要,如果没有设置好正确的值,那么将引起调用虚函数的混乱甚至引起程序的崩溃。编译器往类对象插入虚函数表指针将导致这个类不再具有逐成员拷贝的语意,当程序中没有显式定义拷贝构造函数时,编译器需要为它自动生成一个拷贝构造函数,以便在适当的时机设置好这个虚函数表指针的值。我们以下面的例子来分析一下:

#include <stdio.h>

class Base {
public:
    virtual void virtual_func() {
        printf("virtual function in Base class\n");
    }
private:
    int b;
};

class Object: public Base {
public:
    virtual void virtual_func() {
         printf("virtual function in Object class\n");
    }
private:
    int num;
};

void Foo(Base& obj) {
    obj.virtual_func();
}

int main() {
    Object a;
    Object a1 = a;
    Base b = a;
    Foo(a);
    Foo(b);
    
    return 0;
}

看下生成的汇编代码,节选main函数部分:

main:									# @main
    push    rbp
    mov     rbp, rsp
    sub     rsp, 64
    mov     dword ptr [rbp - 4], 0
    lea     rdi, [rbp - 24]
    call    Object::Object() [base object constructor]
    lea     rdi, [rbp - 40]
    lea     rsi, [rbp - 24]
    call    Object::Object(Object const&) [base object constructor]
    lea     rdi, [rbp - 56]
    lea     rsi, [rbp - 24]
    call    Base::Base(Base const&) [base object constructor]
    lea     rdi, [rbp - 24]
    call    Foo(Base&)
    lea     rdi, [rbp - 56]
    call    Foo(Base&)
    xor     eax, eax
    add     rsp, 64
    pop     rbp
    ret

上面汇编代码中的第10行对应C++代码中的第27行,这里调用的是Object类的拷贝构造函数,汇编代码中的第13行对应C++代码中的第28行,这里调用的是Base类的拷贝构造函数,这说明了编译器为Object类和Base类都生成了拷贝构造函数。继续分析这两个类的拷贝构造函数的汇编代码:

Object::Object(Object const&) [base object constructor]:	# @Object::Object(Object const&) [base object constructor]
    push    rbp
    mov     rbp, rsp
    sub     rsp, 32
    mov     qword ptr [rbp - 8], rdi
    mov     qword ptr [rbp - 16], rsi
    mov     rdi, qword ptr [rbp - 8]
    mov     qword ptr [rbp - 24], rdi       # 8-byte Spill
    mov     rsi, qword ptr [rbp - 16]
    call    Base::Base(Base const&) [base object constructor]
    mov     rax, qword ptr [rbp - 24]       # 8-byte Reload
    lea     rcx, [rip + vtable for Object]
    add     rcx, 16
    mov     qword ptr [rax], rcx
    mov     rcx, qword ptr [rbp - 16]
    mov     ecx, dword ptr [rcx + 12]
    mov     dword ptr [rax + 12], ecx
    add     rsp, 32
    pop     rbp
    ret
Base::Base(Base const&) [base object constructor]:	# @Base::Base(Base const&) [base object constructor]
    push    rbp
    mov     rbp, rsp
    mov     qword ptr [rbp - 8], rdi
    mov     qword ptr [rbp - 16], rsi
    mov     rax, qword ptr [rbp - 8]
    lea     rcx, [rip + vtable for Base]
    add     rcx, 16
    mov     qword ptr [rax], rcx
    mov     rcx, qword ptr [rbp - 16]
    mov     ecx, dword ptr [rcx + 8]
    mov     dword ptr [rax + 8], ecx
    pop     rbp
    ret

在Object类的拷贝构造函数里,上面汇编代码的第10行,调用了Base类的拷贝构造函数,这里的意思是先构造Base子类部分,在Base类的拷贝构造函数里,上面汇编代码的第27行到29行,在这里设置了Base类的虚函数表指针,因为这里构造的是Base子类的对象,所以这里设置的是Base类的虚函数表指针。然后返回到Object类的拷贝构造函数,在上面汇编代码的第12行到第14行,这里又重新设置回Object类的虚函数表指针,因为构造完Base子类之后继续构造Object类,需要重设回Object类的虚函数表指针,Base类和Object类的虚函数表是不同的两个表,所以需要为它们对应的对象设置对应的虚函数表指针。

其实同一类型的对象的赋值是可以采用逐成员拷贝的方式来完成的,比如像Object a1 = a;这行代码,因为它们的虚函数表是同一个,直接拷贝对象a的虚函数表指针给a1对象没有任何问题。但是问题出在于使用派生类的对象给父类的对象赋值时,这里会发生切割,把派生类对象中的父类子对象部分拷贝给父类对象,如果没有编译器扩充的部分(这里是虚函数表指针),只是拷贝数据部分是没有问题的,但是如果把派生类的虚函数表指针赋值给父类子对象,这将导致虚函数调用的混乱,本该调用父类的虚函数的,却调用了派生类的虚函数。所以编译器需要重设这个虚函数表指针的值,也就是说这里不能采用逐成员拷贝的手法了,当程序中没有显式地定义拷贝构造函数时编译器就会生成一个,或者在已有的拷贝构造函数中插入代码,来完成重设虚函数表指针这个工作。

再看下C++代码中的这三行代码:

Base b = a;
Foo(a);
Foo(b);

第一行的赋值语句,虽然是使用派生类Object的对象a作为初值,但是调用的却是Base类的拷贝构造函数(见main函数的汇编代码第13行),因为b的类型是Base类。这就保证了只使用了对象a中的Base子对象部分的内容,以及确保设置的虚函数表指针是指向Base类的虚函数表,这样在调用Foo函数时,分别使用对象a和b作为参数,尽管Foo函数的形参使用的是“Base&”,是使用基类的引用类型,但却不会引起调用上的混乱。第二个调用使用b作为参数,它是Base类的对象,调用的是Base类的虚函数,这两行的输出结果是:

virtual function in Object class
virtual function in Base class

继承链上有virtual base class的情形

当一个类的继承链上有一个virtual base class时,virtual base class子对象的布局会重排,内存布局的分析可以参考另一篇文章《C++对象封装后的内存布局》。为使得能支持虚继承的机制,编译器运行时需要知道虚基类的成员位置,所以编译器会在编译时生成一个虚表,这个表格里会记录成员的相对位置,在构造对象时会插入一个指针指向这个表。这使得类失去了逐成员拷贝的语意,如果一个类对象的初始化是以另一个相同类型的对象为初值,那么逐成员拷贝是没有问题的,问题在于如果是以派生类的对象赋值给基类的对象,这时候会发生切割,编译器需要计算好成员的相对位置,以避免访问出现错误,所以编译器需要生成拷贝构造函数来做这样的事情。以下面的代码为例:

#include <stdio.h>

class Grand {
public:
    int g = 1;
};

class Base1: virtual public Grand {
    int b1 = 2;
};

class Base2: virtual public Grand {
    int b2 = 3;
};

class Derived: public Base1, public Base2 {
    int d = 4;
};

int main() {
    Derived d;
    Base2* pb2 = &d;
    d.g = 11;
    pb2->g = 10;
    Base2 b2 = *pb2;
    
    return 0;
}

第25行的代码是将派生类Derived类的对象赋值给Base2父类对象,这将会发生切割,将Derived类中的Base2子对象部分拷贝过去,看下对应的汇编代码:

# 节选部分main函数汇编
mov     rsi, qword ptr [rbp - 56]
lea     rdi, [rbp - 72]
call    Base2::Base2(Base2 const&) [complete object constructor]

[rbp - 56]存放的是C++代码里的pb2的值,也就是对象d的地址,存放在rsi寄存器中,[rbp - 72]是对象b2的地址,存放到rdi寄存器中,然后将rsi和rdi寄存器作为参数传递给Base2的拷贝构造函数,然后调用它。继续看下Base2的拷贝构造函数的汇编代码:

Base2::Base2(Base2 const&) [complete object constructor]:	# @Base2::Base2(Base2 const&) [complete object constructor]
    push    rbp
    mov     rbp, rsp
    mov     qword ptr [rbp - 8], rdi
    mov     qword ptr [rbp - 16], rsi
    mov     rax, qword ptr [rbp - 8]
    mov     rcx, qword ptr [rbp - 16]
    mov     rdx, qword ptr [rcx]
    mov     rdx, qword ptr [rdx - 24]
    mov     ecx, dword ptr [rcx + rdx]
    mov     dword ptr [rax + 12], ecx
    lea     rcx, [rip + vtable for Base2]
    add     rcx, 24
    mov     qword ptr [rax], rcx
    mov     rcx, qword ptr [rbp - 16]
    mov     ecx, dword ptr [rcx + 8]
    mov     dword ptr [rax + 8], ecx
    pop     rbp
    ret

首先将两个参数(分别存放在rdi和rsi寄存器)拷贝到栈空间[rbp - 8]和[rbp - 16]中,第8到11行代码就是将对象d中的Grand子对象的成员拷贝到b2对象中,对象的前8个字节在构造对象的时候已经设置好了虚表的指针,这里将指针指向的内容存放到rdx寄存器中,第9行取得虚基类成员的偏移地址然后存放在rdx寄存器,第10行将对象的首地址加上偏移地址,取得虚基类的成员然后拷贝到ecx寄存器,在第11行代码里拷贝给[rax + 12],即b2对象的起始地址加上12字节的偏移量(8字节的虚表指针加上成员变量b2占4字节),即完成对Grand类中的成员变量g的拷贝。

所以对于有虚基类的情况,将一个派生类的对象赋值给基类对象时,不能采取逐成员拷贝的手法,需要借助虚表来计算出虚基类的成员的相对位置,以获得正确的成员地址,需要生成拷贝构造函数来完成。

抑制合成拷贝构造函数的情况

C++11标准之后新增了delete关键字,它可以指定不允许编译器生成哪些函数,比如我们不允许拷贝一个类对象,那么可以将此类的拷贝构造函数声明为=delete的。例如标准库中的iostream类,它不允许拷贝,防止两个对象同时指向同一块缓存。如果一个类的定义中有一个类类型成员,而此成员的拷贝构造函数声明为=delete的,或者类的父类中声明了拷贝构造函数为=delete的,那么这个类的拷贝构造函数也会被编译器声明为delete的,这个类的对象将不允许被拷贝,如以下的代码:

class Base {
public:
    Base() = default;
    Base(const Base& rhs) = delete;
};

class Object {
    Base b;
};

int main() {
    Object d;
    Object d1 = d;	// 此行编译错误
    
    return 0;
}

上面代码的第13行会引起编译错误,原因就是Object类没有拷贝构造函数,不允许赋值的操作,同样地,拷贝赋值运算符也将被声明为delete的。

总结

  • 拷贝赋值运算符的情况和拷贝构造函数的情况类似,可以采用上述的方法来分析。
  • 当不需要涉及到资源的分配和释放时,不需要显示地定义拷贝构造函数,编译器会为我们做好逐成员拷贝的工作,效率比去调用一个拷贝构造函数要更高效一些。
  • 当你需要为程序定义一个析构函数时,那么肯定也需要定义拷贝构造函数和拷贝赋值运算符,因为当你需要在析构函数中去释放资源的时候,说明在拷贝对象的时候需要为新对象申请新的资源,以避免两个对象同时指向同一块资源。
  • 当你需要为程序定义拷贝构造函数时,那么也同时需要定义拷贝赋值运算符,反之亦然,但是却并不一定需要定义析构函数,比如在构造对象时为此对象生成一个UUID,这时在析构对象时并不需要释放资源。

此篇文章同步发布于我的微信公众号:深度解读《深度探索C++对象模型》之拷贝构造函数

如果您感兴趣这方面的内容,请在微信上搜索公众号iShare爱分享或者微信号iTechShare并关注,以便在内容更新时直接向您推送。image

posted @ 2024-04-17 12:48  iShare_爱分享  阅读(346)  评论(1编辑  收藏  举报