c++进阶——多态
文章目录
- 多态概念
- 多态的定义和实现
- 实现多态的前提条件
- 虚函数
- 虚函数的重写/覆盖和使用
- 对多态行为和重写/重载的更深理解
- 虚函数重写的一些其他问题
- 对析构函数的重写
- final和override
- 纯虚函数和抽象类
- 重载/隐藏/重写的对比
- 多态的实现原理
- 虚函数表指针
- 多态是如何实现的
- 虚函数表
- 动态绑定和静态绑定
- 虚函数表的底层原理
多态概念
多态,从字面意思上来看就是多种形态。那对应在我们的c++中又是怎么样的概念呢?
多态分为编译时多态(静态多态)和运行时多态(动态多态),这里我们重点讲运行时多态,编译时多态(静态多态)和运行时多态(动态多态)。编译时多态(静态多态)主要就是我们前面讲的函数重载和函数模板,他们传不同类型的参数就可以调用不同的函数,通过参数不同达到多种形态,之所以叫编译时多态,是因为他们实参传给形参的参数匹配是在编译时完成的,我们把编译时⼀般归为静态,运行时归为动态。
光看这些文邹邹的概念肯定会觉得很迷糊,我们来举几个例子解释一下:
在编程领域,动态多态的具体表现是对于一个函数,如果不同的输入,就会表现出不同的形式。比如我们写了一个动物叫的函数,如果输入的是狗就是汪汪叫,如果输入的是猫就是喵喵叫,不同的动物对应的输出是不一样的。
而函数模板也是构成多态,只不过是静态的。函数模板只是对不同类型的参数有相同的行为。只会在编译的时候进行区分,这种就是静态的多态。函数重载也是一样。因为只有在编译的时候才能知道具体的输出是什么。而动态多态是在输入确定的情况下,这一个函数就能有针对不同输入的输出。这点我们需要注意。
多态的定义和实现
现在这个部分,我们一起来看看多态具体是怎么样操作的。
实现多态的前提条件
首先我们得知道多态的构成条件,也就是构成多态的必要条件。
即多态是出现在继承关系中基类和派生类中的。如果要构成多态,必须是在继承中。对于多态的具体行为则是:在继承关系下的两个类对象,去调用同一个函数,但是这个函数却是产生了不同的行为。
还有两个很重要的条件:
1.必须使用基类的指针或者引用来使用多态行为
2.实现多态的函数必须是虚函数,且构成了重写/重载
这里有很多不认识或者是新接触的概念,但是不着急,我们后面会一个一个讲,当前来说有知道这么一个概念就可以,不至于到后面会一脸懵。
虚函数
我们先来看看第一个概念,即什么是虚函数。
还记得在继承章节讲避免菱形继承的时候,我们引入了一个关键字virtual,即在继承方式前加一个这个关键字形成虚继承。这样子就可以避免某个基类在派生类中造成二义性。
这个关键字本身的意思就是虚拟的意思,虚函数也是需要用到这个关键字:
class Person {
public:virtual void Identity() {cout << "Person" << endl;}
protected:int _id;
};
我们来看看这个Identity函数,直接在以往定义函数的方式前面加一个关键字virtual,这样子就构成了虚函数。它会具有虚函数的性质,这一点我们后面会将,当前先见识一下虚函数是如何定义或者声名的。
虚函数的重写/覆盖和使用
虚函数的重写/覆盖:派生类中有⼀个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称派生类的虚函数重写了基类的虚函数。
乍一看,怎么好像构成了隐藏关系呢?在继承部分讲过,如果基类和派生类中只要有名字相同的函数,就构成隐藏关系。但是我们要注意的是,
此时是针对虚函数而言的,一旦我们在基类中定义了一个虚函数,那么派生类中有重名函数,就是构成了重写/覆盖。如果不加上关键字virtual那确实是构成隐藏。这点我们格外注意一下,它们还是有很大区别的。
注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用,不过在考试选择题中,经常会故意买这个坑,让你判断是否构成多态。
这么多概念一时间肯定是无法消化的。所以我们来简单的举几个例子。
比如我们现在想要实现不同动物发出不同的叫的功能,我们可以这样写:
class Animal {
public:virtual void MakeSound() {cout << "Animal:" << endl;}
};class Cat : public Animal {
public:virtual void MakeSound() {cout << "Cat: 喵喵" << endl;}
};class Dog : public Animal {
public:virtual void MakeSound() {cout << "Dog: 汪汪" << endl;}
};class Duck : public Animal {
public:virtual void MakeSound() {cout << "Duck: 嘎嘎" << endl;}
};void testMakeSound(Animal* animal) {animal->MakeSound();
}
我们来一步一步解释内部的要点:
首先我们在基类Animal定义了一个虚函数MakeSound。已经满足继承中使用和虚函数这两个前提条件了。现在就差对这个函数进行重写/覆盖了。
所谓的重写/覆盖其实是将需要重写/覆盖的虚函数放在派生类中,对内部的实现进行重写。但是对于函数的声名(如返回值,函数名,参数列表(一般是参数形式))要完全相同。这段代码中,对于重写的函数确实是如此做的,只有内部的实现我们发现各有千秋。
现在来看如何使用:
我们回忆一下使用的一个条件,必须是通过基类的指针/引用来调用这个实现了多态行为的函数。其实就是使用在继承中讲过的切片分割转换。需要将派生类转化切片分割给基类的指针/引用,此时编译器会识别出是否有多态行为。如果有就会调用到经过重写的函数。当然具体的原理操作放在后面讲多态原理的时候细说。当前知道编译器会干这个事就好了。
我们来看看使用引用是否可以:
很明显是可以的。
还记得前面说到的,对于虚函数在派生类中的继承,即使我们不加入关键字virtual,也是可以构成重写的,我们试一下看看:
甚至还是部分写了,部分没写。我们可这么理解:编译器在继承的时候识别到了基类有虚函数,且在派生类中发现有同名函数的定义,就认为这是一个对虚函数的重写了。但是如果就连基类中都不写成虚函数,那就根本不可能将派生类中的同名函数识别为虚函数重写了,反而识别成了隐藏关系。
一般来讲是不推荐这么写的,这么写并不规范。故一般还是会在重写的时候加上这个关键字。
对多态行为和重写/重载的更深理解
现在我们已经初步的了解了多态的使用了,我们来看看下面一个例子:
以下程序输出结果是什么()
A: A->0
B: B->1
C: A->1
D: B->0
E: 编译出错
F: 以上都不正确
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;
}
结合目前已知的一些知识,我们来一步一步的进行分析。
- 首先,声名了一个指向派生类的指针p,调用了其默认构造函数。
- 其次,通过指针p去调用test()函数,很明显这个test()函数是从基类A中的公有成员public继承下来的。在B中自然是公有成员,所以可以调用。
- 然后,我们发现这个test()函数是调用了func()函数。此时就面临一个问题,基类和派生类中均有这个同名函数,切构成重写了,调用的是哪一个呢?
- 这时候,我们就需要知道的是,此时已经构成重写了,就需要判断是否构成多态。构成多态三大条件:继承关系、构成虚函数重写、通过基类指针/引用调用
- 此时,我们发现test()是成员函数,自然会有this指针,但现在又面临一个问题,这个test()函数被继承到B中this指针是否指向的是B呢?
- 这里给出结论,继承到派生类中,并不会改动this指针。也就是说,test()函数就算是继承在派生类中,那this指针也是指向原来的基类。我们可以这么认为,继承知识把行为和内容继承原封不动继承下来,只不过在派生类中可以使用或者指定访问,但是不会修改一些原有属性。
- 所以此时,指针p调用test()这个成员函数,其实就隐藏地将p指针传给了this指针,指向A的。也就是发生了派生类向基类的转换。在test()函数中调用的func()函数自然是通过基类的this指针来调用,但是编译器此时已经识别到了构成多态的行为了,所以就去调用了B类中的那个被重写的函数。
所以最后答案是打印出B->0吗?答案其实是B->1。
这十分令人诧异,怎么会是这样一个答案呢?这时候就需要提及对虚函数重写的更深一层理解。即函数重写,重写的是函数内部的实现,对于声名部分,其实可以理解为照搬基类中的那个部分。
这是什么意思呢?我们以上面的例子讲解:
我们已经知道了当前构成多态行为也会调用派生类中的那个函数。但是问题是,虽然基类中的虚函数和派生类中的经重写的虚函数参数类型一样,都是int,但是有缺省值。这时候就得紧抓定义:函数重写是只对内容进行重写。所以即使重写的时候给了不一样的缺省值,也是没有用的,我们可以理解对这个函数的重写是这样子的:
由此可见,声名部分和原本一样,只不过实现部分是被重写了而已。
所以最后,就很自然而然的能理解到为什么这题答案选的是B了。
虚函数重写的一些其他问题
虽然我们前面说了对于虚函数的重写,前面部分是要一模一样,但是也是有一个例外。
这个例外在c++中称为 ”协变“ ,当对虚函数的重写构成协变的时候,是可以让函数的返回值不相同的。但是,也不是随意的,是有要求的。
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。协变的实际意义并不大,所以我们了解⼀下即可。
举个例子看看即可:
class A {
public:void Test() {cout << "A" << endl;}
};class B : public A {
public:void Test() {cout << "B" << endl;}
};class Person {
public:virtual A* BuyTicket() {cout << "买票 —— 全价" << endl;}
};class Student : public Person {
public:virtual B* BuyTicket() {cout << "买票-打折" << endl;return nullptr;}
};
我们发现,在Student类中对BuyTicket函数进行了重写。但是我们发现,两个函数的返回值不同,且在基类中的那个返回值是一个基类A,而在派生类中返回值是基类A的派生类B。这就是协变。也是可以像我们之前那样进行正常使用的。
这个返回值必须按照这个关系对应,即在基类中的返回值是基类,在派生类中的重写是返回值基类的派生类。也可以返回自己这个基类和派生类(即把A改为Person,B改为Student)。
但是这个几乎没有什么用,就了解一下,可能会在选择题中遇见。
对析构函数的重写
在继承部分我们讲到过,基类和派生类中虽然析构函数名字看着是不相同,但其实构成了隐藏关系。即想要访问基类的析构函数需要指定访问。这一切的根本原因就是因为编译器对析构函数进行了特殊处理,编译的时候将析构函数均转化为destructor这个函数名。
可是我们有没有想过为什么要这样子做?这个部分就让我们一起来探讨一下。
我们先将一个结论:在继承体系下,析构函数最好写成虚函数,来看看一个场景就知道了:
class A {
public:virtual void Test() {cout << "A" << endl;}~A(){cout << "~A" << endl;}
protected:int _a;
};class B : public A {
public:virtual void Test() {cout << "B" << endl;}~B() {cout << "~B" << endl;delete[] _ptr;}
protected:int* _ptr = new int[10];
};int main() {A* p1 = new A;A* p2 = new B;delete p1;delete p2;return 0;
}
我们来看看这段代码的运行结果:
我们发现竟然调用了两次A的析构函数,正常来讲,对于delete p2操作,我们更希望调用的是B的析构函数,为什么会这样呢?很明显,这样子会导致内存泄漏。
我们来分析一下:
此时我们通过类型转换,将派生类切片分割给了基类的指针p2,但这始终是基类的指针。然后我们知道,使用delete操作,如果释放的是自定义类型,会自动调用它的析构函数。调用的时候是这样调用的:(p2->析构函数名)。析构函数也是成员函数,p2传给A的this指针。
此时问题来了,如果想要调用的是B的析构函数,那么就得构成多态。要不然肯定调用的是A的析构函数。那再来回忆构成多态的三大条件:继承体系、虚函数重写、基类指针/引用调用。
现在很明显就差虚函数的重写了。而虚函数重写又需要函数名相同,所以,c++才会选择对类的析构函数进行特殊处理,使其函数名在编译的时候变为destructor,这样子就会构成重载了。而我们要做的只是定义虚函数:
此时我们就发现,这样子就可以正常完成对基类的释放了。所以一般都是建议,在继承体系下,析构函数定义成虚函数。
final和override
现在来了解一下两个关键字,分别是final和override.
其实final我们早已见到过,就在继承的知识点中讲过,如果不想让一个类被继承,就可以在定义的后面加入这个关键字,这样子就无法让其被继承了。
但是这里我们要讲的是其另外一个作用,即不让虚函数被重写:
很明显,当我们加入final这个关键字的时候,再对其进行重写就会报错了。
而override的作用就很有意思了,是帮助我们进行检查语法错误的:
C++对虚函数重写的要求比较严格,但是有些情况下由于疏忽,比如函数名写错参数
写错等导致无法构成重写,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug调试会得不偿失,因此C++11提供了override,可以帮助检测是否重写。但是注意,编译器必须支持c++11以后的版本。
现在故意将重写的函数名写错,进行编译发现,并没有报错。
此时很明显并没有完成重写操作。我们试着运行一下试试看:
很明显,没有我们想要的多态的效果。这只是比较简单的场景,如果一旦场景复杂再去花大量时间调试,只为了找出是否有重写这个功能是很浪费时间的,效率也不高。
所以我们可以考虑使用override关键字进行检查:
这个时候就检查出来了,效率非常高。
纯虚函数和抽象类
在虚函数的后面写上 =0 ,则这个函数为纯虚函数,纯虚函数不需要定义实现(实现没啥意义因为要被派生类重写,但是语法上可以实现),只要声明即可。包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派生类继承后不重写纯虚函数,那么派生类也是抽象类。纯虚函数某种程度上强制了派生类重写虚函数,因为不重写实例化不出对象。
只要有纯虚函数,就是抽象类。很明显这里的抽象类Car是无法实例化对象的。
此时对虚函数进行了重写,是可以正常使用多态行为的。
但是我们尝试着不对基类中的纯虚函数进行重写,发现就出问题了。因为纯虚函数不被重写继承下来,导致派生类也是抽象类了,也是不能实例化的。
所以一定程度上,纯虚函数是强迫我们对其虚函数进行重写操作。
重载/隐藏/重写的对比
重点讲解一下:
- 函数重载:函数重载是在同一作用域下,可以写参数不同(类型,个数,顺序)的同名函数。返回值可以相同,也可以不相同。函数重载是静态的多态,具体的输出是通过编译器对输入类型的处理自动匹配最合适的那一个版本的函数。
- 隐藏:隐藏关系出现在类的继承体系下。隐藏主要是指在基类和派生类两个类域下,出现同名的成员变量或者成员函数(注意:函数同名就构成隐藏)。在调用相同成员的时候优先调用派生类的。不需要限制其他的相同。注意将隐藏和重载区分开来,最主要的区别就是作用域的区别。
- 重写:重写也是在类的继承体系下,也是隶属于基类和派生类两个不同的类。区别就是,这是针对于虚函数的。需要加入关键字virtual。构成重写的两个虚函数是返回值、函数名、参数(类型)相同。当然有协变这个例外,可以让返回值不同。还需要注意的是重写的本质:对虚函数的实现进行重写。
多态的实现原理
多态这个概念在笔试或者面试的时候会被经常提及,所以必须对多态背后实现的原理理解。
虚函数表指针
我们先来看第一个概念——虚函数表指针。了解这个概念之前先看看下面这个例子:
下⾯编译为32位程序的运⾏结果是什么()
A. 编译报错
B. 运⾏报错
C. 8
D. 12
class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}
protected:int _b = 1;char _ch = 'x';
};
int main()
{Base b;cout << sizeof(b) << endl;return 0;
}
首先,A、B两个选项肯定是不选的,因为这里并没有出现什么语法错误或者使用错误。
那就来看如何计算大小。按照以前内存对齐的知识,_b变量占4个字节,然后_ch变量紧跟在_b的后面,然后需要对其所有变量中的最大对齐数4,所有最后应该是8个字节。具体的原理就不再多说了。但是这题真的能选C吗?
答案竟然是12,选D,这是为什么?我们进入监视窗口看看:
惊奇的发现,b中的变量竟然多了一个指针变量_vfptr,所以自然而然算出来是12个字节了。这就是导致答案与我们预期产生不符的原理。
这个变量其实就是虚函数表的理解,它的全称是:virtual function pointer,其实这个虚函数表,本质就是将函数的地址放在一个数组中,通过这个_vfptr指向它罢了。我们在类中多写几个虚函数看看:
在Base类中有三个虚函数,进入监视窗口一看,这个虚函数表指针确实是指向了一个有三个数据的指针,这三个数据正好是这三个虚函数的地址。
到这里我们就彻底明白了虚函数表本质上是一个数组。什么数组呢?内部存有虚函数的地址,所以是一个指针数组。什么指针呢?函数指针。所以虚函数表本质是函数指针数组。一定要按照这个顺序去理解,万万不能理解成数组指针了。
多态是如何实现的
我们先来简单介绍一下多态的实现方式。
来看以下这一段代码:
class Person {
public:virtual void BuyTicket() {cout << "买票——全价" << endl;}
};class Student : public Person {
public:virtual void BuyTicket() {cout << "买票——打折" << endl;}
protected:string _stuid;
};class Soider : public Person {
public:virtual void BuyTicket() {cout << "买票——优先" << endl;}
protected:string _codename;
};void TestBuyTicket(Person* person) {person->BuyTicket();
}int main() {Person per;Student stu;Soider soi;TestBuyTicket(&per);TestBuyTicket(&stu);TestBuyTicket(&soi);return 0;
}
我们来理解一下,为什么可以正确的完成多态行为:
学完继承后,我们就知道派生类中的结构了。只要在类中写了虚函数,那就会有虚函数表指针。这个指针指向的就是虚函数表。我们现在将派生类切片分割转化为基类的指针,让基类指针指向派生类。传入的是指向哪个类的基类指针,就会在其内部的虚函数表中找到指定调用的函数,这样子就很自然而然的能找到重写的函数了。
虚函数表
有了虚函数表指针,我们就得了解一下虚函数表是什么。以及虚函数表是怎么样被使用的。
动态绑定和静态绑定
对不满⾜多态条件(指针或者引用+调用虚函数)的函数调用是在编译时绑定,也就是编译时确定调用
函数的地址,叫做静态绑定。
满⾜多态条件的函数调用是在运行时绑定,也就是在运⾏时到指向对象的虚函数表中找到调用函数
的地址,也就做动态绑定。
这是什么意思呢?
也就是说,正常的函数,即不满足多态行为的时候,编译器是可以直直接确定函数的地址的。因为在代码转化为汇编代码的时候,调用函数其实就是使用call 函数地址这个指令来调用。使用函数必须知道其地址。而普通的函数是在编译的时候就能直接确定地址的。
但是满足多态条件的时候,由于每个类中都会有虚函数表指针,指向这个类可以使用的虚函数。但是编译器并没有选择在编译的时候就把虚函数表中要使用的虚函数的地址就拿出来,而是等运行的时候,基类指针指向了这个虚函数时才会确定使用的函数的地址。这种行为就是动态绑定。
我们看下面一段代码就知道了:
// ptr是指针+BuyTicket是虚函数满⾜多态条件。
// 这⾥就是动态绑定,编译在运⾏时到ptr指向对象的虚函数表中确定调⽤函数地址
ptr->BuyTicket();
00EF2001 mov eax, dword ptr[ptr]
00EF2004 mov edx, dword ptr[eax]
00EF2006 mov esi, esp
00EF2008 mov ecx, dword ptr[ptr]
00EF200B mov eax, dword ptr[edx]
00EF200D call eax
// BuyTicket不是虚函数,不满⾜多态条件。
// 这⾥就是静态绑定,编译器直接确定调⽤函数地址
ptr->BuyTicket();
00EA2C91 mov ecx, dword ptr[ptr]
00EA2C94 call Student::Student(0EA153Ch)
这就是二者本质的区别。
虚函数表的底层原理
了解完上面的静态绑定和动态后,再加上前面全部的知识,我们现在就可以对虚函数表进行深入的认识和理解了:
来看看几个要点:
- 基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共用同⼀张虚表,不同类型的对象各自有独立的虚表,所以基类和派生类有各自独立的虚表。
- 派生类由两部分构成,继承下来的基类和自己的成员,⼀般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。但是要注意的这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同⼀个,就像基类对象的成员和派生类对象中的基类对象成员也独立的。
- 派生类中重写的基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派⽣类重写的虚函数地址。
- 派生类的虚函数表中包含,(1)基类的虚函数地址,(2)派生类重写的虚函数地址完成覆盖,(3)派生类自己的虚函数地址
- 虚函数表本质是⼀个存虚函数指针的指针数组,⼀般情况这个数组最后面放了⼀个0x00000000标记。(这个C++并没有进行规定,各个编译器自行定义的,vs系列编译器会再后面放个0x00000000标记,g++系列编译不会放)
- 虚函数存在哪的?虚函数和普通函数⼀样的,编译好后是⼀段指令,都是存在代码段的,只是虚函数的地址又存到了虚表中。
- 虚函数表存在哪的?这个问题严格说并没有标准答案C++标准并没有规定。vs下是存在代码段(常量区)
我们一条一条解释。
第一条什么意思呢?即基类中如果有虚函数,那就有虚函数表指针,虚函数表指针指向的表现在就面临着一个问题。对于同一个类的对象,那张表是一样的,究竟是每一个对象各自有一张表还是每个对象共用那一张表呢?答案是共用的,这样子可以节省更多的空间:
通过监视窗口验证这一点,发现三个虚函数表指向的地址是一样的,也就是共用这一张表。
但是不同的类对应的虚函数表大概率是有所不同的,所以不同类的虚函数表不是同一张表。
第二条要和第三、四条一起看,正常来说,虚函数表也是会被继承下来的,也就是说派生类中的虚函数表会有基类的虚函数表。但是我们刚刚说了,不同的类,它们对应的虚函数表肯定不是同一张表。肯定是不同的,我们还是进入监视窗口看:
我们先不在派生类中写虚函数,我们会发现两个类对象中都有属于自己这个类的虚函数表。很明显,student中虚函数表内的函数地址其实和基类person中虚函数表内函数地址是一样的。也就是内容相同。但是它们这个函数指针数组的地址不一样啊。
这就和两个相同的数组一样的,内容相同,但是它们在内存空间中存储的地址是有所不同的。对于虚函数表这种情况下,我们肯定不会称为是同一张表。
如果我们对一些内容完成重写,并且在基类中有自己写的虚函数呢?
我们对第一个函数进行了重写,很明显发现两个虚函数表中第一个函数的地址就不一样了,也就是一旦重写了就会对原来的虚函数进行覆盖。但是第二个还是一样。但是不是说派生类中的虚函数表会有属于自己的虚函数的地址嘛?怎么看着只有基类的呢?其实肯定是有的,只不过vs这里监视窗口并没有显示,我们进入内存窗口查看即可:
输入第二个虚函数表的地址0x00229c00,发现往后数有三个非虚函数表结尾地址0x00000000的内容,其实看到这也就知道地址0x00229c08中存储的就是派生类中自己的那个虚函数的地址了。只不过监视窗口没有显示而已。
从这我们也看到里第四点内容,即vs编译器下会在虚函数表的结尾放一个结尾地址0x00000000,本意是想和字符串在末尾添加\0一样做为结尾标志。只不过这个并不是每个编译器都会这样做,取决于不同的平台。如g++编译器就不这样做。
再来看第五点:虚函数存储在哪里?虚函数本质就是函数,函数就是在底层编译的时候就是一堆的指令,那和普通函数一样,这些函数的地址都是存储在代码段里面的。只不过说为了完成多态行为,会有虚函数表,会把这些虚函数的地址复制给这个表中。
最后来看虚函数表存储在哪?这个c++没有明确规定,但是vs存储在常量区。具体的存储位置还是要看不同的平台是如何实现的。
一定不存储在栈区,这是因为虚函数表是用一个类的多个对象共用的,如果放在栈区,那么当某次在某个局部域内实例化了一个对象,那么这个对象内的虚函数表指针就会指向那个虚函数表。但是局部域内的东西出了作用域就会被全部销毁,那么一旦这个局部域结束了,里面的所欲呕存储在栈区的东西全部被销毁,包括那个虚函数表。那整个代码流程很可能不是这一个对象在使用这张虚函数表。被用一次就被销毁了,那就导致这个虚函数表指针变成了野指针了。这是很危险的。所以放在栈区肯定是不可能的。
所以多态的本质就是通过虚函数表来实现的。所以这也就是为什么传入哪个类对象的地址,就能调用其重写的虚函数。如果不对其完成重写,那就没有覆盖,那就是调用的是基类的那个函数。这个部分的内容十分重要,需要多家复习。