深入探索 C++ 中的多态

1 多态的概念

  • C++ 中的多态是指,子类重写父类的虚函数,这样用父类的指针或引用去指向子类对象的时候,调用被重写的方法,从而达到同一个接口不同的实现这样的一个过程叫做多态

2 多态的定义及实现

2.1 多态的构成条件

多态的构成条件来借助代码来引入

class Person{
public:
	virtual void  BuyTickets()
	{
		cout << "Person,全价" << endl;
	}
};
class Student : public Person
{
public:
	virtual void BuyTickets()
	{
		cout << "Student,半价" << endl;
	}
};

void Buy(Person& sp)
{
	sp.BuyTickets();
	
}
int main()
{
	Person p;
	Student s;
	Buy(p);
	Buy(s);
	return 0;
}

在这里插入图片描述

这样实现了多态。满足多态的条件有两个

  • 必须通过基类的指针或者引用调用虚函数

在这里插入图片描述

  • 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写(覆盖),那么重写也需要满足条件:a、父子类中的函数都是由virtual 修饰的虚函数 b、函数名、参数、返回值都要相同

在这里插入图片描述
注意

在重写基类虚函数时,派生类的虚函数不加 virtual 关键字,虽然也可以构成重写(因为派生类继承了基类中的虚函数的属性),但是这种写法不规范,不要这样写。
不满足多态:跟类型有关(左边),即 sp 是什么类型,就调用这个类中的那个成员函数;满足多态:跟对象有关,也就是指向那个对象就调用哪个(右边)。
计算对象的大小需要多算4个字节(32位是4个字节,64位是8个字节),因为有虚函数表指针(后面讲解)

2.2 什么是虚函数

  • 被 virtual 修饰的类的成员函数是虚函数
class Person {
public:
 virtual void BuyTicket() { cout << "买票-全价" << endl;}
}

2.3 虚函数重写的两个例外

2.3.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.3.2 析构函数的重写(是个高频面试题)

  • 如果基类的析构函数为虚函数,此时派生类只要定义,无论是否加 virtual 关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数做了特殊的处理,编译后析构函数名称统一处理成destructor

C++ 中的基类的析构函数为什么最好定义为虚函数呢?

  • 如果不把基类的析构函数定义为虚函数,则在继承体系中子类的析构函数就不能重写父类的析构函数,则就不能满足多态的条件,则在特殊的情况下会有内存泄露,这个情况代码演示如下
#include <iostream>
using namespace std;

class Person{
public:
	 ~Person(){
		cout << "~Person()" << endl;
	}
};
class Student : public Person
{
public:
	~Student(){
		cout << "~Student()" << endl;
	}
};
int main()
{
	Person p;
	Student s;

	return 0;
}

  • 在上面这个场景下,不给基类析构函数加 virtual => 不构成重写 => 不构成多态 ,是可以正常析构的,如下图
    在这里插入图片描述
  • 但要是下面这个情况
#include <iostream>
using namespace std;

class Person{
public:
	 ~Person(){
		cout << "~Person()" << endl;
	}
};
class Student : public Person
{
public:
	 ~Student(){
		cout << "~Student()" << endl;
	}
};
int main()
{
	// 只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函数,才能
	// 构成多态,才能保证p1和p2指向的对象正确的调用析构函数。
	Person* p1 = new Person;
	delete p1;
	Person* p2 = new Student;
	delete p2;
	//对于 p2 来说,只析构了父类的那一部分,而子类Student的析构函数并没有调用
	//万一子类申请了资源,而你却没有调用子类的析构函数去释放资源给系统,那么就会
	//内存资源泄露,原因是因为我们没有给基类的析构函数加上virtual 从而
	//导致没有构成多态。

	//总结:构成多态调用指向的对象
	//      没有构成多态就按照类型去调用
	//所以设计基类的时候最好把析构函数设计为虚函数
	return 0;
}

在这里插入图片描述
修改代码如下:

#include <iostream>
using namespace std;

class Person{
public:
	virtual ~Person(){
		cout << "~Person()" << endl;
	}
};
class Student : public Person
{
public:
	virtual ~Student(){
		cout << "~Student()" << endl;
	}
};
int main()
{
	// 只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函数,才能
	// 构成多态,才能保证p1和p2指向的对象正确的调用析构函数。
	Person* p1 = new Person;
	delete p1;
	Person* p2 = new Student;
	delete p2;
	//对于 p2 来说,只析构了父类的那一部分,而子类Student的析构函数并没有调用
	//万一子类申请了资源,而你却没有调用子类的析构函数去释放资源给系统,那么就会
	//内存资源泄露,原因是因为我们没有给基类的析构函数加上virtual 从而
	//导致没有构成多态。

	//总结:构成多态调用指向的对象
	//      没有构成多态就按照类型去调用
	//所以设计基类的时候最好把析构函数设计为虚函数
	return 0;
}

在这里插入图片描述

2.4 C++ 11 中与重写有关的两个关键字

从上面的代码可以看出,C++ 对函数重写的要求比较严格,但是有些情况下,由于自己的疏忽导致函数名字写错,无法构成重写,而这种错误在编译期间是不会报出的,只有在程序运行的时候没有得到预期的结果,自己去debug,折腾了半天甚至几天才找到问题的根源,这样得不偿失,因此 : C++ 11 提供了 overridefinal 两个关键字,可以帮助用户检测是否重写

  • final:修饰虚函数,表示该虚函数不能被继承,不能被重写;修饰类则该类不能被继承即不能有子类
class Car 
{
public:
	virtual void Drive() final  {}
};
class Benz :public Car
{
public:
	virtual void Drive() { cout << "Benz-舒适" << endl; }
};

在这里插入图片描述

class Car final
{
public:
	virtual void Drive() {}
};
class Benz :public Car
{
public:
	virtual void Drive() { cout << "Benz-舒适" << endl; }
};

在这里插入图片描述

  • override:检查派生类函数是否重写了基类某个虚函数,如果没有重写写编译报错。
class Car
{
public:
	 void Drive() {}
};
class Benz :public Car
{
public:
	virtual void Drive() override { cout << "Benz-舒适" << endl; }
};

在这里插入图片描述

2.5 重载、覆盖(重写)、隐藏(重定义)的对比(重点理解区分)

在这里插入图片描述

3 抽象类(也很重要,比如谈到纯虚函数)

3.1 抽象类的概念

在虚函数的后面加上 = 0,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承抽象类后只有重写纯虚函数才能实例化出对象,否则派生类也是抽象类。纯虚函数规范了派生类必须重写继承来的虚函数,另外纯虚函数体现了接口继承。

class Car
{
public:
	virtual void Drive() = 0;//纯虚函数
};
class Benz :public Car
{
public:
	virtual void Drive()
	{
		cout << "Benz-舒适" << endl;
	}
};
class BMW :public Car
{
public:
	virtual void Drive()
	{
		cout << "BMW-操控" << endl;
	}
};
void Test()
{
	Car* pBenz = new Benz;
	pBenz->Drive(); 

	Car* pBMW = new BMW;
	pBMW->Drive();
}
int main()
{
	Test();
	return 0;
}

3.2 接口继承和实现继承

普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以不实现多态就不要把函数定义成虚函数。

4 多态的原理(老重要了)

4.1 虚函数表

为了彻底讲清楚多态的原理,需要引入 虚函数表(表指的数组) 这个概念。

观察如下代码,结果应该是多少呢?

class Base{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
private:
	int _b = 1;
};
int main()
{
	Base b;
	cout << sizeof(Base) << endl;
	return 0;
}

是不是以为是4个字节呢,其实不然。
在这里插入图片描述
是8个字节,我们通过监视窗口查看 b 对象,发现除了_b 成员变量,还多了一个_vfptr指针,放在对象的前面(注意:有些平台可能会放在对象的最后面,这个跟平台有关),对象中的这个指针叫做虚函数表指针(v 代表 virtual , f 代表 function ),一个含有虚函数的类中都至少有一个虚函数表指针,因为虚函数的地址要被放在虚函数表中,虚函数表也简称为虚表,虚函数表本质是一个函数指针数组。
在这里插入图片描述
针对上面的代码做下改造
1、我们增加一个派生类Derive去继承Base
2、Derive中重写Func1
3、Base再增加一个虚函数Func2和一个普通函数Func3

#include <iostream>
using namespace std;
class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
	virtual void Func2()
	{
		cout << "Base::Func2()" << endl;
	}
	void Func3()
	{
		cout << "Base::Func3()" << endl;
	}

private:
	int _b = 1;
};
class Derive : public Base
{
public:
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}
private:
	int _d = 2;
};
int main()
{
	Base b;
	Derive d;
	cout << sizeof(Base) << "字节" << endl;
	return 0;
}

在这里插入图片描述

int main()
{
	Base b1;
	Base b2;
	
	Derive d;
	cout << sizeof(Base) << "字节" << endl;
	return 0;
}

问3个问题?
1、b1 和 b2 的虚表是不是同一个?
2、虚表存在哪里?
3、虚表在什么时候生成?

  • 如下图所示,清楚的看出b1 和 b2 的虚函数表是同一个

在这里插入图片描述

  • 看如下的代码和结果图来解答第二个问题,则虚函数表指针存在代码段(常量区)
    Base b1;
	Base b2;
	//为了验证虚表存在哪里,写如下代码
	printf("虚表指针:%p\n", *((int*)&b1));
	int a = 0;
	static int b = 0;
	int *p = new int;
	char* str = "hello world";
	printf("栈:%p\n", &a);
	printf("数据段(静态区):%p\n", &b);
	printf("堆:%p\n", p);
	printf("代码段(常量区):%p\n", str);

在这里插入图片描述

  • 虚函数表在编译时候生成

通过上面的代码我们来探索下在单继承体系中多态是怎样体现和实现的

    Base b;
	Derive d;

在这里插入图片描述
我们可以有一下结论:

  • 派生类对象 d 中也有一个虚表指针,d 对象由两部分组成,一部分是父类继承下来的包括虚表指针,可以理解为把基类的续表拷贝一份给子类,但是子类重写了父类的虚函数 Func1 所以在子类的续表里覆盖了Func1 所以才有了上图的情况。
  • 基类b对象和派生类d对象的虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表中存在的是重写的Derive::Func1,所以虚函数的重写也叫覆盖,覆盖就是指的是虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
  • Func2 继承下来后是虚函数,所以也放进了虚表,Func3 也继承下来了,但是它不是虚函数,所以不会放进虚表。
  • 虚函数表我的本质一个存虚函数指针的指针数组,这个数组最后面放了一个nullptr(上面的图)
  • 总结一下派生类的虚函数表生成:a.先将基类中的虚表内容拷贝一份到派生类虚表中 b. 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数 c. 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后
  • 还有一个比较容易混淆的问题:虚函数存在哪里?虚表存在哪里?很多人可能会这样回答:虚函数存在虚表,续表存在对象中。注意:这是大错特错的。应该是这样的:虚函数表存的是虚函数指针(虚函数所在的地址),存的不是虚函数,虚函数和普通的函数一样是存在代码段的,对象中存的是虚函数表指针,是一个地址。而续表存在的地方我们上面已经验证过了,同样也是代码段。

4.2 多态的原理

上面说了这么大的篇幅的虚函数表,那么终于我们得来聊一聊多态的原理了

  • 上面分析了这个半天了那么多态的原理到底是什么?还记得这里Func函数传Person调用的Person::BuyTicket,传Student调用的是Student::BuyTicket
#include <iostream>
using namespace std;

class Person 
{
public:
	virtual void BuyTicket() 
	{
		cout << "买票-全价" << endl; 
	}
};
class Student : public Person
{
public:
	virtual void BuyTicket() 
	{
		cout << "买票-半价" << endl;
	}
};

void Func(Person& p)
{
	p.BuyTicket();
}
int main()
{
	Person laoyang;
	Student xiaochen;
	Func(laoyang);
	Func(xiaochen);
	return 0;
}

在这里插入图片描述

  • 观察下图的红色箭头我们看到,p是指向laoyang对象时,p->BuyTicket在mike的虚表中找到虚函数是Person::BuyTicket。
  • 观察下图的绿色箭头我们看到,p是指向xiaochen对象时,p->BuyTicket在johson的虚表中找到虚函数是Student::BuyTicket。‘
  • 这样就实现出了不同对象去完成同一行为时,展现出不同的形态。
  • 反过来思考我们要达到多态,有两个条件,一个是虚函数覆盖,一个是对象的指针或引用调用虚函数。反思一下为什么?‘
  • 再通过下面的汇编代码分析,看出满足多态以后的函数调用,不是在编译时确定的,是运行起来以后到对象的中取找的。不满足多态的函数调用时编译时确认好的。
// 以下汇编代码中跟你这个问题不相关的都被去掉了
void Func(Person* p) {
...
p->BuyTicket();
// p中存的是mike对象的指针,将p移动到eax中
001940DE mov eax,dword ptr [p]
// [eax]就是取eax值指向的内容,这里相当于把mike对象头4个字节(虚表指针)移动到了edx
001940E1 mov edx,dword ptr [eax]
// [edx]就是取edx值指向的内容,这里相当于把虚表中的头4字节存的虚函数指针移动到了eax
00B823EE mov eax,dword ptr [edx]
// call eax中存虚函数的指针。这里可以看出满足多态的调用,不是在编译时确定的,是运行起来以后到对
象的中取找的。
001940EA call eax 
001940EC cmp esi,esp 
}
int main()
{
... 
// 首先BuyTicket虽然是虚函数,但是mike是对象,不满足多态的条件,所以这里是普通函数的调用转换成
地址时,是在编译时已经从符号表确认了函数的地址,直接call 地址
 mike.BuyTicket();
 00195182 lea ecx,[mike]
 00195185 call Person::BuyTicket (01914F6h) 
... 
}

4.3 动态绑定和静态绑定

  1. 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载重载
  2. 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
  3. 买票的汇编代码很好的解释了什么是静态(编译器)绑定和动态(运行时)绑定。

多态在生活中随处可见,所以就像艺术来源于生活一样,技术也是如此,常常在理解知识和技术的时候类比生活,你会理解的更深刻。

  • 4
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值