简介
这篇博客主要是将我对 C++ 的继承中不太清晰的点进行整理,主要包括继承中的多态特性是怎么体现的,以及多态特性背后的虚函数的原理。以下测试环境为:
- gcc 9.3.0
- Ubuntu 20.04
- GNU gdb (Ubuntu 9.2-0ubuntu1~20.04) 9.2
- Compile Explorer: https://godbolt.org/
继承和多态
例子
先来看一个简单的通过继承来实现的多态的例子:
#include <iostream>
class Base
{
public:
virtual void info() const
{
std::cout << "Base::Info()" << std::endl;
}
};
class Derived: public Base
{
public:
virtual void info() const override
{
std::cout << "Derived::Info()" << std::endl;
}
};
int main(int argc, char** argv)
{
Base* actual_base = new Base();
Base* actual_derived = new Derived();
actual_base->info();
actual_derived->info();
delete actual_base;
delete actual_derived;
}
这段代码的运行结果为:
$ /home/xt/code_collections/cpp/build/inheritance/basic_example
Base::Info()
Derived::Info()
可以看到,这里我们虽然表面上 actual_base
和 actual_derived
都是 Base*
的类型,但是实际上程序会自动“识别”他们指向的真正类型,从而调用该类型的 info()
方法。但是这个“识别”过程具体是怎么实现的呢?主要就是通过在派生类和基类的对象中维护一个虚函数表,虚函数表中保存了该类的虚函数中的映射关系,通过虚函数表确定调用的函数。
含有虚函数的派生类初始化过程
接下来我们通过汇编代码来看虚函数表的结构,以及在编译过程中编译器是怎么对派生类进行初始化的。为了避免引入外部函数导致汇编代码难以阅读,采用以下例子:
class Base
{
public:
bool flag = true;
virtual ~Base() {}
virtual void flip()
{
flag = !flag;
}
};
class Derived: public Base
{
public:
bool extra_flag = true;
int count = 0;
virtual ~Derived() {}
virtual void flip() override
{
flag = !flag;
extra_flag = !extra_flag;
}
void iterate()
{
count++;
}
};
int main()
{
Base* actual_derived = new Derived();
delete actual_derived;
}
使用 Compile Explorer 查看汇编代码如下:
这个例子中,我们主要关注变量 actual_derived
的构造过程,即图中右侧红框的部分。
先看 main
的部分:
sub rsp, 24
mov edi, 16 // 传入立即数 16 作为 new 的入参,表明需要 new 的空间为 16 个字节
call operator new(unsigned long) // 分配空间返回地址到 rax
mov rbx, rax // 复制新变量的地址到 rbx
mov QWORD PTR [rbx], 0 // 初始化 rbx 后四个字(8个字节)为 0 -- 对应虚表
mov BYTE PTR [rbx+8], 0 // 初始化 rbx 后第 9 个字节 为 0 -- 对应 flag 变量
mov BYTE PTR [rbx+9], 0 // 初始化 rbx 后第 10 个字节 为 0 -- 对应 extra_flag 变量
mov DWORD PTR [rbx+12], 0 // 初始化 rbx 后第 12 个字节的双字(4个字节) -- 对应 count 变量
mov rdi, rbx // 将 rbx 复制到 rdi 用于构造函数入参
call Derived::Derived() [complete object constructor] // 调用 Derived::Derived() 构造函数
mov QWORD PTR [rbp-24], rbx // 将 rbx 中的地址复制到 rbp-24 中,即将 Derived::Derived() 的变量地址分配给 actual_derived 指针变量
可以看到,在 main
部分中主要进行派生类实例空间的分配,从空间分配中我们可以看到编译器给 *actual_derived
分配了 16 个字节,分为四个部分,并分别进行内存初始化(置 0)。接下来我们看 Derived::Derived()
的部分如何处理这些内存。
此时,相关的内存分布为:
[rbx] ---> 0x00 (8 bytes)
[rbx+8] ---> 0x00 (1 bytes)
[rbx+9] ---> 0x00 (1 bytes)
[rbx+10] ---> rand (2 bytes)
[rbx+12] ---> 0x00 (4 bytes)
Derived::Derived()
部分的相关代码如下所示:
sub rsp, 16 // 预留 16 个字节空间
mov QWORD PTR [rbp-8], rdi // 获取派生类实例地址
mov rax, QWORD PTR [rbp-8] // 由于需要调用基类的构造函数,因此重新复制进 rax 然后到 rdi
mov rdi, rax
call Base::Base() [base object constructor] // 调用基类构造函数
mov edx, OFFSET FLAT:vtable for Derived+16 // 让 edx 存放 Derived 虚表起始位置之后第 16 个字节的地址
mov rax, QWORD PTR [rbp-8] // 将变量地址 this 复制到 rax 中,rax 会作为函数返回值返回
mov QWORD PTR [rax], rdx // 将 rdx 的内容(8 个字节),包含 edx (Derived 的虚表内容) 存放至 rax,即 this 中
mov rax, QWORD PTR [rbp-8] // 类似操作,保存 this 到 rax 中
mov BYTE PTR [rax+9], 1 // 初始化 rax + 9 后的一个字节为 1,对应 bool extra_flag = true;
mov rax, QWORD PTR [rbp-8]
mov DWORD PTR [rax+12], 0 // 初始化 rax + 12 后的 (4 个字节)赋值为 0,对应 int count = 0;
可以看到,Derived
的构造函数中,主要做了这么几件事情:1. 调用了基类构造函数;2. 复制 Derived
的虚函数表到变量起始位置,占用 8 个字节的空间;在虚表部分之后的第二个字节中初始化 extra_flag
,占用 1 个字节空间,4.在 extra_flag
之后的第三个字节位置之后初始化 count
,占用 4 个字节空间。对 Derived
变量而言,内存分布为:
this(rax) ---> vtable for Derived+16 (8 bytes)
this+8 ---> 0 (1 bytes)
this+9 ---> 1 (bool extra_flag) (1 bytes)
this+10 ---> rand (2 bytes)
this+12 ---> 0 (int count) (4 bytes)
内存分布中,rax + 8
的内存在构造函数没有涉及,不难想象这是基类中 bool extra_flag
占用的内存空间,而 this+10
之后的两个字节位置只是用于内存对齐,不存放数据。下面先看简单看一下 Base::Base()
的内容,最后再看虚表的具体内容:
Base::Base()
部分的相关代码如下所示:
mov QWORD PTR [rbp-8], rdi // 获取派生类实例地址
mov edx, OFFSET FLAT:vtable for Base+16 // 让 edx 存放 Base 虚表起始位置之后第 16 个字节的地址
mov rax, QWORD PTR [rbp-8] // 将变量地址 this 复制到 rax 中,rax 会作为函数返回值返回
mov QWORD PTR [rax], rdx // 将 rdx 的内容(8 个字节),包含 edx (Base 的虚表地址) 存放至 rax,即 this 中
mov rax, QWORD PTR [rbp-8] // 类似操作,保存 this 到 rax 中
mov BYTE PTR [rax+8], 1 // 初始化 rax + 8 后的一个字节为 1,对应 bool flag = true;
可以看出,这里的操作和 Derived::Derived()
大同小异,基本上都是复制虚表,初始化成员变量。注意在基类中基类的虚表会复制一次,然后在派生类中重新复制以此来覆盖虚表的内容。接下来我们来看一下 Derived
中虚表的内容,如下图红框部分所示:
Derived::Derived()
虚表的相关内容为:
vtable for Derived:
.quad 0
.quad typeinfo for Derived
.quad Derived::~Derived() [complete object destructor]
.quad Derived::~Derived() [deleting destructor]
.quad Derived::flip()
typeinfo for Derived:
.quad vtable for __cxxabiv1::__si_class_type_info+16
.quad typeinfo name for Derived
.quad typeinfo for Base
typeinfo name for Derived:
.string "7Derived"
typeinfo for Base:
.quad vtable for __cxxabiv1::__class_type_info+16
.quad typeinfo name for Base
typeinfo name for Base:
.string "4Base"
可以看到,Derived::Derived()
包含 5 个部分,每个部分占8个字节,共 40 个字节,其作用分别为:
- 第一个 QWORD:置 0
- 第二个 QWORD:派生类的基本信息,指向另一块地址
- 第三个 QWORD:派生类的静态析构函数
- 第四个 QWORD:派生类的动态析构函数
- 第五个 QWORD:
Derived::flip()
虚函数指针
在运行时由于程序能够通过虚函数表直接确定要调用的函数,因此前两个 DWORD 是不需要的,因此在变量中保存的不是虚函数表的起始地址,而是偏移了 16 个字节之后的地址。最后变量的内存布局为 [this]
表示 this
中指向地址:
this(rax) ---> vtable for Derived+16 (8 bytes)
[this-16] ---> 0
[this-8] ---> typeinfo for Derived
[this] ---> Derived::~Derived() [complete object destructor]
[this+8] ---> Derived::~Derived() [deleting destructor]
[this+16] ---> Derived::flip()
this+8 ---> 1 (bool flag) (1 bytes)
this+9 ---> 1 (bool extra_flag) (1 bytes)
this+10 ---> rand (2 bytes)
this+12 ---> 0 (int count) (4 bytes)
下面我们使用 vscode 结合 GDB 来观察程序运行时是否对应我们在汇编中观察的现象,注:GDB 需要设置 set print object on
才能正确识别派生类,首先设置好断点,并找到 acutal_derived
以及指向的对象地址以及对象大小。这里可以看到:
acutal_derived
地址为:0x55555556aeb0
*acutal_derived
地址为:0x555555557d48
*acutal_derived
大小为 16 bytes
这里我们可以看到 *acutal_derived
按顺序包含有:<vtable for Derived+16>, flag = true, extra_flag = true, count = 0
符合我们的预期。接下来将该起始对应的内存打印出来,由于一个变量占 16 个字节,因此我们只打印 16 个字节:
-exec x/16xb actual_derived
0x55555556aeb0: 0x48 0x7d 0x55 0x55 0x55 0x55 0x00 0x00
0x55555556aeb8: 0x01 0x01 0x00 0x00 0x00 0x00 0x00 0x00
可以看到第一行为该变量前 8 个字节,为虚表地址。第二行前两个字节分别为:flag
和 extra_flag
。最后四个字节为 count
初始化为 0。这里我们得到虚表地址为:0x555555557d48
,我们可以进一步将虚表打印出来,由于这个地址是虚表偏移 16 个字节的,因此我们可以减去 16 为 0x555555557d38
。我们从汇编中知道虚表占 40 个字节,因此打印出 0x555555557d38
起始的 40 个字节,如下所示:
-exec x/40xb 0x555555557d38
0x555555557d38 <_ZTV7Derived>: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 # 置 0
0x555555557d40 <_ZTV7Derived+8>: 0x88 0x7d 0x55 0x55 0x55 0x55 0x00 0x00 # typeinfo for Derived
0x555555557d48 <_ZTV7Derived+16>: 0x3c 0x52 0x55 0x55 0x55 0x55 0x00 0x00 # Derived::~Derived() [complete object destructor]
0x555555557d50 <_ZTV7Derived+24>: 0x6a 0x52 0x55 0x55 0x55 0x55 0x00 0x00 # Derived::~Derived() [deleting destructor]
0x555555557d58 <_ZTV7Derived+32>: 0x9a 0x52 0x55 0x55 0x55 0x55 0x00 0x00 # Derived::flip()
从值中,可以发现第一个 QWORD 为 0,其余 4 个都是地址。根据我们对汇编的观察,_ZTV7Derived+8
应该指向 typeinfo for Derived
,我们将其打印出来:
-exec x/24xb 0x555555557d88
0x555555557d88 <_ZTI7Derived>: 0x98 0xbc 0xf7 0xf7 0xff 0x7f 0x00 0x00
0x555555557d90 <_ZTI7Derived+8>: 0x08 0x60 0x55 0x55 0x55 0x55 0x00 0x00
0x555555557d98 <_ZTI7Derived+16>: 0xa0 0x7d 0x55 0x55 0x55 0x55 0x00 0x00
typeinfo for Derived
的第二部分指向的应该是 typeinfo name for Derived
,即:.string "7Derived"
我们来看一下:
-exec x/9xb 0x555555556008
0x555555556008 <_ZTS7Derived>: 0x37 0x44 0x65 0x72 0x69 0x76 0x65 0x64
0x555555556010 <_ZTS7Derived+8>: 0x00
将 37 44 65 72 69 76 65 64
转换成字符,正是:7Derived
验证了我们的观察。
我们可以将剩余三个函数的地址打印出来,如下所示:
-exec x/8xb 0x55555555523c
0x55555555523c <Derived::~Derived()>: 0xf3 0x0f 0x1e 0xfa 0x55 0x48 0x89 0xe5
-exec x/8xb 0x55555555526a
0x55555555526a <Derived::~Derived()>: 0xf3 0x0f 0x1e 0xfa 0x55 0x48 0x89 0xe5
-exec x/8xb 0x55555555529a
0x55555555529a <Derived::flip()>: 0xf3 0x0f 0x1e 0xfa 0x55 0x48 0x89 0xe5
通过 GDB 的提示,我们知道分别对应的 Derived
的两个析构函数,和 flip
函数。
调用虚函数过程
接下来,我们加入调用虚函数的过程,同时也加入一个非虚函数的调用做对比。观察其在汇编下的行为有什么不同。示例代码如下:
class Base
{
public:
bool flag = true;
virtual ~Base() {}
virtual void flip()
{
flag = !flag;
}
void reset()
{
flag = true;
}
};
class Derived: public Base
{
public:
bool extra_flag = true;
int count = 0;
virtual ~Derived() {}
virtual void flip() override
{
flag = !flag;
extra_flag = !extra_flag;
}
void iterate()
{
count++;
}
};
int main()
{
Base* actual_derived = new Derived();
actual_derived->flip();
actual_derived->reset();
delete actual_derived;
}
我们在基类中加入了一个非虚函数 reset()
,在 main
函数中对这两个函数进行调用,汇编结果如下所示,两个函数对应的汇编部分在红框内:
先看 actual_derived->flip();
对应汇编代码如下所示:
mov rax, QWORD PTR [rbp-24] // 获取 actual_derived 地址
mov rax, QWORD PTR [rax] // 获取 *actual_derived 地址
add rax, 16 // 偏移 16 个字节,此时 rax 指向虚表中第三个 QWORD (对应 XXX::flip() 的地址)
mov rdx, QWORD PTR [rax] // rdx 指向 [rax],即 Derived::flip() 的起始位置
mov rax, QWORD PTR [rbp-24] // 将 actual_derived 地址复制给 rax
mov rdi, rax // 将 actual_derived 复制给 rdi 作为入参
call rdx // 通过函数指针调用 rdx 对应的函数
可以发现,在汇编代码中,实际上编译器是不知道具体调用的是哪个函数,它做的只是找到变量的虚表中,找到 flip
对应的函数指针的位置(因此遍历虚表的过程发生在编译而不是运行期),绑定并且调用该函数而已。下面我们来看非虚函数调用:
mov rax, QWORD PTR [rbp-24]
mov rdi, rax
call Base::reset()
非虚函数的调用的就很简单了,直接调用 Base::reset()
即可。因此无需遍历虚表。相对开销也小(省去了虚表指针偏移和绑定的过程)。调用虚函数在运行时才确定调用函数这个过程体现了 C++ 的多态。
早绑定和晚绑定
上述的例子中,编译时不指定具体的函数调用而是通过虚表中函数指针的调用叫晚绑定,即运行时才绑定要调用的函数。但是在这个例子中,程序逻辑很简单,我们很明显可以知道 actual_derived->flip();
调用的是 Derived::flip()
。有没有办法省去从虚函数中获取函数指针的过程,直接调用 Derived::flip()
呢?事实上,现代编译器大部分都会对这种情况进行优化,在可以确定基类指针指向对象的类型的话直接调用该类的虚函数。这次我们使用 -O1
优化选项进行编译,查看汇编代码,如下所示:
可以发现,优化之后两个函数的调用直接变成了两行:
mov BYTE PTR [rax+9], 0
mov BYTE PTR [rax+8], 1
这里编译器的优化过程大致为:通过语法分析判断 actual_derived
指向的是一个 Derived
类型,然后绑定该函数调用。确定调用的函数之后,再对 flip()
和 reset()
进行优化,省去了函数调用的过程,直接对对象进行操作。因此最后的结果为:actual_derived->extra_flag = 0
, actual_derived->flag = 1
。这种在编译期就对虚函数进行绑定的现象就是早绑定。这种时候从汇编代码来看实际上是体现不出来多态特性的。
那如果我们将派生类的类型依赖于用户输入呢?实例代码如下所示:
class Base
{
public:
bool flag = true;
virtual ~Base() {}
virtual void flip()
{
flag = !flag;
}
void reset()
{
flag = true;
}
};
class Derived: public Base
{
public:
bool extra_flag = true;
int count = 0;
virtual ~Derived() {}
virtual void flip() override
{
flag = !flag;
extra_flag = !extra_flag;
}
void iterate()
{
count++;
}
};
int main(int argc, char** argv)
{
Base* maybe_derived = nullptr;
if (argc > 1)
{
maybe_derived = new Derived();
}
else
{
maybe_derived = new Base();
}
maybe_derived->flip();
maybe_derived->reset();
delete maybe_derived;
}
我们将 maybe_derived
的类型依赖于输入参数的个数,然后查看汇编代码,可以发现,虽然开了 -O1
同样进行了部分优化。但是虚函数指针偏移这一步没办法进行优化了,因为编译器不知道在运行时 maybe_derived
到底指向哪个类。
注意事项
在之前的例子中,我们一直用基类指针指向派生类来体现 C++ 的多态特性,那么如果我们不用指针,而用一个派生类来初始化基类局部变量呢?在实际编译之前可以不妨先思考一下其中的逻辑,显然由于派生类的大小和基类不一定相同(可能有额外的成员变量),因此编译器在分配内存时没办法确定派生类的大小,因此只能分配基类的大小。那么用派生类来初始化基类对象时有两种可能:
- 直接复制内存,这样显然内存会越界因此不太可能
- 通过静态类型转换将派生转换为基类然后用拷贝构造来初始化基类对象,这样相当于损失了派生类的性质,也没有了多态特性。
接下来直接看代码:
class Base
{
public:
bool count = true;
virtual ~Base() {}
virtual void flip()
{
flag = !flag;
}
void reset()
{
flag = true;
}
};
class Derived: public Base
{
public:
bool extra_flag = true;
int count = 0;
virtual ~Derived() {}
virtual void flip() override
{
flag = !flag;
extra_flag = !extra_flag;
}
void iterate()
{
count++;
}
};
int main(int argc, char** argv)
{
Base fake_derived = Derived();
fake_derived.flip();
}
汇编代码如下所示:可以发现,虽然构造了一个 Derived
,但是并没有使用其本身信息,而是从中获取变量位置和 count
值,然后调用了 Base
类的拷贝构造函数。调用 flip()
时也是直接调用了 Base::flip()
。因此验证了我们的猜想,在局部变量的情况下不会体现多态特性。