【C++】多态

目录

一、什么是多态

二、多态的定义及实现

[2.1 多态构成后的现象](#2.1 多态构成后的现象)

[2.2 多态构成的必要条件](#2.2 多态构成的必要条件)

三、虚函数

[3.1 虚函数重写的三个例外](#3.1 虚函数重写的三个例外)

[3.1.1 析构函数的重写(基类与派生类析构函数的名字不同)](#3.1.1 析构函数的重写(基类与派生类析构函数的名字不同))

[3.1.2 派生类的虚函数没有virtual关键字](#3.1.2 派生类的虚函数没有virtual关键字)

[3.1.3 协变(基类与派生类虚函数返回值类型不同)](#3.1.3 协变(基类与派生类虚函数返回值类型不同))

[3.2 关于虚函数的面试题](#3.2 关于虚函数的面试题)

[3.3 C++11中的final和override](#3.3 C++11中的final和override)

[3.3.1 final](#3.3.1 final)

[3.3.2 override](#3.3.2 override)

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

五、抽象类

六、多态的原理

[6.1 单继承中的虚函数表](#6.1 单继承中的虚函数表)

[6.2 多继承中的虚函数表](#6.2 多继承中的虚函数表)

七、动态绑定与静态绑定


一、什么是多态

多态的概念:通俗来说,就是多种形态,具体点就是去完成某个同样的行为,当不同的对象去完成会产生出不同的过程或结果。

比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人 买票时是优先买票。

二、多态的定义及实现

2.1 多态构成后的现象

多态是在不同继承关系的类对象(所以多态要建立在继承之上),去调用同一函数,产生了不同的行为。

比如Child继承了Person。Person对象买票全价,Child对象买票半价:

cpp 复制代码
#include<iostream>
using namespace std;

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

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

void Func(Person& p)
{
	p.BuyTicket();
}

int main()
{
	Person p;
	Child c;
	Func(p);
	Func(c);
	return 0;
}

运行效果:

可以看到两个不一样的类调用同一个函数出现了不一样的打印结果

2.2 多态构成的必要条件

我们可以看到上面实现多态的代码有以下几个特点:

  1. 必须通过基类的指针或者引用调用虚函数(Func函数的形参类型为基类的引用)

下面我们修改一下Func函数的形参类型看看会发生什么:

cpp 复制代码
void Func(Person* p)//传入基类的指针类型
{
	p->BuyTicket();
}

int main()
{
	Person p;
	Child c;
	Func(&p);
	Func(&c);
	return 0;
}
cpp 复制代码
void Func(Person p)//传入基类类型
{
	p.BuyTicket();
}

int main()
{
	Person p;
	Child c;
	Func(p);
	Func(c);
	return 0;
}

咦?为什么只有基类的指针或者引用可以呢?(这个问题我们放在后面说)

  1. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
cpp 复制代码
class Person
{
public:
	virtual void BuyTicket()
	{
		cout << "买票-全价" << endl;
	}
};

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

我们可以看到基类和派生类中都有一个同名函数BuyTicket,这个函数不仅仅同名,连返回值和形参类型都一模一样 (不过后面会有返回值不同的例外),并且前面都加了一个virtual关键字(在虚拟继承中也使用到了virtual关键字,但这里和虚拟继承没有任何关系),这样的函数我们将其称为虚函数(关于虚函数的原理我们在后面会说),派生类中的虚函数也意味着对基类中同名函数的重写(覆盖)

有的同学会有疑问:这样的函数不构成隐藏吗

是不构成的,基类和派生类间的成员构成隐藏是没有virtual关键字的

三、虚函数

3.1 虚函数重写的三个例外

3.1.1 析构函数的重写(基类与派生类析构函数的名字不同)

cpp 复制代码
class Person
{
public:
	~Person()
	{
		cout << "~Person" << endl;
	}
};

class Child :public Person
{
public:
	~Child()
	{
		cout << "~Child" << endl;
	}
};
int main()
{
	Person p;
	Child c;
	return 0;
}

我们看到上面的代码,可以预测出来结果应该是派生类对象c先析构打印一个''~Child"和一个"~Person",再轮到基类对象p析构打印一个"~person":

下面我们来改改代码:

cpp 复制代码
int main()
{
	Person* p = new Person;
	Person* c = new Child;
	delete p;
	delete c;
	return 0;
}

运行结果:

咦?怎么和想象中的不一样,怎么少调用了一次Child的析构函数呢

这是因为类的指针在调用析构函数时是使用->调用的,在系统使用指针调用析构函数时,所有析构函数的名字都叫做destructor();所以上述代码调用析构函数时是这样的:p->destructor()、c->destructor(),这时该两个函数被调用的指针类型都是Person*,所以调用的都是Person类的析构函数,导致少用了一次Child的析构函数

为了解决这个问题我们有引出了一个方法: 在基类和派生类中使用虚函数来重写析构函数

cpp 复制代码
class Person
{
public:
	virtual ~Person()//使用虚函数来重写析构函数
	{
		cout << "~Person" << endl;
	}
};

class Child :public Person
{
public:
	virtual ~Child()//使用虚函数来重写析构函数
	{
		cout << "~Child" << endl;
	}
};

int main()
{
	Person* p = new Person;
	Person* c = new Child;
	delete p;
	delete c;
	return 0;
}

运行效果:

但是有的同学会产生疑问:虚函数的形成不是要构成函数名、形参和返回值相同吗?这里的析构函数名字都不相同,怎么加了virtual关键字就变成虚函数了呢?

我们还是要回到上面那个例子:虽然基类与派生类析构函数名字不同, 看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor

3.1.2 派生类的虚函数没有virtual关键字

我们先来看下面的代码:

cpp 复制代码
class Person
{
public:
	void BuyTicket()//基类中没有virtual关键字
	{
		cout << "买票-全价" << endl;
	}
};

class Child :public Person
{
public:
	virtual void BuyTicket()//派生类中有virtual关键字
	{
		cout << "买票-半价" << endl;
	}
};

void Func(Person& p)
{
	p.BuyTicket();
}

int main()
{
	Person p;
	Child c;
	Func(p);
	Func(c);
	return 0;
}

这样的BuyTicket函数在继承中是否构成虚函数呢?只有派生类中的函数有virtual关键字,这样是构成不了虚函数的:

那下面我们试一下: 只有基类中的函数有virtual关键字,派生类中的函数没有virtual关键字:

cpp 复制代码
class Person
{
public:
	virtual void BuyTicket()//派生类中有virtual关键字
	{
		cout << "买票-全价" << endl;
	}
};

class Child :public Person//基类中没有virtual关键字
{
public:
	void BuyTicket()
	{
		cout << "买票-半价" << endl;
	}
};

void Func(Person& p)
{
	p.BuyTicket();
}

int main()
{
	Person p;
	Child c;
	Func(p);
	Func(c);
	return 0;
}

咦?这在基类中有virtual关键字就可以构成虚函数了?

是这样的,如果在基类中有函数前面使用了virtual关键字,那在派生类中只要有与基类中被virtual修饰过的相同函数,编译器就将其认为是对基类函数的重写

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

派生类重写基类虚函数时,可以与基类虚函数返回值类型不同,但返回值必须是具有继承关系的对象的指针或者引用,称为协变。

我们来举个例子:

cpp 复制代码
class Person
{
public:
	virtual Person* BuyTicket()//返回基类的指针
	{
		cout << "买票-全价" << endl;
		return this;
	}
};

class Child :public Person
{
public:
	virtual Child* BuyTicket()//返回派生类的指针
	{
		cout << "买票-半价" << endl;
		return this;
	}
};

void Func(Person& p)
{
	p.BuyTicket();
}

int main()
{
	Person p;
	Child c;
	Func(p);
	Func(c);
	return 0;
}

运行效果:

当然返回值也可以是其他具有继承关系类对象的指针或者引用:

cpp 复制代码
class A
{};

class B : public A
{};

class Person
{
public:
	virtual const A& BuyTicket()//返回其他有继承关系基类的引用(A)
	{
		cout << "买票-全价" << endl;
		return A();
	}
};

class Child :public Person
{
public:
	virtual const B& BuyTicket()//返回其他有继承关系派生类的引用(B)
	{
		cout << "买票-半价" << endl;
		return B();
	}
};

void Func(Person& p)
{
	p.BuyTicket();
}

int main()
{
	Person p;
	Child c;
	Func(p);
	Func(c);
	return 0;
}

运行效果:

3.2 关于虚函数的面试题

我们来看一下下面的代码:

cpp 复制代码
class A
{
public:
	virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
	virtual void test() { func(); }
};

class B : public A
{
public:
	void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};

int main(int argc, char* argv[])
{
	B* p = new B;
	p->test();
	return 0;
}

现在有以下选项:

A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确

正确答案是哪个呢?

我们来分析一下,P指针来调用基类中的test函数,test函数再来调用func函数

调用func函数的this指针为基类的A*;首先观察这个func函数构不构成多态,函数名、返回值和形参类型都一样,只是在派生类中没有使用virtual关键字,所以是构成多态的;由于构成多态,在test中调用func函数,决定性因素在于调用test函数的指针类型,该指针类型为B*,所以调用的是B中的func函数;但是由于B中的func函数是对A类中的重写,重写意味着B中的func函数会继承A类函数的接口,这就造成了在B类中func函数形参缺省值还是1。

这样分析下来,结果就出来了:

下面我们改一下代码再来看看:

cpp 复制代码
class A
{
public:
	virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
};

class B : public A
{
public:
	void func(int val = 0) { std::cout << "B->" << val << std::endl; }
	virtual void test() { func(); }
};

int main(int argc, char* argv[])
{
	B* p = new B;
	p->test();
	return 0;
}

这一次我们将test函数放在B类中了,结果会是什么样的呢?

咦?怎么又发生变化了?

再来分析:P指针来调用基类中的test函数,test函数再来调用func函数;但是这里与上个例子不同的在于调用func的指针类型为派生类的B*,B类并没有被其他类所继承,所以不需要考虑多态问题,所以构成普通调用,直接调用B中的func函数,和A类中的函数并没有任何关系

3.3 C++11中的final和override

3.3.1 final

有的类定义了虚函数,但是并不想被派生类继承并重写该函数(这种情况极少),我们可以在函数的最后加上一个final关键字:

cpp 复制代码
class Car
{
public:
	virtual void Drive() final {}
};
class Benz :public Car
{
public:
	virtual void Drive() { cout << "Benz-舒适" << endl; }//无法被重写
};

3.3.2 override

我们可以在想要被重写函数的后面加上一个override关键字来判断该函数是否完成重写,如果没有完成重写就报错:

cpp 复制代码
class Car
{
public:
	virtual void Drive() {}
};
class Benz :public Car
{
public:
	virtual void Drive() override { cout << "Benz-舒适" << endl; }//该函数不构成重写就报错
};

下面我们演示一下不构成重写的情况:

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

该Drive函数并没有构成重写,但是并不会报错:

但是我们加上override关键字后:

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

五、抽象类

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

下面是举例:

cpp 复制代码
class Car//抽象类
{
public:
	virtual void Drive()=0 {}//纯虚函数
};
class Benz :public Car
{
public:
};

int main()
{
	Car c;//无法实例化
	Benz Bc;//无法实例化
	return 0;
}

下面我们对派生类中继承的虚函数进行重写:

cpp 复制代码
class Car//抽象类
{
public:
	virtual void Drive()=0 {}//纯虚函数
};
class Benz :public Car
{
public:
	virtual void Drive()
	{
		cout << "Benz Car" << endl;
	}
};

int main()
{
	Benz Bc;
	Bc.Drive();
	return 0;
}

这样派生类就可以实例化对象了:

六、多态的原理

我们先来看个代码:

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

int main()
{
	cout << sizeof(Base);
    return 0;
}

在32位平台下,该Base类的大小为多少?

按结构体内存的对齐规则应该是4字节,我们来看看结果:

6.1 单继承中的虚函数表

咦?怎么是8字节?我们来实例化一个对象看看其内部成员:

我们可以看到除了_b成员还有一个指针

这个指针是啥?该指针指向的是一个虚函数表(virtual functions pointer),该表中存着类中所有虚函数的地址(本质就是一个指针数组)

由于虚函数会在派生类中被重写,导致派生类和基类的虚函数存储地址不一样,所以需要通过该表找到不同类对应的虚函数

下面我们来写份代码看看:

cpp 复制代码
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对象,对虚函数进行重写了的虚函数指针记录的地址会发生变化,而没有重写的虚函数地址和基类中是保持一致

下面我们就可以解释为什么必须通过基类的指针或者引用调用虚函数才能构成多态了:

因为在派生类拷贝给基类时,虚函数表是不会被拷贝过去的(因为在切片拷贝时,要保证基类纯粹的只含有基类的数据,如果将派生类的虚表拷贝给基类,那这个基类到底还算不算真正的基类了呢?),下面我们使用代码来看看:

cpp 复制代码
class Base
{
public:
	virtual void Func()
	{
		cout << "Base::Func1()" << endl;
	}
private:
	int _b = 1;
};
class Derive : public Base
{
public:
	virtual void Func()
	{
		cout << "Derive::Func1()" << endl;
	}
private:
	int _d = 2;
};

int main()
{
	Derive d;
	Base b = d;//切片拷贝
	return 0;
}

下面我们来看一下如果在派生类中有着基类中没有虚函数,那这个虚函数的地址是否会被存入虚函数表中:

cpp 复制代码
class Base
{
public:
	virtual void Func1()
	{
		cout << "Base::Func1()" << endl;
	}
	virtual void Func2()
	{
		cout << "Base::Func2()" << endl;
	}
private:
	int _b = 1;
};
class Derive : public Base
{
public:
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}
	virtual void Func3()//基类中没有的虚函数
	{
		cout << "Derive::Func3()" << endl;
	}

private:
	int _d = 2;
};

下面我们来写一个函数打印一下这两个类对象,其虚表内部函数指针的个数(在VS中会在虚表的最后面放一个nullptr,g++编译器并不会):

cpp 复制代码
typedef void(*VF)();

void PrintVFTable(VF* table)//传入函数指针数组的首地址
{
	for (int i = 0; table[i] != nullptr; ++i)
	{
		printf("[%d]:%p->", i, table[i]);
		table[i]();
	}
	cout << endl;
}

int main()
{
	Base b;
	Derive d;
	PrintVFTable((VF*)(*(int*)&b));//强转提取其前四字节空间,64位平台下不适用
	PrintVFTable((*(VF**)&d));//也可以使用二级指针,64位平台下适用
	return 0;
}

运行结果:

可以看到即使派生类中有基类没有的虚函数,该虚函数的地址也会被存入虚函数表中(因为派生类也有可能被作为基类被继承)

那虚表是存在内存的哪一个空间中呢?

下面我们可以再来一段代码来验证一下:

cpp 复制代码
class Base
{
public:
	virtual void Func1()
	{
		cout << "Base::Func1()" << endl;
	}
	virtual void Func2()
	{
		cout << "Base::Func2()" << endl;
	}
private:
	int _b = 1;
};
class Derive : public Base
{
public:
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}
	virtual void Func3()//基类中没有的虚函数
	{
		cout << "Derive::Func3()" << endl;
	}

private:
	int _d = 2;
};

int main()
{
	int x;//栈区
	static int y;//静态区
	const char* p = "aaaaaaaa";//代码段(常量区)
	int* z = new int;//堆区
	Base b;
	Derive d;
	printf("栈区:%p\n", &x);
	printf("静态区:%p\n", &y);
	printf("代码段(常量区):%p\n", p);
	printf("堆区:%p\n", z);
	printf("b的虚表:%p\n", *(int*)&b);
	printf("d的虚表:%p\n", *(int*)&d);
	return 0;
}

运行结果:

我们可以看到续表的地址与常量区的地址最接近,所以虚表是存在常量区的

6.2 多继承中的虚函数表

下面我们来一个多继承,来看看派生类中的虚表是什么样的:

cpp 复制代码
class Base1 {
public:
	virtual void func1() { cout << "Base1::func1" << endl; }
	virtual void func2() { cout << "Base1::func2" << endl; }
private:
	int _b1;
};
class Base2 {
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;
};

下面我们来创建一个Derive类的对象,打印其对应的虚表来看看:

cpp 复制代码
typedef void(*VF)();

void PrintVFTable(VF* table)//传入函数指针数组
{
	for (int i = 0; table[i] != nullptr; ++i)
	{
		printf("[%d]:%p->", i, table[i]);
		table[i]();
	}
	cout << endl;
}

int main()
{
	Derive d;
	PrintVFTable((*(VF**)&d));//打印d对象Base1的虚表
	Base2* b = &d;//通过切片造成指针偏移
	PrintVFTable((*(VF**)b));//打印d对象Base2的虚表
	return 0;
}

运行结果:

我们可以看到多继承的派生类:

如果基类中有相同的虚函数,且派生类中对这个虚函数进行重写,则在派生类对象中该基类的虚函数都会被重写

派生类中若有基类中没有的虚函数,编译器会将该虚函数的地址存入派生类首地址基类的虚表中,其他基类不再保存

但是对于上述结果有个疑问:为什么派生类对象中继承的两个基类的虚表中,重写的函数地址不一样?但是打印出来都一样啊,这至少证明是同一个函数

下面我们深入到汇编来看看是怎么个事:

cpp 复制代码
int main()
{
	Derive d;
	Base1* p1 = &d;
	Base2* p2 = &d;
	p1->func1();
	p2->func1();
	return 0;
}

通过上面的代码我们转到汇编:下面是p1指针调用func1函数的过程

通过call指令来调用函数:

jmp指令来跳转到函数真正的地址:

再来看看p2是怎么调用func1函数的:

同样是call指令,call完后是一个jmp指令的跳转:

但是跳转完后,一个sub指令后又跟着一个跳转:

在上一次的跳转完后,又又是一个跳转!

这一次跳转完过后,终于见到函数本体了:

那p2为什么要跳转这么多次呢?

我们仔细观察,会发现在众多jmp指令中间夹着一个sub指令,而sub指令的目标是ecx寄存器,该寄存器存储的是this指针,所以最终的目的是修正this指针!

因为Base2在派生类Derive中是后被声明的,导致派生类赋值给基类指针时,指针会发生偏移,偏移后的this指针指向的并不是对象首地址,所以在使用时要进行修正

七、动态绑定与静态绑定

静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态, 比如:函数重载
动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态

相关推荐
java1234_小锋44 分钟前
MyBatis如何处理延迟加载?
java·开发语言
FeboReigns1 小时前
C++简明教程(4)(Hello World)
c语言·c++
FeboReigns1 小时前
C++简明教程(10)(初识类)
c语言·开发语言·c++
学前端的小朱1 小时前
处理字体图标、js、html及其他资源
开发语言·javascript·webpack·html·打包工具
zh路西法1 小时前
【C++决策和状态管理】从状态模式,有限状态机,行为树到决策树(二):从FSM开始的2D游戏角色操控底层源码编写
c++·游戏·unity·设计模式·状态模式
.Vcoistnt2 小时前
Codeforces Round 994 (Div. 2)(A-D)
数据结构·c++·算法·贪心算法·动态规划
小k_不小2 小时前
C++面试八股文:指针与引用的区别
c++·面试
摇光932 小时前
js高阶-async与事件循环
开发语言·javascript·事件循环·宏任务·微任务
沐泽Mu2 小时前
嵌入式学习-QT-Day07
c++·qt·学习·命令模式
沐泽Mu2 小时前
嵌入式学习-QT-Day09
开发语言·qt·学习