第17条:要么为继承而设计,并提供文档说明,要么就禁止继承

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

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

按惯例,如果方法调用到了可覆盖的方法,在它的文档注释末尾应该包含关于这些调用的描述信息。这段描述信息要以这样的句子开头:“This implementation.(该实现……)”。这样的句子不应该被认为是在表明该行为可能会随着版本的变迁而改变。它意味着这段描述关注该方法的内部工作情况。下面是个示例,摘自java.util.AbstractCollection的规范:

public boolean remove(Object o)

Removes a single instance of the specified element from this colletion, if it is present(optional operation). More formally, removes an element e such that (o==null ? e==nul : o.equals()), if the collection contains one or more such elements. Returns true if the collection contained the specified element (or equivalently, if the collection changed as a result of the call).

This implementation iterates over the collecting looking for the specified element. If it finds the elements, it removes the element from the collection using the iterators'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.

(如果这个集合中存在指定的元素,就从中删除该指定元素中的单个实例(这是项可选的操作)。更一般地,如果集合中包含一个或者多个这样的元素e,就从中删除这种元素,以便(o==null ? e==nul : o.equals())。如果集合中包含指定的元素就返回true(如果调用最终改变了集合,也一样)。

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

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

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

为了继承而进行的设计不仅仅设计自用模式的文档设计。为了使程序员能够编写出更加有效的子类,而无需承受不必要的痛苦,类必须通过某种形式提供适当的钩子(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 elements to the left (reduces their index). This call shortens the ArrayList 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 imporve the performance of the clear operation on this list and its sublists.

This implementation get a list iterator positioned before fromIndex and repeatedly calls ListIterator.next follows 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(不含)之间的元素。将所有符合条件的元素移到左边(减小索引)。这一调用将从ArrayList中删除(toIndex - fromIndex)之间的元素。(如果toIndex == fromIndex,这项操作就无效。)

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

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

参数:

fromIndex 要移除的第一个元素的索引

toIndex 要移除的最后一个元素之后的索引)

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

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

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

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

还要注意,因继承而需要的特殊文档会打乱正常的文档信息,普通的文档被设计用来让程序员可以创建该类的实例,并调用类中的方法。在编写本书之时,几乎还没有适当的工具或者注释规范,能够把“普通的API文档”与“专门针对实现子类的程序员的信息”分开来。

为了允许继承,类还必须遵守其他一些约束。构造器决不能调用可被覆盖的方法,无论是直接调用还是间接调用。如果违反了这条规则,很有可能导致程序失败。超类的构造器在子类的构造器之前运行,所以,子类中覆盖版本的方法将会在子类的构造器运行之前就先被调用。如果该覆盖版本的方法依赖于子类构造器所执行的任何初始化工作,该方法将不会如预期般地执行。为了更加直观地说明这一点,下面举个例子,其中有个类违反了这条规则:

public class Super {
    // Broken - constructor invokes an overridable method
    public Super() {
        overrideMe();
    }
    public void overrideMe() {
    }
}

下面的子类覆盖了方法overrideMeSuper唯一的构造器就错误地调用了这个方法:

public final class Sub extends Super {
    private final Date date; // Blank final, set by constructor

    Sub() {
      date = new Date();
    }

    // Overriding method invoked by superClass constructor
    @Override public void overrideMe() {
        System.out.println(date);
    }

    public static void main(String[] args) {
        Sub sub = new Sub();
        sub.overrideMe();
    }
}

你可能会期待这个程序打印出日期两次,但是它第一次打印出的是null,因为overrideMe方法被Super构造器调用的时候,构造器Sub还没有机会初始化date域。注意,这个程序观察到的final域处于两种不同的状态。还要注意,如果overrideMe已经调用了date中的任何方法,当Super构造器调用overrideMe的时候,调用就会抛出NullPointerException异常。如果改程序没有抛出NullPointerException异常,唯一的原因就在于println方法对于处理null参数有着特殊的规定。

在为了继承而设计类的时候,CloneableSerializable接口出现了特殊的困难。如果类是为了继承而被设计的,无论实现这其中的哪个接口通常都不是个好主意,因为它们把一些是执行的负担转嫁到了扩展这个类的程序员的身上。然而,你还是可以采取一些特殊的手段,使得子类实现这些接口,无需强迫子类的程序员去承受这些负担。第11条和第74条中讲述了这些特殊的手段。

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

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

到现在为止,应该很明显:为了继承而设计类,对这个类会有一些是实质性的限制。这并不是很轻松就可以承诺的决定。在某些情况下,这样的决定很明显是正确的,比如抽象类,包括接口的骨架实现(skeletal implementation)(见第18条)。但是,在另一些情况下,这样的决定却很明显是错误的,比如不可变的类(见第15条)。

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

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

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

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

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

results matching ""

    No results matching ""