第16条:复合优先于继承

       继承是实现代码重用的有力手段,但并非总是最好的选择。因为对于普通的具体类进行跨超包边界的继承则是非常危险的,它打破了封装性。子类信赖于其超类中特定功能的实现细节。超类的实现有可能会随着发行版本的不同而有变化,子类有可能会被破坏。下面是一个例子,扩展HashSet,记录HashSet实例创建以来一共进行了多少次添加元素的操作。

public class InstrumentedHashSet<E> extends HashSet<E> {

  private static final long serialVersionUID = 678508745042560741L;
   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;
   }
}

public class Test {
   public static void main(String[] args) {
       InstrumentedHashSet<String> s = new InstrumentedHashSet<String>();
       s.add("1");
       System.out.println(s.getAddCount());
       s.add("2");
       System.out.println(s.getAddCount());
       s.add("3");
       System.out.println(s.getAddCount());
       s.addAll(Arrays.asList("Snap", "Crackle", "Pop"));
       System.out.println(s.getAddCount());
   }
}

结果:1;2;3;9

       导致上述问题的原因是因为在HashSet内部,addAll方法是基于add方法来实现的,为了修改这个问题,可以去掉被覆盖的addAll方法,但是它的功能正确性需要信赖于HashSet的addAll方法是在add方法上实现的这一事实,这种自用性(self-use)是实现细节,而不是承诺,不能保证在java平台的所有实现中都保持不变,不能保证随着上发行版本的不同而不发生变化。因此,这样得到的子类将是非常脆弱的。

       导致子类脆弱的原因是,如果父类增加或者移除方法也会对子类产生影响。比如说,子类扩展了某个集合类,覆盖了所有添加元素的方法,在添加元素之前对元素进行检查,让所有元素满足某个条件。如果在后来的版本中,父类增加了新的添加元素的方法,而子类没有覆盖该方法,导致非法元素添加到集合中。那么如果我们只是在子类中对父类进行功能扩展却不重写父类的方法呢?,这样也有可能出现问题。即便父类的实现没有问题,但也可以因为子类实现不当而破坏父类的约束。比如,父类在后续版本中恰好增加了和子类相同签名和返回类型的方法。

       使用“复合”方案可以避免以上的所有问题。不用扩展现有的类,而是在新的类中增加一个私有对象,它引用现有对象的一个实例,这种设计被称作为复合,因为现有的类变成了新类的一个组件,新类中的每个实力方法都可以调用被包涵的现有类实例中对应的方法,并返回它的结果,这称为转发,新类中的方法被称为转发方法。这样得到的类将会非常稳固。它不信赖于现有类的实现细节。即使现有的类增加了新方法,也不会影响到新类。这种实现包含了两部分:类本身和可重用的转发类,包含了所有的转发方法,没有其他方法。

public class ForwardingSet<E> implements Set<E>{
   
   private final Set<E> s;
   public ForwardingSet(Set<E> 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<E> 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(E 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 E> 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();}
}

public class InstrumentedHashSet<E> extends ForwardingSet<E> {

   private int addCount = 0;

   public InstrumentedHashSet(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;
   }
}

       Set接口的存在使得InstrumentedSet类的设计成为可能,因为Set接口保存了HashSet类的功能特性。除了获得健壮性外,这种设计也带来了灵活性。InstrumentedSet类实现了Set接口,并拥有单个构造器,它的参数也是Set类型,从本质上来将,这个类把一个Set转变成了另一个Set,同时增加了计数的功能。前面提到的基于继承的方法只适用与单个具体的类,并且对于超类中所支持的每个构造器都要求有一个单独的构造器,于此不同的是这里的包装类可以用来包装任何Set实现,并且可以结合任何以前存在的构造器一起工作。例如:

       Set<Date> s = new InstrumentedHashSet<Date>(new TreeSet<Date>());
       Set<E> s2 = new InstrumentedHashSet<E>(new HashSet<E>());

       因为每一个InstrumentedSet实例都把另一个Set实例包装起来了,所有InstrumentedSet类被称为包装类。这也正是装饰器模式,因为InstrumentedSet类对一个集合进行了装饰,为它增加了计数特性。有的时候,复合和转发的结合被错误的称为"委托"。从技术的角度来说,这不是委托,除非包装对象把自身传递给被包装的对象。

      包装类几乎没有什么缺点。需要注意的一点是,包装类不适合用在回调框架上,在回调框架中,对象把自身的引用传递给其他的对象,用于后续的调用。因为被包装起来的对象并不知道它外面的包装对象,所以它传递一个指向自身的引用(this),回调时避开了外面的包装对象。
       只有当子类真正是超类的子类型(subtype)时,才适合用继承。对于两个类A和B,只有当两者之间确实存在"is-a"的关系的时候,类B才应该扩展A。如果打算让类B扩展类A,就应该确定一个问题:B确实也是A吗?如果不能确定答案是肯定的,那么B就不应该扩展A。如果答案是否定的,通常情况下B应该包含A的一个私有实例,并且暴露一个较小的、较简单的API:A本质上不是B的一部分,只是它的实现细节而已(使用API的客户端无需知道)。
       如果在适合于使用复合的地方使用了继承,则会不必要地暴露实现细节。这样得到的API会把你限制在原始的实现上。永远限定了类的性能。更为严重的是,由于暴露了内部细节,客户端就有可能直接访问这些内部细节。这样至少会导致语言上的混乱。最严重的,客户有可能直接修改超类,从而破坏了子类的约束条件。比如说Properties的实例,设计者的意图是只允许字符串作为键和值,但是如果直接去访问它的超类Hashtable就可以违反这个约束。

       在决定使用继承而不是复合之前,还应该问自己最后一组问题:对于你正试图扩展的类,它的API中有没有缺陷呢?如果有,你是否愿意把它们传播到类的API中?继承机制会把超类API中的所有缺陷传播到子类中(除非你愿意重写一遍改进版的超类),而复合则允许设计亲的API来隐藏这些细节。


      简而言之,继承的功能非常强大,但是也存在诸多问题,因为它违反了封装原则。只有当子类和超类之间确实存在子类型的关系时,使用继承才是恰当的。即使如此,如果子类和超类处在不同的包中,并且超类并不是为了继承而设计的,那么继承将会导致脆弱性。为了避免这种情况,可以使用复合和转发机制来代替继承,尤其是当存在适当的接口可以实现包装类的时候。包装类不仅比子类更加健壮,而且功能也更强大。


最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 161,873评论 4 370
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,483评论 1 306
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 111,525评论 0 254
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,595评论 0 218
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 53,018评论 3 295
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,958评论 1 224
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,118评论 2 317
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,873评论 0 208
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,643评论 1 250
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,813评论 2 253
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,293评论 1 265
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,615评论 3 262
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,306评论 3 242
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,170评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,968评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 36,107评论 2 285
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,894评论 2 278

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,111评论 18 139
  • 1. Java基础部分 基础部分的顺序:基本语法,类相关的语法,内部类的语法,继承相关的语法,异常的语法,线程的语...
    子非鱼_t_阅读 31,298评论 18 399
  • 一、基本数据类型 注释 单行注释:// 区域注释:/* */ 文档注释:/** */ 数值 对于byte类型而言...
    龙猫小爷阅读 4,214评论 0 16
  • 第十三条、使类和成员的可访问性最小化 设计良好的模块会隐藏所有的实现细节,把它的API和它的实现清晰地隔离开来。然...
    Timorous阅读 286评论 0 0
  • 8月初在北京验血,血红蛋白值为6.5,全家惊恐,一顿止血加补血,协和遇到不靠谱医生建议输铁,当时宛若开启...
    林间溪水阅读 176评论 0 0