前言
今天,我们继续踏入追寻C++的冒险历程。上一章我们简单介绍并且了解了C++三大特性的继承。那么本章将为大家讲解C++三大特性最后的多态。下面让我们一起来进入多态的学习。
1. 多态的概念
我们首先从字面意思上去理解一下多态,**多态(polymorphism)**通俗来说,就是多种形态。这里我们的多态分为两种:
- 静态多态(编译时多态):这里指的主要就是我们之前所讲过的函数重载和函数模板,通过传入不同的参数就可以调用不同的函数,通过参数不同达到多种形态。之所以叫编译时多态,是因为它们实参传给形参进行参数匹配是在编译阶段完成的。(一般将编译时称为静态,运行时称为动态)
- 动态多态(运行时多态):这里具体来说是去完成某个行为(函数)时,通过传入不同的对象去完成不同的行为,从而达到多种形态。这里的多态是建立在继承的基础上才有的。
当在面向对象程序设计中谈论多态时,多态指的都是动态多态,而对于静态多态我们则是称为泛型编程。因此我们下面所谈论的都是动态多态(静态多态也就是泛型编程可以看之前的章节)。
那么,多态究竟什么呢?这里给出一个简单的概念:
多态是一个继承关系下的类对象,去调用同一个函数,产生不同的行为。
例如动物发出声音的一个行为(函数),传猫的对象过去就是“喵喵”,传狗对象过去就是“汪汪”。需要注意的是,我们这里的大前提是它们处于同一个继承关系下,下文中提到的多态都默认在此前提下(可以是一个类继承一个类,也可以是好几个类继承一个类,如下图)。
同时,在继承章节我们讲过对于继承父子类关系下同名函数会构成隐藏,那它们还能构成多态吗?这些问题通过接下来的学习,会为大家详细解释。
2. 多态的实现
实现多态我们还必须要满足两个条件:
- 被调⽤的函数必须是虚函数,并且完成了虚函数重写(也叫做覆盖)。
- 必须是基类的指针或者引⽤调⽤虚函数。
对于上面的两个条件,我们一个一个来看。首先第一个问题,什么是虚函数,这里虚函数的概念是:在类成员函数前面加上virtual关键字,那么这个函数被称为虚函数。这里我们的virtuai关键字与上一章继承中虚继承的virtual关键字相同,不过它们之间并没有什么联系,只是一个关键字两个不同的用处。
class Animal
{
public:virtual void speak() { cout << "声音:" << endl;}
}
注意:非成员函数不能用virtual修饰,例如静态成员函数。
那么第二个问题,什么是虚函数重写呢?在我看来,虚函数重写是整个多态中最重要的部分,因为只有重写了虚函数,基类和派生类才能对同一件事有不同的行为,多态的不同形态效果才能达到。
我们先来一看虚函数是如何重写的,如下面代码所示:
class Animal
{
public:virtual void speak(){cout << "声音" << endl;}
};class Dog : public Animal
{
public:virtual void speak(){cout << "汪汪" << endl;}
};class Cat : public Animal
{
public: virtual void speak(){cout << "喵喵" << endl;}
};
所谓虚函数的重写,就是派生类中有一个跟基类中完全相同的虚函数,这里的完全相同指的是返回值类型相同、函数名字相同、参数列表相同。这样称派生类的虚函数重写了基类的虚函数。那么重写和隐藏有什么关系呢,我们可以将它们看作两个集合,那么重写是属于隐藏的,这是因为重写的条件更加苛刻。
接下来第三个问题,为什么必须是基类的指针或者引⽤调⽤虚函数?这是因为只有基类的指针或者引用才能既指向基类对象又指向派生类对象,这也就是上一章节中讲到的切片。对于存在继承关系的类,派生类的指针可以赋值给基类的指针,这时这个指针指向的是这个派生类中基类的那一部分。
因此,实现多态的几个点都已经简单介绍了,总结一下就是:虚函数,虚函数重写,基类的指针或者引用调用虚函数。
3. 虚函数的重写
接下来我们详细介绍一下虚函数的重写。
3.1 重写
在重写基类虚函数时,派⽣类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派⽣类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使⽤,
class Person
{
public:virtual void shenfen() {cout << "人" << endl;}
};calss Student : public Person
{
public:void shenfen() {cout << "学生" << endl;}
};
在上面的代码中,虽然派生类中的shenfen
函数前没有加virtual关键字,但还是构成重写的(但是基类中的virtual不能省略)。通过接下来的代码,我们来体会一下多态:
class Animal
{
public:virtual void speak() const {cout << "声音" << endl;}
};class Dog : public Animal
{
public:virtual void speak() const {cout << "汪汪" << endl;}
};class Cat : public Animal
{
public: virtual void speak() const {cout << "喵喵" << endl;}
};void letsHear(const Animal& quote, const Animal* ptr)
{// 这⾥可以看到虽然都是Animal指针和Animal引用在调⽤speak()// 但是跟指针和引用的类型没关系,⽽是由指针和引用指向的对象决定的。quote.speak();ptr->speak();
}int main()
{Animal ani;Cat cat;Dog dog;letsHear(ani, &ani);letsHear(cat, &cat);letsHear(dog, &dog);return 0;
}
从上面的代码中我们可以看出,尽管我们只能用基类的指针去调用虚函数,但是每个对象会调用对应的函数。那么这是如何实现的呢?这也就涉及到多态的原理,下面会进行讲解。
关于虚函数的重写还有一些其他的问题,让我们来看一看:
3.2 协变
派⽣类重写基类虚函数时,与基类虚函数返回值类型不同并不是一定无法构成重写。当基类虚函数返回基类对象的指针或者引⽤并且派⽣类虚函数返回派⽣类对象的指针或者引⽤时(这里返回的类可以是本身也可以是其他存在继承关系的父子类),也能构成重写,这种情况称为协变。协变的实际意义并不⼤,所以我们了解⼀下即可。
3.3 析构函数的重写
当基类的析构函数为虚函数时,此时派⽣类析构函数只要定义,⽆论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派⽣类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统⼀处理成destructor
,所以只要基类的析构函数加了virltual修饰,派⽣类的析构函数就构成重写。
class A
{
public:virtual ~A(){cout << "~A()" << endl;}
};
class B : public A
{
public:~B(){cout << "~B()->delete:"<<_p<< endl;delete _p;}
protected:int* _p = new int[10];
};
// 只有派⽣类Student的析构函数重写了Person的析构函数,下⾯的delete对象调⽤析构函数,才能
// 构成多态,才能保证p1和p2指向的对象正确的调⽤析构函数。
int main()
{A* p1 = new A;A* p2 = new B;delete p1;delete p2;return 0;
}
上⾯的代码我们可以看到,如果A的析构函数不加virtual,那么delete p2
时只调⽤的A的析构函数,没有调⽤B的析构函数,当B中有指向的资源时,就会导致内存泄漏问题。当我们用delete去释放自定义类型的资源时,实际上是先调用其自己的析构函数,然后再调用operator delete(由free实现,可以暂且理解为free)
,因此,当我们将基类的析构函数定义为虚函数时,就算我们把派生类对象的指针赋值给基类指针,对这个基类指针进行释放时,就不会有内存泄漏的问题了。
最后的最后关于虚函数的重写还有一个小点,对于派生类中重写基类的虚函数,我们可以理解为是将基类的虚函数中除了函数体中的内容剩下的都覆盖到派生类的虚函数上,也就是说,当我们在派生类中重写一个基类的虚函数时,若它们其中的一个参数有不同的缺省值,那么在派生类的虚函数上并不会使用它自己的缺省值,而是使用基类虚函数上的缺省值。
3.4 override和final关键字
从上⾯可以看出,C++对虚函数重写的要求⽐较严格,但是有些情况下由于疏忽,⽐如函数名写错参数写错等导致⽆法构成重写,⽽这种错误在编译期间是不会报出的,只有在程序运⾏时没有得到预期结果才来debug会得不偿失,因此C++11提供了override
关键字,可以帮助⽤⼾检测是否成功的进行重写。如果我们不想让派⽣类重写这个虚函数,那么可以⽤final
关键字去修饰。
override:(加在派生类中需要重写的虚函数后面)
// error C3668: “Benz::Drive”: 包含重写说明符“override”的⽅法没有重写任何基类⽅法
class Car {
public:virtual void Drive(){}
};
class Benz : public Car
{
public:// 函数名写错virtual void Dirve() override { cout << "Benz-舒适" << endl; }
};
int main()
{return 0;
}
final:()
// error C3248: “Car::Drive”: 声明为“final”的函数⽆法被“Benz::Drive”重写
class Car
{
public:virtual void Drive() final {}
};
class Benz :public Car
{
public://被final修饰的虚函数不能进行重写virtual void Drive() { cout << "Benz-舒适" << endl; }
};
int main()
{return 0;
}
3.5 重载、重写和隐藏的对比(重点)
重载、重写和隐藏都有许多相似的地方,那么该怎么去区分呢,让我们来看看它们各自的定义,一定要牢记:
重载:
- 构成重载的函数要在同一作用域
- 构成重载的函数需要满足函数名相同,参数不同(类型或者个数不同),返回值无要求
重写:
- 两个函数都必须是虚函数
- 两个函数分别在继承体系的基类和派生类中,不能在同一作用域
- 两个函数都必须满足函数名、参数、返回值都相同
隐藏:
- 两个函数分别在继承体系的基类和派生类中,不能在同一作用域
- 函数名相同(只要不构成重写,那就构成隐藏)
- 父子类的成员变量相同也叫隐藏
5.纯虚函数和抽象类
在虚函数的后⾯写上 = 0
,则这个函数为纯虚函数,纯虚函数不需要定义实现(实现没啥意义因为要被派⽣类重写,但是语法上可以实现),只要声明即可。包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派⽣类继承后不重写纯虚函数,那么派⽣类也是抽象类。纯虚函数某种程度上强制了派⽣类重写虚函数,因为不重写实例化不出对象。
//抽象类
class Animal
{
public://纯虚函数virtual void speak() = 0;
};class Dog : public Animal
{
public:virtual void speak() const {cout << "汪汪" << endl;}
};class Cat : public Animal
{
public: virtual void speak() const {cout << "喵喵" << endl;}
};int main()
{// 编译报错:error C2259: “Car”: ⽆法实例化抽象类// Car car;Car* pBenz = new Benz; pBenz->Drive();Car* pBMW = new BMW;pBMW->Drive();return 0;
}
抽象类可以用在无法抽象出实体的类上,例如我们上述代码中的Animal类中,动物的声音有多种多样,但是若没有具体的动物对象,就不知道它能发出什么声音,只有在具体的动物去继承了Animal类后,才会有具体的声音发出(重写虚函数)。
6. 多态的原理
首先我们先看一下下面代码的输出是什么:
class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}
protected:int _b = 1;char _ch = 'x';
};int main()
{Base b;cout << sizeof(b) << endl;return 0;
}
上面的函数是计算一个类的大小,正常来说,这个类中有一个int类型的对象和一个char类型的对象,根据内存对齐规则,我们可以计算出它的大小应该为8个字节。但是事实是这样的吗?让我们运行代码看看:
我们可以看到编译器运行的结果是16,这是为什么呢?当我们打开编译器的监视窗口时,我们会发现b中多了一个指针类型的成员变量__pfptr
,在64位平台下指针的大小为8个字节,因此这个类的大小为16。
那么这个指针是什么呢?其实这与虚函数有关,当我们的类中有虚函数时,对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。⼀个含有虚函数的类中都⾄少都有⼀个虚函数表指针,因为⼀个类所有虚函数的地址都要被放到这个类对象的虚函数表中,虚函数表也简称虚表。因此这个虚函数表就是一个函数指针数组。
回到我们上面实现多态的代码中,从底层的⻆度letsHear
函数中ptr->speak();
,是如何作到ptr指向Animal对象调⽤Animal::speak
,ptr指向Dog对象调⽤Dog::speak
, ptr指向Cat对象调⽤Cat::speak
的呢?通过下图我们可以看到,满⾜多态条件后,底层不再是编译时通过调⽤对象确定函数的地址,⽽是运⾏时到指向的对象的虚函数表中确定对应的虚函数的地址,这样就实现了指针或引⽤指向基类就调⽤基类的虚函数,指向派⽣类就调⽤派⽣类对应的虚函数。
第⼀张图,ptr指向的Animal对象,调⽤的是Animal的虚函数;第⼆张图,ptr指向的Dog对象,调⽤的是Dog的虚函数;第三张图,ptr指向的Cat对象,调⽤的是Cat的虚函数。这些调用靠的就是虚函数表,接下来让我们了解一下虚函数表。
7. 虚函数表
从上图中我们可以看到,在每一个对象中都有一个虚函数表,那它们是同一个虚函数表吗?基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共⽤同⼀张虚表,不同类型的对象各⾃有独⽴的虚表,所以基类和派⽣类有各⾃独⽴的虚表。
派⽣类由两部分构成,继承下来的基类和⾃⼰的成员,⼀般情况下,继承下来的基类中有虚函数表指针,⾃⼰就不会再⽣成虚函数表指针。但是要注意的这⾥继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同⼀个,就像基类对象的成员和派⽣类对象中的基类对象成员也是独⽴的。
如果派⽣类中重写的基类的虚函数,那么派⽣类的虚函数表中对应的虚函数的地址就会被覆盖成派⽣类重写的虚函数地址。派⽣类的虚函数表中包含三个部分,分别是:
- 基类的虚函数地址
- 派⽣类重写的虚函数地址
- 派⽣类⾃⼰的虚函数地址。
那么虚函数是存储在哪的呢?虚函数与普通函数是一样的,编译完成后是一段指令,存放在代码段,只是虚函数的地址又被存放到虚函数表中,那么虚函数表是存在哪里的呢?这个在C++的标准中并没有明确规定,虚函数表的本质是一个存储虚函数地址指针的指针数组,是不会改变的,因此在vs下它是存储在代码段也就是常量区中。
8.动态绑定与静态绑定
- 对不满⾜多态条件(指针或者引⽤+调⽤虚函数)的函数调⽤是在编译时绑定,也就是编译时确定调⽤函数的地址,叫做静态绑定。
- 满⾜多态条件的函数调⽤是在运⾏时绑定,也就是在运⾏时到指向对象的虚函数表中找到调⽤函数的地址,也就做动态绑定。
尾声
若有纰漏或不足之处欢迎大家在评论区留言或者私信,同时也欢迎各位一起探讨学习。感谢您的观看!