Tips
《Effective Java, Third Edition》一书英文版已经出版,这本书的第二版想必不少人都读过,号称Java四大名著之一,不过第二版2009年出版,到如今已经将近8年的时间,但随着Java 6,7,8,甚至9的发布,Java语言发生了深入的变化。
在这里第一时间翻译成中文版。供你们学习分享之用。java
继承是实现代码重用的有效方式,但并不老是最好的工具。使用不当,会致使脆弱的软件。 在包中使用继承是安全的,其中子类和父类的实现都在同一个程序员的控制之下。对应专门为了继承而设计的,而且有文档说明的类来讲(条目 19),使用继承也是安全的。 然而,从普通的具体类跨越包级边界继承,是危险的。 提醒一下,本书使用“继承”一词来表示实现继承(当一个类继承另外一个类时)。 在这个项目中讨论的问题不适用于接口继承(当类实现接口或当接口继承另外一个接口时)。程序员
与方法调用不一样,继承打破了封装[Snyder86]。 换句话说,一个子类依赖于其父类的实现细节来保证其正确的功能。 父类的实现可能会从发布版本不断变化,若是是这样,子类可能会被破坏,即便它的代码没有任何改变。 所以,一个子类必须与其超类一块儿更新而变化,除非父类的做者为了继承的目的而专门设计它,并对应有文档的说明。安全
为了具体说明,假设有一个使用HashSet
的程序。 为了调整程序的性能,须要查询HashSe
,从建立它以后已经添加了多少个元素(不要和当前的元素数量混淆,当元素被删除时数量也会降低)。 为了提供这个功能,编写了一个HashSet
变体,它保留了尝试元素插入的数量,并导出了这个插入数量的一个访问方法。 HashSet
类包含两个添加元素的方法,分别是add
和addAll
,因此咱们重写这两个方法:app
// Broken - Inappropriate use of inheritance! public class InstrumentedHashSet<E> extends HashSet<E> { // The number of attempted element insertions private int addCount = 0; public InstrumentedHashSet() { } public InstrumentedHashSet(int initCap, float loadFactor) { super(initCap, loadFactor); } @Override public boolean add(E e) { addCount++; return super.add(e); } @Override public boolean addAll(Collection<? extends E> c) { addCount += c.size(); return super.addAll(c); } public int getAddCount() { return addCount; } }
这个类看起来很合理,可是不能正常工做。 假设建立一个实例并使用addAll
方法添加三个元素。 顺便提一句,请注意,下面代码使用在Java 9中添加的静态工厂方法List.of
来建立一个列表;若是使用的是早期版本,请改成使用Arrays.asList
:框架
InstrumentedHashSet<String> s = new InstrumentedHashSet<>(); s.addAll(List.of("Snap", "Crackle", "Pop"));
咱们指望getAddCount
方法返回的结果是3,但实际上返回了6。哪里出来问题?在HashSet
内部,addAll
方法是基于它的add
方法来实现的,即便HashSet
文档中没有指名其实现细节,倒也是合理的。InstrumentedHashSet
中的addAll
方法首先给addCount
属性设置为3,而后使用super.addAll
方法调用了HashSet
的addAll
实现。而后反过来又调用在InstrumentedHashSet
类中重写的add
方法,每一个元素调用一次。这三次调用又分别给addCount
加1,因此,一共增长了6:经过addAll
方法每一个增长的元素都被计算了两次。ide
咱们能够经过消除addAll
方法的重写来“修复”子类。 尽管生成的类能够正常工做,可是它依赖于它的正确方法,由于HashSet
的addAll
方法是在其add
方法之上实现的。 这个“自我使用(self-use)”是一个实现细节,并不保证在Java平台的全部实现中均可以适用,而且能够随发布版本而变化。 所以,产生的InstrumentedHashSet
类是脆弱的。工具
稍微好一点的作法是,重写addAll
方法遍历指定集合,为每一个元素调用add
方法一次。 无论HashSet
的addAll
方法是否在其add
方法上实现,都会保证正确的结果,由于HashSet
的addAll
实现将再也不被调用。然而,这种技术并不能解决全部的问题。 这至关于从新实现了父类方法,这样的方法可能不能肯定究竟是否时自用(self-use)的,实现起来也是困难的,耗时的,容易出错的,而且可能会下降性能。 此外,这种方式并不能老是奏效,由于子类没法访问一些私有属性,因此有些方法就没法实现。性能
致使子类脆弱的一个相关缘由是,它们的父类在后续的发布版本中能够添加新的方法。假设一个程序的安全性依赖于这样一个事实:全部被插入到集中的元素都知足一个先决条件。能够经过对集合进行子类化,而后并重写全部添加元素的方法,以确保在添加每一个元素以前知足这个先决条件,来确保这一问题。若是在后续的版本中,父类没有新增添加元素的方法,那么这样作没有问题。可是,一旦父类增长了这样的新方法,则颇有肯能因为调用了未被重写的新方法,将非法的元素添加到子类的实例中。这不是个纯粹的理论问题。在把Hashtable
和Vector
类加入到Collections框架中的时候,就修复了几个相似性质的安全漏洞。学习
这两个问题都源于重写方法。 若是仅仅添加新的方法而且不要重写现有的方法,可能会认为继承一个类是安全的。 虽然这种扩展更为安全,但这并不是没有风险。 若是父类在后续版本中添加了一个新的方法,而且你不幸给了子类一个具备相同签名和不一样返回类型的方法,那么你的子类编译失败[JLS,8.4.8.3]。 若是已经为子类提供了一个与新的父类方法具备相同签名和返回类型的方法,那么你如今正在重写它,所以将遇到前面所述的问题。 此外,你的方法是否会履行新的父类方法的约定,这是值得怀疑的,由于在你编写子类方法时,这个约定尚未写出来。this
幸运的是,有一种方法能够避免上述全部的问题。不要继承一个现有的类,而应该给你的新类增长一个私有属性,该属性是 现有类的实例引用,这种设计被称为组合(composition),由于现有的类成为新类的组成部分。新类中的每一个实例方法调用现有类的包含实例上的相应方法并返回结果。这被称为转发(forwarding),而新类中的方法被称为转发方法。由此产生的类将坚如磐石,不依赖于现有类的实现细节。即便将新的方法添加到现有的类中,也不会对新类产生影响。为了具体说用,下面代码使用组合和转发方法替代InstrumentedHashSet
类。请注意,实现分为两部分,类自己和一个可重用的转发类,其中包含全部的转发方法,没有别的方法:
// Reusable forwarding class import java.util.Collection; import java.util.Iterator; import java.util.Set; public class ForwardingSet<E> implements Set<E> { private final Set<E> s; public ForwardingSet(Set<E> s) { this.s = s; } public void clear() { s.clear(); } public boolean contains(Object o) { return s.contains(o); } public boolean isEmpty() { return s.isEmpty(); } public int size() { return s.size(); } public Iterator<E> iterator() { return s.iterator(); } public boolean add(E e) { return s.add(e); } public boolean remove(Object o) { return s.remove(o); } public boolean containsAll(Collection<?> c) { return s.containsAll(c); } public boolean addAll(Collection<? extends E> c) { return s.addAll(c); } public boolean removeAll(Collection<?> c) { return s.removeAll(c); } public boolean retainAll(Collection<?> c) { return s.retainAll(c); } public Object[] toArray() { return s.toArray(); } public <T> T[] toArray(T[] a) { return s.toArray(a); } @Override public boolean equals(Object o) { return s.equals(o); } @Override public int hashCode() { return s.hashCode(); } @Override public String toString() { return s.toString(); } }
// Wrapper class - uses composition in place of inheritance import java.util.Collection; import java.util.Set; public class InstrumentedSet<E> extends ForwardingSet<E> { private int addCount = 0; public InstrumentedSet(Set<E> s) { super(s); } @Override public boolean add(E e) { addCount++; return super.add(e); } @Override public boolean addAll(Collection<? extends E> c) { addCount += c.size(); return super.addAll(c); } public int getAddCount() { return addCount; } }
InstrumentedSet
类的设计是经过存在的Set接口来实现的,该接口包含HashSet
类的功能特性。除了功能强大,这个设计是很是灵活的。InstrumentedSet
类实现了Set接口,并有一个构造方法,其参数也是Set类型的。本质上,这个类把Set
转换为另外一个类型Set
, 同时添加了计数的功能。与基于继承的方法不一样,该方法仅适用于单个具体类,而且父类中每一个须要支持构造方法,提供单独的构造方法,因此可使用包装类来包装任何Set
实现,而且能够与任何预先存在的构造方法结合使用:
Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp)); Set<E> s = new InstrumentedSet<>(new HashSet<>(INIT_CAPACITY));
InstrumentedSet
类甚至能够用于临时替换没有计数功能下使用的集合实例:
static void walk(Set<Dog> dogs) { InstrumentedSet<Dog> iDogs = new InstrumentedSet<>(dogs); ... // Within this method use iDogs instead of dogs }
InstrumentedSet
类被称为包装类,由于每一个InstrumentedSet
实例都包含(“包装”)另外一个Set
实例。 这也被称为装饰器模式[Gamma95],由于InstrumentedSet
类经过添加计数功能来“装饰”一个集合。 有时组合和转发的结合被不精确地地称为委托(delegation)。 从技术上讲,除非包装对象把自身传递给被包装对象,不然不是委托[Lieberman86;Gamma95]。
包装类的缺点不多。 一个警告是包装类不适合在回调框架(callback frameworks)中使用,其中对象将自我引用传递给其余对象以用于后续调用(“回调”)。 由于一个被包装的对象不知道它外面的包装对象,因此它传递一个指向自身的引用(this),回调时并不记得外面的包装对象。 这被称为SELF问题[Lieberman86]。 有些人担忧转发方法调用的性能影响,以及包装对象对内存占用。 二者在实践中都没有太大的影响。 编写转发方法有些繁琐,可是只需为每一个接口编写一次可重用的转发类,而且提供转发类。 例如,Guava为全部的Collection接口提供转发类[Guava]。
只有在子类真的是父类的子类型的状况下,继承才是合适的。 换句话说,只有在两个类之间存在“is-a”关系的状况下,B类才能继承A类。 若是你试图让B类继承A类时,问本身这个问题:每一个B都是A吗? 若是你不能如实回答这个问题,那么B就不该该继承A。若是答案是否认的,那么B一般包含一个A的私有实例,而且暴露一个不一样的API:A不是B的重要部分 ,只是其实现细节。
在Java平台类库中有一些明显的违反这个原则的状况。 例如,stacks实例并非vector实例,因此Stack
类不该该继承Vector
类。 一样,一个属性列表不是一个哈希表,因此Properties
不该该继承Hashtable
类。 在这两种状况下,组合方式更可取。
若是在合适组合的地方使用继承,则会没必要要地公开实现细节。由此产生的API将与原始实现联系在一块儿,永远限制类的性能。更严重的是,经过暴露其内部,客户端能够直接访问它们。至少,它可能致使混淆语义。例如,属性p指向Properties
实例,那么 p.getProperty(key)
和p.get(key)
就有可能返回不一样的结果:前者考虑了默认的属性表,然后者是继承Hashtable
的,它则没有考虑默认属性列表。最严重的是,客户端能够经过直接修改超父类来破坏子类的不变性。在Properties
类,设计者但愿只有字符串被容许做为键和值,但直接访问底层的Hashtable
容许违反这个不变性。一旦违反,就不能再使用属性API的其余部分(load
和store
方法)。在发现这个问题的时候,纠正这个问题为时已晚,由于客户端依赖于使用非字符串键和值了。
在决定使用继承来代替组合以前,你应该问本身最后一组问题。对于试图继承的类,它的API有没有缺陷呢? 若是有,你是否愿意将这些缺陷传播到你的类的API中?继承传播父类的API中的任何缺陷,而组合可让你设计一个隐藏这些缺陷的新API。
总之,继承是强大的,但它是有问题的,由于它违反封装。 只有在子类和父类之间存在真正的子类型关系时才适用。 即便如此,若是子类与父类不在同一个包中,而且父类不是为继承而设计的,继承可能会致使脆弱性。 为了不这种脆弱性,使用合成和转发代替继承,特别是若是存在一个合适的接口来实现包装类。 包装类不只比子类更健壮,并且更强大。