简单回顾一下什么是“继承”
“继承(inheritance)”是OOP中重要的一个概念,它可以用于代码复用,也可以用于需要提供多态的场合。当我们“提起”继承时,往往用“is-a”的关系来类比,并将其与组合(composition)要求的“has-a”关系来进行对比:比如我们说“汽车”是“载具”,所以“载具”有的一切属性和行为“汽车”也应该具有;同时“汽车”有“轮胎”,不同的汽车可能拥有不同材质、不同数量、不同造价、不同品牌的轮胎。显而易见我们不能倒过来说“汽车有载具”或者是“汽车是轮胎”,那会引发逻辑以及实践的混乱。Java中用extends
关键字来表示“继承”,
1 | public class A extends B {...} |
来表示“类A is-a 类B”的关系,并且A将继承B中所有的属性、方法和内嵌的类(但无法访问被private关键字修饰的)。类A可以有自己专属属性、方法,也可以重写B内的方法。
为什么用OOP——为编程提供抽象
在谈继承的隐患之前,先说下为什么要使用OOP。OOP是一种建模的思想,它将任意一项活动视为不同的对象(Object)进行一系列通信与交互的结果。每个对象拥有并维护自己内部的状态,并且对外具有一系列行为可以影响自己或者其它对象的状态。
这样做的一个好处就是,对象是一个已经抽象化了的个体——大多数时候我们无需了解对象内部的实现原理,只需要调用对象的方法就可以完成我们的任务,此即“拿来主义”。这样,不同的程序员可以工作在不同的抽象层级:负责基件的程序员工作在一个抽象程度较低的层次,在“机器”这一级别上压榨出更多的性能;负责应用层的程序员则使用开发好的基件模块来搭建应用程序,并将使用情况与遇到的问题反馈给前一层,他们自己本身无需care基件的底层实现;负责具体业务的工作人员则在搭建好的平台中进行相关操作,当然,他们也不用担心自己的应用是怎么使用的。
这一种每一层都封装自己内部的实现细节,对外暴露特定功能的接口,为上层使用者提供抽象的模式,极大地提高了开发效率,节约了很多程序员宝贵的时间。
继承的潜在“隐患”——破坏封装和抽象性
一种常见的说法是,在大多数场合下,尤其是需要代码复用的场合,尽量使用“组合”而非“继承”,除非是需要用到多态特性的场合。这其中的理由是,使用“继承”会提高不同模块间的“耦合度”,从而导致代码可维护性的减弱。
这个说法是有道理的,我们先举个例子来说明“封装”是如何提高“耦合度”,进而埋下“隐患”的。
首先是两个非常简单的类,其中VerboseDog
类继承Dog
类:
1 | // 基类 Dog |
1 | // 派生类 VerboseDog |
乍一看,非常简单,不是么?Dog
类只有两个方法,所有的功能都只是向控制台输出文本内容;而VerboseDog
类也就重写了barkMany
方法,在重复输出“bark”前向控制台多打印了一句“As a dog, I say: ”而已,这会有什么问题呢?那请看——
倘若有一天,维护Dog
类的人重构了一遍Dog
类,这个程序员抱着“一切循环皆丑陋,世间至善是递归”的信仰,把Dog
类写成如下的形式:
1 | // 重构后的 Dog 类 |
重构之后的Dog类仍然保持与重构前相同的功能,从使用者的视角上看,既无增减也无其它改动。尽管其具体实现与原来的已经不相同了。这个时候,如果再调用VerboseDog
类的barkMany
方法,就会报“StackOverflow”的错误——可是明明改动之前好好的啊。原来,仔细一看就会发现,VerboseDog
重载过的barkMany
方法,会和未重载的bark
方法相互调用对方并且没有退出机制,这一来一去也就栈溢出了。
当然,在这个例子中,问题不难分析,bug也很好改。但这个简单的例子已经让我们看到,使用“继承”会让我们的派生类对基类“依赖度”变高,用专业点的话来说,就是会提高基类和派生类之间的“耦合”,为了修改自己写的派生类的代码,很有可能不得不去把基类的代码全部都读一遍并且分析一遍,当然,这是比较痛苦的。
有关“继承”与“组合”的取舍
另外一个例子(会需要一点数据结构的知识,只到线性表这部分就够了)则展示了对“继承”和“组合”的选择与取舍,考虑基于“List”的“Stack”,简化起见,我们
- 只考虑Stack的push方法怎么实现;
- List接口规定所有List的实现都必须有“add”方法;
- List的实现有基于引用的LinkedList和基于数组的ArrayList。
下面我们分三种情况来分别实现这个需求:
使用继承is-a的
1
2
3
4
5public class ExtensionStack<T> extends LinkedList<T> {
public void push(T item) {
this.add(x) // add 方法是父类 LinkedList 中本就有的
}
}使用组合has-a,但类型写死的
1
2
3
4
5
6public class DelegationStack<T> {
private LinkedList<T> _items; // 使用 LinkedList 作为组件
public void push {
_items.add(x);
}
}使用组合has-a,但允许用户在初始化时自定义使用哪种List的
1
2
3
4
5
6
7
8
9public class StackAdapter<T> {
private List<T> _item; // 使用 List 作为组件
public StackAdapter(List<T> item) {
_item = item;
}
public void push {
_item.add(x)
}
}
对于第一种实现,我们默认Stack作为一种特殊的LinkedList,在LinkedList已有的基础上来实现Stack的push方法。显然,这里继承的使用让LinkedList“已有的基础”几乎完全暴露给了我们。我们需要心里门清Stack继承了LinkedList的哪些方法,其中哪些使我们在Stack中可以访问的,哪些是不能访问的,等等。这些都要求我们至少要去读过LinkedList的源码并且知道主要内容和重要细节。
对于二三种而言,我们都可以默认把对应的List当做一个完整的组件,只使用其具有的功能,而不必过于关注它的实现。从抽象的角度而言,后者要更高些。第二第三种具体的区别就是,写死LinkedList类型可以让我们利用一些LinkedList中特有的方法(如果必要),只提供List层级的抽象的话,可以在调用时提供更多的灵活性,但是能利用到的List的特性是最为基础的那几个。
上面的都只是一些利弊分析,基于“继承”的实现虽然可能会导致开发时因为耦合度变高导致项目的可维护性降低,但是换来的好处就是对多态的支持,比如在java里,我完全可以拿Stack去做Vector的事情(虽然有时候不太提倡,但事实上它的实现允许你这么干)。按理说一个Stack所支持的操作应该是在顶部进行push和pop,但是实际上完全可以做在任意合法位置插入和删除的事情,比如下面代码(已省略非重要环节,例如定义类和导入相关模块之类的)。
1 | Vector<String> s = new Stack<String>(); |
在Java中能够编译通过并且运行(openjdk 18),然后输出以下结果
[Hello, my, World]
所以具体到开发而言,如何取舍就是仁者见仁智者见智的事情了。
BTW,从前面的例子就能得以窥见,java中Stack的实现是我们说的第一种,基于“继承”来实现的,后面我们会贴出openjdk的实现来进行分析;而c++中无论是gcc系、MSVC系、还是clang系,对stl的stack的实现是基于我们后面说的二三两种,基于“组合”来实现的。下面简单贴个源码,只选取了关键部分:
1 | // openjdk 对 Stack 的实现 |
1 | // llvm-clang 对 Stack 的实现,只摘取源文件中对 Stack 的描述 |
实际上java.util包里的Stack的实现招致过一些批评,还有人指出自己作为面试官,对方在面试时如果使用到java.util里的Stack的话,是作为一个极大的减分项(negative point)来对待的(出处:Java 程序员,别用 Stack?!)。