众所周知
public
继承时,所有的基类成员的访问属性在派生类中不会改变。派生类中只能访问基类的public
和protected
成员,不能访问private
成员;在外部派生类对象只能访问public
的成员;protected
继承时,基类的public
成员到派生类中变成protected
,其余成员的属性不变。派生类只能访问基类的public
和protected
成员;在类的外面,派生类无法访问基类的任何成员。
但覆写抽象类(形状)的纯虚函数(求面积)时,想在派生类(三角形)的外面调用成员(求面积)。如果基类求面积的方法位于 protected
域,此时需要把覆写的成员移动到 public
域下面,这样好吗?还是说,采取某些手段,仍然保持覆写的成员在 protected
域下面?
如果看不懂本文提到的概念,请回去补C++基础。
派生类的同名函数
先引入一丢丢其它的知识,在循序渐进到本文开篇的问题。假设一个场景,许多派生类源自一个基类,但每个类的展示信息是不一样的。现在写一个叫 print
的函数,用于打印不同类的信息。利用继承中的重定义函数,注意不是重载,来隐藏基类中的同名函数:
1 | class Base{ |
什么?你要每个类中功能一致的函数不同名?不嫌累么,之后会讲覆写,同名函数会简化程序的开发流程。
假设我要打印信息了。如果直接利用类型信息来重载函数,每个类型都需要写一个函数,有多少类就需要写多少函数(这里是重载),但这样会很麻烦:
1 | void print(Base* b){ |
虚函数
为避免上述情况的发生,可以借助虚函数来简化代码。虚函数的用途就是:支持动态联编,可以访问指针指向的对象的成员。在基类中通过关键字 virtual
来声明虚函数:
1 | class Base{ |
此时的 print
函数会变得很简单,避免大量无聊的重复代码:
1 | // 引用传参 发生了动态类型的隐式转换 |
此时,不同类型的实体对同一消息有不同的响应,这就是多态。上述程序在多态期间会发生一个叫联编的过程,即将源代码中的函数调用解释为执行特定的函数代码块,也就是说,确定到底该执行哪个函数。分为静态联编和动态联编:
- 静态联编:编译器查看参数和参数名,如函数重载、非虚方法根据指针类型确定调用函数等,在编译时期确定该执行哪个方法,上面展示的一堆
print
函数就是静态联编; - 动态联编:运行时才能确定调用哪个函数,使用虚方法实现,程序调用基类对象的指针或引用指向对象的对应同名虚函数。
这时我们在派生类中重定义一个与基类中虚函数同名的虚函数,称为覆写。建议写上关键字 override
,这会对函数名、类型、参数进行检查,防止低级错误的出现。当然虚函数不是必须需要覆写的,纯虚函数才需要。关于虚方法涉及的联编,在细节一下:
- 如果一个方法不是虚方法,那么将根据引用类型或指针类型选择执行的方法,静态联编
- 如果一个方法是虚方法,将根据指针或引用指向对象的类型选择执行的方法,动态联编
虚函数的注意事项
对于指针或引用该调用哪个虚函数,这里有一些注意事项:
- 基类对象的指针或引用指向派生类,这叫向上强制类型转换;父类有的子类一定有,允许转换
- 派生类对象的指针或医用指向基类,这叫向下强制类型转换;子类有的父类不一定有,不允许转换
- 只有成员才能是虚函数,所以友元函数等非成员函数不能是虚函数
- 基类的构造函数不能是虚函数;而基类的析构函数推荐写成虚函数
- 调用的函数不由指针类型决定,由指针所指的实际对象的类型决定该调用哪个类的函数
- 运行时,检查指针所指对象的类型,如果该类型重定义了虚函数,则使用,否则使用基类的虚函数
- 可以使用纯虚函数将类声明为抽象类,不能定义函数,也不能实例化抽象类
- 虚函数的重定义不会生成函数的重载版本,子类的同名函数会隐藏基类的函数。所以,继承中重定义虚函数时,要保证除返回值以外的其它函数原型相同,这称为返回型协变
- 如果想实现虚函数的重载,那么要从基类开始。如果基类的某个虚函数是重载的,那么子类要重定义所有版本,否则无法使用。如果不需要重定义,只需要调用基类的版本:
void A::show() {Base::show();}
- 虚函数的传递性:如果基类定义了虚函数,那么派生类的同名函数自动变为虚函数,所以派生类可以不写
virtual
关键字,写上会更清楚 - 如果一不小心写错了,且没有关键字
override
早检查,比如派生类中show
写成了Show
,那么不执行自己的虚函数,转而执行基类的虚函数
override
是标志符,指定虚函数覆写另一个虚函数。注意事项:
void foo() const override
不能覆写void foo()
,类型不匹配- 不能覆写非虚函数
override
避免写出bug
的代码,参考上述内容最后一条
纯虚函数
上文提到了一下纯虚函数,多年不写差点忘了纯虚函数,2022 年 5 月 22 日复习回来补充:
- 类里如果声明了虚函数,它的作用就是为了能让这个函数在它的子类里面可以被覆盖,这样的话,编译器就可以使用后期绑定来达到多态了。
- 纯虚函数只是一个接口,是个函数的声明而已,只能留到子类里去实现。
- 虚函数在子类里面可以不重写;但纯虚函数必须在子类实现才可以实例化子类。
- 虚函数继承接口的同时也继承了父类的实现;纯虚函数关注的是接口的统一性,实现由子类完成。
- 带纯虚函数的类叫抽象类,这种类不能直接生成对象,而只有被继承,并重写其虚函数后,才能使用。抽象类被继承后,子类可以继续是抽象类,也可以是普通类。
如何实现虚函数
如图所示:
编译器给每个对象添加一个隐藏成员,隐藏成员中保存一个指向函数地址的数组的指针,这种数组就是虚函数表。虚函数表存储了类声明的虚函数的地址。如 Base
类,包含一个指针,指向这个虚函数表。派生类也有指向独立地址表的指针,如果派生类提供了虚函数的定义,那么该虚函数表就保存为新的函数的地址,如 A
函数;如果没有定义,就保留基类的虚函数的地址,如 B
函数。
因此我们可以知道静态联编和动态联编各有优缺点:
- 动态联编是程序在运行阶段决策,造成额外的开销,如虚函数表等,涉及的类、对象会增大,函数调用时的查表也会造成开销;
- 静态联编效率高,可以在编译时期进行优化,是 C++ 的默认选择
如果不需要覆写基类的任何方法,就不需要动态联编,因此仅将预期的方法定义为虚的。
protected 继承
回到最初的问题,假设有个抽象类 shape
形状,包括三角形、矩形、圆形等。形状类提供两个纯虚函数,一个是求面积,一个是展示信息。当声明为纯虚函数时,其子类必须覆写纯虚函数的实现:
1 | class Shape{ |
公有继承时,可以这么写,十分简单:
1 | class Circle : public Shape{ |
如果是保护继承,那么是保持覆写函数的所在域的一致还是不一致呢?一致的话是在 protected
域内,不一致是在 public
域内,两种写法:
1 | class Circle : protected Shape{ |
放到 protected
域内时,这样保持了 protected
继承的一致性。但考虑到在类外调用,需要改写一下:
1 | class Circle : protected Shape{ |
这样,如果我们在写 print
展示信息的函数,那么又要回到每个类都需要单独写一个函数的繁琐场景,不能使用动态联编的优良特性,这并不推荐。所以,个人建议,在 protected
继承时,如果要覆写函数且需要在外部调用,那么将需要覆写的函数放到 public
域下,这样会更简洁。给个完整代码感受一下:
1 |
|
或者,直接 public
继承多省事: