Effective-Java-18复合优先于继承

继承(inheritance)是实现代码重用的有力手段,但它并非永远是完成这项工作的最佳工具。使用不当会导致软件变得很脆弱。在包的内部使用继承是非常安全的,在那里子类和超类的实现都处在同一个程序员的控制下。对于专门为了继承而设计并且具有很好的文档说明的类来说(见19条),使用继承也是非常安全的。然而,对于普通的具体类(concrete class)进行跨越包边界的继承,则是非常危险的。提示一下,本条目使用”继承”一词,含义是实现继承(当一个类扩展另一个类的时候)。本条目中讨论的问题并不指接口继承(类实现接口或接口扩展接口)。

与方法调用不同的是,继承打破了封装性[Snyder86]。子类依赖于其超类中特定功能的实现细节。超类的实现有可能会随着发行版本的不同而有所变化,如果发生了变化,子类可能会遭到破坏,即时是子类代码完全没有改变。因而,子类必须要跟着其超类的更新而演变,除非超类是专门为了扩展而设计的,并且具有很好的文档说明。

为了说明得更加具体,我们建设有一个程序使用了 HashSet。为了调优该程序的性能需要查询 HashSet,看一看自从它被创建以来添加了多少个元素(不要与它当前的元素数目混淆起来,它会随着元素的删除而递减)。为了提供这种功能,需要基于 HashSet 编写一个变体,定义记录视图插入的元素的数量 addCount,并针对该计数值导出一个访问方法。HashSet 类包含两个可以郑家元素的方法:add 和 addAll,因此这两个方法都要被覆盖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Broken - Inappropriate use of inheritance!
public class InstrumentedHashSet<E> extends HashSet<E> {
// The number of attempted element insertions
private int addCount = 0;

public int getAddCount() {
return addCount;
}

@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}

@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
}

这个类看起来非常合理,但是它并不能正常工作。假设我们创建一个实例,并利用addAll方法添加了三个元素。

1
2
3
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("Snap", "Crackle", "Pop"));
System.out.println(s.getAddCount()); // 6

此时我我们期望 getAddCount 方法能返回3,但实际上它返回的是6。原因是,在 HashSet 的内部 addAll 方法是基于它的add 方法来实现的,即使 HashSet 的文档中并没有说明这样的实现细节。所以实际上在 addAll() 中的所有元素都使得 addCount 加了2。只需要去掉被覆盖的 addAll 方法。虽然这样可以正常工作,但其功能正确性依赖于这样的事实:HashSet 的 addAll 方法是在其 add 方法上实现的。 这种自用性(self-use)是实现细节,而不是承诺,不能保证在 java 平台的所有实现中都保持不变,不能保证随着上发行版本的不同而不发生变化。因此,InstrumentedHashSet 类将会是非常脆弱的。

导致子类脆弱的一个原因是,它们的超类在后续的发行版本中可获得新方法。假设程序的安全性依赖于这一事实:所有被插入至某个集合的元素都满足某个先决条件。下面的做法将能保证这一点:对集合进行子类化,并覆盖掉所有的能够添加元素的方法。如果在后续的发行版本中,超类中没有增加能插入元素的新方法,那么这种做法能够正常工作。然而,一旦超类增加了这样的新方法,则很有可能因为调用了这个没有被覆写的新方法,而将”非法”的元素添加到子类的实例中。这不是一个纯粹的理论问题。在把 Hashtable 和 Vector 加入到 Collections Framework 中的时候,就修正了几个这类性质的安全漏洞。

以上问题都来源于覆盖 (overriding) 方法。在扩展一个类时,增加新的方法,而不覆盖现有的方法只是相对的安全,并不是没有风险。如果超类在后续的发行版本中获得了一个新方法,并且和子类中的某一方法只是返回类型不同,这样的子类将无法通过编译 [JLS,8.4.8.3]。如果给子类提供的方法带有与新的超类方法完全相同的签名及返回类型,这就覆盖了超类中的方法。你的方法是否能遵守新超类方法,也是个问题。因为在编写子类方法时,超类新方法还并存在

幸运的是,有一种方法可以避免前文所述的所有问题。不扩展现有类,而是在新的类中增加一个私有域,引用现有类的一个实例。这种设计被称为 “复合” (composition)。因为现有类变成了新类的一个组件。新类中的每个实例方法都能调用被包含的现有实例中对应的方法,并返回其结果。这被称为转发 (forwarding),新类中的方法被称为转发方法 (forwarding method)。这样的类将会非常的稳固,它不依赖于现有类的实现细节,即使是现有类增加了新的方法,也不会影响到新的类。

如下示例用复合 / 转发方法来代替 InstrumentedHashSet 类。注意这个实现分为两部分:类本身和可重用的转发类 (forwarding class),其中包含了所有转发方法,没有任何的其他方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Wrapper class - uses composition in place of inheritance
public class InstrumentedSet<E> extends ForwardingSet<E> {
private int addCount = 0;

public InstrumentedSet(Set<E> s) { super(s); }

public int getAddCount() { return addCount; }

@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}

@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// Reusable forwarding class
public class ForwardingSet<E> implements Set<E> {
private final Set<E> s;

public ForwardingSet(Set<E> s) { this.s = s; }

public int size() { return s.size(); }

public boolean isEmpty() { return s.isEmpty(); }

public boolean contains(Object o) { return s.contains(o); }

public Iterator<E> iterator() { return s.iterator(); }

public Object[] toArray() { return s.toArray(); }

public <T> T[] toArray(T[] a) { return s.toArray(a); }

public boolean add(E e) { return s.add(e); }

public boolean remove(Object o) { return s.remove(o); }

public boolean containsAll(Collection<?> c) { return s.containsAll(c); }

public boolean addAll(Collection<? extends E> c) { return s.addAll(c); }

public boolean retainAll(Collection<?> c) {
return s.retainAll(c);
}

public boolean removeAll(Collection<?> c) { return s.retainAll(c); }

public void clear() { s.clear(); }

public boolean equals(Object o) { return s.equals(o); }

public int hashCode() { return s.hashCode(); }

public String toString() { return s.toString(); }
}

Set 接口的存在使得 InstrumentedSet 类的设计成为可能,因为 Set 接口保存了 HashSet 类的功能特性。前文的基于继承的方法只适用于单个具体类,并且对于超类中所支持的每个构造器都要求有一个单独的构造器,与此不同的是,这里的包装类 (wrapper class) 可以被用来包装任何 Set 实现,并且可以结合任何先前存在的构造器一起工作:

1
2
Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp));
Set<E> s = new InstrumentedSet<>(new HashSet<>(INIT_CAPACITY));

因为每一个 InstrumentedSet 实例都把另一个 Set 实例包装,所以 InstrumentedSet 类被称为包装类 (wrapper class)。这也正是 Decorator (修饰者) 模式 [Gamma95] (InstrumentedSet 类对集合进行修饰,增加计数特性)。有时复合和转发的结合也被宽松的称为 “委托” (delegation)。从技术的角度而言,这不是委托,除非包装对象把自身传递给被包装的对象 [Liebermen86; Gamma95] (Ryuu:确实是有种委托的味道)。

包装类几乎没有什么缺点。需要注意的是,包装类不适合用于回调框架 (callback framework);在回调框架中,对象需要把自身的引用传递给其他对象,用于后续的调用 (“回调”)。因为被包装的类并不直到它外面的包装对象,它传递一个指向自身的引用 (this),回调时避开了外面的包装对象。这被称为 SELF 问题 [Lieberman86]。有些人担心转发方法调用所带来的性能影响,或者包装对象导致的内存使用。在实践中,这两者都不会造成太大的影响。编写转发方法倒是有点繁琐,但只需给每个接口编写一次构造器,转发类则可以通过包含接口的包提供。如 Guava 就为所有的集合接口提供了转发类 [Guava]。

只有当子类真正是超类的子类型 (subtype) 时,才适合用继承。对于 A、B 两类,只有两个类有 “is-a” 的关系时,B 才应该扩展 A。若想用 B 扩展 A,就应该问问自己:每个 B 是否 is an A?如果不能肯定,那么就不应该进行扩展。如果没有 is-a 的关系,通常情况下,B 包含 A 的一个私有实例,并暴露一个较小的、较简单的 API:A 本质上不是 B 的一部分,只是它的实现细节。

在 Java 平台类库中,有许多明显违反这条原则的地方。例如,栈 (stack) 并不是向量 (vector),所以 Stack 不应扩展 Vector。同样的,属性列表也不是散列表,所以 Properties 不应扩展 Hashtable。这种情况下,复合模式才是恰当的。

若在适用复合的地方使用了继承,则会不必要地暴露实现细节。这样得到的 API 会把你限制在原始的实现上,不必要的局限该类。更为严重的是,由于暴露了内部的细节,客户端可能直接访问这些内部细节。至少会造成语义上的混淆。例如,如果 p 指向 Properties 实例,那么 p.getProperty(key) 就有可能产生与 p.get(key) 不同的结果:getProperty 考虑了默认的属性表,get 继承自 HashTable,未考虑默认的属性列表。最为严重的是,客户可能直接修改超类,从而破坏子类的约束条件。在 Properties 的情况中,设计者的目标是只允许字符串作为键 (key) 和值 (value),但直接访问底层的 HashTable 就允许违反这种约束条件。一旦违反了约束,就不能再使用 Properties API 的其他部分了。等到发现这个问题的时,已经太迟,因为客户已经依赖于使用非字符串的键和值了。

在使用继承而非复合前,考虑被扩展类的 API 是否有缺陷。是否愿意这些缺陷传播到类中 API?继承机制会把超类 API 的所有缺陷传播到子类中,而复合则允许设计新的 API 以隐藏这些缺陷。

继承违背了封装原则。只有当子类和超类有 “is-a” 的关系时,使用继承才是恰当的。并且,若子类与超类处于不同的包,并且超类并不是为继承而设计,那么继承将会导致脆弱性 (fragility)。为了避免这种脆弱性,可用复合转发机制来代替继承,尤其是当存在适当的接口可以实现包装类的时候。包装类不仅比子类更健壮,功能也更强大。