11. 多态(Polymorphism)
11.1 引言
使用多态性,可以设计和实现易于扩展的系统,只要新类是程序通常处理的继承层次结构的一部分,就可以添加新类,而无需对程序的常规部分进行修改。程序中唯一必须更改以适应新类的部分是那些需要直接了解添加到层次结构中的新类的部分。例如,如果我们创建继承自 Animal 类的 Tortoise 类(它可能通过爬行一英寸来响应移动消息),我们只需要编写 Tortoise 类和实例化 Tortoise 对象的模拟部分。处理每只动物的模拟部分通常可以保持不变。
11.2 多态初窥
假定我们需要设计一个游戏,此游戏需要操作多个不同类型的对象,包括类Martian,Venutian,Plutonian,SpaceShip和LaserBeam。设想上述的几个类都是从基类SpaceObject继承而来,此基类包括成员函数draw。每个派生类都以适合该类的方式实现此函数。一个屏幕管理程序包含一个容器(例如,vector),该容器包含指向各种类对象的 SpaceObject 指针。为了刷新屏幕,屏幕管理程序会周期性的向每个对象发送相同的信息---即draw函数。每个类类型的对象都会以独特的方式回应此函数。例如,Martin类的对象会用适当数量的触角将自己画成红色,SpaceShip对象可能会将自己画成一个银色的飞碟,LaserBeam对象可能会在屏幕上将自己绘制为明亮的红色光束。一条发送到不同类的对象的消息(此处即为draw函数)会导致不同的结果。
多态屏幕管理器有助于向系统添加新类,只需对其代码进行最少的修改。
多态性使您能够处理一般性问题,并让执行时环境关注细节。您可以指示各种对象以适合这些对象的方式运行,甚至不知道它们的类型,只要这些对象属于同一继承层次结构,并且正在通过公共基类指针或公共基类引用进行访问。
多态性促进了可扩展性:为调用多态行为而编写的软件是独立于消息发送到的对象的特定类型编写的。因此,可以在不修改基本系统的情况下将可以响应现有消息的新型对象合并到这样的系统中。只有实例化新对象的客户端代码才能适应新类型。
广义的多态概念:不同类型的实体/对象对于同一消息有不同的响应,这就是多态性。
11.2.1 联编
即确定具有多态性的语句调用哪个函数的过程
1.静态联编
在程序编译时确定调用哪个函数,例如函数重载
2.动态联编
在程序运行时,才能够确定调用哪个函数,例如用动态联编实现的多态,也称为运行时多态
11.3 继承关系中对象的关系
通过public继承,派生类的对象可以视为是基类的对象。
11.3.1 Invoking Base-Class Functions from Derived-Class Objects
//
// Created by 22364 on 2023/11/6.
//
#include <iostream>
#include <iomanip>
#include "CommissionEmployee.h"
#include "BasePlusCommissionEmployee.h"
using namespace std;
int main(){
// create base-class object
CommissionEmployee commissionEmployee{
"Sue", "Jones", "222-22-2222", 10000, .06};
// create derived-class object
BasePlusCommissionEmployee basePlusCommissionEmployee{
"Bob", "Lewis", "333-33-3333", 5000, .04, 300};
cout << fixed << setprecision(2); // set floating-point formatting
// output objects commissionEmployee and basePlusCommissionEmployee
cout << "DISPLAY BASE-CLASS AND DERIVED-CLASS OBJECTS:\n"
<<commissionEmployee.toString() // base-class toString
<<"\n\n"
<<basePlusCommissionEmployee.toString(); // derived-class toString
// natural: aim base-class pointer at base-class object
CommissionEmployee* commissionEmployeePtr{&commissionEmployee};
cout << "\n\nCALLING TOSTRING WITH BASE-CLASS POINTER TO "
<< "\nBASE-CLASS OBJECT INVOKES BASE-CLASS TOSTRING FUNCTION:\n"
<<commissionEmployeePtr->toString();// base version
// natural: aim derived-class pointer at derived-class object
BasePlusCommissionEmployee* basePlusCommissionEmployeePtr{
&basePlusCommissionEmployee};// natural
cout << "\n\nCALLING TOSTRING WITH DERIVED-CLASS POINTER TO "
<< "\nDERIVED-CLASS OBJECT INVOKES DERIVED-CLASS "
<< "TOSTRING FUNCTION:\n"
<<basePlusCommissionEmployeePtr->toString();// derived version
// aim base-class pointer at derived-class object
commissionEmployeePtr = &basePlusCommissionEmployee;
cout << "\n\nCALLING TOSTRING WITH BASE-CLASS POINTER TO "
<< "DERIVED-CLASS OBJECT\nINVOKES BASE-CLASS TOSTRING "
<< "FUNCTION ON THAT DERIVED-CLASS OBJECT:\n"
<<commissionEmployeePtr->toString() //base version
<<endl;
}
在上述代码最后的一段,将派生类对象basePlusCommissionEmployee的地址赋给基类的指针commissionEmployeePtr。虽然基类的指针此时指向派生类对象,但是在使用基类指针调用成员函数toString时,调用的是基类的成员函数toString。该程序中每个toString成员函数调用的输出显示,被调用的函数取决于指针所指的实际对象的类型,而不是指针的类型。
11.3.2 Aiming Derived-Class Pointers at Base-Class Objects
可以将派生类对象的地址赋给基类类型的指针,但是反过来不可以。
11.3.3 Derived-Class Member-Function Calls via Base-Class Pointers
使用基类指针,编译器允许我们只调用基类里的成员函数。因此,如果基类的指针指向一个派生类对象,试图访问派生类特有的成员函数,就会出现编译错误。例如下列代码所示:
//
// Created by 22364 on 2023/11/6.
//
#include <string>
#include "CommissionEmployee.h"
#include "BasePlusCommissionEmployee.h"
using namespace std;
int main(){
// create derived-class object
BasePlusCommissionEmployee basePlusCommissionEmployee{
"Bob", "Lewis", "333-33-3333", 5000, .04, 300};
// aim base-class pointer at derived-class object (allowed)
CommissionEmployee* commissionEmployeePtr{&basePlusCommissionEmployee};
// invoke base-class member functions on derived-class
// object through base-class pointer (allowed)
string firstName{commissionEmployeePtr->getFirstName()};
string lastName{commissionEmployeePtr->getLastName()};
string ssn{commissionEmployeePtr->getSocialSecurityNumber()};
double grossSales{commissionEmployeePtr->getGrossSales()};
double commissionRate{commissionEmployeePtr->getCommissionRate()};
// attempt to invoke derived-class-only member functions
// on derived-class object through base-class pointer (disallowed)
double baseSalary{commissionEmployeePtr->getBaseSalary()};
commissionEmployeePtr->setBaseSalary(500);
}
编译器会允许指向派生类对象的基类类型指针访问派生类特有的函数--前提是显式的将基类类型的指针cast到派生类类型的指针。这种操作叫做向下转型(downcasting)。向下转型允许只能由派生类对象完成的派生类特定操作由基类类型的指针完成。但是向下转型会存在潜在的危险。后续章节会介绍如何消除这种潜在的危险。
11.4 (虚函数及虚析构函数)Virtual Functions and Virtual Destructors
11.4.1 为何虚函数有用?
假定Circle\Triangle\Rectangle\square都是基类Shape的派生类。这些类中的每个类都可能被赋予通过成员函数绘制自身的能力,但每个形状的功能却截然不同。在一个绘制形状集合的程序中,将所有的形状笼统地视为基类Shape的对象是有用的。然后,对于任意形状的绘制,我们可以简单地使用基类Shape指针来调用绘图函数,让程序根据基类Shape指针在任意时刻所指向的对象的类型,动态地确定使用哪个派生类绘图函数的(也就是说,在运行时)。这是多态行为。
通过使用虚函数,对象的类型(而不是用来调用对象的成员函数的句柄的类型),决定了调用哪个版本的虚函数。
11.4.2 声明虚函数
为了实现上述的功能,我们在基类中将draw函数声明为虚函数,并且在每个派生类中重写draw函数以绘制相应的形状。从实现的角度看,重写一个函数无异于重新定义一个(也是一直沿用的做法)。派生类中的重写函数与基类中的重写函数具有相同的函数原型。如果我们没有将基类函数声明为虚函数,我们可以重新定义那个函数。如果我们将基类函数声明为虚函数,我们可以重写它以实现多态行为。虚函数的声明格式如下:
virtual 函数类型 函数名();
不需要形参列表。
虚函数的实现要在类外实现,不能在类内实现。
一旦一个函数被声明为虚拟,从那个点开始,它在继承层次结构中一直保持虚拟,即使当派生类重写该函数时,该函数没有被明确声明为虚拟。
尽管某些函数由于在类层次结构中的一个较高的声明而隐含地是虚拟的,但为了清晰起见,在类层次结构的每个层次上都显式地声明这些函数是虚拟的。
当派生类选择不重写其基类的虚拟函数时,派生类只需继承其基类的虚拟函数实现。
11.4.3 Invoking a virtual Function Through a Base-Class Pointer or Reference
如果一个程序通过基类类型的指向派生类对象的指针调用虚函数(即,shapePtr->draw())或者一个指向派生类对象的基类引用(即,shapeRef.draw()),程序会通过对象的类型动态的选择正确的派生类函数执行,而不是根据指针或者引用的类型。在执行时刻(而不是在编译时)选择合适的函数调用称为动态绑定。
11.4.4 通过对象的名字调用虚函数
当通过名称引用特定对象并使用点成员选择算子( 例如,squareObject.draw () )调用一个虚函数时,函数调用在编译时(这被称为静态绑定)得到解决,所调用的虚函数是为该特定对象的类(或被继承)定义的虚函数- -这不是多态行为。与虚函数的动态绑定只发生在指针和引用。
11.4.5 虚函数的注意事项
为了防止错误,将C++11的override关键字应用到每个派生类函数重写基类虚函数的原型中。这使得编译器能够检查基类是否具有具有相同签名的虚拟成员函数。如果不是,则编译器产生错误。这不仅可以确保你用合适的签名覆盖基类函数,还可以防止你意外地隐藏一个具有相同名字和不同签名的基类函数。
11.4.6 虚析构函数
使用多态性处理类层次结构的动态分配对象时,可能会出现问题。到目前为止,您已经看到了未使用关键字 virtual 声明的析构函数。如果通过将 delete 运算符应用于指向该对象的基类指针来销毁具有非虚拟析构函数的派生类对象,则 C++ 标准指定该行为未定义。构造函数不能是虚函数。
当我们delete一个动态分配的对象的指针时将执行析构函数。如果该指针指向继承体系中的某个类型,则有可能出现指针的静态类型与被删除对象的动态类型不符的情况。例如,如果delete一个Quote类型的指针,而指针指向了一个Bulk_quote类型的对象。如果这样的话编译器必须清楚它应该执行的是Bulk_quote的析构函数。
此问题的简单解决方案是在基类中创建一个公共虚拟析构函数。如果基类析构函数声明为虚拟析构函数,则任何派生类的析构函数也是虚拟的。例如,在类 CommissionEmployee 的定义中,我们可以按如下方式定义虚拟析构函数:
virtual ~CommissionEmployee() {};
现在,如果通过将 delete 运算符应用于基类指针来显式销毁层次结构中的对象,则会根据基类指针指向的对象调用相应类的析构函数。请记住,当派生类对象被销毁时,派生类对象的基类部分也会被销毁,因此派生类和基类的析构函数都必须执行。基类析构函数在派生类析构函数之后自动执行。从现在开始,我们将在每个包含虚拟函数并需要析构函数的类中包含一个虚拟析构函数。
如果类具有虚拟函数,则始终提供虚拟析构函数,即使该类不需要虚拟析构函数。这可确保在通过基类指针删除派生类对象时,将调用自定义派生类析构函数(如果有)。
构造函数不能是虚拟的。将构造函数声明为 virtual 是编译错误。
前面的析构函数定义也可以写成如下:
virtual ~CommissionEmployee() = default;
在 C++11 中,可以告诉编译器显式生成默认构造函数、复制构造函数、移动构造函数、复制赋值运算符、移动赋值运算符或析构函数的默认版本,方法是遵循特殊成员函数的原型 = default。例如,当您显式定义类的构造函数,并且仍希望编译器也生成默认构造函数时,这很有用,在这种情况下,请将以下声明添加到类定义中:
ClassName() = default;
11.4.7 override\final关键字
override的价值在于:避免程序员在覆写时错命名或无虚函数导致隐藏bug。
在 C++11 之前,派生类可以重写其任何基类的虚函数。在 C++11 中,在其原型中声明为 final 的基类虚函数,如:
virtual 函数名(参数列表) final;
不能在任何派生类中重写,这保证了基类的final成员函数定义将由所有基类对象以及基类的直接和间接派生类的所有对象使用。同样,在 C++11 之前,任何现有类都可以用作层次结构中的基类。从 C++11 开始,您可以将一个类声明为 final,以防止它被用作基类,如
class MyClass final { // this class cannot be a base class
// class body
};
尝试重写final成员函数或从final基类继承会导致编译错误。
11.5 Type Fields and switch Statements
决定对象类型的一种方式是使用switch语句检查对象中字段的值。这使我们能够区分对象类型,然后为特定对象调用适当的操作,类似于多态性。例如,在形状层次结构中,如果每个形状类(例如,Circle\Triangle\Rectangle\square)的对象都具有shapeType属性,switch语句就可以检查对象的shapeType决定调用哪个toString函数。
使用switch逻辑会使得程序面临各种潜在的问题。例如,您可能忘记在必要时包含类型测试,或者可能忘记在 switch 语句中测试所有可能的情况。通过添加新类型来修改基于 switch 的系统时,可能会忘记在所有相关的 switch 语句中插入新大小写。类的每次添加或删除都需要修改系统中每个关联的 switch 语句;跟踪这些更改可能非常耗时且容易出错。
多态编程可以消除对switch逻辑的需求。通过使用多态机制来执行等效逻辑,可以避免通常与switch逻辑相关的各种错误。
使用多态性的一个有趣的结果是程序呈现出简化的外观。它们包含较少的分支逻辑和更简单的顺序代码。
11.6 抽象类和纯虚函数
当我们将类看作一种类型时,我们假定程序会创造属于此种类型的对象。然而在某些情况下,定义从未打算从中实例化任何对象的类很有用。即,只定义类,并不为类实例化对象。这种类叫做抽象类(abstract classes)。因为这种类在继承层次结构中经常用作基类,也叫做抽象基类。可以被用来实例化对象的类叫做具体类(concrete classes)。
11.6.1 Pure virtual Functions(纯虚函数)
通过声明类的一个或多个虚拟函数是“纯”的,类是抽象的。纯虚函数是通过在其声明中放置“=0”来指定的,如:
virtual 函数类型 函数名(参数列表) = 0; // pure virtual function
“=0”是一个纯说明符。纯虚函数不提供实现。每个具体的派生类都必须用这些函数的具体实现重写所有基类纯虚函数;否则,派生类也是抽象的。
虚函数和纯虚函数的区别是:虚函数有实现,为派生类提供重写函数的选项;纯虚函数没有实现,要求派生类重写该派生类的函数,以便该派生类具体化;否则,派生类将保持抽象。回到我们前面的空间对象示例,基类 SpaceObject 具有函数 draw 的实现是没有意义的(因为如果没有关于正在绘制的空间对象类型的特定信息,就无法绘制通用空间对象)。 定义为虚拟函数(而不是纯虚拟函数)的函数示例是返回对象名称的函数。我们可以命名一个通用的Space Object (例如,"space object"),因此可以为该函数提供一个默认的实现,并且该函数不需要是纯虚的。但是,该函数仍然被声明为虚拟的,因为预期派生类将重写该函数,为派生类对象提供更具体的名称。
一个抽象类在一个类层次结构中为由它派生出来的各种类定义了一个公共接口。一个抽象类包含一个或多个具体派生类必须重写的纯虚函数。
在派生类中不能重写一个纯虚函数使得该类是抽象的。试图实例化抽象类的对象会导致编译错误。
一个抽象类至少有一个纯虚函数,一个抽象类也可以有数据成员和具体函数(concrete functions,包含构造函数和析构函数),它们受派生类继承的一般规则的约束。
虽然我们不能实例化抽象基类的对象,但是我们可以使用抽象基类来声明指针和引用,这些指针和引用可以指向从抽象类派生的任何具体类的对象。程序通常使用此类指针和引用对派生类对象进行多态操作。
11.6.2 Device Drivers: Polymorphism in Operating Systems
多态性对于实现分层的软件系统特别有效。例如,在操作系统中,每一种类型的物理设备都可能与其他类型的物理设备有很大的不同。即便如此,从设备和到设备分别读取数据或写入数据的命令可能具有一定的统一性。发送给设备驱动对象的写消息需要在该设备驱动的上下文中进行特定的解释,以及该设备驱动如何操作特定类型的设备。然而,写入调用本身与写入系统中的任何其他设备并无二致- -将一定数量的字节从内存放到该设备上。面向对象的操作系统可以使用一个抽象的基类来提供一个适合所有设备驱动程序的接口。然后,通过对该抽象基类的继承,形成所有操作类似的派生类。设备驱动程序提供的(即,公共职能)功能在抽象基类中作为纯虚函数提供。这些纯虚函数的实现都是在对应于特定类型设备驱动的派生类中提供的。这种架构还允许新的设备很容易地添加到系统中。用户只需在设备中插入并安装其新的设备驱动程序即可。操作系统通过其设备驱动程序与这个新设备"对话",它具有与所有其他设备驱动程序相同的公共成员功能- -设备驱动程序抽象基类中定义的那些。
11.7 RTTI(运行时类型识别)
运行时类型识别(runtime type information)(RTTI),
C++ 的 RTTI(Run-Time Type Information)是一种运行时类型信息机制,用于在程序运行时获取对象的类型信息。RTTI 主要包括两个关键字:typeid 和 dynamic_cast。
- typeid 运算符,用于返回表达式的类型。
- dynamic_cast 运算符,用于将基类的指针或引用安全地转换成派生类的指针或引用。
一般来说动态类型值得是某个基类指针或引用,指向一个派生类对象,且基类中有虚函数,此时会绑定对象的动态类型。
一般来说,只要有可能我们应该尽量使用虚函数。当操作被定义成虚函数时,编译器将根据对象的动态类型自动的选择正确的函数版本。然而,并非任何时候都能定义一个虚函数。假设我们无法使用虚函数,则可以使用一个RTTI运算符。
运算符dynamic_cast检查指针指向的对象的类型,然后确定该类型是否与指针正在转换的类型有is - a关系。如果是,则动态类型转换返回对象的地址。如果不是,则动态类型转换返回nullptr。
操作符typeid返回对type _ info对象的引用,该对象包含关于操作数类型的信息,包括类型名称。为了使用typeid,程序必须包含头文件<typeinfo>。
当被调用时,type _ info成员函数name返回一个基于指针的字符串,该字符串包含type _ info对象所表示类型的名称。
运算符dynamic_cast和typeid是C + +的运行时类型信息( RTTI )特征的一部分,它允许程序在运行时确定对象的类型。
11.7.1 动态类型转换
1.为何需要dynamic_cast?
void printObject(Shape& shape)// shape是派生类对象的引用
{
cout << "The area is "
<< shape.getArea() << endl;
// 如果shape是Circle对象,就输出半径
// 如果shape是Rectangle对象,就输出宽高
}
如果需要修改函数,让它显示圆的半径应该怎么办?
- Dynamic Casting Example
2.1. dynamic_cast 运算符
(1) 沿继承层级向上、向下及侧向转换到类的指针和引用
(2) 转指针:失败返回nullptr
(3) 转引用:失败抛异常
2.2. 例子
先将Shape对象用dynamic_cast转换为派生类Circle对象
然后调用派生类中独有的函数
// A function for displaying a Shape object
void printObject(Shape &shape)
{
cout << "The area is "
<< shape.getArea() << endl;
Shape *p = &shape;
Circle *c = dynamic_cast<Circle*>(p);
// Circle& c = dynamic_cast<Circle&>(shape);
// 引用转换失败则抛出一个异常 std::bad_cast
if (c != nullptr) // 转换失败则指针为空
{
cout << "The radius is "
<< p1->getRadius() << endl;
cout << "The diameter is "
<< p1->getDiameter() << endl;
}
}