【C++】C++面向对象编程三大特性之一——多态

❤️前言

        继上篇继承的知识之后,本片博文主要和大家一起继续学习多态的知识。多态的实现依附于继承,是面向对象的重要特性。

正文

        多态,顾名思义就是多种状态。简单来说,不同类型的对象进行相同的操作会产生不同的结果。举实例来说就是,不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了Person,可能Person对象买票全价,Student对象买票半价。

多态的定义和实现

        在继承中要构成多态需要两个条件:

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

虚函数

        虚函数是指被virtual关键字修饰的类成员函数。而虚函数的重写(覆盖)则是指:派生类中有个跟基类在函数原型上完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),这样就称子类的虚函数重写了基类的虚函数。

        重写有一个很容易忽略的点,虚函数重写只重写了函数实现,但没有改变函数原型,这时如果参数具有缺省值就会有点差异

        我们按照上面构成多态的两个条件使用多态去实现买票的情形:

【C++】C++面向对象编程三大特性之一——多态_第1张图片

        这就是多态的简单使用方式,现在大家只要根据上面的演示就可以自己编写各种各样的多态代码啦。

虚函数重写的一些细节

        虚函数重写的条件本来是虚函数加上函数原型相同,但是有一些例外:

  1. 派生类的重写虚函数可以不加virtual关键字,,但是这样的写法并不规范,建议大家都写上。
  2. 如果两个虚函数的返回值类型是互为父子关系的指针或引用的话,也可以看作重写(协变)。这种规则并不好,我们做了解即可。
  3. 析构函数的重写和多态。

        前两种情况我们可以简单的了解,第三种情况我们现在认真地进行研究。我们以问答形式进行研究。

        析构函数可以是重写虚函数吗?析构函数加上virtual是不是虚函数重写?

        回答:可以是,析构函数在函数名前加上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; // p->destructor() + operator delete(p)
	return 0;  // 这里必须将p的调用变成多态调用,否则无法清理Student对象自己的资源
}

        因此只要是使用了继承,我们最好将父子类的析构函数设置为虚函数。

C++11 override和final

        我们发现多态重写虚函数的规则十分严格,但是我们有时候可能疏忽而产生问题。如果我们因为重写失败而产生了错误,这种错误往往无法编译过程中发现,而是在得到运行结果之后才会发现bug,这样发现错误之后再去debug会很麻烦,于是C++11就给我们提供了override和final两个关键字来解决相关的问题。

        1.final:修饰虚函数,表示这个虚函数不能再被重写,如果继续对它进行重写,就会发生报错。这样我们 就不需要去考虑虚函数重写的问题了。使用方式如下:

【C++】C++面向对象编程三大特性之一——多态_第2张图片

        除此之外final还可以加在类名的后面,然后让这个类无法被继承,这个类被称之为最终类。

        2.override:检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。通过这个关键字,我们就可以很轻松的知道虚函数重写是否发生了错误。使用方式如下:

【C++】C++面向对象编程三大特性之一——多态_第3张图片

        我们可以看到,Benz类没有重写Drive函数,于是这里报出了编译错误。

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

【C++】C++面向对象编程三大特性之一——多态_第4张图片

抽象类

        在虚函数的后面写上 =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();
}

 接口继承和实现继承

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

多态的原理

        首先我们来看看一道简单的题目:

// 我们来看看这里的sizeof(Base)是多少?
class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
private:
	int _b = 1;
};

        我们调试代码就可以发现这个类的一个对象的大小为八个字节,当我们调试并打开监视窗口监视Bace对象的情况,我们会发现除了_b成员,还多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。

虚表

        一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。那么派生类中这个表放了些什么呢?我们接着往下分析:

// 针对上面的代码我们做出以下改造
// 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;
}

        通过调试上述的代码并观察对象内容,我们可以发现下面的一些结论:

  1. 派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员,虚表指针就类似父类继承下来的成员,还有另一部分是自己的成员。
  2. 基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
  3. 另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但它不是虚函数,所以不会放进虚表。
  4. 总结一下派生类的虚表生成:a.先将基类中的虚表内容拷贝一份到派生类虚表中 b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数 c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。

【C++】C++面向对象编程三大特性之一——多态_第5张图片

多态的原理

        上面研究了这么多但是好像还是没有说明多态的原理,现在让我们重新研究一下之前的买票的场景:

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;
}

        我们调试上面的代码,并观察监视和汇编代码可以看到如下的现象:

  1. p是指向mike对象时,p->BuyTicket在mike的虚表中找到虚函数是Person::BuyTicket。
  2. p是指向johnson对象时,p->BuyTicket在johson的虚表中找到虚函数是Student::BuyTicket。
  3. 满足多态以后的函数调用,不是在编译时确定的,是运行起来以后到对象的中取找的。不满足多态的函数调用时编译时确认好的。

静态绑定和动态绑定

        上面我们发现多态调用函数并不是在编译时确定的,这种确定的方式被称为动态绑定。相应的,还有一种相反的绑定方式称为静态绑定,简单来说:

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

多继承关系的虚函数表

        如果使用了多继承,那么子类就会继承所有父类的虚表指针,并重写虚函数,除此之外,多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中。

        通过研究以下的类,我们可以得到上述结论:

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;
};

【C++】C++面向对象编程三大特性之一——多态_第6张图片

结语

        上面就是今天多态的相关知识啦,希望能对大家有用。

你可能感兴趣的:(【C++】21年精通C++,c++,开发语言,学习)