多态概述

多态是指当相同的消息被不同类型的对象接收时,表现出不同的行为。消息指的是对类成员函数的调用,而不同的行为指的是不同的实现,即调用不同的函数

最简单的例子是“+”运算符,它可以在整数、浮点数和双精度浮点数之间实现加法,包括混合类型加法。当不同类型的对象接收到相同的”+“消息时,使用不同的方法进行加法操作。这就是多态。

多态的类型

面向对象的多态可以分为四类:

  • 重载多态
  • 强制转换多态
  • 包含多态
  • 参数化多态

前两者称为特定多态,后两者称为普遍多态

  1. 重载多态指的是普通函数和类成员函数的重载,当然也包括运算符重载
  2. 强制转换多态,简单来说,就是当+涉及混合类型时,会进行类型强制转换,这是强制转换多态的一个实例。
  3. 包含多态主要指的是在类层次结构中定义在不同类中同名的成员函数的多态行为,通过虚函数实现。
  4. 参数化多态与类模板相关,使用时必须给出实际类型以便实例化。

本文将介绍运算符重载和虚函数。

多态的实现

从实现的角度来看,多态可以分为两类:编译时多态和运行时多态。前者顾名思义,在编译期间确定同名操作的具体操作对象。这个确定操作具体对象的过程称为绑定

绑定是指将计算机程序彼此关联的过程,即将标识符与存储地址连接起来的过程。在面向对象的术语中,它是将消息与对象的方法结合的过程。

绑定也分为两种类型:静态绑定动态绑定

当绑定在编译和链接阶段完成时,称为静态绑定。也称为早期绑定。

与静态绑定相对,动态绑定,顾名思义,是在程序执行期间完成的绑定。也称为晚期绑定。它对应于运行时多态。

运算符重载

运算符重载是为现有运算符赋予多重含义,相同的运算符在作用于不同类型的数据时产生不同的行为。

运算符重载的规则

  1. 在C++中,除了少数运算符外,所有运算符都可以被重载,且只能重载现有运算符
  2. 重载后,运算符的优先级和结合性不会改变。
  3. 运算符应根据新类型数据的实际需求进行适当修改。重载的功能应与原始功能相似,不能改变操作数的数量,且至少一个必须是自定义类型(否则不称为重载)。

几个不能重载的运算符:类成员运算符“.”,成员函数指针运算符”.*“,作用域解析运算符”::“,和三元运算符”?:”


运算符重载有两种形式:作为类的非静态成员函数的重载作为非成员函数的重载

作为类的非静态成员函数和非成员函数的重载的一般形式:

1
2
3
4
返回类型 operator 运算符(形参表)
{
函数体
}

当作为非成员函数重载时,有时需要访问类中的成员,此时可以声明为友元函数。

注意

当运算符作为类的成员函数重载时,函数参数的数量比原始操作数少一个(后缀”++“和”–“除外);当作为非成员函数重载时,参数的数量等于原始操作数的数量。原因在于,当作为类的成员函数重载时,第一个操作数成为函数调用的目标对象,因此不需要出现在参数列表中,函数体可以直接访问第一个操作数的成员。当作为非成员函数重载时,运算符的所有操作数必须通过参数显式传递。

作为成员函数的运算符重载

主要是单目运算符和双目运算符之间的区别。

对于双目运算符,前面的类的数据类型应为该类的成员函数,后面的类的数据类型应放在形式参数中。

单目运算符分为两种类型:前缀单目运算符后缀单目运算符。(++, –是单目运算符,名称根据位置不同而异)

  • 对于前缀单目运算符,重载的成员函数没有形式参数;

  • 对于后缀单目运算符,函数必须有一个int形式参数。这个int参数在操作中没有实际用途,仅用于区分前缀和后缀。

作为非成员函数的运算符重载

对于双目运算符,前后两个数据类型中,只需一个是自定义数据类型即可启用运算符重载,两个数据类型都需要作为函数形式参数。

对于前缀单目运算符,形式参数是被操作的数据类型。

对于后缀单目运算符,有两个形式参数:一个是被操作的数据类型,另一个是int数据类型。

不难看出,成员函数和非成员函数之间的主要区别在于,成员函数隐式地将前一个操作数视为函数调用的对象,而非成员函数则不这样做。


本节使用一个Complex(复数类)来说明

点击查看问题图片(CUG实验问题)

GitHub上的源代码

虚函数

虚函数是动态绑定的基础。虚函数必须是非静态成员函数。通过派生虚函数,可以在类层次结构中实现运行时的多态。

一般虚函数成员

声明语法

1
virtual 函数类型 函数名(形参表);

虚函数声明只能出现在类定义中的函数原型声明中,而不能出现在成员函数实现中。

运行时多态所需的条件:

  1. 赋值兼容性规则
  2. 声明虚函数
  3. 通过成员函数调用或通过指针或引用访问虚函数

如果派生类没有显式声明虚函数,系统将遵循以下规则来确定派生类中的函数成员是否为虚函数:

  1. 函数是否与基类的虚函数同名。
  2. 函数的参数数量和对应的参数类型是否与基类的虚函数相同。
  3. 函数的返回类型是否与基类的虚函数相同,或满足指针和引用类型返回值的赋值兼容性规则。

如果满足上述条件,派生类中的虚函数将覆盖基类中同名的所有函数,这就是作用域隐藏。当然,也可以通过作用域解析运算符”::“进行区分。

需要强调的是,动态绑定仅在通过基类指针或引用调用虚函数时发生。

实际上,是否在派生类的虚函数前添加虚关键字并不重要,但建议添加以使其更清晰地表明它们是虚函数。

对象切片是指使用派生类的实例初始化基类对象,这会调用基类的拷贝构造函数。即,只有与基类相同的数据成员会被复制,其余将被忽略。此时,这个基类对象与派生类对象没有关系,这与类型兼容性规则非常一致。

虚析构函数

虚构造函数不能声明,但虚析构函数可以声明。语法是在普通析构函数前添加虚关键字。

那么,什么时候需要使用虚析构函数呢?

当基类指针指向派生类对象时,如果此时执行delete(基类指针),将调用基类析构函数而不是派生类析构函数,导致内存泄漏。(我实际上认为这种情况几乎不会遇到,仅需了解即可)与普通成员函数的多态差别不大。

纯虚函数和抽象类

纯虚函数

纯虚函数是基类中声明但没有定义的虚函数,要求任何派生类必须定义自己的实现。在基类中实现纯虚函数的方法是在函数原型后添加”=0”,例如:

1
virtual void func() = 0

我最大的疑问是为什么要引入纯虚函数?

  1. 为了方便使用多态特性,我们通常需要在基类中定义虚函数。
  2. 在许多情况下,基类本身生成对象是不合理的。

例如,动物作为基类可以派生出老虎和孔雀等子类,但动物本身生成对象显然是不合理的。

为了解决上述问题,引入了纯虚函数的概念。当一个函数被定义为纯虚函数时,编译器要求它必须在派生类中被重写以实现多态。包含纯虚函数的类称为抽象类,不能生成对象。这很好地解决了上述两个问题。声明纯虚函数的类是抽象类。因此,用户不能创建抽象类的实例,只能创建其派生类的实例(实现了基类中纯虚函数定义的类)。纯虚函数最显著的特征是:它们必须在继承类中重新声明(末尾没有=0,否则派生类无法实例化),并且通常在抽象类中没有定义。定义纯虚函数的目的是使派生类仅继承函数接口。纯虚函数的意义在于允许所有类对象(主要是派生类对象)执行纯虚函数的操作,但该类无法为纯虚函数提供合理的默认实现。因此,在类中声明纯虚函数是在告诉子类设计者:“你必须提供纯虚函数的实现,但我不知道你将如何实现它。”

抽象类

非常简单,具有纯虚函数的类是抽象类,不能被实例化

如果你想了解更多,请查看博客

程序示例 - 变步长梯形积分算法求解函数定积分

查看GitHub

综合示例 - 个人银行账户管理程序的改进

查看GitHub