虚继承是什么意思_C++的虚函数和RTTI

C++的虚函数和RTTI

不少人面试的时候,都会被问起来,C++的虚函数是如何实现的,有人会回答到用虚表实现,那么虚表具体又是怎么实现的呢?

最近读到shaharmike的一个博客系列,很好的回答了这个问题。阅读的过程中有些笔记和心得,记录如下。需要注意的是,这里的内容只是在clang++特定版本上用的实现,只作为学习和参考的目的。

普通类的内存布局和带虚函数类的内存布局

#include

using namespace std;

class NonVirtualClass {

public:

void foo() {}

};

class VirtualClass {

public:

virtual void foo() {}

};

int main() {

cout << "Size of NonVirtualClass: " << sizeof(NonVirtualClass) << endl;

cout << "Size of VirtualClass: " << sizeof(VirtualClass) << endl;

}

这里NonVirtualClass的大小为1,而VirtualClass的大小为8(64位情况),有两个原因造成两者的不同:

C++中类的大小不能为0,所以一个空类的大小为1

如果对一个空类对象取地址,如果大小为0,这个地址就没法取了。

如果一个空类有虚函数,那其内存布局中只有一个虚表指针,其大小为sizeof(void*)

单继承下类的虚表布局和type_info布局

#include

class Parent {

public:

virtual void Foo() {}

virtual void FooNotOverridden() {}

};

class Derived : public Parent {

public:

void Foo() override {}

};

int main() {

Parent p1, p2;

Derived d1, d2;

std::cout << "done" << std::endl;

}

虚表布局

单继承下的虚表比较简单,虚表指针总是指向虚表偏移+16(2 * sizeof(void*))的地址,这个地址是第一个虚函数的入口地址。

Parent类的虚表布局如下:

地址偏移

含义

0x0

top_offset用于多继承

0x8

指向Parent的type_info指针

0x10

Parent::Foo()函数地址,也是p1, p2中虚表指针指向的元素

0x18

Parent::FooNotOverridden()函数地址

Derived类的虚表布局如下:

地址偏移

含义

0x0

top_offset用于多继承

0x8

指向Derived的type_info指针

0x10

Derived::Foo()函数地址,也是d1,d2中虚表指针指向的元素

0x18

Parent::FooNotOverridden()函数地址

从这里可以看到:

Derived的虚表中,如果父类的虚函数没有被override,那么虚表中还是存着父类函数的指针。

还可以看到,所有的虚函数调用,都是从虚表取查的。因此,如果没有必要,尽量少的使用虚函数,否则会有一点额外开销。

type_info布局

而type_info的地址,存在虚表指针指向元素的上一个位置,它的包含三个部分:

辅助类地址,用来实现type_info的函数

类名地址

父类type_info地址

多继承下类的内存布局和虚表布局

多继承的情况比较复杂,我们知道,多继承下,子类指针转为父类指针后,这个父类指针使用起来应当和一个真正的父类对象的指针没有区别。

子类没有override父类的虚函数

先来分析这样一个例子

class Mother {

public:

virtual void MotherMethod() {}

int mother_data;

};

class Father {

public:

virtual void FatherMethod() {}

int father_data;

};

class Child : public Mother, public Father {

public:

virtual void ChildMethod() {}

int child_data;

};

Child类的内存布局如下:

偏移

大小

内容

0x0

8

Mother虚表指针

0x8

4

Mother::mother_data

0x10

8

Father虚表指针

0x18

4

Father::father_data

0x1c

4

Child::child_data

Child类的虚表如下:

地址偏移

含义

0x0

top_offset用于多继承

0x8

指向Child的type_info指针

0x10

Mother::MotherMethod()函数地址,也是Child对象中Mother虚表指针指向的元素

0x18

Child::ChildMother函数地址

0x20

top_offset用于多继承

0x28

指向Child的type_info指针

0x30

Father::FatherMethod()函数地址,也是Child对象中Father虚表指针指向的元素

结论和说明:

Mother虚表指针是作为Mother*和Child*时用到的虚表指针。这里Child自己的虚函数Child::ChildMother紧接着Mother的虚函数地址排布,因此作为Mother*和Child*时可以共用一个虚表指针。

Father虚表指针是作为Father*用到的虚表指针,它并不是原来子类指针指向的地址。当Child*类型的指针转型为Father*类型指针时,需要进行偏移。因此,下面这段代码会触发断言。Child c;

auto p1 = reinterpret_cast(&c);

auto p2 = reinterpret_cast(static_cast(&c));

assert(p1 == p2 && "this will be triggerred");

类的内存布局中有padding的地方。

Child::child_data之前没有padding,这里用到了一种tail padding的技术。

子类override非第一个父类的虚函数

class Mother {

public:

virtual void MotherMethod() {}

};

class Father {

public:

virtual void FatherMethod() {}

};

class Child : public Mother, public Father {

public:

void FatherMethod() override {}

};

Child类的内存布局如下:

偏移

大小

内容

0x0

8

Mother虚表指针

0x8

8

Father虚表指针

它的虚表也发生了一些变化,新的虚表布局如下:

地址偏移

含义

0x0

top_offset用于多继承

0x8

指向Child的type_info指针

0x10

Mother::MotherMethod()函数地址,也是Child对象中Mother虚表指针指向的元素

0x18

Child::FatherMethod函数地址

0x20

top_offset用于多继承

0x28

指向Child的type_info指针

0x30

调用Child::FatherMethod()的thunk函数地址,也是Child对象中Father虚表指针指向的元素

注意到,最后一个元素存储的不再是函数地址,而是thunk地址,这个thunk会调用对应的函数。

为什么要这样多此一举呢?从上文得知,从Child*转型为Father*需要进行指针的偏移。如果子类override过非第一个父类的虚函数,当从父类指针调用这个虚函数时,this指针是偏移过的,用这个指针去调用,结果肯定是不对的,需要把指针再偏移回去,这个偏移量就存在对应的top_offset里面,不过在thunk中并没有用到这个偏移量。

thunk解析

这里把thunk的汇编列出来,顺便加了注释

# 开辟新的栈空间

push %rbp

mov %rsp,%rbp

sub $0x10,%rsp

# 保存rid寄存器内容到栈上,这里存的是this

mov %rdi,-0x8(%rbp)

# 这一步是干嘛的?

mov -0x8(%rbp),%rdi

# this指针偏移,减去了8,指向了Child的起始地址

add $0xfffffffffffffff8,%rdi

# 真正调用的函数

callq 0x400810 <:fatherfoo>

# 清栈

add $0x10,%rsp

pop %rbp

# 结束调用thunk

retq

这里有点疑惑,为什么结束调用后,没有把偏移的指针地址改回来呢?

我在g++(MinGW)上试了下,发现对thunk的处理不太一样。window平台下this指针存在rcx寄存器里,同时也没有做恢复this寄存器的操作。windows的反汇编输出如下:

Dump of assembler code from 0x402d20 to 0x402f80:

13 void FatherMethod() override {}

0x0000000000402d20 : sub $0x8,%rcx

0x0000000000402d24 : jmpq 0x402d00 <:fathermethod>

在VC++中又尝试了一下,发现这次thunk的内容很简单,只有一条jump指令,而对this指针的偏移是放在函数体里面做的,而且函数调用方this指针存放在rcx中,在函数内部this指针存放在rdi中,所以不需要恢复rcx。看来不同的编译器实现的很不一样。

菱形虚继承下类的内存布局和虚表布局

虚继承是一个C++中比较难以让新手入门的地方,虚继承主要是为了菱形继承考虑,如果没有虚继承,最后的派生类会拥有两个祖父对象,这无疑会造成难以查找的bug。

#include

using namespace std;

class Grandparent {

public:

virtual void grandparent_foo() {}

int grandparent_data;

};

class Parent1 : virtual public Grandparent {

public:

virtual void parent1_foo() {}

int parent1_data;

};

class Parent2 : virtual public Grandparent {

public:

virtual void parent2_foo() {}

int parent2_data;

};

class Child : public Parent1, public Parent2 {

public:

virtual void child_foo() {}

int child_data;

};

int main() {

Child child;

}

在这段代码中,祖父类Grandparent派生出两个直接子类Parent1和Parent2,这两者又被最子类Child所继承。与简单的多重继承相比,Child类除了类的内存布局和虚表布局不同之外,又新增了两个construction vtable和一个VTT,下面来看看这些到底是什么东西。

类的内存布局

偏移

含义

0x0

Parent1和Child的虚表指针

0x8

Parent1::parent1_data

0x10

Parent2的虚表指针

0x18

Parent2::parent2_data

0x1c

Child::child_data

0x20

Grandparent的虚表指针

0x28

Grandparent::grandparent_data

从布局可以看到,Grandparent::grandparent_data对象的位置在整个对象的末尾。那么问题来了,Parent1、Parent2和Child的虚函数在调用的时候,怎么去知道Grandparent::grandparent_data的位置的呢?这个疑问先不急着解决,先来看下虚表布局。

类的虚表布局

偏移

含义

虚指针

0x0

0x20

virtual base offset

0x8

0

top_offset

0x10

指向Child的type_info指针

0x18

Parent1::parent_foo()的函数地址

Parent1和Child的虚指针指向这里

0x20

Child::child_foo()的函数地址

0x28

0x10

virtual base offset

0x30

-16

top_offset

0x38

指向Child的type_info指针

0x40

Parent2::parent2_foo()的函数地址

Parent2的虚指针指向这里

0x48

0

virtual base offset

0x50

-32

top_offset

0x58

指向Child的type_info指针

0x60

Grandparent::grandparent_foo()的函数地址

Grandparent的虚指针指向这里

这里多了一个新的项目virtual base offset,其实从字面意义还是挺明确的,它的意思是虚基类相对于当前this指针的偏移,因此如果要访问虚基类中的成员变量,只要在当前this指针上加上这个偏移就可以了。

construction vtable

这里有两个额外的虚表,分别是construction vtable for Parent1-in-Child和construction vtable for Parent2-in-Child。顾名思义,它们是在构造Parent1和Parent2子对象时候用的。

下表是construction vtable for Parent1-in-Child:

偏移

含义

0x0

0x20

virtual base offset

0x8

0x0

top_offset

0x10

Parent1的type_info的地址

0x18

Parent1::parent1_foo()的地址

0x20

0x0

virtual base offset

0x28

-0x20

top_offset

0x30

Parent1的type_info的地址

0x38

Grandparent::grandparent_foo的地址

VTT

VTT的意思是virtual table table,意思是虚表的表,里面存的是虚表的入口地址。这里VTT的使用方式,没有google到详细信息,留坑以后填上。

结论

使用虚继承可以解决菱形继承的问题。如果一个祖父类可能被间接继承多次,并且希望在内存中只有一份,那么只要把它在继承树上所有的直接子类改成虚继承就好了。

编译器自动生成的代码

在C++中,很多操作都会包含一些看不见的行为,一不小心就会造成性能问题或引起bug,这也是C++让人头疼的地方之一。

构造函数

当程序执行一个构造函数时,会进行如下操作:

依次调用父类的构造函数,如果没有指定则调用父类的默认构造函数

如果有虚函数,设置虚函数指针

根据初始化成员列表对成员变量进行初始化,如果没有指定则使用其默认值或initialize list中的参数进行初始化

执行构造函数中的代码

对这样一段代码来说

#include

#include

using namespace std;

class Parent {

public:

Parent() { Foo(); }

virtual ~Parent() = default;

virtual void Foo() { cout << "Parent" << endl; }

int i = 0;

};

class Child : public Parent {

public:

Child() : j(1) { Foo(); }

void Foo() override { cout << "Child" << endl; }

int j;

};

class Grandchild : public Child {

public:

Grandchild() { Foo(); s = "hello"; }

void Foo() override { cout << "Grandchild" << endl; }

string s;

};

int main() {

Grandchild g;

}

每个类型的执行顺序为:

Parent

Child

Grandchild

Call Parent's default ctor

Call Child's default ctor

vtable = Parent's vtable

vtable = child's vtable

vtable = Grandchild's vtable

i = 0

j = 1

call s's default ctor

call Foo();

call Foo();

call Foo();

call operator= on s;

由于每个类型在执行其构造函数时,虚指针指向的是自己的虚函数表,所以此时相当于没有虚函数,所以程序依次输出Parent,Child,Grandchild。这里也解释了为什么需要construction vtable的原因。

析构函数

析构函数和构造函数类似,但是执行顺序是相反的。在父类的的析构函数中调用虚函数,因为此时虚指针已经指向父类的虚表,所以并不会调用到子类的虚函数。

执行析构函数中的代码

执行成员变量的析构函数

设置虚指针为父类的虚指针

依次调用父类的析构函数

隐式转型

前面提到,多重继承中,当指针从父类转型为非第一个子类时,指针的值会发生变化。

Dynamic Cast(RTTI)

dynamic_cast通过检查虚表中type_info的信息判断能否在运行时进行指针转型以及是否需要指针偏移,需要插入额外的操作,这也解释了dynamic_cast的开销问题。

函数指针

留坑。

小测试

#include

using namespace std;

class FooInterface {

public:

virtual ~FooInterface() = default;

virtual void Foo() = 0;

};

class BarInterface {

public:

virtual ~BarInterface() = default;

virtual void Bar() = 0;

};

class Concrete : public FooInterface, public BarInterface {

public:

void Foo() override { cout << "Foo()" << endl; }

void Bar() override { cout << "Bar()" << endl; }

};

int main() {

Concrete c;

c.Foo();

c.Bar();

FooInterface* foo = &c;

foo->Foo();

BarInterface* bar = (BarInterface*)(foo);

bar->Bar(); // Prints "Foo()" - WTF?

}

这里Bar()函数的结果却输出了Foo()。因为强制转型后指针的值没有变化,虚指针也没有变化还是指向FooInterface的虚表。而因为BarInterface和FooInterface的布局是一样的,调用Bar()就相当于调用了Foo。这里如果想要得到期望的结果,需要使用dynamic_cast。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值