前言
最近被问到一个关于多继承虚函数表的问题,当时回答是可能存在多个虚函数表,应该是顺序排列的,但具体怎么排列还是有些疑惑的,回答的时候到有点儿心虚。之后查了资料,做了简单的实验,可以确定的是对于继承了多个含有虚函数基类的子类来说,指向虚函数表的指针应该不止一个。
问题
虚函数表的问题是从C++多态的概念引出的,要想实现多态有3个条件:
- 存在继承:没有继承就没有多态(运行时),在多态中必须存在有继承关系的父类和子类。
- 重写函数:父类中需要定义带有
virtual
关键字的函数,而在子类中重写一个名字和参数与父类中定义完全相同的函数。 - 向上转型:将父类的指针和引用指向子类的对象。
满足以上三个条件,当使用父类的指针调用带有 virtual
关键字的函数时,就会产生多态行为。
实现这种多态表现的核心内容就是虚函数表,对于带有 virtual
关键字的函数地址会被放入一个表格,而在类中会有一个指向虚函数表的指针指向这个表格,表明这个表格属于类的一部分。
对于父类来说,这个表格中都是自己类的虚函数,而对于子类来说,首先这个虚函数表包含父类中所有的虚函数,当子类重写某个虚函数时就会用子类重写后的函数地址替换原来父类中定义的函数地址,同时在子类的虚函数表中还会包含子类独有的虚函数。
由此可见虚函数表的不同和复杂性还是体现在子类上,所以之后会分别测试单继承、多继承、菱形继承三种情况下虚函数表的不同,主要看一下虚函数表的个数和内存布局情况。
测试环境
首先来说明一下测试环境,测试工具是VS2013
,对于int *p; sizeof(p)
的结果是4,说明编译环境是32位的,这个对后面查看内存结构非常关键。
开始测试
使用VS2013查看类的内存布局非常方便,因为类的大小在编译期间就已经确定了,不用运行就可以通过添加编译选项知道类的大小和布局,而指向虚函数表的指针也会占用类的大小,如果说编译的时候确定了类的大小,那从侧面也说明了在编译期间虚函数表实际上也确定了。
使用VS2013查看类的布局时,可以在项目的属性页:“配置属性”–>“C/C++”–>“命令行”中输入以下任意一个命令,
/d1reportAllClassLayout
:这个选项可以在VS的输出窗口显示所有相关联的类结构,因为一些外部类也会显示,最终的内容会非常多,需要自己辨别有用的信息。/d1reportSingleClassLayoutXXX
:这个选项只会在输出窗口显示指定的类结构,只需要将XXX
替换成想显示的类的名字即可,缺点就是无法同时显示多个想查看的类。
无虚函数简单类结构
在查看虚函数表的结构之前,先使用之前的编译参数来查看一下简单的类结构,排除虚函数的干扰,能更清楚的了解类成员在类中的布局情况,有一点需要提一下,成员变量会占用类的大小,但是成员函数不会,如果有虚函数,所有的虚函数会被放入一个表格,而在类中放置一个指向虚函数表的指针,来看一下简单代码:
1 | class CBase |
编译输出的类的内存布局为:
1 | 1> class CBase size(4): |
从上面的输出内容来看,很清楚的可以看到基类 CBase
的大小 size(4)
占用4个字节,只有一个成员变量 m_var1
,在类中偏移量为0的位置,而派生类 CDerived
占用8个字节大小,第一个成员继承自基类 CBase
的 m_var1
,在类中偏移量为0的位置,还有一个子类独有的成员变量 m_var2
,在类中偏移量为4的位置。
掌握着这种简单类的查看类结构的方法,接下来开始看一下包含虚函数的类的内存布局。
包含虚函数的类结构
查看包含虚函数的类结构相对来说麻烦一点,先来说两个符号,免得一会看见结构发懵,vfptr
表示类中指向虚函数表的指针,通常放在类的起始位置,比成员变量的位置都要靠前, vftable
表示类中引用的虚函数表,在具体分析是还有有一些修饰符,用来表明是谁的虚函数表。
单继承
这种情况的下的子类的虚函数表很简单,在该子类的内存布局上,最开始的位置保存了一个指向虚函数表的指针,虚函数表中包含了从父类继承的虚函数,当子类中重写父类虚函数时会将虚函数表中对应的函数地址替换,最后添加上自己独有的虚函数地址,下面上代码分析一下:
1 | class CBase |
上面这两个类的内存布局情况如下:
1 | 1> class CBase size(8): |
看起来是不是比没有虚函数时复杂多了,不过不要着急,从上到下慢慢分析就好了,这次的基类 CBase
大小是8个字节,首先是{vfptr}
这个指向虚函数表的指针,在类中的偏移量是0,接下来是成员变量 m_var1
,在类中偏移量是4。
然后是 CBase::$vftable@
表示基类 CBase
的虚函数表,其中第一行 &CBase_meta
看起来怪怪的,这里我们不展开(因为我也没弄太懂),应该是和虚函数表相关的元数据,第二行是一个0,看起来是一个偏移量,这里没有偏移,当出现偏移时我们再试着分析(相信我,马上就会出现),第三行内容 &CBase::func1
是自己类的虚函数,前面有一个0,应该是指该虚函数在虚函数表中索引,第四行也是相同的情况。
接下来出现了两行非常相似的内容,看一下CBase::func1 this adjustor: 0
,这句代码中的关键是 adjustor
,其实有是一个偏移量,据说涉及到thunk技术,据说“thunk其实就是一条汇编指令,操作码是0xe9,就是jmp,后面紧跟操作数”,这里我们就不展开了,如果后面弄明白了可以单独写一篇总结,到此为止基类的内存结构就分析完了。
继续看派生类 CDerived
,它的大小是12个字节,内部结构首先是 {vfptr}
一个指向虚函数表的指针,偏移量为0,m_var1
是从父类继承的成员变量,偏移量为4,而 m_var2
是自己类独有的成员变量,偏移量是8。
然后看派生类对应的虚函数表 CDerived::$vftable@
,跳过前两行直接看一下后面几个函数,发现只有 func1
是基类的,而函数 func2
和 func3
都是派生类的,出现这种情况的原因是子类重写了函数 func2
和 func3
,所以用重写后的函数地址替换了从基类继承的虚函数,造成了目前看到的状况。
最后又出现了两行 adjustor
,很奇怪为什么 func1
函数没有 adjustor
,貌似这个 adjustor
只对当前类有效,先留个疑问,接下来看一下多继承。
多继承
当多个父类中都包含虚函数的时候,和子类关联的虚函数表就不止一个了,这个情况是可以通过使用sizeof(子类)来简单验证的:
这一部分是在没有VS的情况下预先写下的,本来考虑使用VS展开布局后,这一段就没有什么必要了,但是后来想想还是留着吧,因为这一段使用的g++编译器,64位环境,每个指针占用8个字节,通过不同的环境调试,更加可以证明,多继承下的多个虚函数表的存在性:
1 | class W |
对于这样的一个简单类,sizeof(W) = 8,类的大小等于成员变量的大小。
1 | class W1 |
对于上面这两个简单的包含虚函数的类,sizeof(W1) = 16,sizeof(W2) = 16,因为每个类都除了一个 long
类型的成员变量以外,还包含了指向虚函数的一个指针,所以类的大小是16个字节。
1 | class WW : public W1, public W2 |
而继承了 W1
和 W2
这两个父类的子类 WW
在继承了两个成员变量 n1
和 n2
之外,还有自己的成员变量 nn
,三个变量占用字节24个,而计算类 WW
的的大小 sizeof(W1) = 40,也就是说除了成员变量24个字节,还剩余了16个字节的空间没有着落,我们知道它至少包含一个指向虚函数表的指针,占用8个字节的大小,还剩8个字节没有找到用处,从此处分析应该还有一个指向虚函数表的指针,具体的情况可以看一下内存分布。
接下来和单继承的分析方法一样,写代码编译查看布局:
1 | class CBase0 |
上面3个类描述了一个简单的多继承的情况,之所以写这么多函数就是构建一种,既有虚函数覆盖,又有单独不被覆盖的情况,下面展示了这段代码的内存布局。
1 | 1> class CBase0 size(8): |
内容很多,前面两个基类 CBase0
和 CBase1
的布局很简单,参照之前的分析很容易看懂,直接从派生类看起吧。
我们发现派生类 CDerived
中确实有两个指向虚函数表的指针,接下来看一下这两个虚函数表,这个虚函数表和前面遇到的格式一样,除了第一行的元数据,第二行的诡异偏移量0,剩下的虚函数指针有的是从基类继承来的,有的是被当前派生类覆盖的,还有派生类自己独有的。
而第二个虚函数表就有点意思了,首先是少了 &CDerived_meta
这一行,然后偏移量终于不是0了,而是-8,从派生类 CDerived
的内存布局上来看,以下开始大胆假设,至于小心求证的部分放到以后来做(看自己的进步状态了)。
第二个指向虚函数表的指针是不是距离类的起始偏移量是8,我猜这个-8的意思就是指的这个偏移量,这个值有可能被后面使用,第二行出现了 &thunk: this-=8; goto CDerived::func2
,其中包含 thunk
字样,表示这个 func2
不归我管,你去-8偏移量的那个虚函数表里找一找。
还有一点你有没有发现 func5
这个函数只在第一个虚函数表中出现,而没有出现在第二个虚函数表中,这也是一个规则,自己独有的虚函数放到第一个虚函数表中,这可能也是为什么只有第一个虚函数表包含元数据行。
最后一点,我们发现对于函数 func4
来说 adjustor
终于不是0了,而值变成了8,仿佛在说这个虚函数只在偏移量的为8的位置。
菱形继承
对于这一部分,并没有太多新的内容,只是简单的菱形继承中,最初的基类在最终的子类中会包含两份,而虚函数的样子并没有太大的不同,接下来简单看一下代码和对应的内存布局即可,因为菱形继承并不被提倡,所以也不用花太多时间来分析这个问题。
1 | class CSuper |
1 | 1> class CSuper size(8): |
虚继承
解决菱形继承的一个常用的办法就是改为虚继承,实际上虚继承中就是将从最基类中继承的公共部分提取出来放在最子类的末尾,然后在提取之前的位置用一个叫做vbptr
的指针指向这里。
之前看到过一种说法:
虚继承内部实现也相当复杂,似乎破坏了OO的纯洁性
至于复杂不复杂,看看后面的内存布局就很清楚了,那是相当复杂,其中出现了各种偏移,简单了解下就行了,如果不是维护老代码,谁现在还写这样的结构。
1 | class CSuper |
1 | 1> class CSuper size(8): |
总结
- 虚函数表是用来实现多态的核心内容。
- 多继承很强大但是不要滥用,当多个基类都含有虚函数时,派生类会有多个指向虚函数表的指针。
- 忘记菱形继承吧,为了取消二义性引入虚继承,结果造成内存分布复杂而又难以理解,大道至简,回归本质吧!