C++的虚函数,虚表与多继承 | Blurred code

C++的虚函数,虚表与多继承

2020/10/29

Updated:2020/10/29

Categories: cpp

虚函数

考虑到面向对象设计中,当基类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函数。