您现在的位置是:首页 > 正文

C/C++笔记总结——多态,虚析构与纯虚析构(看了就懂)

2024-01-30 21:50:48阅读 0

一、了解多态

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.拥有纯虚析构函数的类也属于抽象类

网站文章