目录
通过子类的指针存取虚基类成员的实现分析
通过第一基类的指针存取虚基类成员的实现分析
通过第二基类的指针存取虚基类成员的实现分析
通过虚基类的指针存取虚基类成员的实现分析
小结
存取虚基类成员与普通类成员的效率对比
接下来我将持续更新“深度解读《深度探索C++对象模型》”系列,敬请期待,欢迎左下角点击关注!也可以关注公众号:iShare爱分享,或文章末尾扫描二维码,自动获得推文和全部的文章列表。
阅读这一篇,请结合上一篇一起阅读,因为有些图表在第一篇文章中,这里就没有再贴出来,上一篇请从这里阅读:
深度解读《深度探索C++对象模型》之虚继承的实现分析和效率评测(一)
例子的代码这里再贴一下,方便查看:
class Grand {
public:
virtual ~Grand() {}
int g;
};
class Base1: virtual public Grand {
public:
int b1;
};
class Base2: virtual public Grand {
public:
int b2;
};
class Derived: public Base1, public Base2 {
public:
int d;
};
int main() {
Derived d;
d.g = 5;
Derived* pd = &d;
pd->g = 6;
Base1* pb1 = &d;
pb1->g = 7;
Base2* pb2 = &d;
pb2->g = 8;
Grand* pg = &d;
pg->g = 9;
return 0;
}
通过子类的指针存取虚基类成员的实现分析
C++代码的第22、23行代码,是通过Derived类型的指针来存取数据成员g,对应的汇编代码:
lea rax, [rbp - 56]
mov qword ptr [rbp - 64], rax
mov rax, qword ptr [rbp - 64]
mov rcx, qword ptr [rax]
mov rcx, qword ptr [rcx - 24]
mov dword ptr [rax + rcx + 8], 6
前两行是取得对象d的地址然后存放在rbp - 64空间,后面几行是计算得到虚基类的成员g的地址,这个跟上面通过对象来存取的代码是一模一样的。这里看到生成的汇编代码中,通过对象来存取虚基类的成员和通过指针来存取虚基类的成员的代码是一样的,按道理这里是可以有优化的地方的,通过对象来存取的话,因为在编译期间它的类型是确定,所以它的偏移值也是确定的,可以不需要通过虚表来得到偏移值,从而省掉几个步骤,但编译器在这里并没有优化。
通过第一基类的指针存取虚基类成员的实现分析
C++代码的第24、25行代码,是转换成Base1类型的指针来存取成员g,对应的汇编代码如下:
lea rax, [rbp - 56]
mov qword ptr [rbp - 72], rax
mov rax, qword ptr [rbp - 72]
mov rcx, qword ptr [rax]
mov rcx, qword ptr [rcx - 24]
mov dword ptr [rax + rcx + 8], 7
因为Base1类是第一继承的父类,所以这个子类部分排在最前面,起始地址和对象d的起始地址一致的,所以上面的代码跟通过Derived类型指针来存取是一模一样的,这里就不再赘述。
通过第二基类的指针存取虚基类成员的实现分析
C++代码的第26、27行代码是转换成Base2类型的指针来存取成员g,对应的汇编代码如下:
xor eax, eax
lea rcx, [rbp - 56]
cmp rcx, 0
mov qword ptr [rbp - 96], rax # 8-byte Spill
je .LBB0_2
lea rax, [rbp - 56]
add rax, 16
mov qword ptr [rbp - 96], rax # 8-byte Spill
.LBB0_2:
mov rax, qword ptr [rbp - 96] # 8-byte Reload
mov qword ptr [rbp - 80], rax
mov rax, qword ptr [rbp - 80]
mov rcx, qword ptr [rax]
mov rcx, qword ptr [rcx - 24]
mov dword ptr [rax + rcx + 8], 8
这次的汇编代码复杂好多,Base2类是第二继承的父类,在布局上,它和对象d的起始地址之间相差了Base1子类。所以Derived类型指针转换成Base2类型指针需要进行this指针的调整,伪代码像下面这样:
Base2* pb2 = &d : &d + sizeof(Base1) ? 0;
Base1子类的大小是16字节,所以上面汇编代码第7行是对象d的地址加上16的偏移值,接着把它存放在rbp - 80空间中,第12、13行代码是取Base2子类起始地址的内容,它在构造时被设置了一个虚函数表指针,见下面的汇编代码:
lea rcx, [rip + vtable for Derived]
add rcx, 64
mov qword ptr [rax + 16], rcx
请参考着Derived虚表看,Derived虚表的内容请往上翻,上面三行汇编代码就是将虚表的起始地址偏移64后写入Base2子类的起始地址(对应Derived虚表中的第10行)。接着看上面汇编代码的第14、15行,将这个地址减去24即rcx - 24再取它的内容,它的值是16,第15行代码就是将Base2子类的地址加上偏移值16(也就是Base2子类的大小),再加上8(Grand类的虚函数表指针),最后得到虚基类成员g的地址,对其赋值为8。
通过虚基类的指针存取虚基类成员的实现分析
最后一种情形,就是C++代码中的第28、29行,是转换成虚基类Grand类型的指针来存取成员g,这两行代码对应的汇编代码如下:
xor eax, eax
lea rcx, [rbp - 56]
cmp rcx, 0
mov qword ptr [rbp - 104], rax # 8-byte Spill
je .LBB0_4
mov rcx, qword ptr [rbp - 56]
lea rax, [rbp - 56]
add rax, qword ptr [rcx - 24]
mov qword ptr [rbp - 104], rax # 8-byte Spill
.LBB0_4:
mov rax, qword ptr [rbp - 104] # 8-byte Reload
mov qword ptr [rbp - 88], rax
mov rax, qword ptr [rbp - 88]
mov dword ptr [rax + 8], 9
这段汇编代码跟上面转换成Base2类型的代码基本一样,区别的地方在调整成Base2类型指针时,对象d的首地址加上偏移量16,这个16是编译器直接计算好了Base1子类的大小,而这里转换成Grand类型指针的偏移值的计算是通过虚表取得的(对应第6到第8行代码),最后以Grand子类的起始地址为基址,加上8(前面8字节的虚函数表指针)的偏移值,得到成员g的地址,再对其赋值9。
小结
虚继承为了解决重复继承的问题,采用了共享子类的方法,所有的派生类中共享同一份虚基类,虚基类一般在内存布局上是存放在整个类的最尾端,借助虚表来访问虚基类中的数据成员,每个派生类中都有编译器为它生成的虚表,里面记录了这个派生类的起始地址相对于虚基类成员的偏移值,这个偏移值在编译期间计算确定下来的。通过指针或者引用类型来访问虚基类的数据成员时,因为不知道它具体指向的类型是什么,所以需要等到运行时,根据它的具体类型找到它对应的虚表,从虚表中得到它的偏移值。通过对象来访问虚基类成员时,因为编译时已经知道了它的具体类型,理论上是可以直接计算它的偏移值是多少,从而得到一个确切的内存地址,不需要经过虚表来访问,但是从上面生成的汇编代码来看,clang编译器并没有做这个优化,仍然采用跟通过指针来访问一样的实现方法。
存取虚基类成员与普通类成员的效率对比
最后来测试对比下存取虚基类成员的效率和存取普通类成员的效率,将下面的测试代码:
#include <cstdio>
#include<chrono>
using namespace std::chrono;
class Grand {
public:
int g = 1;
};
class Base1: virtual public Grand {
public:
int b1 = 2;
};
class Base2: virtual public Grand {
public:
int b2 = 3;
};
class Derived: public Base1, public Base2 {};
void foo() {
Derived* pd1 = new Derived;
Derived* pd2 = new Derived;
auto start = system_clock::now();
for (auto i = 0; i < 10000000; ++i) {
pd2->g = pd1->g + pd1->b1 - pd1->b2;
pd2->b1 = pd1->b1 + pd1->b2 - pd2->g;
pd2->b2 = pd1->b1 + pd1->g - pd2->b1;
}
auto end = system_clock::now();
auto duration = duration_cast<milliseconds>(end-start);
printf("spend %lldms\n", duration.count());
}
class Object {
public:
int a = 1;
int b = 2;
int c = 3;
};
void bar() {
Object* pb1 = new Object;
Object* pb2 = new Object;
auto start = system_clock::now();
for (auto i = 0; i < 10000000; ++i) {
pb2->a = pb1->a + pb1->b - pb1->c;
pb2->b = pb1->b + pb1->c - pb2->a;
pb2->c = pb1->b + pb1->a - pb2->b;
}
auto end = system_clock::now();
auto duration = duration_cast<milliseconds>(end-start);
printf("spend %lldms\n", duration.count());
}
int main() {
foo();
//bar();
return 0;
}
有几点需要说明下:
- foo函数和bar函数分开测试,即每次只运行一个函数,之后屏蔽掉另外一个再重新编译运行,怕会对运行效率有影响。
- 类中没有加入成员函数来存取数据成员,采用直接存取的方式,主要是加入函数后,会有调用函数的开销,就算是使用inline,也是会多了很多行的汇编代码,这里抛开这些影响因素。
- 编译时没有使用优化选项,因为使用优化选项的话编译器的优化太过于激进,会把整个循环都优化掉,直接在编译期间即运算完成了,不能反映实际的情况。
- 每个测试情况分别都运行10次,然后取其平均值。
测试结果:
虚继承 | 64.9ms |
普通类 | 51.7ms |
从测试结果可以看出,虚继承的情况下存取虚基类的成员效率要比普通类存取数据成员的效率要低一些,大约增加了26%的时间。
本主页会定期更新,为了能够及时获得更新,敬请关注我:点击左下角的关注。也可以关注公众号:请在微信上搜索公众号“iShare爱分享”并关注,或者扫描以下公众号二维码关注,以便在内容更新时直接向您推送。