【C++修炼】面向对象—多态
多态
1. 多态的概念
多态 (polymorphism):通俗来说,就是多种形态。多态分为静态多态(编译时多态) 和动态多态(运行时多态)。重点是运⾏时多态。
- 静态多态,例如函数重载和函数模板就是静态多态,通过参数使函数达到多种形态。函数重载靠形参,模板靠模板参数,根据不同类型的参数调⽤不同的重载或模板实例。之所以叫编译时多态,是因为参数的类型匹配是在编译时完成的,而编译时通常归为静态,运⾏时通常归为动态。
- 动态多态,传不同的对象就会完成不同的⾏为 (函数)。⽐如买票,普通⼈要全价买票;学⽣优惠半价买票;军⼈免排队优先买票。再⽐如动物的叫声,传cat类对象过去,就是喵喵叫;传dog类对象过去,就是汪汪叫。
2. 多态的定义及实现
2.1 多态的构成条件
多态是继承关系下,不同类对象调⽤同⼀函数,产⽣了不同的⾏为。⽐如Student继承父类Person,Person对象原价买票,Student对象半价买票。(重载或模板本质是根据不同参数类型,实例化不同函数进行调用,前者由用户进行,后者交给编译器)。
实现多态的两个重要条件是:
- 被调⽤的函数必须是虚函数,且子类完成了虚函数的重写/覆盖。完成重写才可以实现不同行为,达到多种形态的效果。
- 必须是父类的指针或引⽤调⽤虚函数。因为只有父类的指针或引⽤才能既指向父类对象,⼜指向子类对象。
2.2 虚函数
在前面继承章节,我们提到了虚继承和菱形虚继承。所谓虚继承,就是定义子类时在继承方式前加上virtual。虚函数同样用到virtual关键字,但它们是两回事,只是恰好用同一个关键字而言,一个用于继承关系,一个用于函数。
- 类的成员函数前加virtual修饰,该成员函数称为虚函数。强调:⾮成员函数不行。
代码示例 2.2.1
class Person { public: // 在成员函数前加 virtual 成为虚函数,注意必须是成员函数 virtual void BuyTicket() { cout << "买票全价" << endl; } };2.3 虚函数的重写/覆盖
- 在子类中,实现⼀个跟父类有“三同”的虚函数,则称子类的虚函数重写了父类的虚函数。(三同:返回值类型、函数名、参数列表完全相同)
注意:重写父类的虚函数时,子类的虚函数可以不加virtual关键字,也可以构成重写。我们可以理解成,父类的虚函数先被子类继承下来了,所以在子类中已经具有虚函数属性,再由子类进行重写。不过这种写法并不规范,不建议这样使⽤,但在一些笔试题中,却经常会故意埋这个坑,让人判断是否构成多态。
总结一下:
- 通过父类的指针或引用调用虚函数(父类对象都不可以)。
- 子类要完成重写,即实现和父类相同的虚函数:函数实现可以不同,但返回值类型/函数名/参数列表必须相同。
代码示例 2.3.1
#include <iostream> #include <string> using namespace std; class Person { public: virtual void BuyTicket() { // 原价买票 cout << "Person: Buy at full price." << endl; } }; class Student : public Person { public: // 在子类中完成重写 virtual void BuyTicket() { // 半价买票 cout << "Student: Buy at half price." << endl; } }; // 必须是父类 Person 的指针或引用 void Func(Person* ptr) { // 这⾥可以看到虽然都是 Person 指针 Ptr 在调用 BuyTicket // 但是跟 ptr 没关系,而是由 ptr 指向的对象决定的 ptr->BuyTicket(); } int main() { // 定义子类对象 st 和父类对象 ps Person ps; Student st; // 父类对象调用 Func Func(&ps); // 子类对象调用 Func Func(&st); // 子类可以赋值给父类(会切割子类) return 0; }运行结果:
代码示例 2.3.2
#include <iostream> #include <string> using namespace std; class Person { public: // 不满足多态:BuyTicket 不是虚函数 void BuyTicket() { // 原价买票 cout << "Person: Buy at full price." << endl; } }; class Student : public Person { public: // 不满足多态:子类不写 virtual 可以继承,父类不可以 virtual void BuyTicket() { // 半价买票 cout << "Student: Buy at half price." << endl; } }; // 改成引用 Person& ptr 也满足多态,但改成 Person 对象就不满足,必须指针或引用 // 改成子类也不可以,子类对象/指针/引用都可以切割赋值给父类对象/指针/引用,反过来不行 // 除非父类的指针/引用本就指向子类,那父类的指针/引用就可以赋值给子类的指针/引用 void Func(Person* ptr) { // 满足多态,ptr 指向谁调用谁; // 不满足多态,ptr 指针的类型是什么就调用什么,如 Person* 则调用 Person::BuyTicket ptr->BuyTicket(); } int main() { Person ps; Student st; Func(&ps); // 如果 ptr 是 Student* 就只能指向子类对象,不能指向父类对象 Func(&st); return 0; }运行结果:
- 相比重载和模板,多态实现复用的关键,不是函数本身实现,在于父类指针或引用具体指向的对象。
可以看到,始终都是同一函数Func,但ptr指向的对象不同,Func产生的结果不同,这便是多态。重载和模板则是对函数本身,Func本身进行复用,实现了多种参数类型的Func进行个性化处理。利用多态,Func函数中其他代码逻辑可以被复用,但关键行为结点,如虚函数ptr->BuyTicket(),则会根据父类和子类中的不同实现,进行不同行为,Func得到不同结果。
除了父类和子类,也可能同时存在多个子类,子类和子类之间完成不同重写,ptr指向不同子类对象,实现子类之间的多态(子类的指针/引用无法指向其他子类,可见,这也是必须用父类的指针/引用的原因之一)。
代码示例 2.3.3
#include <iostream> using namespace std; // 基础动物类 class Animal { public: virtual void talk() const { // 动物叫声 // ... } }; // 狗 class Dog : public Animal { public: virtual void talk() const { std::cout << "Dog: Woof!Woof!" << std::endl; } }; // 猫 class Cat : public Animal { public: virtual void talk() const { std::cout << "Cat: Meow~Mewo~" << std::endl; } }; // 父类指针,指向不同子类对象 Cat/Dog void Hear(const Animal& animal) { animal.talk(); } int main() { Cat cat; Dog dog; Hear(cat); Hear(dog); return 0; }运行结果:
2.4 多态笔试题练习
下面程序的输出结果是 ( ) ?
A:A->0 B:B->1 C:A->1 D:B->0 E:编译出错 F:以上都不正确
代码示例 2.4.1
#include <iostream> using namespace std; class A { public: virtual void func(int a = 1) { std::cout << "A->" << a << std::endl; } virtual void test() { func(); } }; class B : public A { public: void func(int b = 0) { std::cout << "B->" << b << std::endl; } }; int main() { B* p = new B; p->test(); return 0; }解析
首先有几点信息:
- B和A是继承关系;
- 子类对func函数完成了重写:virtual虚函数属性可以从父类中继承,返回值类型同为void,函数名同为func,形参都为int变量。注意,形参名称虽然不同,但和重载那一块一样,我们通常关注的是形参的类型,而非名称,包括缺省值虽然不同,也不影响。
- 子类没有重写test,虚函数可以不重写,但无法构成多态。
根据上面这些信息,我们来分析主要代码逻辑。用父类指针可以指向父类对象、子类对象以及其他子类对象,所以它是构成多态的必要条件之一。对于p->test(),test是成员函数,第一个参数永远是隐式的this指针,成员函数内通过this指针访问成员,p是B*指针只能指向B对象,那就只能给this传参B对象,所以p只能调用B中成员。这里又有一个问题:test是子类继承的,那它的this指针是什么类型?A*还是B*?实际上,继承只是“形象地说法”,并不会真的从父类拷贝一份到子类。继承影响的是搜索规则,先在子类搜索,找不到再去父类,如果同名成员构成隐藏则屏蔽父类作用域。所以子类的test还是父类中的同一个,this指针仍然是A*,因此调用func是构成多态的,this->func()的this是父类指针,结果取决于this指向的对象是谁。this只能指向B对象,那就只能调用子类中重写的func。
到这里,我们只能算行百里者半九十。如果选 D 那就功亏一篑了。
因为:
- 重写的本质,是对函数的实现进行重写。绝对不应该重新定义继承而来的成员函数形参的缺省值。
什么意思?我们可以理解为,子类对func的重写,实际是把花括号{}内的代码给替换掉了。也就是A 进行了函数的声明 virtual void func(int a = 1),但B 进行了函数定义的重写 { std::cout << "B->" << b << std::endl; }。B中void func(int b = 0),形参b的缺省值是0,但真正由A进行函数声明,形参的缺省值其实是1,所以形参b被初始化为1。正确答案应该是 B:B->1。
我们再来进行一下简单理解。关于“三同”,为什么有这3点?因为子类对虚函数重写,函数如何声明根本不重要,不管是返回值类型、函数名、形参或者缺省值,全部都在父类中声明过了,重写只会覆盖函数的具体定义。所以,函数声明照抄,跟父类保持相同,表明身份即可,反正不重要。
代码示例 2.4.2
#include <iostream> using namespace std; 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(); // p 是子类指针,不满足多态 // 也没有 test 中的 this 父类指针来构成多态 // 所以这里完全不可能涉及多态,这就是一个普通调用,不需要考虑重写 // p 只能指向 B 对象,那就原原本本的调用 B 的 void func(int val = 0) p->func(); // 结果:B->0 return 0; }运行结果:
可以看到,明明p始终指向B对象,结果却不一致,不属于多态、不属于重载、不属于模板,三不沾,妥妥的大坑Bug,而且隐藏的极深。
再次强调:对于从父类继承而来的函数,子类重写时,千万不要重写定义形参的缺省值。
2.5 虚函数重写的⼀些其他问题
协变(了解)
子类重写时,返回值类型在部分情况可以不同,如协变:父类的虚函数返回父类对象的指针/引⽤;子类的虚函数返回子类对象的指针/引⽤。实际意义不⼤,了解⼀下即可。
注:返回父类/子类对象的指针或引用,这里的父子不一定是自己,也可以是别的父子。例如Person中虚函数返回值类型恰好是父类Animal*,子类Student/Teacher进行重写时,允许返回值类型不同,未必要相同返回Animal*,可以返回Animal的子类的指针/引用,例如Dog*或者Cat&。
代码示例 2.5.1
class A { // ... }; class B : public A { // ... }; class Person { public: virtual A* BuyTicket() { cout << "Person: Buy at full price." << endl; return nullptr; } }; class Student : public Person { public: // 部分情况下,重写允许返回值类型不同,但必须是对应的父子关系,返回指针/引用 virtual B* BuyTicket() { cout << "Student: Buy at half price." << endl; return nullptr; } };如果返回值类型不同,但不是父子关系,或者不是指针/引用,或者父子关系反了 (Person中返回B*,Student中返回A*),都不满足协变要求。
报错:
析构函数的重写 (需要注意)
析构函数的重写也很容易踩坑,需要特别注意,一些笔试面试题中比较喜欢出。
回顾一下前面继承章节的部分原文:
- 编译器会将析构的函数名称特殊处理成destructor(),例如~Person和~Student。所以在不加virtual的情况下,子类析构和父类析构满足隐藏。(注:多态场景下,析构函数需要构成重写,满足重写的条件之⼀就是函数名相同,后面多态章节会讲解)。
因此,尽管子类析构名称和父类析构名称不同,但底层是一致的,都是destructor()。所以,父类析构为虚函数,子类析构只要定义就是重写(不管子类写不写virtual,子类析构都会继承父类析构的虚函数属性)。
代码示例 2.5.2
#include <iostream> using namespace std; class A { public: // 父类析构为虚函数 virtual ~A() { cout << "~A()" << endl; } }; class B : public A { public: // 子类析构只要定义就是重写,~A 和 ~B 底层都叫 destructor ~B() { cout << "~B()->delete: " << _p << endl; delete _p; // 释放动态数组空间 } protected: int* _p = new int[10]; // 申请十个整型元素大小的数组空间 }; // 父类析构为虚函数,Student 必须定义了析构,才会满足重写 // 父类指针 p1/p2 调用析构才能构成多态,才能保证根据 p1/p2 真正指向的对象正确调⽤析构 // 如果不满足多态,由于 p1/p2 都是 A*,指针是什么类型就调用谁的,两个都会调用 ~A() int main() { A* p1 = new A; A* p2 = new B; // delete 会调用析构释放资源 delete p1; // 调用析构 p1->~destructor() delete p2; // 调用析构 p2->~destructor() return 0; }运行结果:
p1指向A对象,delete p1调⽤~A();p2指向B对象,delete p2调⽤~B();前面继承章节说过,子类对象有3部分:内置类型、自定义类型和父类部分。父类部分当作整体,作为一个父类对象进行任何处理,又为了保证析构是先子后父,所以子类析构结束后会自动调用父类析构~A()。
假如,~A()不加virtual,不构成多态,那么delete p2调⽤的就是~A(),而不是~B()。B对象中的资源得不到释放,导致内存泄漏。
这都是为了解决父类的指针或引用的问题:父类的指针/引用既可以指向父类对象,也可以指向子类对象,但父类和子类实现不同,有不同的资源情况,而p1和p2都是A*,delete p1和delete p2只会调用~A(),对于指向子类对象的p2来说是不行的。所以析构必须为多态,编译器才会对析构的函数名称进行特殊处理。
总结:如果存在继承关系,那么父类析构和子类析构必须是虚函数,这样子类才能完成析构的重写,才能构成多态。
2.6 override 和 final 关键字
C++对虚函数重写的要求⽐较严格,有些情况下容易疏忽,⽐如返回值写错、函数名写错、参数写错等都⽆法构成重写。是否完成重写这种错误是不会编译报错的,只有自己运行程序后没有得到预期结果才会发现。
因此C++11提供了两个关键字:
- override:可以帮助用户检测虚函数是否有重写。
- final:让父类的虚函数无法被子类重写 (final在前面继承章节出现过,被final修饰成为最终类,就无法被子类继承。这里修饰的是虚函数)。
代码示例 2.6.1
#include <iostream> using namespace std; class Car { public: // 函数名故意写错,正确应该是 Drive virtual void Dirve() { // ... } }; class Benz : public Car { public: // 在子类重写的虚函数后面加个 override 关键字 // override 会判断各种条件,看是否正确重写 virtual void Drive() override { cout << "奔驰——舒适" << endl; } }; int main() { // ... return 0; }编译报错,函数名不同,不构成重写:
代码示例 2.6.2
#include <iostream> using namespace std; class Car { public: // 不希望父类的虚函数被重写,在函数后面加 final 关键字 virtual void Drive() final { // ... } }; class Benz : public Car { public: // 无法重写,虚函数被 final 修饰,编译报错提醒 virtual void Drive() { cout << "奔驰——舒适" << endl; } }; int main() { // ... return 0; }无法重写被 final 修饰的虚函数,编译报错:
2.7 重载/重写/隐藏的对⽐
拿这3个进行对比是因为它们具有一定的相似性,都是两个函数名相同的函数之间的关系。
函数名相同的两个函数:
- 不在同一作用域,就不是重载。重载不看返回值,只看参数列表中参数的类型和个数。
- 分别在父类和子类中,只有函数名相同,那就是隐藏。
- 分别在父类和子类中,函数名、参数列表、返回值类型都相同,且父类的那个加了virtual,那就是重写。返回值部分情况可以不同,例如协变。
3. 纯虚函数和抽象类
- 纯虚函数:无需函数定义,只需要函数声明,等待子类进行函数定义的重写。在虚函数的后⾯写上=0。
纯虚函数不需要定义实现,实现没意义,因为要被子类重写,但是语法上可以实现。
- 抽象类:只要这个类有纯虚函数,那就是抽象类。抽象类不能实例化出对象。如果子类不重写纯虚函数,那么子类也是抽象类。
抽象事物是没有实体的,所以抽象类这个名字,其实就是在说明它不会有实体。纯虚函数某种程度上强制了子类必须完成虚函数的重写,因为子类不重写纯虚函数它就得继承下来,子类就变成了抽象类,就无法实例化出对象 (override不同,它是直接语法规则限制。如果不实例化抽象类对象,那就无所谓重不重写,也就不存在所谓的强制了)。
代码示例 3.1
#include <iostream> using namespace std; // 动物 class Animal { public: // 作为基础类,单纯的“动物”是没有具体叫声的,talk 的实现没有意义 // 因此,可以定义为虚函数,函数后面加个等于0即可 // 只需函数声明,无需函数定义 virtual void talk() const = 0; // 纯虚函数 virtual void talk() const = 0 可以实现,但没有意义 //virtual void talk() const = 0 //{ // // 没有意义 //} }; // 狗 class Dog : public Animal { public: // 子类不重写纯虚函数,子类也是抽象类,因为子类会继承得到父类的纯虚函数 }; // 猫 class Cat : public Animal { public: // 重写纯虚函数,子类就不是抽象类了 virtual void talk() const { std::cout << "Cat: Meow~Mewo~" << std::endl; } }; int main() { // Animal 类中有纯虚函数,则为抽象类,抽象类不能实例化对象 // 报错,C2259: “Animal”: 无法实例化抽象类 // Animal* pA = new Animal; // 子类 Dog 继承得到纯虚函数,同样是抽象类 // 报错,C2259: “Dog”: 无法实例化抽象类 // Animal* pD = new Dog; // Cat 重写了纯虚函数,不是抽象类,可以成功实例化 Animal* pC = new Cat; // Animal 是抽象类,但可以用抽象类的指针或引用 pC->talk(); return 0; }运行结果:
4. 多态的原理
4.1 虚函数表指针
下⾯编译为32位程序的运⾏结果是什么 ( ) ?
A、编译报错 B、运⾏报错 C、8 D、12
代码示例 4.1.1
#include <iostream> using namespace std; 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; }运行结果:
我们知道,对象中只存储成员变量。整型变量占4个字节,字符变量再占1个字节,最后内存对齐一下,答案应该是8。
但最终答案是12字节。实际上是因为多了一个指针__vfptr,32位下指针为4字节。对象中的这个指针叫做虚函数表指针(v即virtual,f即function,ptr即指针)。__vfptr被放在对象的前⾯ (有些平台可能会放到对象的最后⾯)。
- 一个含有虚函数的类,编译器会为其生成一张虚函数表,存放所有虚函数的地址。该类每一个对象,都会包含一个隐藏的虚表指针,指向这个类的虚表。
这里的表其实就是个数组,存储了指向虚函数的函数指针,严格来说虚函数表其实就是函数指针数组。再看上面的图片,[0]其实就是数组下标为0的元素,即1个函数指针,存储了函数地址0x00a01546,也就是Base::Func1(void)的地址。如果再多几个虚函数,那就再多存储几个函数指针,再多几个数组元素。但注意,如果不是虚函数不会存入虚表。
注:虚函数表 virtual function table,所以 __vfptr 应该是 __vftptr 会更完整准确一点,少了个表 table 的缩写 t。
4.2 原理
4.2.1 多态是如何实现的
代码示例 4.2.1.1
#include <iostream> #include <string> using namespace std; // 普通人原价买票 class Person { public: virtual void BuyTicket() { cout << "Person: Buy at full price." << endl; } private: string _name; }; // 学生半价买票 class Student : public Person { public: // 虚函数重写 virtual void BuyTicket() { cout << "Student: Buy at half price." << endl; } private: string _id; }; // 军人优先买票 class Soldier : public Person { public: // 虚函数重写 virtual void BuyTicket() { cout << "Soldier: Buy tickets with priority." << endl; } private: string _codename; }; void Func(Person* ptr) { // 由父类指针 ptr 指向的对象决定买票结果 ptr->BuyTicket(); } int main() { // 多态可以发生在父类和子类之间,也可以发生在同一父类的子类之间 Person ps; Student st; Soldier sr; Func(&ps); Func(&st); Func(&sr); return 0; }可以看到,3个对象的__vfptr虚表指针地址都不一样,说明虚表各个类自己单独有一份。并且每个虚表在[0]处,分别存储了这3个类BuyTicket的地址,函数重写后地址各不相同。
问题是:从底层的⻆度,Func函数中的ptr->BuyTicket(),为什么ptr指向Person对象就调⽤Person::BuyTicket,ptr指向Student对象就调⽤Student::BuyTicket,具体如何实现?
- 我们知道,文件经过预编译阶段与编译阶段会得到目标文件,代码语句被翻译成汇编指令,函数调用变成call [函数地址],如
。
- 编译包括多个小阶段,例如语法分析和语义分析,编译器会调用语法分析器和语义分析器,检查语法语义。如果ptr->BuyTicket()满足多态,则会去ptr指向的对象中查找函数地址。
- ptr的类型是Person*,对于指针而言,它指向的始终是Person对象。即使是子类,ptr指向的也是子类对象中经过切割得到的父类对象。
- 因此,不管是父类还是子类,编译器只需要去这个Person对象查找虚表指针即可。虚表指针可能在对象的前4个字节,也可能在之后4个字节 (64位下指针为8 Bytes)。
- 从父类对象取到虚表指针后,就能找到这个类的虚表,或者说函数指针数组。找到数组,就能找到各个函数指针,如果是父类,存储的就是父类的虚函数的地址;如果是子类,存储的就是子类重写的虚函数的地址。
满⾜多态条件后,底层不再是“编译时,通过调⽤对象确定函数地址”,⽽是“程序运行时,到指针指向的对象中,找这个类的虚表,通过虚表确定虚函数的地址”。这就是所谓的动态多态,或者说运行时多态。
4.2.2 动态绑定与静态绑定
- 不满⾜多态,父类的指针/引⽤调⽤虚函数,函数调⽤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 的类型 Person*,找到 Person 确定函数地址 ptr->BuyTicket(); 00EA2C91 mov ecx,dword ptr [ptr] 00EA2C94 call Student::Student (0EA153Ch)从运行效率的角度来说,静态绑定的效率会高一点,只需要两句指令,动态绑定则需要运行时再去找。
4.2.3 虚函数表
- 同类型对象虚表共用,不同类型对象虚表各自独立。因此,父类和子类的虚表不同。父类存父类的虚函数;子类存子类的虚函数。
- 子类重写了父类的虚函数,子类虚表中,原本是父类虚表中的虚函数地址,则被覆盖成子类重写的虚函数的地址 (逻辑上,可以理解为子类先拷贝了一份父类虚表,以此为基础进行覆盖)。子类如果不重写,就不会覆盖地址,即该虚函数在父类和子类的虚表中地址是相同的。
- 子类虚表包括三部分:1. 父类的虚函数地址 (未重写覆盖);2. 子类重写覆盖后的虚函数地址;3. 子类⾃⼰新定义的虚函数的地址。
- 子类有两部分:父类部分和子类成员。子类对象继承的父类部分包括虚表指针,子类对象无需再⽣成。但注意,子类对象中的父类对象,是子类对象调用构造初始化时调用父类构造定义初始化的,虚表指针其实也是新创建的,并指向了子类虚表。就像定义了两个普通的父类对象,虚表是父类对象共有的,但虚表指针是对象各自独有的变量。
- 虚函数表本质是⼀个存储虚函数指针的指针数组。通常,这个数组最后⾯放了⼀个0x00000000标记(C++没有规定标准,各编译器⾃⾏定义,Visual Studio 系列编译器会在后⾯放0x00000000标记,g++系列编译器不会放)。
就像字符串一样,字符数组存储字符串,要求最后1个元素必须存储'\0',当然和这里没关系,只是相似。虚表这里是一种标记,而且不同编译器的实现也不同,像g++系列就不放,算不上语法规则。
- 虚函数存储在哪?和普通函数一样,虚函数编译后就是⼀句句汇编指令,都存在代码段,只是虚函数的地址还会单独放一下,存储到虚表/函数指针数组中,存到虚表的是指针,是虚函数的地址,而不是虚函数。
- 虚表存储在哪?或者说函数指针数组存在哪?严格来说没有标准答案,C++标准没有规定,但VS是存在常量区的。
注:代码区存储可执行代码 (机器指令);常量区存储常量数据,如字符串常量等;静态区存储全局变量和静态变量;栈区存储局部变量等;堆区存储动态分配的内存空间。
代码示例 4.2.3.1
#include <iostream> using namespace std; class Base { public: virtual void func1() { cout << "Base::func1" << endl; } virtual void func2() { cout << "Base::func2" << endl; } // func5 不是虚函数,不存到虚表 void func5() { cout << "Base::func5" << endl; } protected: int a = 1; }; class Derive : public Base { public: // func2 虚函数不重写 // 重写父类的 func1 virtual void func1() { cout << "Derive::func1" << endl; } // 子类自己的虚函数 func3 virtual void func3() { cout << "Derive::func1" << endl; } // 子类自己的普通函数 func4,不存到虚表 void func4() { cout << "Derive::func4" << endl; } protected: int b = 2; }; int main() { // 看看栈、堆、静态区、常量区的地址大概是多少 int i = 0; // i 为局部变量,栈区 static int j = 1; // j 为静态变量,静态区 int* p1 = new int; // p1 指针变量本身是局部变量,栈区;p1 指向的是动态内存空间,堆区 const char* p2 = "xxxxxxx"; // p2 指针变量本身是局部变量,栈区;p2 指向的是字符串常量,常量区 printf("栈:%p\n", &i); // 打印变量 i 的地址 printf("静态区:%p\n", &j); // 打印变量 j 的地址 printf("堆:%p\n", p1); // 打印 p1 指针指向的地址 printf("常量区:%p\n", p2); // 打印 p2 指针指向的地址 // 看看虚表地址是多少,比对一下 Base b; Derive d; Base* p3 = &b; // 父类指针 p3,指针变量在栈上,指向的父类对象也在栈上 Derive* p4 = &d; // 子类指针 p4,指针变量在栈上,指向的子类对象也在栈上 // 如何拿到虚表的地址?VS下,32位下,拿到对象的前4个字节即可取得虚表指针,虚表指针存储了虚表的地址 // int 变量4个字节,所以 int* 指针解引用后也取4个字节 // p3 存储了父类对象的首地址,Base* 解引用取地址整个父类对象的字节大小 // p3 强制类型转换变成 int*,int* 解引用则只取父类对象的前4个字节 // p4 同理,只取子类对象的前4个字节 // (int*)p3 和 (int*)p4 可分别获取父类的虚表指针和子类的虚表指针 // 还需要对虚表指针进行解引用,获取指针存储的值,即虚表的地址 printf("Base 虚表地址:%p\n", *(int*)p3); printf("Derive 虚表地址:%p\n", *(int*)p4); // 看看代码区的地址:虚函数和普通函数都存储在代码区,只是指向虚函数地址的指针被存储在了虚表中 printf("虚函数地址:%p\n", &Base::func1); // 函数名即函数首地址 printf("普通函数地址:%p\n", &Base::func5); // 函数名即函数首地址 return 0; }运行结果:
可以看到,两个虚表地址都是00E0AB,常量区也是00E0AB,它们是最接近的。也就是说,在Visual Studio下,虚表或者说函数指针数组,被认为是常量。
放到常量区是合理的,类被定义虚表就会存在,定义类对象时则是创建虚表指针指向虚表,所以虚表应该是整个程序始终全局存在的,而且运行时是只读,不可修改。
如果放到堆,也可以全局存在,只不过需要系统动态申请,再由系统自己释放。
如果放到静态区,可以像全局变量全局存在,也可以像静态变量只读,而且静态区的变量随程序结束自动释放。
如果放到栈上,就不是很合理,因为都是局部变量,函数调用结束就销毁,定义两个对象,其中一个在普通函数内部,函数结束对象被销毁虚表被销毁,这是不合理的。