类和接口
类和接口
类和接口是Java程序设计语言的核心,也是Java语言的基本抽象单元
使类和成员的可访问性最小化
在公有类中使用访问方法而非公有域
使可变性最小化
复合优先于继承
继承(inheritance)是实现代码重用的有力手段,但它并非永远是完成这项工作的最佳工具。
在包的内部使用继承是非常安全的,在那里,子类和父类的实现都处在同一个程序员的控制之下。
对于专门为了继承而设计、并且具有很好的文档说明的类来说,使用继承也是非常安全的。
继承打破了封装性,子类(subclass)依赖于其超类(superclass)中特定功能的实现细节。超类的实现有可能会随着发行版本的不同而有所变化,如果真的发生变化了,子类可能会遭到破坏,即使子类的代码完全没有改变。因而,子类必须要跟随其父类的更新而演变,除非父类是专门为了扩展而设计的,并且具有很好的文档说明。
示例
我们假设有一个程序使用了HashSet,为了调优改程序的性能,需要查询HashSet,看一看自从它被创建以来曾经添加了多少个元素,为了提供这种功能,我们的编写一个mAddCount变量,它记录下试图插入的元素数量,并针对该计数值导出一个访问方法。
chapter4/item16/InstrumentedHashSet.java
我们期望getAddCount方法将返回3,但是它实际上返回的是6。哪里出错了?
我们只要去掉了被覆盖的addAll方法,就可以修复这个bug,虽然这样得到的类可以正常工作,但是它的功能正确性则需要依赖于这样的事实:HashSet的addAll方法是在它的add方法上实现的。这种“自用性(self-use)”是实现细节,不是承诺,不能保证在在Java平台的所有实现中都保持不变,不能保证随着发行的版本的不同而不发生变化。因此,这样得到的InstrumentHashSet类将是非常脆弱的。
稍微好一点的做法是,覆盖addAll放法来遍历指定的集合,为每个元素调用一次add方法,这样做可以保证得到一个正确的结果,因为父类的addAll实现将不会再被调用到。然而这项技术并没有解决所有的问题,它相当于重新实现了父类的方法,这些父类的方法可能是自用的(self-use),也可能不是自用的,这种方法写起来会很困难,也非常耗时,并且容易出错。此外,这种方法不总是可行的,因为无法访问子类的私有域。
示例
幸运的是,有一种方法可以避免这个问题,不用扩展现有的类,而是在新的类中增加一个私有域,它引用现有类的一个实例。
chapter4/item16/InstrumentedSet.java
这种设计被称为“复合(composition)”,因为现有类变成了新类的一个组件。新类中的每个实例方法都可以调用被更包含的现有类实例中对应的方法,并返回它的结果,这被称为“转发(forwarding)”,新类中的方法被称为“转发方法(forwarding method)”。这样得到的类将会比较稳固,它不依赖于现有类的实现细节,即使现有类添加新的方法,也不会影响新的类。
除了获得健壮性外,这种设计也带来了格外的灵活性。InstrumentedSet类的构造器参数是Set类型,从本质上讲,这个类把一个Set转变成另一个Set,同时增加了计数功能,前面提到的基于继承的方式只适用于单个具体的类,并且对于父类中所支持的构造器都要要求有一个单独的个构造器。
InstrumentedSet类甚至可以用来临时替换一个原本没有计数特性的Set实例:
static void walk(Set<Dog> dogs) {
InstrumentedSet<Dog> iDogs = new InstrumentedSet<Dog>(dogs);
// ... Within this method use iDog instead of dogs
}
因为每一个InstrumentedSet实例都会把另一个Set实例包装起来,所以InstrumentedSet类被称为包装类(wrapper class),这也正是Decorator模式,因为InstrumentedSet类对一个集合进行了修饰,为它增加了计数特性。
有些人担心转发方法调用所带来的性能影响,或者包装的对象导致的内存占用,在实践中,这两者都不会造成很大的影响。
只有当子类是真正是父类的子类型(subtype)时,才会适合用继承。换句话说,对于两个类A和B,只有当两者之间确实存在“is-a”关系的时候,类B才应该扩展类A。如果你打算让类B扩展类A,就应该问问自己:每个B确实也是A吗?
在Java平台类库中,有许多明显违反了这条原则的地方。例如,栈(Stack)并不是向量(Vector),所有Stack不应该扩展Vector。同样地,属性列表也不是散列表,所有Properties不应该扩展HashTable。在这两种情况下,复合模式才是恰当的。
如果在适合使用复合的地方使用了继承,则会不必要地暴露一些实现细节。这样得到的API会把你限制在原始的实现上,永远限定了类的性能,更为严重的是,由于暴露了内部的细节,使得客户端就有可能直接访问这些具体的内部细节,这样至少会导致语义上的混淆。例如,如果p指向了Properties实例,那么p.getProperty(key)就有可能与p.get(key)产生的结果不同:前者考虑了默认的属性表,而后者是继承HashTable的,而它并没有考虑默认属性表。更为严重的是,客户有可能直接修改父类,从而破坏了子类的约束条件。在Properties的示例中,设计者的目的是只允许字符串作为key和value,但是直接访问底层的HashTable就可以违反这种约束条件。一旦违反了这个约束条件,就不可能再使用Properites的load和store这样的API了。等到发现这个问题时,要修改它为时已晚,因为客户端依赖于使用了非字符串的key和value了。
要么为继承而设计就,并提供文档说明,要么就禁止继承
首先,该类的文档必须精确地描述覆盖每个方法所带来的影响。对于每个公有的或者受保护的方法或者构造器,它的文档必须指明该方法或者构造器调用了哪些可覆盖的方法,是以什么顺序调用的,每个调用的结果又是如何影响后续的处理过程的。
更为一般地,类必须在文档中说明,在哪些情况下它会调用可覆盖的方法,所谓可覆盖(overriddable)的方法,是指非final的,公有的或者受保护的。例如,后台的线程或者初始化器(initializer)可能会调用这样的方法。
按照惯例,如果方法调用了可覆盖的方法,在它的文档注释的末尾应该包含关于这些调用的描述信息。这段描述信息要以"This implementation"为开头描述,它不应该被认为是在表明该行为可能会随着版本的变迁而改变,这也意味着这段描述关注该方法的内部工作情况。
示例
java.util.AbstractCollection的remove()方法注释
该文档清楚地说明了,覆盖iterator方法会影响remove方法的行为,而且,它确切地描述了iterator()方法返回的Iterator对象的行为将会怎么样影响remove()方法。与此相反的是,在“复合优先于继承”的情形中,在子类化HashSet的时候,并无法说明覆盖add()方法是否会影响addAll()方法的行为。
好的API文档应该描述一个给定的方法做了什么工作,而不是描述它是如何做到的。