19. 要么设计继承并提供文档说明,要么禁用继承
条目 18 中提醒你注意继承没有设计和文档说明的「外来」类的子类化的危险。 那么对于专门为了继承而设计并且具有良好文档说明的类而言,这又意味着什么呢?
首先,这个类必须准确地描述重写每个方法带来的影响。 换句话说,该类必须文档说明可重写方法的自用性(self-use)。 对于每个 public 或者 protected 的方法,文档必须指明方法调用哪些可重写方法,以何种顺序调用的,以及每次调用的结果又是如何影响后续处理。 (重写方法,这里是指非 final
修饰的方法,无论是公开还是保护的。)更一般地说,一个类必须文档说明任何可能调用可重写方法的情况。 例如,后台线程或者静态初始化代码块可能会调用这样的方法。
调用可重写方法的方法在文档注释结束时包含对这些调用的描述。 这些描述在规范中特定部分,标记为「Implementation Requirements」,由 Javadoc 标签 @implSpec
生成。 这段话介绍该方法的内部工作原理。 下面是从 java.util.AbstractCollection
类的规范中拷贝的例子:
public boolean remove(Object o)
Removes a single instance of the specified element from this collection, if it is present (optional operation). More formally, removes an element e such that Objects.equals(o, e), if this collection contains one or more such elements. Returns true if this collection contained the specified element (or equivalently, if this collection changed as a result of the call).
Implementation Requirements: This implementation iterates over the collection looking for the specified element. If it finds the element, it removes the element from the collection using the iterator’s remove method. Note that this implementation throws an UnsupportedOperationException if the iterator returned by this collection’s iterator method does not implement the remove method and this collection contains the specified object.
从该集合中删除指定元素的单个实例(如果存在,optional 实例操作)。 更广义地说,如果这个集合包含一个或多个这样的元素 e,就删除其中的一个满足 Objects.equals(o, e)
的元素 e。 如果此集合包含指定的元素(或者等同于此集合因调用而发生了更改),则返回 true。
实现要求: 这个实现迭代遍历集合查找指定元素。 如果找到元素,则使用迭代器的 remove
方法从集合中删除元素。 请注意,如果此集合的 iterator
方法返回的迭代器未实现 remove
方法,并且此集合包含指定的对象,则该实现将引发 UnsupportedOperationException
异常。
这个文档清楚地说明,重写 iterator
方法将会影响 remove
方法的行为。 它还描述了 iterator
方法返回的 Iterator
行为将如何影响 remove
方法的行为。 与条目 18 中的情况相反,在这种情况下,程序员继承 HashSet
并不能说明重写 add
方法是否会影响 addAll
方法的行为。
关于程序文档有句格言:好的 API 文档应该描述一个给定的方法做了什么工作,而不是描述它是如何做到的。那么,上面这种做法是否违背了这句格言呢?是的,它确实违背了!这正是继承破坏了封装性所带来的不幸后果。所以,为了设计一个类的文档,以便它能够被安全地子类化,你必须描述清楚那些有可能未定义的实现细节。
@implSpec
标签是在 Java 8 中添加的,并且在 Java 9 中被大量使用。这个标签应该默认启用,但是从 Java 9 开始,除非通过命令行开关-tag "apiNote:a:API Note:"
,否则 Javadoc 工具仍然会忽略它。
为了继承而进行的设计不仅仅涉及自用模式的文档设计。为了使程序员能够编写出更加有效的子类,而无须承受不必要的痛苦,类必须以精心挑选的 protected 方法的形式,提供适当的钩子(hook),以便进入其内部工作中。或者在罕见的情况下,提供受保护的属性。 例如,考虑 java.util.AbstractList
中的 removeRange
方法:
protected void removeRange(int fromIndex, int toIndex)
Removes from this list all of the elements whose index is between fromIndex, inclusive, and toIndex, exclusive. Shifts any succeeding elements to the left (reduces their index). This call shortens the list by (toIndex - fromIndex) elements. (If toIndex == fromIndex, this operation has no effect.)
This method is called by the clear operation on this list and its sublists. Overriding this method to take advantage of the internals of the list implementation can substantially improve the performance of the clear operation on this list and its sublists.
Implementation Requirements: This implementation gets a list iterator positioned before fromIndex and repeatedly calls ListIterator.nextfollowed by ListIterator.remove, until the entire range has been removed. Note: If ListIterator.remove requires linear time, this implementation requires quadratic time.
Parameters:
fromIndex index of first element to be removed.
toIndex index after last element to be removed.
从此列表中删除索引介于 fromIndex
(包含)和 inclusive
(不含)之间的所有元素。 将任何后续元素向左移(减少索引)。 这个调用通过(toIndex - fromIndex
)元素来缩短列表。 (如果 toIndex == fromIndex
,则此操作无效。)
这个方法是通过列表及其子类的 clear
操作来调用的。重写这个方法利用列表内部实现的优势,可以大大提高列表和子类的 clear
操作性能。
实现要求: 这个实现获取一个列表迭代器,它位于 fromIndex
之前,并重复调用 ListIterator.remove
和 ListIterator.next
方法,直到整个范围被删除。 注意:如果 ListIterator.remove
需要线性时间,则此实现需要平方级时间。
参数:<br>
fromIndex 要移除的第一个元素的索引<br>
toIndex 要移除的最后一个元素之后的索引
这个方法对 List
实现的最终用户来说是没有意义的。 它仅仅是为了使子类很容易提供一个快速 clear
方法。 在没有 removeRange
方法的情况下,当在子列表上调用 clear
方法,子类将不得不使用平方级的时间,否则,或从头重写整个 subList
机制——这不是一件容易的事情!
那么当你设计一个继承类的时候,你如何决定暴露哪些的受保护的成员呢? 不幸的是,没有灵丹妙药。 所能做的最好的就是努力思考,做出最好的测试,然后通过编写子类来进行测试。 应该尽可能少地暴露受保护的成员,因为每个成员都表示对实现细节的承诺。 另一方面,你不能暴露太少,因为失去了保护的成员会导致一个类几乎不能用于继承。
测试为继承而设计的类的唯一方法是编写子类。 如果你忽略了一个关键的受保护的成员,试图编写一个子类将会使得遗漏痛苦地变得明显。 相反,如果编写的几个子类,而且没有一个使用受保护的成员,那么应该将其设为私有。 经验表明,三个子类通常足以测试一个可继承的类。 这些子类应该由父类作者以外的人编写。
当你为继承设计一个可能被广泛使用的类的时候,要意识到你永远承诺你文档说明的自用模式以及隐含在其保护的方法和属性中的实现决定。 这些承诺可能会使后续版本中改善类的性能或功能变得困难或不可能。 因此, 在发布它之前,你必须通过编写子类来测试你的类。
另外,请注意,继承所需的特殊文档混乱了正常的文档,这是为创建类的实例并在其上调用方法的程序员设计的。 在撰写本文时,几乎没有工具将普通的 API 文档从和仅仅针对子类实现的信息,分离出来。
还有一些类必须遵守允许继承的限制。 构造方法绝不能直接或间接调用可重写的方法。 如果违反这个规则,将导致程序失败。 父类构造方法在子类构造方法之前运行,所以在子类构造方法运行之前,子类中的重写方法被调用。 如果重写方法依赖于子类构造方法执行的任何初始化,则此方法将不会按预期运行。 为了具体说明,这是一个违反这个规则的类:
public class Super {
// Broken - constructor invokes an overridable method
public Super() {
overrideMe();
}
public void overrideMe() {
}
}
以下是一个重写 overrideMe
方法的子类,Super
类的唯一构造方法会错误地调用它:
public final class Sub extends Super {
// Blank final, set by constructor
private final Instant instant;
Sub() {
instant = Instant.now();
}
// Overriding method invoked by superclass constructor
@Override
public void overrideMe() {
System.out.println(instant);
}
public static void main(String[] args) {
Sub sub = new Sub();
sub.overrideMe();
}
}
你可能期望这个程序打印两次 instant
实例,但是它第一次打印出 null
,因为在 Sub
构造方法有机会初始化 instant
属性之前,overrideMe
被 Super
构造方法调用。 请注意,这个程序观察两个不同状态的 final
属性! 还要注意的是,如果 overrideMe
方法调用了 instant 实例中任何方法,那么当父类构造方法调用 overrideMe
时,它将抛出一个 NullPointerException
异常。 这个程序不会抛出 NullPointerException
的唯一原因是 println
方法容忍 null
参数。
请注意,从构造方法中调用私有方法,其中任何一个方法都不可重写的,那么 final
方法和静态方法是安全的。
Cloneable
和 Serializable
接口在设计继承时会带来特殊的困难。 对于为继承而设计的类来说,实现这些接口通常不是一个好主意,因为这会给继承类的程序员带来很大的负担。 然而,可以采取特殊的行动来允许子类实现这些接口,而不需要强制这样做。 这些操作在条目 13 和条目 86 中有描述。
如果你决定在为继承而设计的类中实现 Cloneable
或 Serializable
接口,那么应该知道,由于 clone
和 readObject
方法与构造方法相似,所以也有类似的限制: clone
和 readObject
都不会直接或间接调用可重写的方法。 在 readObject
的情况下,重写方法将在子类的状态被反序列化之前运行。 在 clone
的情况下,重写方法将在子类的 clone
方法有机会修复克隆的状态之前运行。 在任何一种情况下,都可能会出现程序故障。 在 clone
的情况下,故障可能会损坏原始对象以及被克隆对象本身。 例如,如果重写方法假定它正在修改对象的深层结构的拷贝,但是尚未创建拷贝,则可能发生这种情况。
最后,如果你决定在为继承设计的类中实现 Serializable
接口,并且该类有一个 readResolve
或 writeReplace
方法,则必须使 readResolve
或 writeReplace
方法设置为受保护而不是私有。 如果这些方法是私有的,它们将被子类无声地忽略。 这是另一种情况,把实现细节成为类的 API 的一部分,以允许继承。
到目前为止,设计一个继承类需要很大的努力,并且对这个类有很大的限制。 这不是一个轻率的决定。 有些情况显然是正确的,比如抽象类,包括接口的骨架实现(skeletal implementations)(详见第 20 条)。 还有其他的情况显然是错误的,比如不可变的类(详见第 17 条)。
但是普通的具体类呢? 传统上,它们既不是 final
的,也不是为了子类化而设计和文档说明的,但是这种情况是危险的。每次修改这样的类,则继承此类的子类将被破坏。 这不仅仅是一个理论问题。 在修改非 final
的具体类的内部之后,接收与子类相关的错误报告并不少见,这些类没有为继承而设计和文档说明。
解决这个问题的最佳方法是禁止对在设计上和文档说明中都不支持安全子类化的类进行子类化。 这有两种方法禁止子类化。 两者中较容易的是声明类为 final
。 另一种方法是使所有的构造方法都是私有的或包级私有的,并且添加公共静态工厂来代替构造方法。 这个方案在内部提供了使用子类的灵活性,在条目 17 中讨论过。两种方法都是可以接受的。
这个建议可能有些争议,因为许多程序员已经习惯于继承普通的具体类来增加功能,例如通知和同步等功能,或限制原有类的功能。 如果一个类实现了捕获其本质的一些接口,比如 Set
,List
或 Map
,那么不应该为了禁止子类化而感到愧疚。 在条目 18 中描述的包装类模式为增强功能提供了继承的优越选择。
如果一个具体的类没有实现一个标准的接口,那么你禁止继承可能给一些程序员带来不便。 如果你觉得你必须允许从这样的类继承,一个合理的方法是确保类从不调用任何可重写的方法,并文档说明这个事实。 换句话说,完全消除类的自用(self-use)的可重写的方法。 这样做,你将创建一个合理安全的子类。 重写一个方法不会影响任何其他方法的行为。
你可以机械地消除类的自我使用的重写方法,而不会改变其行为。 将每个可重写的方法的主体移动到一个私有的“帮助器方法”,并让每个可重写的方法调用其私有的帮助器方法。 然后用直接调用可重写方法的专用帮助器方法来替换每个自用的可重写方法。
简而言之,专门为了继承而设计类是一件很辛苦的工作。你必须建立文档说明其所有的自用模式,并且一旦建立了文档,在这个类的整个生命周期中都必须遵守。如果没有做到,子类就会依赖父类的实现细节,如果父类的实现发生了变化,它就有可能遭到破坏。为了允许其他人能编写出高效的子类,你还必须暴露一个或者多个受保护的方法。除非意识到真的需要子类,否则最好通过将类声明为 final
,或者确保没有可访问的构造器来禁止类被继承。