目录
上节学习了继承和派生,这节开始学习多态!
多态polymorphic
所有的面向对象编程都具备这几个特征:抽象、封装、继承、多态
多态是面向对象编程的最后一个特征。
多态可以理解为多种形态。
先看这样一个代码:
很多人觉得通过这个p调用的是派生类Child里的show,但结果调用的是基类里面的show
为什么?
这就涉及到静态联编,编译的时候编译器发现p是基类类型,所以通过p调用的就是基类里面的show。
也就是说在编译期间(还不是运行的时候)的链接过程(编译分为四个步骤:预处理,编译,汇编和链接,这个在C语言的时候写过了,忘记的自己去翻翻看)中,直接把p->show()后面的这个“show”符号链接成基类的show函数的地址。
这就是所谓的静态联编,“静态”指的是在编译的时候,运行是属于“动态”,联编就是编译与链接。
但是这显然不是我们想要的结果,所以我们需要做一点修改,在基类的show函数前加上“virtual”。
派生类里面这里加不加都行
加上之后结果就不一样了,调用的是派生类里面的show
此时用基类指针指向基类对象,调用的就是基类里面的show
所以多态可以理解为:同一条语句有不同的执行结果,取决于指针指向谁。
这也就是一开始我们说的:“所谓多态就是多种形态”。
之前我们讲过一个概念叫“隐藏”,比如基类和派生类里面函数同名时,会把派生类继承过来的同名函数隐藏掉,所以调用的时候默认调用的是派生类里面的这个同名函数。
现在我们来学习一下“重写”的概念。
多态成立的三个条件
这就涉及到多态成立的三个条件:
1、要有继承
2、要有虚函数重写
虚函数就是刚刚被我们用virtual修饰的这个成员函数
所谓虚函数重写的意思是一定基类里面有这个虚函数
在派生类里面的这个show前面可以加virtual,也可以不加。而且这两个show的原型必须是一致的,一样的返回值,参数个数是也是一致的。
注:加上这个virtual之后,之前讲的“隐藏”的概念还是成立的。
3、用父类指针(父类引用)指向子类对象
多态一定是跟指针有关系。
以上三个条件只要缺少一个就不叫多态。
重载与重写区别
如果派生类定义了和基类相同原型的函数会怎么样?编译器支持这种写法,并且叫做函数重写。
函数重写。
函数重写就是在派生类中定义与基类原型相同的函数。函数重写只发生在派生类和基类之间。
重载与重写区别:
重载:同一个作用域;
派生类无法重载基类函数,基类同名函数将被覆盖;
重载是在编译期间根据参数类型和个数决定;
重写:发生于基类、派生类之间;
父类和子类函数有相同的函数原型;
使用virtual关键字声明后能够产生多态;
运行期间根据具体对象类型决定调用的函数。(因为new申请的空间是动态内存)
也就是说使用了virtual后,这条语句中p到底指向谁在编译期间(静态)编译器是不知道的,只有在运行的时候,这条语句真的执行了,才在堆空间申请一块内存,然后p才知道指向的是派生类对象,然后才调用派生类的函数,所以加上virtual后,这里属于动态联编。
动态联编和静态联编
1、联编是指一个程序模块、代码之间互相关联的过程。
2、静态联编(static binding),是程序的匹配、连接在编译阶段实现,也称为早期匹配。
重载函数使用静态联编。
3、动态联编是指程序联编推迟到运行时进行,所以又称为晚期联编(迟绑定)。switch 语句和 if 语句是动态联编的例子(比如得在运行时才能获取键值决定走哪条分支的情况)。
C++与C相同,是静态编译型语言;
在编译时,编译器自动根据指针的类型判断指向的是一个什么样的对象;所以编译器认为父类指针指向的是父类对象;
由于程序没有运行,所以不可能知道父类指针指向的具体是父类对象还是子类对象
从程序安全的角度,编译器假设父类指针只指向父类对象,因此编译的结果为调用父类的成员函数。这种特性就是静态联编。
多态原理
当类中声明虚函数时,编译器会在类中生成一个虚函数表。虚函数表是一个存储类成员函数指针的数据结构。虚函数表是由编译器自动生成与维护的。
virtual成员函数会被编译器放入虚函数表中,当存在虚函数时,每个对象中都有一个指向虚函数表的指针(C++编译器给父类对象、子类对象提前布局vptr指针;当进行函数调用时,C++编译器不需要区分子类对象或者父类对象,只需要再base指针中,找vptr指针即可。)
VPTR一般作为类对象的第一个成员 。
直接上代码理解:
我们来研究一下Parent这个类占几个字节,现在这个类是个空类,在内存中空类是占一个字节,就只有一个占位字符在里面
那如果有了多态在里面,如下面这段代码,Parent这个类占几个字节?
结果是占了4个字节
所以其实多态就是靠这4个字节实现的,这4个字节是一个指针(我这个是32位系统,如果是64位系统,指针占8个字节)。而且这个指针是存放在类的前面4个字节。
接下来我们在每个类中加上一个成员变量再看看这两个类的大小
看看一下地址分布情况
内存布局:
这个指针我们就称为虚函数表指针。
凡是有虚函数的类,都会有一个虚函数表(里面保存的是函数的地址)
接下来我们以这个代码为例讲解多态的实现原理,从这两句代码开始讲起
首先p指向了Child,
接下来通过p指向show函数的步骤是:先通过p找到Child的对象,访问对象的前4个字节里面的虚函数指针,虚函数指针里面存放的是0x700,是Child虚函数表的地址。
然后再通过虚函数表找到show这个函数的地址是0x200,然后程序才跳到show函数所在空间,然后执行show。
注意:在多态中,子类的重写的show前面加不加virtual都一样,都是虚函数,子类继承了父类的虚函数和虚函数表,按理来说Child虚函数表里面应该是有一个0x100,但是被隐藏了,非要调用,函数名前面可以加作用域。
虽然把所有成员函数都声明为虚函数,也就是都加上Virtual也没事,但是不要把所有的成员函数声明成虚函数,因为这样调用的效率太低了。
比如父类中有个成员函数print,子类中没有这个函数的重写,但是我们也把virtual加在print前面让它成为虚函数,这样编译也不会报错,但是不建议这样做。
那接下来我们让基类指针指向基类对象的话,实现原理又是怎样的?
此时p指向parent的对象的前4个字节,里面的虚函数指针存放的parent虚函数表的地址,根据这个地址找到虚函数表,然后再根据虚函数表上的写的show的地址找到show,再执行。
以上就是多态实现的原理。
构造函数中调用虚函数能否实现多态?
构造的顺序是先构造父类、再构造子类;
当调用父类的构造函数的时候,虚函数指针vfptr 指向父类的虚函数表;
当父类构造完,调用子类的构造函数的时候,虚函数指针 vfptr 指向子类的虚函数表;
结论:构造函数中无法实现多态;
直接上代码理解
这里是基类指针指向派生类对象,之前我们说过,创建派生类对象的时候,再初始化时要先调用基类的构造函数初始化,再调用派生类的构造函数。所以在这段代码中创建派生类对象后,会调用Parent里面的无参构造函数Parent(),而这个无参构造函数里面调用了虚函数show,那此时这个show是子类里面的show还是基类里面的show?
结果是调用父类Parent里面的show。
为什么这里不会调用子类的show?
因为调用基类构造函数的时候,派生类对象还没有形成,不存在多态。因为多态的形成一定是通过它最前面的那个指针实现的。
用父类指针指向子类数组
指针也是一种数据类型,C++类对象的指针p++/--,仍然可用。
指针运算是按照指针所指的类型进行的。
父类p++与子类p++步长不同;不要混搭,不要用父类指针++方式操作子类对象数组
直接上代码
也可以通过指针p来访问数组吗?
结果是不可以
因为成员变量形成不了多态,编译器发现p是Parent*类型,当时基类里面存在b。
那是否可以通过这个指针p来访问函数呢?
结果是不可以
首先p[2]是数组c的第三个元素,是一个子类对象,调用这个子类对象中的show()函数就出现了段错误。
结论就是:不要用基类指针指向派生类数组,因为步长不一样。
首先一个派生类对象的大小是4104个字节,c[2]是c这个地址往后走了4104*2个字节,而一个parent基类的大小是8个字节,p是parent*类指针,p[2]是p这个地址向后走了8*2个字节,也就是说c[2]和p[2]根本不是一个意思。P[2]就相当于是p+2,如果我们对p+2用*取值的话,取出来的就不是一个合法的对象了,再如果我们想通过p[2]去访问show的话,它根本就找不到这个show在哪,可能访问了一个非法的地址,程序就出现了段错误。
虚析构函数
在什么情况下应当声明虚函数?
构造函数不能是虚函数。建立一个派生类对象时,必须从类层次的根开始,沿着继承路径逐个调用基类的构造函数
析构函数可以是虚的。虚析构函数用于指引 delete 运算符正确析构动态对象
虚析构函数:通过父类指针释放子类对象。
注:之前我们讲过普通成员函数的隐形参数this指针,现在再补充一点,在 C++ 中,this 是一个指向当前对象的指针,我们可以通过 this 来访问当前对象的所有成员。this 实际上是当前类类型的指针,例如,对于类 Box 的成员函数,this 是 Box* 类型的指针。
this 指针可以在类的所有非静态成员函数中使用,包括构造函数和析构函数。我们可以使用 this 指针来访问成员变量和成员函数。
直接上代码讲解:
以上这段代码没有函数重写,所以不涉及多态,因此属于静态联编。当通过p访问show的时候,编译器发现p是Person类型,而Person里面没有show,所以编译就报错了。
因此我们要在Person里面加上show函数并且声明成virtual,这样才能构成虚函数重写
这一次它就能把这个学生信息打印出来了,因为已经构成了多态。
但是你会发现还有一个问题没有解决,那就是它只调用了构造函数,没有调用析构函数。也就是我们只申请了空间,但是没有释放空间。
那么我们直接在main函数中手动释放空间可以吗?
结果还是不可以
因为这里delete p释放的是Person*类型的指针,它只会调用Person里面的析构函数。
怎么办?
我们可以把Person里面的析构函数写出来,然后声明为虚析构函数。
这样就可以了,为什么?
因为虚析构函数可以通过基类指针释放派生类对象。
也就是说我们这里写的是delete p,但是编译器会调用p指向的那个对象(派生类对象)的析构函数。
所以基类里面的析构函数我们尽量写成虚析构函数(派生类里面的析构函数加不加virtual都可以)
那构造函数要不要也加上virtual变成虚函数呢?
结果报错了
结论:构造函数不能被声明为虚函数。
因为调用构造函数的时候,对象还没有初始化完成,对象的最前面的4个字节是不是指向虚函数表还不一定。
接下来再看一个跟多态相关的问题
动态类型识别
直接上代码讲解
在这段代码中,现在如果把test(&p)改成test(&c)的话,那test函数的参数要改成Child*类型吗?
其实是不需要的,有了多态之后,这里不需要改成Child*类型。
因为在多态里面,基类指针可以指向基类对象,也可以指向派生类对象,而且指向不同的对象就调用不同的成员函数。
所以当我们有很多的派生类,不知道派生类的具体类型的情况下,我们就可以懵懂地定义一个基类指针,反正运行的时候它会根据指针指向谁就调用谁的成员函数,这就是多态的好处和意义。
现在我们在test函数里面将p强转成child*类型,然后赋值给c,再通过c访问派生类里面的数组b的最后一个元素。
这段代码目前编译正常,但是我们分析一下就有问题了,一开始test传的是基类对象p的地址,然后基类指针p指向的是基类的对象p,但是在test函数体内,我们将基类指针p强转为child*类型(此时编译器看c->b[1024000-1]=1;这句里面的c的确是有数组b,所以语法上就通过了,所以编译没有问题),但是p指针里面的值是没有改变的,也就是说p指针里面存的还是基类对象p的地址,当Child*c=(Child*)p;这句一旦执行完毕,就是派生类指针c指向了基类对象p,而在基类里面没有数组b,这时如果我们通过c访问数组b的话,就出现了段错误。
结论:强制类型转换有的时候能转换,有的时候不能转换,如果p指向派生类对象,可以转换,如果指向基类对象,就不能转换。
要想拯救这段代码,那就得用多态来解决。
C++为了能够在运行时正确判断一个对象确切的类型,加入了RTTI(Run-Time Type Identification)。
RTTI提供了以下两个非常有用的操作符:
(1)typeid操作符,返回指针和引用所指的实际类型。
(2)dynamic_cast操作符,将基类类型的指针或引用安全地转换为派生类型的指针或引用。
三种办法可以解决C++动态类型识别问题:
第一种方法:自定义类型
C++如何得到动态类型?
C++中的多态根据实际的对象类型调用对应的函数
1、可以在基类中定义虚函数返回具体的类型信息
2、所有的派生类都必须实现类型相关的虚函数
3、每个类中的类型虚函数都需要不同的实现
使用虚函数进行动态类型识别的缺陷
1、必须从基类开始提供类型虚函数
2、所有派生类都必须重写类型虚函数
3、每个派生类的ID必须唯一
直接上代码讲解:
刚刚说过如果指针p指向派生类对象,可以转换,如果指向基类对象,就不能转换。这主要取决于指针p到底指向的是谁,因为它指向谁就调用谁的函数,那要想实现这个效果,我们就得形成多态。
以下这段代码中想形成多态就差函数重写这一个条件
那虚函数我们要怎么写呢?既然说自定义类型,那我们用数字表示也可以,直接用枚举
既然是虚函数重写,我们就每个类都写一个getID函数
那在这种情况下只有getID返回1的情况可以强转
如果换成这样就是不能强转
所以我们就可以通过一个数字ID的方式来判定指针到底指向的是谁,进而我们就知道这个类型到底能不能强转。
既然是自定义,那么我们不一定要把这个“ID”定义成枚举类型,也可以定义成int类型。
最后这里是可以强转成这样的:
虽然这种方法能有效解决问题,但是我们需要写很多辅助代码,比较麻烦。
第二种方法:dynamic_cast动态转换
dynamic_cast是C++里面强转的关键字
新的关键字 dynamic_cast
1、dynamic_cast是C++中的新型关键字
2、dynamic_cast用于基类和派生类之间的转换
3、dynamic_cast要求使用的目标类型是多态的
即要求所在类族至少有一个虚函数
用于指针转换时,转换失败返回空指针
用于引用转换时,转换失败将引发bad_cast异常
dynamic_cast的优势
1、不用显示的声明和定义虚函数
2、不用为类族中的每个类分配类型ID
dynamic_cast的缺陷
1、只能用于有虚函数的类族
直接上代码讲解:
在C语言里面这样强转的,但是这个什么时候能转什么时候不能转我们不知道,dynamic_cast就可以解决这个问题
dynamic_cast强转
如果p这个指针原本指向的就是Chinese的对象的话,那么这条语句才会强转成功,否则强转失败,失败返回空指针,所以我们只要判断指针c是不是空的。
注意,dynamic_cast所强转的类型的类里面必须要有虚函数才行,也就是能形成多态的地方才允许这么使用。
第三种方法:typeid获取类型信息
如果获取一个变量的类型?
C++提供了typeid关键字用于动态获取类型信息
1、typeid关键字返回对应参数的类型信息
2、typeid关键字返回一个type_info类对象的常引用(别名))
当typeid参数为NULL时,抛出bad_typeid异常
3、type_info类的使用需要包含typeinfo头文件
直接上代码讲解
打印出来的结果并不是完整的类型表示符,而是这个类型在编译器里面的编号,比如int就i,char就是c,float就是f,我们自定义的三个类型前面加了数字。不同的编译器打印出来可能不一样。
现在我们用typeid来解决我们之前的问题
这里能不能强转就取决于*p是什么类型,因为p指向一个对象,*p就是一个对象。
再来看一下多态里面的一个概念
抽象类
纯虚函数是一个在基类中说明的虚函数,在基类中没有定义,要求任何派生类都定义自己的版本;
纯虚函数为各派生类提供一个公共界面(接口的封装和设计、软件的模块功能划分)
纯虚函数的说明形式:
virtual 类型 函数名(参数列表) = 0;
一个具有纯虚函数的类称为抽象类。
直接上代码:
抽象类示例:图形、圆、矩形
这段看上去没毛病,可是运行后的结果是
因为目前这个代码没有构成多态,因为没有虚函数重写,没有构成多态,所以只是静态联编的过程。
基类指针指向派生类对象我们这里写的是没毛病的
但是编译通过ps去Shape里面找的时候发现没有get_s这个函数,所以就报错了。
所以我们要把虚函数写上,构成虚函数重写,形成多态
但是由于Shape这个类表示的只是很多图形的大类,没有具体的图形,所以不好在get_s()函数中写具体的函数体,按按理这个get_s在基类里面就不应该存在,但是为了形成多态,它又必须存在,怎么办?
如果我们就这样空着函数体,编译就会报错,因为它比如有返回值,返回double类型,如果空着函数体,就会默认返回int类型
我们可以这样写:
这种为了形成多态不得不写的函数,而函数体又没法写,那么可以直接让它等于0(它既然不得不存在,那就让它等于0,留一个接口给它,类似于声明),我们称这个函数为纯虚函数。
含有纯虚函数的类,我们称为抽象类;抽象类派生出来的类可以创建对象。
这样写就没有问题了
此时如果我们通过基类指针创建一个基类对象,编译就会报错
结论:抽象类不能创建对象
因为如果我们创建了一个抽象类的基类对象,之后它想要调用get_s函数的时候,根本没办法执行,因为get_s是一个纯虚函数
完整代码:
#include <iostream>
using namespace std;
class Shape
{
protected:
int m_a;
int m_b;
public:
Shape(int a,int b=0)//如果是圆的话只需要半径一个参数,所以b设为默认参数
{
m_a=a;
m_b=b;
}
virtual double get_s()=0; //纯虚函数
};
class Rectangle:public Shape
{
public:
Rectangle(int a,int b):Shape(a,b) //创建Rectangle的对象时,调用Rectangle构造函数,然后调用Shape构造函数初始化,这里将a和b传给Shape()
{
}
double get_s()//计算矩形面积
{
return m_a*m_b;
}
};
class Circle:public Shape
{
public:
Circle(int r):Shape(r)//创建Circle这个类时,传一个参数r给Shape,Shape的另外一个参数默认是0
{
}
double get_s()
{
return m_a*m_a*3.14;
}
};
int main()
{
Shape *ps=new Rectangle(1,2);
cout<<ps->get_s()<<endl;
delete ps;
ps=new Circle(1);//ps被释放后重新申请一块内存给它
cout<<ps->get_s()<<endl;
delete ps;
//ps=new Shape(1,2);//抽象类不能创建对象
//ps->get_s();
return 0;
}
抽象类注意事项
1、抽象类不能用于直接创建对象实例,可以声明抽象类的指针和引用;
2、可使用指向抽象类的指针支持运行时多态性
3、派生类中必须实现基类中的纯虚函数,否则它仍将被看作一个抽象类
抽象类在多继承中的使用
C++中没有Java中的接口概念,抽象类可以模拟Java中的接口类。(接口和协议)
工程上的多继承
被实际开发经验抛弃的多继承
工程开发中真正意义上的多继承是几乎不被使用的
多重继承带来的代码复杂性远多于其带来的便利
多重继承对代码维护性上的影响是灾难性的
在设计方法上,任何多继承都可以用单继承代替
纯虚函数的应用
C++中没有接口的概念
C++中可以使用纯虚函数实现接口
接口类中只有函数原型定义。
实际工程经验证明
多重继承接口不会带来二义性和复杂性等问题
多重继承可以通过精心设计用单继承和接口来代替
接口类只是一个功能说明,而不是功能实现。
子类需要根据功能说明定义功能实现。
多态案例
公司员工工号从1号开始,每来一个员工工号加1;
总经理底薪10000;
工程师每小时100元;
销售月薪是销售额的10%;
销售经理底薪5000+部门销售额的5%;
用代码表示每个人每月的薪资。
把继承和多态的一些知识点综合起来练习一下
完整代码:
Employee.h
#ifndef EMPLOYEE_H
#define EMPLOYEE_H
//抽象基类
class Employee
{
protected:
int id;//工号
static int num;//员工的个数
public:
Employee();//可在外部实现
virtual void print_salary()=0;//纯虚函数在派生类中实现
virtual ~Employee();//因为最后要通过基类指针释放派生类对象,所以要虚析构函数
};
//总经理
class Manager:virtual public Employee
{
protected:
int base;//基本工资
public:
Manager(int b);//把基本工资传过来初始化
void print_salary();//计算并打印工资
};
//工程师
class Engineer:public Employee
{
private:
int hour;//工时
public:
Engineer(int h);//把工时传过来初始化
void print_salary();//计算并打印工资
};
//销售
class SalePerson:virtual public Employee
{
protected:
int sales;//销售额
static int sum;//总的销售额
public:
SalePerson(int m=0);//把销售额传过来初始化;创建销售经理对象时要调用这个构造函数,但是销售经理不需要传销售额,所以把m设为默认参数
void print_salary();//计算并打印工资
};
//销售经理
class SaleManager:public Manager,public SalePerson //使用Base和sum;为了让id只继承一份过来,所以把上面Manager的SalePerson改成虚继承Employee
{
public:
SaleManager(int b);//把基本工资传过来初始化
void print_salary();//计算并打印工资
};
#endif
Employee.cpp
#include "employee.h"
#include <iostream>
using namespace std;
int Employee::num=0;//静态成员变量在全局初始化,它比主函数先执行
int SalePerson::sum=0;//总的销售额
Employee::Employee()
{
num++;//人数累计,静态成员变量不能被构造函数的this指针访问,因为它是共享的变量,不属于这里
this->id=num;//用数字当工号
}
Employee::~Employee()
{
}
Manager::Manager(int b)
{
this->base=b;
}
void Manager:: print_salary()
{
cout<<"职位:经理 工号:"<<this->id<<" 工资:";//先不换行
cout<<base<<endl;//换行
}
Engineer::Engineer(int h)
{
hour=h;
}
void Engineer::print_salary()
{
cout<<"职位:工程师 工号:"<<this->id<<" 工资:";//先不换行
cout<<100*hour<<endl;//换行
}
SalePerson::SalePerson(int m)
{
sales=m;
sum+=sales;
}
void SalePerson::print_salary()
{
cout<<"职位:销售 工号:"<<this->id<<" 工资:";//先不换行
cout<<sales*0.1<<endl;//换行
}
SaleManager::SaleManager(int b):Manager(b) //派生类的对象要先调用基类构造函数初始化,所以用初始化列表给基类传参
{
//base=b;
}
void SaleManager::print_salary()
{
cout<<"职位:销售经理 工号:"<<this->id<<" 工资:";//先不换行
cout<<base+sum*0.05<<endl;//换行
}
Main.cpp
#include "employee.h"
#include <iostream>
#include <time.h>
#include <stdlib.h>
using namespace std;
int main()
{
//要创建20个员工
Employee *e[20]={0};//基类指针指向派生类对象
int idx=0;//对象数组元素下标
//创建一个总经理
e[idx++]=new Manager(10000);
srand(time(NULL));
//创建10个工程师,工时随机
for(int i=0;i<10;i++)
{
e[idx++]=new Engineer(rand()%100+120);//工时范围120-219
}
//创建8个销售,销售额随机
for(int i=0;i<8;i++)
{
e[idx++]=new SalePerson(rand()%20000+100000);//销售额范围100000-299999
}
//创建一个销售经理
e[idx++]=new SaleManager(5000);
for(int i=0;i<20;i++)
{
e[i]->print_salary();
delete e[i];
}
return 0;
}
运行结果:
下节开始学习运算符重载!
如有问题可评论区或者私信留言,如果想要进扣扣交流群请私信!