面向对象程序设计的三大特性(三):C++中的多态

前言

在我们系统的学习了封装和继承之后,那自然少不了面向对象程序设计的另一大特性——多态,C++中的多态又是怎样的呢?下面带大家来一起梳理C++中多态的相关知识。

1. 多态的概念

顾名思义,多态就是多种形态。比如,买票这个行为,普通人买票时是全价买票,学生买票时是学生价买票,军人买票时是优先买票。

多态:不同的对象去完成某个相同的行为时会产生出不同的状态。

  • 在继承中要构成多态的两个条件

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

2.被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写

#include<iostream>
using namespace std;

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

1. 虚函数的重写
class Student : public Person {
public:
	virtual void BuyTicket() { cout << "买票-半价" << endl; }
};

2. 基类的指针或引用调用虚函数
void Func(Person& p){
	p.BuyTicket();
}

int main(){
	Person ps;
	Student st;
	Func(ps);
	Func(st);

	return 0;
}
运行结果:
	买票-全价
	买票-半价
  • 满足多态的条件

跟调用对象的类型无关,和指向对象的类型有关。

指针或引用指向哪个类实例化出的对象,就调用这个类的虚函数。

  • 不满足多态的条件

跟调用对象的类型有关。

哪个类的对象调用虚函数,就调用这个类的虚函数。

2. 虚函数

  • 虚函数的概念

虚函数:即被virtual修饰的类的成员函数称为虚函数。

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

注意:

内联函数不能是虚函数,因为内联函数会直接在被调用的地方展开,没有地址,不能放进虚表中。

static型函数不能是虚函数,因为static型函数没有this指针。虚函数是通过对象中的虚函数表指针去找到虚函数的地址。

  • 虚函数的重写(覆盖)

派生类中有一个跟基类完全相同的虚函数,即派生类虚函数与基类虚函数的返回值类型、函数名、参数列表完全相同。称派生类的虚函数重写了基类的虚函数。

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

注意:在重写(覆盖)基类的虚函数时,派生类的虚函数在不加virtual关键字时,虽然也能构成重写(基类的虚函数被继承到派生类中后仍然保持虚函数属性),但这种写法不是很规范,不建议使用。

  • 虚函数重写的两个例外

1. 协变(基类与派生类虚函数的返回值类型不同)

派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。

2. 析构函数的重写

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

析构函数应当定义为虚函数: 防止只析构基类而不析构派生类的情况发生。

基类指针指向派生类对象时,此时调用非虚析构函数,则会出现只会析构基类对象而不析构派生类对象的情况,造成内存泄漏的问题。

3. C++11中的override和final

C++对函数的重写要求的比较严格,但是有些情况下由于疏忽,使函数无法构成重写,这种错误在编译期间是不会报错的,在程序运行时没有得到预期的结果从而Debug得不偿失,所以,C++11提供了override和final两个关键字来帮助用户检测是否重写

  • final

final修饰虚函数表示该虚函数不能被重写,final修饰类表示该类不能被继承。

  • override

检查派生类虚函数是否重写了基类的某个虚函数,如果没有重写则编译报错。

4. 重载、重定义(隐藏)、重写(覆盖)的对比

  • 1. 重载

两个函数在同一作用域,并且函数名相同,参数列表不同。

  • 2. 重定义(隐藏)

两个函数分别在基类和派生类的作用域,并且函数名相同。

  • 3. 重写(覆盖)

两个虚函数分别在基类和派生类的作用域,函数名、参数、返回值都必须相同(协变例外)。

5. 抽象类

在虚函数的后面写上 =0 ,则这个函数为纯虚函数(不需要实现,没有函数体)。

包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象,派生类继承后也不能实例化出对象。

重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。

  • 实现继承

普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。

  • 接口继承

虚函数的继承是一种接口继承,派生类继承了基类函数,目的是为了重写,实现多态,继承的是函数的接口。

6. 虚函数表

1.基类的虚函数表:

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

在32位平台下测试,发现 sizeof(Base) = 8,其实Base类实例化的对象b中除了成员变量 _b外,还有一个 _vfptr放在成员变量的前面(前后关系和平台有关),_vfptr其实是一个指针,我们叫做虚函数表指针,虚函数表底层是一个指针数组,其中存放着虚函数的地址。一个含有虚函数的类中都至少有一个虚函数表指针,因为虚函数的地址需要存放到虚函数表中。

2.派生类的虚函数表:

Derive类继承了Base类,Derive类中重写了虚函数Func1。

class Base{
public:
	virtual void Func1(){
		cout << "Base::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;
	return 0;
}

经过测试,派生类d对象中也有一个虚函数表指针(从基类继承下来的),值得注意的一点是,派生类对象d和基类对象b的虚函数表是不同的,派生类中Func1完成了重写,所以派生类的虚函数表中存放的是重写的Derive :: Func1的地址。虚函数Func2继承下来后没有被重写,Func2的地址直接放入派生类的虚函数表中,而Func3不是虚函数,虽然被继承下来,但是它的地址不会被存入虚函数表。

在这里插入图片描述

总结一下派生类虚函数表的生成: 先将基类中的虚函数表内容拷贝一份到派生类虚函数表中 ,如果派生类重写了基类中某个虚函数,则用派生类自己的虚函数覆盖虚表中基类的虚函数,派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。

总结一下容易混淆的问题: 虚函数表中存的是虚函数的指针,而不是虚函数。虚函数和普通函数一样,都存放在代码段。对象中存的是虚函数表指针,而不是虚函数表。VS下虚函数表存在代码段。虚函数表是在编译阶段生成的。

7. 多态的原理

在了解了虚函数表的相关知识之后,哪么多态的原理是什么呢?

举个栗子:

上面我们说过,Func函数传Person对象调用的是Person :: BuyTicket,Func函数传Student对象调用的是Student :: BuyTicket

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 Mike;
	Func(Mike);
	Student Johnson;
	Func(Johnson);
	return 0;
}

经过分析,程序运行时,如果p指向Mike对象时,p.BuyTicket通过Mike对象的虚函数表指针找到虚函数表,找到的虚函数为Person :: BuyTicket 。如果p指向Johnson对象时,p.BuyTicket通过Johnson对象的虚函数表指针找到虚函数表,找到的虚函数为Student :: BuyTicket 。

简而言之就是指向谁就到谁的虚函数表中去找对应的虚函数,这样就实现出了不同对象去完成同一行为时,展现出不同的形态

我们再来看看汇编代码:
在这里插入图片描述

  • 静态绑定

静态绑定又称为前期绑定,在程序编译期间确定了程序的行为,调用具体函数,也称为静态多态,比如:函数重载。

  • 动态绑定

动态绑定又称为后期绑定,在程序运行期间,根据具体拿到的类型确定程序的具体行为,调具体的函数,也称为动态多态。

8. 单继承和多继承关系的虚函数表

在单继承和多继承关系中,我们去研究的是派生类对象的虚函数表模型。

  • 1. 单继承关系中的虚函数表
class Base {
public :
	virtual void func1() { cout<<"Base::func1" <<endl;}
	virtual void func2() { cout<<"Base::func2" <<endl;}
private :
	int a;
};

class Derive :public Base {
public :
	virtual void func1() {cout<<"Derive::func1" <<endl;}
	virtual void func3() {cout<<"Derive::func3" <<endl;}
	virtual void func4() {cout<<"Derive::func4" <<endl;}
private :
	int b;
};

我们通过监视窗口来看一下基类与派生类的虚函数表:
在这里插入图片描述
我们可以发现,监视窗口中我们发现看不见func3和func4。这里其实是编译器的监视窗口故意隐藏了这两个函数。

那么我们如何查看派生类的虚函数表呢?下面我们使用代码打印出虚函数表中的函数。

// 传参类型为函数指针
typedef void(*VF_PTR)();
void PrintVFTable(VF_PTR* table){
	for (size_t i = 0; table[i] != 0; i++){
		printf("vfTable[%d]:%p->", i, table[i]);
		VF_PTR f = table[i];
		f();
	}
	cout << endl;
}

int main(){
	Base b;
	Derive d;
	// 取对象中前四个字节村的虚函数表指针
	PrintVFTable((VF_PTR*)*(int*)&b);
	PrintVFTable((VF_PTR*)*(int*)&d);
}

结果如下:
在这里插入图片描述

  • 2. 多继承关系中的虚函数表

多继承派生类的未重写虚函数放在第一个继承基类部分的虚函数表中
在这里插入图片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章