摘要: 本文详细介绍了C++继承的三种方式和相关重要概念,整理了众多继承与组合中的注意问题。在C++继承存在不安全的默认实现,非虚函数的覆盖,多重继承的函数名冲突、菱形继承等众多问题下,如何实现多个功能的自由组合?阿里云高级开发工程师采用mixin,为大家提供了更好扩展性和更高代码复用度的解决方案
摘要:本文详细介绍了C++继承的三种方式和相关重要概念,整理了众多继承与组合中的注意问题。在C++继承存在不安全的默认实现,非虚函数的覆盖,多重继承的函数名冲突、菱形继承等众多问题下,如何实现多个功能的自由组合?阿里云高级开发工程师采用mixin,为大家提供了更好扩展性和更高代码复用度的解决方案。
数十款阿里云产品限时折扣中,赶紧点击这里,领劵开始云上实践吧!
本次直播视频精彩回顾,戳这里!
演讲嘉宾简介:
付哲(花名:行简),阿里云高级开发工程师,哈尔滨工业大学微电子学硕士,主攻方向为分布式存储与高性能服务器编程,目前就职于阿里云表格存储团队,负责后端开发。
以下内容根据演讲嘉宾视频分享以及PPT整理而成。
本文将围绕一下几个方面进行介绍:
1. C++继承方式
2. 继承相关重要概念及注意问题
3. 问题及解决:如何组合正交的多个功能
一. C++继承方式
C++有三种继承方式:public/protected/private,这三种继承方式中,派生类都会继承基类的public和protected成员,但无法直接访问基类的private成员,只能通过继承后的方法来访问。如图所示一个简单的示例:
在本例中,Base为基类,包含public/protected/private三种成员,其中public和protected成员可以被派生类继承,而mName不可以被派生类直接访问,只可以通过继承后的函数F(),G(),H(),I()来访问。
1. Public继承
采用public继承方式时,基类的成员在派生类中的访问级别与基类中一致,即public成员仍是public级别,protected成员仍是protected级别。如对上例中Base进行public继承,得到下派生类:
其中F()为纯虚函数,派生类只继承到函数的接口,需要再进行具体实现;G()为虚函数,派生类同时继承了接口和实现;H()为public方法,有实现但不为虚函数,无法在调用指针时触发多态,该派生类继承了接口和强制的实现,这是不能改写的;I()是protected方法,它不是基类的接口,因此派生类只继承了它的实现。此时,该派生类可以作为一个基类对象使用,例如上图中创建派生类对象用于两个函数中,此时派生类引用/指针可转换为基类引用/指针。
2. Protected继承
protected继承与public继承的不同在于,基类的成员在派生类中的访问级别的改变。public和protected成员都成为protected级别。此时派生类接口不包含基类的接口,因此protected继承不是is-a的关系。继承后成员如下图所示:
3. Private继承
private继承中,基类的public和protected成员都成为private成员。和protected继承类似,派生类接口不包含基类的接口,因此private继承也不是is-a的关系。同时派生类引用/指针不可转换为基类引用/指针。此外,由于此时派生类成员都为private,那么后续派生类型再也无法继承该类型。对上例中Base进行private继承,如下图所示:
那么综上所述,C++的继承方式中:public继承包括基类的接口与实现;protected继承只包括基类的实现,且可继续传递;private继承只包括基类的实现,且不可继承传递。这里值得注意的是,派生类无法继承基类private成员,这是指派生类无法直接访问,即基类private成员对派生类对象不可见,但在内存布局中是包含这些private成员的,且派生类的构造、析构、复制也会受到这些private成员影响。例如假设基类中有引用private成员,这不仅导致基类方法无法进行复制和移动,也同样会导致派生类的无法复制和移动。
二. 相关重要概念
1. 纯虚函数与抽象类
纯虚函数是声明为等于0的虚函数,具体如下图所示:
此处0填充在虚表中,这会导致纯虚函数的虚表为0项,即无法创建虚表,无法实例化。包含纯虚函数的类称为抽象类,此处Base即为一个抽象类。但这里需要注意的是,纯虚函数不等于无定义的虚函数。如果这里将图中的等于0去掉,即F() = 0改为F(),那么Base类是无法派生的,派生类会报错F()无法使用。
2. 接口继承与实现继承
接口是类与外界的通信协议,是抽象的;实现是类对协议的反应,是具体的。当称派生类继承了基类的某接口时,表示派生类对外的协议中也包含了基类对外的协议,调用该接口时,派生类对象就会被当做基类对象使用。当称派生类继承了基类的某实现时,指派生类可以调用基类的某种行为。与Java和C#不同的是,无论是继承接口还是实现,C++中只有一种继承语法,并且如上所述,public继承包括基类的接口与实现,protected和private继承只包括基类的实现,不包含接口。例如下图中:
当派生类继承基类public方法时,draw()方法为纯虚函数,只能继承到接口,即派生类必须改写该函数,否则不能实例化对象;error()方法为虚函数,继承到接口与默认实现,即若派生类改写了该函数,那么该函数就失效了,若派生类未改写,则可以直接使用该函数;id()方法为非虚函数,继承到接口与强制实现,即派生类无法改写该方法。
3. 安全的默认实现
大家可能觉得派生类需要给每个纯虚函数进行实现,太过繁琐,虚函数效果更佳。若该纯虚函数较为通用,可能在派生类中需要重写多遍,而虚函数提供了一个默认实现,只需在需要时改写即可。但请注意,这其实是非常危险的。因为在编写基类时是不知道未来将会产生哪些派生类,默认实现不一定适用所有派生类。例如下例中:
ModelA和ModelB都可以使用基类的默认实现,但后续加入了不能使用基类的默认实现的ModelC,此时理应为ModelC改写该实现但被编程人员遗忘了。编译时由于存在基类的默认实现,因此不会报错。运行初期可能不会出现问题,但会为后续埋下非常危险的隐患。因此严格的代码规范中禁止虚函数提供默认实现,即必须使用纯虚函数。但虚函数可以避免代码的重复,因此仍存在一定的价值。实际运用中,存在很多派生类需要使用默认实现,那么如何强制要求派生类显式地使用每个接口,又可以为派生类准备一个可调用的默认实现呢?一种方法就是为纯虚函数提供定义。这种定义不会存入虚表,因此基类本身仍然是抽象类,并且派生类仍然需要提供一个显式实现。但更简便的方法是在派生类中调用基类的实现,注意此处不能使用虚函数,而是直接使用函数名调用。如下图示例所示:
如此,需要使用默认实现时只需简单调用该函数;而不需要使用默认实现时,若程序员忘记编写具体实现,编译时便会报错,强制要求提供函数实现。因此,便可以避免上述默认实现的隐患。
4. 纯接口继承与接口类
纯接口继承是指基类只提供接口,不提供定义,即严格代码规范下,基类的所有函数都是纯虚函数,不提供具体实现,派生类需要对所有方法进行自定义,这样的类型称为纯接口类。纯接口继承完全分离了接口与实现,依赖更少,如下例所示:
这样的接口类有以下三个特点:一,没有非静态成员变量;二,所有成员都是public成员;三,所有成员都是纯虚函数,析构函数除外,因此在上例Interface类中存在一个有定义的虚的析构函数。纯接口继承的优点是最小化调用处的依赖,且接口与实现完全分离,这样在只有实现发生变化时,调用处不会受到任何影响。而它的缺点是不利于代码复用,如果多个派生类都要实现相差不多的方法F(),就需要重复编写多遍F()的代码。
5. 确保接口继承是“is-a”关系
在实行接口继承时,需要确保接口继承是“is-a”关系。当派生类以public方式继承一个基类时,它也继承了这个基类的所有接口,那么所有使用基类接口处都可以使用派生类。从这个角度说,派生类对象是一个(“is-a”)基类对象。这也是基类接口对派生类的约束,派生类需要严格保证其所有行为都符合基类接口的要求。但有时派生类并没有达到这一要求,如下经典示例所示:
本例中,基类Bird包含接口fly,正如大家理解,鸟都会飞。Penguin继承自Bird,如果采用默认实现那么Penguin也要继承接口fly。但大家知道企鹅不会飞,也就意味着它不能有fly接口。如果这里给Penguin一个空的fly方法,虽然可实行,但这是不合常理的,必须要向使用者显示Penguin是不含有fly行为的。如果此处不给出具体实现的话,只有在运行时才会抛出异常,这种意外的异常也是不友好的。这个问题其实源于对基类接口的设计。当已知不是所有鸟都会飞之后,那么便不应该给Bird类一个fly接口,基类的这种接口是一种不合理的强加的约束。一种解决方法是中间再加一个层次:Bird类本身是没有接口的,而Bird的派生类FlyingBird才会提供fly接口,那么Penguin便继承Bird类,而不是继承FlyingBird类。如此若调用Penguin类的fly接口,编译时就会报错,便可以防止上述问题的发生。另一个示例为矩形的实现,如下所示:
长方形类中有一提前假设,即它的长和宽是独立的,改变其中一个值,另一个值不会随之改变。makeBigger就体现了这种假设,这也是对所有长方形的派生类的要求。但正方形却不满足这个要求,它的长和宽必须是相等的。因此正方形根本不应该是长方形的派生类,这种继承是错误的。由此可见,C++中的继承比现实中的继承更加严格,需要编程人员谨慎的选择基类与派生类,任何适用于基类的性质都需要适用于派生类。存在任何不满足“is a”关系的继承都是不合理的。
6. 不要覆盖基类的非虚函数
当派生类public继承一个有非虚函数的基类时,派生类也会继承这个非虚函数,并且是继承了强制实现。然而与虚函数不同的是,派生类没办法改写这个函数,相反,如果自定义编写一个同名函数,基类的版本就被“覆盖了”,如下例所示:
派生类和基类中都有函数F(),但此处不是改写而是覆盖,基类接口被覆盖会导致调用产生的行为不一致。直接通过派生类对象调用F()与通过基类指针调用F(),会产生不一样的行为!这种不一致就表明派生类与基类不再是is a的关系。因此,不要覆盖基类的非虚函数。
7. 实现继承与组合
当派生类以protected或private继承一个基类时,派生类没有继承到基类的接口,而是继承到了基类的实现。这种方式被称为实现继承。实现继承意味着派生类与基类不是is-a的关系,而只是需要复用其实现或功能。例如,若有一个类Password,它需要使用std::string功能,那么一种解决方法就是让它继承自std::string,如图所示:
此时Password类便可以直接调用string方法。但string类本身并没有设计为可以成为一个基类,可能存在其析构函数不是虚函数,那么使用基类指针指向析构函数时,会忽略派生类对象中的内容。但这里还有另一种选择,那就是将std::string变成Password的一个成员,而不是Password的基类,这样仍能使用std::string的各种功能,且不需要增加一种继承关系。这种方法被称为“组合”,它是比继承更灵活的复用方法。一般在可以用组合达到目的时,要尽量避免使用实现继承。然而,在某些场景下,实现继承有它独特的用途。一是在改写基类的某些功能时,如下例所示:
当Widget只需要复用Timer的其他功能,但不需要Timer的onTick()时,就可以使用private继承Timer,然后改写onTick()函数,这是对象组合无法轻易完成的。当然该场景下,结合内部类,组合也可以达到类似效果。用内部类private继承Timer,与Widget构成组合关系,如此来避免Widget直接继承Timer。
第二种场景是当基类是空类型时,继承可以应用到空基类优化,在派生类中不占空间,而对象组合则没有这种优化,空类型成员至少要占用一字节的空间,一般会在八字节及以上。该场景最经典的例子是boost::noncopyable,无论是private继承,还是将其作为成员变量,都可以令自定义类型无法复制,但private继承时,因为该基类是空类型,因此在派生类中不占空间。实现继承的该特性在标准类型库中被广泛使用。
8. 多重继承的问题
C++允许一个派生类继承自多个基类,但逐渐大家意识到这种自由会导致一些棘手的问题,因此Java等语言取消了这种特性,而是派生类只允许有一个基类。多重继承会导致以下两个问题。一是不同基类间的名字冲突或者歧义,如下例所示:
此处有A和B两基类,C同时继承A和B。虽然A类中的Func()是public函数,B中的func()是private函数,但调用C类的func()函数时,它理应调用A的func(),但C++的名字查找规则是优先于访问级别检查的,因此先查找名字,进行重载决议,再检查访问级别,而在重载决议时编译器检查到了两个相同优先级的func(),这就产生了冲突。解决这个问题就需要显式调用某个基类的版本,指定调用函数的namespace:
第二个问题是菱形继承问题,这比上述问题更加棘手。当语言允许多继承时,一个基类可能会多次出现在同一个派生类的基类树中,如下例所示:
InputFile和OutputFile同时继承于File类,而下一层IOFile类同时继承InputFile和OutputFile,即继承了两次File,这就是菱形继承。当出现菱形继承时,意味着派生类对象中有多个相同类型的基类子对象,此时调用该基类方法时会产生如何选择子对象的混乱。并且有些属性对派生类是唯一的,比如File属性,在IOFile中只应有一份。为了解决这个问题,C++增加了虚继承,虚继承的基类在派生类中只会有一份。但虚继承被认为是比多重继承更糟糕的特性,它比虚函数的开销更大,且反直觉地要求最终派生类型的构造函数来构造整个继承链条中所有虚继承的基类。因此,为了避免菱形继承,一些编程规范规定:不能使用虚继承;尽量不要使用多重继承;如果要用多重继承,尽量模仿Java语言,至多只能有一个基类有实现,其它基类都是接口类。
三. 问题及解决:如何组合正交的多个功能
假设有若干个彼此独立,或说正交的功能,如何将它们组合起来?例如有一个TaskManager类,负责管理所有拥有ITask接口的对象,如下所示:
现在需要为ITask类型增加两个功能:一是timing功能,即在ITask对象执行Execute方法前后计时;二是logging功能,即在ITask对象对待Execute方法前后打印日志。那这该如何解决呢?
1. 继承
第一种方式是通过继承复用功能。具体如下所示:
从ITask类派生出一个ILoggingTask类,它增加OnExecute()接口,其派生类只要实现这个接口,在调用ITask::Execute时就能打印日志了。同样的方法,这里也通过增加ITimingTask类实现timing的功能:
然而当需要同时复用timing和logging该如何解决呢?假设使用上述方法,那么ITimingTask的Execute()与ILoggingTask的Execute()是冲突的,无法同时复用两个功能。这就体现了通过继承来复用代码的缺陷,对于单个功能,可以将需要复用的实现代码放在基类中,但如果需要同时复用多个功能,通过继承复用功能就无法解决了。另外,本例中因为增加了一层虚函数,而且还是在虚函数中调用另一个虚函数,这就导致编译器无法inline代码,从而增加运行期的开销。
2. 组合
第二种方式是通过组合复用功能。具体如下所示:
这里LoggingTask不再作为基类存在,而是作为代理,把对LoggingTask的请求转发给它持有的task成员,由task来解决请求。同样地,这里也增加一个TimingTask类:
接下来就可以通过链式传递来组合这两个功能。
通过组合来复用功能仍然也存在一些问题。一是这种方法依然有一些运行期的开销,比如需要在堆上分配每个对象,多次调用虚函数。但它解决了组合多个功能的问题,不同功能间也耦合较低。二是LonggingTask需要实现一些不用的接口。像LoggingTask这样纯粹的功能,本是不需要实现GetName这样的接口,但它继承自ITask,就需要实现ITask所有的接口。假如ITask还有其它接口,LoggingTask也都需要实现,这就增加了代码的复杂度,使得该模块特别臃肿。那么这该如何解决呢?
3. 重返继承
这次仍然尝试用继承来解决该问题,但与上述第一种继承方法相比,做出一些变化。前面的方法中增加了两个基类,且把需要复用的部分放在基类中。而这里把需要复用的部分放在派生类中:
这里将MyTask作为基类,TimingTask中继承MyTask来执行计时的操作。而LoggingTask继承TimingTask,如此LoggingTask便同时具有计时和打印两个功能。但这种方法仍然有很多缺陷:一是不同功能之间因为继承完全耦合在一起,功能之间无法分割;二是这两个功能绑定在MyTask类中,导致这两个功能完全无法被其它类型复用。虽然有这么多致命缺陷,但这种方法仍然有独特的优势:首先,不需要堆分配;其次,没有多余的虚函数定义及其调用;最后,编译器有机会做更多内联优化。那么,该如何解决这个方法的缺点呢?由代码分析可知,上例中的两个缺点都是因为基类是固定的,无法变化,如果能用模板将基类作为参数传递,上述缺点便解决了。
4. Mixin
最后一种方法是通过mixin复用功能。mixin本身是面向对象领域的一个非常宽泛的概念,它是有一系列被称为mixin的类型,这些类型分别实现一个单独的功能,且这些功能本身是正交的。当需要使用这些功能时,就可以将不同的mixin组合在一起,像搭积木一样,完成功能复用。一个更清晰的解释是这样的:一个mixin就是类里的一小块,可以用来与其它类或mixin做组合;一个独立的类与一个mixin的区别在于,一个mixin只建模小的功能点(如timing或printing),并不是用来独立使用,而是给其它需要这个功能的类做组合。
在C++中最常用的实现mixin的方式叫“参数化模板”。这里可以将TimingTask和LoggingTask的基类都换成模板参数:
此时便可以解决前述所有问题,TimingTask和LoggingTask实现完全不耦合,所有代码独立,且所有含有Execute()方法的类型都可以组合这两个功能。组合过程如下所示:
首先新建MyTask对象,将该对象传递进入TimingTask类中,生成对象t1,然后可以将t1传递进LoggingTask类中生成对象t2。t2便同时具备Timing和Logging功能。
C++可以通过继承方法来支持mixin,而不像其他语言,如Ruby,显式的支持mixin。如此使用mixin便能够实现自由组合多个功能,并且囊括了之前方法的所有优点,有更好的扩展性和更高的代码复用度。
当然这个类并不是最终希望得到的,因为它没有实现ITask接口。因此仍然可以增加一个新的mixin,来将任意含有Execute和GetName方法的类型适配为ITask的派生类:
本文由云栖志愿小组郭雪整理,编辑百见
原文链接
干货好文,请关注扫描以下二维码: