C++ 中的多态

313 阅读7分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

今天介绍面向对象三大特性之一:多态

引入

通俗来说,多态就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。

比如消费行为,当普通顾客购买时,一般都是全额付款。而当VIP付款时,总是享受折扣优惠;这也是多态~

这么看来之前所说的大数据杀熟也是一种多态行为了~ /滑稽

  • 静态多态:静态多态就是重载,因为是在编译期决议确定,所以称为静态多态。
  • 动态多态:动态多态就是通过继承重写基类的虚函数实现的多态,因为是在运行时决议确定,所以称为动态多态。

构成条件

多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。 比如Student继承了PersonPerson对象买成人票全价,Student对象买学生票半价。

那么在继承中要构成多态还有两个条件:

  1. 必须通过基类的指针或者引用调用虚函数
  2. 被调用的函数必须是虚函数,并且派生类必须对基类的虚函数进行重写

虚函数

虚函数:被virtual关键字修饰的类成员函数

virtual void VIPpurchase() { cout << "折扣40%" << endl;}

虚函数的重写(覆盖)

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

演示

class Person {
public:
	virtual void Purchase() {
		std::cout << "100yuan\n";
	}
};

class Student :public Person {
public:
	virtual void Purchase() {		//派生类重写虚函数
		std::cout << "50yuan\n";
	}
};

/*注意:
	在重写基类虚函数时,派生类的虚函数在不加 virtual 关键字时,虽然也可以构成重写(因为继承后
	基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用
*/
void Func(Person& p) {		//必须是基类的指针 / 引用
	p.Purchase();
}

int main() {
	Student s;
	Func(s);

	Person p;
	Func(p);

	return 0;
}

输出结果:

50yuan
100yuan

如代码所示,就完成了不同对象的多态行为。

虚函数重写特例

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. 析构函数的重写

  • 如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写。

虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然。 这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor

class Person {
public:
	virtual ~Person() { cout << "~Person()" << endl; }
};
class Student : public Person {
public:
	virtual ~Student() { cout << "~Student()" << endl; }
};

// 只有派生类Student的析构函数重写了Person的析构函数
//	下面的delete对象调用析构函数,才能构成多态
//	才能保证p1和p2指向的对象正确的调用析构函数。

int main() {
	Person* p1 = new Person;
	Person* p2 = new Student;
	delete p1;
	delete p2;
	system("pause");
	return 0;
}

C++11:override 和 final 关键字

C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此:C++11提供了overridefinal两个关键字,可以帮助用户检测是否重写。

final

final修饰虚函数,表示该虚函数不能再被继承

class Car{
public:
	virtual void Drive() final {}
};

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

错误信息

Car::Drive”: 声明为“final”的函数无法被“Benz::Drive”重写

override

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

class Car {
public:
	virtual void Drive() {}
};

class Benz :public Car {
public:
	virtual void Drive() override { cout << "Benz-舒适" << endl; }		//构成重写
	virtual void Drive(int a) override { cout << "Benz-舒适" << endl; }	//未重写
};

错误信息

包含重写说明符“override”的方法没有重写任何基类方法

重载、重写、重定义

学了继承与多态发现这几个名词已经有些搞不清了,这里明晰一下~

/作用域参数与返回值其他
重载两个函数在同一作用域函数名、参数相同-
重写(覆盖)两个函数分别在基类与派生类的作用域函数名、参数、返回值相同(协变除外)两个函数都必须是虚函数
重定义(隐藏)两个函数分别在基类与派生类的作用域函数名相同即可两个基类和派生类的同名函数不构成重写,就是重定义

抽象类

  • 在虚函数的后面写上 =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;
	}
};
int main(){
	//Car r;		//错误,不允许使用抽象类类型Car的对象

	Car* pBenz = new Benz;		//如果没有重写纯虚函数,则报错不能实例化抽象类
	pBenz->Drive();
	Car* pBMW = new BMW;
	pBMW->Drive();
	return 0;
}

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

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


多态底层原理剖析

虚函数表

请看代码:

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

运行结果:

8

???不是只有一个int类型的成员变量吗,也没有发生明显的内存对齐,为何会输出8

  • 其实一个含有虚函数的类中都至少内嵌有一个虚函数表指针,这个指针一般都在对象地址空间的最前面(有的平台会放在最后面)。因为虚函数的地址要被放到虚函数表中所以要添加指向,虚函数表也简称虚表

指针变量在32位机器下是4个字节,所以相加之后整个类的大小就是8个字节了。当然对于64位就是12字节了~

在这里插入图片描述

虚函数表指针__vfptr的命名修饰规则:v表示virtualf表示func1ptr表示pointer

针对上面的代码做出以下改造:

  1. 增加一个派生类Derive继承Base
  2. Derive中重写Func1
  3. Base再增加一个虚函数Func2和一个普通函数Func3
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对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1。 【所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法】
  • 另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函数,所以不会放进虚表。
  • 虚函数表本质是一个存虚函数指针的指针数组,这个数组最后放了一个nullptr

为什么没有显示虚函数func3?因为编译器做了优化,实际底层还是保存这此函数的虚函数指针的。

【总结一下派生类的虚表生成】:

  1. 先将基类中的虚表内容拷贝一份到派生类虚表中。
  2. 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数 。
  3. 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后
  • 那么虚函数存在哪虚表存在哪

答:

  • 虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。
  • 对象中存的是虚表指针,不是虚表。
  • 那么虚表存在哪的呢?vs下是存在代码段的

代码演示

用上面虚函数的重写中部分代码块为例:

class Person {
public:
	virtual void Purchase() {
		std::cout << "100 yuan\n";
	}
};

class Student :public Person {
public:
	virtual void Purchase() {
		std::cout << "50 yuan\n";
	}
};
void Func(Person* p) {
	p->Purchase();
};

int main() {
	Student sun;
	Func(&sun);
	sun.Purchase();

	Person paul;
	Func(&paul);
	paul.Purchase();

	return 0;
}

输出结果

50 yuan
50 yuan
100 yuan
100 yuan
  • p指向sun对象时,p->Purchase()sun的虚表中找到虚函数是Student::Purchase()
  • p指向paul对象时,p->Purchase()paul的虚表中找到虚函数是Person::Purchase()

这样就实现出了不同对象去完成同一行为时,展现出不同的形态。

关于Func(&paul);paul.Purchase();: 请看通过下面的汇编代码:

// 以下汇编代码中跟你这个问题不相关的都被去掉了
void Func(Person* p){
	...
		p->Purchase();
	// p中存的是paul对象的指针,将p移动到eax中
	001940DE mov eax, dword ptr[p]
		// [eax]就是取eax值指向的内容,这里相当于把paul对象头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(){
	...
		// 首先Purchase虽然是虚函数,但是paul是对象,不满足多态的条件
		//所以这里是普通函数的调用转换成地址时,是在编译时已经从符号表确认了函数的地址,直接call 地址
		paul.Purchase();
	00195182 lea ecx, [paul]
	00195185 call Person::Purchase(01914F6h)
	...
}

我们可以看出:

  • 满足多态的函数调用,是运行起来后到对象的中去找的。
  • 不满足多态的函数调用是编译时确认好的。

动态绑定与静态绑定

  1. 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态。 (比如:函数重载)

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


单继承中的虚函数表

请看代码:

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;
};
int main() {
	Base b;
	Derive d;

	return 0;
}

在监视窗口中我们找不到func3func4。这就是前面说的编译器的监视窗口故意隐藏(处理)了这两个函数,也可以认为是他的一个小bug。

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

typedef void(*VFPTR) ();

void PrintVTable(VFPTR vTable[]){
	// 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数
	cout << " 虚表地址>" << vTable << endl;
	for (int i = 0; vTable[i] != nullptr; ++i){
		printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);
		VFPTR f = vTable[i];
		f();
	}
	cout << endl;
}
int main(){
	Base b;
	Derive d;
	
	VFPTR* vTableb = (VFPTR*)(*(int*)&b);
	PrintVTable(vTableb);
	VFPTR* vTabled = (VFPTR*)(*(int*)&d);
	PrintVTable(vTabled);
	return 0;
}

思路:取出b、d对象的头4bytes,就是虚表的指针,前面我们说了虚函数表本质是一个存虚函数指针的指针数组,这个数组最后面放了一个nullptr

  1. 先取b的地址,强转成一个int*的指针
  2. 再解引用取值,就取到了b对象头4bytes的值,这个值就是指向虚表的指针
  3. 再强转成VFPTR*,因为虚表就是一个存VFPTR类型(虚函数指针类型)的数组。
  4. 虚表指针传递给PrintVTable进行打印虚表
  5. 需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最后没有放nullptr,导致越界,这是编译器的问题。我们只需要清理解决方案,再编译就好了。

多继承中的虚函数表

class Base1 {			//基类 1
public:
	virtual void func1() { cout << "Base1::func1" << endl; }
	virtual void func2() { cout << "Base1::func2" << endl; }
private:
	int b1;
};

class Base2 {			//基类 2
public:
	virtual void func1() { cout << "Base2::func1" << endl; }
	virtual void func2() { cout << "Base2::func2" << endl; }
private:
	int b2;
};

class Derive : public Base1, public Base2 {		//多重继承派生类
public:
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func3" << endl; }
private:
	int d1;
};

typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[]){
	cout << " 虚表地址>" << vTable << endl;
	for (int i = 0; vTable[i] != nullptr; ++i){
		printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);
		VFPTR f = vTable[i];
		f();
	}
	cout << endl;
}

int main(){
	Derive d;
	VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);
	PrintVTable(vTableb1);
	VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));
	PrintVTable(vTableb2);
	return 0;
}

多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中

菱形继承、菱形虚拟继承: 实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定的性能损耗。 所以菱形继承、菱形虚拟继承就不深入讨论了,因为实际中很少用。


多态总结

  1. inline函数可以是虚函数吗? 答:不能,因为inline函数没有地址,无法把地址放到虚函数表中。

  2. 静态成员可以是虚函数吗? 答:不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。

  3. 构造函数可以是虚函数吗? 答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的,而虚表是在编译时就初始化的。如果构造函数也是虚函数,那么又如何在未调用构造函数的情况下的对象中确定身为虚函数的构造函数的指针?这个问题就存在“鸡生蛋还是蛋生鸡”的逻辑问题。

  4. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数? 答:可以,并且最好把基类的析构函数定义成虚函数。

  5. 对象访问普通函数快还是虚函数更快? 答:首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。

  6. 虚函数表是在什么阶段生成的,存在哪的? 答:虚函数是在编译阶段就生成的,一般情况下存在代码段(常量区)的。

  7. C++菱形继承的问题?虚继承的原理?答:这里是继承问题。注意这里不要把虚函数表和虚基表搞混了~

  8. 什么是抽象类?抽象类的作用?答:参考博客上半段抽象类板块。抽象类强制重写了虚函数,同时抽象类体现出了接口继承关系。为了复用~