一、了解多态
1.1 概念
多态实C++面向对象的三大特性之一。面向对象的三大特性:封装,继承,多态。
1.2 分类
多态分为两类:
静态多态:函数重载和运算符重载属于静态多态,复用函数名。
动态多态:派生类和虚函数实现运行时多态。
他们的区别在于:(后面会举例说明)
静态多态的函数地址早绑定,即在编译阶段确定。
动态多态的函数地址晚绑定,即在运行阶段确定。
动态多态的满足条件:
1.有继承关系。
2.子类重写父类的虚函数。
重载:函数名相同,参数不同。
重写:函数名、返回值类型、参数类型和个数都相同。
3.父类的指针或引用指向子类对象。
二、简单示例
#include<iostream>
#include<string>
using namespace std;
class Animal {
public:
void speak() {
cout << "动物在说话" << endl;
}
};
class Cat : public Animal {
public:
void speak() {
cout << "小猫在说话" << endl;
}
};
void DoSpeak(Animal& animal) {
animal.speak();
}
int main() {
Cat cat;
DoSpeak(cat);
}
上例,cat是继承animal的子类,由于DoSpeak函数传参是一个animal类的引用,所以这时候如果传入一个cat类型,输出还是animal类中的speak函数。显示“动物在说话”。
这称作“地址早绑定”,即在编译阶段就确定了函数地址。如果想要执行“小猫在说话”,那么这个函数地址就不能提前绑定,需要在运行阶段进行绑定,即地址晚绑定。
要想实现地址晚绑定,只需要在最大的父类中的成员函数前面加一个关键字virtual,将这个函数变为虚函数即可。这称作“地址晚绑定”。
class Animal {
public:
virtual void speak() {
cout << "动物在说话" << endl;
}
};
这样在调用DoSpeak函数的时候,参数地址在调用的时候才确定,并不是一开始写死的animal类的引用接口。这是简单的理解,如果理解不了就往下看。
三、多态的底层剖析
如果不加virtual,即当speak不是虚函数的时候,使用sizeof计算Animal类的大小为1;加上virtual之后,使用sizeof计算Animal类的大小为4。
这说明Animal类内部结构发生了变化,由于任何指针变量的大小都是4个字节,所以我们猜测Animal的结构变为指针。
而实际上,Animal内部多了一个虚函数表指针,这个虚函数表指针指向了一个虚函数表,虚函数表内部写的是虚函数的入口地址,也就是&Animal::speak;当子类重写这个虚函数的时候,会把虚函数表中的虚函数地址替换成子类的虚函数地址。这时候当父类的指针或引用指向子类对象时,调用animal.speak()这个公共接口时,会发生多态,编译器会找到子类匹配的接口,也即cat.speak()。
四、举例
第三部分一定要看明白,多看几次就明白了,如果不理解什么是虚函数表指针,虚函数表啥的,可以看另一篇文章:C/C++笔记总结——虚函数,虚函数表,虚函数表指针的关系_给你糖ya的博客-CSDN博客
我们这里举一个实用一些的例子——计算器。为了更直观地体会到用多态和不用多态的区别,我先给出不用多态的情况,再给出用多态的情况。
1.不用多态:
#include<iostream>
#include<string>
using namespace std;
class Calculator {
public:
int getResult(string oper) {
if (oper == "+") {
return m_Num1 + m_Num2;
}
else if (oper == "-") {
return m_Num1 - m_Num2;
}
else if (oper == "*") {
return m_Num1 * m_Num2;
}
}
int m_Num1;
int m_Num2;
};
void text() {
Calculator c;
c.m_Num1 = 10;
c.m_Num2 = 10;
int sum = c.getResult("+");
cout << sum;
}
int main() {
text();
return 0;
}
如果想要扩展新功能,比如添加除法运算,需要修改源码,这是很麻烦的。在真实开发中提倡开闭原则。
开闭原则:对扩展进行开放,对修改进行关闭。
2.使用多态:
#include<iostream>
#include<string>
using namespace std;
//实现计算机抽象类
class AbstractCalculator {
public:
virtual int getResult(){
return 0;
}
int m_Num1;
int m_Num2;
};
//加法运算器类
class AddCalculator :public AbstractCalculator {
public:
int getResult(){
return m_Num1+m_Num2;
}
};
//减法运算器类
class SubCalculator :public AbstractCalculator {
public:
int getResult() {
return m_Num1 - m_Num2;
}
};
//乘法运算器类
class MulCalculator :public AbstractCalculator {
public:
int getResult() {
return m_Num1 * m_Num2;
}
};
void text() {
//多态的使用条件是 父类的指针或引用指向子类的对象
AbstractCalculator* abs = new AddCalculator; //创建一个指针abs指向加法运算器
abs->m_Num1 = 10;
abs->m_Num2 = 10;
cout << "加法运算结果为:" << abs->getResult() << endl;
//因为是new出来的对象,创建在堆区,所以用完后要手动释放
delete abs;
//减法运算
abs = new SubCalculator();
abs->m_Num1 = 5;
abs->m_Num2 = 4;
cout << "减法运算结果为:" << abs->getResult() << endl;
delete abs;
}
int main() {
text();
return 0;
}
我们看到,代码量虽然多了很多,但各种运算方法的实现类都是差不多的,所以写起来也不会很费力;并且,这时候如果我们想添加一些运算,比如添加除法运算,那么就只需要在程序中增添一个除法运算类,继承自 AbstractCalculator 这个父类,在类内实现除法运算就可以了,不需要修改源代码,但可以很方便的扩展功能。
四、多态的好处
1.组织结构清晰。
以计算器为例,如果某个运算比如加法运算出错了,那么只需要修改加法运算器的代码,而不需要管其他的代码。
2.可读性强。
3.方便扩展和维护。
五、纯虚函数和抽象类
在多态中,通常父类中虚函数的实现是毫无意义的,主要是调用子类重写的内容。
比如在计算机类中,父类的实现只写了一个return 0,这是毫无意义的;再比如在之前的示例Animal类中的虚函数实现写了输出“动物在说话”,而实际上并没有运行这行代码。
因此,可以将虚函数改为纯虚函数。纯虚函数语法:virtual 返回值类型 函数名 (参数列表) = 0;当类中有了纯虚函数,那么这个类也称为抽象类。
class AbstractCalculator {
public:
virtual int getResult() = 0;
int m_Num1;
int m_Num2;
};
抽象类的特点:
1.无法实例化对象。(即无法创建抽象类的对象,“Animal a”就会报错)
2.子类必须重写抽象类中的纯虚函数,否则也属于抽象类。
六、虚析构与纯虚析构
6.1 使用场景
我们知道,类的调用过程中会先调用构造函数,结束调用后会调用其析构函数,而析构函数常用于释放堆区的内存。
多态使用时,如果子类中有属性开辟到堆区,那么父类指针在释放时无法调用到子类的析构代码。即不会调用子类的析构函数,无法释放子类的堆区属性,容易造成内存泄漏。
解决方法:将父类中的析构函数改为虚析构或者纯虚析构。
6.2 语法
虚析构语法:
virtual ~类名(){}
纯虚析构语法:
virtual ~类名()=0;
//下面是实现
类名::~类名(){}
6.3 虚析构与纯虚析构的共性与区别
共性:
可以解决父类指针释放子类对象;都需要有具体的函数实现。
区别:
如果是纯虚析构,该类属于抽象类,无法实例化对象。
6.4 举例
上面的概念可能你看的比较懵逼,但没关系,看完这个示例你肯定能懂。
#include<iostream>
#include<string>
using namespace std;
class Animal {
public:
Animal() {
cout << "Aanimal的构造函数" << endl;
}
~Animal() {
cout << "Animal的析构函数" << endl;
}
virtual void speak() = 0; //纯虚函数
};
class Cat : public Animal {
public:
Cat(string name) {
cout << "Cat的构造函数" << endl;
m_name = new string(name); //使用指针来接收
}
~Cat() {
if (m_name != NULL) { //手动释放创建在堆区的成员属性m_name;
delete m_name;
m_name = NULL;
}
cout << "Cat的析构函数" << endl;
}
void speak() {
cout << *m_name + "小猫在说话" << endl;
}
string* m_name; //创建在堆区,是一个指针
};
void text() {
Animal* animal = new Cat("Tom"); //多态
animal->speak();
delete animal; //手动释放创建在堆区的animal,会调用析构函数
}
int main() {
text();
return 0;
}
上例的输出结果为:
问题:没有经过“Cat的析构函数”代码,也即给m_name赋值后没有释放m_name的内存,会造成内存泄漏。
产生原因:使用父类指针指向子类对象,所以delete父类指针的时候不会走子类的析构代码。也即“父类指针在析构的时候,不会调用子类中的析构函数,导致子类如果有堆区属性,出现内存泄漏”。
解决方法:把父类的析构函数改为虚析构,也即析构函数前面加一个virtual。这样在父类析构时会调用子类的析构函数。也即“虚析构可以解决父类指针释放子类对象不干净的问题”
class Animal {
public:
Animal() {
cout << "Aanimal的构造函数" << endl;
}
virtual ~Animal() {
cout << "Animal的析构函数" << endl;
}
virtual void speak() = 0; //纯虚函数
};
当然,正如我们上面提到过的,也可以通过把父类的析构函数改为纯虚析构的方式解决。
纯虚析构需要声明,也需要实现。因为有可能父类有部分属性开辟到堆区,所以需要代码实现,来释放堆区数据。有了纯虚析构之后,这个类也属于抽象类,不能实例化对象。
class Animal {
public:
Animal() {
cout << "Aanimal的构造函数" << endl;
}
virtual ~Animal() = 0; //纯虚析构
virtual void speak() = 0; //纯虚函数
};
Animal::~Animal() { //纯虚析构的实现
cout << "Animal的析构函数" << endl;
}
6.5 小结
1.虚析构或纯虚析构就是用来解决通过父类指针释放子类对象
2.如果子类中没有堆区数据,可以不写为虚析构或纯虚析构
3.拥有纯虚析构函数的类也属于抽象类