美文网首页
Effective Java 3rd 条目19 为继承设计和文档

Effective Java 3rd 条目19 为继承设计和文档

作者: tigershin | 来源:发表于2018-02-13 22:42 被阅读62次

    条目18使你意识到子类化一个“不熟悉的”类的危险性,该类不是为继承设计和文档化。那么一个类是为继承设计和文档化,是什么意思呢?

    首先,类必须清晰地文档化覆写任何方法的影响。换句话说,类必须文档化可覆写方法的它的自用性(self-use)。对于每个公开或者受保护的方法,文档必须表明这方法调用了哪个可覆写方法、是什么顺序,而且每个调用的结果怎么影响了后续处理。(对于可覆写的(overridable),我们的意思是,非final的而且公开或者受保护的。)更通常地,一个类必须文档化可能调用一个可覆写方法的任何情形。例如,调用可能来自于后台线程或者静态初始化器。

    一个调用可覆写方法的方法,在它文档描述的最后,包含了这些调用的描述。这个描述是在规范的一个特殊部分,标记为“实现要求”,这通常由javadoc的@implSpec标签产生。这个部分描述了这个方法的内部运行。以下是一个例子,从java.util.AbstractCollection规范中拷贝来的:

    java public boolean remove(Object o)

    从这个集合中移除特定元素的单个实例,如果它是存在的(可选操作)。更正式地讲,移除一个满足Objects.equals(o, e)的元素e,如果这个集合包含一个或者多个这样的元素。返回真,如果这个集合包含了特定的元素(或者相当于如果这个集合由于这个调用而改变了)。

    实现要求:这个实现对集合迭代,寻找特定元素。如果它发现了这个元素,那么他使用迭代的remove方法从这个集合中移除这个元素。注意到,如果这个集合的iterator方法返回的迭代器,没有实现remove方法而且这个集合包含了指定对象,那么这个实现抛出了UnsupportedOperationException。

    这个文档毫无疑问表明,覆写iterator方法将会影响remove方法的行为。而且精确地描述了Iterator方法返回的iterator的行为怎么样影响了remove方法的行为。这与条目18中情形相反,程序员仅仅子类化HashSet,不会表明覆写add方法是否会影响addAll方法的行为。

    但是这并不违反这个格言:一个好的API文档应该描述一个给定的方法是做什么的而不是它怎么干的?是的,确实如此!这是继承违反了封装这个事实的不幸后果。为了文档化一个类以致它可以安全地子类化,你必须描述实现细节,否则不会被指明。

    Java8中添加了@implSpec标签,在Java9中用得比较多。这个标签应该默认开启,但是就像在Java9中,javadoc工具仍旧忽略了它,除非你传递命令行开关-tag "apiNote:a:API Note:"。

    为继承设计包含了超过仅仅自用文档化模式。为了让程序员有效率而没有过多痛苦地编写子类,一个类可能不得不,以明智地选出的受保护方法或者极少情况下受保护的域的方式,提供它内部构造的钩子。比如,考虑java.util.AbstractList的removeRange方法:

    protected void removeRange(int fromIndex, int toIndex)

    从这个列表中移除所有的元素,它的索引从fromIndex(包含)到toIndex(不包含)。移动后续元素到左边(减少它们的索引)。这个调用使得这个列表缩短了(toIndex - fromIndex)个元素。如果toIndex == fromIndex,这个操作没有任何效果。

    这个方法由这个列表和它的子列表上的clear操作调用。为了利用列表的内部实行,覆写这个方法可显著地改进列表和它的子列表上clear操作的性能。

    实行要求:这个实现获得一个在fromIndex之前放置的列表迭代器,而且重复地调用ListIterator.next,随后是ListIterator.remove,直至真个范围都被移除了。注意:如果ListIterator.remove要求线性时间,那么这个实现需要二次时间

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

    List实现的最终用户对这个方法是不感兴趣的。它仅仅是为了子类提供子列表上的快速clear方法变得容易。如果没有removeRange方法,当调用子列表上的clear方法,或者从新编写整个子列表(不是一个容易的任务)时,子类将会不得不用二次性能应付。

    那么,当你为继承设计一个类时,你怎么决定暴露哪些受保护成员呢?不幸的是,这没有灵丹妙药。你能做的最好方法是,努力思考,采用最好的猜测,然后通过编写子类测试它。你应该暴露尽可能少的受保护成员,因为每个成员都代表对实现细节的承诺。另外一方面,你不能暴露太少,因为缺少受保护成员可以导致一个类几乎不可用于继承。

    测试为继承而设计的类的唯一方式是编写子类。如果你忽略了一个关键的受保护成员,编写子类的尝试将会让这个忽略异常明显。相反地,如果编写多个子类而一个也没有使用受保护方法,那么你大概应该让它变成私有的。经验表明,三个子类测试一个可扩展类通常是足够的。这些子类的一个或者多个应该由其他人而不是超类作者编写。

    当你为一个可能广泛使用的类设计继承时,要认识到,你永久承诺了你文档化的自用模式和受保护方法和域的实现决定。这些承诺可能在后续发布中使得类改进性能或者功能是困难的或者是不可能的。所以,你必须在发布它之前通过编写子类测试你的类

    而且注意到,继承要求的特定文档使得通常文档变得凌乱,这个通常文档是为创建类的实例而且调用它们方法的程序员而设计的。在我编写的时候,几乎没有什么工具把普通API文档从实现子类的程序员感兴趣的信息分离出去。

    为了允许继承,一个类必须遵从再多几个限制。构造子必须不直接或者间接地调用可覆写方法。如果你违反了这个规则,会导致程序失败。超类构造子在子类构造子之前运行,所以在子类构造子运行之前将会调用超类的覆写方法。如果这个覆写方法依赖于子类构造子执行的任何初始化,那么这个方法不会像预期的行为。为了使得这个具体,以下是一个违反这个规则的类:

    public class Super { 
        // 已破坏 - 构造子调用了一个可覆写的方法 
        public Super() { 
            overrideMe(); 
        } 
        public void 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(); 
        }
    }
    

    你可能期待这个程序两次打印出instant,但是它只在第一次打印,因为在Sub构造子有机会初始化instant域之前Super调用了overrideMe。注意到,这个程序在两个不同的状态观察一个final域!同时注意到,如果overrideMe调用了instant上的任意方法,那么当Super构造子调用overrideMe时它将会抛出NullPointerException。这个程序不会抛出NullPointerException的唯一原因是,以实际情况来说,println方法容忍null参数。

    注意,对一个构造子来说,调用私有方法、final方法和静态方法是安全的,因为这些方法都不是可覆写的。

    当为继承设计时,Cloneable和Serializable接口提出了特殊的困难。一个为继承设计的类实现这两个接口之任何一个,通常都不是一个好主意,因为它们给想扩展类的程序员增加了巨大负担。然而,可以采取的特殊行动是,让子类实现这些接口而不会强制它们这么做。这些行动在条目13和条目86里面描述。

    如果你决定在为继承设计的类中实现Cloneable或者Serializable,你应该清楚,因为clone和readObject方法行为很像构造子,一些相似的限制也是适用的:clone和readObject都不应该直接或者间接地调用可覆写的方法。在readObject的情形,覆写方法将会在子类状态反系列化之前运行。在clone的情形,覆写方法将会在子类clone方法有机会确定clone状态之前运行。这两个情形之一出现,一个程序失败可能会跟随而来。在clone情形,失败可能损害原来的对象和克隆对象。比如,如果覆写方法假设他修改了对象深层结构的clone的拷贝,但是这个拷贝还没完成,那么这个失败会发生。

    最后,如果你决定在为继承设计的类中实现Serializable,而且这个类有readResolve或者writeReplace方法,你必须使得readResolve或者writeReplace方法是受保护的而不是私有的。如果这些方法是私有的,那么子类会默默地忽略它们。这是另外一个情形,为了允许继承,实现细节成为类API的一部分。

    到现在为止,为继承设计一个类需要巨大的努力而且在类上造成了很大的限制,这是明显的。这不是一个可以轻松承受的决定。有些情形,清楚地表明是在做正确事情,比如抽象类,包括接口的骨架实现(skeletal implementation)(条目20)。有另外的情形,清楚地表明是在做错误事情,比如不可变类(条目17)。

    但是普通的具体类怎么样呢?习惯上,它们不是final也不是为子类化而设计或者文档化的,但是事情状态是危险的。在这样的类中每次改变发生,扩展这个类的子类都有可能破坏。这不仅仅是一个理论问题,在修改一个非final具体类(这个类不是为继承而设计和文档化)的内部结构之后,收到子类化相关的错误报告,这不是不常见的。

    这个问题的最好解决方法是禁止类的子类化,这些类不是为安全子类化而设计和文档化的。有两种方式防止子类化,两者中比较容易的是声明类为final。替代方案是让所有构造子是私有的或者包私有的,而且添加公共静态工厂代替构造子。这个替代方案,提供了内部使用子类的灵活性,在条目17中讨论了。两个方案之一都是可接受的。

    这个建议可能有点争议,因为许多程序员已经习惯了为了添加功能(比如插桩、通知和同步或者限制功能)而子类化普通具体类。如果一个类实现了某个接口,这个接口规定了它的必要部分,比如Set、List或者Map,那么你对禁止子类化不会感到内疚。就像在条目18讨论的,包装类模式为增强功能提供了一个更优的继承替代方案。

    如果一个具体类没有实现一个标准接口,那么你由于禁止继承可能给某些程序员带来不方便。如果你感觉你必须从这样的类继承,一个合理方案是确保这个类从未调用它的任意可覆写方法,而且文档化这个事实。换句话说,完全消除这个类的可覆写方法的自用。如果这样做了,你将会创建一个相当安全子类化的类。覆写一个方法将永远不会影响任何其他方法的行为。

    你可以机械地消除一个类的可覆写方法的自用,而没有改变它的行为。把每个可覆写方法的主体移动到一个私有“辅助(helper)方法”,而且让每个可覆写方法调用它的私有辅助方法。然后用一个直接调用可覆写方法的私有辅助类代替一个可覆写方法的每个自用。

    总之,为继承设计一个类是艰难的工作。你必须文档化所有它的自用模式,而且一旦你文档化它们,你必须在这个类的生命周期内承诺它们。如果你未能这么做,那么子类可能变得依赖于超类的实现细节,而且如果超类的实现改变了,那么子类可能遭到破坏。为了让其他人编写有效率的子类,你可能也的不得不导出一个或者多个受保护方法。除非你知道有一个真实的子类需求,你大概最好以声明你的类为final或者确保没有可访问构造子的方式,禁止继承。

    相关文章

      网友评论

          本文标题:Effective Java 3rd 条目19 为继承设计和文档

          本文链接:https://www.haomeiwen.com/subject/ciestftx.html