目录
含有虚函数的情形
继承链上有virtual base class的情形
抑制合成拷贝构造函数的情况
总结
接下来我将持续更新“深度解读《深度探索C++对象模型》”系列,敬请期待,欢迎左下角点击关注!也可以关注公众号:iShare爱分享,或文章末尾扫描二维码,自动获得推文和全部的文章列表。
上一篇请从这里阅读:深度解读《深度探索C++对象模型》之拷贝构造函数(一)
含有虚函数的情形
从前面的文章中我们知道,当一个类定义了一个或以上的虚函数时,或者继承链上的父类中有定义了虚函数的话,那么编译器就会为他们生成虚函数表,并会扩充类对象的内存布局,在类对象的起始位置插入虚函数表指针,以指向虚函数表。这个虚函数表指针很重要,如果没有设置好正确的值,那么将引起调用虚函数的混乱甚至引起程序的崩溃。编译器往类对象插入虚函数表指针将导致这个类不再具有逐成员拷贝的语意,当程序中没有显式定义拷贝构造函数时,编译器需要为它自动生成一个拷贝构造函数,以便在适当的时机设置好这个虚函数表指针的值。我们以下面的例子来分析一下:
#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++对象模型》之C++对象的内存布局(一)”、“深度解读《深度探索C++对象模型》之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,这时在析构对象时并不需要释放资源。
本主页会定期更新,为了能够及时获得更新,敬请关注我:点击左下角的关注。也可以关注公众号:请在微信上搜索公众号“iShare爱分享”并关注,或者扫描以下公众号二维码关注,以便在内容更新时直接向您推送。