Design Principles from Design Patterns
Copyright © 1996-2005 Artima Software, Inc. All rights reserved
http://www.artima.com/lejava/articles/designprinciples.html
源自《设计模式》的设计原则
与Erich Gamma的一次谈话,第三部分
Bill Venners
Jun 21, 2005
翻译:lxwde
摘要
在这次采访中,Erich Gamma(里程碑式的书籍《设计模式》的作者之一)向Bill Venners谈论了两个设计原则:针对接口(而不是实现)编程,优先选择对象组合而不是类继承(favor object composition over class inheritance)。
Erich Gamma是在1995年作为畅销书籍《设计模式:可复用面向对象软件的基础》 (Addison-Wesley, 1995) [1] 的合著者而跃上软件业界舞台的。这项具有里程碑意义的工作,经常被援引为“四人帮”(GoF)的书,该书针对通常的设计问题分类整理出了23种特定的解决方案。1998年,他和Kent Beck组成团队开发Junit [2],这成为Java社区事实上的单元测试工具。Gamma现在是IBM的一名杰出工程师,他在位于瑞士苏黎世的IBM Object Technology International(OTI)实验室工作。他担任Eclipse社区的领导工作,负责Eclipse平台[3]上与Java开发相关的事务。
2004年10月27号,Bill Venners 在加拿大温哥华举行的OOPSLA会议上遇到了Erich Gamma。在这次采访中(这次采访的内容将分多次在Artima Developer的Leading-Edge Java频道刊登出来),Gamma讲述了软件设计中深层次的东西。
在第一部分:如何使用设计模式中, Gamma给出了他对于如何正确思考和使用设计模式的看法,并且描述了模式库(比如GoF)和Alexandrian的模式语言之间的差异。
在第二部分:Erich Gamma讲述灵活性和重用中, Gamma谈论了重用的重要性,推测(speculating)的风险,以及 框架症(frameworkitis)所带来的问题。
在第三部分中,Gamma谈论了在GoF书中被着重强调的两个设计原则:针对接口(而不是实现)编程,优先选择对象组合而不是类继承。
针对接口(而不是实现)编程
Bill Venners: 在GoF那本书的绪论里,您提到了关于可重用的面向对象设计的两个原则。第一个原则是:“针对接口(而不是实现)编程。”这句话到底是什么意思?为什么要这么做呢?
Erich Gamma: 这条原则实际上是关于依赖关系的,在大型程序里必须小心对待这个问题。给某个类增加一个依赖是很容易的。几乎是太容易了;只要加一个import语句就可以了,而且诸如Eclipse的新式Java开发工具甚至可以替你写这个语句。有趣的是反过来可并不那么容易了,去除一个不想要的依赖可能需要做大量的重构工作,甚至更坏的情况下会妨碍你在另外的环境下重用这些代码。因为这个原因,当要引入依赖关系的时候,在开发过程中你可要睁大眼睛。这个原则告诉我们,依赖于某个接口常常是有好处的。
Bill Venners: 为什么?
Erich Gamma: 一旦你仅仅依赖于接口,你就与实现分离开来了。这就意味着实现可以变化,而且这是一个健康的依赖关系。例如,测试的时候你可以用一个轻量级的模拟数据库来代替大型的数据库实现。幸运的是,有了现在的重构支持,你再也用不着一上来就直奔接口。当你对某个问题有了全面深入的了解以后,你可以从一个具体的类里面提取出一个接口。预期的接口只要使用“提取接口(extract interface)”重构一下就可以了。
这种方法给了你灵活性,但是它也把真正有价值的部分(指设计)与实现分割开了,而原本通过设计可以让客户端与实现相分离。有个问题,你是否应该总是使用Java的interfaces来达到这个目的。抽象类也很管用。实际上,在需要逐步演化的时候,抽象类可以给你更多的灵活性。你可以添加新的行为而不会打断客户端。
Bill Venners: 怎么弄呢?
Erich Gamma: 在Java里,当你给某个接口添加新方法的时候,你就打断了所有的客户端。如果你使用抽象类,你可以添加一个新方法并且在它内部提供一个默认的实现。所有的客户端将会继续工作。像通常情况一样,这里会有一个折衷,接口给你关于基类的自由,抽象类给你以后添加新方法的自由。并不是说总是可以在一个抽象类内部定义接口,但是根据(设计)演化的情况你应该考虑是不是一个抽象类就够用了。
既然改变接口会打断客户,一旦发布,你就应该认为它们都是不可改变的。因而在为一个接口添加新方法的时候你必须把它放到一个单独的接口里去。在Eclipse里我们很认真的对待API的稳定性,正是由于这个原因你会在我们的API里发现诸如IMarkerResolution2或IWorkbenchPart2等等所谓的I*2接口。这些接口往基本接口ImarkerResolution和IWorkbenchPart添加了一些方法。因为添加的这些方法都是在单独的扩展接口里完成的,所以你没有打断客户端。然而,现在调用者就有了一些额外的负担,它们需要在运行时刻判定一个对象是否实现了某个特定的扩展接口。
我们学到的另外一个教训就是,你应该不仅仅把注意力放在第一个版本的开发上,而且你还应该考虑到接下来的版本。这并不意味着要你设计未来的扩展性,而只是要你记住你应该维护你弄出来的东西并且设法让API在较长的一段时间内保持稳定。你想构建常盛不衰的软件(You want to build to last)。从一开始,这就是Eclipse开发的一个重要话题。我们把Eclipse构建成了一个平台。我们设计Eclipse的时候始终记着它必须得持续10到20年。有时候这让人望而却步。
开始的时候我们就添加了对于基础平台演化的支持。其中一个例子是IAdaptable接口。实现了这个接口的类可以被适配到另一个接口。这是扩展对象模式(Extension Object pattern)的一个例子。[4]
Bill Venners: 有意思的是,现今我们是如此的先进发达,但是当我们说到想让自己的东西常盛不衰的时候,我们的意思是10或20年。当古埃及人说要让他们建造的东西常盛不衰的时候,他们的意思是。。。。。
Erich Gamma: 几千年,对么?但是对于Eclipse,10到20年,哇。说实话,我不是设想说,10或20年后某个软件考古学家发现在一块硬盘上某块地方安装着Eclipse。我实际的意思是,Eclipse在接下来10或20年里应该仍然能够支持一个活跃的社区。
接口的价值
Bill Venners: 您刚才说过接口更有价值。它们的价值是什么呢?为什么它们比实现更有价值呢?
Erich Gamma: 接口提取出对象之间的协作关系。接口是独立于实现细节的,而且它定义了协作的语汇(vocabulary)。一旦我理解了接口,我就理解了系统的大部分。为什么?因为当我理解了所有接口以后,我应该就能够理解关于这个问题的语汇。
Bill Venners: “这个问题的语汇”,您是指什么?
Erich Gamma: 方法名称是什么?抽象是什么?抽象加上方法名称就定义了语汇。Java的Collection包是说明这个问题的一个很好的例子。与这些集合(collection)的使用相关的语汇都被抽取到诸如List或Set这样的接口了。这些接口拥有一大堆的实现,但是一旦你理解了关键的接口所有东西都不在话下了。
Bill Venners:关于“针对接口(而不是实现)编程”,我猜想我的问题的核心应该是:Java里有一种特殊的类叫做接口,当我写代码的时候我把它放入代码体(code font)—— Java的接口构造里。但是还有一个是面向对象的接口概念,每一个类都有这个面向对象的接口概念。
如果我是在写客户端代码并且需要使用一个对象,而那个对象的类存在于某个类型体系里。在这个(类)体系的顶部是非常抽象的东西。在底部是非常具体的东西。我思考针对接口编程的方法是这样的,写客户端代码的时候,我希望针对那个体系越靠上的类型的接口写代码越好,而不要过于深入那个体系。那个体系里的每一个单独的类型都有一个契约(contract)。
Erich Gamma: 你说的对。而且,针对类体系里靠上的类型写代码和针对接口编程的原则是一致的。
Bill Venners: 我该如何写一个实现呢?
Erich Gamma: 设想我定义了一个有五个方法的接口(interface),接下来我定义了一个实现类实现了这五个方法并且又添加了另外十个方法。除非这个接口被当作API发布了,否则如果你调用十个方法中的任意一个你做的都是内部调用。你调用了一个不受契约约束的方法,而这个方法我随时都有可能改变。如Martin Fowler所说,这就是公共的(public)和已发布的(published)的差别所在。有些东西可以是公共的,但那并不意味着你已经把它发布出去了。
在Eclipse里我们使用命名约定,包含“internal”字段的package意味着这是一个内部package。它们包含我们认为不是已发布类型的那些类型,即使这个package包含一个公共类型。简短明了的package名字是用来命名API的,而长的名字是用来命名内部package的。显然使用package私有类和接口是在Java里隐藏实现类型的另一中方法。
Bill Venners: 现在我明白你的意思了。存在公共的和已发布发的。Martin Fowler给出的术语很好地说明了两者的区别。
Erich Gamma: 而且在Eclipse里我们有针对这种区别的约定(convention)。实际上我们甚至有工具来支持这一点。在Eclipse 3.1里我们添加了针对哪些package是已发布API的定义规则的支持。这些访问规则在一个工程的class path上定义。一旦你定义了这些访问约束,Eclipse的Java开发工具就会像报告其它编译警告一样报告对内部的类(internal classes)的访问。比如,当你往一个没有被发布的类添加依赖的时候,一边打字一边你就能得到反馈。
Bill Venners: 所以,如果我写的代码与未发布类(non-published class)的接口打交道,我就是在以某种方式针对实现写代码,它有可能会被打断。
Erich Gamma: 是的,从提供者的角度给出的解释是,我需要一些自由并且保留对实现进行改动的权利。
什么时候该考虑接口
Bill Venners: 说到接口这个话题,GoF那本书包括一些UML类图。UML图似乎把接口和实现混在一起了。你看这些东西的时候会经常看到一些代码的构思。什么是API以及什么是实现并非必须是显而易见的。作为对比,如果你看看JavaDoc,你会看到接口。我发现另一个缺少对接口和实现进行区分的地方是XP。XP谈论的是代码。你是在用测试驱动开发来更改未定型的代码段。设计者什么时候该针对一大堆代码来考虑接口呢?
Erich Gamma: 设计一个程序的时候你所考虑的东西可能和设计一个平台的时候不同。设计平台的时候,你必须时刻关心什么应该暴露成为你的API的一部分,什么应该保持在内部。现今对于重构的支持使更改名字非常容易,所以你必须小心不要一不小心把已经发布的API改掉了。这比仅仅定义哪些类型是已发布的走的更远。你还必须回答类似于这样的问题:你允许客户从这种类型派生出子类么?如果允许,这就要强加(给你)很大的义务。如果你看看Eclipse API,我们尽力把自己是否想让客户从一种类型派生子类弄得一目了然。另外,有了Jim des Rivières[5]的参与,我们团队有了一个API协调者。他不仅帮助我们遵从我们的规则,而且更为重要的是Jim还帮助我们讲述我们的API的来龙去脉。
当说到应用程序的时候,即使这时候你拥有的也是有多个变体的抽象。就你的设计来说,你希望先弄出关键抽象,然后你希望其它代码只是与这些抽象打交道,而不是与特定的实现。这样你就有了灵活性。当某个抽象出现了一个新的变体的时候,你的代码仍然可以工作。至于XP,如我先前所提到的,先进的重构工具可以让你很容易的把接口引入现有代码,因此它和XP是一致的。
Bill Venners: 这样看来,对于应用程序来说,它和平台有着同样的思维过程,只不过它在一个较小的规模上。另外一个不同之处在于,如果我可以控制这个接口的所有客户端我就可以在需要改变接口的时候很容易的更新它们。
Erich Gamma: 是的,对于应用程序来说,它和平台有着同样的思维过程。你同样想要构建一个持久耐用的应用程序。对于一个变化的需求所做出的反应不应该波及到整个程序。你能够控制所有的客户端当然可以帮上大忙。一旦你分发了你的代码你就不再能够支配所有的客户端了,然后你就进入API这个领域了。
Bill Venners: 即使是那些客户端是由同一个公司的不同小组写的。
Erich Gamma: 即使是这样,毫无疑问。
Bill Venners: 听起来像是,随着项目规模越来越大,接口就变得越来越重要。如果项目只有两三个人,考虑接口并非特别重要,因为如果你需要改动它们你就改动好了。重构支持工具。。。。。。
Erich Gamma: 。。。。。。会为你做这一切。
Bill Venners: 但是如果是一个100人的团队,那就意味着人们会被分成各个小组。不同的小组会有不同领域的职责。
Erich Gamma: 我们沿习的一个习惯是,把某个组件分派给一个小组。这个小组负责这个组件并且发布它的API。这么一来,依赖关系就通过API定义下来了。我们同样地拒绝定义友元(friend)关系这种诱惑,意思是,某些组件比其它组件更合适(equal)而被允许使用内部的东西。在Eclipse里,所有的组件都是平等的。例如,Java开发工具的插件并没有任何特权,而且和其它插件一样使用同样的API。
一旦你发布了一个API以后,保持它们的稳定性就是你的责任了。否则你就会打断其它的组件而且没人能够向前推进自己的工作了。对于此类大小的项目,拥有稳定的API是项目前进的一个关键。
在一个像你刚才描述的那种封闭环境中,当做改动的时候你会有更多的灵活性。例如,你可以使用Java的deprecation支持让其它团队逐步地跟上你的改变。在这样一个环境里,你可以在一段达成共识的时间间隔之后去除掉废弃的(deprecated)方法。在一个完全暴露的平台里,这恐怕是不可能的。在那里,废弃的方法是不能被移除的,因为你仍然可能会在某些地方打断一个客户。
组合vs.继承
Bill Venners: 在GoF那本书的绪论里你给出了另外一个面向对象设计的原则,“优先考虑对象组合而不是类继承(Favor object composition over class inheritance)。”这是什么意思,为什么它是一个好的做法?
Erich Gamma: 虽然已经过了十年,我仍然认为它是对的。继承是用以改变行为的一种很酷的方法。但是我们知道它是脆弱的,因为子类可以轻易地针对调用它所覆写过的某个方法的上下文做出种种假设。在基类和子类之间存在紧密的耦合,因为调用我插入子类的代码的上下文空间是不明晰的。组合有着较好的性质。通过往较大的东西里插入较小的东西降低了耦合性,而且较大的对象只是往回调用较小的对象。从API的角度来看,定义一个可以被覆写的方法比定一个可以被调用的方法需要更严格的约束。
在一个子类里,当你覆写的某个方法被调用的时候,你可以做出关于父类内部状态的种种假设。如果你是仅仅插入一些行为,那么这么做是较简单的。这就是为什么你应该偏向于组合。通常有一个误解认为组合根本不使用继承。组合是使用继承的,但是通常你只是实现一个小的接口,而且你不是从一个大的类继承而来。Java的listener惯用法是关于组合的一个很好的例子。通过listener你可以实现一个listener接口或者继承自一个所谓的适配器(adapter)。比如,你可以创建一个listener对象并且把它注册到一个Button部件。没必要为了对事件做出反应而对Button进行子类化。
Bill Venners: 当我在我的设计讲座上谈论到GoF那本书的时候,我提到(那本书)一遍又一遍展示的通常是,出于不同的缘由与接口继承一道使用组合。说到接口继承,我的意思是,比如,在C++里继承自一个纯虚基类,或者在Jva里属于代码体的接口(code font interface)。比如您提到的Listener的例子,也涉及到了继承。我把MouseListener实现成MyMouseListener。当我通过addMouseListener添加一个实例到Jpanel的时候,我就是在使用组合了,因为前端(front-end)持有那个MouseListener的JPanel会调用它的mouseClicked方法。
Erich Gamma: 是的,你这么做降低了耦合度。除此之外,你还有了一个单独的listener对象,你可能还可以用它连接其它对象。
Bill Venners: 我一直在关注组合相对于继承的额外的灵活性,但是我总是很难解释它们。我希望你能够用话语把它们总结下来。为什么这样?实际发生的是什么?那些多出来的灵活性到底是从哪儿来的?
Erich Gamma: 我们把这叫做黑箱复用。你有一个容器,然后你往里面放入一些小的对象。这些小的对象配置这个容器并且定制容器的行为。这么做是可能的,因为容器把某些行为委托给了这些小东西。最后,你通过配置得到定制的东西。这提供给你针对那些小东西的灵活性和重用的机会。这么做是很有效的。我不想给你一个冗长的解释,让我简单给你讲讲Strategy模式。这是我关于组合相对于继承的灵活性的一个最初的(prototypical)例子。多出来的灵活性来自一下事实,你可以插入不同的strategy对象,而且你甚至还可以在运行时刻动态改变strategy对象。
Bill Venners: 如果我非要使用继承的话。。。。。。
Erich Gamma: 那你就不能针对strategy对象做混合和匹配了。尤其是不能在运行时刻动态地做这个事情了。
下周
6月13号,星期一,请您回来看与Erich Gamma这次谈话的下一部分。如果你想收到Artima.com上新文章每周简报的电子邮件,请订阅Artima Newsletter。
反馈
对本文中讨论的设计模式话题有自己的观点么?那么请到文章论坛里讨论这个话题, Design Principles from Design Patterns.
资源
[1] Erich Gamma是《设计模式:可复用面向对象软件的基础》的合著者之一,可以在Amazon.com上找到这本书 :
http://www.amazon.com/exec/obidos/ASIN/0201633612/
[2] Erich Gamma 是JUnit的作者之一,JUnit是事实上的Java单元测试标准工 具:http://www.junit.org/index.htm
[3] Erich Gamma 领导Eclipse平台上与Java开发相关的事务: http://www.eclipse.org/
[4] 参见Robert Martin的《Pattern Languages of Program Design 3》( Addison- Wesley, 1997)一书的“Extension Object”一节。 可以在 Amazon.com 上找到这本书:
http://www.amazon.com/exec/obidos/ASIN/0201310112/
[5] “Evolving Java-based APIs,” 作者是 Jim des Rivières:
http://eclipse.org/eclipse/development/java-api-evolution.html
[See also] 《Contributing to Eclipse: Principles, Patterns, and Plug-Ins》, 作者是 Erich Gamma 和 Kent Beck, 这本书可以在Amazon.com上找到:http://www.amazon.com/exec/obidos/ASIN/0321205758/
关于作者
Bill Venners 是Artima软件公司的主席兼Artima Developer的主编。他是《Inside the Java Virtual Machine》一书的作者,该书从面向程序员的角度讲述了Java平台的架构和内幕。他在JavaWorld杂志上广受欢迎的专栏覆盖了Java内幕,面向对象设计,以及Jini。从Jini启动以来,Bill就活跃在这个社区。他领导Jini社区的ServiceUI项目,这个ServiceUI API成为连接用户接口和Jini服务之间既成事实的标准方法。Bill还被选为Jini社区最初的技术监管委员会(TOC)的一员,他担任这个职务期间帮助定义了社区的管理流程。