面向对象三大特性之一——多态详解

目录

前言

一、多态的概念

二、多态的定义及实现

 1、虚函数的概念

2、虚函数的重写

2.1概念

2.2虚函数重写的两个例外 

3、多态的构成条件

4、c++ 11 override和final

4.1 final

4.2 override

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

三、抽象类

1、概念

2、接口继承和实现继承 

四、多态的原理

1、虚函数表

2、多态的原理

3、动态绑定和静态绑定

 五、继承的虚函数表

1、单继承中的虚函数表

 2、多继承中的虚函数表

六、常见面试题

总结


前言

哈喽,小伙伴们大家好。上一章我们一起学习了继承,今天我们继续来学习面向对象三大特性中的最后一个特性——多态。事不宜迟,快拿起小本本,跟我一起开始吧。


一、多态的概念

概念:多态,顾名思义,也就是多种状态。通俗点说就是不同的对象去干同一件事情时会产生不同的状态。以买火车票为例,普通人买票就要付全款,学生买票就可以出半价,军人虽然不能优惠但是可以优先买票。

放到我们程序中就是不同继承关系的类对象去调用同一个函数,会产生不同的状态。

二、多态的定义及实现

 1、虚函数的概念

在了解多态的构成条件前,我们先来了解一个概念,虚函数。

虚函数:被virtual修饰的类成员函数称为虚函数。要注意,虽然虚函数的关键字和虚拟继承的关键字相同,但实际它们没有任何关系。

2、虚函数的重写

2.1概念

虚函数的重写(覆盖):派生类中有一个和基类中完全相同的虚函数(函数名,返回值,参数列表都相同),则成为子类对基类的虚函数进行了重写。

注意:如果基类的函数为虚函数,不管派生类的相同函数前加不加virtual,都默认是虚函数,进行了重写。

class Person {
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
	virtual void BuyTicket() { cout << "买票-半价" << endl; }
	/*注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因
	为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议
	这样使用*/
};

2.2虚函数重写的两个例外 

虚函数重写一般要严格遵守函数完全相同的条件,但是有两个例外情况下这个规则被打破。

(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) 析构函数重写(名字不同)

如果基类的析构函数为虚函数,则与派生类的析构函数进行重写。虽然这两个函数看上去名字并不相同,但编译器进行了处理,编译后析构函数的名称统一编译成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;
    return 0;
}

注意:我们在写多态时,尽量不要出现协变,并且基类和派生类的虚函数前面都加上virtual,使代码更规范。

3、多态的构成条件

多态的构成条件:

  • 被调用的必须是虚函数,且派生类必须对基类的虚函数进行了重写。
  • 必须通过基类的指针或引用调用虚函数。

我们以普通人和学生买票为例,代码如下:

4、c++ 11 override和final

在实际写代码的过程中,我们很可能因为不小心把虚函数的名字母敲错或敲反,导致无法构成重写,所以c++11加了两个关键字来帮我们检查这个错误。

4.1 final

final修饰虚函数,表示该函数不能再被重写。如果发生了重写会在编译阶段报错。

class A
{
public:
	virtual void fun() final {}
};
class B :public A
{
public:
	virtual void fun() { cout << "重写成功" << endl; } //编译的时候报错
};

4.2 override

override函数用来检查派生类的函数是否虚写了基类的某个虚函数,如果没有则发生报错。

class A
{
public:
	virtual void fun();
};
class B :public A
{
public:
	virtual void fun() override { cout << "重写成功" << endl; }
};

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

重载:

  • 在同一个作用域。
  • 函数名相同,参数不同。

重写:

  • 在基类和派生类两个作用域。
  • 必须是虚函数。
  • 返回值,函数名,参数列表必须都完全相同。(协变除外)。

隐藏:

  • 在基类和派生类两个作用域。
  • 函数名相同。
  • 两个基类和派生类的同名函数不构成重写就构成隐藏。

三、抽象类

1、概念

在虚函数的后面加上=0,这个函数被称为纯虚函数,包含纯虚函数的类被称为抽象类(也叫做接口类)。抽象类的派生类依旧是抽象类。只有重写了纯虚函数,派生类才能够实例化对象。纯虚函数规范了派生类需要重写,并且很好的体现出了接口继承的特性。

class A
{
public:
virtual void func() = 0;//纯虚函数
};

2、接口继承和实现继承 

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

四、多态的原理

1、虚函数表

大家先看这样一道题,sizeof(A)的值是多少。

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

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

 表面上看上去只有两个成员变量a和b,根据内存对齐规则,这个类的大小为8Bytes,但实际通过测试,这个类的大小是12Bytes。我们打开监视窗口发现,这个类中隐藏了一个_vfptr指针。这个指针叫做虚函数表指针(v是virtual,f是function),指向虚函数表,简称虚表。虚函数表中存放的是类中虚函数的地址。每个含有虚函数的类都至少有一个虚函数表指针,如果A这个类里没有虚函数,那sizeof(A)就是8Bytes。

 那么派生类中这个表里放了什么呢?我们继续往下研究。

class Person
{
public:
	virtual void Func1()
	{
		cout << "Person::Func1()" << endl;
	}
	virtual void Func2()
	{
		cout << "Person::Func2()" << endl;
	}
	void Func3()
	{
		cout << "Person::Func3()" << endl;
	}
private:
	string name="zxy";
};

class Student : public Person
{
public:
	virtual void Func1()
	{
		cout << "Student::Func1()" << endl;
	}
private:
	int _id = 100;
};

int main()
{
	Person p;
	Student s;
	return 0;
}

经过调试我们发现,在派生类中也有一个虚函数表。

 我们从上面的表中可以得出以下信息。

  • 由于虚函数表指针是二级指针,说明虚函数表中存的并不是虚函数,而是虚函数的地址。虚函数表的本质是一个存了虚函数指针的指针数组,一般数组的最后放了一个nullptr。
  • 由于Func3是普通函数,所以虚函数表中只存了Func1和Func2两个函数的地址。
  • 基类的虚函数表和派生类的虚函数表不是同一张。
  • 派生类中虚函数表的生成过程:先将基类的虚函数表拷贝一份放到派生类虚表中。如果在派生类中对某个虚函数进行了重写,则用派生类自己的虚函数覆盖表中基类的虚函数。
  • 派生类中自己新添加的虚函数按照声明顺序依次加到派生类虚表的后面。(这个通过监视窗口看不到,需要看内存窗口)。
  • 虚表是存在代码段中的。

2、多态的原理

还是以最开始提到的买票的例子为例。

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的类型,跟p指向的对象没关系。

 这时候我们反过来思考一下,多态要求必须通过基类的指针或引用去调用,为什么不能通过基类对象去调用呢?

如果是通过引用或指针调用,指针(引用的本质也是指针)直接指向父类对象或子类对象切片出来的那一部分,即可找到相应的虚表。

而如果通过父类对象调用,则需要子类对象切片拷贝到父类对象中,但是要注意一点,子类的虚表指针是不会拷贝到父类对象中去的。因为同类型的对象,虚表是一样的,共享一张虚表,把子类的虚表指针拷贝到父类中不合理。

3、动态绑定和静态绑定

  • 静态绑定指的是在程序编译期间就确定了程序的行为,也叫做静态多态,比如函数重载。
  • 动态绑定指的是在程序运行阶段,根据具体拿到的数据类型调用相应的函数,也叫做动态多态。

 我们平时说的多态一般都指动态多态,本章介绍的也主要围绕动态多态。

 五、继承的虚函数表

1、单继承中的虚函数表

我们来进一步研究一下虚函数表模型,假设在派生类中又增加了虚函数,那他应该存在哪里呢?

class Person
{
public:
	virtual void Func1()
	{
		cout << "Person::Func1()" << endl;
	}
	virtual void Func2()
	{
		cout << "Person::Func2()" << endl;
	}
	void Func3()
	{
		cout << "Person::Func3()" << endl;
	}
private:
	string name="zxy";
};

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

	virtual void Func4()
	{
		cout << "Student::Func4()" << endl;
	}
private:
	int _id = 100;
};

int main()
{
	Person p;
	Student s;
	return 0;
}

我们发现监视窗口中看不到func4,可以认为是编译器隐藏了起来。

通过内存窗口观察发现,派生类中新增加的虚函数会按声明顺序依次加在派生对象虚表的后面。

 2、多继承中的虚函数表

这里不再演示具体过程,只需要记住结论,多继承派生类新增加的虚函数会放在第一个基类部分的虚表中。

六、常见面试题

1. 什么是多态?答:参考本章内容
2. 什么是重载、重写(覆盖)、重定义(隐藏)?答:参考本章内容
3. 多态的实现原理?答:参考本章内容
4. inline函数可以是虚函数吗?答:可以,虽然inline函数没有地址,但是编译器编译时会忽略inline属性,这个函数就不再是inline,可以放到虚表中去。
5. 静态成员可以是虚函数吗?答:不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
6. 构造函数可以是虚函数吗?答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。构造函数之前没有虚函数指针。
7. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?答:可以,并且最好把基类的析构函数定义成虚函数。
8. 对象访问普通函数快还是虚函数更快?答:首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
9. 虚函数表是在什么阶段生成的,存在哪的?答:虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的。
10. C++菱形继承的问题?虚继承的原理?答:参考之前写的继承博客。注意这里不要把虚函数表和虚基表搞混了。
11. 什么是抽象类?抽象类的作用?答:参考本章内容。抽象类强制重写了虚函数,另外抽象类体现出了接口继承关系。


 总结

以上就是今天要讲的全部内容,本章主要介绍了多态的相关概念和原理。多态的知识还是比较复杂的,还有很多地方博主也没有理解的很透彻。在以后的日子里我会坚持学习和探索,争取把更好的作品带给大家。感谢阅读,来日方长,期待和大家再次见面。

猜你喜欢

转载自blog.csdn.net/weixin_59371851/article/details/125903149