第11章 多态
学习继承的基本知识、创建继承层次结构并明白公有继承实际上模拟的是 is-a 关系后,该学习面向对象编程的核心——多态,并应用这些知识了。
在本章中,您将学习:
• 多态意味着什么;
• 虚函数的用途和用法;
• 什么是抽象类及如何声明它们;
• 虚继承意味着什么以及在什么情况下使用。
11.1 多态基础
Poly源自希腊语,意思是“多”,而morph是“形态”的意思。多态(Polymorphism)是面向对象语言的一种特征,让您能够以类似的方式处理不同类型的对象。本章重点介绍多态行为,这种行为也被成为子类型多态(subtype polymorphism),在 C++中,可通过继承层次结构来实现。
在第10章,您发现Tuna和Carp从Fish那里继承了方法Swim(),如程序清单10.1所示。然而, Tuna和Carp都可提供自己的Swim()方法,以定制其游泳方式,但鉴于它们都是Fish,如果将Tuna实例作为实参传递给 Fish 参数,并通过该参数调用 Swim(),最终执行的将是 Fish::Swim(),而不是Tuna::Swim()。程序清单11.1演示了这种问题。
为最大限度地减少代码,以提高可读性,本章的所有代码示例都进行了简化,使其足以诠释相关主题即可。在您自己编程时,应根据应用程序的设计和用途正确地编写类,并创建合理的继承层次结构。
程序清单11.1 将Tuna实例传递给Fish参数,并通过该参数调用方法
输出:
分析:
Tuna类以公有方式继承了Fish类,如第12行所示,它还覆盖了方法Fish::Swim()。在main()中,第33行直接调用了Tuna::Swim(),并将myDinner(其类型为Tuna)作为参数传递给了MakeFishSwim(),而该函数将其视为 Fish 引用,如第 22 行的声明所示。换句话说,虽然传入的是 Tuna 对象, MakeFishSwim(Fish&)也将其视为 Fish,进而调用 Fish::Swim。第 2 行输出表明,虽然传入的是 Tuna对象,但得到的却是Fish的输出(这也适用于Carp对象)。
理想情况下,用户希望 Tuna 对象表现出金枪鱼的行为,即便通过 Fish 参数调用 Swim()时亦如此。换句话说,第25行调用InputFish.Swim()时,用户希望执行的是Tuna::Swim()。要实现这种多态行为——让 Fish 参数表现出其实际类型(派生类 Tuna)的行为,可将 Fish::Swim()声明为虚函数。
可通过Fish指针或Fish引用来访问Fish对象,这种指针或引用可指向Fish、Tuna或Carp对象,但您不知道也不关心它们指向的是哪种对象。要通过这种指针或引用调用方法Swim(),可以像下面这样做:
您希望通过这种指针或引用调用Swim()时,如果它们指向的是Tuna对象,则可像Tuna那样游泳,如果指向的是Carp对象,则可像Carp那样游泳,如果指向的是Fish,则可像Fish那样游泳。为此,可在基类Fish中将Swim()声明为虚函数:
通过使用关键字virtual,可确保编译器调用覆盖版本。也就是说,如果Swim()被声明为虚函数,则将参数myFish(其类型为Fish&)设置为一个Tuna对象时,myFish.Swim()将执行Tuna::Swim(),如程序清单11.2所示。
程序清单11.2 将Fish::Swim()声明为虚函数带来的影响
输出:
分析:
函数MakeFishSwim(Fish&)与程序清单11.1中完全相同,但输出截然不同。首先,根本没有调用Fish::Swim() ,因为存在覆盖版本 Tuna::Swim()和 Carp::Swim() ,它们优先于被声明为虚函数的Fish::Swim()。这很重要,它意味着在MakeFishSwim()中,可通过Fish&参数调用派生类定义的Swim(),而无需知道该参数指向的是哪种类型的对象。
这就是多态:将派生类对象视为基类对象,并执行派生类的Swim()实现。
程序清单11.1演示了一个问题,那就是将派生类对象传递给基类参数时,并通过该参数调用函数时,将执行基类的函数。然而,还存在一个问题:如果基类指针指向的是派生类对象,通过该指针调用运算符delete时,结果将如何呢?
将调用哪个析构函数呢?请看程序清单11.3。
程序清单11.3 在函数中通过基类指针调用运算符delete
输出:
分析:
在 main()中,第 37 行使用 new 在自由存储区中创建了一个 Tuna 实例;然后马上使用辅助函数DeleteFishMemory()释放分配的内存,如第39行所示。出于比较的目的,第42行在栈上创建了另一个Tuna实例——局部变量myDinner,在main()结束时,它将不再在作用域内。输出是由Fish和Tuna类的构造函数和析构函数中的cout语句生成的。注意到由于使用了关键字new,在自由存储区中构造了Tuna和Fish,但delete没有调用Tuna的析构函数,而只调用了Fish的析构函数;而构造和析构局部变量myDinner时,调用了基类和派生类的构造函数和析构函数,这形成了鲜明的对比。在第10章中,程序清单10.7演示了派生类对象的构造和析构过程;它表明,在析构过程中,需要调用所有相关的析构函数,包括~Tuna();显然是什么地方出了问题。
这个程序清单表明,对于使用new在自由存储区中实例化的派生类对象,如果将其赋给基类指针,并通过该指针调用delete,将不会调用派生类的析构函数。这可能导致资源未释放、内存泄露等问题,必须引起重视。
要避免这种问题,可将析构函数声明为虚函数,如程序清单11.4所示。
程序清单11.4 将析构函数声明为虚函数,确保通过基类指针调用delete时,将调用派生类的析构函数
输出:
分析:
相比于程序清单11.3,程序清单11.4唯一不同的地方是,在第10行声明基类Fish的析构函数时,添加了关键字virtual。这种修改导致将运算符delete用于Fish指针(如第31行所示)时,如果该指针指向的是Tuna对象,则编译器不仅会执行Fish::~Fish(),还会执行Tuna::~Tuna()。输出还表明,无论Tuna对象是使用new在自由存储区中实例化的(第37行),还是以局部变量的方式在栈中实例化的,构造函数和析构函数的调用顺序都相同。
务必像下面这样将基类的析构函数声明为虚函数:
这可避免将delete用于Base指针时,不会调用派生类的析构函数的情况发生。
对学习使用多态而言,并非必须掌握本节的内容。您可以跳过本节;如果想满足您的好奇心,也可阅读它。
在程序清单11.2的函数MakeFishSwim()中,虽然程序员通过Fish引用调用Swim(),但实际调用的却是方法Carp::Swim()或Tuna::Swim()。显然,在编译阶段,编译器并不知道将传递给该函数的是哪种对象,无法确保在不同的情况下执行不同的Swim()方法。该调用哪个Swim()方法显然是在运行阶段决定的,这是使用实现多态的不可见逻辑完成的,而这种逻辑是编译器在编译阶段提供的。
请看下面的Base类,它声明了N个虚函数:
下面的Derived类继承了Base类,并覆盖了除Base::Func2()外的其他所有虚函数:
编译器见到这种继承层次结构后,知道Base定义了一些虚函数,并在Derived中覆盖了它们。在这种情况下,编译器将为实现了虚函数的基类和覆盖了虚函数的派生类分别创建一个虚函数表(Virtual Function Table,VFT)。换句话说,Base和Derived类都将有自己的虚函数表。实例化这些类的对象时,将创建一个隐藏的指针(我们称之为VFT*),它指向相应的VFT。可将VFT视为一个包含函数指针的静态数组,其中每个指针都指向相应的虚函数,如图11.1所示。
图11.1 类Base和Derived的虚函数表
每个虚函数表都由函数指针组成,其中每个指针都指向相应虚函数的实现。在类Derived的虚函数表中,除一个函数指针外,其他所有函数指针都指向 Derived 本地的虚函数实现。Derived 没有覆盖Base::Func2(),因此相应的函数指针指向Base类的Func2()实现。
这意味着遇到下述代码时,编译器将查找Derived类的VFT,确保调用Base::Func2()的实现:
调用被覆盖的方法时,也将如此:
在这种情况下,虽然将objDerived传递给了objBase,进而被解读为一个Base实例,但该实例的VFT指针仍指向Derived类的虚函数表,因此通过该VTF执行的是Derived::Func1()。
虚函数表就是这样帮助实现C++多态的。
程序清单11.5将sizeof用于两个相同的类(一个包含虚函数,另一个不包含),并对结果进行比较,从而证明了确实存在隐藏的虚函数表指针。
程序清单 11.5 对两个相同的类(一个包含虚函数,另一个不包含)进行比较,证明确实存在隐藏的虚函数表指针
输出:
分析:
最大程度地简化了这个示例。其中有两个类——SimpleClass和Base,它们包含的成员数量和类型都相同,但在Base中,将FuncDoSomething()声明成了虚函数,而在SimpleClass中没有这样做。添加关键字virtual带来的影响是,编译器将为Base类生成一个虚函数表,并其虚函数表指针(一个隐藏成员)预留空间。在32位系统中,Base类占用的内存空间多了4字节,这证明确实存在这样的指针。
在C++中,可使用类型转换运算符dynamic_cast确定Base指针指向的是否是Derived对象,再根据结果执行额外的操作。
这被称为运行阶段类型识别(Run Time Type Identification,RTTI)。虽然大多数C++编译器都支持RTTI,但应尽可能避免这样做。因为需要知道基类指针指向的是派生类对象通常是一种糟糕的编程实践。
RTTI和dynamic_cast将在第13章讨论。
不能实例化的基类被称为抽象基类,这样的基类只有一个用途,那就是从它派生出其他类。在 C++中,要创建抽象基类,可声明纯虚函数。
以下述方式声明的虚函数被称为纯虚函数:
该声明告诉编译器,AbstractBase的派生类必须实现方法DoSomething():
AbstractBase类要求Derived类必须提供虚方法DoSomething()的实现。这让基类可指定派生类中方法的名称和特征(Signature),即指定派生类的接口。再次以Tuna类为例,假定它继承了Fish类,但没有覆盖Fish::Swim(),因此不能游得很快。这种实现存在缺陷。通过将Swim声明为纯虚函数,让Fish变成抽象基类,可确保从Fish派生而来的Tuna类实现Tuna::Swim(),从而像金枪鱼那样游动,如程序清单11.6所示。
程序清单11.6 Fish是Tuna和Carp的抽象基类
输出:
分析:
main()的第1行(第34行)被注释掉了,它意义重大。它表明,编译器不允许您创建Fish实例。编译器要求您创建具体类(如Tuna)的对象,这与现实世界一致。第7行声明了纯虚函数Fish::Swim(),这迫使Tuna和Carp必须分别实现Tuna::Swim()和Carp::Swim()。第27~30行实现了MakeFishSwim (Fish&),这表明虽然不能实例化抽象基类,但可将指针或引用的类型指定为抽象基类。抽象基类提供了一种非常好的机制,让您能够声明所有派生类都必须实现的函数。如果Trout类从Fish类派生而来,但没有实现Trout::Swim(),将无法通过编译。
抽象基类常被简称为ABC。
ABC有助于约束程序的设计。
11.2 使用虚继承解决菱形问题
第 10 章介绍过,鸭嘴兽具备哺乳动物、鸟类和爬行动物的特征,这意味着 Platypus 类需要继承Mammal、Bird和Reptile。然而,这些类都从同一个类——Animal派生而来,如图11.2所示。
图11.2 采用多继承的Platypus类的类图
实例化Platypus时,结果将如何呢?对于每个Platypus实例,将实例化多少个Animal实例呢?程序清单11.7回答了这个问题。
程序清单11.7 每个Platypus实例包含多少个基类Animal的实例
输出:
分析:
输出表明,由于采用了多继承,且 Platypus 的全部三个基类都是从 Animal 类派生而来的,因此第38行创建Platypus实例时,自动创建了三个Animal实例。这太可笑了,因为鸭嘴兽是一种动物,继承了哺乳动物、鸟类和爬行动物的属性。存在多个Animal实例带来的问题并非仅限于会占用更多内存。Animal 有一个整型成员——Animal::Age,为方便说明问题,将其声明成了公有的。如果您试图通过Platypus 实例访问 Animal::Age(如第 42 行所示),将导致编译错误,因为编译器不知道您要设置Mammal::Animal::Age、Bird::Animal::Age还是Reptile::Animal::Age。更可笑的是,如果您愿意,可以分别设置这三个属性:
显然,鸭嘴兽应该只有一个 Age 属性,但您希望 Platypus 类以公有方式继承 Mammal、Bird 和Reptile。解决方案是使用虚继承。如果派生类可能被用作基类,派生它是最好使用关键字virtual:
程序清单11.8列出了更佳的Platypus类声明(实际上是更佳的Mammal、Bird和Reptile类声明)。
程序清单11.8 在继承层次结构中使用关键字virtual,将基类Animal的实例个数限定为1
输出:
分析:
如果将这里的输出与程序清单11.7的输出进行比较,将发现构造的Animal实例数减少到了1个,这表明只构造了一个Platypus。这是因为从Animal类派生Mammal、Bird和Reptile类时,使用了关键字virtual,这样Platypus继承这些类时,每个Platypus实例只包含一个Animal实例。这解决了很多问题,其中之一是第41行能够通过编译,不再像程序清单11.7中那样存在二义性。
在继承层次结构中,继承多个从同一个类派生而来的基类时,如果这些基类没有采用虚继承,将导致二义性。这种二义性被称为菱形问题(Diamond Problem)。
其中的“菱形”可能源自类图的形状(如果使用直线和斜线表示Platypus经由Mammal、Bird和Reptile与Animal建立的关系,将形成一个菱形)。
C++关键字virtual的含义随上下文而异(我想这样做的目的很可能是为了省事),对其含义总结如下:
在函数声明中,virtual意味着当基类指针指向派生对象时,通过它可调用派生类的相应函数。从Base类派生出Derived1和Derived2类时,如果使用了关键字virtual,则意味着再从Derived1和Derived2派生出Derived3时,每个Derived3实例只包含一个Base实例。也就是说,关键字virtual被用于实现两个不同的概念。
11.3 可将复制构造函数声明为虚函数吗
这个节标题是个疑问句,这是有道理的。从技术上说,C++不支持虚复制构造函数。但如果能实现虚复制构造函数,则创建一个基类指针集合(如静态数组,其中的每个元素指向不同的派生类对象):
并将其赋给另一个相同类型的数组时,则虽然是通过Fish指针调用的复制构造函数,但将复制指向的派生类对象,并对其进行深复制。
然而,这只是一种美好的梦想。
根本不可能实现虚复制构造函数,因为在基类方法声明中使用关键字virtual时,表示它将被派生类的实现覆盖,这种多态行为是在运行阶段实现的。而构造函数只能创建固定类型的对象,不具备多态性,因此C++不允许使用虚复制构造函数。
虽然如此,存在一种不错的解决方案,就是定义自己的克隆函数来实现上述目的:
虚函数Clone模拟了虚复制构造函数,但需要显式地调用,如程序清单11.9所示。
程序清单11.9 Tuna和Carp包含Clone函数,它们模拟了虚复制构造函数
输出:
分析:
在main()中,第 40~44行声明了一个静态基类指针(Fish )数组,并各个元素分别设置为新创建的Tuna、Carp、Tuna和Carp对象。注意到myFishes数组能够存储不同类型的对象,这些对象都是从Fish派生而来的。这太酷了,因为本书前面的大部分数组包含的都是相同类型的数据,如int。如果这还不够酷,您还可以在循环中使用虚函数Fish::Clone将其复制到另一个Fish数组(myNewFishes)中,如第48行所示。注意到这里的数组很小,只有4个元素,但即便数组长得多,复制逻辑也差别不大,只需调整循环结束条件即可。第 52 行进行了核实,它通过新数组的每个元素调用虚函数 Swim(),以验证Clone()复制了整个派生类对象,而不仅仅是Fish部分。输出表明,确实像预期的那样复制了整个派生类对象。
11.4 总结
在本章中,您学习了如何使用多态,以充分发挥 C++继承的威力。您学习了如何声明和编写虚函数。通过基类指针或引用调用虚方法时,如果它指向的是派生类对象,将调用派生类的方法实现。纯虚函数是一种特殊的虚函数,确保基类不能被实例化,让这种基类非常适合用于定义派生类必须实现的接口。最后,您学习了多继承导致的菱形问题以及如何使用虚继承解决这种问题。
11.5 问与答
问:为何在基类函数声明中使用关键字virtual(因为即便不使用该关键字,代码也能通过编译)?
答:如果不使用关键字virtual,就不能确保objBase.Function()执行Derived::Function()。另外,代码够通过编译并不意味着其质量上乘。
问:编译器为何创建虚函数表?
答:用于存储函数指针,确保调用正确的虚函数版本。
问:基类总应包含一个虚析构函数吗?
答:最好如此。如果编写了如下代码:
则仅当析构函数~Base()被声明为虚函数时,delete pBase才会调用析构函数~Derived()。
问:抽象基类(ABC)都不能被实例化,它有何用途呢?
答:ABC并非为实例化而创建的,而仅充当基类。它包含纯虚函数,指定了派生类必须实现哪些函数,可充当接口。
问:在继承层次结构中,需要在所有虚函数声明中都使用关键字 virtual,还是只需在基类中这样做?
答:只需在基类的虚函数声明中使用关键字virtual即可。
问:在ABC中,可定义成员函数和成员属性吗?
答:当然可以。这样的ABC也不能被实例化,因为它至少包含一个纯虚函数,派生类必须实现该函数。
11.6 作业
作业包括测验和练习,前者帮助读者加深对所学知识的理解,后者提供了使用新学知识的机会。请尽量先完成测验和练习题,然后再对照附录 D 的答案。在继续学习下一章前,请务必弄懂这些答案。
1.假设您要模拟形状——圆和三角形,并要求每个形成都必须实现函数Area()和Print()。您该如何办?
2.编译器为每个类都创建虚函数表吗?
3.我编写了一个Fish类,它有两个公有方法、一个纯虚函数和几个成员属性。这个类是抽象基类吗?
1.创建一个继承层次结构,实现问题1中的Circle和Triangle类。
2.差错:下面的代码有何问题?
3.给定练习2所示的(错误)代码,像下面这样创建并销毁Car实例时,将按什么样的顺序执行构造函数和析构函数?