早在5年前写过《从汇编层面深度剖析C++虚函数》一文,介绍C++的虚函数表和调用过程。最近在看OSv操作系统代码,迫不得已看了C++11中的新语法,最后还是跳不出虚函数的五指山。本文尽量使用图来解释虚函数在类,继承,多继承各种场下的对象模型结构,以及虚函数实现多态绑定。
值得注意的是,不同编译器生成的对象结构和虚函数表稍为有一些不同,本文均采用gcc 5.3.0版本下的g++编译器作为研究对象。
class object {
int a;
int b;
public:
object(): a(0), b(1) {}
virtual void f() {}
};
上述代码中定义object类,定义两个int成员,分别是a和b,然后定义了虚函数f。
下图是定义两个object对象o1和o2的内存结构。
所有带虚函数类对象的首4字节为虚函数表(vtable)指针,object也是如此。object对象的第一个4字节为vtable指针,它指向object全局的虚函数表,每个类只需一个vtable表即可。o1和o2共享一个虚函数表。
虚函数表的内容依次是:object::f(),object::g()。
接下来8个字节是意想不到的东西,那就是object类的type_info对象,这由编译器生成的对象结构,它与C++库中的type_info内存部局完全相同。
g++编译器将类的type_info对象信息放到了vtable表的尾部。
具体过程可解释如下:
- 从o对象中找到它的虚函数表地址
- 根据g函数在虚函数表中的offset,该函数地址
- 根据函数地址进行调用
C++的RTTI机制本该属于别一个话题,不适合在虚函数中谈论。但在具体实现过程中,编译器将它和vtable合并到一起,所以还在有必要简单讨论RTTI机制。
由于type_info信息也是放到vtable里面,那可以认为typeid操作符是虚函数一部分,它在vtable也有一个offset.
下面是object对象获取它的type_info引用的过程。
与其它虚函数调用类似,typeid返回的type_info对象就是vtable尾部的type_info对象。
每个类只有一个type_inof对象,不能被修改,所以typeid操作符只能是返回const引用。
可以想象一下typeid(o).name()就是返回type_info对象是name成员指向的字符器串”6object”。
下面代码定义父类base和子类derive.
class base {
int b;
public:
virtual void f() {}
virtual void g() {}
};
class derive: public base {
int d;
public:
virtual void g() {}
};
derive子类重写了g()函数,所以它的vtable中的第二项为derive::g(),而f()函数没有重写,所以第一项仍然是base::f()函数。
我们经常看到这样的代码:
base *b = new derive();
b->g();
在b->g()调用过程中,调用的是derive::g()函数,而不是base::g(),是如何实现的呢?这其中的奥秘就是虚函数表中。详见下图。
b对象尽管是base*类型的,但它的地址跟new出来derive对象地址是同一个(后面多重继承例子中就不是这样子的了),所以在调用b->g()时,从vtable指向的虚函数中找第二项,它值为derive::g()函数的地址,所以最终调用的是derive::g()函数。
多重继承是更复杂的一个场景,在多重继承的情况下,子类指针向基类指针转换时,它的地址是不一样的,所以编译必须生成一些额外代码来做地址转换。
class base1 {
int b1;
public:
virtual void f1() {}
virtual void g1() {}
};
class base2 {
int b2;
public:
virtual void f2() {}
virtual void g2() {}
};
class base3 {
int b3;
public:
virtual void f3() {}
virtual void g3() {}
};
class derive: public base1, public base2, pbulic base3 {
int d;
public:
virtual void f1() {}
virtual void f2() {}
virtual void f3() {}
};
下图分别定义base1, base2, base3基类对象,不需过多解释。
从这个图开始,我们开始要烧脑了。下图是derive对象d的内存结构:
derive对象内存结构有以下几个特点:
- base1, base2, base3这3个基类依次排列,后面才是derive类新增的d成员
- derive对象有3个虚函数表指针(请注意不是1个了,这里面大有戏法)
3.derive对象有前8字节,也是base1基类所在坑的位置;它的vtable指针指向的虚函数表,供derive类型使用,也供base1类型使用。对于base1类型,只使用前两项。而derive类型,则使用更多项- derive类的虚函数表中:前两项的排列是与base1完全一样的,而后面的derive::f2(),derive::f3(),则是dervice类重载base2/base3虚函数的总列表。
- derive另外两个虚函数表,以base2坑的虚函数表为例,它有两项。第一项是non-virtual thunk to derive::f2(),第二项是base2::g2()。因为derive类没有重写g2函数,所以第二项填base2::g2()是乎合理解的。而non-virtul thunk to derive::f2()这项我们后面会解释。
- 其它的-8和-16数字,估计是其它语法场景下有用,目前没有看到,可以先跳过它们。
- 另外在整个derive的虚函数表中,出现两次derive类的type_info指针,先忽略它们吧。
也许你知道,派生类对象转基类对象转换之后,这两者的地址都是一样的,而在多重继承里面,这个结论就不对了。
从上面看到,派生类是将基类依次排列而成。所以派生类对象指针向第一个基类指针转换时,两者地址是一样的;而第二个和第三个基类对象指针转换时,它的地址就不一样的。请看下图:
以这两行代码为例
derive *d = new derive();
d->g2();
显然,derive没有重写g2()函数,所以它调用的是base2类的虚函数。
其实,不管derive是否有重写g2函数,都是通过base2的虚函数表找出来的。具体过程如下图所示:
由于g2函数是最早是由base2类定义的,所以d->g2()调用时,先从d对象中的base2虚函数表,查找g2偏移量(值为4)的表项,再调用。
但这里有个细节一定要注意的是,base2::g2函数的this指针是base2 *类型的,而这里的d是derive*
类型的,需要先将derive *
指针转换成base2*
指针。这个转换完成之后,指针值就增加8字节了。
这里详细分析
base2 *b2 = new derive();
b2->f2();
是如何实现从基类到派生类f2()函数的调用。
b2指针已指向了derive对象的base2部分,然后b2->f2()从base2-vtable对应的虚函数表的第一项,找到了non-virtual thunk to derive::f2(),然后调用。
咦,这里不应该是derive::f2()吗,那个non-virtual thunk to derive::f2()是什么鬼?
答案是和this指针强相关。
derive::f2()函数的this指针肯定是derive*
类型的,而这里的b2是base2*
类型,不能直接调用。
non-virtual thunk to derive::f2()代码其实是两行汇编,它完成出b2指针从base*
类型转换成derive*
类型的功能,也即地址减去8。
其实我想只用图表将C++虚函数全部表达出来,但当我画出来之后,发现很多细节不用文字稍作说明,不是很难明白。
其实这里说的C++虚函数原理跟你之前了解的应该是一致的,只是很难技术细节你没有想过而已,但不管理怎么样,我们一起学习吧。
后继再跟大家分析,菱形继承和虚继承场景下,虚函数的技术细节。