humid1ch blogs

本篇文章

手机用户建议
PC模式 或 横屏
阅读


C++ 2022 年 7 月 26 日

[C++] 多态的使用分析: 多态使用相关问题、协变、析构函数的多态、final与override关键词、抽象类分析...

C++继承里面有一些坑是为C++的多态服务的。比如, 继承类的析构函数构成隐藏, 为什么?有什么意义?等
上一篇文章详细介绍了C++的继承, 继承是面向对象的三大特性之一。但是C++继承里面有一些坑是为C++的多态服务的。比如, 继承类的析构函数构成隐藏, 为什么?有什么意义?为什么父类对象不能直接复制给子类对象, 而父类指针和父类对象可以赋值给子类指针和子类引用?这些问题 都可以在多态中解答
多态我打算用两篇文章来介绍, 一篇 只涉及使用, 而另一篇则更深入的去了解一些原理
本篇文章是C++ 多态介绍的第一篇文章, 也是涉及 C++多态使用 的一篇
下面就来介绍一下多态:

什么是多态?

在生活中你一定遇到过, 同一件事但是面对不同的对象却有着不同执行策略, 比如 出去旅游去景区买票时, 不同类型的人, 票价一般是不同的: 普通人一般是全价, 而学生一般是半价, 不足1.2米的儿童、60/70岁以上老人免票 或者 军人优先购票、半价等等
这就是 同一件事情, 但是对不同的对象有着不同的执行策略。这就是生活中的多态
C++ 也可以通过多态来实现类似的场景, 可能表现为: 不同的对象调用同一个函数, 但是执行的结果不同
下面 就用C++ 多态来模拟实现一下, 普通成年人、学生、军人、老人关于景区买票而可能会出现的多态场景

多态的使用

要实现多态调用, 需要满足两个条件:
  1. 必须是父类的指针父类的引用 来调用 虚函数
  2. 被调用的函数必须是 虚函数, 并且 此 虚函数 必须被 重写
这两个条件提及了两个名词 : 虚函数重写
先介绍一下 虚函数 在上一篇文章中 接触了一个C++中的关键字 virtual , 用于 解决菱形继承的数据冗余和二义性的问题, 将菱形继承改为 菱形虚拟继承 不仅是虚拟继承, 虚函数 也是使用 关键字virtual 定义的
在上图的继承体系中, 可以看到 四个类都存在一个函数名相同的成员函数 buyTicket
在普通的继承当中, 父子类存在同函数名的函数 构成隐藏 但上图继承体系中, 很明显的每个成员函数的函数名前都加了 virtual, 函数名前加 virtual 的函数被称为虚函数 父类中的虚函数, 在子类中 如果存在 同名、同返回值类型、同参数的函数, 则 构成函数重写, 而不构成隐藏

什么是重写

C++ 类成员函数中 加 virtual 的函数被称为 虚函数 如果这个类存在子类, 且其子类中 存在与父类中的虚函数 函数名、函数参数、函数返回值类型 都相同 的函数, 则称子类重写了父类的虚函数, 或 父类虚函数被重写

即 重写 是父类虚函数与子类函数的关系, 且如果想要构成重写, 这需要满足两个必要条件:

  1. 父类中的函数必须是虚函数, 即必须有 virtual
  2. 子类中的函数 与父类中 虚函数的函数名、函数参数、函数返回值类型 都相等

必须同时满足这两个条件, 则称 子类重写了父类的虚函数

子类中的函数 不写 virtual 也同样构成重写, 但是建议写上可读性比较强

所以上图中的继承体系, Person类中的成员函数 virtual void buyTicket() 分别被 StudentElderlySoldier类中的 virtual void buyTicket() 重写
既然构成了重写, 就可以使用 父类指针或父类引用 来进行多态调用:
上述示例中, 函数BuyTicket(Person& per) 使用 父类引用作为参数, 在函数体内调用成员函数 buyTicket
四次调用分别传入 不同类型的对象作为参数, 除Person对象之外, 其他类型对象传参发生切片, 也就意味着 函数中的Person& 是 不同类型对象的Person部分的引用
如果函数参数是 Person* 类型也是同理, 即 Person 指向不同类型对象的Person部分*
所以, 父子类函数满足重写时, 父类指针 或 父类引用 指向哪个对象, 调用函数时 就会调用对应类中的函数, 这就是C++中的多态调用

相关问题

问题1: 如果只是父类对象可不可以多态调用呢?

答: 不可以。

示例:

如果 父子类虚函数构成重写, 但 使用父类对象调用虚函数, 则不构成多态

问题2: 如果虚函数不构成重写 构不构成多态?

答: 不构成。

示例:

如果 父子类函数之间不构成重写, 即使使用 父类指针或父类引用, 也是不构成多态

所以说, 构成多态的两个条件缺一不可

问题3: 为什么子类重写父类的虚函数时, 不加 virtual 依旧是虚函数

答: 因为在继承体系中, 子类已经继承了父类虚函数的接口部分(函数名、函数返回值、函数参数列表等, 包括virtual), 所以子类中重写父类的虚函数不加virtual也可以

这也说明了, 子类重写父类的虚函数时, 父类虚函数对于子类来说属于 接口继承

也仅限于重写的时候是接口继承

虽然子类重写父类虚函数时, 可以不加 virtual, 但是建议加上 便于分析

协变

C++ 规定, 父子类虚函数 必须 同函数名、同函数参数、同函数返回值类型, 才能构成重写
但其实有两个例外, 可以让父子类虚函数的 返回值类型不相同时也可以构成重写
  1. 当父类虚函数的返回值类型是父类指针时, 子类虚函数返回值类型可以是子类指针, 同样构成重写
  2. 当父类虚函数的返回值类型是父类引用时, 子类虚函数返回值类型可以是子类引用, 也构成重写
如果将之前示例中的 继承体系 改为上面两种, 重写同样成立:
而这种写法: 父子类虚函数的返回值类型为父子类的指针或引用, 重写依旧成立。被称为 协变

析构函数的多态

父子类中 一般的虚函数都可以指定函数名、函数参数、函数返回值类型, 进而构成重写, 在进而构成多态
在某种场景之下, 析构函数也是需要构成多态的, 用于方便不同类型对象的析构
但是 析构函数的函数名是指定的——为 ~类名() , 怎么对析构函数进行重写呢?
还记得在介绍 C++ 继承的时候 提到过“ 父子类中各自的析构函数构成隐藏, 因为编译器会 **将父子类的析构函数统一为 ~destructor **, 并且 解释说这是为了多态的使用
现在看来, 编译器已经为析构函数做好了准备, 我们只需要将 父子类的析构函数设置为虚函数 就可以构成重写, 进而构成多态了:
使用上面的 继承体系:
即使 析构函数显式定义 函数名不同, 编译器也会将继承体系中的所有析构函数 统一为 ~destructor 为多态做准备

C++11: final、override

C++对虚函数重写的判定是非常严格的, 稍有不注意可能就会发生一些运行错误。一个大的项目中, 运行错误 通常是不容易找到的
所以, C++11中, 对多态又增加了两个新的关键字: finaloverride

final:

意为最终, 作用也非常的简单: 添加在 虚函数函数名之后, 可以禁止此虚函数被重写, 即表示 此函数已经是最终的函数不能再改变

override

override 的作用, 则是 用于子类的虚函数 检查此虚函数是否完成了对父类虚函数的重写, 若没有完成重写, 则报错

抽象类

抽象一词有意思是, 不具体的。这个意思用来描述抽象类也是非常合适的
不具体的类, 其实就是不能实例化对象的类
什么样的类才能被称为抽象类?包含纯虚函数的类是抽象类
问题又来了, 什么是纯虚函数?
C++ 规定, 在一个虚函数 函数名后 加上 =0 那么这个虚函数就变成了纯虚函数
如上图所示, Clothes类中包含了一个 纯虚函数, 那么 Clothes类就是一个抽象类, 抽象类是无法实例化对象的:
并且, 抽象类的子类也是无法实例化对象的:

继承了抽象类的类, 也就继承了抽象类的纯虚函数, 所以 抽象类的子类也无法实例化对象

但是, 当 子类重写了父类的纯虚函数时, 子类就可以实例化对象 了, 并且可以多态调用:

一个纯虚函数的函数内容是无意义的

因为 包含纯虚函数的类 不能实例化对象, 其子类也得重写纯虚函数才能实例化对象

所以 纯虚函数的函数内容是无意义的, 这一点更加说明了 父类虚函数被继承且重写时, 对子类来说是接口继承

接口继承分析

至此, 本篇文章已经提到了两次 接口继承, 那么究竟什么是接口继承?
其实接口继承, 意思就是子类只继承了父类虚函数的接口, 而没有继承父类虚函数的实现
还是以, Clothes类体系为例:
父类的纯虚函数根本没有函数实现, 所以无法实例化对象也无法正常调用
而子类对父类的纯虚函数进行了重写, 子类就可以实例化对象并且可以正常的调用虚函数
这就说明 父类虚函数的函数实现对子类的重写是没有影响的, 子类重写父类的虚函数也只是继承了父类虚函数的接口

关于接口继承有一题, 可以验证对接口继承的理解:

这段代码的输出结果是什么?

答案是: B->1

如果没有对类的继承、多态、接口继承没有一个明确清晰的了解, 这一题是不容易分析出来的

分析:

首先, B类 new了一个对象, 并将其指针给了 p(B类指针), 即 p 为指向B类对象的B类指针

所以, p->test(); 调用的是 B类对象的A类部分的成员函数, 且A类中的虚函数已被重写

进入 test() 函数时, this指针是A*类型 的, 指向的是B类中A类的部分:

所以 p->test()内部调用 func(); 其实是 this->func();

而此时 this是A*类型的, 在 B类对象中 使用 A* 调用构成重写的函数是什么?多态调用

所以应该执行的语句是: cout << "B->" << val << endl;

那么问题又来了, 为什么调用B类重写的的func, val 为 1

这就是 接口继承 的体现了

多态调用构成重写的函数, 编译器将其认定为 接口继承 即 编译器认定 virtual void func(int val = 1) 被继承到了B类中, 所以此函数构成重写的前提下, 修改此函数的部分内容是无效的, 因为编译器已经认定了接口的组成

所以, 在多态调用此函数时, 其实是:

而不是:

C++ 这样设计真的离谱!!!

而 如果是正常的调用 B类的func() 而不通过多态调用, 又会正常调用函数:

是因为正常调用, 编译器会只考虑B类本身的内容, 即使A类虚函数被重写了, 但是没有多态调用 编译器不会将其认定为 接口继承, 而是 认定为 实现继承

所以就 以

调用

版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)

作者: 哈米d1ch 发表日期:2022 年 7 月 26 日