Effective Java笔记第三章类和接口
第四节复合优先于继承
1.继承是实现代码重用的有力手段,但是并非永远是完成这项工作的最佳工具。使用不当会导致软件变得很脆弱。在包的内部使用继承是非常安全的,在那里,子类和超类的实现都处于同一个程序员的控制之下。对于专门为了继承而设计,并且具有很好的文档说明的类来说,使用继承也是非常安全的。然而,对于普通的具体类进行跨越包边界的继承,则是非常危险的。本文的继承只实现继承(当一个类扩展另一个类时),并不适用于接口继承(当一个类实现一个接口时)。
2.与方法调用不同的是,继承打破了封装性。子类依赖于其超类中特定功能的实现细节。超类的实现有可能会随着发行版本的不同而有所变化,如果真的发生了变化,子类可能会遭到破坏,即使它的代码完全没有改变。
下面我们举个例子:
public class InstrumentedHashSet<E> extends HashSet<E> {
private int addCount = 0;
public InstrumentedHashSet() {
}
public InstrumentedHashSet(int initialCapacity, float loadFactor) {
super(initialCapacity, loadFactor);
}
@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
//因为继承了HashSet,覆写了HashSet类addAll和add,所以调用的是覆写HashSet类的add方法。
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
public static void main(String[] args) {
InstrumentedHashSet<String> is=new InstrumentedHashSet<String>();
is.addAll(Arrays.asList("Snap","Crackle","Pop"));
System.out.println(is.addCount);
}
}
输出:
6
这是因为在HashSet内部,addAll是基于add方法实现的。InstrumentedHashSet中的addAll方法会先给addCount加3,之后super.addAll©来调用HashSet的addAll,这个方法又会依次调用被InstrumentedHashSet覆盖的add方法,每个元素调用一次,这三次调用每次都让addCount加1,所以总共加了6。
3.如果超类在后续的发行版本中获得了一个新的方法,并且不幸的是,你给子类提供了一个签名相同但返回类型不同的方法,那么这样的子类将无法通过编译。如果给子类提供的方法带有与新的超类方法完全相同的签名和返回类型,那么实际上就覆盖了超类中的方法。
4.我们可以不用扩展现有的类,而是在新的类中增加一个私有域,它引用现有类的一个实例。这种设计被称作"复合",因为现有的类变成了新类的一个组件。新类中的每个实例方法都可以调用被包含的现有类实例中对应的方法,并返回他的结果。这被称为转发,新类中的方法被称为转发方法。这样得到的类将会非常稳固,他不依赖于现有类的实现细节。即使现有的类添加了新的方法,也不会影响新的类,下面我们用转发/复合的方法代替InstrumentedHashSet类。
类本身
public class InstrumentedSet<E> extends Forwarding<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();
//因为本质上是覆写了Forwarding包装类的addAll方法,之后调用的是传入的HashSet类的add方法,而不是InstrumentedSet类覆写的add方法。
//参数是哪个类调用哪个类的addAll方法,之后调用的是参数类的add方法,而不是InstrumentedSet类覆写的add方法。
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
public static void main(String[] args) {
InstrumentedSet<String> is=new InstrumentedSet<String>(new HashSet<String>());
is.addAll(Arrays.asList("Snap","Crackle","Pop"));
System.out.println(is.addCount);
}
}
可重用的转发类
public class Forwarding<E> implements Set<E> {
private final Set<E> s;
public Forwarding(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 obj) {
return s.equals(obj);
}
@Override
public int hashCode() {
return s.hashCode();
}
@Override
public String toString() {
return s.toString();
}
}
InstrumentedSet类实现了Set接口,并且拥有单个构造器,它的参数也是Set类型。从本质上讲,这个类把一个Set转变成了另一个Set,同时增加了计数功能。基于继承的方法只适用于单个具体的类,并且对于超类中所支持的每个构造器都要求有一个单独的构造器,与此不同的是,包装类可以被用来包装任何Set实现,并且可以结合任何先前存在的构造器一起工作。这就是装饰者模式。
5.包装类几乎没什么缺点。但是要注意,包装类不适合用在回调框架中,在回调框架中,对象把自身的引用传递给其他的对象,用于后续的调用。因为被包装起来的对象并不知道它外面的包装对象,所以他传递一个指向自身的引用,回调时避开了外面的包装对象。
6.只有当子类真正是超类的子类型时,才适合继承。换句话说,对于两个类A和B,只有当两者之间确实存在"is-a"关系时,类B才应该扩展类A。
7.如果在适合于使用复合的地方使用了继承,则会不必要的暴露实现细节。这样得到的API会把你限制在原始的实现上,永远的限定了类的性能。更为严重的是,由于暴露了内部的细节,客户端就有可能直接访问这些内部细节。这样至少会导致语义上的混淆。
8.简而言之,继承的功能非常强大,但是也存在诸多问题,因为它违背了封装原则。只有当子类和超类之间确实存在子类型关系时,使用继承才是恰当的。即使如此,如果子类和超类处于不同的包中,并且超类并不是为了继承而设计的,那么继承将会导致脆弱性。为了避免这种脆弱性,可以用复合和转发机制来代替继承,尤其是当存在适合的接口可以实现包装类的时候。包装类不仅比子类更加健壮,而且功能更加强大。