虚函数
考虑到面向对象设计中,当基类base
派生出的Derived
类的方法覆盖了基类中的方法时,使用基类的指针去访问此方法,可能出现:
- 调用基类的方法(静态绑定)
- 调用子类的方法 (动态绑定,迟绑定,运行期绑定等各种名称)
虚函数是用来实现动态绑定的方法,它允许使用基类指针指代一系列的子类对象,在调用函数的时候分发调用到实际的子类中去。
一份没有虚函数的示例:
class Animal
{
public:
void bark()
{
std::cout<<"Animal::bark not implemented"<<std::endl;
}
};
class Dog : public Animal
{
public:
void bark()
{
std::cout<<"Dog bark"<<std::endl;
}
};
int main()
{
Animal* b = new Dog();
b->bark(); //Animal::bark not implemented
return 0;
}
而当virtual关键字加上后,
class Animal{
virtual void bark() {
std::cout<<"Animal::bark not implemented"<<std::endl;
}
};
class Dog {...};
int main()
{
Animal* b = new Dog();
b->bark(); //Dog bark
return 0;
}
tips1:为什么基类的析构函数往往是虚函数?
因为如果不是虚函数,调用基类的指针去析构不会将析构操作派发到子类的析构函数上,这样子类的析构函数没有被正确调用,发生资源泄露。
tips2:为什么构造函数不能是虚函数?
C++之父亲自回答过这个问题,不过简单的说,构造一个对象需要确切的知道这个类的完整信息。另外,虚函数的执行要查虚表,可是在对象构造前指向虚表的指针都还没有初始化呢。
虚表
虚表(vtable)属于C++编译器的自定实现,不属于C++的标准范围以内。主流编译器基本靠使用虚表来实现虚函数的功能。
对于每个包含了虚函数的类,编译器会自动生成类所持有的虚表,虚表里记载了该类所有的虚函数的地址。对于包含了虚函数的类所生成的对象,编译器会插入一个指向虚表的指针,用于查找虚表。
对虚函数的调用object.A()
可以展开成object->vptr->vtable[index]->A()
。
简化一下,去掉std::cout
等无关的东西。
class Animal
{
int weight;
public:
virtual void bark(){}
int size;
};
class Dog : public Animal
{
public:
void bark(){}
int paws;
};
注意:以下内容会随着编译器的版本而迭代
gcc的虚表(gcc 9.3)
g++ -fdump-lang-class example.cpp
.
Vtable for Animal
Animal::_ZTV6Animal: 3 entries
0 (int (*)(...))0 //gcc的虚表里,前两项是固定的,一个是offset_to_top
8 (int (*)(...))(& _ZTI6Animal) // 一个是typeinfo,分别是为了dynamic_cast和RTTI
16 (int (*)(...))Animal::bark // 虚表里第一个函数
Class Animal
size=16 align=8
base size=16 base align=8 //两个int和一个vptr占据了16字节的空间
Animal (0x0x7fe12b0c1420) 0
vptr=((& Animal::_ZTV6Animal) + 16) // vptr指向虚表偏移+16的bark函数
Vtable for Dog
Dog::_ZTV3Dog: 3 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI3Dog)
16 (int (*)(...))Dog::bark //子类的虚表里的虚函数被替换为子类的函数
Class Dog
size=24 align=8
base size=20 base align=8 //有一部分padding的空间
Dog (0x0x7fe12af6d208) 0
vptr=((& Dog::_ZTV3Dog) + 16)
Animal (0x0x7fe12b0c14e0) 0
primary-for Dog (0x0x7fe12af6d208)
msvc2019的虚表
使用64位的cl.exe,不然指针的位数上有差异。
cl.exe /d1reportAllClassLayout a.cpp > cl.txt
msvc的输出蛮清晰的
class Animal size(16):
+---
0 | {vfptr} //在对象的头部插了虚指针
8 | weight
12 | size
+---
Animal::$vftable@:
| &Animal_meta //应该也是RTTI和offset
| 0
0 | &Animal::bark //Animal的虚表里记录A::bark的地址
Animal::bark this adjustor: 0
class Dog size(24):
+---
0 | +--- (base class Animal)
0 | | {vfptr}
8 | | weight
12 | | size
| +---
16 | paws
| <alignment member> (size=4)
+---
Dog::$vftable@:
| &Dog_meta
| 0
0 | &Dog::bark // Dog的虚表里记录Dog::bark的地址
Dog::bark this adjustor: 0
clang10的虚表
clang和gcc用的是同一套abi,在虚表的表现上应该一样。
clang -cc1 -fdump-vtable-layouts -fdump-record-layouts-simple -emit-llvm a.cpp > a.txt
导出了一大堆东西,摘录一些
Vtable for 'Dog' (3 entries).
0 | offset_to_top (0)
1 | Dog RTTI
-- (Animal, 0) vtable address --
-- (Dog, 0) vtable address --
2 | void Dog::bark()
VTable indices for 'Dog' (1 entries).
0 | void Dog::bark()
Vtable for 'Animal' (3 entries).
0 | offset_to_top (0)
1 | Animal RTTI
-- (Animal, 0) vtable address --
2 | void Animal::bark()
VTable indices for 'Animal' (1 entries).
0 | void Animal::bark()
LLVMType:%class.Animal = type { i32 (...)**, i32, i32 } //Animal的结构,虚表vptr指针在第一个 LLVMType:%class.Dog = type <{ %class.Animal, i32, [4 x i8] }> //基类Animal,再加一个i32,占用4xi8的size
多继承的虚表
多继承+多覆盖的情况比较复杂。 来个菱形继承。
无覆盖的情况
class A
{
virtual void a1(){}
virtual void b1(){}
virtual void c1(){}
};
class B : public A
{
virtual void a2(){}
virtual void b2(){}
virtual void c2(){}
};
class C : public A
{
virtual void a3(){}
virtual void b3(){}
virtual void c3(){}
};
class D : public B,public C
{
virtual void a4(){}
virtual void b4(){}
virtual void c4(){}
};
完全无覆盖的时候,可以猜测,子类可能在虚表内部按ABCD
的顺序一次复制所有函数,也有可能是ABACD
这种顺序,因为B,C都继承于A导致A内部的函数地址被复制了两份到虚表内。
g++的虚表是按ABDAC
的顺序。
Vtable for D
D::_ZTV1D: 19 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI1D)
16 (int (*)(...))A::a1
24 (int (*)(...))A::b1
32 (int (*)(...))A::c1
40 (int (*)(...))B::a2
48 (int (*)(...))B::b2
56 (int (*)(...))B::c2
64 (int (*)(...))D::a4
72 (int (*)(...))D::b4
80 (int (*)(...))D::c4
88 (int (*)(...))-8
96 (int (*)(...))(& _ZTI1D)
104 (int (*)(...))A::a1
112 (int (*)(...))A::b1
120 (int (*)(...))A::c1
128 (int (*)(...))C::a3
136 (int (*)(...))C::b3
144 (int (*)(...))C::c3
msvc的处理则是D既然继承了B,C两个基类,那么插两个虚表指针不就可以指向两个不同的虚表了吗,于是D的布局成了下面,其实把两个指针指向的虚表合并一下就成了gcc的虚表。
class D size(16):
+---
0 | +--- (base class B)
0 | | +--- (base class A)
0 | | | {vfptr} # 指向B的虚表的指针
| | +---
| +---
8 | +--- (base class C)
8 | | +--- (base class A)
8 | | | {vfptr}
| | +---
| +---
+---
D::$vftable@B@:
| &D_meta
| 0
0 | &A::a1
1 | &A::b1
2 | &A::c1
3 | &B::a2
4 | &B::b2
5 | &B::c2
6 | &D::a4
7 | &D::b4
8 | &D::c4
D::$vftable@C@:
| -8 #offset_to_top
0 | &A::a1
1 | &A::b1
2 | &A::c1
3 | &C::a3
4 | &C::b3
5 | &C::c3
有覆盖的情况
class A
{
virtual void a1(){}
virtual void b1(){}
virtual void c1(){}
};
class B : public A
{
virtual void a2(){}
virtual void b2(){}
virtual void c2(){}
};
class C : public A
{
virtual void a3(){}
virtual void b3(){}
virtual void c3(){}
};
class D : public B,public C
{
virtual void a1(){}
virtual void b2(){}
virtual void c3(){}
};
MSVC中的虚表变成如下
D::$vftable@B@:
| &D_meta
| 0
0 | &D::a1 # 对A.a1()的函数被替换到了D::a1()
1 | &A::b1
2 | &A::c1
3 | &B::a2
4 | &D::b2 # 同理,对b2的函数指针指向了D::b2()
5 | &B::c2
D::$vftable@C@:
| -8
0 | &thunk: this-=8; goto D::a1 #同样,跳转到D::a1()上
1 | &A::b1
2 | &A::c1
3 | &C::a3
4 | &C::b3
5 | &D::c3
再看看gcc
Vtable for D
D::_ZTV1D: 17 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI1D)
16 (int (*)(...))D::a1 #a1被替换
24 (int (*)(...))A::b1
32 (int (*)(...))A::c1
40 (int (*)(...))B::a2
48 (int (*)(...))D::b2 #b2被替换
56 (int (*)(...))B::c2
64 (int (*)(...))D::c3
72 (int (*)(...))-8
80 (int (*)(...))(& _ZTI1D)
88 (int (*)(...))D::_ZThn8_N1D2a1Ev
96 (int (*)(...))A::b1
104 (int (*)(...))A::c1
112 (int (*)(...))C::a3
120 (int (*)(...))C::b3 #b3被替换
128 (int (*)(...))D::_ZThn8_N1D2c3Ev
虚表的安全问题
从导出的内存里可以看到,虚表里记载了所有的虚函数的地址,无论类的访问权限。因此只要我们知道父类的类的布局结构,我们可以直接计算虚表内函数的偏移,从而直接调用父类里本来无权访问的private函数。