C++的虚函数和RTTI
C++的虚函数和RTTI
不少人面试的时候,都会被问起来,C++的虚函数是如何实现的,有人会回答到用虚表实现,那么虚表具体又是怎么实现的呢?
最近读到shaharmike的一个博客系列,很好的回答了这个问题。阅读的过程中有些笔记和心得,记录如下。需要注意的是,这里的内容只是在clang++特定版本上用的实现,只作为学习和参考的目的。
普通类的内存布局和带虚函数类的内存布局
#include <iostream>
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 <iostream>
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<void*>(&c); auto p2 = reinterpret_cast<void*>(static_cast<Father*>(&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 <Child::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 <non-virtual thunk to Child::FatherMethod()+0>: sub $0x8,%rcx
0x0000000000402d24 <non-virtual thunk to Child::FatherMethod()+4>: jmpq 0x402d00 <Child::FatherMethod()>
在VC++中又尝试了一下,发现这次thunk的内容很简单,只有一条jump指令,而对this指针的偏移是放在函数体里面做的,而且函数调用方this指针存放在rcx中,在函数内部this指针存放在rdi中,所以不需要恢复rcx。看来不同的编译器实现的很不一样。
菱形虚继承下类的内存布局和虚表布局
虚继承是一个C++中比较难以让新手入门的地方,虚继承主要是为了菱形继承考虑,如果没有虚继承,最后的派生类会拥有两个祖父对象,这无疑会造成难以查找的bug。
#include <iostream>
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 <iostream>
#include <string>
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 <iostream>
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
。