基本概念
我们知道C++动态多态是用虚函数实现的,而虚函数的实现方式虽说C++标准没有要求,但是基本都是用虚函数表实现的(编译器决定)。所以我们有必要了解一下虚函数表的实现原理。
用virtual关键字声明的成员函数是虚函数。
具有虚函数的类及其派生的类会在编译时创建虚函数表,简称虚表(vtbl),虚表是虚函数指针的数组。
具有虚函数的类对象有一个虚表指针(vfptr),是编译器生成的指针,在对象构造时初始化。虚表指针vfptr指向虚表的第一个虚函数指针(即vfptr的值是虚表第一个虚函数指针的地址)。
打印对象布局或虚表布局
VS2017
在C/C++ -> 命令行里添加:/d1 reportSingleClassLayoutXXX(XXX是类名),重新编译会在输出窗口的生成里看到对象布局和虚表布局。
查看所有类布局
/d1 reportAllClassLayout
查看具体类布局
/d1 reportSingleClassLayoutXXX
gcc
查看虚表布局
g++ -fdump-class-hierarchy Base.cpp
// >g++(8.3.1)
g++ -fdump-lang-class Base.cpp
然后会生成文件Base.cpp.002t.class,里面有虚表布局
gdb打印对象布局或虚表
开启打印虚表
set print vtbl on
打印对象的虚表
i vtbl OBJECT
根据虚表打印对象的派生类
set p object on
对象和虚表的内存布局
为了方便同时在windows和linux测试,统一使用x64编译器,数据类型使用intptr_t(与指针大小相同的整型)。
接下来介绍无继承有虚函数,单继承,多重继承,菱形继承,虚继承下的内存布局。
以下测试会在VS2017或gcc5.4.0进行。
无继承有虚函数
对象布局:第一个成员是虚表指针。
虚表布局:虚函数指针顺序即虚函数声明顺序。
class NoInhert_A {
public:
NoInhert_A() { cout << "NoInhert_A::NoInhert_A()" << endl; }
virtual ~NoInhert_A() { cout << "NoInhert_A::~NoInhert_A()" << endl; }
virtual void f() { cout << "NoInhert_A::f()" << endl; }
virtual void g() { cout << "NoInhert_A::g()" << endl; }
intptr_t a = 1;
};
VS2017
可以看到类NoInhert_A大小为16字节,虚表指针占8字节,变量a占8字节。虚表指针vfptr指向虚表的第一个虚函数指针,也就是析构函数。通过vfptr的偏移可以访问不同的虚函数指针。
gcc5.4.0
no_inhert.cpp.002t.class文件输出:
gdb输出:
可以看到对象布局和VS一样,都是16字节。
虚表指针vfptr指向虚表的第一个虚函数指针,接着是两个虚析构函数和两个虚函数。这是VS和gcc编译器的不同之处,gcc有两个析构函数指针,一个用在栈析构,一个用在堆析构。
虚表第一项是offset_to_top,表示该类虚表指针距离对象顶部地址的偏移量,这里是0,因为对象内存的第一项就是虚表指针,只有存在多重继承才不为0。
虚表的第二项是type_info,即RTTI指针,指向运行时类型信息,用于运行时类型识别,用于typeid和dynamic_cast。
整体内存布局如下:
单继承
单继承和多级继承比较简单,
对象布局:先是父类的成员,然后是子类的成员。
虚表布局:当子类重写虚函数时,父类的虚函数指针替换为子类的虚函数指针。
class SingleInhert_A {
public:
SingleInhert_A() { cout << "SingleInhert_A::SingleInhert_A()" << endl; }
virtual ~SingleInhert_A() { cout << "SingleInhert_A::~SingleInhert_A()" << endl; }
virtual void f() { cout << "SingleInhert_A::f()" << endl; }
virtual void g() { cout << "SingleInhert_A::g()" << endl; }
intptr_t a = 1;
};
class SingleInhert_B : public SingleInhert_A {
public:
SingleInhert_B() { cout << "SingleInhert_B::SingleInhert_B()" << endl; }
virtual ~SingleInhert_B() { cout << "SingleInhert_B::~SingleInhert_B()" << endl; }
virtual void f() override { cout << "SingleInhert_B::f()" << endl; }
virtual void h() { cout << "SingleInhert_B::h()" << endl; }
intptr_t b = 2;
};
int main() {
SingleInhert_B obj;
return 0;
}
VS2017
gcc5.4.0
single_inhert.cpp.002t.class文件输出:
gdb输出:
整体内存布局如下:
可以看到SingleInhert_B的虚表中,f函数被覆盖,g函数继承下来,h是新的虚函数。
多重继承
// 16字节
class Base1 {
public:
Base1() { cout << "Base1::Base1()" << endl; }
virtual ~Base1() { cout << "Base1::~Base1()" << endl; }
virtual void f() { cout << "Base1::f()" << endl; }
virtual void g() { cout << "Base1::g()" << endl; }
intptr_t a = 1;
};
// 16字节
class Base2 {
public:
Base2() { cout << "Base2::Base2()" << endl; }
virtual ~Base2() { cout << "Base2::~Base2()" << endl; }
virtual void f() { cout << "Base2::f()" << endl; }
virtual void g() { cout << "Base2::g()" << endl; }
virtual void h() { cout << "Base2::h()" << endl; }
intptr_t b = 2;
};
// 40字节
class Derived : public Base1, public Base2 {
public:
Derived() { cout << "Derived::Derived()" << endl; }
virtual ~Derived() { cout << "Derived::~Derived()" << endl; }
void f() override { cout << "Derived::f()" << endl; }
void h() override { cout << "Derived::h()" << endl; }
virtual void k() { cout << "Derived::k()" << endl; }
intptr_t c = 3;
};
int main() {
Derived d;
return 0;
}
VS2017
/d1 reportSingleClassLayoutDerived
可以看到对象布局:先是父类Base1的成员,然后是Base2的成员,最后是子类Derived的成员。
注意这里继承2个父类所以子类有2个虚表,Derived和Base1共享虚表,对象有2个虚表指针。
整体内存布局如下:
可以看到子类Derived的f()和析构函数覆盖了Base1,&Base1::g和&Base2::g直接继承下来,Derived新增的 &Derived::k追加到Base1虚表的末尾。Derived和Base1共享第一个虚表。
多重继承需要解决什么问题?
多重继承需要this指针调整。
考虑下面的情况:
Base2* pb2 = new Derived;
delete pb2;
Derived对象赋值给Base2指针时,this指针需要向后调整sizeof(Base),这样才能调用Base2的成员。
delete pb2时this指针要向前调整sizeof(Base),这样才能调用Derived的析构函数。
VS2017下多重继承总结
- 子类的虚函数如果存在覆盖(override),会修改父类的虚表(替换为子类的虚函数指针)
- 子类和第一个父类共享虚表,子类独有的虚函数指针追加到第一个父类的虚表后面
-
有n个父类,则子类共有n个虚表
gcc5.4.0
multiple_inhert.cpp.002t.class输出:
gdb输出:
和VS虚表实现不同,gcc下:
两个虚表似乎合并了,第二个虚表是通过偏移量得到的。为了方便下面介绍时还是说成两个虚表。
Base2被覆盖的虚函数指针&Derived::h被放在了第一个虚表里。当通过Base1或Derived的指针指向Derived对象时,调用h()函数不需要调整this指针,而通过Base2指针指向Derived对象时反而要调整this指针。这一点和《深度探索C++对象模型》多重继承例子里的mumble函数刚好相反,我个人猜测是gcc实现变了。
注意第二个虚表的offset_to_top是-16,即虚表指针向后调整16是对象起始地址,即this-=16。
所以gcc下Base2的虚函数除非没有被覆盖,否则都会走this指针调整。
菱形继承
/**
* @brief 16字节
* vptr_A
* a
*/
class A {
public:
A() { cout << "A::A()" << endl; }
virtual ~A() { cout << "A::~A()" << endl; }
virtual void f() { cout << "A::f()" << endl; }
intptr_t a = 1;
};
/**
* @brief 24字节
* vptr_B
* a
* b
*/
class B : public A {
public:
B() { cout << "B::B()" << endl; }
virtual ~B() { cout << "B::~B()" << endl; }
virtual void g() { cout << "B::g()" << endl; }
intptr_t b = 2;
};
/**
* @brief 24字节
* vptr_C
* a
* c
*/
class C : public A {
public:
C() { cout << "C::C()" << endl; }
virtual ~C() { cout << "C::~C()" << endl; }
virtual void h() { cout << "C::h()" << endl; }
intptr_t c = 3;
};
/**
* @brief 56字节
* --------------------
* vptr_B
* a
* b
* --------------------
* vptr_C
* a
* c
* --------------------
* d
*/
class Tom : public B, public C {
public:
Tom() { cout << "Tom::Tom()" << endl; }
virtual ~Tom() { cout << "Tom::~Tom()" << endl; }
void f() override { cout << "Tom::f()" << endl; }
virtual void k() { cout << "Tom::k()" << endl; }
intptr_t d = 4;
};
int main() {
Tom t;
t.a = 2; // Tom::a不明确
return 0;
}
菱形继承存在问题
- 公共父类的数据存在两份拷贝,造成浪费。(公共基类会构造和析构两次)
- 存在二义性,不能直接访问公共父类的数据和函数,需要通过类名::访问
解决方法:使用虚继承
VS2017
/d1 reportSingleClassLayoutTom
对象布局:
虚表布局:
从上面对象布局可以看到A的数据成员a有两份。因为二义性我们也不能直接通过t.a访问a。
gcc5.4.0
diamond_inhert.cpp.002t.class输出:
gdb输出:
菱形继承gcc逻辑跟VS差不多。
菱形继承+虚继承
/**
* @brief 16字节
*/
class VA {
public:
VA() { cout << "VA::VA()" << endl; }
virtual ~VA() { cout << "VA::~VA()" << endl; }
virtual void f() { cout << "VA::f()" << endl; }
intptr_t a = 1;
};
/**
* @brief VS2017:40字节,gcc5.4.0:32字节
*/
class VB : virtual public VA {
public:
VB() { cout << "VB::VB()" << endl; }
virtual ~VB() { cout << "VB::~VB()" << endl; }
//void f() override { cout << "VB::f()" << endl; }
virtual void g() { cout << "VB::g()" << endl; }
intptr_t b = 2;
};
/**
* @brief VS2017:40字节,gcc5.4.0:32字节
*/
class VC : virtual public VA {
public:
VC() { cout << "VC::VC()" << endl; }
virtual ~VC() { cout << "VC::~VC()" << endl; }
virtual void h() { cout << "VC::h()" << endl; }
intptr_t c = 3;
};
/**
* @brief VS2017:80字节,gcc5.4.0:56字节
* --------------------
*/
class VTom : public VB, public VC {
public:
VTom() { cout << "VTom::VTom()" << endl; }
virtual ~VTom() { cout << "VTom::~VTom()" << endl; }
void f() override { cout << "VTom::f()" << endl; }
virtual void k() { cout << "VTom::k()" << endl; }
intptr_t d = 4;
};
int main() {
VTom v;
cout << sizeof(VA) << endl;
cout << sizeof(VB) << endl;
cout << sizeof(VC) << endl;
cout << sizeof(VTom) << endl;
return 0;
}
虚继承可以解决菱形继承的问题,菱形继承改为虚继承后,A只有一份拷贝。
深度探索C++对象模型里建议不要在虚基类声明非静态数据成员。
公共基类VA是虚基类。
只有第一个直接基类会调用虚基类的构造函数。
VS2017
/d1 reportSingleClassLayoutVTom
对象布局:
两个直接基类多了vbptr,即虚基类指针。vbptr指向虚基类表vbtable。
接着是子类成员,然后是4字节的vtordisp,为了保持8字节对齐,前面保留4字节。
最后才是虚基类的成员。
虚表布局:
可以看到子类新增的虚函数指针&VTom::k在第一个直接基类VB里。
再来看虚基类表:
- 虚基类表的第一项是类首地址相对vbptr的偏移量,这里都是-8。
- 虚基类表的第二项是虚基类的虚表指针相对vbptr的偏移量,即64-32=32,64-8=56。
gcc5.4.0
virtual_inhert.cpp.002t.class输出:
Vtable for VTom
VTom::_ZTV4VTom: 21u entries
0 40u
8 (int (*)(...))0
16 (int (*)(...))(& _ZTI4VTom)
24 (int (*)(...))VTom::~VTom
32 (int (*)(...))VTom::~VTom
40 (int (*)(...))VB::g
48 (int (*)(...))VTom::f
56 (int (*)(...))VTom::k
64 24u
72 (int (*)(...))-16
80 (int (*)(...))(& _ZTI4VTom)
88 (int (*)(...))VTom::_ZThn16_N4VTomD1Ev
96 (int (*)(...))VTom::_ZThn16_N4VTomD0Ev
104 (int (*)(...))VC::h
112 18446744073709551576u
120 18446744073709551576u
128 (int (*)(...))-40
136 (int (*)(...))(& _ZTI4VTom)
144 (int (*)(...))VTom::_ZTv0_n24_N4VTomD1Ev
152 (int (*)(...))VTom::_ZTv0_n24_N4VTomD0Ev
160 (int (*)(...))VTom::_ZTv0_n32_N4VTom1fEv
Construction vtable for VB (0x0x7f648bdb1888 instance) in VTom
VTom::_ZTC4VTom0_2VB: 13u entries
0 40u
8 (int (*)(...))0
16 (int (*)(...))(& _ZTI2VB)
24 0u
32 0u
40 (int (*)(...))VB::g
48 0u
56 18446744073709551576u
64 (int (*)(...))-40
72 (int (*)(...))(& _ZTI2VB)
80 0u
88 0u
96 (int (*)(...))VA::f
Construction vtable for VC (0x0x7f648bdb1820 instance) in VTom
VTom::_ZTC4VTom16_2VC: 13u entries
0 24u
8 (int (*)(...))0
16 (int (*)(...))(& _ZTI2VC)
24 0u
32 0u
40 (int (*)(...))VC::h
48 0u
56 18446744073709551592u
64 (int (*)(...))-24
72 (int (*)(...))(& _ZTI2VC)
80 0u
88 0u
96 (int (*)(...))VA::f
VTT for VTom
VTom::_ZTT4VTom: 7u entries
0 ((& VTom::_ZTV4VTom) + 24u)
8 ((& VTom::_ZTC4VTom0_2VB) + 24u)
16 ((& VTom::_ZTC4VTom0_2VB) + 80u)
24 ((& VTom::_ZTC4VTom16_2VC) + 24u)
32 ((& VTom::_ZTC4VTom16_2VC) + 80u)
40 ((& VTom::_ZTV4VTom) + 144u)
48 ((& VTom::_ZTV4VTom) + 88u)
Class VTom
size=56 align=8
base size=40 base align=8
VTom (0x0x7f648be1dd90) 0
vptridx=0u vptr=((& VTom::_ZTV4VTom) + 24u)
VB (0x0x7f648bdb1888) 0
primary-for VTom (0x0x7f648be1dd90)
subvttidx=8u
VA (0x0x7f648bd785a0) 40 virtual
vptridx=40u vbaseoffset=-24 vptr=((& VTom::_ZTV4VTom) + 144u)
VC (0x0x7f648bdb1820) 16
subvttidx=24u vptridx=48u vptr=((& VTom::_ZTV4VTom) + 88u)
VA (0x0x7f648bd785a0) alternative-path
gdb输出:
虽然gdb输出VA的成员在最前面,但实际上的对象布局是这样的:
整体内存布局:
virtual base offsets表示虚基类虚表指针VA::vfptr相对于直接基类首地址的偏移量,即40和24。
可以看到整体上虚表布局显示类VB部分,接着是类VC部分,最后是虚基类VA部分。
虚基类部分都是使用trunk技术,即调整this指针来访问虚函数。
虚表开头是第一个直接基类VB的虚函数指针&VB::g,然后是覆盖虚基类的&VTom::f和子类新增的&VTom::k。
整体上虚表是一个连续的数组,但是逻辑上我们可以认为这里有3个虚表。
gcc下没有vbptr。
参考链接
C++虚函数的实现基本原理 - JackTang's Blog