C++虚函数与继承
作者刚升入大三,最近在找实习.在此之前多次学习过C++虚函数相关的问题,但都云里雾里不透彻.借这次找实习的机会,深入研究一下虚函数相关内容,以求尽量深入并在日后能快速复习,这篇文章需要一定的使用经验和基础,希望能帮助到你.
继承及其特征
继承是现代C++很重要的一个特性.利用类之间的继承,我们可以便携的设计接口,也可以使用向上类型转换达到类型擦除的目的.
例如在一个线程池中,我们可以使用一个类作为线程池的框架,但把挂起和唤醒的函数设置为虚函数.当我们想要使用epoll+eventFd调度时我们可以直接新建一个继承类,想要用条件变量我们可以新建另一个类来继承.而任务运行的循环不必修改.这样的设计能让程序更加便于扩展和维护.
还是上面的例子.假设我们要支持很多类型的任务.我们可以设计出一个Task类,其中包含run接口,这样我们的线程池中运行任务只需要调用run方法就能存储并运行不同类型的任务而不改变线程池逻辑(类型擦除),新添任务类型只需呀继承Task类并实现run接口即可进行任务调度.
总结上面的例子,继承需要确保:
- 子类转换为基类时,能正确调用子类重写的方法.
- 子类转换为基类后,调用子类重写方法时仍能正常访问到子类的成员变量(为了1功能的正常)
- 被转换为基类的子类,要能重新转换回来.(上面并未体现)
在C++的继承中,最常见的有三种类型的继承:单继承,多继承和虚继承.
单继承
继承关系: B继承A
我们都知道,cpp实现运行时多态是通过虚函数表实现的,那这个虚函数表是什么,里面装了什么数据,我们如何找到这个虚函数表的呢?
下面的代码以单继承为例子,展示了手动找到虚函数表的过程:(来自https://www.bilibili.com/video/BV1j49mYuEoF/,只在特定编译器版本有效,当前环境gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0):
#include <iostream>
#include <memory>
class A {
public :
A() {}
virtual void print(int num ) {
std::cout << "A " << num << std::endl;
}
private:
int data;
};
class B : public A{
public :
B() {}
void print(int num) override {
std::cout << "B " << num << std::endl;
}
private:
int data;
};
using Func = void (*) (void*, int); // 函数声明和printA是一致的
int main() {
B b;
void** vptr = (void**) &b; // 1 取b开头8字节看作一个指针,vptr就是这8字节
void** vtable = reinterpret_cast<void**>(*vptr); // 2 对vptr解引用找到虚函数表起始位置
Func f = Func(vtable[0]); // 3 把虚函数表前8字节当作一个函数指针,即f
f(&b, 1);
return 0;
}
// output:
// ╭─koddnty@koddnty-Legion-Y7000P-IRX9 ~/Nextcloud/blogs/八股但不表面/多态/codes
// ╰─$ ./single_inheritance
// B 1这段代码的3[1-3]个注释已经说明了代码的流程.这表明了在单继承情况下,构建的对象前面会8字节(vptr)的数据指向虚函数表(vtable),而vtable开始就是一个函数指针. 这里可以直接理解为这个vptr指向的就是当前对象生成的虚函数表,后续会对此进一步说明.单继承情况的内存布局如下图所示:
那为什么需要这个vptr指向一个vtable呢,这样做实现了什么?
首先看vtable的开头,这里的函数指针是B重写的函数指针,也就是说当我们使用当前对象(B)的方法时,总能通过vtable找到B重写的函数指针.而使用vptr,能让我们找到vtable.
考虑一个现实使用中的情况:
void test() {
B b;
A* a = &b;
a->print(2);
}我们知道此时调用的print是B重写的print,通过上述的机制是如何做到的呢?
按照上述内存布局,a指针是通过b取地址得到的,由于A在B的上面(地址更低的位置),因此a == &b(注意这个条件在以后说道的多继承情况下可能不成立).所以a指向的内存开头8字节仍然是vptr.这样就可以找到vptr指向的B构建的vtable进而找到B重写的函数指针进行正确的调用.
此时B的成员变量B::data是如何在a->print(2)调用时找到的呢?
cpp的类方法(非static)参数开头有一个隐藏的this指针,既然a==&b,直接把a传入的效果其实和&b是一样的,就能找到b的数据了.
小结
找到正确的方法需要借助vtpr和vtable,而找到正确的数据需要找到正确的this指针.
多继承
继承关系: C 多继承 A B, 完整代码
基础内存布局
cpp在编排内存时,会把A B按次序放在C的上面,现在假设A放在了最上面,接着是B最后是C.
单继承仅将vptr放在对象开头显然无法做到向上类型转换后仍然能自如的调用C重写的函数.因为c对象转换为B时顶层记录的vptr B就用不到了(因为B* b = &c, b != c, 单继承时说的不总成立的条件).我们不是设计者,直接看内存图:
如图,解决问题的方法很直接,即在B头上也放一个vptr指向C重写B的函数表.但是此时还是有问题:当C转换为B时,对应的指针就指向16字节位置了,这时候调用C的print,显然this指针指向是不对的(单继承提到this指针是确保找到正确数据的关键,详见多继承时类内存地址对比).这个问题可以深入汇编层面去寻找答案.
汇编
用https://godbolt.org/生成代码的汇编代码.重点观察C的构造函数部分(从这里开始,就不能简单认为vptr直接指向v tbale开始了):
"C::C()":
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi
mov rax, QWORD PTR [rbp-8]
mov rdi, rax
call "A::A()"
mov rax, QWORD PTR [rbp-8]
add rax, 16
mov rdi, rax
call "B::B()"
--- ^^^调用了A和B的构造函数,其实A,B构造时已经初始化好了他们头部的vptr.感兴趣的可以自行到网站中查看
mov edx, OFFSET FLAT:"vtable for C"+16
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx
--- ^^^用"vtable for C"偏移16字节位置的内容 覆盖最开头的字节
mov edx, OFFSET FLAT:"vtable for C"+48
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax+16], rdx
--- ^^^用"vtable for C"偏移16字节位置的内容 覆盖最开头的字节
nop
leave
ret
.set "C::C()","C::C()"这里面的细节很多,我们慢慢来看.
单继承时我们简单认为vtable里面仅记录着虚函数指针, 现在我们可以看看vtable for C的内容到底是什么.
-- 并非原始代码,我添加了一些偏移地址和vptr等注释
"vtable for C":
-- 第一块:C为A生成的vtable
0 .quad 0
8 .quad "typeinfo for C"
vptr 16 .quad "C::printA()"
24 .quad "C::printB()"
-- 第二块:C为B生成的vtable
32 .quad -16 -- 用于找到真正的this
40 .quad "typeinfo for C" -- RTTI
vptr 48 .quad "non-virtual thunk to C::printB()" -- 可以暂时看作和 "C::printA()"一样的东西这是编译器生成的汇编代码,构造了一片数据,每一个.quad都会偏移8字节.
所以刚刚填入vptr的内容就是:
- 第一段
"vtable for C"+16的内容就是第三个.quad,即"C::printA()". - 第二段
"vtable for C"+48的内容是第6个.quad,即"non-virtual thunk to C::printB()"
这里面有很多细节,这些细节正好能解释我们最开始的疑惑:
为啥vptr指向16和48字节的位置而不直接指向对应块的顶部?
我们观察可以看到,16和48的位置都是两个函数指针而且正好是在列表的顶部.结合单继承的代码来看,我们通过
vptr就能直接找到真正的虚函数列表(注意不是说虚函数表),但这里在不同编译器会有不同实现,解释只是一个比较合理的说法.两块开头的
0和-16是啥意思? 这里的数据是两个vptr向前偏移16字节位置的数据,位置是固定的.回想一下多继承时遇到的this指针指向不正确的问题,这个问题的答案就呼之欲出了:通过这两个数字,就可以找到真正的this指针.比如B* b = &c,拿到b时先通过vptr找到虚函数表,在向上偏移16字节找到存储的-16,意思是把b偏移-16就能找到真正的对象起始地址.这就解决了this指针指向的问题.
"typeinfo for C"是什么? 这是C的类型信息,和RTTI有关,将在下文中详细阐述.
第一块中C为A生成的vtable为啥包含
"C::printB()"这个B中的虚函数? 这个是最顶层vptr指向的位置,因此c对象也是需要使用这个vtbale的.试想一下,如果不要这个C::printB(),我们在使用c->printB()这个虚函数时需要先偏移找到B开头的vptr,再查vtable找到真正的虚函数指针,绕了很大一圈.因此这里起到了方便调用的作用.当然上述流程其实也是不正确的,看完下一条你就会理解.
最后的
"non-virtual thunk to C::printB()"为啥不直接写作"C::printB()" 这里可以直接把它当作C::printB()虚函数指针,但其中又有些不同.因为如果直接当作C::printB()时,运行时无法区分当前填入的this指针是否需要经过[2]中提到的
-16进行偏移.实际运行过程中,默认不偏移,所以这里就需要手动进行偏移.懂汇编的小伙伴可以看一下这个东西,简单来说就是在调用C::printB()前对this指针进行一次手动偏移."non-virtual thunk to C::printB()": sub rdi, 16 jmp .LTHUNK0#### 小结
找到正确的方法需要继承的基类每一个都记录
vptr指向vtable不同的区域(C继承自A B时对AB分别生成的,其实也可以看作内存上连续的不同的vtable),而找到正确的数据需要通过vtable中的偏移数据找到正确的this指针.
虚继承
虚继承主要解决的是菱形继承情况下类存储重复而问题,试想情况:D继承自BC,BC分别继承自A,此时若没有虚继承,A就会在D对象中存储两份,显然是存在问题的.
cpp中引入了虚继承来解决上面的问题,具体原理比较复杂,但基本上还是上面说的多继承和单继承的原理,不一样的是虚继承时借助一些其他的如RTTI信息实现了继承时的对A这样的基类进行特殊的处理,然后借助虚函数表来定位A类.这里不详细展开,有兴趣的可以自己按上面的流程去https://godbolt.org/看一看.
RTTI向下类型转换
在多继承中,我们讨论了vptr指向的函数指针,向前偏移16字节的对象内存偏移量,那剩下的"typeinfo for C"是干嘛的呢?
首先上面我们说的都是从子类转换到基类的情况,这在上述的过程中显然是十分安全的.当转换的基类想要恢复到子类呢?如果恢复的子类如果不是他原来的类型呢?为了做这样的安全性验证,就需要typeinfo信息了,也称之为cpp的RTTI特性.
还是多继承的代码:
"typeinfo for C":
.quad "vtable for __cxxabiv1::__vmi_class_type_info"+16 // 1
.quad "typeinfo name for C"
.long 0 // 2
.long 2 // 2
.quad "typeinfo for A" // 3
.quad 2
.quad "typeinfo for B"
.quad 4098
"typeinfo for B":
.quad "vtable for __cxxabiv1::__class_type_info"+16
.quad "typeinfo name for B"
"typeinfo for A":
.quad "vtable for __cxxabiv1::__class_type_info"+16
.quad "typeinfo name for A"这上面是汇编后的所有typeinfo信息.可以注意到有3种不同类型的信息.在下面会对其进行依次解释.
"vtable for __cxxabiv1::__vmi_class_type_info"+16
前面已经写到vtable了,说明这个地方是有一个虚函数表的.这里是 RTTI 类型体系自身的虚函数表。不同的 RTTI 类型(例如单继承的
__si_class_type_info和多继承的__vmi_class_type_info)可以通过多态方式执行相应的类型关系判断,而dynamic_cast会利用这些 RTTI 信息完成运行时类型检查和地址调整。单继承通过一条链找到继承关系,多继承通过多叉树找继承关系,而虚继承需要借助偏移找继承关系. 当然这里更深层次还是因为转换后的类在编译期间无法确定对象的动态类型,所以这里才会用到多态进行实现而不是编译期间直接定下转换函数.
.long 0 ... .long 4098
这个数字的作用应分两段看,这是一个64位的数据,以4098为例.
4098 16进制表示为0x1002,低8位是2,这8位是对继承的描述位置(flag),描述继承是否是虚继承等属性.对数据右移8位变为0x10 = 16,正好是多继承中说明的B类型在继承中的偏移量.有了这个数据,就能直接dynamic进行横向从A*到B*类型的转换了."typeinfo for A/B/C"...
"typeinfo for B"表示 B 自身的 RTTI 信息。对于typeinfo for C,其中保存了 C 的直接基类 RTTI 信息,因此可以知道 C 继承了 A 和 B,并结合后面的offset_flags知道它们在 C 中的位置及继承属性。通过这些信息,RTTI 系统可以建立完整的继承关系,从而辅助dynamic_cast进行运行时类型检查。
写在最后
这篇blog前前后后包括学习一共花了我五天的晚上,也算得上是我写的最仔细的blog了.文章大多数内容都是基于我自身借助AI的理解,可能很浅显主观.如果blog中存在纰漏欢迎留言(也可以到我github上留言).另外如果你对这些知识有更高的见解,也欢迎提出改进意见.感谢阅读,祝你有所收获.
附录
1. 多继承时类内存地址对比
void compareAddressInMultipleInheritance() {
C c;
A* a = &c;
B* b = &c;
std::cout << "Address of C: " << &c << std::endl;
std::cout << "Address of A part of C: " << a << std::endl;
std::cout << "Address of B part of C: " << b << std::endl;
}output:
╭─koddnty@koddnty-Legion-Y7000P-IRX9 ~/Nextcloud/blogs/八股但不表面/多态/codes
╰─$ ./multiple_inheritance
Address of C: 0x7ffe0a856ed0
Address of A part of C: 0x7ffe0a856ed0
Address of B part of C: 0x7ffe0a856ee0观察发现a, b地址值差正好是16字节,即一个vptr加上A对象的data大小和4字节的内存对齐.
2.多继承完整代码
#include <iostream>
class A {
public :
A() {}
virtual void printA( ) {
std::cout << "A\n";
}
private:
int data;
};
class B{
public :
B() {}
virtual void printB( ) {
std::cout << "B\n";
}
private:
int data;
};
class C : public A, public B{
public :
C() {}
virtual void printA() override {
std::cout << "C_A\n";
}
virtual void printB() override {
std::cout << "C_B\n";
}
private:
int data;
};
void compareAddressInMultipleInheritance() {
C c;
A* a = &c;
B* b = &c;
std::cout << "Address of A part of C: " << a << std::endl;
std::cout << "Address of B part of C: " << b << std::endl;
}
void baseTest() {
C c;
c.printA();
A* a = &c;
B* b = &c;
C* a_c = dynamic_cast<C*>(a);
C* b_c = dynamic_cast<C*>(b);
a_c->printA();
b_c->printA();
}
int main() {
compareAddressInMultipleInheritance();
return 0;
}

