💖作者:小树苗渴望变成参天大树🎈
🎉作者宣言:认真写好每一篇博客💤
🎊作者gitee:gitee✨
💞作者专栏:C语言,数据结构初阶,Linux,C++ 动态规划算法🎄
如 果 你 喜 欢 作 者 的 文 章 ,就 给 作 者 点 点 关 注 吧!
前言
今天我们开始讲解多态的底层原理,相信这篇博客会让你对多态的理解会更加的透彻,话不多说我们开始进入讲解
一、虚函数表
我们在多态语法的时候一直强调要构成虚函数,我们来看看虚函数在内存是怎么存储的?
// 这里常考一道笔试题:sizeof(Base)是多少?
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
private:
int _b = 1;
};
我们发现我们对象里面存放两个内容:成员变量和一个指针地址,刚好大小就是8。====
通过观察测试我们发现b对象是8bytes,除了_b成员,还多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表,。那么派生类中这个表放了些什么呢?我们接着往下分析
我们往这这个Base里面增加先的内容:
class Base
{
public:
virtual void Func1()
{
cout << "Base::Func1()" << endl;
}
virtual void Func2()
{
cout << "Base::Func2()" << endl;
}
void Func3()
{
cout << "Base::Func3()" << endl;
}
private:
int _b = 1;
};
class Derive : public Base
{
public:
virtual void Func1()
{
cout << "Derive::Func1()" << endl;
}
private:
int _d = 2;
};
int main()
{
Base b;
Derive d;
return 0;
}
- 派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员,成员包括自身的变量和虚表里面的虚函数。还有一部分是自己的成员。
- 基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
- 另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但不是虚函数,所以不会放进虚表。
- 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。
但是最好每一次都重新生成一下解决方案,不然你在是之前的基础上修改在调试的可能就看不到效果
-
有虚函数的类创建不同对象,共有的是同一张虚表
-
总结一下派生类的虚表生成:a.先将基类中的虚表内容拷贝一份到派生类虚表中 b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数 c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
我们猜想这可能是vs监视窗口的一个bug,一会验证内存当中多出来的地址是不是我们特有的虚函数。
二、多态的原理
为什么需要的是基类的指针和引用调用虚函数
(1)为什么是基类的??
我们在继承的第二节讲到,继承的赋值,子类对象可以赋值给父类的对象指针和引用,父类可以接收自身的,也可以接收子类的,而子类只能接收自己的,如果是强转,可能会造成一系列问题,所以多态规定只能是基类的
(2)为什么是指针和引用去调用?
>是将地址赋值给父类指针变量,引用底层也就是指针道理是一样的,指向什么对象就去调用什么哪个对象的函数了,这样就实现同一行为,展现出不同的形态
(3)为什么虚函数要进行重写
如果不重写,就达不到覆盖的效果,那么子类的虚表还是存的是父类里面的虚函数,虽然你指向子类的虚表,但是虚表里面指向的函数地址还是父类的虚函数,重写了就完成了覆盖,重写就相当于对父类虚函数的重新定义放在了子类的虚表里面了。
那么对象为什么不行??
原因是对象赋值时要调用拷贝构造或者赋值运算符的,子类会进行切片将父类的那一部分拷贝给父类对象,此时子类的虚表指针如果也拷贝过去了,会影响父类对象里面的虚表指针,那样就乱套了,指针和引用并不会改变父类对象里面的变量和虚表指针的。所以就不允许这样的赋值,就算拷贝过来,也是属于父类里面本身的属性拷贝过来,就把d1里面的_b给拷贝过去了,虚表还是父类本身的虚表,那么调用的时候就还是父类的函数,就调不到子类的。
三、解决疑惑
1. 虚函数存在哪的?虚表存在哪的?
答:虚函数存在虚表,虚表存在对象中。注意上面的回答的错的。但是很多童鞋都是这样深以为然的。注意虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。
证明一下:
我们猜想有四个位置:栈,堆,数据段(静态区),代码段(常量区)
我们通过代码来演示:
通过测试我们发现虚表的地址离常量区最近,也就是代码段,有的书上说是在静态区,但是自己测试之后才知道应该离常量区最近。
2. 为什么监视窗口没有特有的虚函数,内存当中多出来的地址是不是我们猜想的结果:
在上面第二节的第六小点,我们发现,子类特有的属性居然不在虚表里面,二内存中却多出来了一个地址,我们猜测是那个特有的虚函数,但是也不能确定,所以我们只能想办法验证:
我们需要写一个函数,将函数数组指针里面的地址取出来,然后再调用
我们确实把地址取出来,接下来直接通过地址来调用:
确实和我们猜想的是一样的
测试代码:
class A
{
public:
virtual void fun1() { cout << "A::fun1" << endl; }
};
class B :public A
{
public:
virtual void fun1() { cout << "B::fun1" << endl; }
virtual void fun2(){ cout << "B::fun2" << endl; }
};
typedef void(*Fun_c)();
void Adderss(Fun_c arr[])
{
for (int i = 0; arr[i] != nullptr; i++)
{
printf("[第%d个地址]:%p\n", i + 1, arr[i]);
arr[i]();
}
}
int main()
{
B b;
// 思路:取出b、d对象的头4bytes,就是虚表的指针,前面我们说了虚函数表本质是一个存虚函数指针的指针数组,这个数组最后面放了一个nullptr
// 1.先取b的地址,强转成一个int*的指针
// 2.再解引用取值,就取到了b对象头4bytes的值,这个值就是指向虚表的指针
// 3.再强转成VFPTR*,因为虚表就是一个存VFPTR类型(虚函数指针类型)的数组。
// 4.虚表指针传递给PrintVTable进行打印虚表
// 5.需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最
//后面没有放nullptr,导致越界,这是编译器的问题。我们只需要点目录栏的-生成-清理解决方案,再编译就好了。
Adderss((Fun_c*)(*(int*)&b));
return 0;
}
四、多继承中的虚函数表
刚才我们说的都是单继承中的,把单继承的原理搞懂,多继承的处理方法其实思路是一样的,有虚表,但是因为是多继承,继承下来的不是一个类的成员,所以再处理方面还是有所不同的,接下来我来给大家介绍,我们的多继承的虚表是什么样的
我们来看测试代码:
class A
{
public:
virtual void funa1() { cout << "A::funa1" << endl; }
virtual void funa2() { cout << "A::funa1" << endl; }//基类特有的虚函数
};
class B
{
public:
virtual void funb1(){ cout << "B::funb1" << endl; }
virtual void funb2() { cout << "B::funb1" << endl; }//基类特有的虚函数
};
class C :public A,public B
{
public:
virtual void funa1() { cout << "C::funa1" << endl; }//重写A类的虚函数
virtual void funb1(){ cout << "C::funb1" << endl; }//重写B类的虚函数
virtual void func1() { cout << "C::func1" << endl; }//派生类特有的虚函数
void func2() { cout << "C::func2" << endl; }//不是虚函数
};
int main()
{
C c;
return 0;
}
通过上面图的结果来看,我们多继承的派生类中有两张虚表,而且猜想派生类特有的虚函数是放在第一张表中,此时我们还是按照上面方法去验证:
果然和我们猜想是一样的
我们再来看看下面的案例:
class A
{
public:
virtual void fun1() { cout << "A::fun1" << endl; }
virtual void fun2() { cout << "A::fun2" << endl; }
};
class B
{
public:
virtual void fun1(){ cout << "B::fun1" << endl; }
virtual void fun2() { cout << "B::fun2" << endl; }
};
class C :public A,public B
{
public:
virtual void fun1() { cout << "C::fun1" << endl; }
virtual void fun3(){ cout << "C::fun3" << endl; }//特有的虚函数
};
int main()
{
C c;
//因为C类的一个fun1是重写了A和B类的虚函数,所以指向谁就调用谁
A* a = &c;
a->fun1();
B* b = &c;
b->fun1();
return 0;
}
通过这个案例我们又可以猜想,是不是地址实际就一个,其中以恶搞是直接找到的,另一个做了一下修改最后也能找到,因为实际想想同一分代码用两个地址存,显然有点浪费空间了,所以编译器也不允许这样的事情发生,带着这个疑问,我们通过汇编来看看是什么样的:
因为fun1的真正地址只有一份,有三种调用fun1的方式,其中两种就是上面画图演示的多态调用,还有一种是c对象自己去调用,而多态调用是去虚表中找,自己就直接调用,最终都需要指向c对象才能去调用,a和c对象的指向刚好重叠了,所以也类似于直接调用,而b对象需要修正,才能去调用。
上面我们说的都不是菱形继承的多继承,前面也说过,尽量不要设计出菱形继承,所以我们去研究也没有什么意义,所以菱形继承、菱形虚拟继承我们的虚表我们就不看了,一般我们也不需要研究清楚,因为实际中很少用。
五、总结
今天讲解的知识还是以往大家下来自己去测试一样,尽量在测试前清理一下解决方案,不然会有影响。相信大家知道了底层原理之后,对于多态的时候应该不在陌生了,就是由于这一系列的底层要求,多态的形成条件才有那么多,也明白了为什么要哪些条件了,这篇博主花了很长时间,帮助自己梳理了一遍知识,也把知识分享给大家啊,希望大家多多支持