文章目录
- 1. 理解虚表
- 1.1 虚表
- 1.2 验证
- 1.3 子类虚表
- 1.4 相同类不同对象的虚表
- 2. 静态绑定和动态绑定
- 2.1 静态绑定
- 2.2 动态绑定
- 3. 多态的实现原理
- 3.1 向上转型
- 3.2 多继承
- 3.3 原理
- 4. 拓展
- 4.1 构造函数能不能是虚函数
- 4.2 父类和子类的析构函数在底层的命名问题
- 4.3 对象之间无法实现多态的原因
1. 理解虚表
多态:简单来说就是执行一种行为,不同的对象会表现出不同的执行过程
今天分享一下 C++ 中 多态的实现原理:
1.1 虚表
首先看一下下面这个简单的例子:
很显然,这里分别打印 4
和 8
非常合理,但是如果我们在 Father
类让这两个函数变成虚函数 ,这时候打印结果是多少?
可以看出:Father
类的空间大小变成了 8
字节,但是有没有可能是函数的大小?并不会,因为C++的类成员函数会存放在内存中的代码区(将 virtual
删掉之后,打印分别是 4
和 8
),所以导致空间变大了的原因就是 virtual
这里通过调试就可以看到这个指针 __vfptr
,并且可以看出这个指针子类也有,但是和父类一样但是不完全一样:指针的地址不一致,但是指针的内容一致,这个现象后面再讨论
1.2 验证
实际上,当一个类中有虚函数的时候,这个函数就会存储一个指针 —— 虚表指针,也就是这里的 __vfptr
,顾名思义,指向虚表,也就是虚函数表
而虚函数表中存储的就是虚函数的地址,并且大部分情况下,这个虚表指针__vfptr
在对象模型中会被放在第一位
就拿这个 Father
来说,上述讲的内容可以总结如下:
接下来是验证过程:
① 首先定义一个函数指针,类型就是Father
成员函数的类型void function()
,并且类型起名为function_t
typedef void(*function_t)();
② 那么由于这个虚函数表一般放在内存模型的第一位,那么我们只需要取出前 4
个字节的数据,就可以得到虚表指针了
但是由于毫不相干的指针没法互相转化,所以我们需要做点特出处理
Firstly
:获取father
对象的地址
&father
Secondly
:然后强转成 int*
就可以获取前 4
个字节的 int*
指针
(int*)(&Father)
Thirtly
:然后解引用,就可以获得这 4
个字节的真实数据了对吧
*(int*)(&Father)
Finally
: 这 4
个字节也就是虚表指针的地址,也就是虚函数数组首地址,所以再转化成函数指针,再接收
function_t* ptr = (function_t*) (*(int*)(&father));
③于是成功得到虚表指针,然后我们再对 ptr
解引用,就可以得到第一个虚函数,再调用,就可以成功调用里面的第一个函数了!
主要代码如下:
执行结果如下:
所以我们就成功证明了以上的结论,虚表里面存放的也确实是该类的虚函数,再简单总结一下:
- 如果类中有虚函数,那么这个类对象的第一个成员变量(一般是放在第一位)就是虚表指针,虚表指针指向虚表,里面存放该类的虚函数地址,有多少个虚函数,这个虚表就会有多大
1.3 子类虚表
前面的截图中可以看到,子类继承父类后,子类也有虚表指针,内容一样,但是虚表指针的值不一样
这时候思考一下:子类继承父类之后是直接继承父类的虚表指针咩?如果是直接继承,那么 __vfptr
为何不一样
这时候再修改一下代码:
在子类,对父类的 function1
函数进行重写,这时候调式情况如下:
看出:子类虚表指针的其中第一个虚函数地址变化了
现在再通过同样的方法来调用子类虚表中的第一个虚函数
int main()
{
Father father;
A a;
function_t* ptr = (function_t*) (*(int*)&a);
(*ptr)();
return 0;
}
打印结果如下,得出:调用的就是子类重写之后的函数,子类虚表中改变的那一项就是重写父类虚函数function1
的地址
⭐结论:
Ⅰ 如果父类有虚函数,那么子类会拷贝父类的虚表
Ⅱ 并且如果子类重写了父类的虚函数,则会在虚表中修改同位置的被重写的父类虚函数
Ⅲ 如果子类有自己定义的虚函数,那么也会放到自己的虚表中
结合下面草图理解理解
1.4 相同类不同对象的虚表
那么如果Father
类中有多个实现类,虚表的情况如何 0.o?
对代码稍作修改,调试如下:
总结:可以看出所有Father
对象的虚表内容都是一样的
- 同一个类的所有对象都共用同一份虚表
2. 静态绑定和动态绑定
至此还需要补充一点知识:静态绑定和动态绑定
2.1 静态绑定
概念:程序在编译时期就能确定程序中需要调用的函数地址,即确定程序的行为
2.2 动态绑定
编译阶段无法确定对象调用函数的地址,具体在程序运行的期间,再根据对象或者指针的实际类型,动态地决定使用程序所调用的函数。(运行时在虚函数表中寻找要调用的函数地址)
这一部分大伙可以看这篇文章,作者写的很好,我不多嗦
3. 多态的实现原理
而多态就是基于动态绑定所实现的,如果发生了多态,编译时期无法得知具体程序会调用哪个函数,于是就会进行动态绑定在运行中确定具体需要调用的函数
然后回顾一下多态发生的两个前提条件:
- 重写
子类需要对父类的虚函数进行重写。重写之后,子类和父类有着不同的虚表 - 父类引用/指针 接收 子类引用/指针
例如Father* father = new Son()
在原理之前,看完向上转型可能可以更好地理解
重写父类的虚函数,这个没什么好说的,这里具体看看向上转型:
3.1 向上转型
为了方便讲解,以下的场景,都拿指针来举例子
当父类引用 / 指针接收子类对象的时候,那么这个指针指向的区域是个什么样子?也就是这块内存具体长什么样?
这涉及到了切片
⭐切片的本质就是:舍弃子类成员,但是不是真正意义上的舍弃,只是无法访问
(sizeof
关键字也不会计算子类成员)
如下,还是类似的代码,子类继承了 Father
并重写了 function1
函数
class Father
{
public:
int father;
virtual void function1() {
cout << "this is function1()" << endl;
}
virtual void function2() {}
};
// A B 都是子类
class A : public Father
{
public:
int a;
void function1() { // 重写父类 function1 函数
cout << "son A : this is function1" << endl;
}
};
现在有如下代码:终点是代码中的这两个指针
int main()
{
A a;
Father* ptr1 = &a;
A* ptr2 = &a;
return 0;
}
① A* ptr2 = &a
先分析一下这个代码,这个就是典型的子类指针接收子类对象
首先父类有两个成员,一个虚表指针,一个自己的成员变量 father
,子类 A
会继承父类的属性,并且拷贝虚表并覆盖虚表的内容,如果 A
类中有自己独有的虚函数,也会添加到虚表中
所以 A 指针表示如下
② Father* ptr1 = &a
这里就涉及到了向上转型,会对 a
对象进行切片
所以这和上面那个基本一样
Father*
指针表示如下,也就是粉色部分,子类的特有成员无法被访问
③ Father* ptr3 = new Father
强调一下:需要区分,这个和前面两个是不一样的,这里创建的是父类对象,所以虚表自然也就是父类的虚表
3.2 多继承
如果是多继承的情况,情况又是怎样的
现在对代码稍加需改,让子类 A
多继承一个类:Mother
class Father
{
public:
int father;
virtual void function1() {
cout << "this is function1()" << endl;
}
virtual void function2() {}
};
class Mother
{
public:
int mother;
virtual void function3() {}
};
class A : public Father, public Mother
{
public:
int a;
void function1() { // 重写父类 function1 函数
cout << "son A : this is function1" << endl;
}
};
如果是上面这种继承关系,那么如下指针需要如何表示
int main()
{
A a;
Father* ptr1 = &a;
Mother* ptr2 = &a;
A* ptr3 = &a;
return 0;
}
① 首先第一个问题是创建好的 a
对象模型是什么样的,它继承了Father
和 Mother
,而Father
和 Mother
都有虚函数,也就都有虚表,那么 a
类也就都会拷贝虚表并修改。
② 然后就只需要和上面一样进行切片就好了,最终表示如下
3.3 原理
上面那部分看懂之后,多态的原理可以拿下了,这里做个陈述和总结:
⭐
- C++ 的多态依赖于动态绑定,需要在程序运行过程中确定被调用的函数地址,具体就是查询虚函数表,确定调用的是哪个函数,因此,被调用的时候是在运行的时候才会被确定的。
- 当满足多态的条件之后,父类和子类都会有虚表指针,分别指向各自的虚表,不同的是,子类会拷贝父类的虚表,并将 重写的虚函数地址 覆盖掉原虚表中对应的虚函数,所有的子类都会这样。
- 所以当发生向上转型的时候,会创建子类对象,并且父类指针指向属于父类的那部分(切片)。因此在调用函数的时候,由于不同的子类有不同的虚表,就直接去虚表中调用对应的虚函数最终就可以实现多态。
如果还是有点懵,可以看一下我画的这份草图
所以,就可以根据这样,一个父类接收不同的子类,当调用子类重写函数的时候,就可以实现调用一个父类指针的一个函数,因为接收子类对象的不同,来表现出不同的函数,即多态
4. 拓展
4.1 构造函数能不能是虚函数
虚函数表会在编译阶段完成构建,但是虚函数表中的虚函数需要依靠虚表指针才能实现,然而,虚表指针的初始化发生在对象的构建期间,也就是构造函数中(也就是将虚表的地址赋值给虚表指针)。
这就尴尬了,如果构造函数是虚函数,那么虚函数的调用需要虚表指针才可以完成,然而虚表指针需要在构造函数中初始化
所以 不行。
4.2 父类和子类的析构函数在底层的命名问题
提前说一个结论:在 C++ 中,父类和子类的析构函数在底层的命名都是 destructor
其实目的就是为了形成多态,假设现在有这样的代码Father* ptr = new Son()
,那么当这个对象需要被回收的时候,由于这个指针是 Father
类的,所以就会去调用 Father
类的析构函数,但是子类的成员却没有被释放(虽然是切片,但是子类成员在内存空间中仍然存在)。
因此,如果子类和父类的析构函数同名,那么上述情形就可以发生多态,调用子类的析构函数,并且编译器会在子类的析构函数执行完成之后自动调用父类的析构函数(特性),因此,原因如上。
这也就是为什么父类的析构函数一般都要加上 virtual
修饰的原因
4.3 对象之间无法实现多态的原因
父类对象接收子类对象,不管是赋值操作还是构造操作,都只会处理普通成员变量。一个类中的不同对象的虚表一样,所以父类对象的虚表不会受子类的影响,与子类无关,调用的时候只会调用父类虚表中的虚函数。