文章目录
- 前言
- 一、虚函数表
- 二、一道经典的例题
- 三、深度剖析多态的条件之一:为什么必须是父类的指针或引用
- 四、深度剖析多态的条件之二:为什么是虚函数的重写/覆盖?
- 五、虚函数表的一些总结
- 六、关于Func3的验证
- 七、动态绑定与静态绑定
- 八、总结
前言
在前面,我们也了解了多态的定义、概念、实现。对于多态的使用,有很多需要注意的细节,可谓到处都是坑!了解了多态的使用,那么现在我们来了解一下多态的原理吧。
一、虚函数表
我们先来猜猜下面程序的运行结果是多少?
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
private:
char _c = 1;
};
int main()
{
cout << sizeof(Base) << endl;
return 0;
}
我们可能会以为是1,实际上运行结果是8
那么为什么是8呢?
我们可以进入调试观察一下,我们会发现它里面似乎多了一个指针
这个指针是四字节的话,那么内存对齐一下,刚好是8个字节。
那么这个指针究竟是何方神圣呢?实际上这个指针是虚函数指针(v代表虚拟,f代表函数,ptr是指针)。从后面的vftable也可以看出来,它是一个虚函数表
从这里我们也可以知道,我们一般不使用多态的话,最好还是不要加上virtual,因为是有开销的。
这个虚表里面存储的就是虚函数的地址。而虚函数是存放在代码段的。
如果我们有两个虚函数的话,那么这个虚表里面就有两个虚函数的地址
以上是由于虚函数导致的对象中的一些变化,虚函数是应用于重写的。那么重写的时候会发生什么呢?
我们接下来使用如下代码
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
int _a = 1;
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
int _b = 1;
};
void Func(Person& p)
{
p.BuyTicket();
}
int main()
{
Person p;
Func(p);
Student s;
Func(s);
return 0;
}
我们对其进行分析:下面是监视窗口里面的样子
我们不妨将他们用下图代替,即用下面的图更能清晰的表达他们的关系
根据上面的图中,我们可以注意到,所谓的重写,其实从原理层的角度来看,其实就是将虚表里面的地址给覆盖了。才导致调用不同的函数。所以重写是语法层的概念,覆盖是原理层的概念。
对于子类的虚表,我们也可以认为是将父类的虚表给拷贝下来了,然后在将重写的给覆盖上去。
这个时候我们就知道了如何实现的指向父类调父类,指向子类调子类了。
所以现在,我们知道了多态是如何实现的。如果是父类的对象,进行调用的时候那么自然就是调用它的虚表里面的函数,如果是将子类对象,使用指针或者引用进行切片的话,本质上还是指向子类,只不过是指向子类中的父类的那一部分罢了,而我们这里的虚表中的地址已经被替换了。所以当然可以实现调用不同的函数了。
其实如果是普通的调用的话,那么它在编译的时候地址已经被确定了。
如下就是普通调用的时候,在编译的时候地址早已被确定好了,所以它恒定的调用一个函数。这里也就解释了为什么必须是基类的指针和引用。如果是对象的切片的话,这里的虚表中的内容是不会被切片过去的,p调用函数的时候地址在编译时候早已被确定了。
如果符合多态的话,运行时到指向对象的虚函数表中找调用对象的地址。不是在编译时候就确定了地址了
我们也不管他是子类还是父类,即便是子类,经过切片后,也是一个父类。我们只需要找到对应的虚表中的地址就可以了。我们可以看到,同一个函数,多态调用的时候指令都变多了
二、一道经典的例题
我们来看下面这道题,猜猜它选什么呢?
class A
{
public:
virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
virtual void test() { func(); }
};
class B : public A
{
public:
void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};
int main()
{
B* p = new B;
p->test();
return 0;
}
这道题的运行结果为
看到这里,我们肯定已经蒙了,这是为什么呢?为什么是这么一个出乎意料的结果呢?
我们现在来分析一下代码
首先我们定义了一个派生类的指针,指向了B对象。然后我们现在想用这个派生类的指针去调用test函数,这里是可以去调用的,因为子类继承了父类
在test函数里面,只有一个功能就是调用func函数,注意,这里的func函数是由this指针来进行调用的,只不过是this指针隐藏了。
现在我们来思考一下,这里调用func是不是多态调用呢?
我们知道,多态调用有两大条件:父类的指针或引用去调用虚函数,这个虚函数必须是重写的虚函数。
那么这里的this指针是父类的指针吗?答案是这里的this指针确实是父类的指针,而不是派生类的指针!
为什么是A*父类的指针呢?因为这里的func函数是继承下来的。这里的继承并不是单纯的将test函数在派生类生成了一份,编译器不会那样做的。
继承的对象模型是这样做的,它的对象模型分为两部分,一部分是将父类的整体当成一个成员给拿下来,这里父类会自己内存对其等操作,然后另一部分就是自己的本来的成员,经过与父类对象进行内存对齐以后,整个进行建模。然后这些成员函数它都是在代码段的,它并不会生成多份的。编译的时候是检查语法的,先去派生类里面去找,找不到再去父类里面去找。 所以test不会有两份,所以这里只能是A*指针了。这样就满足了多态的第一个条件了。或者说这里发生一个切片,B*指针切片给了A*类型的指针。
第二个条件是虚函数的重写,那么这个func满足虚函数的重写吗?其实是满足的,首先有基类和派生类里面都有func函数,这两个函数满足虚函数加三同的条件,注意形参的类型相同指的是类型的相同,有没有缺省参数,缺省参数是多少跟他们没有任何关系,即便是形参名字不同也是无所谓的。
所以现在满足了多条的条件,已经是多态的调用了。我们知道多态的调用看的是指向的对象是哪里。而这里我们的A*的指针是由B*的指针切片得到的,所以这里实际指向的是一个派生类,那么自然就调用的是派生类的func了
此时我们以为得到了正确答案B->0,实则不然,我们又调入了一个大坑里面,我们要注意,多态改变的是函数的实现,虚函数加上三同只是可以告诉我们说这个构成了多态。换言之,多态在调用的时候,前面的部分,即返回值形参函数名这些看的是基类的部分,而实现的部分看的是多态的调用,即指向的对象的那一部分。而在这里,形参使用基类中的1,实现打印B。
所以最终结果为B->1
甚至于我们还可以将派生类中的缺省参数给去掉也是没有任何问题的
甚至于我们可以直接换名
但是我们不可以连形参名字都不写了,因为我们在里面毕竟用到了val了,如果连val名字都不写的话是不行的
所以说,虚函数的重写,重写的只是实现,那一个壳用的还是基类的。这里也印证了为什么派生类可以不加virtual。因为只是重写的实现。
如果我们将上面的题稍作修改,如下所示:那么结果又会如何变化呢?
class A
{
public:
virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
};
class B : public A
{
public:
void func(int val = 0) { std::cout << "B->" << val << std::endl; }
virtual void test() { func(); }
};
int main()
{
B* p = new B;
p->test();
return 0;
}
这里比较有意思的是,我们只是将test这个函数换在了B类里面,即派生类里面,这样的话我们p调用test的时候,this指针和p一样了都是派生类的指针类型,这已经不构成多态的条件了,所以是一个普通的调用,就直接看的是派生类中的这个函数了所以结果为B->0
三、深度剖析多态的条件之一:为什么必须是父类的指针或引用
我们在回过头来看一下多态的条件为什么是那两条:1.基类的指针或引用去调用虚函数2.被调用的函数必须是重写的虚函数
为什么必须是父类的指针或引用,子类的指针或引用为什么不可以呢?为什么不能是父类的对象呢?
先回答第一个问题:因为只有父类的指针才可以指向子类和父类,如果是子类的指针的话就只能指向子类了,不能指向多种形态了。这个问题还算比较容易理解
再来回答第二个问题:我们知道对象的切片和指针与引用的切片是有一些不同的,我们先要知道对象切片和指针切片的差异是什么。为了演示这个差异,我们使用如下代码
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
virtual void Func1() {};
virtual void Func2() {};
protected:
int _a = 0;
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
protected:
int _b = 1;
};
void Func(Person& p)
{
p.BuyTicket();
}
int main()
{
Person ps;
Student st;
return 0;
}
如下是监视窗口中的样子
我们使用如下方式进行展现,这样方便我们进行观察
此时我们还是比较容易理解的,这里两个对象,分别有他们自己的虚函数表。这里的虚函数表严格来说应该是一个指针,指向着虚函数表。
我们知道下面的三种方式都是切片,那么他们的差异究竟在哪里呢?
ps = st;
Person* ptr = &st;
Person& ref = st;
首先毋庸置疑的是,如果是指向父类的指针,那么它指向的是一个父类的对象,看到的自然就是父类的虚表了。
如果是指向子类的指针或引用的话,那么它指向的是一个子类中的父类的那一部分,看到的其实是子类中的虚表,这个虚表是经过虚函数重写覆盖过的。
所以说指针和引用的切片他们是不存在任何的拷贝的问题的。
而对象的切片就存在拷贝的问题了。
当我们使用对象的切片的时候,子类中的父类部分的成员变量肯定是都会被拷贝过去的,但是虚表会被拷贝过去吗?我们可以测试一下
为了方便我们观察,我们可以提前先修改一下派生类中_a的值
然后我们在使用对象的切片,如下图所示,是未切片的时候
如下所示,是切片发生之后
我们已经发现,对象的切片,并不会改变虚表,所以虚表是不会进行拷贝的
那么为什么不拷贝虚表呢?拷贝虚表会带来什么问题呢?
我们可以这样思考一下,假如我们将派生类中的虚表给拷贝过去了,那么我们使用ps这个父类对象给取出它的地址以后,使用这个指针去调用它里面的函数的话,就会反而调用了派生类中的函数。这个明显有点奇怪。因为指向父类的居然调用了子类的函数。这明显不符合多态的要求。毕竟多态是指向什么类就调用什么类的。这样做显然就会乱套的。
所以我们得到一个结论:子类赋值给父类对象切片,不会拷贝虚表。如果拷贝虚表,那么父类对象虚表中的究竟是父类虚函数还是子类虚函数就不知道了,因为我们并不知道究竟这个对象有没有被赋值切片过。总之,就乱套了
上面这个结论,也就回答了我们前面的问题,为什么多态的条件不能是父类的对象。
四、深度剖析多态的条件之二:为什么是虚函数的重写/覆盖?
在前面我们也已经提到过,虚函数的重写/覆盖本质就是是什么?
在语法层面称之为重写,重写的是它的实现。所以有时候我们也会提出一个概念,普通的函数的继承称为实现继承,而多态,虚函数的重写,其实就是一个接口继承,然后重写它的实现
在原理上就是说将父类的虚函数表给拷贝下来,然后将子类中重写的部分给覆盖。
其次,因为只有完成了虚函数的重写,那派生类的虚表里面才能是派生类的虚函数。这样的话,这个基类指针才能做到指向父类调用父类,指向子类调用子类。
五、虚函数表的一些总结
-
派生类对象st中也有一个虚表指针,st对象由两部分构成,一部分是父类继承下来的成员,虚表指针也就是存在这一部分的,另一部分是自己的成员。
-
基类b对象和派生类d对象虚表是不一样的,这里我们发现BuyTicker完成了重写,所以d的虚表中存的是重写的Student::Buyticker,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法
-
另外Func1和Func2继承下来后是虚函数,所以放进了虚表,如果Func2不是虚函数,那么它也继承下来了,但是因为不是虚函数,所以不会放进虚表
-
虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr 。(注意不是所有的编译器都会给的,g++编译器就没有给,而且有时候vs的编译器有一些问题,不会给这个nullptr,这时候我们可以自己清理一下解决方案,然后重新编译一下就有了,这里算是一个编译器的bug)
下面的就是给了nullptr的
-
总结一下派生类的虚表生成:
a. 先将基类中的虚表内容拷贝一份到派生类虚表中
b. 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数
c. 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后
注意c这个小点中,虽然它会将这个添加到派生类虚表的最后,但是我们的监视窗口有时候是看不见的,如下所示,我们并没有看到Func3的虚函数表中的地址。
但是我们是可以从内存窗口看到有一个地址的,这个地址就是Func3的虚表中的地址。这里算是一个监视窗口的一个bug
所以说监视窗口和内存窗口有时候还是有一些不一样的。准确的来说,这里我们也不能断言说这里一定是Func3函数的地址。因为我们并没有给出证明,所以后面我们会给出一个证明。
- 还有一点是虚表是存储在哪里的呢?是栈区or堆区or数据段(静态区)or代码段(常量区)这四个中的哪一个呢?
首先我们就可以排除的是堆区,因为堆区还需要new,delete一下,编译器大概率是不会这样做的
然后我们还可以排除的是栈区,因为如果是存在栈区的话,那如果是两个栈帧的话,里面的虚表的地址肯定是不一样的,而我们经过下面的测试,发现地址是一样的,也就是说他们共用虚表,所以可以排除栈区。当然其实也不能百分之百排除掉栈区,因为万一存储在main函数的栈帧中呢?但是大概率还是不会存储在main中的。
同时上面的情形还说明了一件事,同类型的对象共用虚表
然后我们就可能会去猜测是静态区中存储着虚表,实际上不是的,虽然说网上的很多答案都是静态区,不过这个答案其实是错误的。
我们可以使用如下代码去验证:
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
virtual void Func1() {};
virtual void Func2() {};
int _a = 0;
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
virtual void Func3() {};
protected:
int _b = 1;
};
int main()
{
int a = 0;
printf("栈区:%p\n", &a);
int* p = new int;
printf("堆区:%p\n", p);
static int b = 0;
printf("静态区(数据段): %p\n", &b);
const char* str = "hello world";
printf("常量区(代码段): %p\n", str);
Person ps;
printf("Person: %p\n", *(int*)&ps);
Student st;
printf("Student: %p\n", *(int*)&st);
return 0;
}
我们先来解释一下这段代码,前面都很简单,最后两个打印的时候,由于对象里面是没有虚表的,但是有一个虚表指针,并且这个指针就是第一个成员变量,所以我们ps的地址就是虚表指针的地址,然后我们为了可以直接用这个虚表指针的地址去打印出来虚表所在的地址,于是我们就对其进行强制类型转换为int*,因为我们的指针是四字节的。然后我们直接解引用,就可以拿到这个虚表指针所指向的值了。由于这个虚表指针本身就是一个二级指针,里面存储的就是一个地址,这个地址所指向的就是虚函数所存储的地址了。
或许你已经被绕晕了,不要紧,我们来画个图来直观的感受一下:
而我们上面所进行的操作,正好取出来的就是绿色方块里面的值,也就是一个地址,这个地址就是虚函数表的地址。相信大家这会儿已经听懂了吧
而我们最终的运行结果是这样的
我们对比后发现,与常量区,即代码段的数值最为接近。所以虚表应该存储在常量区/代码段
那么虚函数存储在哪里呢?
如果直接打印地址的话,恐怕并不好打印,有点繁琐,我们不如直接在监视窗口里面观察
可以注意到,虚函数显然距离常量区更近一些。所以也是存储在常量区的
六、关于Func3的验证
我们在前面中提到了,监视窗口中的虚表少了一个func3的地址,但是当我们进入内存查看的时候,存在一个指针。那么这个指针究竟是不是func3我们还需要进行验证。
我们想要验证这个东西,我们得先将虚表里面的地址看能否给拿出来。只要能拿到虚表里面的函数地址,我们就可以去调用这些函数从而判断是不是该函数
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
virtual void Func1()
{
cout << "Person::Func1()" << endl;
};
virtual void Func2()
{
cout << "Person::Func2()" << endl;
};
int _a = 0;
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
virtual void Func3()
{
cout << "Student::Func3()" << endl;
};
protected:
int _b = 1;
};
typedef void (*Func_Ptr)();
void PrintVFT(Func_Ptr* table)
{
for (int i = 0; table[i] != nullptr; i++)
{
printf("[%d]:%p\n", i, table[i]);
}
cout << endl;
}
int main()
{
Person ps;
Student st;
int vft1 = *(int*)&ps;
PrintVFT((Func_Ptr*)vft1);
int vft2 = *(int*)&st;
PrintVFT((Func_Ptr*)vft2);
return 0;
}
如上所示的代码就是可以打印出虚表,上面代码的原理是这样的,由于虚表是一个函数指针数组,每一个函数指针都是void(*)()类型的指针。所以我们直接使用typedef一下方便我们使用这种类型的指针,然后我们在想办法取出虚表的地址。这个取法在前文中已经提及了。然后我们就可以直接去打印这个虚表了。注意:我们这里使用的vs2022 ,x86环境的,我们的指针都是4字节的,其次vs在虚表结束的时候是会添加一个nullptr的,如果是Linux环境的话,首先默认是x64环境的,所以指针是八字节的,在取地址的时候就要小心了。我们不能用int类型了,可以使用long long类型的。其次Linux环境下最后是不会在虚表的结尾补一个nullptr的,所以就不能像我们上面那样使用了。必须得写死了才能打印出虚表。
如下就是我们此时打印出来的虚表
现在我们已经有了虚表中的每一个函数的地址了,那么有了函数的地址了,再去调用这个函数就非常之简单了,我们对前面的代码稍作修改,得到如下代码,可以去正常访问每一个函数
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
virtual void Func1()
{
cout << "Person::Func1()" << endl;
};
virtual void Func2()
{
cout << "Person::Func2()" << endl;
};
int _a = 0;
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
virtual void Func3()
{
cout << "Student::Func3()" << endl;
};
protected:
int _b = 1;
};
typedef void (*Func_Ptr)();
void PrintVFT(Func_Ptr* table)
{
for (int i = 0; table[i] != nullptr; i++)
{
printf("[%d]:%p->", i, table[i]);
table[i]();
}
cout << endl;
}
int main()
{
Person ps;
Student st;
int vft1 = *(int*)&ps;
PrintVFT((Func_Ptr*)vft1);
int vft2 = *(int*)&st;
PrintVFT((Func_Ptr*)vft2);
return 0;
}
对于上面的代码,我们简要的分析一下,我们的目的是为了打印虚表中的每一个函数,虚表本质是一个函数指针数组,注意它与虚基表是不一样的,虚基表是菱形虚拟继承中用来存储偏移量的。虚表是虚函数表,简称虚表,它本质是就是一个函数指针数组。我们可以给每个虚函数都加上打印。因为我们在前面已经取出来了每一个函数的地址,这里就有点类似于回调函数中的做法。我们有了函数地址,它就可以当作函数名直接调用这个函数,然后观察打印结果就可以验证。
注意在这里我们有时候可能会遇到程序崩溃的情况,这是因为vs的一个bug,本来在虚表后面是要补一个nullptr的,但是我们有时候生成完解决方案以后去修改了代码,可能就不会添加这个空指针了。从而导致程序调用野指针,程序崩溃。这时候我们只需要重新生成一下解决方案即可解决这个问题。
从上面的运行结果来看,是由Func3的,那么这里就已经验证了Func3的存在是在虚表中的,也就说明了那个指针确实是Func3。至于监视窗口没有显示Func3函数的地址,可能是由于编译器的bug
这里其实也说明了一件事,我们不要太过于相信监视窗口,只有内存窗口里面的才是最真实的
不过需要注意的是,上面的代码其实是被精心设计过的,它并不是正常的访问方式,首先我们的虚表中每一个函数我们的类型都设置成了一模一样的,否则的话在调用函数的时候必然因为指针的类型不同而出现问题。
其次我们的函数都是没有访问成员变量的,一旦函数里面存在访问成员变量的话,可能会出现很多问题。毕竟我们的是非正常访问,是没有this指针的。这里的非正常访问方式是无视类域的限制的,即便是私有的照样可以访问。因为他们都只是语法层面的限制,我们这里直接从内存中去找到对应的地址去调用的。
七、动态绑定与静态绑定
我们有时候又将多态分为静态的多态与动态的多态
所谓静态的多态,一般是指编译时的多态,也就是函数重载
比如下面的例子:
int main()
{
int i = 1;
double d = 1.1;
cout << i << endl;
cout << d << endl;
return 0;
}
即不同的对象调用不同的函数,这些是在编译时候就确定好了的。通过函数名修饰规则等,来匹配不同的函数。我们也将之称为静态绑定
静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态 。它与普通的调用是一样的,在编译时就确定了地址
如下所示,现在所处的就是一个普通的调用。它在编译时就确定好了地址。
而下面这个则是多态的调用,编译器也不知道调用的到底是谁,反正就是通过一系列方法将这里面的函数给取出来去调用
这里也就是动态的多态,即运行时的多态,他是通过继承,虚函数重写实现的多态。
八、总结
本次主要讲解了多态的底层原理。深入浅出的讲解了虚函数表,深度剖析了多态的条件,以及虚表的很多细节。希望能对大家带来帮助