Walt You - 行是知之始

《Effective Java》学习日志(三)19:如果使用继承设计,必须要文档说明,否则不该使用

2018-10-22

条目 18 中提醒你注意继承没有设计和文档说明的“外来”类的子类化的危险。 那么为了继承设计而文档说明一个类是什么意思呢?


学习资料主要参考: 《Effective Java Third Edition》,作者:Joshua Bloch



首先,这个类必须准确地描述重写这个方法带来的影响。 换句话说,该类必须文档说明可重写方法的自用性(self-use)。 对于每个公共或受保护的方法,文档必须指明方法调用哪些重写方法,以何种顺序以及每次调用的结果如何影响后续处理。 (重写方法,这里是指非final修饰的方法,无论是公开还是保护的。) 更一般地说,一个类必须文档说明任何可能调用可重写方法的情况。 例如,后台线程或者静态初始化代码块可能会调用这样的方法。

调用可重写方法的方法在文档注释结束时包含对这些调用的描述。 这些描述在规范中特定部分,标记为“Implementation Requirements,”,由Javadoc标签@implSpec生成。 本节介绍该方法的内部工作原理。 下面是从java.util.AbstractCollection类的规范中拷贝的例子:

public boolean remove(Object o);

文档: 从该集合中删除指定元素的单个实例(如果存在,optional实例操作)。 更正式地说,如果这个集合包含一个或多个这样的元素,删除使得 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实用工具仍然会忽略它。

设计继承涉及的不仅仅是文档说明自用的模式。 为了让程序员能够写出有效的子类而不会带来不适当的痛苦,一个类可能以明智选择的受保护方法的形式提供内部工作,或者在罕见的情况下,提供受保护的属性。 例如,考虑java.util.AbstractList中的removeRange方法:

protected void removeRange(int fromIndex, int toIndex)

文档: 从此列表中删除索引介于fromIndex(包含)和inclusive(不含)之间的所有元素。 将任何后续元素向左移(减少索引)。 这个调用通过(toIndex - fromIndex)元素来缩短列表。 (如果toIndex == fromIndex,则此操作无效。)

这个方法是通过列表及其子类的clear操作来调用的。重写这个方法利用列表内部实现的优势,可以大大提高列表和子类的clear操作性能。

实现要求:这个实现获取一个列表迭代器,它位于fromIndex之前,并重复调用ListIterator.remove和ListIterator.next方法,直到整个范围被删除。 注意:如果ListIterator.remove需要线性时间,则此实现需要平方级时间。

参数:
fromIndex 要移除的第一个元素的索引
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)的可重写的方法。 这样做,你将创建一个合理安全的子类。 重写一个方法不会影响任何其他方法的行为。

你可以机械地消除类的自我使用的重写方法,而不会改变其行为。 将每个可重写的方法的主体移动到一个私有的“帮助器方法”,并让每个可重写的方法调用其私有的帮助器方法。 然后用直接调用可重写方法的专用帮助器方法来替换每个自用的可重写方法。

你可以机械地消除类的自用的重写方法,而不会改变其行为。 将每个可重写的方法的主体移到一个私有的“辅助方法(helper method)”,并让每个可重写的方法调用其私有的辅助方法。 然后用直接调用可重写方法的专用辅助方法来替换每个自用的可重写方法。

总之,设计一个继承类是一件很辛苦的事情。 你必须文档说明所有的自用模式,一旦你文档说明了它们,必须承诺为他们的整个生命周期。 如果你不这样做,子类可能会依赖于父类的实现细节,并且如果父类的实现发生改变,子类可能会损坏。 为了允许其他人编写高效的子类,可能还需要导出一个或多个受保护的方法。 除非你知道有一个真正的子类需要,否则你可能最好是通过声明你的类为final禁止继承,或者确保没有可访问的构造方法。


上一篇 HIPI 介绍

Content