✨✨ 欢迎大家来到贝蒂大讲堂✨✨
🎈🎈养成好习惯,先赞后看哦~🎈🎈
所属专栏:C++学习
贝蒂的主页:Betty’s blog
前言需知:
本文中的代码及解释都是在vs2022
下的x86
程序中,涉及的指针都是4bytes
。如果要其他平台下,部分代码需要改动。比如:如果是x64
程序,则需要考虑指针是8bytes
问题等等。
1. 多态的引入
1.1. 多态的概念
通俗来说,多态就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。举一个简单的例子:
当我们去买票时,普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优先买票。普通人,学生,军人虽然都属于人这类整体,但是在进行买票这个行为时却会发生不同的状态。这就是一种典型的多态行为。
1.2. 多态的定义
在 C++ 中,多态指的是通过基类的指针或引用,在运行时能够调用派生类中实现的同名函数,从而表现出不同的行为。而构成多态一定要满足两个条件:
- 必须通过基类的指针或者引用调用虚函数。
- 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写。
下面是一个简单的多态调用:
class Person { public: virtual void BuyTicket() //虚函数 { cout << "买票-全价" << endl; } }; class Student : public Person { public: virtual void BuyTicket()//虚函数重写 { cout << "买票-半价" << endl; } }; void Func(Person& p)//基类引用调用 { p.BuyTicket(); } int main() { Person ps; Student st; Func(ps); Func(st); return 0; }
2. 虚函数
2.1. 虚函数的定义
虚函数:即被virtual
修饰的类成员函数称为虚函数。这里的virtual
与虚继承的virtual
毫无关系。
class Person { public: virtual void BuyTicket() { cout << "买票-全价" << endl; } };
2.2. 虚函数重写
虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。
class Person { public: virtual void BuyTicket() //虚函数 { cout << "买票-全价" << endl; } }; class Student : public Person { public: virtual void BuyTicket() //虚函数重写 { cout << "买票-半价" << endl; } };
- 注意:在重写基类虚函数时,派生类的虚函数不加virtual关键字,也可以构成重写。
2.3. 虚函数重写的例外
- 协变:派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用。这里的基类对象可以是可以来自自身的继承体系,也可以来源于其他继承体系。
class A {}; class B :public A{};//不同的继承 class Person { public: virtual A* f() { return new A; } }; class Student : public Person { public: virtual B* f() //协变 { return new B; } };
- 析构函数的重写:我们在前面学习继承时就知道编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成
destructor
,所以只要基类的析构函数加了virtual
关键字,它就一定会形成重写。
class Person { public: //~Person() virtual ~Person() { cout << "~Person()" << endl; } }; class Student : public Person { public: virtual ~Student() //构成重写 { cout << "~Student()" << endl; } }; int main() { Person* p1 = new Person; Person* p2 = new Student; delete p1; delete p2; return 0; }
注意:如果不存在析构函数的重写,那么我们在释放子类对象时就可能造成内存泄漏。
2.4. 重载,重写(覆盖),隐藏(重定义)的对比
3. 抽象类
在 C++ 中,抽象类是一种不能被实例化的类,它至少包含一个纯虚函数。而纯虚函数是一种特殊的虚函数,在类中只有声明,没有定义,其声明的形式为:
virtual 返回值类型 函数名(参数列表) = 0;
class Car//抽象类 { public: virtual void Drive() = 0;//纯虚函数 }; class Benz :public Car { public: virtual void Drive() { cout << "Benz-舒适" << endl; } }; class BMW :public Car { public: virtual void Drive() { cout << "BMW-操控" << endl; } }; void Test() { Car* pBenz = new Benz; pBenz->Drive(); Car* pBMW = new BMW; pBMW->Drive(); }
抽象类一般作为接口定义为派生类规定必须实现的方法,提供一个统一的接口规范。并且实现部分功能可以包含已实现的成员函数和数据成员,为派生类提供一些通用的功能和数据。
4. 静态多态与动态多态
- 静态多态:在程序编译期间确定了程序的行为。比如:函数重载。
- 动态多态:是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数。比如:多态。
5. 多态的原理
5.1. 虚函数表
首先让我们先看看下面这段代码:
//sizeof(Base)是多少? class Base { public: virtual void Func1() { cout << "Func1()" << endl; } private: int _b = 1; };
通过观察测试我们发现b
对象是8bytes,除了_b
成员,还多一个__vfptr
放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。它指向的对象就是虚函数表,一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。
并且派生类继承了基类的虚函数,存在虚函数那就有虚函数表。
class Base { public: virtual void Func1() { cout << "Func1()" << endl; } private: int _b = 1; }; class Derive :public Base {};
我们也不难发现这时如果派生类并没有对基类进行重写,派生类与基类的虚函数表相同。
5.2. 虚函数表的特点
我们将以以下代码作为研究对象:
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; } virtual void Func4() { cout << "Base::Func4()" << endl; } private: int _d = 2; };
- **如果派生类对基类的虚函数进行重写,派生类的虚函数表会发生改变。**所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
- 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个
nullptr
。 - 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。(但是不一定会在监视入口显示出来)
当然我们可以通过一个方法显示打印观察。因为虚函数指针的类型都是void (*) ()
,并且最后一般都是以nullptr
结尾。所以我们可以写一个打印虚函数表的程序:
typedef void(*VF_PTR)(); void printVFTable(VF_PTR *table) { for (int i = 0; table[i] != nullptr; i++) { printf("第%d个虚函数表的地址为:%p->", i+1, table[i]); table[i]();//通过函数指针调用函数 } } int main() { Base b; Derive d; //需要取_vfptr的前4个字节(当然不同平台实现也有区别) printVFTable((VF_PTR*)(*(int*)&b)); cout << endl; printVFTable((VF_PTR*) (*(int*)&d)); return 0; }
从这里我们就可以看出虚函数Func4
地址在VS编译器的监视窗口被隐藏了。
5.3. 多态的原理
多态的原理简单来说就是通过对象里的虚函数表指针,去找到其对应的虚函数表,这是子类对象的虚指针就指向子类的虚函数表,父类对象的虚指针就指向父类的虚函数表,这样它们就能调到对应的虚函数,进而实现多态。
现在我们再来看实现多态的两个条件:
第一个条件为什么要有虚函数的重写?
因为只有子类对父类的虚函数进行了重写,子类的虚函数表里面才会有自己重写后的地址,这样通过对象找到虚表的时候才能调到不同的函数,进而实现多态。
第二个条件为什么必须是父类的指针或引用去调用虚函数呢?
因为父类的指针和引用既可以指向子类对象,也可以指向父类对象啊,我们之前学过,它们之间是支持赋值转换的。
问题:为什么父类的对象不能实现多态?
因为子类对象赋值给父类对象时,相应的虚函数表指针并不会拷贝过去,所以无论是通过子类还是父类都只能调用父类的虚函数。并且如果子类向父类赋值时,能将虚函数表指针一起赋值,那么我们在使用父类调用多态时,根本无法判断其调用的是父类还是子类的虚函数。
6. 多继承中的虚函数表
接下来让我们来探究多继承的虚函数表,我们以下面这段代码为例:
class Base1 { public: virtual void func1() { cout << "Base1::func1" << endl; } virtual void func2() { cout << "Base1::func2" << endl; } private: int b1; }; class Base2 { public: virtual void func1() { cout << "Base2::func1" << endl; } virtual void func2() { cout << "Base2::func2" << endl; } private: int b2; }; class Derive : public Base1, public Base2 { public: virtual void func1() { cout << "Derive::func1" << endl; } virtual void func3() { cout << "Derive::func3" << endl; } private: int d1; };
6.1. 虚函数表的个数
派生类继承了两个基类,并且基类都存在虚函数。所以有两个虚函数表。
6.2. 子类新增的虚函数存放在哪张续表
通过内存我们无法观察子类新增的虚函数func3
存放在哪张虚函数表,这时我们可以通过打印观察:
typedef void(*VF_PTR)(); void printVFTable(VF_PTR* table) { for (int i = 0; table[i] != nullptr; i++) { printf("第%d个虚函数表的地址为:%p->", i + 1, table[i]); table[i]();//通过函数指针调用函数 } } int main() { Derive d; printVFTable((VF_PTR*)(*(int*)&d)); cout << endl; //通过切片找到第二张虚函数表的存放位置 Base2* ptr = &d; printVFTable((VF_PTR*)(*(int*)ptr)); return 0; }
通过上述图像我们可以看出:多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中。
6.3. 同样重写的虚函数,为何在两张表的地址不同?
通过上图我们发现,同样是调用子类的func1
函数,为何地址会不同呢?我们可以以下代码分析的汇编:
int main() { Derive d; Base1* ptr1 = &d; Base2* ptr2 = &d; ptr1->func1(); ptr2->func1(); return 0; }
通过汇编我们发现ptr2
调用func1
只不过是中间绕了几步,最终调用的地址仍然是与ptr1
相同的。那么为什么会这样呢?
因为
ptr1
与ptr2
形成多态调用,调用的是派生类Derive
中的func1
函数。但是派生类中func1
的this
指针为Derive*
类型,指向的是Derive
起始位置。所以要调用派生类中的func1
函数,必须都先指向Derive
的起始位置。而在继承中先声明的是Base1
,所以ptr1
恰好指向Derive
的起始位置不需要调整,但prt2
指向的是Base2
所以需要调整。(汇编第三步sub ecx 8
,在VS中this
指针一般就存在ecx
中,8bytes恰好是Base1
的大小)
7. 菱形虚拟继承的虚函数表
我们先来看这么一段代码:
class A { public: virtual void func1() {} public: int _a; }; class B : virtual public A { public: virtual void func1() {} public: int _b; }; class C : virtual public A { public: virtual void func1() {} public: int _c; }; class D : public B, public C { public: //必须自己重写func1 virtual void func1() {} int _d; };
这里我们必须要在D中重写func1
,因为虚继承之后A只有一份,如果BC都覆盖的话,就会有二义性,继承下来的A的虚函数表里面不知道是B重写的还是C重写。
并且此时的虚函数表只有A这一份。
但是如果在派生类中添加几个虚函数,情况就会发生改变。
class A { public: virtual void func1() {} public: int _a; }; class B : virtual public A { public: virtual void func1() {} virtual void func2() {} public: int _b; }; class C : virtual public A { public: virtual void func1() {} virtual void func3() {} public: int _c; }; class D : public B, public C { public: //必须自己重写func1 virtual void func1() {} int _d; };
这时就有三张虚函数表。并且我们可以通过内存观察一下具体情况:
我们在学习继承的原理就已经明白第二行存放的是距离公共数据的偏移量,而第一行fc ff ff ff
就是十进制的-4
,也就是距离虚函数表指针的偏移量。
通过这么一简单的介绍,我们就发现菱形虚拟的繁琐与复杂。所以在实际运用中,尽量少写菱形继承、菱形虚拟继承。
8. 易错解析
8.1. 易错一
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继承了派生类A的test
函数,但是test
函数中的this指针
并不会改变,类型仍是A*
,并且func
函数也在派生类中完成了重写,所以调用func
时形成多态,最终会调用派生类中的func
函数。但是答案会是B->0吗?
为什么会出现这种情况呢?我们就得先分辨清楚接口继承与实现继承。
普通函数的继承是一种实现继承,派生类继承了基类的成员函数,可以使用该函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口所以如果不实现多态,最后不要把函数定义成虚函数。
所以派生类中的func
函数接口仍是基类的函数接口,所以val=1
。
8.2. 易错二
- 内联函数可以是虚函数吗?
可以。虽然说内联函数会在编译时展开,并不会建立函数栈帧,也自然不会形成地址,也就无法形成虚函数表,但是内联说明只是向编译器发送的一个请求,编译器可以选择忽略。
- 静态成员可以是虚函数吗?
不可以。静态成员函数没有
this
指针,使用类型::成员函数就可以调用。但是这种方式并不能访问虚函数表,因为访问虚函数表都是多态的情况下通过对象调用虚函数的方式访问的,所以静态成员函数的地址不会放进虚函数表。
- 构造函数可以是虚函数吗?
不可以,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。而虚函数多态调用需要到虚函数表中招,但是这时虚函数表指针还未初始化。
- 析构函数可以是虚函数吗
**可以,**因为基类与派生类的析构函数都会被编译器统一处理成
destructor
。为了防止内存泄漏,最好将析构函数定义成虚函数。
- 对象访问普通函数快还是虚函数更快?
首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找 。
- 虚函数表是在什么阶段生成的,存在哪的?
虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的。(编译过程中的汇编阶段会生成符号表,此时就可以确定函数的地址。)