16.C++三大重要特性之多态
目录
一、多态的概念
二、多态的定义
1. 多态的构成条件
2. 虚函数
3. 虚函数的重写/覆盖
4. 虚函数重写的一些其他问题
(1) 协变
(2) 析构函数重写
5. override 和final关键字
6. 重写/重载/隐藏的对比
三、纯虚函数与抽象类
四、多态的原理
1. 虚函数表指针
2. 多态是如何实现的
3. 静态绑定与动态绑定
4. 虚函数表
总结:
一、多态的概念
多态顾名思义就是多种形态。多态又分为编译时多态(静态多态)和运行时多态(动态多态),本篇文章我们重点讲解运行时多态。
编译时多态主要是我们之前讲的函数重载和函数模板,他们传不同的参数类型就可以调用不同的函数来实现多用形态,而这一匹配过程是在编译时进行的,所以叫编译时多态,一般归为静态。
运行时多态就是完成某个函数时,传不同的对象就会完成不同的行为,比如普通人买票是全价,而学生买票是半价。这一行为是在运行时决定的,所以叫运行时多态。
光看概念还是比较抽象,所以可以在后续的讲解中慢慢体会运行时多态的特性。本文之后讲的多态一般指的都是运行时多态。
二、多态的定义
1. 多态的构成条件
多态是在一个继承关系下的对象,去调用同一函数产生了不同的行为。如Student继承了Person,但Student对象买票是半价,而Person对象买票是全价。
实现多态还需要两个重要条件:
- 调用时必须使用基类的指针或引用(不能是对象)
- 被调用的函数必须是虚函数,且完成了虚函数重写/覆盖
补充说明:
第一点是因为只有基类的指针或引用才能在使用同一类型指针的前提下,既能指向基类对象又能指向派生类对象。
第二点是因为只有虚函数完成了重写/覆盖,基类和派生类才能有不同的函数,才能实现多态的不同形态的效果。
2. 虚函数
虚函数是就是在类的成员函数的最前面加上virtual关键词修饰(在返回类型之前),注意非成员函数是不能成为虚函数的。
class Person {
public:virtual void BuyTicket() {cout << "全价买票" << endl;}
};
虚函数实现效果:
基类和派生类同时调用func()函数,且用的都是基类类型的引用,最终调用的是不同函数,实现了多态。
3. 虚函数的重写/覆盖
虚函数的重写/覆盖(重写和覆盖是相同意思的两种叫法)有一定条件:即派生类中有一个和基类完全相同的虚函数(完全相同指的是函数返回值类型、函数名字和参数列表都完全相同),这样派生类的虚函数就和基类的虚函数构成了重写。
注:重写时,派生类虚函数不加virtual关键字也可以构成重写(因为基类的虚函数被继承下来后还保有虚函数的属性),但这种写法不规范,不建议这样写。不过在考试选择题中可能会出,所以了解一下即可。
4. 虚函数重写的一些其他问题
(1) 协变
派生类重写时有一种情况函数返回值可以不同——基类虚函数返回基类对象的指针或引用,而派生类虚函数返回派生类对象的指针或引用。
这种情况称为协变,但协变实际意义不大,所以了解一下即可。
class A {};
class B:public A {};class Person {
public:virtual A* BuyTicket() {cout << "全价买票" << endl;return nullptr;}
};class Student:public Person {
public:virtual B* BuyTicket() {cout << "半价买票" << endl;return nullptr;}
};
(2) 析构函数重写
为什么析构函数需要重写,首先我们先来看一个场景:
可以看到我们创建了一个A类的对象,一个B类的对象,又由于A是基类,所以可以用A*的指针指向B类的对象。但当我们用delete释放对象时发现因为是A*指针,所以两者调用的都是A的析构函数。此时B对象如果有还没释放的元素,如图中的arr数组,就会造成内存泄漏。所以把析构函数设计成虚函数是有必要的!
可基类的析构函数和派生类的析构函数名字不同,一个是~A,一个是~B,所以编译器对析构函数的名字做了特殊处理,将析构函数的名字统一处理成了destructor,这样只要在析构函数前加上virtual关键字就可以正常构成重写了。
5. override 和final关键字
虚函数重写的要求比较严格,需要两个函数完全相同,但有些情况会由于疏忽,如写错函数名而导致没有构成重写。而这种问题又不会在编译阶段报错,只有在后续程序运行过程没有达到预期效果才能发现并debug,损失较大,所以C++提供了override关键字来监测是否完成了重写。
另外还有一个final关键字,用于限制虚函数,不让派生类对这个虚函数进行重写。
class Person {
public:virtual void BuyTicket() {cout << "全价买票" << endl;}
};class Student:public Person {
public:virtual void BuyTicket() override {cout << "半价买票" << endl;}
};
被override修饰的虚函数如果没有构成重载会报错
class Person {
public:virtual void BuyTicket() final {cout << "全价买票" << endl;}
};class Student:public Person {
public:virtual void BuyTicket() {cout << "半价买票" << endl;}
};
被final修饰的虚函数如果被重写就会报错
6. 重写/重载/隐藏的对比
这三个概念容易弄混,所以我们来对比一下
重载:
1. 两个函数在同一个作用域
2. 函数名相同,参数列表不同,返回值可同可不同
重写(覆盖):
1. 两个函数分别在同一个继承体系内的基类和派生类的不同作用域中
2. 函数完全相同,名字,返回值,参数都相同(协变除外)
3. 两个函数都必须是虚函数
隐藏(重定义):
1. 两个函数分别在同一个继承体系内的基类和派生类的不同作用域中(和重写一样)
2. 函数名相同,对返回值和参数列表没有要求
3. 满足第一个条件的两个函数只要不构成重写就构成隐藏
4. 除了函数外,基类与派生类的同名成员变量也可以构成隐藏
三、纯虚函数与抽象类
在虚函数的后面写上=0,则这个函数为纯虚函数,纯虚函数不需要定义实现(实现没啥意义因为要被派生类重写,但是语法上可以实现),只要声明即可。包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派生类继承后不重写纯虚函数,那么派生类也是抽象类。纯虚函数某种程度上强制了 派生类重写虚函数,因为不重写就实例化不出对象。
virtual void func() = 0;
虽然纯虚函数不能定义对象,但仍能定义指针用来指向他的派生类对象,同样可以实现多态
四、多态的原理
1. 虚函数表指针
虚函数与普通的成员函数有所区别,虚函数的地址是存储在一个虚函数表中的,而这个指向虚函数标的指针会存储在类对象中。
一个类中的虚函数地址会按一定顺序存储在这个数组中。
可以看到p对象中多了一个_vfptr的指针,指向一个指针数组。并且p对象的大小也变成了16字节。(64位操作系统下的指针是8字节,再加上两个int类型的数据)
2. 多态是如何实现的
那么从底层的角度看,多态是怎么实现的呢?
当满足多态条件时,底层不再是编译时根据不同的对象调用去确定函数地址,而是根据指针或引用的指向,在运行时直接找到虚函数表,再在表中找到对应的虚函数地址。这样就实现了指针或引用指向基类就调用基类的虚函数,指向派生类就调用派生类对应的虚函数。
3. 静态绑定与动态绑定
对不满足多态条件的函数调用是在编译时就绑定函数地址,叫做静态绑定。
对满足多态条件的函数调用是在运行时再去虚函数表绑定函数地址,叫做动态绑定。
4. 虚函数表
我们再详细了解一下虚函数表:
- 基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共用同一张虚表,不同类型的对象各自有独立的虚表,所以基类和派生类有各自独立的虚表。
- 派生类由两部分组成,继承下来的基类成员和自己的成员。一般情况下,派生类会继承基类的虚函数表指针而不是自己生成,但要注意继承下来的这个指针和基类中的虚函数表不是同一个。他们的关系就像基类中的成员和派生类中继承下来的基类成员一样,是独立存在的。
- 派生类如果对基类的虚函数进行了重写,那么派生类的虚函数表中对应的虚函数就会被覆盖成重写后的虚函数地址。
- 派生类的虚函数表中包含,(1)基类的虚函数地址,(2)派生类重写后的虚函数地址,(3)派生类 自己的虚函数地址这三个部分。
- 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个0x00000000标记。(这个由编译器决定,C++并没有明确规定)
- 虚函数和普通函数一样的,编译好后是一段指令,都是存在代码段的,只是虚函数的地址又存到了虚表中。而虚函数表存放在哪C++并没有明确规定,vs下存放在常量区(代码区)
总结:
以上就是本篇文章的全部内容了,如果觉得有帮助的话可以点赞收藏加关注支持一下!