继承

DinS          Written on 2017/10/31

继承是OOP的骨架。
用骨架来形容是非常恰当的,因为继承必然意味着设计一个类体系。
在进入具体的编码之前,需要花些时间讨论什么是继承.。

一、继承的概念

继承是为了处理类与类之间的关系而抽象出来的。
继承意味着两个类之间“是一种”(is-a)的关系。比如说苹果是一种水果(apple is a fruit),葡萄也是一种水果(grape is a fruit),桃子也是一种水果(peach is a fruit)。

但是仅仅停留在这里是不够的,要理解的更深。但凡出现了继承,便意味着存在抽象。水果是对苹果、葡萄、桃子的一种抽象,换言之水果更具一般性,苹果、葡萄、桃子更具特殊性,即它们涵盖的信息更多。

我们使用图示来表述这样一种抽象:

依照惯例,把更具一般性的类放在上面,称为基类,把更具特殊性的类放在下面,称为派生类。通过一个小三角形表明继承关系。
一个类既可以是基类也可以同时是派生类。基类与派生类是一对相对概念。

从这张图上可以看出类体系的味道,可以想象出来类体系会非常复杂。
于是可以提出几个重要的问题:
1.如何设计类体系?这个问题留待之后解决。这部分实际上是看出高下的地方。

2.为什么需要类体系,或者说继承这个功能的意义何在?
浅层次的原因是继承方便了代码复用。基类中的代码和功能会被派生类自动继承下来,即可以直接用。
深层次的原因是继承所带来的向上类型转换(upcasting)。什么意思呢?
从逻辑上看,凡是需要用到基类的地方,派生类都可以胜任。比如说自助餐提供水果,那么不管是苹果、葡萄、桃子都是可以的。凡是用到派生类的地方,基类不一定能够胜任。比如自助餐指明了提供葡萄,那么就不能放苹果和桃子。

从计算机的角度讲,派生类中含有基类部分的代码,即用到基类功能的地方用派生类提供也是没有问题的。基于上述推理C++提供了继承的重要应用:接受基类引用或指针参数的函数也可以接受派生类。
这意味着什么?这意味着通过良好的设计,我们有可能不需要改变原有的代码就实现新的功能。那么为什么叫向上类型转换?因为从图上来看,是从下往上走。

3.如何在编码中使用继承?让我们来仔细讲解。

二、继承的语法形式

先让我们来定义基类Fruit。
这个跟普通的类没什么区别。我们把派生类共有的功能和成员放到基类里,这样之后的派生类就不用再声明、实现这些内容了,这是代码复用的直接体现。

构造和析构使用默认版本也没问题。
这里是想说明继承内部的原理所以在实现中加了一条输出。

上文的Apple类并没有什么显著差异,区别是增加了一个水果种类,方便区分。
另一个是移除了计算总价的声明,为了给派生类留下空间。
接下来看看派生类,这是重点。

首先再次明确一下,派生类继承基类意味着派生类中含有基类的全部,我们在派生类中新声明的内容相当于在基类之外添加新内容。
派生类的语法形式是在正常声明class后跟一个:,后面写public接着基类的类名。Public表明派生类是public继承基类。public继承的含义是派生类可以访问基类的public内容,但是不能访问private部分。注意基类的private内容实际上在派生类中存在,只不过因为访问限制不能直接访问而已。
既然有public继承,那么可以想像出有private继承,以及还有一个protected继承。永远使用public继承,只有这个有应用价值,其他的继承方式只是为了语言的完备性而存在的。

派生类内部的写法跟正常的类一样,可以声明private。这样除了基类的成员外这里的派生类又多了自己特殊的成员。

之前说过public继承的派生类可以访问基类的public内容,但是Big-Three和赋值运算符是例外,派生类不能继承基类的这些内容。
这个是说得通的,派生类既然是继承基类的,那么肯定跟基类多少有点不同,不然就没必要继承了。多出来的部分肯定要单独处理,继承基类的Big-Three和赋值没有意义。
在这一部分有一点特别需要注意,那就是先完成基类部分的构造才能进而完成派生类部分的构造。因为派生类包含基类的完整部分,所以在建立派生类对象时必须把基类处理好,不然没法继续。在实现部分还会说到这一点。
如果只使用容器和智能指针,那么同理析构、拷贝和赋值可以使用默认版本。但是无论如何构造函数还是要自定义的,一定会碰到这一点。

接口部分声明派生类的新接口。
一个有意思的问题是如果派生类中声明了跟基类一样的接口会如何?
除了覆盖继承而来的虚函数外,派生类中不要声明跟基类一样的名字。这会让问题复杂化而且很头疼,所以不要这样做。虚函数会在后面讲解。

声明部分就是这么多了,看看实现部分:

大图点这里

依然使用初始值列表来初始化,派生类归根到底也是个类,没什么特殊的。但是特殊的部分在:后面的Fruit()。
注意这里调用了基类的默认构造函数来初始化派生类中的基类部分,等这部分完成了再初始化派生类新加的成员。

自定义构造函数同理,先调用基类的自定义构造函数,然后初始化派生类成员。

然而析构函数中我们却没有显式调用基类的析构函数,这是因为编译器在销毁派生类时,会自动先销毁派生类的部分,然后调用基类析构函数销毁基类部分。不需要我们手动处理。

拷贝构造函数有一个很有意思的地方,那就是Fruit(rightApple)。
按理说这应该是一个Fruit的构造函数,然而我们并没有在基类中声明这个函数,代码为什么还能够正常运行呢?
这同样是因为派生类中包含了基类的完整部分。编译器遇到这种情况会自动把派生类中的成员赋给基类的对应成员,从而完成基类初始化。

赋值运算中多了一行没见过的代码,意义有些费解。
首先明确一点,在派生类赋值过程中由于涉及到对象建立,所以同样要处理基类部分,但是赋值并不是初始化,所以不能调用基类的构造函数。解决方法是调用基类的赋值运算符。这个Fruit::operator=(rhs)就是这个意思。
只要这么写就能够完成任务。即使基类中没有显式声明=,编译器也会找到默认版本,这个不是问题。当然这也引申出另一点:如果基类中不允许赋值,那么派生类自然也不能够赋值。

最后是一个新加的接口。
注意我们并没有直接使用基类m_dPrice和m_dWeight。这是因为这两个值在基类中位于private部分,所以派生类没法直接访问,解决方法是调用基类的接口来获得这两个值。

可以看到通过使用继承,派生类不用把每一个接口都声明、实现一遍。
在基类处理过了,派生了就可以不写了。这是代码复用的体现。
接下来我们看看如何在主程序中使用基类和派生类,以及初次体验使用继承的浅、深层级。

三、继承的使用方法

BatchSet函数的用意是展现向上类型转换。

Calc函数是为了看看构造和析构派生类时候发生的事情。

看主函数。建立一个基类对象并调用函数,没什么新奇的地方。

然后构造了一个派生类对象,注意在这里派生类直接调用了基类的接口。这是代码复用的直接体现。

BatchSet接收基类引用,但是这里传入了派生类。发生了向上类型转换。

最后进行计算看看向上类型转换是否成功了,以及看看拷贝构造和析构派生类。

运行结果:

我们在建立派生类对象时,首先是基类构造函数被调用,然后是派生类构造函数。

派生类调用了基类接口正常输出了内容,BatchSet中没有输出。
下面进入Calc函数。这里只输出了派生类的拷贝构造函数,这是因为基类的拷贝构造函数使用了默认版本,并没有输出。实际上是先调用基类拷贝构造函数然后基类
函数返回时先析构了派生类然后是基类。

最后的计算结果是29.7,这证明了向上类型转换成功了,否则数不应该是这个。

四、继承的意义

使用继承,我们可以优化代码结构。
比如可以写出接收基类引用的通用函数,派生类都可以适用这个函数。
同时派生类可以自己添加新接口以保证独立变化的能力。

具体而言,在水果商人的例子中,对象初始化可以放在一个通用函数里
如果之后打折策略变化,我们可以只在派生类中修改,如果有新水果也可以派生出新的类。

这个思路很好,但是仔细想想的话使用继承来实现会遇到一些问题。
比如Apple.CalcTotalPrice和Grape.CalcTotalPrice这两个东西是没法混合起来的,在代码中还是需要出现Apple和Grape类,这样不可避免会在后期修改代码。
我们真正需要的是一个更加抽象的东西,比如Fruit基类,这个已经有了。但是我们还希望让这个基类调用CalcTotalPrice,程序能够根据具体的类型自动调用派生类中对应的函数。
只有做到了这个,才能让代码更加抽象,才能尽可能减少后期修改原有代码的可能。如果能够做到只使用基类编码,但程序运行时能够自动调用“正确”的函数,我们就写出了扩展性良好的代码。这是因为无论如何变化,基类作为一个概念是稳定的,我们很少会动到抽象概念本身。

我们的这种设想是OOP的精髓,在C++中有一个功能来实现:动态绑定。
继承的最终目的是动态绑定,见《动态绑定/多态》。