目录
这两条的关系较强,核心都是继承,但是更强调继承的脆弱性,而且给出了继承的一个更优替代--复合。
继承的设计
继承是实现代码重用的有效手段,但是最为关键的是要关注类中可覆盖方法的自用,如果存在这种自用情况,那就要提供详细的文档来向调用者说明,要么就完全消除这种情况。那什么是可覆盖方法的自用,它又会给继承带来什么问题呢?我们可以通过一个例子来说明。
worker类有run、getup和gotowork三个可覆盖方法,其中run和getup可以作为gotowork的自用类,disabled类继承了worker的特性,但是由于无法跑步便通过覆盖禁用了run方法,这就导致所继承的gotowork方法发生了异常:
//worker类
public class worker {
private String name;
public worker(String name){
this.name = name;
}
public void gotoWork(){
getup();
run();
System.out.println(this.name+"is working now");
}
public void run(){
System.out.println(this.name+"is running");
}
public void getup(){
System.out.println(this.name+"has woken up");
}
}
//disabled类
public class disabled extends worker {
public disabled(String name){
super(name);
}
@Override
public void run(){
throw new RuntimeException("I cannot run");
}
}
//客户端程序
public class Application {
public static void main(String[] args) {
disabled tom = new disabled("Tom");
tom.gotoWork();
}
}
结果在调用gotowork的时候发生了异常:
(base) MacBook-Pro:test5 $ java test5/Application
Tom has woken up
Exception in thread "main" java.lang.RuntimeException: I cannot run
at test5.disabled.run(disabled.java:11)
at test5.worker.gotoWork(worker.java:11)
at test5.Application.main(Application.java:7)
这里从调用者的视角来看,由于没有文档说明gotowork的实现细节,所以默认是直接继承worker的实现,但实际上gotowork自用了worker的run方法,子类对其的禁用会直接导致所继承gotowork方法的异常。所以,作者强调除非可以保证可覆盖方法完全不自用,否则一定要对于有自用场景的可覆盖方法做出说明,那么需要如何说明才算具体呢?
对用于继承的类可覆盖方法的说明
用于继承的类必须要对于可覆盖方法的自用性进行详细说明,必须要指明:
- 该方法调用了哪些可覆盖的方法
- 调用的顺序和过程是什么样的
- 每一个调用的结果又是如何影响后续处理过程的
下面截一个Oracle关于Java.util.AbstractCollection类中remove方法的规范解释:
其中的implementation一段就指出了其依赖于iterator的remove方法来实现,如果其没有被实现将会抛出异常。
被继承类还需要遵循的约束
构造器不能调用可被覆盖的方法:一旦子类覆盖了有关方法可能会导致构造函数运行失败,
类构造器(比如Cloneable接口的clone方法和Serializable接口的readObject方法)也不能调用可被覆盖的方法:这些问题与第一条类似,但是clone方法调用失败会损害被克隆对象本身,所以会有更严重的问题。
如何对继承类进行测试
测试继承类的最佳方式就是通过子类(一般编写3个即可),如果遗漏了关键的受保护成员,尝试编写子类就会使得遗漏所带来的痛苦变得更加明显。同时,如果编写多个子类并没有使用到受保护的成员,或许就应该把它作为私有的。总之,在子类构建的实践中可以对于每一个方法的共享范围有一个深刻的印象。
如何禁止继承
继承有这样大的风险,所以对于不是专门为继承设计的类就尽量设置为禁止继承,主要有两种方法:
- 类设计为final
- 所有的构造器私有,通过静态工厂方法来构造类实例。
复合的设计
从前文中已经可以发现继承虽然是一种实现代码重用的有效手段,但是有着很明显的缺陷。
破坏封装:要使用继承就必须对于类中可覆盖方法做详细的了解,这在一定程度上破坏了类的封装
父类与子类的强耦合性:部分子类方法强依赖父类,如果父类方法发生变化则会破坏整个子类
这里通过HashSet类的例子来说,HashSet类是Java集合框架中的一个实现类它实现了Set接口,其中addAll方法的实现调用了它的另一个方法add,这里就体现了add方法的自用性。当我们需要有一个新类继承HashSet同时要加入对于新增元素的计数功能时就会出现问题:
//CountHashSet类,继承自HashSet
import java.util.Collection;
import java.util.HashSet;
public class CountHashSet<T> extends HashSet<T>{
private int count;
public CountHashSet(){
super();
count = 0;
}
@Override
public boolean add(T o){
count++;
return super.add(o);
}
@Override
public boolean addAll(Collection<? extends T> sets){
count += sets.size();
return super.addAll(sets);
}
public int getCount(){
return count;
}
}
//Application.java
import java.util.ArrayList;
import test6.CountHashSet;
public class Application {
public static void main(String[] args) {
CountHashSet sets = new CountHashSet();
ArrayList list = new ArrayList();
list.add(1);
list.add(2);
list.add(3);
sets.addAll(list);
System.out.println(sets.getCount());
}
}
这里客户端预期计数为3,但是实际输出为6,因为自用的add方法会进行重复计数。
所以有自用性方法的类就要仔细去分析它的实现规则,来判断是否符合继承的要求,这就破坏了封装,同时子类方法的实现强依赖与父类,这也体现了强耦合性。
什么是复合
复合本身也是一种实现代码重用的方式,是将需要引入的类(引入类)以类实例的方式作为另一个类(目标类)的私有域,实现将引入类的特性添加到目标类上。这其中有一种特殊的复合方式称为转发,即目标类的方法直接包装引入类的对应方法,并且返回引入类方法原本的返回值(就是单纯做一个包装),而把引入类所有方法都进行包装的类成为引入类的转发类。
举个例子,上面的CountHashSet类如果以复合的方法来实现,可以简单的把Set接口包装成一个转发类:
import java.util.Collection;
import java.util.Iterator;
import java.util.Set;
public class ForwardingSet<T> implements Set<T> {
private final Set<T> s;
public ForwardingSet(Set<T> s){
this.s = s;
}
@Override
public int size() {
return s.size();
}
@Override
public boolean isEmpty() {
return s.isEmpty();
}
@Override
public boolean contains(Object o) {
return s.contains(o);
}
@Override
public Iterator<T> iterator() {
return s.iterator();
}
@Override
public Object[] toArray() {
return s.toArray();
}
@Override
public <T> T[] toArray(T[] a) {
return s.toArray(a);
}
@Override
public boolean add(T e) {
return s.add(e);
}
@Override
public boolean remove(Object o) {
return s.remove(o);
}
@Override
public boolean containsAll(Collection<?> c) {
return s.containsAll(c);
}
@Override
public boolean addAll(Collection<? extends T> c) {
return s.addAll(c);
}
@Override
public boolean retainAll(Collection<?> c) {
return s.retainAll(c);
}
@Override
public boolean removeAll(Collection<?> c) {
return s.removeAll(c);
}
@Override
public void clear() {
s.clear();
}
}
可以看到Set接口的所有方法都被ForwardingSet的方法实现了转发(单纯包装)。如果再想实现计数等扩展功能就可以基于转发类来实现:
import java.util.Collection;
import java.util.Set;
import InstruSet.ForwardingSet;
public class CustomInstrumentedSet<T> extends ForwardingSet<T> {
private int addCount=0;
public CustomInstrumentedSet(Set<T> s){
super(s);
}
@Override
public boolean add(T t){
addCount++;
return super.add(t);
}
@Override
public boolean addAll(Collection<? extends T> set){
addCount += set.size();
return super.addAll(set);
}
public int showAddCount(){
return addCount;
}
}
这样实现首先将Set接口与CustomInstrumentedSet实现类解耦开了,无论在客户端中被包装的是哪一种Set接口的实现类(比如HashSet),都与CustomInstrumentedSet无关。同时也加强了被包装类的封装性,因为CustomInstrumentedSet的设计者不再需要去关注每种可覆盖方法的实现逻辑了。
复合的缺点
复合唯一的缺点是涉及到回调机制的时候,当引入类方法有返回其自身的功能实现时,就只能返回引入类实例自身,而无法返回包装类,因为引入类并不知道它包装类是谁,这就是复合的SELF问题。