Effective Java笔记(19)要么设计继承并提供文档说明,要么禁止继承

        对于专门为了继承而设计并且具有良好文档说明的类而言,意味着什么呢?

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

如果方法调用到了可覆盖的方法,在它的文档注释的末尾应该包含关于这些调用的描述信息。这段描述信息是规范的一个特殊部分,写着:“Implementation Requirements”(实现要求..... ),它由Javadoc标签@implSpec生成。这段话描述了该方法的内部工作情况。下面举个例子,摘自java.util. AbstractCollection的规范:

public boolean remove(Object o)

(如果这个集合中存在指定的元素,就从中删除该指定元素中的单个实例(这是项可选的操作)。更广义地说,即如果集合中包含一个或者多个这样的元素e,就从中删除掉一个,如objects.equals(o,e)。如果集合中包含指定的元素,就返回true(如果调用的结果改变了集合,也是y一样)。
实现要求:该实现遍历整个集合来查找指定的元素。如果它找到该元素,将会利用迭代器的remove方法将之从集合中删除。注意,如果由该集合的iterator方法返回的迭代器没有实现remove方法,该实现就会抛出UnsupportedOperationException。)

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

        关于程序文档有句格言:好的API文档应该描述一个给定的方法做了什么工作,而不是描述它是如何做到的。那么,上面这种做法是否违背了这句格言呢?是的,它确实违背了!这正是继承破坏了封装性所带来的不幸后果。所以,为了设计一个类的文档,以便它能够被安全地子类化,你必须描述清楚那些有可能未定义的实现细节。@implSpec标签是在Java 8中增加的,在Java9中得到了广泛应用。这个标签应该是默认可用的,但是到Java 9, Javadoc 工具仍然把它忽略,除非传人命令行参数: -tag"apiNote:a:API Note:”。

为了继承而进行的设计不仅仅涉及自用模式的文档设计。为了使程序员能够编写出更加有效的子类,而无须承受不必要的痛苦,类必须以精心挑选的受保护的( protected )方法的形式,提供适当的钩子(hook),以便进入其内部工作中。这种形式也可以是罕见的实例,或者受保护的域。例如,以java.util.AbstractList中的removeRange方法为例:

protected void removeRange(int fromIndex, int toIndex)

        (从列表中删除所有索引处于fromIndex (含)和toIndex (不含)之间的元素。将所有符合条件的元素移到左边(减小它们索引)。这一调用将从ArrayList中删除从toIndex到fromIndex之间的元素。(如果toIndex == fromIndex,这项操作就无效。)

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

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

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

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

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

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

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

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

public class Super {

    public Super() {
        overrideMe() ;
    }
    public void overrideMe() {
    }
}

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

public final class Sub extends Super {

    private final Instant instant;
    Sub() {
        instant = Instant.now() ;
    }

    @Override 
    public void overrideMe() {
        System.out.println(instant);
    }
    public static void main(String[] args) {
        Sub sub = new Sub();
        sub.overrideMe();
    }
}

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

        注意,通过构造器调用私有的方法、final 方法和静态方法是安全的,这些都不是可以被覆盖的方法。

        在为了继承而设计类的时候,Cloneable 和Serializable接口出现了特殊的困难。如果类是为了继承而设计的,无论实现这其中的哪个接口通常都不是个好主意,因为它们把一些实质性的负担转嫁到了扩展这个类的程序员身上。然而,你还是可以采取一些特殊的手段,允许子类实现这些接口,无须强迫子类的程序员去承受这些负担。

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

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

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

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

        这个问题的最佳解决方案是,对于那些并非为了安全地进行子类化而设计和编写文档的类,要禁止子类化。有两种办法可以禁止子类化。比较容易的办法是把这个类声明为final的。另一种办法是把所有的构造器都变成私有的,或者包级私有的,并增加一些公有的静态工厂来替代构造器。这两种办法都是可以接受的。这条建议可能会引来争议,因为许多程序员已经习惯于对普通的具体类进行子类化,以便增加新的功能设施,比如仪表功能( 如计数显示等)、通知机制或者同步功能,或者为了限制原有类中的功能。如果类实现了某个能够反映其本质的接口,比如Set、List或者Map,就不应该为了禁止子类化而感到后悔。第18条中介绍的包装类( wrapper class)模式
还提供了另一种更好的办法,让继承机制实现更多的功能。

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

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

        简而言之,专门为了继承而设计类是一件很辛苦的工作。你必须建立文档说明其所有的自用模式,并且一旦建立了文档,在这个类的整个生命周期中都必须遵守。如果没有做到,子类就会依赖超类的实现细节,如果超类的实现发生了变化,它就有可能遭到破坏。为了允许其他人能编写出高效的子类,还你必须导出一个或者多个受保护的方法。除非知道真正需要子类,否则最好通过将类声明为final,或者确保没有可访问的构造器来禁止类被继承。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值