12.1 什么是继承
如第 11 章所述,类的最基本作用是分类。同一类别的事物具有共同的属性。即使将分类进一步细化,这些属性还是会被继承下去。
假设你要设计一个射击类的游戏。游戏中出场的角色为具有位置和头像的模型,并且进一步可以分类为由人操纵的己方角色和由计算机操纵的敌方角色。当然,己方角色和敌方角色都一样,具有位置和头像的属性。可见分类进一步细化后属性得到了继承。
己方角色和敌方角色在实际实现中,分别声明各自的位置和头像属性将是一个重复工作。如果类中已经声明的属性能在对其进一步细分的子类 1 中自动传承,这种语言的功能是非常好的。因此,继承产生了(图 12.1)。
1子类(subclass)是从其他类中继承而创建的类。类 Y 从类 X 继承得到,就可以说类 Y 是类 X 的子类。子类的反义词是父类(superclass)。Y 是 X 的子类同时也意味着 X 是 Y 的父类。

图 12.1 继承:己方角色和敌方角色都具有位置和头像属性
如能使用继承实现方式的功能,那编程实现不就变得轻松很多吗?出于这种考虑,继承的使用变得广泛起来。同样是继承,考虑问题的方法和使用方式不尽相同。
继承的不同实现策略
继承的实现策略大体可以分为三种。
一般化与专门化
第一种策略是在父类中实现那些一般化的功能,在子类中实现那些专门的个性化的功能(图 12.2)。其设计方针就是子类是父类的专门化。在更细致的层面上,它和分类意义是相近的,这和 class=分类的想法一致。它让人很自然地意识到子类就是父类的一种。

图 12.2 对一般类的专门化
共享部分的提取
第二种策略是从多个类中提取出共享部分作为父类(图 12.3)。它和一般化与专门化的考虑很不一样。对于子类是否为父类的一种,它的答案是否定的。这种提取出共享部分的设计方针是习惯了函数的一种考虑问题的方法。

图 12.3 从多个类中提取共享部分
差异实现
第三种策略认为继承之后仅实现有变更的那些属性会带来效率的提高(图 12.4)。它把继承作为实现方式再利用的途径,旨在使编程实现更加轻松。的确有很多这样的情况。但这些情况下通常子类都不是父类的一种。

图 12.4 继承已有的类并实现差异部分
继承是把双刃剑
使用方法多意味着继承这种机制有很高的自由度。这和我们在第 4 章中接触到的 goto 语句有点类似。滥用 goto 语句可能造成代码理解困难,因此对其使用应加以限制。与此类似,对继承的使用也有相同的考虑,应该对其加以限制。尤其是第三种使用方法——继承已有的类并实现差异部分,这种编程风格会造成多层级的继承树,很容易导致代码理解困难。
多层级的继承树是如何导致代码理解困难的呢?假设某个对象具有方法 X,这一方法的定义在哪里呢?在这个类中,它的父类中,还是它的父类的父类中?要知道答案就需要追溯继承关系检查多个类 2。此外,如果你要修改某个方法,这将影响到所有的子类,以及所有子类的子类。影响范围越广,就越难确定这种修改会不会带来什么问题 3。
2现在有很多集成开发环境都可以承担这种繁重的任务,这说明为使编程这项工作更加轻松,相关工具也得到了进化。
3这个问题不是单靠在程序设计上下工夫就能解决的,同时也可以把大量的检查工作交给计算机去完成来帮助解决,这叫回归测试。解决问题的方法总是有很多种。
这个和我们在第七章中谈到的动态作用域的问题非常相似。人的理解能力是有限的,影响范围太大的话就理解不了了。影响范围小理解起来就轻松。通过使用继承实现代码的再利用,对于编写程序来说代码编写量减少了,工作变轻松了。但是反复使用继承后代码的影响范围便变大了,理解起来也困难了。因此,为了保证理解的简易性,就要防止继承树的层级过多。
里氏置换原则
在第 6 章我们提到过 CLU 语言,它的设计者芭芭拉·利斯科夫(Barbara Liskov)等人在 1987 年提出一种原则——里氏置换原则,这一原则现在常常在创建子类时作为注意点被提及。
这个原则可以表述为:假设对于 T 类型的对象 x,属性 q(x) 恒为真。如果 S 为 T 的派生类,那么 S 类型的对象 y 的属性 q(y) 也必须恒为真 4。
4芭芭拉·利斯科夫,周以真,“A behavioral notion of subtyping”, ACM Transactions on Programming Languages and Systems (TOPLAS), Vol.16, Issue 6, ACM, 1994, pp.1811-1841.在 1987 年的演说“Data abstraction and hierarchy”中,她们最早提出了这个思想。这里的解说针对的是 1994 年的确定性说法,其原文表述是: “Let ø(x) be a property provable about objects x of type T. Then ø(y) should be provable for objects y of type S where S is a subtype of T.”。
这句话换种表达就是,对于类 T 的对象一定成立的条件,对于类 T 的子类 S 的对象也必须成立 5。
5这里简单化处理把子类和 subtype 等同视之。至少在 C++ 语言和 Java 语言中这样理解应该没有问题。
语言表达可能比较难理解,我们用图来说明(图 12.5)。符合置换原则的情况如图左边所示,类 T 的所有对象都满足条件 q。并且类 T 的 子类类 S 的所有对象都满足条件 q。从图中可以看出 S 是 T 的子集,这是很自然的。

图 12.5 里氏置换原则
打破置换原则的情况如图右边所示。箭头指向的部分有不满足条件 q 的 S 类型的值。这边 S 不是 T 的子集。比如在 Java 语言中,如果 S 是 T 的子类,那么可以将类 S 的对象传递给类 T 的类型的变量。即在类型系统中 S 是 T 的子集。然而在现实情况中,最初所有的 T 都满足条件 q,但 S 继承了 T 之后就出现了不满足条件 q 的类 T。因此,为了保证类的继承关系和类型的父子关系这两种关系之间的一致性,有必要遵守这一原则。
这一原则也可以表达为继承必须是 is-a 关系。把子类 S 的所有对象都看作是父类 T 的对象而不会有任何问题,必须要做到这一点。
这一约束条件是非常严格的。当要继承某种类时,需要考虑该类是否可以被继承。假设继承的时候考虑的属性可以使里氏置换原则成立。但是在随后的程序编写过程中,需要的属性可能会越来越多。随着属性的增加,置换原则就有可能被打破。是在设计阶段就把所有属性列出来,只有当置换原则绝对不被打破时才去继承呢?还是在开发阶段如果发现新的属性就放弃类的继承呢?不管哪种方式都很费劲。
