C++ 虚函数表解析
一、何为多态
多态(polymorphism)指为不同数据类型的实体提供统一的接口,或使用单一的符号来表示多个不同的类型。比如我们熟悉的函数重载、模板技术,都属于多态。无论是模板还是函数重载,都是静态绑定的。也就是说,究竟该调用哪个重载函数或者说调用哪个模板类的实例化,在编译期就是确认的。虚函数也是多态的一种,它是运行时的多态。
下面的代码演示了通过虚函数实现的多态:
1 #include<iostream> 2 using namespace std; 3 class Base 4 { 5 public: 6 virtual void f() 7 { 8 cout<<"Base::f()"<<endl; 9 } 10 }; 11 class Derived: public Base 12 { 13 public: 14 void f() 15 { 16 cout<<"Derived::f()"<<endl; 17 } 18 }; 19 int main() { 20 Derived x; 21 Base* p = &x; 22 p->f(); //输出Derived::f()。 23 Base &b = x; 24 b.f(); //输出Derived::f()。 25 Base b1 = x; 26 b1.f();//输出Base::f(),值语义,不能表现出多态 27 return 0; 28 }
运行结果:
[root@VM-16-4-opencloudos vtable]# ./main Derived::f() Derived::f() Base::f()
用法是:基类的指针或引用,用不同的子类赋值时,就表现不同的行为。而值语义是不能表现出多态的。
实现的机制是因编译器而异,但基本上使用虚函数表来实现的,这个后面再介绍。
这会我想谈的是:为什么说虚函数是运行时的多态,基类的指针指向的类型需要在运行期间才能确定?
其实单看上面的代码,也不需要在运行时才知道p->f()调用的是Devied中的函数呀,代码里已经明确了Base的指针p就是指向子类Derived的,我直接看代码都知道了,难道编译器是傻的吗,还要等到运行期时才去通过虚函数表找到p->f()实际调用的是Derived中的函数?其实不是的,对于编译期能确定调用目标的虚函数,最终生成的代码并不会傻乎乎的去查虚表,编译器会执行一些优化,进行静态绑定。具体的讨论可以参考下面这个回答:虚函数一定是运行期才绑定么?
那为什么都说是运行期绑定的?
其实上面这份代码看不出来,可以看下面这份:
1 #include<iostream> 2 using namespace std; 3 class Base 4 { 5 public: 6 virtual void f() 7 { 8 cout<<"Base::f()"<<endl; 9 } 10 }; 11 class Derived: public Base 12 { 13 public: 14 void f() 15 { 16 cout<<"Derived::f()"<<endl; 17 } 18 }; 19 int main() { 20 char k = getchar(); 21 Base *p = NULL; 22 if(k == 'a') { 23 p = new Base(); 24 } else { 25 p = new Derived(); 26 } 27 p->f(); //输出Base::f() or Derived::f() ? 28 return 0; 29 }
在编译期间无法分析出这个指针究竟指向什么类型的对象,只有等到程序运行时,用户从键盘输入字符之后才能确定。此时只能是通过运行期动态绑定了。
之所以要在运行期动态绑定来实现的原因就是运行期外部 IO。参考知乎的回答:为什么C++实现多态必须要虚函数表?
搞清楚了为什么需要运行期动态绑定了,那下面就来说说怎么实现的吧。
二、虚函数表
我们来看以下的代码。类 A 包含虚函数vfunc1
,vfunc2
,由于类 A 包含虚函数,故类 A 拥有一个虚表。
1 class A { 2 public: 3 virtual void vfunc1(); 4 virtual void vfunc2(); 5 void func1(); 6 void func2(); 7 private: 8 int m_data1, m_data2; 9 };
类 A 的虚表如图 1 所示。
图 1:对象它的虚表示意图
虚表是一个指针数组,其元素是虚函数的指针,每个元素对应一个虚函数的函数指针。需要指出的是,普通的函数即非虚函数,其调用并不需要经过虚表,所以虚表的元素并不包括普通函数的函数指针。
虚表内的条目,即虚函数指针的赋值发生在编译器的编译阶段,也就是说在代码的编译阶段,虚表就可以构造出来了。
虚表是属于类的,而不是属于某个具体的对象,一个类只需要一个虚表即可。同一个类的所有对象都使用同一个虚表。
为了指定对象的虚表,对象内部包含一个虚表的指针,来指向自己所使用的虚表。为了让每个包含虚表的类的对象都拥有一个虚表指针,编译器在类中添加了一个指针,*__vptr
,用来指向虚表。这样,当类的对象在创建时便拥有了这个指针,且这个指针的值会自动被设置为指向类的虚表。
为什么弄懂虚表的内存布局,我找了很多资料,认为这两个知乎的回答我能理解清楚:
一个回答是:单继承下的虚函数表的内存布局;
另一个回答是我不太明白的地方:对象在其起始地址处存放了虚表指针(vptr),vptr指向虚表,虚表中存储了实际的函数地址。原来我以为虚表中存储的只有虚函数的地址,但其实不是的,vptr指向的并不是虚表的表头,而是直接指向虚函数的位置。实际上虚表中虚函数的位置之前,还有两个槽位,每个槽位占8个字节,这篇回答说明了这两个槽位的作用:多继承下的虚函数表的内存布局。
另外之前我有个疑惑:派生类中会有几个vptr呢?比如B继承A,然后C继承了B,C类中会有几个vptr呢?是有3个吗?(A、B、C各一个),还是只有1个?
答案是:在单链继承中,每一个派生类型都包含了其基类型的数据以及虚函数,这些虚函数可以按照继承顺序,依次排列在同一张虚表之中,因此只需要一个虚指针即可。并且由于每一个派生类都包含它的直接基类,且没有第二个直接基类,因此其数据在内存中也是线性排布的,这意味着实际类型与它所有的基类型都有着相同的起始地址。
而对于多继承而言,假设类型C同时继承了两个独立的基类A和B,比如:
1 struct A 2 { 3 int ax; 4 virtual void f0() {} 5 }; 6 7 struct B 8 { 9 int bx; 10 virtual void f1() {} 11 }; 12 13 struct C : public A, public B 14 { 15 int cx; 16 void f0() override {} 17 void f1() override {} 18 };
与单链继承不同,由于A
和B
完全独立,它们的虚函数没有顺序关系,即f0
和f1
有着相同对虚表起始位置的偏移量,不可以顺序排布。 并且A
和B
中的成员变量也是无关的,因此基类间也不具有包含关系。这使得A
和B
在C
中必须要处于两个不相交的区域中,同时需要有两个虚指针分别对它们虚函数进行索引。 其内存布局如下所示:
C Vtable (7 entities) +--------------------+ struct C | offset_to_top (0) | object +--------------------+ 0 - struct A (primary base) | RTTI for C | 0 - vptr_A -----------------------------> +--------------------+ 8 - int ax | C::f0() | 16 - struct B +--------------------+ 16 - vptr_B ----------------------+ | C::f1() | 24 - int bx | +--------------------+ 28 - int cx | | offset_to_top (-16)| sizeof(C): 32 align: 8 | +--------------------+ | | RTTI for C | +------> +--------------------+ | Thunk C::f1() | +--------------------+
在上面的内存布局中,C将A作为主基类,也就是将它虚函数“并入”A的虚表之中,并将A的vptr作为C的内存起始地址。
上面的内存布局中,offset_to_top(-16)用于确保如果将类C实例的地址赋给类B的指针p,调用p->f0()时,能找到对应的虚函数,其内部会将指针偏移offset_to_top个字节,找到类C的虚表指针(vptr_A),然后找到对应的虚函数来调用。至于为什么是16个字节,是因为vptr本身占8个字节,另外还有int ax; 虽然int是4字节的,但因为内存对齐,所以总共占16个字节。
假设类型C
同时继承了两个独立的基类A
和B
C++的编译器保证虚函数表的指针存在于对象实例中最前面的位置, 这意味着我们通过对象实例的地址得到这张虚函数表,然后就可以遍历其中函数指针,并调用相应的函数。
下面的程序用于验证包含虚函数的类对象内存的首地址是vptr,并演示如何通过vptr找到虚函数表并访问虚函数:
#include <iostream> using namespace std; class Base { public: virtual void f() { cout << "Base::f" << endl; } virtual void g() { cout << "Base::g" << endl; } virtual void h() { cout << "Base::h" << endl; } }; typedef void(*Fun)(void); int main(int argc, char *argv[]) { Base b; Fun pFun = NULL; cout << "虚函数表地址:" << (int*)(&b) << endl; //vptr cout << "虚函数表 — 函数指针数组的首地址:" << (int*)*(int*)(&b) << endl; // Invoke the first virtual function pFun = (Fun)*((int*)*(int*)(&b)+0); // Base::f() pFun(); return 0; }
运行结果:
[root@VM-16-4-opencloudos vtable]# ./main 虚函数表地址:0x7ffdb2560880 虚函数表 — 第一个函数地址:0x400b78 Base::f
&b取到了虚函数表的地址(vptr),*(int*)(&b)是对虚函数表地址的解引用,得到的是虚函数表。虚函数表中存放了一个函数指针数组,即数组中的每个元素都是一个函数指针,指向每一个虚函数。直接访问(int*)*(int*)(&b)得到的是函数指针数组的首地址。对其进行解引用,即*(int*)*(int*)(&b),则可以得到函数指针数组的首地址指向的元素,该元素是一个函数指针,因为虚函数按照其声明顺序放于虚函数表中,所以*(int*)*(int*)(&b)对应的是Base::f()函数的地址。