如何在 Java 中安全地使用子类型

你可能还记得,Liskov 代换原则是关于承诺和契约的规则 。但具体是怎样的承诺呢?为了确保 subtype(子类型)的安全性,意味着必须保证可以合理地从超类型推导出 subtype,而且这个过程具有传递关系 。在数学中,对所有 a,b,c ∈ x,如果 aRb 并且 bRc,那么 aRc 。在面向对象程序设计中,subclass 即对应 subtype,然而这不是正确的打开方式(这篇文章中 subclass 特指 subtype) 。我们必须确保不会违反继承超类的承诺,我们不会依赖于一些无法控制的东西 。如果它发生更改,则可以影响其他对象(这些是不可变对象) 。实际上,subclass 甚至可能导致 bug 。
译注:Liskov 于1987年提出了一个关于继承的原则:“继承必须确保超类所拥有的性质在子类中仍然成立” 。也就是说,只有当一个子类的实例应该能够替换任何其超类的实例时,它们之间才具有 is-A 关系 。
1. 为什么要安全地使用subtype(子类型)
实际上,subclass 是一种特殊的 subtype,它允许 subtype 重用 supertype 的实现(目的是防止因超类中的小改动导致重新实现) 。我们可以认为 subclass 是 subtype,但不能说 subtype,但不能说 subtype 是 subclass 。subclass 主要有两个工作:subtype(多态)和代码重用 。subtype 的影响最大,父类中任何 public 或 protected 更改都将影响其子类 。subetype 有时候是,但并不总是 Is-A 关系 。实际上,subtype 是一种程序上的代码重用技术,也是一种实现动态多态性(dynamic polymorphism)的工具 。
subclass 只关心实现的内容和方式,而非承诺的内容 。如果违背了基类承诺会发生什么,如何保证它们之间能够兼容?即使编译器也无法理解这种错误,留给你的会是代码中的 bug,比如下面这个例子:
class DoubleEndedQueue { void insertFront(Node node) { // ... // 在队列的前面插入节点 } void insertEnd(Node node) { // ... // 在队列末尾插入节点 } void deleteFront(Node node) { // ... // 删除队列前面的节点 } void deleteEnd(Node node) { // ... // 删除队列末尾的节点 }}class Stack extends DoubleEndedQueue { // ...}如果 Stack 类希望使用 subtype 实现代码重用,那么它可能会继承一个违反自身原则的行为,比如 insertFront 。让我们接着看另一个代码示例:
public class DataHashSet extends HashSet { private int addCount = 0; public function DataHashSet(Collection collection) { super(collection); } public function DataHashSet(int initCapacity, float loadFactor) { super(initCapacity, loadFactor); } public boolean function add(Object object) { addCount++; return super.add(object); } public boolean function addAll(Collection collection) { addCount += collection.size(); return super.addAll(collection); } public int function getAddCount() { return addCount; }}上面的示例使用 DataHashSet 类重新实现 HashSet 跟踪插入操作 。DataHashSet 继承 HashSet 并成为它的一个子类 。我们可以在 JAVA 中传入 DataHashSet 对象替换 HashSet 对象 。此外,我的确重写(override)了基类中的一些方法 。这在 Liskov 代换原则中合法吗?由于没有对基类行为进行任何更改,只是加入跟踪插入操作代码,似乎完全合法 。但我认为这显然是错误的 subtype 代码 。
首先,应该看一下 add 方法到底能做什么 。它把 addCount 属性加1,并调用父类方法 。这段代码存在一个溜溜球问题 。让为我们看看 addAll 方法 。首先,它把 addCount 的值加上集合大小,然后调用父类的 addAll 方法 。但是父类的 addAll 方法具体怎么执行呢?它将多次调用 add 方法(循环遍历集合) 。问题来了,父类将调用哪个 add 方法?是当前子类的 add 还是父类中 add?答案是子类中的 add 方法 。因此,count 大小增加两倍 。调用 addAll 时,count 增加一次,当父类调用子类 add 方法时,count 会再增加一次 。这就是为什么称之为悠悠球问题 。

译注:yo-yo problem 溜溜球问题 。在软件开发中,溜溜球问题是一种反模式(anti-pattern) 。当阅读和理解一个继承层次非常长且复杂的程序时,程序员不得不在许多不同的类定义之间切换,以便遵循程序的控制流程 。这种情况经常发生在面向对象程序设计 。该术语来自比较弹跳注意的程序员的上下运动的玩具溜溜球 。Ganti、Taenzer 和 Podar 解释为什么这么命名时说道:“当我们试图理解这些信息时,常常会有一种骑着溜溜球的感觉” 。
这里还有一个例子证明 subtype 是有风险的,看下面这段代码:
class A { void foo(){ ... this.bar(); ... } void bar(){ ... }}class B extends A { // 重写 bar void bar(){ ... }}class C { void bazz(){ B b = new B(); // 这里会调用哪个 bar 函数? B.foo(); }}


推荐阅读