July.cc 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 日