第19项:要么为继承而设计,并提供文档,要么就禁止继承

  第18项提醒我们,对于不是为了继承而设计、并且没有文档说明的“外来”类进行子类化是多么危险。那么对于专门为了继承而设计并且具有良好文档说明的类而言,这又意味着什么呢?

  首先,该类的文档必须精确地描述覆盖每个方法所带来的影响。换句话说,该类必须有文档说明它可覆盖(overridable)的方法的自用性(self-use)。 对于每个公有的或受保护的方法或者构造器,它的文档必须指出方法调用哪些可覆盖的方法,以何种顺序以及每次调用的结果是如何影响后续处理的。(所谓的覆盖(overidable),是指非final的,公有的或受保护的)。更一般地,类必须在文档中说明,在哪些情况下它会调用可覆盖的方法。例如,后台的线程或者静态的初始化器(initializer)可能会调用这样的方法。

  如果方法调用了可覆盖的方法,在它的文档注释的末尾应该包含关于这些调用的描述信息。该描述位于规范的特殊部分,以这样的标记作为开头:“This implementation,(该实现。。。)”,由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.

  (如果这个集合中存在指定的元素,就从中删除该指定元素中的单个实例(这是项可选的操作)。更一般地,如果集合中包含一个或多个元素e满足表达式 Obiect.equals(o, e) ,就从集合中删除它们,如果这个集合中包含指定的元素,就返回true(如果调用最终改变了集合,也一样)。)

  实现要求:该实现遍历整个集合来查找指定的元素。如果它找到该元素,将会利用迭代器的remove方法将之从集合中删除。注意,如果由该集合的iterator方法返回的迭代器没有实现remove方法并且该集合包含这个特殊的对象,该实现就会抛出UnsupportedOperationException。)

  该文档清楚地说明了,覆盖iterator方法将会影响remove方法的行为。而且,它确切地描述了iterator方法返回的Iterator的行为将会怎样影响remove方法的行为。与此相反的是,在第18项的情形中,程序猿在子类化HashSet的时候,并无法说明覆盖add方法是否会影响addAll方法的行为。

  关于程序文档有句格言:好的API文档应该描述一个给定的方法做了什么工作,而不是描述它是如何做到的。那么,上面这种做法是否违背了这句格言呢?是的,它确实违背了!这正是继承破坏了封装性所带来的不幸后果。所以,为了设计一个类的文档,以便它能够被安全地子类化,你必须描述清楚那些有可能未指定的实现细节。

  @implSpec标签是在Java 8中添加的,而且在Java 9中用的很频繁。这个标签应该默认开启,但是在Java 9中,除非你传递命令行开关-tag “apoNote?️API Note:”,否则javadoc实用程序仍然会忽略它。

  为了继承而进行设计不仅仅涉及自用模式的文档设计。为了使程序猿能够编写出更加有效的子类,而无需承受不必要的痛苦,一个类必须通过某种形式提供适当的钩子(hook),以便能够进入它的内部工作流中,这种形式可以是精心选择的受保护的(protected)方法, 也可以是受保护的字段,后者比较少见。例如,考虑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.next followed 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(含)和toIndex(不含)之间的元素。将所有符合条件的元素移到左边(减小索引)。这一调用将从List中删除(toIndex-fromIndex)之间的元素。(如果toIndex==fromIndex,这项操作就无效。)

  这个方法是通过clear操作在这个列表及其子列表中调用的。覆盖这个方法来利用列表实现的内部信息,可以充分地改善这个列表及其子列表中的clear操作的性能。

  实现要求:这项实现获得了一个处在fromIndex之前的列表迭代器,并依次地重复调用ListIterator.remove和ListIterator.next,直到整个范围都被移除为止。注意:如果ListIterator.remove需要先行的时间,该实现就需要平方级的时间。

  参数:
    fromIndex 要移除的第一个元素的索引
    toIndex 要移除的最后一个元素之后的索引

  这个方法对于List实现的最终用户并没有意义。提供该方法的唯一目的在于,使子类更易于提供针对子列表(sublist)的快速clear方法。如果没有removeRange方法的情况下,当在子列表上调用clear方法,子类必须使用平方级的时间(quadratic performance)来完成它的工作。否则,就得重新编写整个subList机制————这可不是件容易的事情!

  因此,当你为了继承而设计类的时候,如何决定应该暴露哪些受保护的成员呢?遗憾的是,并没有神奇的法则可供你使用。你所能做到的最佳途径就是努力思考,发挥最好的想象,然后编写一些子类进行测试。你应该尽可能少地暴露受保护的成员,因为每个都代表了对实现细节的承诺。另一方面,你又不能暴露得太少,因为漏掉的受保护方法可能会导致这个类无法被真正用于继承。

  对于为了继承而设计的类,唯一的测试方法就是编写子类。 如果遗漏了关键的受保护的成员,尝试编写子类就会使遗漏所带来的痛苦变得更加明显。相反,如果编写了多个子类,并且无一使用受保护的成员,或许就应该把它做成私有的。经验表明,3个子类通常就足以测试一个可扩展的类。除了超类的创建者之外,都要编写一个或者多个这种子类。

  在为了继承而设计有可能被广泛使用的类时,必须要意识到,对于文档中所说明的自用模式(self-use pattern),以及对于其受保护的方法和字段中所隐含的实现策略,你实际上已经做出了永久的承诺。这些承诺使得你在后续的版本中提高这个类的性能或者增加新功能都变得非常困难,甚至不可能。因此,你必须在发布类之前先编写子类对类进行测试。

  还要注意,因继承而需要的特殊文档会打乱正常的文档信息,普通的文档被设计用来让程序猿可以创建该类的实例,并调用该类中给的方法。在编写本书之时,几乎还没有适当的工具或者注释规范,能够把“普通的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,因为overrideMe方法被Super构造器调用的时候,构造器Sub还没有机会初始化instant字段。注意,这个程序观察到的final字段处于两种不同的状态。还要注意,如果overrideMe已经调用了instant中的任何方法,当Super构造器调用overrideMe的时候,调用就会抛出NullPointerException异常,唯一的原因就在于println方法对于处理null参数有着特殊的规定。

  注意,在构造器中调用私有方法、final方法和静态方法是安全的,因为这三种方法没有一种是可以被覆盖的。

  在为了继承而设计类的时候,Cloneable和Serializable接口出现了特殊的困难。如果类是为了继承而被设计的,无论实现这其中的哪个接口通常都不是个好的主意,因为它们给扩展类的程序员带来了沉重的负担。然而,你还是可以采取一些特殊的手段,使得子类实现这些接口,无需强迫子类的程序猿去承担这些负担。第13项和第86项就讲述了这些特殊的手段。

  如果你决定在一个为了继承而设计的类中实现Cloneable或者Serializable接口,就应该意识到,因为clone和readObject方法在行为上非常类似于构造器,所以类似的限制规则也是适用的:无论是clone还是readObject,都不可以调用可覆盖的方法,不管是以直接还是间接的方式。 对于readObject方法,覆盖版本的方法将在子类的状态被反序列化(deserialized)之前先被运行。而对于clone方法,覆盖版本的方法则是在子类的clone方法有机会修正被克隆对象的状态之前先被运行。无论是哪种情形,都不可避免地将导致程序失败。在clone方法的情形中,这种失败可能会同时损害到原始的对象以及被克隆的对象本身。例如,如果覆盖版本的方法假设它正在修改对象深层结构的克隆对象的备份,就会发生这种情况,但是该备份还没有完成。

  最后,如果你决定在一个为了继承而设计的类中实现Serializable,并且该类有一个readResolve或者writeReplace方法,就必须使readResolve或者writeReplace成为受保护的方法,而不是私有方法。如果这些方法是私有的,那么子类将会不声不响地忽略掉这两个方法。这正是“为了允许继承,而把实现细节编程一个类的API的一部分”的另一种情形。

  到现在为止,应该很明显:为了继承而设计类,对这个类会有一些实质性的限制。 这并不是一个草率的决定。在某些情况下,这样的决定很明显是正确的,比如抽象类,包括接口的骨架实现(skeletal implementations)(第20项)。但是,在另外一些情况下,这样的决定却很明显是错误的,比如不可变的类(第17项)。

  但是,对于普通的具体类应该怎么办呢?它们即不是final的,也不是为了子类化而设计和编写文档的,所以这种状况很危险。每次对这种类进行修改,从这个类扩展得到的客户类就有可能遭到破坏。这不仅仅是个理论问题。对于一个并非为了继承而设计的非final具体类,在修改了它的内部实现之后,接收到与子类化相关的错误报告也并不少见。

  这个问题的最佳解决方案是,对于那些并非为了安全地进行子类化而设计和编写文档的类,要禁止子类化。 这里有两种办法可以禁止子类化。比较容易的办法是把这个类声明为final的。另一种办法是把所有的构造器都编程私有的,或者包级私有的,并增加一些公有的静态工厂来替代构造器。后一种办法在第17项中讨论过,它为内部使用子类提供了灵活性。这两种办法都是可以接受的。

  这条建议可能会引来争议,因为许多程序猿已经习惯于对普通的具体类进行子类化,以便增加新的功能设施,比如仪表功能(instrumentation,如计数显示等)、通知机制或者同步功能,或者为了限制原有类中的功能。如果实现了某个能够反映其本质的接口,比如Set、List或者Map,就不应该为了禁止子类化而感到后悔。第18项中介绍的包装类(wrapper class)模式提供了另一种更好的办法,让继承机制实现更多的功能。

  如果具体的类没有实现标准的接口,那么禁止继承可能会给有些程序猿带来不便。如果你认为必须允许从这样的类继承,一种合理的办法是确保这个类永远不会调用它的任何可覆盖的方法,并在文档中说明这一点。换句话说,完全消除这个类中可覆盖方法的自用特性。这样做之后,就可以创建“能够安全地进行子类化”的类。覆盖方法将永远也不会影响其他任何方法的行为。

  你可以机械地消除类中可覆盖方法的自用特性,而不改变它的行为。将每个可覆盖方法的代码体移到一个私有的“辅助方法(helper method)”中,并且让每个可覆盖的方法调用它的私有辅助方法。然后,用“直接调用可覆盖方法的私有辅助方法”来替代“可覆盖方法的每个自用调用”。

  总之,设计一个为了继承的类是一项艰苦的工作。对于它的所有自用模式,你必须提供文档,并且你一旦为它们提供了文档,您必须在类的生命周期中遵守它们。如果你不这样做,子类可能会依赖于超类的实现细节,并且如果超类的实现发生更改,则可能会打破这种依赖。要允许其他人编写有效的子类,您可能还必须导出一个或多个收保护的方法。除非你知道确实需要子类,否则最好通过将类声明为final或者确保没有可访问的构造函数来禁止继承。

第20项:接口优先于抽象类

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值