[C++] 多态详解
Posted 哦哦呵呵
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[C++] 多态详解相关的知识,希望对你有一定的参考价值。
目录
前言
本篇文章基于的环境是 win10, vs2019
1. 概念
当不同的对象去完成某一行为时会产生不同的状态。
比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优先买票。
2. 实现条件
1.必须在继承体系下进行实现
2.基类中必须含有虚函数(被virtual修饰的函数),并且派生类必须要对基类中的虚函数重写。即将基类中的虚函数拷贝至子类重新实现。
3.虚函数调用必须通过基类的引用或者指针调用虚函数。根据基类的指针指向不同的类,选择对应的虚函数进行调用。
注意: 以上条件缺一不可,否则无法实现多态,无论基类指向哪一个子类,调用的都是基类中的函数。
3. 其他概念
3.1 重写
1. 概念
- 基类中的成员必须是虚函数,将该函数头拷贝至子类进行实现。
- 子类成员函数前可以不加virtual关键字。
- 基类和派生类中虚函数的原型必须相同(返回值类型,参数列表,函数名),有两个例外,稍后解释
重写与访问权限无关,子类重写的虚函数的访问权限可以和父类不同。
基类与派生类中虚函数可以不同的例外:
1. 协变
派生类的返回值类型可以与基类虚函数不同,但是基类虚函数返回值必须是基类对象的指针或者引用,派生类的虚函数返回值才可以是派生类对象的指针或者引用。
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;}
};
2. 析构函数的重写
如果基类的析构函数为虚函数,派生类析构函数只要显式定义,无论是否加virtual关键字,都与基类的析构函数构成重写。并且在派生类的析构函数中不需要显式调用基类的构造函数,虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor。
一般将基类的虚函数设置为虚函数。
class Person {
public:
virtual ~Person() {cout << "~Person()" << endl;}
};
class Student : public Person {
public:
virtual ~Student() { cout << "~Student()" << endl; }
};
// 只有派生类Student的析构函数重写了Person的析构函数,
// 下面的delete对象调用析构函数,才能构成多态,
// 才能保证p1和p2指向的对象正确的调用析构函数。
int main()
{
Person* p1 = new Person;
Person* p2 = new Student;
delete p1;
delete p2;
return 0;
}
为什么一般将基类的析构函数设置为虚函数?
防止子类中有资源管理,如果基类的析构函数不是虚函数,那么就不会通过基类的指针调用子类的析构函数,子类申请的资源就得不到有效的释放。
因为多态实现必须是基类对象指向派生类,通过基类指针调用子类的虚函数,但是基类中的析构函数不是虚函数,那么调用时就只会调用基类自己的析构函数,并且没有构成多态。
如果基类的析构函数是虚函数,通过基类指针指向派生类,并且析构对象时,那么就会先调用派生类的析构函数,等派生类中成员全部释放后自动调用积累的析构函数完成资源的释放。
3.2 override和final关键字
这两个关键字可以帮助用户检测是否重写。
1.final
修饰虚函数,标明该虚函数不能被子类重写,一般用于修饰子类的虚函数。
修饰类,表明该类不能被其它类继承。
2.override
在编译阶段检测子类虚函数是否对基类哪个虚函数进行了重写,如果没有重写则报错。
class Car{
public:
virtual void Drive(){}
};
class Benz :public Car {
public:
virtual void Drive() override {cout << "Benz-舒适" << endl;}
};
3.3 重载、重写、重定义的对比
4. 抽象类
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
接口
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
5. 多态的原理
5.1 问题提出
5.1.1 包含虚函数的类和不包含虚函数的类有什么区别?
- 带有虚函数的类会多4个字节。多个指针,存放指向虚表的地址。
- 如果没有显式定义构造函数,编译器一定会给该类合成构造函数
- 构造函数中,编译器会对对象的前4个字节赋值。前四个自己存放虚表的地址。
5.2.2 派生类与基类的虚表是否相同?
- 派生类不会与基类共用一个虚表
- 如果子类重写了基类的某个虚函数,编译器会使用子类重写的虚函数入口地址替换相同偏移量位置的基类虚函数。
- 子类新增加虚函数,与继承下来的虚函数放在同一张虚表中。对于派生类新增加的虚函数,会按照其在类中声明的先后次序依次放在虚表的末尾。
5.2 虚函数表
5.2.1 虚函数表概念
上方内容说了,如果包含了虚函数那么这个类中的最前方就会多出4个字节用于存放虚表指针,虚表中存放的是类中虚函数的入口地址,并且子类和基类有各自不同的虚表。
- 虚表是在构造函数进行生成
- 虚表中存放函数的入口地址,按照虚函数声明的先后次序进行存放
- 虚函数本质是一个函数指针数组,最后一个位置存放nullptr
如何证明虚表中存放的是虚函数入口地址?
前四个字节存放的是虚函数指针,可以通过指针取出这4个字节,访问指向的函数地址,进行打印输出。
typedef void(*PVFT)();
void PrintVFT(Base& b, const string& s)
{
cout << s << endl;
PVFT* pvft = (PVFT*)*(int*)&b;
while (*pvft)
{
(*pvft)(); // 通过函数指针调用对应类中的虚函数
++pvft;
}
cout << endl;
}
int main()
{
cout << sizeof(D) << endl;
D d;
d._b1 = 1;
d._b2 = 2;
d._d = 3;
PrintVFT(d, "D->B1->VFT:");
return 0;
}
5.2.2 虚函数表的构建规则
基类构建: 按照虚函数在类中声明的次序依次放置到虚表中
派生类的构建
- 将基类虚表中内容拷贝一份到子类的虚表中
- 如果子类重写了基类的某个虚函数,则使用子类虚函数地址替换虚表中相同偏移量位置的基类虚函数
- 将子类新增加的虚函数按照其在类中声明的先后次序依次放在虚表的最后
6. 多态原理
大前提: 虚函数必须要通过基类的指针或者引用来调用。
注意: 一个类中的所有对象共享一个虚函数表。
如果没有通过基类的指针或引用调用虚函数,而是通过基类的对象调用虚函数,则直接调用基类中的函数.
通过基类的指针或引用调用虚函数
- 从基类所引用的对象前4个字节取虚表地址
- 给虚函数传递该派生类的this指针
- 从虚表中对应的偏移量位置取虚函数地址
- 调用该虚函数
6.1 动态绑定与静态绑定
1.静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载
2.动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
6.2 单继承和多继承的虚函数表的存储模型
单继承模型
多继承模型
6.3 类中哪些函数可以作为虚函数哪些可以?
构造函数不能是虚函数
构造函数中会初始化对象,给对象空间中放入合适的值,还会将对象的虚表放入对象的前4个字节中。
将函数设置为虚函数目的是为了实现多态,则这个函数就要通过虚表进行调用,会通过虚表指针取出函数地址。如果构造函数为虚函数,那么构造函数就可以实现多态,则构造函数要从虚表中找到并执行,那么对象创建还没有构造虚表,从哪去找虚表中的函数呢,所以构造函数不能是虚函数。
析构函数可以是虚函数: 上文已经解释了
静态成员函数和友元函数不能是虚函数
因为这两个函数没有this指针,就无法拿到对应的虚表指针,无法访问虚表,所以这两个函数也不能放入虚表中。
inline成员函数语法层面上可以是虚函数
因为内敛函数在编译阶段会在调用位置进行展开,函数体就已将确定了,所以无法实现多态。
但是,inline只是对编译器的一个建议,如果将内敛函数来声明为虚函数,那么编译器就会忽略内敛属性。
以上是关于[C++] 多态详解的主要内容,如果未能解决你的问题,请参考以下文章