2021-09-06
前言
最近读了 Lippman 大神的《深度探索C++对象模型》(Inside the C++ Object Model),讲的比较深,很容易忘,写点笔记助记一下。这本书给我的感觉,更像是很多技术博文拧合到一起的产物,不太像书,有些地方思维很跳跃,没有 C++ Primer 那种学院派一板一眼顺理成章的感觉。而且个人感觉中文版排版和翻译效果很一般,所以有些内容最好还是和英文版对照着看会好点。《深度探索C++对象模型》不是一本只需要看一遍的书,本文是第一轮阅读的笔记。
NOTE:鉴于本人在 C++ 语言上还只是处于入门级菜鸟级别,在代码量上没有很多的沉淀,所以文章中如果有任何错漏之处,欢迎各位看官指正。
第一章:关于对象
这章主要介绍了 C++ 对象大致的内存布局。
1.1 基础
首先是不考虑继承的最简单的类对象布局,用以下简单代码调试:
/// 类定义
class Object {
public: //section1
uint64_t longNo2;
public: //section2
uint64_t longNo1;
uint8_t no1;
uint8_t no2;
uint8_t no3;
public: //section3
uint8_t no4;
};
/// 调试代码
void main() {
Object obj = Object();
size_t sizeObj = sizeof(Object);
}
NOTE:我的开发调试环境:操作系统 macOS Big Sur;集成开发环境 Xcode 12.5;编译器 clang 12.0.5;模拟器:iPhone 11(iOS 14.5)。
运行结果sizeObj为 24。简单修改上面的代码可以观察到一些现象:
- 把
section2移到section1上方(调整 access sections 的顺序),输出sizeObj为 32; - 把
no2移到longNo1上方(调整成员变量的顺序),输出sizeObj为 32;
加之断点调试,使用类似po (int)(&(obj.no2) - (uint8_t *)(&(obj.longNo1)))或者po (int)(&(obj.no2) - (uint8_t *)(&obj))的 LLDB 命令观察成员变量的内存地址的相对偏移量。发现 C++ 对象的成员变量布局和 C 语言的 struct 布局非常相似,同样需要考虑内存字节对齐问题,同样是按照成员声明的顺序进行布局(第三章会有更详细的介绍)。
为进一步确认内存布局,使用 LLDB 的x命令(参考文档 Examining Memory)打印obj的内存空间布局情况,为方便区分每块内存区域,给所有对象都附上常数值,调试代码如下:
/// 类定义(修改后)
class Object {
public:
uint64_t longNo2 = 1;
public:
uint8_t no2 = 12;
uint64_t longNo1 = 2;
uint8_t no1 = 11;
uint8_t no3 = 13;
public:
uint8_t no4 = 14;
};
使用x/32b &obj打印obj对象的内存,其中b表示以字节划分,32 表示打印 32 个字节的内存空间,打印结果如下。首先内存空间是0x7ff打头,基本可以断定是栈空间(从高位向地位分配)。其次内存空间中有很多值为0x00的 Padding 字节空间,为了满足内存对齐的需要。注意到前 8 个字节中,低位保存低位,所以当前调试平台是使用小端模式(little-endian)。
0x7ffee5b02060: 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffee5b02068: 0x0c 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffee5b02070: 0x02 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffee5b02078: 0x0b 0x0d 0x0e 0x00 0x00 0x00 0x00 0x00
顺手x/8b &sizeObj打印sizeObj的内存空间。上面说过sizeObj的值是 32(十六进制0x20),正好相符。另外,注意到sizeObj的内存地址是0x7ffee5b02058,比obj的内存地址小了 8 个字节,8 个字节恰好就是sizeObj的大小(sizeof(size_t))。
0x7ffee5b02058: 0x20 0x00 0x00 0x00 0x00 0x00 0x00 0x00
从上面的调试过程可见,上面构建的Object对象的内存空间在栈空间中。稍微修改一下调试代码,以new方式构建Object对象。编译运行,po objPtr打印objPtr指针(表示objPtr所指向的内存地址),输出结果为0x0000600003ead360,此时构建的Object对象存在于内存堆空间中。
/// 调试代码(修改后)
void main() {
Object obj = Object();
size_t sizeObj = sizeof(Object);
Object *objPtr = new Object;
size_t sizeObjPtr = sizeof(objPtr);
}
使用x/32b objPtr查看内存,从数据不难发现它就是个Object对象,不同之处在于,这里构建的Object的为了内存对齐而冗余出来的 Padding 空间的值是不确定的,未必是0x00,不过这并不影响,因为“有效字节”上的数据都是正确的。
0x600003ead360: 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x600003ead368: 0x0c 0x00 0x60 0x00 0x00 0x00 0x00 0x00
0x600003ead370: 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x600003ead378: 0x0b 0x0d 0x0e 0x63 0x65 0x00 0x00 0x00
NOTE:如果用带圆括号的
Object()构建对象,例如:Object *obj = new Object()则 Padding 会是全0x00,同理如果在前面的调试代码中,使用Object obj;隐式初始化,则 Padding 基本不会是全0x00。
1.2 继承
上面只是最基础的情况,再考虑继承的情况:
/// 基类定义
class Object {
public:
uint64_t longNo2 = 1;
public:
uint8_t no2 = 12;
uint64_t longNo1 = 2;
uint8_t no1 = 11;
uint8_t no3 = 13;
public:
uint8_t no4 = 14;
};
/// 子类定义
class SubClassObject : Object {
public:
uint64_t subLongNo1 = 3;
uint8_t subNo1 = 15;
};
/// 调试代码
void main() {
Object obj;
size_t sizeObj = sizeof(Object);
SubClassObject subobj;
size_t sizeSubobj = sizeof(SubClassObject);
}
打印sizeSubobj为 48,x/48b &subobj打印内存空间,不难发现,子类成员变量的数据是直接拼接在基类的成员变量内存空间后面。
0x7ffeebedb018: 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffeebedb020: 0x0c 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffeebedb028: 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffeebedb030: 0x0b 0x0d 0x0e 0x00 0x00 0x00 0x00 0x00
0x7ffeebedb038: 0x0f 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffeebedb040: 0x03 0x00 0x00 0x00 0x00 0x00 0x00 0x00
在子类定义代码中,交换subNo1和subLongNo1的顺序会导致sizeSubobj变化么?答案是会变化,交换后sizeSubobj为 40。也就是说,子类的实例的成员变量的内存空间,可以在基类成员变量内存空间尾部的 Padding 中起始。x/40b &subobj打印内存空间如下:
0x7ffeea098020: 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffeea098028: 0x0c 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffeea098030: 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffeea098038: 0x0b 0x0d 0x0e 0x03 0x00 0x00 0x00 0x00
0x7ffeea098040: 0x0f 0x00 0x00 0x00 0x00 0x00 0x00 0x00
上面的内容只调试了类的实例变量定义对对象内存布局的影响,那么对于类变量(静态成员变量)呢?类变量不会影响类的对象的内存布局,它保存在内存的静态区中(也是书中所说的 data segment 范畴)。
Virtual 函数之外的成员函数也不会影响类的对象的内存布局。这也比较好理解,函数的代码本身是保存在内存的代码区(属于 text segment 范畴)中,而函数的地址也无需保存在类的实例中。因为不需要,实例或类调用方法时,编译器来负责找到对应的函数指针(后面章节会详细介绍)。
NOTE:C++ 存在较大的编译器差异性,尤其是一些非标准 C++ 编译器,所以本文一些通过调试得出的结论只能保证针对 Clang 编译器成立。这可能也是为什么很多人认为 C++ 是一门“恶心”的语言的原因之一。
1.3 虚拟
类的继承链中是否存在虚拟函数会直接影响类的实例的大小。总体是这样的规律:若某个类型声明了 virtual 函数,则直接或间接继承了该类型的所有衍生类,都需要维护一张 virtual table,用于保存 virtual 函数指针,而此时声明了 virtual 函数的类的实例以及该类的所有衍生类的实例,都需要保证 8 个字节(64 位机)的内存空间用于保存 virtual table 的内存地址,这就是 vptr 指针。
用以下代码进行调试,其中:
sizeof(Object)为 32,因为继承链上没有 virtual 函数,所以无需保存 vptr 指针;sizeof(MidObject)为 40,因为自身声明了 virtual 函数,所以需要保存 vptr 指针;sizeof(SubClassObject1)和sizeof(SubClassObject2)均为 40,因为父类MidObject声明了 virtual 函数,所以需要保存 vptr 指针(不管有没实现 virtual 函数);
class Object {
public:
uint64_t longNo2 = 1;
public:
uint8_t no2 = 12;
uint64_t longNo1 = 1;
uint8_t no1 = 11;
uint8_t no3 = 13;
public:
uint8_t no4 = 14;
};
class MidObject : Object {
public:
virtual void vfun(void) {
// Abstract Method
}
};
class SubClassObject1 : MidObject {
public:
void vfun() {
printf("implement one");
}
};
class SubClassObject2 : MidObject {
};
/// 调试代码
void main() {
Object obj = Object();
MidObject midObj = MidObject();
SubClassObject1 subObj1 = SubClassObject1();
SubClassObject2 subObj2 = SubClassObject2();
}
那么,virtual table 大概保存了什么东西呢?同样可以用 LLDB 的x命令来简单探索一下。分别打印midObj、subObj1、subObj2的内存空间,差异在前 8 个字节,也就是说 Clang 编译器将 vptr 保存在实例的首 8 个字节。
(lldb) x/32b &midObj
0x7ffeedfab038: 0x40 0x50 0xc5 0x01 0x01 0x00 0x00 0x00
0x7ffeedfab040: 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffeedfab048: 0x0c 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffeedfab050: 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
(lldb) x/32b &subObj1
0x7ffeedfab010: 0x90 0x50 0xc5 0x01 0x01 0x00 0x00 0x00
0x7ffeedfab018: 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffeedfab020: 0x0c 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffeedfab028: 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
(lldb) x/32b &subObj2
0x7ffeedfaafe8: 0xd0 0x50 0xc5 0x01 0x01 0x00 0x00 0x00
0x7ffeedfaaff0: 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffeedfaaff8: 0x0c 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffeedfab000: 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
midObj、subObj1、subObj2的 vptr 也就是 virtual table 的内存地址分别是0x00000101c55040、0x00000101c55090、0x00000101c550d0。用x/g命令分别打印出出上述三个地址为起始的 8 个字节的内存中所保存的内容:
(lldb) x/g 0x00000101c55040
0x101c55040: 0x0000000101c52e10
(lldb) x/4g 0x00000101c55090
0x101c55090: 0x0000000101c52e60
(lldb) x/4g 0x00000101c550d0
0x101c550d0: 0x0000000101c52e10
比较明显的是三个内存地址从大小上看应该是 text segment 或 data segment 范畴内的内存地址。而且第一和第三竟然值相等,很大可能 virtual 函数地址,逐个试探果然就是。
(lldb) po (void(*)(void))0x0000000101c52e10
(CPPModelDemo`MidObject::vfun() at ViewController.mm:30)
(lldb) po (void(*)(void))0x0000000101c52e60
(CPPModelDemo`SubClassObject1::vfun() at ViewController.mm:37)
(lldb) po (void(*)(void))0x0000000101c52e10
(CPPModelDemo`MidObject::vfun() at ViewController.mm:30)
每组数据的内容基本上包含:
- 指向 virtual 函数的实现的函数指针;
libc++abi.dylib的代码vtable for __cxxabiv1::__vmi_class_type_info + 16地址;- 指向前类的类型信息名称的指针,用
po (char *)0x0000000101c54e18可以打出个字符串;
比较好理解的是,有实现虚函数vfunc的类对象的 virtual table,保存了指向自身实现的vfun的函数指针。而未实现vfun的类对象的 virtual table,则保存了继承链中某个类所实现的vfun的函数指针(这里是指向MidObject实现的vfun)。
比较幸运,歪打正着摸索出了一点关于 Clang 编译器的 virtual 机制实现。
既然虚拟函数有引入 virtual table 的特殊性,那么虚拟继承呢,是否更特殊了?确实更特殊了。以至于在看完后面的内容之前没办法介绍,探索都无从下手。另外,虚拟函数在多重继承、虚拟继承的场景中,编译器对涉及类型的每个 virtual 函数地址的决议过程也更为复杂,还是需要在后面的内容中才能一探究竟。
NOTE:Virtual table 的内存布局估计也存在编译器差异,上面调试出来的 Clang 编译器编译产物的 virtual table 的内存布局,其他编译器未必是一致的。
1.4 小结
本章简单调试了 C++ 模型的一些特性,发现其内存布局和 Objective-C 的 Runtime 实现还是有一定的相似之处,例如要求字节对齐和继承触发的对象尺寸扩展的行为特点。
不过对于看过 Objective-C Runtime 的开发者,必然会有这样的疑问。Objective-C 有isa表示对象的类型,C++ 不需要么?确实,C++ 不需要。C++ 实现面向对象,实际是以 C++ 编译器强干预为前提的。这也是为什么编写 C++ 代码过程中,会更容易遇到类型检查相关的编译 Error 提示,尤其是不按规则的强转,因为 C++ 在编译阶段就需要确立很多具体的类型信息,例如成员变量在对象内存空间中的偏移,类的成员函数的具体地址,这些类型信息基本不会推迟到运行时决议(也不需要),所以 C++ 原生是没有反射机制的。
以下调试代码为例:
void main() {
Object obj = Object();
MidObject midObj = MidObject();
SubClassObject1 subObj1 = SubClassObject1();
SubClassObject2 subObj2 = SubClassObject2();
// 调试代码一:非 virtual 机制
MidObject *midObjPtr = (MidObject *)&midObj;
Object *baseObjPtr = (Object *)&midObj;
// 调试代码二:virtual 机制
SubClassObject1 *subObjPtr1 = &subObj1;
MidObject *midObjPtr = (MidObject *)subObjPtr1;
midObjPtr->vfun(); //打印结果:implement it
}
如上面调试代码一所示,用 LLDB po sizeof(*baseObjPtr)和po sizeof(*midObjPtr)得到的结果分别是 32 和 40,即使baseObjPtr和midObjPtr指向相同的内存地址,但编译阶段实际上已经将其标记为两种类型,所以运行时baseObjPtr和midObjPtr所指向的对象类型就是编译时所声明的类型。这在 Objective-C 的开发者看来就相当地不可思议。
由此可见,抛开 virtual 机制的话,C++ 是一门非常“静态”的语言,在 virtual 加持下方法的调用会具有更多的动态性。譬如上面的调试代码二,方法调用使用 virtual 机制时,C++ 的类型才具有了一定的运行时动态性。
第二章:构造函数语义学
本章内容是挖掘编译器对对象构造过程的干涉,及其对代码形式以及执行效率的影响。
2.1 默认构造函数
如果某个 C++ 类未定义任何构造器(有参/无参),C++ 编译器会自动为该类合成默认构造函数。譬如以下这个ListNode类:
class ListNode {
public:
int val;
ListNode *next;
};
书中说上述情况下编译器合成的默认构造函数是 trivial(没有用的)的。但从实际调试过程发现,并不完全是,下面两句构造ListNode的代码中,node1的成员变量的值是随机的,不符合预期,这句代码使用的构造函数是 trivial 的。而node2的成员变量node2.val值为 0,node2.next值为 NULL,符合预期,这句代码使用的构造函数则是 nontrivial 的。所以对 Clang 编译器,以下两种构造方式是存在差异的。
//调试代码一
ListNode node1;
//调试代码二
ListNode node2 = ListNode();
但是 Clang 真的会闲的蛋疼构造两个默认构造函数么?估计不是。从编译器理解代码的角度推测,之所以会产生上述的差异,是因为ListNode node1会触发编译器生成一条语义为“在栈上分配sizeof(ListNode)内存”的代码,这句后续是没有初始化操作的,仅仅是分配内存而已。而ListNode node2 = ListNode()则类似生成了两句代码:1、“在栈上分配sizeof(ListNode)内存”;2、“调用编译器合成的ListNode()默认构造函数初始化这块内存”。
写段简单的代码调试一下,验证上面的结论。首先看方式一构建对象的实现原理:
class A {
int a;
};
void main() {
int i = 1;
A a;
int j = 2;
}
调试过程中截取main函数中三句代码对应的汇编语言是:
0x100e15e94 <+4>: movl $0x1, -0x4(%rbp)
0x100e15e9b <+11>: movl $0x2, -0xc(%rbp)
很奇怪三句 C 语言代码竟然只使用两条汇编代码就实现了。逐行解析:
- 在当前栈帧偏移
-0x4的位置写入int常数0x1(占据-0x4~-0x1之间 4 字节); - 在当前栈帧偏移
-0xc的位置写入int常数0x1(占据-0xc~-0x9之间 4 字节);
于是中间空出来的 4 个字节就自然留给了对象a,4 个字节,恰好等于sizeof(A)。Nice and simple。原来在栈上分配内存的操作竟是如此简单。接下来调试方式二构建对象的实现原理:
class A {
int a;
};
void main() {
int i = 1;
A a = A();
int j = 2;
}
同样调试过程中截取main函数中三句代码对应的汇编代码如下,明显长了很多。逐行解析:
- 对应
int i = 1; - 读取栈帧
-0x28偏移的内存地址(分配内存块的起始地址)写入rcx寄存器; - 将
rcx中保存的内存地址写入rdi寄存器; - 将常数
0x4写入edx寄存器; - 将
rcx中保存的内容写入栈帧-0x38偏移的内存地址,根据完整汇编代码的上下文,此时rcx寄存器实际保存的是值'\0'; - 调用
memset,其实上面几条指令已经为memset完成传参,则这句的具体含义是向栈帧-0x28偏移的内存地址为起始的 4 个字节的内存中填充'\0'; - 对应
int j = 2;
0x106648ebc <+60>: movl $0x1, -0x24(%rbp)
0x106648ec3 <+67>: leaq -0x28(%rbp), %rcx
0x106648ec7 <+71>: movq %rcx, %rdi
0x106648eca <+74>: movl $0x4, %edx
0x106648ecf <+79>: movq %rax, -0x38(%rbp)
0x106648ed3 <+83>: callq 0x106649426 ; symbol stub for: memset
0x106648ed8 <+88>: movl $0x2, -0x2c(%rbp)
总结 Clang 编译器的实际操作和前面的猜想是基本吻合的,只是比较失望的是没有看到通过callq指令调用编译器合成默认构造函数的汇编指令,其原因应该是这种场景下编译器合成默认构造函数是以内联的方式存在的,所以会直接转化为一组汇编指令直接插入到代码中。
NOTE:若
A需要考虑 virtual 机制,例如继承链上存在虚拟继承,或者自身/继承链声明了 virtual 函数,则会在对应的汇编代码中看到callq指令,因为设置vptr的值的逻辑具有一定复杂度,不适合内联。
再回头看书中总结的 C++ 编译器会生成 nontrivial 默认构造函数的四种场景:
- 成员变量的类显式定义了默认构造函数;
- 基类之一显式定义了默认构造函数;
- 定义了 virtual 函数;
- 虚拟继承一个基类;
逐个调试后发现,Clang 编译器的处理与书中所述并不完全一致,具有以下规则:
- 只声明而不显式调用默认构造函数时(类似上面
ListNode node1):继承链中有显式声明默认构造函数的话则调用,没有声明则不调用。其中对 class 类型的成员变量(不是指针,直接是某个 class 的实例),有显式声明默认构造函数的话则调用,没有声明则不调用(当类本身及成员变量并递归向下,均不调用默认构造函数,这种情况下合成的默认构造函数才能真正算是 trivial 的); - 声明且显式调用默认构造函数时(类似上面
ListNode node2 = ListNode()):继承链中有显式声明默认构造函数的话则调用,没有声明则调用编译器合成的默认构造函数,这种情况下合成的默认构造函数有包含memset清零内存的操作,所以应该算是 nontrivial 的。其中对 class 类型的成员变量,有显式声明默认构造函数的话则调用,没有声明则调用编译器合成的默认构造函数;
书中这块内容则可以一句话概括:只要编译器合成的默认构造函数做了某些有意义的事情(例如初始化了其中一部分数据块,包括给对象中的 vptr 指针赋值或者初始化了用于实现虚拟继承而设置的特定数据块),都可以被视为 nontrivial 的编译器合成的默认构造函数。
2.2 复制构造函数
如果某个 C++ 类未定义任何复制构造函数,C++ 编译器会自动为该类合成复制构造函数。C++ 通常采用两种方式复制对象:1、使用复制构造函数初始化(将参数复制到目标对象);2、使用赋值运算符重载(将右值对象复制到左值)。本章讨论的焦点在第一种。
另外,C++ 的复制构造函数还支持 bitwise copy 语义。意思就是,譬如以下的Word类型,编译器不会为其合成复制构造函数,因为有定义 bitwise 复制构造函数(Word( const char* )),所以编译器判定为“已经有复制构造函数了,无需编译器自动合成”。
class Word {
public:
Word( const char* );
~Word() { delete [] str; }
private:
int cnt;
char *str;
};
不使用 bitwise copy 语义的情况有以下四种:
- 类的成员之一的类声明了复制构造函数;
- 继承了存在复制构造函数的基类;
- 定义了一个或多个 virtual 函数(因为对象 vptr 设置错误会带来非常严重的后果);
- 继承链中存在一个或多个 virtual 基类(类似上面的原因);
但在 Clang 编译器下,是这样的么?貌似不是。例如下面的M明显不属于上面四种情况,并声明了一个 bitwise copy 语义的复制构造函数,但是下面的调试代码中,两句代码均可以通过编译,这意味着 Clang 编译器没有把M (const char *str)视为复制构造函数,所以自动合成了M (M m)复制构造函数。
class M {
private:
char a;
public:
M (const char *str) { a = str[0]; }
};
void main() {
M m = M("abc");
M mcpy(m);
}
NOTE:需要注意,上面的情况在调试代码中,如果添加
M m;,这句是会编译不通过的,因为已经定义了M (const char *str)构造函数,编译器无需自动合成。进一步地,如果把M (const char *str) { a = str[0]; }屏蔽掉,则M mcpy(m);和M m;都可以通过编译,即编译器既合成了默认构造函数,也合成了复制构造函数。
接下来是 C++ 编译器在需要考虑 virtual 机制的情况下,是如何处理对象复制的。用以下代码进行调试。结论是:Clang 编译器下,当继承链中存在虚拟继承时,类的实例大小会需要额外 8 个字节(不考虑必要的 Padding)用于保存虚拟继承相关信息,且查看调试数据发现虚拟继承相关信息同样是保存在 virtual table 中。注意到第二句调试代码A a(c),这里调用了复制构造函数使用c实例构建a实例,此时就涉及如何将c中的数据拷贝到a的问题。这种情况下,编译器会排除掉c实例中的vptr指针,只将c中对应A类型相关的成员变量数据拷贝到a实例。
class A {
public:
int a;
};
class C : public virtual A {
public:
int c;
};
void main() {
C c = C();
A a(c);
}
进一步地,如果在A中声明virtual void vfun() { },则对象a和对象c均包含vptr。这种情况下,编译器会将“与A类型绑定的 virtual table 的地址”写入到a实例,并将c的成员变量数据拷贝到a实例。
NOTE:上面的现象和 C++ 多态相矛盾了吗?不矛盾,因为这里是对象类型的转换,体现的是 C++ 是静态类型语言的特性。如果声明
A *aptr = &c,此时aptr指针就具有多态性,因为它既可以指向A类型对象,也可以指向A的衍生类型的对象。
2.3 程序转化语义学
本章介绍的是 C++ 编译器的一些隐式转换处理。
2.3.1 传参
首先看函数参数传递过程中的处理细节,调试代码如下,沿用上面的A和C的定义。分别在func1、func2、main函数中下断点,用 LLDB 的po &c查看三个函数中c变量内存地址。分别为0x00007ffee4186060、0x00007ffee4186070、0x00007ffee4186070。显然func2的c实参和main的c变量具有相同的内存地址,这意味着,main在调用func1前编译器拷贝了c对象的临时副本并传递到func1,而func2直接按引用传递参数则不会有这个拷贝操作。所以func1的效率是明显低于func2的。
class A {
public: int a;
};
class C : public virtual A {
public: int c;
};
void func1(C c) {
printf("implement it\n");
}
void func2(const C &c) {
printf("implement it\n");
}
void main() {
C c = C();
func1(c);
func2(c);
}
2.3.2 返回值
然后再看编译器对返回值的处理,相同的套路。用 LLDB 的po &c查看func、main函数中c变量内存地址,均为0x00007ffee9d72070,也就是说即使函数返回的是对象,但这个过程并没有引入冗余的对象复制过程。
class C {
public:
uint64_t a = 15;
virtual void vfunc() { };
};
C func() {
C c;
return c;
}
void main() {
C c = func();
}
为什么可以产生上述的效果呢,因为编译器对上面的代码做了进一步处理。原文对该处理过程如下:
- 将
func改造为void __func(C &c)形式; - 调用
__func()按引用传入一个C对象; __func()内部构建一个临时的C对象,并通过复制构造函数将临时对象的数据复制到按引用传入的参数C对象;
上述的编译器处理貌似还是包括临时对象的生成的过程,其实还是存在冗余操作的。然后调试一下看 Clang 是否也是相同的处理方式。查看main函数中触发func函数调用的核心代码。第一句是是将读取栈帧-0x10偏移的内存地址写入rdi寄存器;第二句是调用func函数。
0x10ac7fe78 <+8>: leaq -0x10(%rbp), %rdi
0x10ac7fe7c <+12>: callq 0x10ac7fe20 ; func at ViewController.mm:24
继续查看func的核心代码。在main中写入rdi寄存器的内存地址在这里起到至关重要的作用。忽略前 4 条指令,在callq前后打印rdi寄存器中保存的内存地址的内容(p/x $rdi查看寄存器内容)。明显,callq之后在rdi寄存器所保存的内存地址中,初始化了一个C对象,而且通过试探发现,调用的是无参构造函数,而不是如书中所述并不完全一致,明显 Clang 编译器的处理更为简洁高效。
0x10ac7fe28 <+8>: movq %rdi, %rax
0x10ac7fe2b <+11>: movq %rdi, %rcx
0x10ac7fe2e <+14>: movq %rcx, -0x8(%rbp)
0x10ac7fe32 <+18>: movq %rax, -0x10(%rbp)
0x10ac7fe36 <+22>: callq 0x10ac7fe50 ; C::C at ViewController.mm:18
上述调试过程具体操作日志如下:
(lldb) p/x $rdi
(unsigned long) $1 = 0x00007ffeee3e3060
(lldb) x/2g 0x00007ffeee3e3060
0x7ffeee3e3060: 0x0000000000000000 0x0000000000000000
(lldb) x/2g 0x00007ffeee3e3060
0x7ffeee3e3060: 0x000000010181d030 0x000000000000000f
从书中后面的内容发现,这就是编译器层对“按值返回对象”的函数的性能优化,也就是书中所说的 NRV(Named Return Value)。NRV 优化是指将按值返回对象的函数形式,转化为在主调函数中声明(只声明不初始化)原返回值对象,并将原返回值对象按引用方式、以参数形式传入被调函数,从而避免冗余的对象构建或拷贝操作。
NRV 优化后复制构造函数本身的性能也会得到提升,因为复制构造函数也是将按值返回对象的函数形式。NVR 优化后的行为和上面 Clang 编译器对func函数的处理高度相似,只是需要加上额外的数据拷贝逻辑即可,改造后的复制构造函数签名可以设想大致是void C(C & dst, const C &src)形式。
另外,由于复制构造函数都具有非常高的一致性,因此在大多数情况下,开发者是没有必要给类显式定义复制构造函数的。如果非要自行定义,如果开发者以比较花里胡哨的memset、memcpy之类的方式实现复制构造函数,还需要考虑在 virtual 机制下,需要给vptr正确赋值的问题。一言以蔽之,最好别去显式定义复制构造函数。
2.4 初始化列表
这节是讲使用构造函数初始化列表时,可能会遇到的一些“陷阱”,意思是开发者都有必要了解下编译器对初始化列表的处理细节。以下三种场景,定义构造函数时最好使用初始化列表语法:
- 初始化引用类型的成员变量;
- 初始化常量型的成员变量;
- 调用基类或成员变量的类的有参构造函数;
对情况一,引用类型必须在声明时初始化,类中的成员变量是个例外,可以只声明引用类型的成员变量,而不指定默认值(例如:int &a;),不过这种情况下,在任何函数体中存在调用该成员变量的代码都会编译失败,抛Undefined symbols for architecture x86_64: "C::a", referenced from: ...之类的链接错误。这种情况只有两种选择:要么在class中声明引用类型的成员变量时指定初始值;要么在构造函数初始化列表中初始化该引用型成员变量。
对情况二,在任何函数体中给const常量赋值会引发编译错误,抛Can't assign to ...之类的编译错误。这种情况也只有两种选择:这种情况只有两种选择:要么在class中声明const成员变量时指定初始值;要么在构造函数初始化列表中初始化该const成员变量。
对情况三,如果不使用初始化列表,而是在构造函数中去实现与初始化列表等价的代码,是不会导致任何编译器警告或者错误的,但是这种方式看起来比较傻,重点是执行效率低。用以下代码调试,注意A中的A() { }必须要有,不然会抛Constructor for 'C' must be explicitly initialize the member 'a' which does not have a default constructor编译错误。
class A {
public:
int a;
A() { }
A(int x) {
a = x;
}
};
class C {
public:
int c;
A a;
// 方式一:在构造函数体中初始化a
C() {
a = A(255);
c = 1;
}
// // 方式二:使用初始化列表初始化a
// C() : a(255) {
// a = A(255);
// c = 1;
// }
};
void mainn() {
C c;
}
分别查看上面两种方式定义的C()构造函数的实现,对应的汇编代码如下:第一段对应方式一,第二段对应方式二。明显,方式一调用了两个构造函数,而方式二只调用了一次。通过断点调试,方式一调用的构造函数分别为:默认构造函数A()以及A(int x)。
方式一对c.a的处理实际包含三个步骤:
- 调用
A()初始化c.a的内存空间; - 在栈上分配
A类型的临时对象的内存,并调用A(int x)初始化临时对象; - 将临时对象的数据拷贝到
c.a的内存空间;
方式二对c.a的处理则是一步到位:调用A(int x)初始化c.a的内存空间。这里体现的并不明显,因为A类型本身结构简单,如果A结构复杂,或需要复杂的析构操作呢?则方式一引入的冗余的c.a初始化、临时对象构建、临时对象析构则会上升为不小的负担。
NOTE:可能会有疑问,方式二只少了两条指令,核心代码就少了两步啦?事实就是如此。代码中的一大半都是内存寻址和对栈帧的处理,核心代码本来就少。
0x105ccbe50 <+0>: pushq %rbp ; $rdi == &c,主调层通过rdi传入c地址
0x105ccbe51 <+1>: movq %rsp, %rbp
0x105ccbe54 <+4>: subq $0x20, %rsp
0x105ccbe58 <+8>: movq %rdi, -0x8(%rbp) ; $rdi == &c, -0x8(%rbp) = &c
0x105ccbe5c <+12>: movq -0x8(%rbp), %rax ; $rax == &c
0x105ccbe60 <+16>: movq %rax, %rcx ; $rcx = &c
0x105ccbe63 <+19>: addq $0x4, %rcx ; $rcx = &c.a
0x105ccbe6a <+26>: movq %rcx, %rdi ; $rdi = &c.a
0x105ccbe6d <+29>: movq %rax, -0x18(%rbp) ; -0x18(%rbp) = &c
0x105ccbe71 <+33>: callq 0x105ccbea0 ; A::A at ViewController.mm:22,初始化c.a
0x105ccbe76 <+38>: leaq -0x10(%rbp), %rdi ; $rdi = -0x10(%rbp),在栈上分配临时对象__tempa的
; 内存,地址为-0x10(%rbp),并通过rdi寄存器传参
0x105ccbe7a <+42>: movl $0xff, %esi ; 传参0xff
0x105ccbe7f <+47>: callq 0x105ccbec0 ; A::A at ViewController.mm:24,初始化__tempa
0x105ccbe84 <+52>: movl -0x10(%rbp), %edx ; $edx = &__tempa
0x105ccbe87 <+55>: movq -0x18(%rbp), %rax ; &rax = &c
0x105ccbe8b <+59>: movl %edx, 0x4(%rax) ; c.a = __tempa,将__tempa中的数据拷贝到&c.a中
0x105ccbe8e <+62>: movl $0x1, (%rax)
0x105ccbe94 <+68>: addq $0x20, %rsp
0x105ccbe98 <+72>: popq %rbp
0x105ccbe99 <+73>: retq
0x104731e90 <+0>: pushq %rbp
0x104731e91 <+1>: movq %rsp, %rbp
0x104731e94 <+4>: subq $0x10, %rsp
0x104731e98 <+8>: movq %rdi, -0x8(%rbp)
0x104731e9c <+12>: movq -0x8(%rbp), %rax
0x104731ea0 <+16>: movq %rax, %rcx
0x104731ea3 <+19>: addq $0x4, %rcx
0x104731eaa <+26>: movq %rcx, %rdi
0x104731ead <+29>: movl $0xff, %esi
0x104731eb2 <+34>: movq %rax, -0x10(%rbp)
0x104731eb6 <+38>: callq 0x104731ed0 ; A::A at ViewController.mm:24
0x104731ebb <+43>: movq -0x10(%rbp), %rax
0x104731ebf <+47>: movl $0x1, (%rax)
0x104731ec5 <+53>: addq $0x10, %rsp
0x104731ec9 <+57>: popq %rbp
0x104731eca <+58>: retq
另外,编译器对初始化列表的处理是有特定顺序的,注意是按照成员变量在class中的声明顺序,而不是按照初始化列表所指定的顺序。例如,上面的构造函数即使声明为C() : A(255), c(1) { },编译器在C()中仍然会先初始化c然后再初始化a,因为两个变量的声明顺序就是先c后a。最后,编译器是在构造函数的函数体的所有代码执行前,插入构造函数的初始化列表所指定的操作,也就是优先保证初始化列表操作的执行。
最后,通过初始化列表初始化C类的c成员和函数体中初始化c成员是没有性能差异的。开发中没有必要这样去定义上面的构造函数C() : A(255), c(1) { },因为这样写很难让人感知到两个成员初始化在编译器处理上的差异性。在开发中养成只做有必要的事情的习惯,可以加深对这种编译器差异性的印象。
NOTE:C++ 编译器总是喜欢悄咪咪做事。
2.5 小结
作者在本章剖析了 C++ 编译器对构造函数的一些处理细节,这些处理细节其实也可以实际应用到具体业务开发工作中,例如 NRV 优化的原理、尽量避免按值传递对象,就很有参考意义。C++ 编译器本身就存在很大的差异性,如果开发者在业务代码的层面上就优化了程序的性能,那么就无需考虑各平台具体的 C++ 编译器到底有没有在这些机制上做优化了。
第三章:Data语义学
本章介绍 C++ 的成员变量、静态成员变量的实现原理,例如对象的数据布局、类的静态成员变量的存放、成员变量访问的实现、virtual 机制如何影响对象的内存布局等等。其实前面第一章在实际调试过程中已经提前扒出了不少这方面的信息,本章内容会深挖更多细节。
3.1 成员变量的绑定
看以下例程。需要注意,在很旧版本的编译器上(旧到现在几乎是见不着的),会将inline函数float getX()函数体中的x变量绑定到extern float x上,导致编译器错误理解的原因是:在定义inline函数float getX()时,float x成员变量尚未声明。不过后面版本的编译器规定了inline函数的变量绑定会在class声明完成后才进行,所以避免了这个错误。
extern float x;
class Point3d {
pubic:
Point3d(float, float, float);
float getX() const { return x; }
private:
float x, y, z;
}
旧版本 C++ 代码为了避免上述的错误因而引入了两条规则,虽然现在的 C++ 编译器基本没有问题,但是这两条规则还是沿用下来了(也许是基于可读性、统一性考虑):
- 把所有成员变量声明放在
class起始处,确保正确绑定; - 把所有
inline函数定义都放在class声明之外;
也就是改造如下:
extern float x;
class Point3d {
private:
float x, y, z;
pubic:
Point3d(float, float, float);
}
inline float Point3d::getX() const { return x; }
3.2 对象布局
关于 C++ 对象布局其实现在已经可以总结出一些结论:
- 简单的对象的成员的基本布局和 C 语言的
struct基本一致,遵循字节对齐原则; - 子类继承父类,直接在父类对象布局上,扩展子类定义的成员变量所需存储空间即可;
- 涉及 virtual 机制的对象,都需要额外扩展出 8 个字节用于存储 virtual table 的地址;
所以 C++ 对象包含的成分包括五个成分,不过是否包含第五个成分需要视编译器而定。有些编译器是通过被虚拟继承类和主虚拟继承类对应的存储空间分离,在主虚拟继承类的存储空间中新增指针,指向被虚拟继承类对应的存储空间,这种编译器编译的对象就会包含下面的第五个成分。有些编译器直接将被虚拟继承类和主虚拟继承类对应的存储空间整合到一块连续的内存,这种编译器编译的对象就不会包含第五个成分,例如 Clang 编译器。
- 保存类本身定义的成员变量;
- 保存继承链上的类定义的成员变量;
- 字节对齐所需的额外 Padding 内存空间;
- 保存
vptr内存空间; - 保存实现虚拟继承所需引入的内存空间;
声明为static的成员变量,会在编译期确定其内存地址(相对地址),程序载入内存后,在内存的 data segment 静态区中占据固定的内存位置。
NOTE:如果区分编译时和运行时的话,静态变量的存储位置可能存在两个位置:data segment 的静态区或者 BSS segment。不过,到程序加载就绪并正式运行时,两者都会统一到 data segment 静态区。编译期,声明一并初始化的静态变量或全局变量保存于二进制文件静态区,并会直接增加二进制文件的大小。例如,声明
static int s_arr[0x10000000] = {100, 101, 102};会直接使编译产物增大 (2^28 * 4)B = 1GB。编译期间,声明时未初始化的静态变量或全局变量保存于二进制文件 BSS 段,BSS 段记录变量的基本信息,所以直接造成的二进制文件大小增量是很小的。例如,声明static int s_arr[0x10000000];,编译产物的大小几乎不变。二进制文件静态区在运行时直接映射到虚拟内存;BSS 段则在运行时再正式分配虚拟内存空间统一到静态区。
这里引申一个问题,编译器是如何转换一句访问对象成员变量的代码的,例如以下例程,调试代码中是两种直接访问成员变量的方式:直接访问、通过指针访问(两者在多态上的处理和访问的间接性上是有区别的)
class Point3d {
public:
int x = 100, y = 200, z = 300;
};
void main() {
Point3d pt;
int x1 = pt.x;
Point3d *ptPtr = &pt;
int x2 = ptPtr->x;
}
对应的汇编代码如下
0x100f13e30 <+0>: pushq %rbp
0x100f13e31 <+1>: movq %rsp, %rbp
0x100f13e34 <+4>: subq $0x30, %rsp
0x100f13e38 <+8>: leaq -0x10(%rbp), %rdi
0x100f13e3c <+12>: callq 0x100f13e60 ; Point3d::Point3d at ViewController.mm:10
0x100f13e41 <+17>: movl -0x10(%rbp), %eax ; 开始 int x1 = pt.x
0x100f13e44 <+20>: movl %eax, -0x14(%rbp)
0x100f13e47 <+23>: leaq -0x10(%rbp), %rcx
0x100f13e4b <+27>: movq %rcx, -0x20(%rbp)
0x100f13e4f <+31>: movq -0x20(%rbp), %rcx ; 开始 int x2 = ptPtr->x
0x100f13e53 <+35>: movl (%rcx), %eax
0x100f13e55 <+37>: movl %eax, -0x24(%rbp)
0x100f13e58 <+40>: addq $0x30, %rsp
0x100f13e5c <+44>: popq %rbp
0x100f13e5d <+45>: retq
编译器在编译pt.x时,首先从前面声明代码知道其是个Point3d类型,并知道其地址是当前栈基(前面汇编代码中的%rbp寄存器)的 -0x10 ~ -0x5 偏移地址(因为下面有指针类型的局部变量所以栈按 8 字节对齐)的 12 字节(sizeof(Point3d))内存,而且在对象内存 0 偏移地址上保存x、4 偏移上保存y、z偏移上保存z。因此pt.x就被翻译为访问栈基的 0 偏移地址的 4 个字节(sizeof(int))所保存的数据。
编译器在编译ptPtr->x时,将其翻译为读取栈基 -0x20 ~ -0x19 所保存的内存地址(其实也就是指针所指向的pt对象的地址,根据上下文还是对象内存 0 偏移地址的 4 个字节所保存的数据。也就是说,ptPtr->x比pt.x多了一层“访问指针所指向的地址”的间接性,但是两者所访问的数据是相同的。
Think:编译器其实就是手里抓着一堆类型、函数地址、字面量等程序元数据,并基于这些信息翻译成机器语言,来回倒腾内存、运算,来实现你编写的高级语言代码所要实现的效果。
那么 C++ 是如何访问类的静态变量的呢?下面是调试用的main函数及两句赋值代码对应的汇编指令。因为类的静态变量变量和静态变量主体实现是一样的,所以直接用 C 代码调试了。
static int s_x = 100;
void main() {
int x = s_x;
int y = s_x;
}
0x101e99ea9 <+41>: movl 0x780d(%rip), %ecx ; int x = s_x;
0x101e99eaf <+47>: movl %ecx, -0x14(%rbp)
0x101e99eb2 <+50>: movl 0x7804(%rip), %ecx ; int y = s_x;
0x101e99eb8 <+56>: movl %ecx, -0x18(%rbp)
可见对静态变量(包括类的静态成员变量)或者全局变量的访问,是通过 ip 寄存器地址计算得到的。以第一条汇编指令为例,编译器在编译这条汇编指令时,就已经知晓:这条汇编指令的运行时内存地址(0x10556eea9)将会比这个静态变量的运行时内存地址大0x780d。既然是通过 ip 寄存器定位,ip 寄存器是指向一条执行的指令地址,指令地址是会变的,而静态变量的内存地址是固定的,所以两句访问相同静态变量的编译指令,所用的 ip 地址偏移量参数基本是不一样的。
3.3 继承
Clang 编译器的 virtual 机制和书中描述不太一致,两者细节上存在不同,但是套路上基本是一致的。因为主要通过调试并结合书中内容来探索,为图方便,本章则直接以 Clang 编译器的实现为准。
不考虑多态的继承,就是简单地有扩展就拼接,不再赘述。引入多态后,也就是 virtual 机制,对程序会带来额外的时间和空间上的负担,主要列举如下:
- 需要引入 virtual table(空间),用来存放与类型关联的 virtual 函数的地址;
- 关联了 virtual table 的类型的每个对象,都要分配额外 8 个字节内存用于存储
vptr; - 扩展构造器功能,使其可以正确初始化对象的
vptr; - 扩展析构器功能,使其可以正确释放对象的
vptr内存(vptr指针本身所占用的内存);
那么vptr是具体如何影响对象内存布局的呢?从前面的内容,先总结以下结论:
结论一:在不引入多重继承的情况下,
vptr总是保存在对象的首 8 个字节。
3.3.1 多重继承
用下面的例程简单调试:
class A {
public:
int a = 0x1;
virtual void vfun1() { };
};
class B {
public:
int b = 0x2;
virtual void vfun2() { };
};
class C : public A, public B {
public:
int c = 0x3;
void vfun1() {
printf("c vfunc1 imp\n");
}
void vfun2() {
printf("c vfunc2 imp\n");
}
};
void main() {
C c;
}
编译运行,po sizeof(a)为 16 字节,x/32b &a打印a对象内存,可以发现有vptr。也就是说,类声明了 virtual 函数后,类的对象将引入 8 个字节内存空间,在 0-7 字节上保存vptr,成员变量内存布局紧接在后面。继续po sizeof(c)为 32 字节,x/32b &c打印c对象内存,可以发现有两个vptr!比较明显,一个是由于继承了声明了 virtual 函数的A,另一个是由于继承了也声明了 virtual 函数的B,在C类定义的成员变量内存布局紧接在后面。从而得出第二个结论:
结论二:使用多重继承时,对象中的每个基类的空间布局包含基类的
vptr(如果有需要的话)以及基类的成员变量布局。
具体操作日志如下:
(lldb) po sizeof(a)
16
(lldb) x/16b &a
0x7ffee5e04060: 0x40 0xc0 0xdf 0x09 0x01 0x00 0x00 0x00
0x7ffee5e04068: 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
(lldb) po sizeof(c)
32
(lldb) x/32b &c
0x7ffee5e04040: 0x68 0xc0 0xdf 0x09 0x01 0x00 0x00 0x00
0x7ffee5e04048: 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffee5e04050: 0x88 0xc0 0xdf 0x09 0x01 0x00 0x00 0x00
0x7ffee5e04058: 0x02 0x00 0x00 0x00 0x03 0x00 0x00 0x00
NOTE:书中介绍
vptr通常是在成员变量布局后面,说是有利于最大程度保持引入多态前的成员变量布局规则,并且有利于兼容 C 语言特性(例如从 C 结构体派生出一个具有多态性的类)。不过 C++ 编译器既然都这么能干了,估计这些事情也不是都做不了。瞧着 Clang 不就这么干了吗,而且也兼容了结构体派生多态类,不过貌似也因此引入了更多间接性,这里不作深入探索,感兴趣的话可以按这种调试套路摸索一下。
那么class C : public A, public B的定义中,继承列表的顺序是有讲究的么?是有的。上面的代码如果是class C : public B, public A,则C会首先布局B再布局A,调试日志如下:
(lldb) po sizeof(c)
32
(lldb) x/32b &c
0x7ffee2d580a0: 0x90 0x80 0xea 0x0c 0x01 0x00 0x00 0x00
0x7ffee2d580a8: 0x02 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffee2d580b0: 0xb0 0x80 0xea 0x0c 0x01 0x00 0x00 0x00
0x7ffee2d580b8: 0x01 0x00 0x00 0x00 0x03 0x00 0x00 0x00
那么,Clang 编译器就是严格按继承列表的顺序来布局么?并不是。在最初调试代码的基础上,屏蔽A中声明的virtual void vfun1() { };函数。此时,A的对象无需保存vptr,而B对象需要保存vptr。这种情况下,Clang 编译器是优先布局了B之后再布局A。具体调试日志如下。
(lldb) po sizeof(c)
24
(lldb) x/24b &c
0x7ffeed0ae0b0: 0x60 0x20 0xb5 0x02 0x01 0x00 0x00 0x00
0x7ffeed0ae0b8: 0x02 0x00 0x00 0x00 0x01 0x00 0x00 0x00
0x7ffeed0ae0c0: 0x03 0x00 0x00 0x00 0x00 0x00 0x00 0x00
那么,Clang 编译器是把所有带vptr的基类布局好后,再回头去布局不带vptr的基类么?也不是。在最初调试代码的基础上,屏蔽A中声明的virtual void vfun1() { };函数,再引入D类型,并声明class C : public A, public B, public D。这种情况下,Clang 先布局了B再回头布局A然后才到D。因此,Clang 编译器只是为了保证对象在需要vptr的情况下,首 8 个字节必然是用于保存vptr。
class D {
public:
int d = 0x4;
virtual void vfun3() { };
};
0x7ffee8bfd0a8: 0x68 0x30 0x00 0x07 0x01 0x00 0x00 0x00
0x7ffee8bfd0b0: 0x02 0x00 0x00 0x00 0x01 0x00 0x00 0x00
0x7ffee8bfd0b8: 0x80 0x30 0x00 0x07 0x01 0x00 0x00 0x00
0x7ffee8bfd0c0: 0x04 0x00 0x00 0x00 0x03 0x00 0x00 0x00
综上得出第三个结论:
结论三:多重继承时,编译器会从继承列表中拎出第一个带
vptr的基类优先布局,然后再按照继承列表的顺序布局剩余的基类。
3.3.2 虚拟继承
虚拟继承是为了解决多重继承体系下,某个类的两条继承链在上层相交,导致类的对象中包含两块或以上的内存区,用于给上层相交类布局,通过虚拟继承,可以让对象仅用一块内存区给相交类布局(或者用一个指针指向相交类),从而避免对象布局过程中的内存冗余,以及对象访问相交类的成员变量时的歧义风险。
Think:C++ 的多继承体系真的是混乱。不是说编译器实现存在问题,而是它提供给开发者一种营造混乱的继承体系的武器!而且基类通过虚拟继承避免衍生类访问基类成员变量歧义,未免有点上层知晓下层细节的嫌疑。
前面提到过,Clang 编译器的对象布局中,被虚拟继承类和主虚拟继承类对应的存储空间整合到一块连续的内存的。通过以下代码来探索 Clang 处理虚拟继承的布局策略,为捕捉到更多处理细节,让调试代码定义了一个非常复杂的继承结构(注意这个继承结构并不合理,例如访问c.x时会抛Non-static member 'x' found in multiple base-class subobjects of type 'Base':编译错误,因为c的对象布局中有多个存放x的 slot,也就是存在命名冲突,编译器不能区分该代码是具体想访问到具体哪个x的 slot)。
class Base {
public: int x = 0xff;
};
class A : public virtual Base {
public: int a = 0x1;
};
class B : public Base {
public: int b = 0x2;
};
class D : virtual public B {
public:
int d = 0x4;
};
class E : virtual public Base {
public: int e = 0x5;
};
class F : virtual public E {
public: int f = 0x6;
};
class G : virtual Base {
public: int g = 0x7;
};
class H : public G {
public: int h = 0x8;
};
class C : public A, virtual public D, public F, virtual public H {
public:
int c = 0x3;
};
void main() {
C c;
int x = c.g;
}
先根据前面结论三,依次处理A、D、F、H基类,暂不考虑内存对齐问题。
从A开始。A虚拟继承了Base,Clang 编译器处理虚拟继承的方式是:将当前类的布局空间的索引标定为 0,则其普通继承的基类的索引为正,在索引 0 的内存区的上方(内存地址负向偏移),虚拟继承的基类的索引为负,在索引 0 的内存区的下方(内存地址正向偏移)。由于A直接引入虚拟继承所以需要给A指定vptr。所以上述注释中A对象布局得出的过程为:
// A{A-vptr, 0x1}(0),
// Base{0xff}(-)
然后看D。对象中B对应的内存布局比较简单,就是普通继承叠加为0xff, 0x2,接下来需要注意由于D虚拟继承了B,所以B对应的整块内存的索引都是负,放在索引 0 下方。
// D{D-vptr, 0x4}(0),
// {Base{0xff}(+), B{0x2}}(-)
接下来看F。F的虚继承基类E和A的布局是一样的,不考虑对齐是E{E-vptr, 0x5}(+), Base{0xff}(-),再把E整块标记为负。
// F{F-vptr, 0x6}(0),
// {E{E-vptr, 0x5}, Base{0xff}(-)}(-)
再看H。其继承的基类G和A的布局是一样的,不考虑对齐是G{G-vptr, 0x7}(0), Base{0xff}(-),然后整块G标记为正,然后将其中标记为负的Base{0xff}(-)移到索引 0 下方。
// 1. 确定相对位置
// {G{G-vptr, 0x7}, Base{0xff}(-)}(+),
// H{0x8}(0)
// 2. 负索引块调整位置
// G{G-vptr, 0x7}(+),
// H{0x8}(0),
// Base{0xff}(-)
至此,C的四个基类布局处理完毕,可以处理C对象布局。遵循以下操作原则:
- 按照
C的继承列表的顺序逐个处理每个基类; - 普通继承的基类布局插入索引 0 邻接的正上方,整个标记为索引为正
(+),如果其中包含索引为负(-)的子块,则需要移到布局末端; - 虚拟继承必须借助 virtual table,若类虚拟继承了某个基类,且类本身都不包含
vptr,则需要在对象布局首 8 字节插入vptr; - 虚拟继承的基类布局插入到布局末端,在此过程中,需要校验每个索引为负的子块,如果已有相同的子块存在于内存布局中并且同样标记为负索引,则将其视为冗余,将其移除只保留布局中已存在的旧子块;
- 所有基类布局插入完成后,需要对内存作最终对齐;
// 1. 放好 C 索引为 0
// C{0x3}(0)
// 2. 处理继承 A,并负索引移到 0 索引下方
// A{A-vptr, 0x1}(+) <-
// C{0x3}(0)
// Base{0xff}(-) <-
// 3. 处理虚拟继承 D
// A{A-vptr, 0x1}(+)
// C{0x3}(0)
// Base{0xff}(-)
// {D{D-vptr, 0x4}(0), <-
// {Base{0xff}(+), B{0x2}}(-)}(-) <-
// 3. 处理继承 F
// A{A-vptr, 0x1}(+)
// F{F-vptr, 0x6}(+), <- 注意是插入索引 0 邻接的正上方
// C{0x3}(0)
// Base{0xff}(-)
// {D{D-vptr, 0x4}(0),
// {Base{0xff}(+), B{0x2}}(-)}(-)
// E{E-vptr, 0x5}(-) <- 注意这里从中剔除了重叠的 Base{0xff}(-)
// 4. 处理虚拟继承 H
// A{A-vptr, 0x1}(+)
// F{F-vptr, 0x6}(+),
// C{0x3}(0)
// Base{0xff}(-)
// {D{D-vptr, 0x4},
// {Base{0xff}(+), B{0x2}}(-)}(-)
// E{E-vptr, 0x5}(-)
// {G{G-vptr, 0x7}(+), <- 注意是插入到尾部
// H{0x8}}(-), <-
// <- 注意这里从中剔除了重叠的 Base{0xff}(-)
// 5. 最终的内存对齐
// A{A-vptr,
// 0x1}(+)
// F{F-vptr,
// 0x6}(+),
// C{0x3}(0), Base{0xff}(-)
// {D{D-vptr,
// 0x4}, {Base{0xff}(+),
// B{0x2}}(-)}(-)
// E{E-vptr,
// 0x5}(-)
// {G{G-vptr,
// 0x7}(+), H{0x8}}(-)
操作日志如下,与上面的描述相符。
(lldb) po sizeof(c)
96
(lldb) x/96b &c
0x7ffee720f068: 0xf8 0x10 0x9f 0x08 0x01 0x00 0x00 0x00
0x7ffee720f070: 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffee720f078: 0x18 0x11 0x9f 0x08 0x01 0x00 0x00 0x00
0x7ffee720f080: 0x06 0x00 0x00 0x00 0x03 0x00 0x00 0x00
0x7ffee720f088: 0xff 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffee720f090: 0x30 0x11 0x9f 0x08 0x01 0x00 0x00 0x00
0x7ffee720f098: 0x04 0x00 0x00 0x00 0xff 0x00 0x00 0x00
0x7ffee720f0a0: 0x02 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffee720f0a8: 0x48 0x11 0x9f 0x08 0x01 0x00 0x00 0x00
0x7ffee720f0b0: 0x05 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffee720f0b8: 0x60 0x11 0x9f 0x08 0x01 0x00 0x00 0x00
0x7ffee720f0c0: 0x07 0x00 0x00 0x00 0x08 0x00 0x00 0x00
接着看main函数中的c.g是如何访问到对象成员变量的。main函数的汇编代码如下。所以 Clang 编译器会将所有成员变量在对象中的偏移地址保存在对象首 8 字节的vptr所指向的 virtual table 中,Clang 会在编译期确定成员变量名与 virtual table 中对应的 slot 索引之间的映射关系,运行时通过 “对象地址 -> virtual talble 地址 -> 保存目标成员变量的 slot 索引 -> 目标成员变量在对象中的偏移地址” 访问目标成员变量。
0x10aa97b60 <+0>: pushq %rbp
0x10aa97b61 <+1>: movq %rsp, %rbp
0x10aa97b64 <+4>: subq $0x70, %rsp
0x10aa97b68 <+8>: leaq -0x60(%rbp), %rdi
0x10aa97b6c <+12>: callq 0x10aa97ba0 ; C::C at ViewController.mm:80
0x10aa97b71 <+17>: movq -0x60(%rbp), %rax ; 定位虚表地址保存到rax寄存器
0x10aa97b75 <+21>: movq -0x38(%rax), %rax ; 定位c.g的在c实例中的偏移量,注意这里拿到
; 的偏移量比实际偏移量小了8个字节,因为
; 要排除掉首个vptr所占用的8个字节
0x10aa97b79 <+25>: movl -0x58(%rbp,%rax), %ecx ; 含义是($rbp+$rax)-0x58,拆解成更好理解的
; 形式:($rbp+$rax+8)-0x60,进一步拆解为
; ($rbp-0x60)+(+$rax+8),最终含义“对象地
; 址+成员变量c.g在对象中的真实偏移量”
0x10eb50b9d <+29>: movl %ecx, -0x64(%rbp)
0x10eb50ba0 <+32>: addq $0x70, %rsp
0x10eb50ba4 <+36>: popq %rbp
0x10eb50ba5 <+37>: retq
NOTE:不涉及 virtual 机制的类的对象直接在编译期静态生成成员变量的偏移地址,不需要 virtual table 访问的间接性,因此无论是在编译效率还是执行效率上必然是更高的。
3.4 小结
书中介绍完继承后,还有两小节介绍了各种场景下成员变量的访问效率差异,不过其结论所基于的原理,在前面的内容都有提过,所以不赘述。
编译器之所以需要制订一套繁杂的内存布局机制,是为了每次构建类的对象时,都可以产出一个确定性的结果,并且方便于基类对象及其衍生类对象之间的类型转化。在此前提下,编译器才得以通过对象成员变量名、成员变量类型、以及对象类型、对象是否涉及 virtual 机制、对象 virtual table 的地址等静态信息,定位到成员变量在对象内存中的准确位置。
对象引入 virtual table 后,成员变量的偏移需要考虑对象中vptr需要占据的内存空间,如果每次都需要根据对象类型的继承结构去计算成员变量的准确位置的话,运行时效率肯定会十分低下。因此,编译器将成员变量在内存中的偏移地址也一并保存在 virtual table 中,通过“对象地址 -> virtual talble 地址 -> 保存目标成员变量的 slot 索引 -> 目标成员变量在对象中的偏移地址” 访问到目标成员变量。
第四章:函数语义学
本章介绍 C++ 编译器的对成员函数的处理细节。
4.1 成员函数的调用方式
本节分别介绍 virtual 函数和非 virtual 函数的调用方式,即如何使用。暂时不考虑底层实现原理,底层实现在 4.2、4.3 通过结合汇编指令调试再去探索。
4.1.1 普通成员函数
C++ 的设计准则之一是:非静态成员函数和普通函数的有相同的调用效率。用以下代码为例:
class A {
public:
int a = 0x1;
void func() {
a = 0;
};
};
void mainn() {
A a;
a.func();
}
为遵循上述设计准则,C++ 编译器需要对成员函数作以下处理:
- 首先,成员函数和普通函数的最大区别是对
this指针的支持,原因是编译器会转换成员函数签名为void func(A *this),this来自哪里?就是调用a.func()这句代码的a对象的地址。 - 其次,成员函数的函数体可以通过成员变量名直接访问,原因是编译器会转化成员函数体的代码,成员变量访问均通过
this实现,也就是将a = 0转化为this->a = 0。 - 然后,需要给函数重新命名,以避免不同类型命名相同函数、函数重载等带来的命名冲突,这个过程叫做 mangling,通常情况下,需要在函数名中加入类名、参数类型编码生成类似于
func转化为func__A_v的 mangle 后的函数名; - 最后,调用代码需要转化,即
a.func()为func__A_v(&a);
至此,每个成员变量函数实际上都被转化为普通函数,在调用效率上基本无差异了。
静态成员函数的处理与非静态成员函数的处理的主要区别在于 mangling。例如,如果在A中再声明一个静态成员函数static int sfunc() { return 0; },则它会被转化为类似sfunc__A_SF_v的格式,其中SF表示它是一个静态成员函数。
静态成员函数经常会用于访问或操作静态成员变量,由于静态成员变量本质上还是全局变量,因此为了防止命名冲突,静态成员变量必然也是需要 mangling 的。实际上所有成员变量,无论是否是静态,C++ 编译器都会做 mangling 处理。
4.1.2 virtual成员函数
将func声明为 virtual,继续看 C++ 编译器对 virtual 成员函数的处理。
class A {
public:
int a = 0x1;
virtual void func() {
a = 0;
};
};
void mainn() {
A a;
A *aptr = &a;
a.func();
aptr->func();
}
上一节(4.1.1)对成员函数的处理还是需要的。重点是要关注到调用代码的转换。首先看a.func(),由于这种调用方式a对象的类型是静态的,所以只需要转化为func__A_v(&a)即可。对aptr->func()的处理则需要考虑多态,首先需要获得对象的vptr,然后在其指向的 virtual table 中取出该 virtual 函数对应的函数指针,最后触发函数指针,也就是最终转换为类似(* (aptr->vptr[1]))(a)(1、假设可以直接通过aptr->vptr获得 virtual table 地址;2、假设func函数指针放在 virtual table 索引 1 的 slot 上)的形式。不难看出,需要考虑 virtual 机制的成员函数调用比普通成员函数调用的效率会低不少。
4.2 virtual函数详解
由于 Clang 编译器的 virtual 机制实现和书中完全不同,因此这章内容大体略过。取而代之的是,在这里简单调试一下 Clang 编译器下的 virtual 函数调用机制。
class A {
public:
int a = 0x1;
virtual void vfunc() { };
virtual void vfunc2() { };
};
class B : public A{
public:
int b = 0x2;
virtual void vfunc() { };
virtual void vfunc2() { };
};
void main() {
A a;
B b;
A *aptr = &a;
A *bptr = &b;
aptr->vfunc2();
bptr->vfunc2();
}
对应的完整汇编代码如下:在0x107e31db1 <+65>: callq *0x8(%rcx)和0x107e31da7 <+55>: movq -0x30(%rbp), %rax指令处下断点。调试日志在汇编代码下方,可见第一条callq *0x8(%rcx)指令用于获取a的 virtual table 正偏移 8 个字节的位置所保存的函数指针,并调用该函数指针,该函数指针指向A类型的vfunc2。同理,第二条callq *0x8(%rcx)指令用于获取b的 virtual table 正偏移 8 个字节的位置所保存的函数指针,并调用该函数指针,该函数指针指向B类型的vfunc2。
0x1097d8d90 <+0>: pushq %rbp
0x1097d8d91 <+1>: movq %rsp, %rbp
0x1097d8d94 <+4>: subq $0x30, %rsp
0x1097d8d98 <+8>: leaq -0x10(%rbp), %rdi
0x1097d8d9c <+12>: callq 0x1097d8de0 ; A::A at ViewController.mm:19
0x1097d8da1 <+17>: leaq -0x20(%rbp), %rdi
0x1097d8da5 <+21>: callq 0x1097d8e00 ; B::B at ViewController.mm:27
0x1097d8daa <+26>: leaq -0x10(%rbp), %rax
0x1097d8dae <+30>: movq %rax, -0x28(%rbp) ; 至此 -0x28(%rbp) 为 a 对象地址
0x1097d8db2 <+34>: leaq -0x20(%rbp), %rax
0x1097d8db6 <+38>: movq %rax, -0x30(%rbp) ; 至此 -0x30(%rbp) 为 b 对象地址
0x1097d8dba <+42>: movq -0x28(%rbp), %rax
0x107e31d9e <+46>: movq (%rax), %rcx
0x107e31da1 <+49>: movq %rax, %rdi
0x107e31da4 <+52>: callq *0x8(%rcx) ; 此时 rcx 为 A 的 virtual table 地址
0x107e31da7 <+55>: movq -0x30(%rbp), %rax
0x107e31dab <+59>: movq (%rax), %rcx
0x107e31dae <+62>: movq %rax, %rdi
0x107e31db1 <+65>: callq *0x8(%rcx) ; 此时 rcx 为 B 的 virtual table 地址
0x1097d8dd2 <+66>: addq $0x30, %rsp
0x1097d8dd6 <+70>: popq %rbp
0x1097d8dd7 <+71>: retq
// 断点1调试日志
(lldb) p/x $rcx + 0x08
(unsigned long) $5 = 0x000000010afb3040
(lldb) x/gx 0x000000010afb3040
0x10afb3040: 0x000000010afb0ea0
(lldb) po (void(*)(void))0x000000010afb0ea0
(CPPModelDemo`A::vfunc2() at ViewController.mm:25)
// 断点2调试日志
(lldb) p/x $rcx + 0x08
(unsigned long) $10 = 0x000000010afb3070
(lldb) x/gx 0x000000010afb3070
0x10afb3070: 0x000000010afb0f10
(lldb) po (void(*)(void))0x000000010afb0f10
(CPPModelDemo`B::vfunc2() at ViewController.mm:34)
嗯,场景不够复杂。将B修改为虚拟继承A,即class B : virtual public A,此时汇编代码立刻增长了 10 行。解析汇编代码语义发现,处理aptr->vfunc2的逻辑是维持不变的,仍然是调用a对象的 virtual table 正偏移 8 个字节的位置所保存的函数指针。
新增代码基本是用于处理bptr->vfunc2。首先,从前面章节的内容可以知道b对象的内存中包含两个vptr,分别是B类型由于虚拟继承A类型所引入的vptr(记为bvptr),以及继承自A类型的vptr(记为aptr)。下列代码首先定位到bvptr,然后偏移-0x18得到avptr在b对象布局中的偏移量,从而定位到avptr,avptr所指向的 virtual table 正偏移 8 个字节的位置所保存的函数指针就是本次需要调用的vfunc2。
然后简单盘一下从上面调试过程获取到的信息:
- Clang 编译器下,对象
vptr并不是指向 virtual table 的首地址,而是当前类型的所实现的虚函数的函数指针列表的起始地址。所以本节第一个调试代码中,在vptr指向的内存地址偏移 8 个字节找到了vfunc2的地址,而偏移 0 个字节则正好是vfunc1; - 函数指针在 virtual table 中的排序是按照 virtual 函数声明的顺序,并不是具体实现的顺序,所以调换
B中vfunc1和vfunc2位置汇编代码是基本不变的,而调换A中vfunc1和vfunc2位置汇编代码获取函数指针相对vptr的偏移量会发生改变;
0x10be84d00 <+0>: pushq %rbp
0x10be84d01 <+1>: movq %rsp, %rbp
0x10be84d04 <+4>: subq $0x50, %rsp
0x10be84d08 <+8>: leaq -0x10(%rbp), %rdi ; -0x10(%rbp) == &a
0x10be84d0c <+12>: callq 0x10be84d80 ; A::A at ViewController.mm:19
0x10be84d11 <+17>: leaq -0x30(%rbp), %rdi ; -0x30(%rbp) == &b
0x10be84d15 <+21>: callq 0x10be84da0 ; B::B at ViewController.mm:28
0x10be84d1a <+26>: xorl %eax, %eax ; eax寄存器清零
0x10be84d1c <+28>: movl %eax, %ecx ; ecx寄存器清零
0x10be84d1e <+30>: leaq -0x10(%rbp), %rdx
0x10be84d22 <+34>: movq %rdx, -0x38(%rbp) ; -0x38(%rbp) == &(a->vptr) //划重点
0x10be84d26 <+38>: leaq -0x30(%rbp), %rdx ; %rdx == &b
0x10be84d2a <+42>: cmpq $0x0, %rdx
0x10be84d2e <+46>: movq %rcx, -0x48(%rbp) ; %rcx == 0,-0x48(%rbp) == 0
0x10be84d32 <+50>: je 0x10be84d4b ; <+75> at ViewController.mm
0x10be84d38 <+56>: movq -0x30(%rbp), %rax ; %rax == &(b->bvptr)
0x10be84d3c <+60>: movq -0x18(%rax), %rax ; %rax == 0x10
0x10be84d40 <+64>: leaq -0x30(%rbp), %rcx ; %rax == &b
0x10be84d44 <+68>: addq %rax, %rcx ; %rcx == &b + 0x10 == &(b->avptr)
0x10be84d47 <+71>: movq %rcx, -0x48(%rbp) ; -0x48(%rbp) == &(b->avptr)
0x10be84d4b <+75>: movq -0x48(%rbp), %rax ; %rax == &(b->avptr)
0x10be84d4f <+79>: movq %rax, -0x40(%rbp) ; -0x40(%rbp) == &(b->avptr) //划重点
0x10be84d53 <+83>: movq -0x38(%rbp), %rax
0x10be84d57 <+87>: movq (%rax), %rcx
0x10be84d5a <+90>: movq %rax, %rdi
0x10be84d5d <+93>: callq *0x8(%rcx)
0x10be84d60 <+96>: movq -0x40(%rbp), %rax
0x10be84d64 <+100>: movq (%rax), %rcx
0x10be84d67 <+103>: movq %rax, %rdi
0x10be84d6a <+106>: callq *0x8(%rcx)
0x10be84d6d <+109>: addq $0x50, %rsp
0x10be84d71 <+113>: popq %rbp
0x10be84d72 <+114>: retq
还没完,再通过 LLDB 调试看下两条callq *0x8(%rcx)具体调用了什么。首先是第一条,调试日志如下,直接调用的是A::vfunc2()。这个没有问题,因为aptr指向的对象类型和a的实际类型都是A本身,所以直接调用A的实现。
(lldb) p/x $rcx
(unsigned long) $0 = 0x000000010423a038
(lldb) x/g 0x000000010423a040
0x10423a040: 0x0000000104237ea0
(lldb) po (void(*)(void))0x0000000104237ea0
(CPPModelDemo`A::vfunc2() at ViewController.mm:25)
然后看第二条,发现callq调用的了一个叫 virtual thunk 的东西,查看这块区域的内存,乱七八糟线索断了。不过从po打印的信息看,这块内存区域是与B::vfunc2()关联的。
(lldb) p/x $rcx
(unsigned long) $2 = 0x000000010423a0a0
(lldb) x/g 0x000000010423a0a8
0x10423a0a8: 0x0000000104237f00
(lldb) po (void(*)(void))0x0000000104237f00
(CPPModelDemo`virtual thunk to B::vfunc2() at ViewController.mm)
(lldb) x/4g 0x0000000104237f00
0x104237f00: 0xf87d8948e5894855 0x48088b48f8458b48
0x104237f10: 0x8948c80148e0498b 0x90ffffffb1e95dc7
回到上面的汇编代码,注意到标记了“//划重点”的两行代码。第一条代码的含义是,将a对象的地址传入到栈基偏移 -0x38 ~ -0x31 的 8 个字节上,代码中注释&(a->vptr)也是没有问题的,因为a对象首 8 字节就是用于保存a的vptr,所以对象的地址就是a->vptr的地址,没有毛病。第二条代码的含义是,将b对象中A类型对应的布局区的地址传入到栈基偏移 -0x40 ~ -0x39 的 8 个字节上,b对象中A类型对应的布局区的首 8 字节用于保存b对象中A类型对应的vptr,所以注释也没有毛病。
重点是这两个内存地址都会通过rdi寄存器传入到下面callq所调用的函数中,实际上rdi就是作为成员函数A *this参数传入的。也就是说,A *aptr = &a和A *bptr = &b对于编译器而言,一定是一个指向A对象的指针。为啥?因为编译器不能“因小失大”。
- 首先将
bptr指向的对象转化为指向A对象的指针,是符合代码的语义的; - 其次,C++ 编译器必须优先保证非 virtual 成员函数的调用效率,如果不对
bptr做上述处理,则后面bptr每次调用A类型所定义的非 virtual 函数时,都要进行操作bptr指向A对象,从而会降低非 virtual 成员函数的调用效率;
上面这种操作在编译器中叫做 this adjustment。回到前面callq调用的是 virtual thunk 而不是函数指针的问题。首先 thunk 本质是一段汇编代码,编译器支持将汇编代码编码为平台相关的机器码写入一块内存中,并可以通过callq直接调用。所以,上面 virtual thunk 的用处,其中之一就是把“可能被 this adjustment 后的this指针”(在上面的代码中是指向b对象中A类型对应的布局空间)调整回指向真实的对象(在上面的代码中是指回b对象)。
从上面的调试过程中还发现了一个现象,就是B类型的 virtual table 中,除了上面 virtual thunk 附近对应的是“被B继承的A类型 virtual 函数列表”,还有一块B类型本身的 virtual 函数列表。为啥不直接用 virtual thunk 呢?其实之所以多维护一份也正是为了减少 virtual thunk 的调用次数(或者说减少各种 adjustment 的执行次数)。
例如,如果在前面调试代码的基础上,定义类C普通继承B,并且C不 override vfunc2,则下面cptr->vfunc2(),在定位到c->bvptr后,就可以直接调用B类型本身的 virtual 函数列表中的vfunc2函数指针,从而无需调用vfunc2 的 virtual thunk 而引入更多 this adjustment 过程。
C c;
B *cptr = &c;
cptr->vfunc2();
NOTE:C++ 中一个类的所有对象都具有相同的对象布局,所有对象的
vptr也指向同一个 virtual table,但是对象布局内部某个基类的vptr,和该基类的vptr不一定指向同一个 virtual table(调试代码中a->vptr != b->aptr)。
关于 Clang 编译器的 virtual 函数的实现就探索到这里吧。再把多重继承纳入进来的话,“虚拟函数+虚拟继承+多重继承”的终极场景,估计我是调试不动了。最后,书中有提到的一点:最好不要在 virtual 继承的基类中声明非静态成员变量,否则无论是编译器还是代码维护者本身,对代码的理解成本都会大大增加。最后,如果非要调试“虚拟函数+虚拟继承+多重继承”场景,建议用类似以下的经典的虚拟多继承结构,再逐步灵活调整代码进行调试。
class A {
public:
int a = 0x1;
virtual void vfunc() { };
};
class B : virtual public A{
public:
int b = 0x2;
virtual void vfunc() { };
};
class C : virtual public A{
public:
int c = 0x3;
};
class D : public B, public C {
public:
int d = 0x4;
};
void mainn() {
D d;
// 情况一:使用这句时,无需 this adjustment,因为 B 的布局空间起始地址恰好是 D 布局空间起始地址
// 可直接调到 B::vfunc,因为 B 类型本身就实现了 vfunc,所以 B 的 vptr 这里是直接指向 vfunc,直接
// 调用 d->vptr 所指向的函数指针即可,十分高效,不过还是会略次于普通成员函数的调用效率,毕竟间接
B *dptr = &d;
// 情况二:可以试下把 B 中的 vfunc 屏蔽掉,this adjustment 要花费更很多指令,因为要定位到 A
// 的 virtual table 才能从中找到 vfunc 的实现
// 情况三:使用这句时,this adjustment 要花费更更很多指令
// C *dptr = &d;
dptr->vfunc();
}
NOTE:关于 thunk 和 this adjustment 的内容参考了 Itanium C++ ABI 以及 clang::ThunkInfo Struct Reference。结合上面两个文档,Clang 编译器在 iOS 平台应该使用的是 Itanium C++ ABI 构建,推测依据是下面这段 Clang 编译器源码:
union VirtualAdjustment的成员,不是Itanium就是Microsoft,总不能是Microsoft吧。
union VirtualAdjustment {
struct {
int64_t VCallOffsetOffset;
} Itanium;
struct {
int32_t VtordispOffset;
int32_t VBPtrOffset;
int32_t VBOffsetOffset;
} Microsoft;
...
} Virtual;
4.3 指向成员函数的指针
用对象的指针使用箭头操作符->调用“指向 virtual 成员函数的指针”时,也是需要考虑多态,即同样需要考虑,在运行时根据对象的指针所指向对象的真实类型,来决议到底是调用哪个 virtual 函数的实现。例如下面的示例代码,fptr0函数指针并不是指向固定的函数,而是需要运行时判断调用该函数指针的对象的具体类型,决定具体调用哪个函数,下面的示例代码中(aptr->*fptr0)()调用到的目标函数是B的vfunc2,而且决议其具体调用哪个函数并不是在编译时,而是在运行时。
class A {
public:
int a = 0x1;
virtual void vfunc1() { };
virtual void vfunc2() { };
};
class B : virtual public A{
public:
int b = 0x2;
void vfunc1() { };
void vfunc2() { };
};
void main() {
void(A::*fptr0)(void) = &A::vfunc2;
A *aptr = new B;
(aptr->*fptr0)();
}
再看下上面main对应的汇编,看看 Clang 编译器在编译期间确立了什么数据,并如何通过编译器数据,在运行时定位到函数的正确位置。注释中new B构建的对象记为b。可以直接关注到下面标注为重点的代码:
- 重点1:在上面的场景下,编译器将指向 virtual 函数
A::func2的指针aptr的值标记为0x9; - 重点2:如果函数指针的值为奇数,说明该函数指针指向 virtual 函数,否则指向普通函数,这是为什么编译器标记
aptr的值为0x9的原因之一; - 重点3:将指向 virtual 函数的函数指针减
0x1,得到 virtual 函数相对于经过 this adjustment 后的this指针(也就是对象的编译时类型对应的vptr)的偏移量,由于vfunc2在A的成员函数列表排第二,所以需要偏移 8 个字节,这是为什么编译器标记aptr的值为0x9的原因之二。因此如果是void(A::*fptr0)(void) = &A::vfunc1,则aptr的值为0x1; - 重点4:如果函数指针指向非 virtual 函数,则函数指针的值直接可用;
综上所述,Clang 编译器将指向 virtual 函数的函数指针的值设定为“该 virtual 函数在 this adjustment 后的this指针所指向的 virtual table 中的偏移量”加 1,加 1 的目的是将其转化为奇数,使其与非 virtual 函数指针(因为是内存地址且按 8 字节对齐,必然是偶数)区分开来。
0x1083b7cf0 <+0>: pushq %rbp
0x1083b7cf1 <+1>: movq %rsp, %rbp
0x1083b7cf4 <+4>: subq $0x40, %rsp
0x1083b7cf8 <+8>: movq $0x0, -0x8(%rbp) ; -0x8(%rbp) == 0
0x1083b7d00 <+16>: movq $0x9, -0x10(%rbp) ; (重点1)-0x10(%rbp) == 9,对应代码:fptr0 = &A::vfunc2
0x1083b7d08 <+24>: movl $0x20, %edi ; 指定堆分配32个字节(B类型实例的大小)
0x1083b7d0d <+29>: callq 0x1083b8446 ; symbol stub for: operator new(unsigned long)
0x1083b7d12 <+34>: movq %rax, %rdi ; $rdi == &b
0x1083b7d15 <+37>: movq %rax, -0x20(%rbp) ; -0x20(%rbp) == &b
0x1083b7d19 <+41>: callq 0x1083b7db0 ; B::B at ViewController.mm:26
0x1083b7d1e <+46>: xorl %ecx, %ecx ; 清零ecx寄存器
0x1083b7d20 <+48>: movl %ecx, %eax ; 清零eax寄存器
0x1083b7d22 <+50>: movq -0x20(%rbp), %rdx ; $rdx == &b
0x1083b7d26 <+54>: cmpq $0x0, %rdx ; 如果前面new失败了才会执行下面的je
0x1083b7d2a <+58>: movq %rax, -0x28(%rbp) ; B对象地址保存到-0x28(%rbp)
0x1083b7d2e <+62>: je 0x1083b7d46 ; <+86> at ViewController.mm
0x1083b7d34 <+68>: movq -0x20(%rbp), %rax ; $rax == &b
0x1083b7d38 <+72>: movq (%rax), %rcx ; $rcx == b->vptr
0x1083b7d3b <+75>: movq -0x18(%rcx), %rcx ; $rcx == (b->avptr在b的对象布局中的偏移量)
0x1083b7d3f <+79>: addq %rcx, %rax ; $rax == &(b->avptr)
0x1083b7d42 <+82>: movq %rax, -0x28(%rbp) ; -0x28(%rbp) == &(b->avptr)
0x1083b7d46 <+86>: movq -0x28(%rbp), %rax ; $rax == &(b->avptr)
0x1083b7d4a <+90>: movq %rax, -0x18(%rbp) ; -0x18(%rbp) == &(b->avptr)
0x1083b7d4e <+94>: movq -0x18(%rbp), %rax ; $rax == &(b->avptr)
0x1083b7d52 <+98>: movq -0x10(%rbp), %rcx ; $rcx == 0x9
0x1083b7d56 <+102>: movq -0x8(%rbp), %rdx ; $rdx == 0x0
0x1083b7d5a <+106>: addq %rdx, %rax ; $rax == &(b->avptr)
0x1083b7d5d <+109>: movq %rcx, %rdx ; $rdx == 0x9
0x1083b7d60 <+112>: andq $0x1, %rdx ; (重点2)$rdx == 0x1
0x1083b7d67 <+119>: cmpq $0x0, %rdx ; 由于rdx != 0所以不执行下面的je跳转
0x1083b7d6b <+123>: movq %rcx, -0x30(%rbp) ; -0x30(%rbp) == 0x9
0x1083b7d6f <+127>: movq %rax, -0x38(%rbp) ; -0x38(%rbp) == &(b->avptr)
0x1083b7d73 <+131>: je 0x1083b7d98 ; <+168> at ViewController.mm
0x1083b7d79 <+137>: movq -0x38(%rbp), %rax ; $rax == &(b->avptr)
0x1083b7d7d <+141>: movq (%rax), %rcx ; $rcx == b->avptr
0x1083b7d80 <+144>: movq -0x30(%rbp), %rdx ; $rdx == 0x9
0x1083b7d84 <+148>: subq $0x1, %rdx ; (重点3)$rdx == 0x9-0x1 = 0x8
0x1083b7d8b <+155>: movq (%rcx,%rdx), %rcx ; $rcx == (b->avptr + 0x8)
0x1083b7d8f <+159>: movq %rcx, -0x40(%rbp) ; -0x40(%rbp) == (b->avptr + 0x8)
0x1083b7d93 <+163>: jmp 0x1083b7da0 ; <+176> at ViewController.mm
0x1083b7d98 <+168>: movq -0x30(%rbp), %rax ; (重点4)上面的je代码的目标跳转位置。-0x30(%rbp) == fptr0
0x1083b7d9c <+172>: movq %rax, -0x40(%rbp) ; -0x40(%rbp) == fptr0
0x1083b7da0 <+176>: movq -0x40(%rbp), %rax ; 上面jmp跳到这里。$rax == (b->avptr + 0x8)
0x1083b7da4 <+180>: movq -0x38(%rbp), %rdi ; $rdi = &(b->avptr),传参this指针
0x1083b7da8 <+184>: callq *%rax ; 调用$rax所保存的函数指针
0x1083b7daa <+186>: addq $0x40, %rsp
0x1083b7dae <+190>: popq %rbp
0x1083b7daf <+191>: retq
如果将(aptr->*fptr0)();替换为aptr->vfunc2();,则编译的代码,立马减少为 30 行(上面是 50 行),所以通过函数指针来访问 virtual 函数的效率是肯定会低于直接访问函数的。不过应该也不会低很多,毕竟只是多了一些简单的内存寻址操作。
如果是使用以下代码,vfunc1和vfunc2都声明为非 virtual 成员函数呢,main函数的汇编代码函数会不会减少?是不会的。因为main中的大部分代码就是 Clang 处理函数指针的必要指令,和具体访问的是否是 virtual 函数指针是不相关的。经过以下修改后,最大的区别是fptr0 = &A::vfunc2对应的指令变为leaq 0xc1(%rip), %rax以及movq %rax, -0x10(%rbp),因为编译器在编译时知晓A::vfunc2是非 virtual 函数,所以直接指向A::vfunc2的地址。
class A {
public:
int a = 0x1;
void vfunc1() { };
void vfunc2() { };
};
class B : virtual public A{
public:
int b = 0x2;
};
void main() {
void(A::*fptr0)(void) = &A::vfunc2;
A *aptr = new B;
(aptr->*fptr0)();
}
NOTE:引入多重继承的话,应该也不会增加
main的指令复杂度,因为无论继承结构多复杂,编译器在编译期间都已经处理了这些复杂性,并将其转化为内存寻址的静态数据写入汇编代码。运行时只要去寻址、去捞数据就可以了。
4.4 小结
本节主要内容是调试了 Clang 编译器是如何处理调用成员函数、成员函数指针的代码的。普通成员函数的调用效率和非成员函数的调用效率是对等的,编译器会给成员函数增加一个this指针参数,指向对象自身,并结合定义成员函数的类的类名、函数的参数列表类型,通过 mangling 在全局范围内区分所有成员函数。
指向对象的指针通过箭头操作符->调用成员函数时,传入的this指针有时需要作 this adjustment 处理,做 this adjustment 目的在于定位到“定义成员函数的类在对象内存布局中对应的内存地址”。但是对于调用 virtual 成员函数而言,传入this指针是需要指向对象本身(因为基类定义的 virtual 函数可以被衍生类 override,并且 override 函数体内有时需要访问衍生类自身定义的成员变量或成员函数),因此编译器需要有额外的预处理(例如把 this adjustment 还原回去),此时采用的 virtual thunk 的方式,将预处理和调用目标 virtual 函数的代码转为机器指令保存到 virtual thunk,同样通过callq调用。
最后,C++ 编译器调用函数指针所指向的成员函数,对比直接调用成员函数,是有额外开销的。根本原因在于需要考虑多态的情形。Clang 编译器在编译时将指向 virtual 函数的函数指针的值设定为“该 virtual 函数在 this adjustment 后的this指针所指向的 virtual table 中的偏移量”加 1,加 1 是为了将其标记为奇数从而区分普通函数指针,有偏移量就自然可以定位到指针实际指向的 virtual table slot(内容是 virtual 函数地址或 virtual thunk)。
第五章:构造析构拷贝语义学
关于 C++ 编译的系统化知识点,可以说在书中前四章基本已经介绍完毕了,从第五章开始,基本是一些碎片化的知识点的集合,以及对前面内容的细节补充。这章开头说的几个点就很碎片:
- 没有必要在基类提供实现的函数声明为纯虚函数,例如
virtual void vfunc() = 0;; - 对于 virtual 函数是否需要声明为 const 的问题,因为无法保证衍生类实现该函数是否需要修改对象的成员,所以比较直接的方法是不使用 const;
后面的内容基本是这种形式,第五六七章也不做小结。
5.1 构造语义学
下面这句代码到底包含了哪些操作:
- 递归调用基类构造函数;
- 递归调用虚拟继承基类构造函数;
- 初始化
vptr; - 执行成员变量初值初始化操作;
- 执行构造函数的初始化列表所指定的操作;
- 如果还有 class object 类型的成员未初始化,执行该成员对应的默认构造函数;
- 执行默认构造函数体中的代码;
T t;
NOTE:默认情况下,对象中的 class object 类型的成员初值不是
0x0,而是会调用其默认构造函数给它初始化,前面为什么推荐在构造函数初始化列表中初始化 class object 成员,就是这个原因,因为放函数体里面的话,就存在冗余操作了。
关于对象中的各级vptr指针的初始化,其执行时间点是在构造函数初始化列表执行之前。所以在构造函数初始化列表中调用虚函数初始化某个成员是合法的。
5.2 复制语义学
设计 class 时,指定“将 class 的一个对象赋值给另一个对象”的操作通常有三种实现方式:
- 什么都不作,直接使用默认复制构造函数策略;
- 自定义
operator=赋值运算符; - 禁止将 class 的一个对象赋值给另一个对象;
如果需要禁止对象赋值行为,可以将operator=声明为private并且不提供该函数的定义。如果不需要禁止对象赋值行为,则应当优先使用默认复制构造函数策略,例如赋值行为的实现只是简单的逐成员赋值。仅当默认复制构造函数策略不安全或者不正确时,才选择自定义operator=赋值运算符。文中以 bitwise copy semantic 为例阐述了其中的原因,但是 Clang 本身不支持 bitwise copy semantic,这部分就此略过。
5.3 析构语义学
如果类没有定义析构函数,则只有在类的成员变量、或者基类拥有析构函数的情况下,编译器才会合成该类的析构函数(注意不需要析构函数时压根就不合成)。
NOTE:关于析构函数为什么需要声明为 virtual 可以参考这篇文章:C++中基类的析构函数为什么要用virtual虚析构函数。
最后以下代码delete t时,析构函数将会执行:
T *t = new T;
...
delete t;
析构函数执行时会触发以下操作:
- 析构函数函数体首先执行(由于传入
delete的是指针类型所以需要注意考虑多态); - 如果对象中若干成员变量的类定义了析构函数,则会以成员变量声明顺序的相反顺序被依次调用;
- 如果对象包含
vptr,则需要重新设定,指向适当的基类的 virtual table; - 如果有任何直接的非虚拟继承基类定义了析构函数,则会以其声明顺序的相反顺序被依次调用;
- 如果有任何直接的虚拟继承基类定义了析构函数,则会以其声明顺序的相反顺序被依次调用;
对象析构过程中,对象的vptr会逐级向上层基类退化。例如,A是根类不继承任何类型,B继承A,C继承B,则C类型的对象析构时,对象会先退化为B,vptr指向B类的 virtual table,然后退化为A,vptr指向A类的 virtual table,最后A析构函数调用完毕则整个对象完成析构。
可以用下面的代码简单验证一下退化过程。在main函数中delete b时,会首先触发编译器合成的B类析构函数,然后必然会触发基类的~A()析构函数,此时在基类中调用vfunc1()(等价于this->vfunc1())触发的是断点1,因此调用触发~A()时,对象已经退化为A类型,当然vptr也已调整为指向A的 virtual table。
class A {
public:
int a = 0x1;
virtual void vfunc1() {
// 断点1
};
virtual ~A() {
vfunc1();
};
};
class B : virtual public A{
public:
int b = 0x2;
void vfunc1() {
// 断点2
};
};
void main() {
B *b = new B;
delete b;
}
第六章:运行时语义学
本章主要介绍了 C++ 编译器为保障程序正确运行而需要加入的一些额外操作。
6.1 对象构造和析构
以下示例代码为例,看看编译器对作为局部变量的对象的处理:
class A {
public:
int a = 0x1;
virtual void vfunc1() { };
virtual ~A() { };
};
class B : virtual public A{
public:
int b = 0x2;
void vfunc1() { };
};
void main() {
B b;
}
由于虚拟继承的基类A声明并定义了析构函数,所以编译器会为B自动合成析构函数~B()。从以下main函数的汇编代码中不难发现:
- 编译器会在声明
b变量之后,插入调用默认构造函数B()的代码; - 并且在退出变量
b的作用域之前,插入调用析构函数~B()的代码;
0x10df3bcb0 <+0>: pushq %rbp
0x10df3bcb1 <+1>: movq %rsp, %rbp
0x10df3bcb4 <+4>: subq $0x20, %rsp
0x10df3bcb8 <+8>: leaq -0x20(%rbp), %rdi
0x10df3bcbc <+12>: callq 0x10df3bcd0 ; B::B at ViewController.mm:26
0x10df3bcc1 <+17>: leaq -0x20(%rbp), %rdi
0x10df3bcc5 <+21>: callq 0x10df3bd30 ; B::~B at ViewController.mm:26
0x10df3bcca <+26>: addq $0x20, %rsp
0x10df3bcce <+30>: popq %rbp
0x10df3bccf <+31>: retq
6.1.1 全局变量
C++ 对全局对象的处理,在注释处下断点,编译运行:
class A {
public:
int a = 0x1;
virtual void vfunc1() { };
virtual ~A() { };
};
class B : virtual public A{
public:
int b = 0x2;
void vfunc1() { };
};
A a;
B b; // 此处下断点
void main() {
B x = b;
}
断点处对应的汇编代码如下:并使用bt指令打印此时的栈帧。从调试区可以观察到在当前平台(iOS Simulator)下,全局变量对象初始化是在程序的二进制文件载入时完成初始化的:
- 对每个定义了全局变量对象文件源/头文件,会生成类似
_GLOBAL__sub_I_ViewController.mm的函数,用于初始化ViewController.mm文件内定义的所有全局变量; - 对每个全局变量对象,则会生成类似
__cxx_global_var_init.1的函数,用于初始化单个全局变量对象,显然该函数会被声明该变量的文件所对应的_GLOBAL__xxx函数调用;
具体看__cxx_global_var_init.1做了哪些操作:
- 调用构造函数
B()初始化全局变量b,可以观察到b的地址是来自 data segment(通过$rip定位的数据通常是来自 data segment 的数据); - 调用
__cxa_atexit注册全局变量b的析构函数~B(),在程序退出时会触发;
总之,C++ 编译器会保证,在程序正式运行前,也可以说是在main函数执行前,完成所有全局变量对象初始化,并在程序退出前,析构所有全局变量对象。
CPPModelDemo`__cxx_global_var_init.1:
0x1024dde90 <+0>: pushq %rbp
0x1024dde91 <+1>: movq %rsp, %rbp
-> 0x1024dde94 <+4>: leaq 0x77bd(%rip), %rdi ; b
0x1024dde9b <+11>: callq 0x1024ddb70 ; B::B at ViewController.mm:24
0x1024ddea0 <+16>: leaq -0x2d7(%rip), %rax ; B::~B at ViewController.mm:24
0x1024ddea7 <+23>: leaq 0x77aa(%rip), %rcx ; b
0x1024ddeae <+30>: movq %rax, %rdi
0x1024ddeb1 <+33>: movq %rcx, %rsi
0x1024ddeb4 <+36>: leaq -0x1ebb(%rip), %rdx ; _mh_execute_header
0x1024ddebb <+43>: callq 0x1024de43c ; symbol stub for: __cxa_atexit
0x1024ddec0 <+48>: popq %rbp
0x1024ddec1 <+49>: retq
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 2.1
* frame #0: 0x00000001024dde94 CPPModelDemo`__cxx_global_var_init.1 at ViewController.mm:32:3
frame #1: 0x00000001024ddeeb CPPModelDemo`_GLOBAL__sub_I_ViewController.mm at ViewController.mm:0
frame #2: 0x0000000102509c95 dyld_sim`ImageLoaderMachO::doModInitFunctions(ImageLoader::LinkContext const&) + 513
frame #3: 0x000000010250a08a dyld_sim`ImageLoaderMachO::doInitialization(ImageLoader::LinkContext const&) + 40
frame #4: 0x0000000102504bb7 dyld_sim`ImageLoader::recursiveInitialization(ImageLoader::LinkContext const&, unsigned int, char const*, ImageLoader::InitializerTimingList&, ImageLoader::UninitedUpwards&) + 455
frame #5: 0x0000000102502ec7 dyld_sim`ImageLoader::processInitializers(ImageLoader::LinkContext const&, unsigned int, ImageLoader::InitializerTimingList&, ImageLoader::UninitedUpwards&) + 191
frame #6: 0x0000000102502f68 dyld_sim`ImageLoader::runInitializers(ImageLoader::LinkContext const&, ImageLoader::InitializerTimingList&) + 82
frame #7: 0x00000001024f626b dyld_sim`dyld::initializeMainExecutable() + 199
frame #8: 0x00000001024faf56 dyld_sim`dyld::_main(macho_header const*, unsigned long, int, char const**, char const**, char const**, unsigned long*) + 4789
frame #9: 0x00000001024f51c2 dyld_sim`start_sim + 122
frame #10: 0x000000010481f57a dyld`dyld::useSimulatorDyld(int, macho_header const*, char const*, int, char const**, char const**, char const**, unsigned long*, unsigned long*) + 2093
frame #11: 0x000000010481cdf3 dyld`dyld::_main(macho_header const*, unsigned long, int, char const**, char const**, char const**, unsigned long*) + 1199
frame #12: 0x000000010481722b dyld`dyldbootstrap::start(dyld3::MachOLoaded const*, int, char const**, dyld3::MachOLoaded const*, unsigned long*) + 457
frame #13: 0x0000000104817025 dyld`_dyld_start + 37
NOTE:调试观察到的程序表象和书中所述可能又不太一致的地方(毕竟各种编译器差异性是 C++ 的永恒槽点),不过所幸本质是一样的。另外上面有反复强调,是对象类型的成员变量,如果不是对象类型例如
int、char之类的全局变量,是不需要生成__cxx_global_var_init函数的来初始化的,编译期间就可以完成初始化。
6.1.2 局部静态变量
将上面调试代码的main函数替换为以下代码编译运行:
void main() {
static B stb;
}
对应的汇编代码如下,代码有点多,关注到cmpb $0x0, 0x7a49(%rip),该指令用于判断stb静态变量是否为空,非空则通过jne 0x10a27dcb5直接跳转到几乎函数末端,明显0x10a27dc5f指令行和0x10a27dcb5就是用于stb初始化,空才需要初始化。再注意到__cxa_guard_acquire和__cxa_guard_release,这两句应该是给stb初始化操作加锁,为了防止重入。那么剩下的代码就比较明显了,和全局变量的处理差不多,先调用构造函数B()初始化stb,然后调用__cxa_atexit注册stb析构函数,在程序退出前触发该析构函数。
CPPModelDemo`mainn:
0x10a27dc50 <+0>: pushq %rbp
0x10a27dc51 <+1>: movq %rsp, %rbp
0x10a27dc54 <+4>: subq $0x10, %rsp
0x10a27dc58 <+8>: cmpb $0x0, 0x7a49(%rip) ; mainn()::stb + 31
0x10a27dc5f <+15>: jne 0x10a27dcb5 ; <+101> at ViewController.mm:36:1
0x10a27dc65 <+21>: leaq 0x7a3c(%rip), %rdi ; guard variable for mainn()::stb
0x10a27dc6c <+28>: callq 0x10a27e422 ; symbol stub for: __cxa_guard_acquire
0x10a27dc71 <+33>: cmpl $0x0, %eax
0x10a27dc74 <+36>: je 0x10a27dcb5 ; <+101> at ViewController.mm:36:1
-> 0x10a27dc7a <+42>: leaq 0x7a07(%rip), %rdi ; mainn()::stb
0x10a27dc81 <+49>: callq 0x10a27dbb0 ; B::B at ViewController.mm:24
0x10a27dc86 <+54>: leaq -0x7d(%rip), %rax ; B::~B at ViewController.mm:24
0x10a27dc8d <+61>: leaq 0x79f4(%rip), %rcx ; mainn()::stb
0x10a27dc94 <+68>: movq %rax, %rdi
0x10a27dc97 <+71>: movq %rcx, %rsi
0x10a27dc9a <+74>: leaq -0x1ca1(%rip), %rdx ; _mh_execute_header
0x10a27dca1 <+81>: callq 0x10a27e41c ; symbol stub for: __cxa_atexit
0x10a27dca6 <+86>: leaq 0x79fb(%rip), %rdi ; guard variable for mainn()::stb
0x10a27dcad <+93>: movl %eax, -0x4(%rbp)
0x10a27dcb0 <+96>: callq 0x10a27e428 ; symbol stub for: __cxa_guard_release
0x10a27dcb5 <+101>: addq $0x10, %rsp
0x10a27dcb9 <+105>: popq %rbp
0x10a27dcba <+106>: retq
6.1.3 对象数组
将上面调试代码的main函数替换为以下代码编译运行:
void main() {
B barray[10];
}
会不会以为,这main函数的汇编代码应该很短?现实很爆炸,出来 50 行汇编指令。代码有点多,不过核心操作是比较明显的:
- 构造时:在栈上连续分配 10 块
B对象所需内存空间,并分别调用构造函数B(); - 析构时:对栈上连续分配的 10 个
B对象,依次执行析构函数~B();
CPPModelDemo`mainn:
0x1013e0bd0 <+0>: pushq %rbp
0x1013e0bd1 <+1>: movq %rsp, %rbp
0x1013e0bd4 <+4>: subq $0x180, %rsp ; imm = 0x180
0x1013e0bdb <+11>: leaq -0x150(%rbp), %rax
0x1013e0be2 <+18>: movq 0x242f(%rip), %rcx ; (void *)0x00007fff8000a0e0: __stack_chk_guard
0x1013e0be9 <+25>: movq (%rcx), %rcx
0x1013e0bec <+28>: movq %rcx, -0x8(%rbp)
-> 0x1013e0bf0 <+32>: movq %rax, %rcx
0x1013e0bf3 <+35>: addq $0x140, %rcx ; imm = 0x140
0x1013e0bfa <+42>: movq %rcx, -0x158(%rbp)
0x1013e0c01 <+49>: movq %rax, -0x160(%rbp)
0x1013e0c08 <+56>: movq -0x160(%rbp), %rax
0x1013e0c0f <+63>: movq %rax, %rdi
0x1013e0c12 <+66>: movq %rax, -0x168(%rbp)
0x1013e0c19 <+73>: callq 0x1013e0b30 ; B::B at ViewController.mm:24
0x1013e0c1e <+78>: movq -0x168(%rbp), %rax
0x1013e0c25 <+85>: addq $0x20, %rax
0x1013e0c2b <+91>: movq -0x158(%rbp), %rcx
0x1013e0c32 <+98>: cmpq %rcx, %rax
0x1013e0c35 <+101>: movq %rax, -0x160(%rbp)
0x1013e0c3c <+108>: jne 0x1013e0c08 ; <+56> at ViewController.mm
0x1013e0c42 <+114>: leaq -0x150(%rbp), %rax
0x1013e0c49 <+121>: movq %rax, %rcx
0x1013e0c4c <+124>: addq $0x140, %rcx ; imm = 0x140
0x1013e0c53 <+131>: movq %rax, -0x170(%rbp)
0x1013e0c5a <+138>: movq %rcx, -0x178(%rbp)
0x1013e0c61 <+145>: movq -0x178(%rbp), %rax
0x1013e0c68 <+152>: addq $-0x20, %rax
0x1013e0c6e <+158>: movq %rax, %rdi
0x1013e0c71 <+161>: movq %rax, -0x180(%rbp)
0x1013e0c78 <+168>: callq 0x1013e0b90 ; B::~B at ViewController.mm:24
0x1013e0c7d <+173>: movq -0x180(%rbp), %rax
0x1013e0c84 <+180>: movq -0x170(%rbp), %rcx
0x1013e0c8b <+187>: cmpq %rcx, %rax
0x1013e0c8e <+190>: movq %rax, -0x178(%rbp)
0x1013e0c95 <+197>: jne 0x1013e0c61 ; <+145> at ViewController.mm
0x1013e0c9b <+203>: movq 0x2376(%rip), %rax ; (void *)0x00007fff8000a0e0: __stack_chk_guard
0x1013e0ca2 <+210>: movq (%rax), %rax
0x1013e0ca5 <+213>: movq -0x8(%rbp), %rcx
0x1013e0ca9 <+217>: cmpq %rcx, %rax
0x1013e0cac <+220>: jne 0x1013e0cbb ; <+235> at ViewController.mm
0x1013e0cb2 <+226>: addq $0x180, %rsp ; imm = 0x180
0x1013e0cb9 <+233>: popq %rbp
0x1013e0cba <+234>: retq
0x1013e0cbb <+235>: callq 0x1013e1432 ; symbol stub for: __stack_chk_fail
0x1013e0cc0 <+240>: ud2
NOTE:关于上面汇编代码中的
__stack_chk_guard,可以参考文章:GCC中的编译器堆栈保护技术。反正意思就是B barray[10];的复杂度要比想象中大得多。
6.3 new和delete运算符
关于new和delete运算符:
new运算符是用于在堆上分配内存,返回指向分配内存起始地址的指针;delete运算符是用于释放在堆上分配的内存;
需要注意,在堆上分配/释放数组时,通常是new []和delete []配套使用。例如:
void main() {
B *barray = new B[10];
delete [] barray;
}
NOTE:Clang 编译器貌似不支持书中所说的 Placement Operator new 语义,略过。
6.3 临时对象
临时对象是编译器为了实现代码所指定效果而引入的,不会在代码中显示声明的,临时性的对象。以下简单的调试代码为例,调试代码一和调试代码二为了实现x + y表达式都需要引入临时对象来保存该表达式的结果,但是两者的执行效率是不一样的。
class A {
public:
int a = 0x1;
virtual void vfunc1() { };
A operator+(const A &y) {
A result;
result.a = a + y.a;
return result;
}
};
// 调试代码一
void main() {
A x, y;
A sum;
sum = x + y;
}
// 调试代码二
void main() {
A x, y;
A sum = x + y;
}
由于编译器 NVR 优化的存在,A operator+(const A &y)实际是传入一个临时对象的栈地址用于保存返回的对象,先看调试代码一的汇编代码,关注到A::operator+果然通过leaq传入了三个内存地址,其中rdi寄存器就是返回对象的地址。此时-0x40(%rbp)就是用于保存调试代码中表达式x + y所对应的临时对象。
NOTE:为什么不直接把
sum变量的地址-0x30(%rbp)当成返回地址传进去?不得不说,在上面的调试代码中确实会得到正确的结果。但是放诸其他上下文则未必成立,最简单的理解是:编译器不能忽略sum = x + y中A::operator=可能存在的任何自定义逻辑。
CPPModelDemo`mainn:
0x101b35740 <+0>: pushq %rbp
0x101b35741 <+1>: movq %rsp, %rbp
0x101b35744 <+4>: subq $0x40, %rsp
0x101b35748 <+8>: leaq -0x10(%rbp), %rdi
0x101b3574c <+12>: callq 0x101b35790 ; A::A at ViewController.mm:18
0x101b35751 <+17>: leaq -0x20(%rbp), %rdi
0x101b35755 <+21>: callq 0x101b35790 ; A::A at ViewController.mm:18
0x101b3575a <+26>: leaq -0x30(%rbp), %rdi
0x101b3575e <+30>: callq 0x101b35790 ; A::A at ViewController.mm:18
-> 0x101b35763 <+35>: leaq -0x40(%rbp), %rdi
0x101b35767 <+39>: leaq -0x10(%rbp), %rsi
0x101b3576b <+43>: leaq -0x20(%rbp), %rdx
0x101b3576f <+47>: callq 0x101b357b0 ; A::operator+ at ViewController.mm:22
0x101b35774 <+52>: leaq -0x30(%rbp), %rdi
0x101b35778 <+56>: leaq -0x40(%rbp), %rsi
0x101b3577c <+60>: callq 0x101b35810 ; A::operator= at ViewController.mm:18
0x101b35781 <+65>: addq $0x40, %rsp
0x101b35785 <+69>: popq %rbp
0x101b35786 <+70>: retq
再看调试代码二的汇编代码。代码A sum = x + y任然是通过rdi寄存器传入返回对象地址,不过后续没有再调用A::operator=,因为对编译器而言,这里的=是对象初始化语义,不是A::operator=调用语义。因此调试代码二明显执行效率更高,根本原因是两者语义上区别。
NOTE:打个比方说明下上面所说的初始化语义。例如,编译器处理
A result = A()语句时,并不需要调用A()构造函数,然后再调用一次A::operator=复制构造函数,这应该是=的最典型的初始化语义。
CPPModelDemo`main:
0x103cab770 <+0>: pushq %rbp
0x103cab771 <+1>: movq %rsp, %rbp
0x103cab774 <+4>: subq $0x30, %rsp
0x103cab778 <+8>: leaq -0x10(%rbp), %rdi
0x103cab77c <+12>: callq 0x103cab7b0 ; A::A at ViewController.mm:18
0x103cab781 <+17>: leaq -0x20(%rbp), %rdi
0x103cab785 <+21>: callq 0x103cab7b0 ; A::A at ViewController.mm:18
-> 0x103cab78a <+26>: leaq -0x30(%rbp), %rdi
0x103cab78e <+30>: leaq -0x10(%rbp), %rsi
0x103cab792 <+34>: leaq -0x20(%rbp), %rdx
0x103cab796 <+38>: callq 0x103cab7d0 ; A::operator+ at ViewController.mm:22
0x103cab79b <+43>: addq $0x30, %rsp
0x103cab79f <+47>: popq %rbp
0x103cab7a0 <+48>: retq
那么编译器制定了在什么时机释放临时对象呢?通常情况下,在表达式所在的代码执行完成后就可以立即释放表达式计算过程中引入的临时性变量。例如,上面sum = x + y执行完成后就可以立即释放x + y表达式所引入的临时变量。但是有两种情况是例外:
- 用临时变量来声明对象。例如
A sum = x + y,表达式x + y生成的临时对象实际成为了sum,需要超出了sum的作用域才能释放; - 将临时变量绑定到引用。例如
const A &sumRef = x + y,如果立即释放x + y所生成的临时对象,则引用类型的sumRef则会失去意义;
最后一节是说 C++ 生成临时变量导致其对比 Fortran 语言,在性能上受到不少质疑。导致这个问题的原因一部分是来自,栈上的临时对象读写效率低的问题,文中说的反聚合优化是将对象的成员的部分成员变量直接放置到寄存器中操作以提高效率,不过一般编译器都不会把这个视为关键优化方向。
第七章:站在对象模型的尖端
7.1 模板
当编译器看到一个模板定义时,不会立刻生成模板对应的二进制代码,而是在看到使用模板的代码时,才正式生成,并且只生成其中一部分必要的代码,书中将生成模板二进制代码的过程称为 instanciate(实例化)。举个例子,某个模板类定义了 200 个成员函数,且代码中仅用到了其中 3 个函数,则编译器只会实例化这三个函数生成其二进制代码。之所以采用这种方式是因为:
- 时间和空间效率上的考虑。实例化操作本身具有编译时间成本,且编译成二进制代码自然占据二进制文件中的空间,因此具有空间成本;
- 非必要功能考虑。使用模板时必定是与某一具体类型绑定的,绑定 A 类型所需实例化的函数或类型,和绑定 B 类型所需实例化的函数或类型,并不是完全重合的;
更具体地,编译器可以选择在两个阶段处理模板实例化过程:
- 编译阶段:编译器在编译期间,看到使用模板的代码后,立即执行实例化操作;
- 链接阶段:编译器在链接期间,再回头统一处理所有实例化作;
模板的实例化是足够“懒”的一个过程,例如,编译器下面的代码也不会触发实例化操作,因为pt只是一个指针,编译器此时仍然可以在不知道Point<float>的对象布局以及成员函数组成的前提下构建pt指针:
Point<float> *pt = 0;
但是需要注意,以下代码会触发实例化操作,因为引用不可以指向空,编译器实际上会将其转化为代码下方注释的两行代码,也就是有Point<float>的构建过程:
Point<float> &pt = 0;
// 编译器会将上面的代码转换为以下两句代码
// Point<float> temp = Point<float>(0);
// Point<float> &pt = temp;
由于在模板绑定具体类型之前,编译器没有掌握模板的完整信息,所以编译器对模板定义中的错误检查通常会比非模板更加滞后。最明显的是一些 type checking 相关操作。
书中“Name Resolution within a Template”说到,解析模板代码中的名称需要考虑两种作用域:
- Scope of Template Declaration:模板声明作用域;
- Scope of Template Initiation:模板实例化作用域;
当调用非模板函数foo时与模板的具体绑定类型T没有关联时,使用模板声明作用域,所以调试代码一触发的是extern double foo(double)。当调用非模板函数foo时与模板的具体绑定类型T有关联时,使用模板实例化作用域,所以调试代码二触发的是extern int foo(int)。
但是,当使用 Clang 调试时,调试代码一和调试代码二都触发了extern double foo(double),也就是说都使用了模板声明作用域,无论如何调整代码的位置,放到不同的源文件/头文件都是同样的结果。所以 Clang 很可能不是用的这套规则。
extern double foo(double);
template <class T>
class ScopeRule {
private:
int _val;
T _member;
public:
void t_independent() {
_member = foo(_val);
}
T t_dependent() {
return foo(_member);
}
};
void main() {
extern int foo(int);
ScopeRule<int> sr0;
// 调试代码一:为什么说`t_independent`与`T`没有关联,是因为此时传入`foo`的参数`_val`的类型始终是`int`,
// 不随`T`的具体类型而改变,所以触发了模板声明作用域内的`extern double foo(double)`
sr0.t_independent();
// 调试代码二:为什么说`t_dependent`与`T`有关联,是因为此时传入`foo`的参数`_member`的类型,取决于`T`的
// 具体类型,这里是`int`,所以触发了模板实例化作用域内的`extern int foo(int)`
int x = sr0.t_dependent();
}
double foo(double) {
return 0.0;
}
int foo(int) {
return 100;
}
成员函数实例化是实现模板的最大的难点。但是书中这块内容实在写得太跳跃,且涉及的内容都存在编译器差异性,所以实在不太好理解,暂且跳过。不过关于编译器大致如何处理模板的过程可以总结一下:
- 编译过程不会产生任何模板实例化,仅将相关信息记录于源文件对应的 object files 中;
- 链接 object files 时,prelinker 检查 object files,搜集模板实例化相关信息;
- 对所有需要执行模板实例化的 object file,执行对应的模板实例化,并将实例化操作注册到 object file 对应的 ii 文件中;
- prelinker 重新编译所有“对应的 ii 文件曾发生改变”的源文件,直到所有实例化操作执行完成;
- 所有 object files 链接成可执行文件;
书中也提到这种方式需要有特定的处理流程作为支撑,否则会有 Bug,其中的因果逻辑也正是没看懂的地方。不过乍一看上面的流程,就给人效率不是很高的感觉。
NOTE:从 Class template instantiation 参考文档的内容看,编码过程中也是需要对模板实例化的概念有所了解。大致对应的是《C++ Primer》中的“模板特例化”的内容。
7.2 异常处理
已知每次函数调用时,调用栈都会推入被调函数而增加一层,函数返回时,调用栈会弹出当前函数而减少一层。C++ 上层的异常处理流程大致如下:
- 运行时抛出异常的当前函数抛出异常时,异常捕获机制需要产生对应的 Exception 对象实例;
- 然后使当前函数放弃控制权,放弃控制权意味着将当前函数从调用栈中弹出(将调用栈逐层弹出的操作叫做 unwinding the stack)。在函数被弹出来之前,函数的所有 local class object 的析构函数会被调用;
- 在逐层弹出的过程中,如果异常被当前层的
try-catch机制捕获(注意此时仍然需要自行处理 local class object 的必要析构操作)并通过 RTTI 判断异常的具体类型进到正确的catch逻辑分支中; - 在逐层弹出的过程中,如果异常没有被任何
try-catch机制捕获,则 C++ 异常处理机制会调用terminate()终止程序;
NOTE:详细的 Clang 异常捕获机制可以参考这篇文档:Itanium C++ ABI: Exception Handling。
7.3 RTTI(运行时类型识别)
运行时类型识别在某种程度上是为了异常处理存在,在捕获异常时,try-catch机制需要在运行时判断异常的具体类型以进入到对应的处理逻辑分支,所以需要 RTTI 的支持。
7.3.1 down cast
Down cast 是指从衍生类向基类的转型,有两种情况,对象的向下转型和指针的向下转型,对象的向下转型会触发对象构建的过程,指针向下转型则不会触发对象构建。对象的向下转型是为了压制多态性带来的额外调用损耗,指针向下转型则是表现 C++ 的多态性。
以下例程为例调试对象向下转型:
class A {
public:
int a = 0x1;
virtual void vfunc1() { };
};
class B : virtual public A{
public:
int b = 0x2;
void vfunc1() { };
};
void main() {
B b;
B *bptr = &b;
// 对象向下转型
A a = (A)b;
}
代码中b向下转型为a,但实际上a和b并不是同一个对象,它们两者的内存空间甚至都不存在任何重叠之处。调试过程如下:
(lldb) p/x &a
(A *) $3 = 0x00007ffeee45cfe0
(lldb) p/x &b
(B *) $4 = 0x00007ffeee45cff0
(lldb) p sizeof(a)
(unsigned long) $5 = 16
(lldb) p sizeof(b)
(unsigned long) $6 = 32
(lldb) x/2g 0x00007ffeee45cfe0
0x7ffeee45cfe0: 0x00000001017a70d8 0x0000000000000001
(lldb) x/4g 0x00007ffeee45cff0
0x7ffeee45cff0: 0x00000001017a7058 0x0000000000000002
0x7ffeee45d000: 0x00000001017a7078 0x0000000000000001
再稍微修改一下调试代码调试指针向下转型:
void main() {
B b;
B *bptr = &b;
// 指针向下转型
A *aptr = (A *)bptr;
}
代码虽然很短,但是包含的操作不少。其中A *aptr = (A *)bptr对应的汇编指令是以下面0x10526bd8d代码地址为起始,也就是movq -0x28(%rbp), %rdx。从注释中总结,A *aptr = (A *)bptr在汇编层面上是得到“对象b中A类型所对应的内存布局空间起始地址”,也就是注释中的b->avptr。那么问题来了:
- 问题一:
A *aptr = (A *)bptr执行后,源代码中的aptr的值就会是b->avptr的地址吗?显然不是。调试中p/x aptr和p/x bptr得到的必然是相同的值(b对象的地址),而在汇编层面上计算的“对象b中A类型所对应的内存布局空间起始地址”是用于当源代码使用诸如aptr->xxx访问成员或函数时,将这个地址直接作为对象地址使用; - 问题二:既然使用
aptr->xxx访问成员或函数时,将“对象b中A类型所对应的内存布局空间起始地址”直接作为对象地址使用,那么aptr->vfun1触发的会是A类型所定义的vfunc1吗?显然不是,如果是的话,那就与 C++ 虚拟机制相矛盾了。必须要明确b->avptr和A类型实例的vptr并不是指向相同的 virtual table,编译器虚拟机制在编译期间就已经处理好了。
0x10526bd70 <+0>: pushq %rbp
0x10526bd71 <+1>: movq %rsp, %rbp
0x10526bd74 <+4>: subq $0x40, %rsp
0x10526bd78 <+8>: leaq -0x20(%rbp), %rdi
0x10526bd7c <+12>: callq 0x10526bdd0 ; B::B at ViewController.mm:16
0x10526bd81 <+17>: xorl %eax, %eax
0x10526bd83 <+19>: movl %eax, %ecx
0x10526bd85 <+21>: leaq -0x20(%rbp), %rdx ;
0x10526bd89 <+25>: movq %rdx, -0x28(%rbp) ;
0x10526bd8d <+29>: movq -0x28(%rbp), %rdx ; 对象 b 的地址写入 $rdx
0x10526bd91 <+33>: cmpq $0x0, %rdx ;
0x10526bd95 <+37>: movq %rdx, -0x38(%rbp) ; 对象 b 的地址写入-0x38(%rbp)
0x10526bd99 <+41>: movq %rcx, -0x40(%rbp) ; -0x40(%rbp) 置为 0
0x10526bd9d <+45>: je 0x10526bdb5 ; <+69> at ViewController.mm
0x10526bda3 <+51>: movq -0x38(%rbp), %rax ; 对象 b 的地址写入 $rax
0x10526bda7 <+55>: movq (%rax), %rcx ; b->vptr 写入 $rcx
0x10526bdaa <+58>: movq -0x18(%rcx), %rcx ; b->avptr 在对象 b 中的偏移量写入 $rcx
0x10526bdae <+62>: addq %rcx, %rax ; 计算对象 b 中 b->avptr 的地址,并写入 $rax
0x10526bdb1 <+65>: movq %rax, -0x40(%rbp) ; b->avptr 的地址写入 -0x40(%rbp)
0x10526bdb5 <+69>: movq -0x40(%rbp), %rax ; b->avptr 的地址写入 $rax
0x10526bdb9 <+73>: movq %rax, -0x30(%rbp) ; b->avptr 的地址写入 -0x30(%rbp)
0x10526bdbd <+77>: addq $0x40, %rsp
0x10526bdc1 <+81>: popq %rbp
0x10526bdc2 <+82>: retq
那么向下转型有什么好处呢?可以在调试代码中加上这两句代码,第一句代码对应 3 条汇编指令,第二句代码对应 5 条汇编指令,因此向下转型可以降低多态/继承所引入的基类成员/非虚拟函数访问的间接度,或者更准确地说,将一些间接性在向下转型时提并前统一做了。
int xx = aptr->a;
int yy = bptr->a;
0x10370ad9d <+77>: movq -0x30(%rbp), %rax ; int xx = aptr->a
0x10370ada1 <+81>: movl 0x8(%rax), %ecx
0x10370ada4 <+84>: movl %ecx, -0x34(%rbp)
0x10370ada7 <+87>: movq -0x28(%rbp), %rax ; int yy = bptr->a
0x10370adab <+91>: movq (%rax), %rdx
0x10370adae <+94>: movq -0x18(%rdx), %rdx
0x10370adb2 <+98>: movl 0x8(%rax,%rdx), %ecx
0x10370adb6 <+102>: movl %ecx, -0x38(%rbp)
不过,对于虚拟函数访问的话,向下转型前后的复杂度是相同的。把上面两句调试代码替换为以下两句,并贴出对应的汇编代码,明显两者的指令是基本一样的,唯一的区别在于传入函数的this指针(对象地址)参数。
aptr->vfunc1();
bptr->vfunc1();
0x10e58cd9d <+77>: movq -0x30(%rbp), %rax ; aptr->vfunc1();
0x10e58cda1 <+81>: movq (%rax), %rcx
0x10e58cda4 <+84>: movq %rax, %rdi
0x10e58cda7 <+87>: callq *(%rcx)
0x10e58cda9 <+89>: movq -0x28(%rbp), %rax ; bptr->vfunc1();
0x10e58cdad <+93>: movq (%rax), %rcx
0x10e58cdb0 <+96>: movq %rax, %rdi
0x10e58cdb3 <+99>: callq *(%rcx)
7.3.2 dynamic cast
Dynamic cast 可以理解为支持安全地向上转型。前面向下转型是衍生类向基类转换,而 dynamic cast 可用于基类向衍生类转换。沿用 7.3.1 例程中的继承结构,调试代码替换为以下代码。显然下面的代码中aptr指向的真实对象是A类型,所以下面代码得到的bptr值为 0。C++ 运行时是如何知道aptr不能转化为B *类型的呢?是通过维护所有类型的 typeinfo 实现。
NOTE:不同于向下转型,dynamic cast 只支持对象指针或引用类型的转换。另外,对象指针或引用类型转换,无论是向下转换还是 dynamic cast,都不会改变指针所指向的对象的任何数据。
void main() {
A a;
A *aptr = &a;
B *bptr = dynamic_cast<B *>(aptr);
}
简单看一下对应的汇编代码,关注到0x10fdcddeb到0x10fdcde12这 9 行代码。显然运行时是通过调用 C++ runtime library 的__dynamic_cast函数,传入B类型和A类型的 typeinfo,最后将结果写入rax寄存器。其中,B类型和A类型的 typeinfo 保存在程序的 data segment 中,所以这里获取 typeinfo 代码和前面获取全局变量和静态变量的套路是一样的。
0x10fdcddc0 <+0>: pushq %rbp
0x10fdcddc1 <+1>: movq %rsp, %rbp
0x10fdcddc4 <+4>: subq $0x30, %rsp
0x10fdcddc8 <+8>: leaq -0x10(%rbp), %rdi
0x10fdcddcc <+12>: callq 0x10fdcde40 ; A::A at ViewController.mm:10
0x10fdcddd1 <+17>: leaq -0x10(%rbp), %rax
0x10fdcddd5 <+21>: movq %rax, -0x18(%rbp)
0x10fdcddd9 <+25>: movq -0x18(%rbp), %rax
0x10fdcdddd <+29>: cmpq $0x0, %rax
0x10fdcdde1 <+33>: movq %rax, -0x28(%rbp)
0x10fdcdde5 <+37>: je 0x10fdcde1b ; <+91> at ViewController.mm
0x10fdcddeb <+43>: movq 0x220e(%rip), %rax ; (void *)0x000000010fdd0030: typeinfo for A
0x10fdcddf2 <+50>: movq 0x220f(%rip), %rcx ; (void *)0x000000010fdd0040: typeinfo for B
0x10fdcddf9 <+57>: movq -0x28(%rbp), %rdx
0x10fdcddfd <+61>: movq %rdx, %rdi
0x10fdcde00 <+64>: movq %rax, %rsi
0x10fdcde03 <+67>: movq %rcx, %rdx
0x10fdcde06 <+70>: movq $-0x1, %rcx
0x10fdcde0d <+77>: callq 0x10fdce426 ; symbol stub for: __dynamic_cast
0x10fdcde12 <+82>: movq %rax, -0x30(%rbp)
0x10fdcde16 <+86>: jmp 0x10fdcde28 ; <+104> at ViewController.mm
0x10fdcde1b <+91>: xorl %eax, %eax
0x10fdcde1d <+93>: movl %eax, %ecx
0x10fdcde1f <+95>: movq %rcx, -0x30(%rbp)
0x10fdcde23 <+99>: jmp 0x10fdcde28 ; <+104> at ViewController.mm
0x10fdcde28 <+104>: movq -0x30(%rbp), %rax
0x10fdcde2c <+108>: movq %rax, -0x20(%rbp)
0x10fdcde30 <+112>: addq $0x30, %rsp
0x10fdcde34 <+116>: popq %rbp
0x10fdcde35 <+117>: retq
最后再简单看一下,调试过程中捞出的一些数据:
(lldb) po (void(*)(void))$rax
(CppDemo`typeinfo for A)
(lldb) po (void(*)(void))$rcx
(CppDemo`typeinfo for B)
(lldb) p/x $rax
(unsigned long) $4 = 0x000000010fdd0030
(lldb) p/x $rcx
(unsigned long) $5 = 0x000000010fdd0040
(lldb) x/2g 0x000000010fdd0030
0x10fdd0030: 0x0000000110ec00e8 0x000000010fdce4e4
(lldb) po (void(*)(void))0x0000000110ec00e8
(libc++abi.dylib`vtable for __cxxabiv1::__class_type_info + 16)
(lldb) po (void(*)(void))0x000000010fdce4e4
(CppDemo`typeinfo name for A)
(lldb) po (char *)0x000000010fdce4e4
"1A"
(lldb) x/2g 0x000000010fdd0040
0x10fdd0040: 0x0000000110ec01b8 0x000000010fdce4e7
(lldb) po (void(*)(void))0x0000000110ec01b8
(libc++abi.dylib`vtable for __cxxabiv1::__vmi_class_type_info + 16)
(lldb) po (void(*)(void))0x000000010fdce4e7
(CppDemo`typeinfo name for B)
(lldb) po (char *)0x000000010fdce4e7
"1B"
最后,如果使用 dynamic cast 来做向下转换,编译器会作何处理呢?别的编译器不知道,反正 Clang 编译器会将其当做向下转型处理,所以下面代码对应的汇编指令,和 7.3.1 最初的调试代码的汇编指令,是完全一样的,这样一来,C++ 运行时不需要走__dynamic_cast函数调用流程,效率自然会更高。
B b;
B *bptr = &b;
A *aptr = dynamic_cast<B *>(bptr);
本节主要通过代码调试分析汇编代码并结合书中内容探索 C++ 的类型转换机制,本文是通过 Clang 编译器调试,结论和书中并不一致。最后,A *a = static_cast<A *>(bptr)和A *a = (A *)bptr对 Clang 编译器而言,两者是等价的(汇编指令相同),所以 static cast 就是强制转换。
Think:Clang 编译器把 dynamic cast 处理向下转型,直接当成 static cast 安全吗?推测看,由于向上转型的唯一通道是 dynamic cast,dynamic cast 无法转型成功则转为 0,而 0 可以转换为任何类型的指针,而且编译器可以阻断以强制转换的语法进行向上转型,例如
B *baptr = (B *)&a必触发编译错误,因此把 dynamic cast 处理向下转型应该是安全的。但是转换时安全并不意味着使用时安全,因为值为 0 的指针使用->运算符是必然引发崩溃的,所以在使用 dynamic cast 向上转换时,要尤其谨慎。
7.3.3 引用和指针
指针和引用在本质上都是对象的内存地址。指针在使用上更加灵活,可以在任意时候绑定到任何对象,甚至可以置为 0,指针声明时可以初始化也可以不初始化。引用则限定条件更多,引用声明时必须初始化且不可以初始化为 0,引用一经声明就不可以绑定到其他对象。或者更准确地说,引用的生命周期内,总是与固定的一块内存绑定。不正经地说,指针就像渣男,随时都可以换对象。
引用和指针都可以通过强制转换语法、static cast、dynamic cast 来做类型转换。分析下面一段看起来挺别扭的代码。首先代码执行完成后,aptr和aref还会指向真实的A对象吗?是的,下面代码是直接操作a对象内存空间,所以aptr和aref前前后后都是对象a的地址。那么*aptr = B()和aref = B()到底做了什么事情呢?首先需要要明确=在这里的语义是复制构造函数,所以这两句代码的本质是:1、构建一个B类型的临时对象;2、调用A类型的赋值构造函数,将临时对象的数据拷贝到a对象。因此,后续无论是aptr->vfunc1()还是aref.vfunc1()实际调用到的都是A类所定义的vfun1。
void main() {
A a;
A &aref = a;
A *aptr = &a;
*aptr = B();
aref = B();
}
由于引用不可以初始化为 0,也不可以赋值为 0,所以引用 dynamic cast 时,如果返回的是 0,则会在运行时触发libc++abi.dylib: terminating with uncaught exception of type std::bad_cast: std::bad_cast异常,注意是运行时触发,编译时是检查不出来的。
7.3.4 typeid运算符
前面介绍异常时提到 C++ 运行时需要有机制支持获取对象具体类型,typeid就是<typeinfo>公开的用于获取对象具体类型的运算符。使用如下:
#include <typeinfo>
...
void main() {
A a;
A *aptr = &a;
const char *aptrName = typeid(aptr).name();
}
对应汇编代码如下。所以编译器处理typeid运算符的流程是:
- 从 data segment 中获取类型对应的
type_info对象; - 根据语义调用
type_info的对应函数返回目标信息;
0x10078cde0 <+0>: pushq %rbp
0x10078cde1 <+1>: movq %rsp, %rbp
0x10078cde4 <+4>: subq $0x20, %rsp
0x10078cde8 <+8>: leaq -0x10(%rbp), %rdi
0x10078cdec <+12>: callq 0x10078ce20 ; A::A at ViewController.mm:11
0x10078cdf1 <+17>: movq 0x2208(%rip), %rax ; (void *)0x000000010078f038: typeinfo for A*
0x10078cdf8 <+24>: leaq -0x10(%rbp), %rcx
0x10078cdfc <+28>: movq %rcx, -0x18(%rbp)
0x10078ce00 <+32>: movq %rax, %rdi
0x10078ce03 <+35>: callq 0x10078ce40 ; std::type_info::name at typeinfo:296
0x10078ce08 <+40>: movq %rax, -0x20(%rbp)
0x10078ce0c <+44>: addq $0x20, %rsp
0x10078ce10 <+48>: popq %rbp
0x10078ce11 <+49>: retq
总结
首刷此书,在 C++ 代码时,和之前的感觉已经不太一样了。之前写 C++ 代码的感觉是云里雾里写完,能实现功能就了事了,现在写代码,脑海里会自然联想起,每句代码背后编译器和运行时大概会做些什么事情,而且会思考是否有更好的写法。所以说基础还是很重要的,还是那句话:基础不牢,地动山摇。只有打捞底层基础,写出来的代码才真正经得起推敲。