目录
接下来我将持续更新“深度解读《深度探索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
上面举例了具有逐位拷贝语意的情形,那么有哪些情形是不具有逐位拷贝语意的呢?那就是在编译器需要插入代码去做一些事情的时候以及扩展了类的内容的时候,如以下的这些情况:
- 类中含有类类型的成员,并且它定义了拷贝构造函数;
- 继承的父类中定义了拷贝构造函数;
- 类中定义了一个以上的虚函数或者从父类中继承了虚函数;
- 继承链上有一个父类是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
从结果中可以看出最重要的两点:
- String类的拷贝构造函数被调用了;
- 对象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类中的拷贝构造函数注释掉,这时编译器就不会生成一个拷贝构造函数,因为不需要,这时它会实行逐成员拷贝的方式,若遇到成员是类类型的,则递归地执行逐成员拷贝的操作。
(未完待续。。。敬请点击左下角的关注以获得及时更新)
本主页会定期更新,为了能够及时获得更新,敬请关注我:点击左下角的关注。也可以关注公众号:请在微信上搜索公众号“AI与编程之窗”并关注,或者扫描以下公众号二维码关注,以便在内容更新时直接向您推送。