OOD启思录

978-7-115-26543-2
作者: 【美】Arthur J.Riel
译者: 鲍志云
编辑: 傅道坤龚昕岳

图书目录:

详情

本书提供了改进面向对象设计(OOD)的真知灼见,总结出了60多条OOD的指导原则,涵盖了从类到对象到面向对象物理设计的重要主题。通过本书,读者可以理解经验原则和“设计模式”这一流行概念之间的相互作用,并进一步提升对OOD的认识和程序开发水平。

图书摘要

版权信息

书名:OOD启思录

ISBN:978-7-115-26543-2

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

• 著    [美] Arthur J.Riel

  译    鲍志云

  责任编辑 傅道坤

• 人民邮电出版社出版发行  北京市丰台区成寿寺路11号

  邮编 100164  电子邮件 315@ptpress.com.cn

  网址 http://www.ptpress.com.cn

• 读者服务热线:(010)81055410

  反盗版热线:(010)81055315


Authorized translation from the English language edition, entitled OBJECT-ORIENTED DESIGN HEURISTICS (PAPERBACK), 1E, 9780321774965 by RIEL, ARTHUR J., published by Pearson Education, Inc, publishing as Addison-Wesley Professional, Copyright © 2011 by Addison-Wesley.

All rights reserved. No part of this book may be reproduced or transmitted in any form or by any means, electronic or mechanical, including photocopying, recording or by any information storage retrieval system, without permission from Pearson Education, Inc.

CHINESE SIMPLIFIED language edition published by PEARSON EDUCATION ASIA LTD., and POSTS & TELECOMMUNICATIONS PRESS Copyright @ 2011.

本书中文简体字版由Addison-Wesley授权人民邮电出版社出版。未经出版者书面许可,对本书任何部分不得以任何方式或任何手段复制和传播。

版权所有,侵权必究。


本书提供了改进面向对象设计的真知灼见。

全书共11章,总结出了60多条面向对象设计(OOD)的指导原则。这些经验原则涵盖了从类到对象(主要强调它们之间的关系,包括关联、使用、包含、单继承、多继承)到面向对象物理设计的重要主题。本书将帮助你理解经验原则和“设计模式”这一流行概念之间的相互作用。你可以借助经验原则发现设计中所存在的某一方面的问题,而设计模式则提供了解决方案。

本书对各个层次的开发者都有价值,新手能借助本书走上通向面向对象编程的快车道,想提升自己的面向对象开发水准的老手则会受益于本书深具洞察力的分析。本书提供了让你成为更好的软件开发者的途径。


在向几千名学生讲授面向对象分析、设计和实现的过程中,我逐渐意识到,业界很需要可以帮助开发者做出正确决定的指导原则。自1987年起我就开始查阅文献,试图找到可以适用于不同层次的开发过程的效率和复杂性参考基准,以便改善面向对象应用程序。除了那些从文献里找出来的指导原则,我还增加了自己的“家酿”原则。这样,我就得出了大约60条原则。其中有几条颇有些玩笑意味,但它们同样值得重视。我曾考虑是否把这些原则命名为“面向对象设计与分析的60条黄金准则”,不过我想起了Dykstra那传奇般的论文Goto Considered Harmful,该文给用goto语句的人戴上了“异教徒”的帽子(听起来就好像他们应当被绑在公司大院的木架上施以火刑一样)。这非常重要,因为该文给业界提出了规则,阻止了使用goto语句的人继续有意或无意地破坏他们的系统的可维护性。不幸的是,这样的规则也造成了不良影响:出现了一群心理不太正常的作者,25年来一直在发表文章声称“在应用程序的某个诡异角落明智地使用goto语句,能让代码的可读性高于对应的结构化代码”。当然,这些论文后面还跟着一堆“驳论文”,后面还有“驳—驳论文”,等等。

为了避免类似的宗教战争再次发生,我把本书中的60来条指导原则称为经验原则(heuristics)。你不必严格遵守这些原则,违背它们也不会被处以宗教刑罚。但你应当把这些原则看成警铃,若违背了其中的某一条,那么警铃就会响起。你应当严肃对待这些警告并细致检查。如有必要,你应修改设计以消除警告。当然,如果在某个例子中有一些经验原则因这样或那样的原因而不适用,这也是完全正常的。事实上,在面向对象设计的特定场合下两条经验原则常常会互不相容。开发者需要判断在该场合下哪条经验原则更重要一些。

虽然“创建一种‘Riel的面向对象分析/设计方法学’”想法听起来很诱人,但本书没有发明新的面向对象分析或设计方法学。业界已经有够多的方法学了,它们提供了类似的或是重复的建议,并且对相同的概念使用完全不同的术语。无论采用何种方法学,面向对象开发者遇到的典型问题出现在设计完成之后。但迄今还没有人认真地去解决这个问题。这个问题是:“现在我做完设计了,但这个设计是好?是坏?还是好坏参半?”如果去问面向对象专家,那么好的设计获得的评价是常常是“这个设计‘感觉起来’挺好的”。对开发者来说,这个答案价值不大,但这样的回答却有其内在真理。这位面向对象大师会在下意识里让这个设计走过一个经验原则列表。这个列表是随这位大师的设计经历而积累起来的。如果设计顺利通过了这个经验列表,那么“感觉挺好的”,否则这个设计“感觉不太对”。

本书试图描述出这个原本存于大师们潜意识中的经验原则列表,并且以现实例子为支撑。读者们很快就会意识到,有一些经验原则比其他的原则更重要。经验原则的重要程度取决于违反这条原则造成的后果有多严重。不过我并不把这些原则按照重要程度排序,因为我觉得在很多情况下优先次序取决于应用领域和用户需求,是不能在本书中量化的。例如,在设计中有时会遇到两条经验原则背道而驰的情况,其中一种常见情况是用复杂性和灵活性之间的取舍。问一下你自己,你最想要的是什么,是增加灵活性还是降低复杂性。这样,你就会意识到难以在本书中列出经验原则的优先次序。这些设计经验原则是以现实世界中的例子为背景而定义的,这些例子覆盖了各条经验原则所属的领域。实际例子是向初学者解释面向对象技术的概念的理想平台,因此本书很适合于初学者。借助本书,初学者可以驶上理解面向对象编程的快车道,而不必徘徊于甚至迷失在充斥于OO世界的时髦用语之间。与此同时,对经验丰富的面向对象开发者而言,如果他们正在寻求良好的分析和设计经验原则的话,也会发现本书独具魅力,能为他们的开发带来很大帮助。

第1章探讨面向对象编程的动因,以Frederick Brooks发表于1987年的《没有银弹》一文(见参考文献)中论及的一些问题入题。我对面向对象编程的看法是,这是面向动作开发之后的自然进步,或称为演进。软件变得越来越复杂,所以我们必须达到离机器更远的一个新抽象层次,这样才能继续控制软件开发过程。正如结构化方法要比自底向上的编程高一个抽象层次,面向对象技术也比结构化方法提高了一个抽象层次。这并不意味着“自底向上编程或者结构化方法是错误的,面向对象编程才是正确的”。如果能用的内存只有4KB,那么自底向上的编程完全适用;若能用的内存有256KB,那么结构化方法非常合适。不过随着硬件越来越强大且越来越便宜,软件的复杂性也扶摇直上。在20世纪80年代早期,开发者还不需要考虑图形用户界面和多线程应用程序的复杂性;那时候司空见惯的是简单的菜单驱动单线程应用。但是,在不久的将来,没人会购买不带移动视频和语音识别这类多媒体特性的软件产品。越复杂的系统就需要越高层次的抽象,面向对象范型就提供了这一抽象。这不是软件开发的革命,只是正常的演进。

第2章讨论了类和对象的概念,它们是面向对象技术的基石。我们把类和对象看作数据及其相关行为的封装,数据和行为的关系是双向的。我们通过现实世界中的例子探讨了发送消息、定义方法、设计协议的表示法。从这个章节开始陆续列出经验原则。因为到本章为止我们只接触了面向对象范型的一个小小子集,所以这些经验原则是较为简单的,但这并不意味着它们的用处小于稍后章节出现的较复杂的经验原则。

第3章探讨了面向动作和面向对象布局之间的不同。方法学的不同布局昭示了面向对象开发的核心真理。面向动作的开发在很大程度上专注于对任务集合的控制,这些任务是通过功能分解的,控制机制是“中央集权”式的;而面向对象的开发则专注于分布式的交互实体集。我确信,这一“范型迁移”意味着思考方式从集中式模型到分布式模型的转变。对于我们这些成长于面向动作开发世界的人来说,面向对象开发的学习曲线也就是面向动作开发的遗忘曲线。我们生活的现实世界更接近于对象模型,而非集中控制机制影响下的模型。很容易看出哪些系统的开发者的思维范型尚未迁移:如果系统中有一个位居中央的全能对象,其他对象都退居次要,那么这样的系统是由依然执着于面向动作布局的开发者创建的。本章提出了很多经验原则,用来指导应用程序布局的优化。

第4章到第7章通过一系列现实世界中的例子探讨了5个主要的面向对象关系:使用(第4章)、包含(第4章)、单继承(第5章)、多继承(第6章)、关联(第7章)。面向对象设计者感兴趣的大多数经验原则都可以在这些章节中找到。关于继承的章节包含了很多常见的误用继承关系的例子。对减少“泛滥成灾的类”的问题(比如为一个特定的应用设计了太多的类)而言,这些信息至关重要。“泛滥成灾的类”的问题是面向对象开发失败的主要原因之一。

第8章探讨了与类相关的数据和行为,这和与对象相关的数据和行为相对。发票类被用作需要与类相关的数据和行为的抽象的例子。我们既描述了SmallTalk元类也描述了C++的关键字机制。此外,我们还比较了C++元类(也即模板)和SmallTalk的元类表示法。

第9章讨论了在开发面向对象系统中面向对象物理设计的角色。虽然关于物理设计有很多可以说的,但很多都和面向动作范型中讨论过的东西是重复的。比如,已经有大量文献详细讨论了高效实现的粒度问题(例如,为了提高应用程序的速度,在逐条检测语句之前,请先考虑:替换硬件、替换编译器、替换机制、替换算法)。因此,本书中讨论的物理设计问题要么是只有面向对象范型中才存在的,要么是面向对象范型提供了特殊解决方案的。其中包含:把不友好的(比如,非面向对象的)子系统同面向对象的问题域隔离开的软件包装层、空间持久性和时间持久性、面向对象数据库管理系统和关系数据库管理系统、内存管理和垃圾收集、引用计数、最小公有接口、并行面向对象编程以及用非面向对象语言实现面向对象设计。

1987年,我参加了OOPSLA会议的一个小型研讨会,会上讨论了面向对象范型的过去、今天和未来。在会议上,Kent Beck谈及了Christopher Alexander在建筑学领域发表的论文。Alexander认为,所有的建筑都有“无名的质”,他试图用称为“模式”的实体来表述这些“无名的质”。Kent论及了寻找模式(也即已知问题的独立于领域的解决方案,或者面向对象构架中的有趣结构)的可能性。近来,大量研究正在这个领域展开,模式已经成为对象群体的最活跃前沿。于是,我不得不问自己:“经验原则和模式之间有什么关系?”显然,两者是相关的,因为寻找它们的方式是类似的。我们研究任何一个在许多不同领域出现的结构或问题,然后试图用经验原则或者设计模式的格式来封装该实体。本书第10章讨论了设计模式和它们同设计经验原则之间的关系。我相信,模式和经验原则之间最有趣的关系是:经验原则可以告诉设计者,何时该用几个设计模式中的某一个了。对普通的设计者而言,模式的尺度超过了能靠直觉掌握的范围,而经验原则则正好相反,它们很少超过两句话的长度,而且很易于应用。这两者组合起来可以发挥很大的效力。本章也描述了一些设计模式和经验原则共同具备的有趣特性。

本书中前面几个例子可能微不足道,或者不属于计算机科学的领域,但请读者不要一上来就大加鞭挞。在我的讲座中,常常会听到一小部分参与者不久就开始抱怨,他们低声说:“这些信息毫无用处,因为我不会去编写水果篮、有尾巴的狗或者闹钟。”既然本书中很大一部分讨论的是现实世界中的日常事项,那么我可以给出一个合理的解释:如果设计经验原则和模式确实是独立于领域的,那么我为何不选择一个简单的领域来讲授它们呢?在我曾讲授过的设计课程中,我常常会听到某个设计小组大叫:“这是苹果去核问题”或者“这是具有可选尾巴的狗的问题”。一旦理解了一条经验原则,那么如何把它的运用扩展到别的领域就很简单了,不管那个领域有多复杂。

我把第11章纳入本书,这是为那些想看到更具“真材实料”的设计例子的人准备的。第11章围绕自动柜员机(ATM)展开讲了一些分析和设计问题。很多叙述面向对象范型的不同书籍都使用了ATM的例子。我选择这个例子,是因为读者对它很熟悉,而且这个例子在更加“计算机科学化”的领域中描述了设计经验原则和模式的使用。此外,因为这是一个分布式系统(ATM和银行位于不同的地域),所以我可以在这个例子中描述称为“用代理设计”(design with proxies)的设计技法。这种设计技法使得系统构架师在进行逻辑设计时可以忽略应用程序的分布特性,把这些问题推迟到设计后期。这很重要,因为很多分布式系统的设计缺陷的原因都可以归结为在逻辑设计完成前就考虑分布式处理。

最后,我要说的是,在我的所有课程上,都注意到班级分成了两拨人。一拨人喜欢在抽象领域中生活,他们为了设计而讨论设计,很少讨论实现的问题。另一拨人则难以理解抽象的东西,但是如果你把一段代码拿给他们看,他们很快就会领会讨论的要点。当本书交付审阅时,我请每一位审阅者都指出,这本书是否应当用C++实现描述设计实例。两位审阅者说,显然这本书需要那些C++例子,如果没有这些例子的话读者会很难理解那些抽象的概念。另外两位审阅者则说,这是一本关于设计的书,因此同C++或者别的编程语言没有关系。还有两位审阅者则站在相对中立的位置。这样,我就面临着显而易见的二难困境,无法让所有人都感到满意。我的解决方法是,在正文后面列出了一系列精选的C++实现作为附录,这些实现所对应的设计例子则在正文中的章节讨论。如果你是个抽象主义者,那么你不读附录就可以了;如果你习惯于通过研究对抽象概念的实现来学习(在多数时间里我也是这样的),那么你或许会想要研究附录中特定设计问题的实现。这种安排没有用代码扰乱正文中对设计的陈述,我希望这种安排也能让期待看到实现代码的人满意。

请注意:所有C++例子都在奔腾100MHz的IBM PC兼容机上用Borland C++ 4.5编译测试通过。你常用的C++编译器也应该可以毫无问题地编译这些例子。


拥有丰富面向对象开发经验,并且愿意无保留地告诉你自己的经验,而且还有这个天分能把这些宝贵经验讲述得深入浅出,这样的人不多。而本书作者碰巧就是。Arthur J. Riel曾工作于贝尔实验室、Draper实验室、IBM、东北大学,他既有丰富的开发经验,又有丰富的授课经验。而Authur整理自己开发与授课心得,并参考了众多面向对象经典著作后写就的《OOD启思录》一书,也确实无愧乎读者的评价——“面向对象设计领域中的Effective C++”。正如Effective C++能助你迈向C++专家层面,《OOD启思录》能助你迈入OOD的殿堂。

这本书并不讲述RUP等方法学框架,也不讲述UML或者C++语言,而纯粹讲述设计经验(其实原书书名中heuristics一词是启发式教学的意思)。这样的书在目前书市中汗牛充栋的OO著作中并不多见。而且,和开发方法学著作或者其他OO著作中以“best practice”(最佳实践)的方式传授设计经验的做法不同,这本书不仅告诉你好的做法,还告诉你不好的做法,并且告诉你如何识别,如何判断好的(糟糕的)做法,并且教你在面对多种可能的设计方案时如何作出取舍。拥有这些能力正是有经验的优秀开发者同初学者的区别所在。作者能够把这些往往“可意会但难言传”的设计经验提炼成易学易记易参考的“经验原则”的形式,而且伴以分析、讨论与实际应用例子来帮助你理解和消化这些经验,这是非常难能可贵的。

书的语言载体是C++。这只是因为写作时C++恰好是业界最流行的语言,并不意味着这些经验原则都只适用于C++。其实,这些凝聚着智慧的设计经验中大多数是跨越语言(并且跨越时代)的,同语言相关的经验原则只占全书很小一部分。所以,如果您只会Java或者C#,本书对您也依然很有价值并且容易读懂。这是因为书中的C++代码是比较朴素的风格,没有大量采用模板,没有采用现代的泛型编程技法,看起来和Java、C#的语法差不多。

对国内的读者,这本书又多了一层含义。任何学过一门编程语言的人,若是想要做一些真正的项目,写出高质量的代码,都会有向优秀开发者学习第一手经验的迫切需求。但偏偏目前国内的软件开发教学尚有与实践脱节之处,目前大学计算机系教学计划中基本都有C++或Java语言课程,但是很少有关于设 计经验的课程,而且高校中也缺乏这样的师资,在这本书之前也缺乏这样的教材。若是纯粹依靠自己开发摸索来获得设计经验,又需要耗费很长时间,而且会走很多弯路,且不够系统。而《OOD启思录》正好填补了这个空白,很适合刚学完编程语言,希望向面向对象设计层面进阶的读者。

说到设计经验,就不得不提到模式。或许很多读者看过或者听说过《设计模式》这本书,并对书中归纳的23个经典模式耳熟能详或者倒背如流。但不知您是否熟知在怎样的场合应当使用哪个模式,又为何要使用那个模式?记住模式容易,学以致用难,因为在实践中场景是千变万化的,难以判定应当采用某一模式。而这正是本书解决的问题之一。《OOD启思录》第10章讲述了书中列出的经验原则同模式的关系:一条经验原则对应了一个或者多个变换模式——从反模式(糟糕的设计——违反了某条经验原则)变换成适合设计需要的模式。

现今,糟糕的设计还有另一个昵称:“bad smell”(见Martin Fowler的《重构》一书)。那么,变换模式所做的也正是以相应经验原则为指导,把具有bad smell的设计重构成合适的模式。这倒是和后来Joshua Kerievsky的想法“Refactoring to Patterns”殊途同归了。

依照目前的趋势,可以预言,将会有越来越多的开发工具支持自动识别“bad smells”(Together、Pattern Testing等),或者支持自动重构(很多IDE都支持了),甚至两者皆支持(那就近似可以自动或半自动地改善设计质量了)。事实上,这种做法正是将专家经验植入开发工具。那么,我们是否还需要看这本书中列出的专家经验呢?面对内置专家经验的“智能开发工具”的“自动修改或者建议修改你的代码/设计”的新功能,有3种可能的态度:[1]放弃对代码的控制权,任工具自动去修改 [2] 关闭工具的这项功能 [3]掌握工具这项功能的背后原理,知道每个修改建议的“所以然”,在自己保有对代码控制权的同时借助工具新功能来大幅提高效率。如果您觉得[3]比较适合您,那么最好还是看一下这本书。

本书附有大量代码,可通过如下链接下载:

http://www.awprofessional.com/isapi/product_id~{457BA62C-4660-4632-922B-4CC6E2BDE0BA}/selectDescTypeId~{A5B39B29-323D-4AF1-B0F8-554D9C309CD3}/st~{7CB2A4C6-CB65-41C4-B301-E9516A7E6930}/session_id~{7035CB14-FFF7-4580-A6BE-8DC5EF623BD4}/catalog/product.asp

原书印刷版本并未包含网站上的全部代码,而只是摘选,所以译本也照此处理。此外,因为网站内容更改容易,书本内容印出来后却难以实时更新,所以网站上代码和书中代码细节之处可能会略有“版本差异”,但不会影响阅读和理解。

最后,要感谢人民邮电出版社的陈编辑在众多OO书籍中独具慧眼地挑出了这一本。虽然书中内容对我而言多属“温故而知新”,但在翻译过程中我还是获得了不少启迪,受益良多。希望这个译本也能带给您帮助,并且能给您带来愉快的阅读体验。如您发现译文有不妥之处,请不吝指正。我的E-mail是:wesley.bao@acm.org。

鲍志云

于南京

2004年7月


Arthur J. Riel从事C和C++编程工作已有超过12年的经验。目前,他每年在学术界和产业界讲授40多次课程。他参与了许多大系统的开发,曾就职于AT&T贝尔实验室、Draper实验室、IBM、东北大学。他还在Journal of Object-Oriented Programming、The C++ Insider、The C/C++ Gazette等刊物上发表了众多文章。他还经常在OOPSLA、Object Expo、SCOOP、C++ World等顶级会议上演讲。

鲍志云 已翻译出版了《对象揭秘:Java、Effiel和C++》、《应用MDA》和《解析MDA》3本译著,曾以“紫云英”为笔名在《程序员》、《程序春秋》等刊物发表多篇技术文章,并为《DDJ软件研发》翻译技术文章。他在学生时代曾参加ACM/ICPC亚洲区比赛并获佳绩,曾参加“挑战杯”学术科技作品竞赛并获全国二等奖。他现供职于趋势中国研发中心(Trend Micro CDC)。


在学习面向对象范型以及相关知识的过程中,你首先必须知道我们社区中的很多对立观点。每组对立观点意味着两个或者多个阵营,他们对自己的观点一般都具有宗教般的热情。最重要的对立观点之一是革命家与改革家之争。革命家相信,有一群开发者某一天在凌晨3点醒来,并发现以前我们一直都在用错误的方式开发软件。他们相信,他们找到了解决软件危机的方法,并且把这种方法叫做“面向对象编程”。或许读者已经猜到,我是属于改革家阵营的。

改革家认为,面向对象编程不是软件开发领域的革命,而是使得开发者远离底层机器细节的持久进步中顺其自然的一步。软件危机的来源并不是我们在用错误的方法开发软件,而是因为我们的编程范型已经不足以应付我们所需解决问题的日益增加的复杂性了。在处于只有8条指令和1KB内存(而且这1KB中90%还被操作系统占据了)的年代,用八进制或者十六进制操作码来写程序是天经地义的。我使用这种编程方法毫无障碍,因为只要记8条指令,要写的代码也不过30行。而当硬件变得更加强健,我开始遇到需要64KB内存的问题,这样一来记忆操作码就变得复杂,超出了我的掌控能力。于是,我开始使用汇编语言助记符。助记符范型非常完美,直到有一天我的问题再次超越它能容忍的复杂性上限。于是我改用高级语言来编程。高级语言的每条指令都能替换很多条汇编语言助记符,这样我就能在更高层次上思考。除了减少语言复杂性,我也在寻找思考问题的更好方式。我不是把一串串的指令塞进小的函数再把这些函数拼成程序,而是用一种结构化的方法来把问题分割成小的子问题,每个子问题的复杂性都在可管理的范围之内。

目前,软件开发的发展又到达了临界点。对于今天的硬件所能处理的问题的复杂性,结构化的、自顶向下的设计方法已经不足以应付了。我们需要找到一种软件开发范型,它要可以应付增加的复杂性。几年以前,菜单驱动的系统还广为使用;今天的软件系统已经必须具有图形用户界面,否则在市场上会乏人问津。在未来的几年,消费者会期望可以与他们对话并显示动态视频的多媒体应用程序。作为一种应付更复杂的应用程序的方法,面向对象范型为什么会引起那么多关注呢?

最主要的原因之一是,迁移到面向对象范型可以让开发者以更接近现实世界的模型来开发复杂的应用程序。在日常生活中,什么是大家公认的特别复杂的东西呢?我们发现,很多以分布的方式彼此交互的机器构成了复杂的系统。并没有中央控制机制来要求所有成员都汇报工作、听取命令、完成命令并回来等着分配更多的工作。结构化方法是按照中央控制的思路设计的。而分布构架的优点在于,任何问题都只会影响应用软件的一个部分。在好的设计中,这个部分和其他部分应该是松耦合的。这样,如果系统的一个部分出了问题,或者我们想扩展该部分,改变所带来的影响就被局限在那个部分,而不会影响全局。既然分布的方式让现实世界接受了这一切复杂性的存在,并且这些复杂的事物都得以顺利运作,那么为什么我们不能以同样方式来应对软件问题的复杂性呢?

对象技术的初学者可能会遇到这样的建议:“当你成为一个真正的面向对象开发者之前,你需要承受一次范型迁移(paradigm shift)。”虽然这似乎有些耸人听闻,但“范型迁移”的说法有其内在真理。软件开发者需要用分布的方式来思考,而不再遵照传统结构化方法的集中控制方式。因为成功的现实世界实体是以分布的方式交互的。我认为,这一范型迁移不仅有其学习曲线,还有其遗忘曲线。你需要忘记你以前学到的面向动作(action-oriented)型软件开发所依赖的集中控制的方式。

关于面向对象编程,无论你支持革命家的观点还是改革家的观点,你学习面向对象范型会遇到的第一件烦恼事就是充斥该领域的时髦词语。例如,我们在本书中要讨论的一个相当简单的概念叫做“多态”(polymorphism),一个具有希腊语词根的单词隐藏了一个简单的概念。而且,这个概念还有很多同义词,比如动态绑定(dynamic binding)、运行时类型识别(runtime-type discrimination[1])。而且还有很多用于修饰这些单词的形容词,比如“纯的”(pure)多态、“真正的”(true)多态。面对这些,你很容易坠入迷惘之网。在新生领域,时髦词语是一种流行病,因为待到技术逐渐成熟至能够用标准语汇来准确表达该技术的概念,还需要相当长的时间。在面向对象领域中,这个问题更为严重,因为计算机科学的很多不同子领域不约而同地在向对象范型迁移,而每个子领域都有它们自己的一套术语。可能一个领域的人很高兴地谈论包含关系和继承关系,另一个领域的人已经把这些概念分别称作“has-a”和“a-kind-of”关系了。我估计,在不久的将来,时髦词语的问题还会常伴我们左右,但等到这个领域成熟了,这个问题也就不复存在了。在这个过渡时期中,我将避免在本书中使用同义词,除非我需要向用户强调一些东西。

Frederick Brooks在1987年10月份的IEEE Computer上发表了一篇有趣的文章,标题是Conceptual Essence of Software Engineering or There Is No Silver Bullet [参考文献1]。Frederick Brooks是The Mythical Man-Month [参考文献2]的作者。The Mythical Man-Month记录了他管理软件开发项目的经验,包括担任IBM360项目经理那两年的经验,是软件工程领域的必读书。他的书描述了他的项目中做对和做错的地方,并且解释了为什么。每个同软件生产有关的人,特别是项目经理,都应当读一下这本书。那篇“No Silver Bullet”(没有银弹)文章是关于他对软件工程的洞察的续篇。在该文中,他谈论了我们为什么会遇上软件危机,为什么没有解决所有问题的万能方法,在未来我们能使用什么有潜力的技术来缓解这个危机。

该文提出的一个基本观点是,造成软件危机的有两种复杂性,一种是非根本复杂性(accidental complexity),另一种是根本复杂性(essential complexity)。非根本复杂性来自不适合于应用软件的范型、方法学和/或工具。只要有足够的资源来创建或者购买各种互为补充的工具,这类复杂性是可以消除的。面向对象编程有助于消除非根本复杂性,因为它提供了一种一致的软件开发范型,包容了分析、设计、实现各个阶段。这不是说面向对象的软件项目不包含非根本复杂性。MIS(Management Information Science,管理信息科学)[2]世界和其他领域面对的是一种特殊类型非根本复杂性。这些组织已经把大笔资金投入到关系数据库技术中去了,而现在正从面向动作往面向对象迁移。关系数据库模式语言的表现力不足以直接描述面向对象世界中数据和行为间的复杂关系。结果就是,面向对象设计者需要把这些复杂的关系翻译成关系数据库中简单的关系。这一翻译带来了非根本复杂性,而大多数MIS公司都愿意接受这一复杂性,因为否则的话他们就需要抛弃已经在用的久经考验的关系数据库产品,而去购买未经严格测试的面向对象数据库。即便在这样的情况下,面向对象范型也使得我们可以通过包装器(wrapper)来控制这种复杂性[3]。包装器是指把应用软件的一个部分同其他部分的非根本复杂性隔离开来的抽象层。我们将在第9章再讨论这种包装器机制。第9章覆盖了面向对象的物理设计问题。

软件危机的真正原因是根本复杂性。根本复杂性来自这一事实:软件本质上就是复杂的,没有哪种方法学或者工具可以消除这一复杂性。软件具有根本复杂性的理由如下:

1.从规模上来说,软件应用程序是人类创建的最复杂的实体。

2.软件是难以把握的,而且大部分是无形的。

3.软件不会像具有移动零件的机器那样会在传统意义上磨损折旧。但是,人们常常会以软件编写者从未想到的方式来使用软件(并常常会发现错误),而且最终用户始终希望他们的软件得以扩展。

以往的范型把软件开发过程当作一条装配流水线,这已经不再正确。请看图1.1所示的传统的软件开发瀑布模型。在这一模型中,分析、设计、编码、测试和维护形成了5个独立的步骤,每个步骤都有精确定义的输入和精确定义的输出。每个阶段的过渡,输出工件都成为经理评估项目进度的依据。没过多久,软件与装配流水线的不同之处就显而易见了。想象一下,如果装配流水线上有一个环节速度太慢而拖累了其他环节,我们能采取的最佳行动是什么?比如,如果装配流水线正在组装洋娃娃,负责安装胳膊的工人太慢了而拖累了整条流水线,那么我们应当怎么办?显然,我们应当指派另外一位工人去协助原来安装胳膊的工人。这样,我们就可以消除瓶颈。那么,在软件工程中也尝试一下这种方法吧,结果多半是灾难性的。如果编码延迟了,项目经理无法简单地给开发团队增加一些人手。如果这样做的话,团队现有成员的生产效率会降低,因为他们需要花时间来指导新来的人。但是,很多公司依然在继续使用瀑布模型来开发项目。

图1.1 软件开发的瀑布模型

或许瀑布模型易于理解,易于跟踪,并且符合经理人员的喜好,因为他们可以通过一系列精确定义的工件来跟踪进度。但是,对大系统开发者而言,这个模型并不能很好工作。事实上,我怀疑开发者是否真地用过这个模型。如果你在设计你的第15个邮件列表程序,这个模型可能会工作得很好。你已经创建过14个邮件列表程序了,现在你只需要稍稍修改一下你已有的分析模型和设计模型,并实现新的模型(这个新模型看上去和以往的14个非常像),然后测试它。但如果把一个负责折叠西装袖子的机器人实时进程控制系统的规约交给写邮件列表的这组开发者,并要求他们用瀑布过程来写出一个好的应用程序,这实在是无法想象的。我有太多的开发者朋友被要求用瀑布模型来应对不熟悉的领域,而他们的反应是:“噢,好吧,我会写那些规约以便让老板高兴,但我会以我想要的方式来编写真正的东西。”当然,错误的规约要比根本没有规约还糟。为什么不建立一个真正反映现实的软件开发过程呢?这样的过程应当包含回头修改设计、增加新的需求以及测试新的想法等要求。这样的过程被称作软件开发的迭代式过程。

软件开发的迭代模型看上去和瀑布模型差不多,区别只在于迭代模型允许开发者沿项目流程往返(见图1.2)。如果我们在为系统的某个部分编写代码时发现了一个设计缺陷,我们可以回到设计阶段来分析并改正它。或者,如果我们在测试系统的一部分时发现了新的系统需求,我们可以回到分析阶段来修正这个问题。在面向动作范型中,迭代模型会带来很多问题。面向动作的软件常常会有很多位于数据和行为之间的隐含依赖关系。再同集中控制机制一结合,你就会发现自己处于这样一个境地:如果触动了已经存在的应用程序的部分,整个系统就会轰然倒塌。如果已经为应用程序编写了90%的代码,那么增加需求或者改变设计是无法接受的。面向对象范型改正了这一问题,它向开发者提供分布式的流程控制,并使他们具备防止数据、行为间隐含依赖性的能力。于是,对面向对象开发者来说,软件开发的迭代模型就成了理所当然的选择。[4]

图1.2 软件开发的迭代模型

但是,迭代模型也不是一点问题都没有。虽然我相信,这个模型准确地反映了从系统构架师角度上看到的开发过程,但是对项目经理来说,它却带来了很大的问题。简而言之,这个模型目前缺乏一系列精确定义的开发里程碑。这并不意味着在整个软件系统构筑完毕移交客户的那一天之前,项目经理无法获得任何反馈;而是表明我们需要新的迭代式的里程碑来在应用系统成型之前就提供必要的反馈。

一种这样的工件(deliverable)是软件原型(software prototype)。原型领域源自这样的认知:现实世界中的复杂实体是逐渐生长而成的(grown),而不是创建而得的(built)。很多开发者都把原型看作是控制当今软件根本复杂性的方法。通过创建原型,应用程序可以一次增长一层,每层都经过彻底的测试,然后才开始下一层的增长。通过这种方法,设计缺陷就可以尽早发现,从而改正的代价也相对较低。而工作原型也可以用作生产率的衡量尺度。通过衡量原型中功能点的数目并将其同伪功能点(尚未真正实现其功能)相比较,我们可以跟踪进度。[5]

原型领域可划分为两大流派。一派认为原型与最终产品应当用同一种语言编写(same-language prototyping),另一派认为原型与最终产品应当用不同的语言编写(different-language prototyping)。相同语言原型带来的好处看起来简直无法拒绝:使用同一种语言,这意味着同一套工具,相同的参考书,相同的学习曲线,相同的培训课程。一点也不奇怪,这两大流派中相同语言原型被更多地运用。

最后的原型终将成为产品。这一模型的主要缺点是,在创建原型过程中,编写垃圾代码(缺少修饰,缺乏效率、健壮性和可扩展性)是允许的(如果不说是被鼓励的话)。毕竟,创建原型的目的是测试设计想法和需求的可行性,而不是实现最终产品。在这个领域,很多程序员都忍不住要这样说,“我知道这个代码缺少前面提到的那些特性,但毕竟它只是个原型,以后我会重写应用程序的这个部分。”当然,6个月之后,项目延期了,“以后”永远不会成为现在,垃圾代码则随产品被交付了。

相同语言原型的另一个问题是,如果软件平台不在前端出现,语言问题有时可能会影响设计者对系统开发的判断。例如,C++开发者常常会遇到设计问题,因为他们的语言是一种多范型语言(multiparadigm language),而不是像SmallTalk那样的纯粹面向对象语言(pure object-oriented language)。多范型语言允许开发者很容易地混合使用面向对象范型和面向动作范型。而纯粹面向对象语言则诱使开发者使用面向对象范型。使用多范型语言来进行面向对象开发的一个缺点是,开发者很容易倒退到直接的面向动作开发过程中去。如果开发者使用纯粹的语言,那么他就可以确保使用面向对象开发的构造。(请注意,不要把“面向对象”与“好”相等同。使用纯粹语言的开发者也可能是糟糕的面向对象开发者,可能会开发出无法维护的系统,虽然系统是用面向对象的结构来搭建的。不必惊讶于面向对象范型也允许使用者创建糟糕的系统。没有哪种技术能防止这样的事情发生。)

在不同语言原型中,我们可以把上面讨论的优势和劣势交换一下。两条学习曲线,两套工具等等令人生畏;此外,说服你的经理并说服你自己,编写5000行代码然后把它扔进垃圾桶是件好事,也颇具难度。我会首先争辩说,代码本身没什么意义,从代码提炼出来的无形的设计才是真正有价值的。但是,你依然会遇到很大的心理阻力,特别是在仍旧使用编写的代码数量来衡量生产力的公司环境中。而从不“偶然地”交付垃圾代码,可以在纯粹的面向对象环境中工作而不必理会应用程序的目标语言,这些优势也足以吸引很多公司采用这种创建原型的方法。典型的语言组合是,用SmallTalk来创建原型,用C++作为应用程序的目标语言。

控制根本复杂性的另一种方法是干脆避免开发软件。如果能买到软件,何必要创建它呢?我们的MIS开发者并不创建他们的关系数据库,而是购买现成的产品。如果你需要电子数据表格,你也不会自己创建一个,你会从Lotus、Microsoft、Borland或者其他的供应商购买。购买软件的优势是要比创建软件便宜,特别是在考虑了维护成本的情况下。主要的劣势则是,你获取的功能就是他们提供的功能。你自己创建的软件可以精确地满足你的需要,但是买来的软件很可能并非如此。

现在很多人不使用“购买软件”这个术语,而是说“复用软件”。“软件复用”在面向对象社群中成了一个主要的时髦词语。为什么在面向对象世界中软件复用这样振奋人心?我们一直都在复用软件。多少开发者在他们最喜欢的编程语言中用过if语句?多少C程序员用过printf?多少Pascal程序员用过writeln?为什么人们再次对软件复用展开讨论呢?我们知道,代码的尺寸(或者说粒度)和它的灵活性成反比。if语句很小,所以很灵活;而Lotus 1-2-3是大尺度的复用,但它的目标很专一。而面向对象范型中的那些构造在粒度和灵活性间取得了较好的平衡。不幸的是,面向对象程序所达到的软件复用层次让很多开发者失望了。这一失望有几个原因,我们将在本书讨论相关面向对象构造的章节中展开讨论。

Brooks在“No Silver Bullet”一文中提到的作为控制根本复杂性方法的最后一个话题是在企业中建立一个优秀软件设计者阶层,让他们从大批初级设计者中选拔接班人。这可以同管理者阶层类比:高级经理位居顶端,并且从大批初级经理中选拔接班人。这触及了软件开发者间“艺术还是科学”这一争论的核心。软件开发能力是后天习得的,还是需要天赋?我不想参加这一争论,但我想做个类比。如果有人拿枪指着我的脑袋逼我在一年内学会弹钢琴(我之前从未学过乐器),我不会觉得太困惑。如果事关自己的生命,我当然可以学会弹钢琴。但是,如果那个拿枪指着我的人逼我在三年内成为一个伟大的钢琴家,那我恐怕只能让他开枪了。

不管伟大的设计者是天生的还是后天培养出来的,我认为“建立一个优秀软件设计者阶层”这一做法有一个严重缺陷。这也是目前一些公司管理者阶层所存在的缺陷。新的想法来自何处呢?传统上,新念头来自底层(草根阶层)的运动。若建立等级层次,那么我们就可能迟滞不前。作为例证,我发现了有趣但也令人吃惊的事实:结构化设计和逐步细化方法很大程度上来自学术界,而工业界则不太情愿接受;而面向对象编程技术则来自工业界和实验室,学术界则不太情愿接受(当然也有不少例外)。我相信,学术界不情愿向本科生讲授面向对象编程技术是因为那些已经走上高等级层次的教授们以前一直都是在宣扬面向动作(action-oriented)的好处。而面向对象社群声称“我们30年来一直在用错误的方法开发软件,面向对象编程才是正确的方法”,这更是加大了与学术界的隔阂。

我们现在已进入这样一个时代,软件开发的复杂性已不是结构化方法所能应付的。而随着硬件性能呈指数增长,未来的软件只会更加复杂。我们所面临的问题是,能否发明一种软件开发方法学,能够提供这样的可能性:消除非根本复杂性,并且至少是控制根本复杂性?我相信,面向对象范型通过分散控制流、相互关联的数据和行为、隐含的情况分析(即多态)和信息隐藏机制,再配合快速原型和软件开发的迭代模型,为实现这一目标提供了最佳的可能性。[6]本书的剩下部分将讨论关于面向对象范型的很多问题,并讨论如何追踪和解决这些问题,让面向对象范型所提供的可能性更接近现实。

Accidental complexity

非根本复杂性。在应用程序中使用不合适的软件开发范型或者工具造成的复杂性。

Different-language prototyping

不同语言原型化。一种创建原型的方式,用来创建原型的语言和项目语言不同。

Essential complexity

根本复杂性。因为应用软件本身的性质造成的复杂性。

Multiparadigm language

多范型语言。既支持面向对象范型又支持面向动作范型的编程语言。

Paradigm shift

范型迁移。从旧的编程模型到新的编程模型的转移。

Programming paradigm

编程范型。开发软件的模型。

Pure object-oriented language

纯面向对象语言。只支持面向对象范型的语言。

Same-language prototyping

相同语言原型化。一种创建原型的方式,用来创建原型的语言和用来创建最终产品的语言是一样的。

Software prototype

软件原型。应用软件的模型,用来测试软件设计、实现或者解决方案的可行性。常常为了可以快速完成而省略了可扩展性、效率和稳定性这些特性。

Software prototyping

软件原型化。创建最终应用软件产品的模型的行为,用来测试或者证明特定软件的设计、实现或者解决方案的可行性。

Iterative model (of software development)

(软件开发的)迭代模型。一种设计软件的灵活的模型,它意识到软件开发是一个迭代的过程,必须允许实践者可以修改已有的成果来改正前面犯下的错误。

Waterfall model (of software development)

(软件开发的)瀑布模型。一种设计软件的不灵活的模型,它注重的是产生精确定义的工件的里程碑,开发过程是单向的,也就是说,一旦到达一个里程碑,那么前面所做的步骤就不能改变了。

[1] 译注:现在更多是称作RTTI。

[2] 译注:现在更多是指管理信息系统。

[3] 译注:现在很多O/R Mapping产品就是这样的wrapper。

[4] 译注:面向对象范型是通过解耦合来使得修改系统已有部分变得容易且不会引入bug,但这还不够,所以人们提出通过重构(refactoring)和单元测试(unit testing)使得迭代开发更容易。而在解耦合方向上的新的探索则包括AOP、MDA等。

[5] 译注:作者在这里只是粗略提及这样的开发方式,本书出版几年后的今天,这样的开发方式已经成为现实。

[6] 译注:从现在看来,面向对象范型还不足以解决这些问题。AOP、IP等都是新的努力。也有人把这些相关的“非面向对象”的努力归纳为Generative Programming。OMG正主推的MDA也是基于Code Generation的技术。


面向对象范型使用类和对象的概念作为基本建筑材料。应用程序的分析、设计、实现模型一致地使用这些概念。通过现实世界中的例子来解释这些概念是最佳方案。如果有一屋子的人,你问:“给你们所需的全部零件,谁能装配出一只闹钟”?最多有一两个人会举手。但如果你问他们“这个房间里谁能够把闹铃设到早上9点”,那么我可以放心地和你打赌,大多数人都会举手。大多数人会使用闹钟,但不会装配闹钟,这难道不荒谬吗?对这个问题,你最直接的反应当然是“当然不荒谬,你的问题才荒谬呢”!

在现实世界中,有很多东西是我们会使用但不会制造的,比如冰箱、汽车、复印机、计算机等等。这只是列举了一小部分。为什么我们可以不知道它们的实现却能轻松使用它们?因为它们被设计为通过一个精确定义的公有界面[1]来使用。这个公有界面极大地依赖于内部的实现,但又向用户隐藏了内部实现。这一设计策略还允许闹钟制造商把目前闹钟用到的60个小零件替换成进口的3个子部件,而闹钟的使用者对此不会有意见。

公有界面与实现的另一个例子可见于汽车行业。很少有驾车者介意机械点火系统(配电盘、电插座、电容器)到电子点火系统的转变。为什么?因为公有界面保持了一致,改变的只是实现。但是,请想象一下,如果你去汽车经销 商处购买新车,经销商递给你一把钥匙,并让你试车。你坐在驾驶座上,寻找点火装置的钥匙孔。你从驾驶杆找到仪表板,再搜索相邻区域,却寻不着钥匙孔。你问经销商如何发动汽车。经销商说,“噢,这个型号是这样的,你用钥匙打开旅行箱,然后你会看到一个红色按钮。按一下那个按钮汽车就会启动了。”你会感到不安,因为汽车制造商改变了你熟悉的公有界面。

面向对象范型的一个基本想法就是这样。所有构成系统的实现细节都应该隐藏在精确定义并且一致的公有接口后面。使用这些构造的用户需要知道这个公有接口,但你不让他们看见实现细节。这样,如果需要,实现者就可以改变实现细节,只要公有接口保持不变就行。我经常旅行,可以向你保证,不需要知道实现细节就能使用闹钟实在很方便。我曾在许多旅馆住过,用过很多种闹钟,有用电的,有需要上发条的,有依靠电池的,有数字型的,有模拟型的。但是,我坐在飞机上时从未忧虑过不会使用将到达的旅馆房间里的闹钟。

我提到“闹钟”这个词后大多数读者都会知道我指的是什么,虽然可能你身边并没有闹钟。为什么?因为你曾经看见过很多闹钟,并且知道,所有的闹钟都有一些共同的属性,比如时间、闹铃时间(都按小时和分钟显示)以及闹铃开关。你还知道,你看到过的所有闹钟都允许你设置它们的时间和闹铃时间,并且允许你打开或者关闭闹铃。这样,你就有了一种叫做“闹钟”的概念,这一概念用一个简洁的组合表示了所有闹钟的数据和行为。这种概念称作类(class)。而你拿在手上的闹钟实物叫做闹钟类的对象(object)或者实例(instance)。类和对象之间的关系叫做实例化关系(instantiation relationship)。我们说,闹钟对象是从闹钟类实例化(instantiate)而来,闹钟类是你遇到的所有闹钟对象的泛化(generalization)[2](参见图2.1)。

图2.1 闹钟和它的对象

如果我告诉你,我的闹钟从我的床头几上跳起来,咬了我一口,然后去追邻居的猫了,你一定会认为我疯了。但如果我告诉你,我的狗做了这些事情,你会觉得这挺合理的。这是因为,类的名字不仅意味着一组属性,还表示实体的行为。这种数据和行为的双向联系是面向对象范型的基石之一。

一个对象一定会有如下4个重要方面:

1.它自己的身份标识(可能只是它在内存中的地址);

2.它的类的属性(通常是静态的)和这些属性的值(通常是动态的);

3.它的类的行为(从实现者的角度看);

4.它的类的公开接口(从用户的角度看)。

将这一讨论置于软件开发的语境,类可以被实现为一个结构定义以及一组可以处理这个结构的操作。在过程式语言中,任给一个函数,很容易找出数据依赖性。只要检查函数实现并看一下所有参数、返回值以及局部变量声明的数据类型就可以了。但是,如果你想要找出一个数据定义的函数依赖性,那你就不得不检查全部代码,寻找依赖于这个数据的函数。而在面向对象模型中,两种依赖性(函数对数据的依赖性和数据对函数的依赖性)都现成摆明在那里了。对象是类数据类型的变量。它们的内部细节只对同它们的类关联的那组函数可见。这种对内部细节的访问限制称作信息隐藏(information hiding)。在很多面向对象语言中,这种隐藏不是强制的,这样我们就有了第一条(也是最重要的一条)经验原则。

所有数据都应该隐藏在它所在的类内部。

违反这条经验原则意味着你不重视可维护性。面向对象范型所带来的益处,大部分归因于在设计阶段和实现阶段始终确保信息隐藏。如果你把数据设定为公有,那么就很难判断系统哪部分的功能依赖于这个数据。事实上,这样一来,数据变动与函数的映射关系就和面向动作范型一模一样了。我们不得不检查所有的函数以判断哪些函数依赖于公有数据。

有时开发者会争辩说,“我需要把这个数据设为公有,因为……”在这种情况下,开发者应该问自己,“我到底要用这个数据来做什么?为什么不是类为我提供这个操作?”在所有这类情况下,问题出在类缺少了一个必需的操作。比如,考虑图2.2中的File类。开发者出人意料地认为,byte_offset数据成员应该是公有的,这样才能允许随机I/O访问。但是,我们实际上需要的是执行随机访问任务的操作。(如果你不是C程序员,那么我在这里补充说明一下:fseek和ftell和标准C库函数,用于执行文件的随机I/O访问。)冒昧地认为“我们可以把这个数据设为公有,因为它永远也不会改变”的程序员请注意,Murphy关于编程的一条定理表明,这是第一个需要改变的数据。

图2.2 不应出现的公有数据

通过下面的例子,我们可以进一步描述数据隐藏带来的好处。这是一个点类,它的实现采用了直角坐标系(参见图2.3)。天真的设计者可能会争辩说,我们可以把点的x坐标和y坐标设为公有,因为实现永远也不会改变。但是,不可避免地,某些新的需求会迫使你改用极坐标系,从而会影响使用这个点类的所有用户。如果我们把数据隐藏起来,那么只有类的实现者需要改变他们的代码。

图2.3 公有数据的危险性

对象应当被看作机器,机器只为提出恰当请求的人执行公有接口所定义的操作。因为对象独立于使用者,也因为一些实现了面向对象概念的早期语言的语法,术语“发送消息”用于描述执行对象的行为。当消息被发送至对象,它必须判断是否理解该消息。如果理解,那么对象就把消息映射为一个函数调用,并把自身作为隐含的第一个参数传递过去。对解释语言而言,判断是否理解一个消息是在运行时完成的,而编译语言则是在编译时完成的。

对象行为的名称(或者原型)被称作消息(message)。许多面向对象语言都支持重载函数(overloaded function)或者操作符。这一构造的约定是,系统中的两个函数可以有相同的名字,只要它们的参数类型不同(类内重载)或者所属的类不同(类间重载)就可以了。闹钟类可以有两个不同的set_time消息,一个消息用两个整数作为参数,另一个消息用一个字符串作为参数。这是一个类内重载的例子。

  void AlarmClock::set_time(int hours, int minutes); 
  void AlarmClock::set_time(String time);

此外,闹钟和手表可能都有set_time消息,它们可能都以两个整数作为参数。这是一个类间重载的例子。

  void AlarmClock::set_time(int hours, int minutes); 
  void Watch::set_time(int hours, int minutes);

值得一提的是,消息的组成部分包括函数名、参数类型、返回值类型,以及消息所属的类。这是类的使用者所需知道的主要信息。在一些语言和/或系统中,可能还会有其他信息,比如消息抛出的异常的类型,以及其他相关的同步信息(比如,消息是同步的还是异步的)。类的实现者必须知道如何实现消息。消息的实现,也即实现消息的代码,被称作方法(method)。一旦控制进入方法内部,对接收消息的对象的全部数据成员都是通过隐含的第一个参数引用的。这个隐含的第一个参数在很多语言中都称作“self对象”(C++则偏爱称其为“this对象”)。对象所能响应的消息列表被称作对象的协议(protocol)。

类/对象可以响应两种特殊的消息。第一种是用于为了创建类的对象而调用的操作。这称为类的构造函数(constructor)。类可以有多个构造函数,每个构造函数接受一组不同的初始化参数。例如,我们可以通过传递5个整数参数分别指明小时、分钟、闹铃小时、闹铃分钟、闹铃状态来构造闹钟;我们也可以传递两个字符串和一个整数参数,每个字符串都是“小时:分钟”格式,分别表明时间和闹铃时间;而整数则表明闹铃状态。有的类甚至可以有十几个或者更多构造函数。

类/对象能够响应的第二种特殊的消息是在把对象从系统删除之前清除对象内容的操作。这个操作称为类的析构函数(destructor)。大多数面向对象语言每个类都只有一个析构函数,因为在运行时需要做出的任何决定都可以保存为对象状态的一部分,没有必要再给方法传递额外的参数。我们将在书中多处提及构造函数和析构函数。你可以认为它们是面向对象范型的初始化和清除机制。

类的使用者必须依赖类的公有接口,但类不能依赖它的使用者。

这条经验原则背后的基本原理是可复用性。闹钟可以用于卧室(参见图2.4)。使用闹钟的人显然依赖于闹钟的公有界面。但是,闹钟不应当依赖于那个人。如果闹钟依赖于使用者,比如说那个在卧室中用闹钟的人,那么闹钟就无法被用来制造定时锁保险箱,除非把那个人也绑定在保险箱上。这样的依赖性是不受欢迎的,因为我们想要把闹钟用于其他的领域,而不想为此依赖于使用者。所以,最好把闹钟看作一个小型机器,这个小型机器对它的使用者一无所知,它仅仅是执行定义于公有界面的行为,而不管发送消息的是谁。

图2.4 使用闹钟

尽量减少类的协议中的消息。

就在几年前,还有人撰文提倡刚好与这条经验原则相反的实践。当时是这样说的:关于这个类的操作,凡是类的实现者能想象到的,将来就会有用户用到。那么,既然如此,为什么不实现这些操作呢?如果你采纳这样的经验原则,那么你肯定会钟爱我的链表类——它的公有接口有4 000个操作。问题时,当你想对两个链表对象执行合并操作时,你认为链表类一定提供了这个操作,所以你依照字母顺序检查消息列表,但是却找不到哪个操作是以merge、union、combine或者你知道的其他同义词命名的。不幸的是,真正的操作是一个重载的加号(在C++中是operator+)。庞大的公有接口的问题是,你永远都无法找到你想要找的东西。这严重损害了接口的可复用性。而如果让接口最小化,我们就可以让系统易于理解,并使组件易于复用。

实现所有类都理解的最基本公有接口[例如,拷贝操作(深拷贝与浅拷贝)、相等性判断、正确输出内容、从ASCII描述解析等等]。

如果一个开发者设计和实现的类要被另一个开发者在其他应用程序中复用,那么提供一个常用的最小公有接口常常很有用。[3]这个最小公有接口包含的功能是人们合理地预期每个类都会有的。我们可以把这个接口当作了解可复用软件代码中类的行为的基础。我们将在第9章中更详细地探讨关于这个最小公有接口的事项。

不要把实现细节(例如放置共用代码的私有函数)放到类的公有接口中。

这条经验原则用于为使用者降低类接口的复杂性。基本想法是,类的使用者不想在公有接口中看见他们不用的成员。这些成员属于类的私有区域。如果类的两个方法有一段公共代码,那么就可以创建一个防止这些公共代码的私有函数。把这些公共代码封装成一个独立方法常常会带来方便,但是这个方法并不是一个新的操作,它只是类中两个操作的实现细节。因为是实现细节,所以它应当放在类的私有区域中,而不是公共区域中(参见图2.5)。

图2.5 公共代码私有函数示例

为了让你对公共代码私有函数有更贴近实际的了解,你可以认为类X是一个链表,f1和f2是函数insert和remove,公共代码私有函数f是在链表中找到插入点或者删除点位置的操作。

不要以用户无法使用或不感兴趣的东西扰乱类的公有接口。

这条经验原则与前一条是相关的,因为类的用户不会想调用公共代码函数,所以把这些函数放在公有接口中只会扰乱类的公有接口。它们并不是类的新操作。有些语言,比如C++,允许在公有接口中错误地包含其他类型的函数。例如,在C++中把抽象类的构造函数放在那个类的公有接口中是合法的,虽然当类的使用者试图使用这样的构造函数时编译器会报告一条语法错误。若遵循更一般化的经验原则2.6,那么这些问题就不会发生了。

一些经验原则用于解决类的耦合与内聚问题。我们努力让类更紧密地内聚,并尽量降低类间耦合程度。这和在面向动作范型中试图让函数更紧密地内聚并尽量降低函数间的耦合程度的努力是一致的。函数中的紧密内聚意味着组成函数的所有代码都是紧密相关的。函数间的松耦合意味着当一个函数想要使用另一个函数时,它应当在总是从同一点进入该函数,并从同一点退出。这样,我们就可以得出这样的面向动作的经验原则:“函数应当只有一条返回语句。”

在面向对象范型中,我们把松耦合和紧内聚的目标映射到了类的层次。类之间有5种形式的耦合关系:零耦合(nil coupling)是最佳的,因为这意味着两个类丝毫不依赖于对方。你可以去掉一个类,而不会影响另一个。当然,如果只用到零耦合,你无法创建有意义的应用程序。若只用到零耦合,我们最多只能创建类库,这样的类库由一系列的独立类组成,这些类相互之间没有影响。导出耦合(export coupling)则表明,一个类依赖于另一个类的公有接口。[4]也就是说,这个类用到另一个类的一个或多个公有操作。授权耦合(overt coupling)则意味着一个类经允许使用另一个类的实现细节。C++的友元机制是授权耦合的典型例子。一个C++类X可以声明类Y是它的友元。这样,Y的方法就获得授权可以访问X的实现细节。自行耦合(covert coupling)和授权耦合差不多,也是类Y访问类X的实现细节,但区别在于类Y是未经授权的。如果我们发明一种语言机制,允许类Y声明自身是X的友元并且将使用X的实现细节,那么X和Y就是自行耦合的。最后一种耦合是暗中耦合(surreptitious coupling),这种耦合是指类X通过某种方式知道了Y的实现细节。如果类X使用类Y的公有数据成员,那么X就和Y暗中耦合。暗中耦合是最危险的耦合形式,因为它在Y的行为和X的实现之间建立了很强的隐式依赖关系。

类之间应该零耦合,或者只有导出耦合关系。也即,一个类要么同另一个类毫无关系,要么只使用另一个类的公有接口中的操作。

所有其他形式的耦合都允许类把实现细节暴露给其他类,这样就在两个类的实现之间建立了隐含依赖关系。将来如果一个类想要修改它的实现,那么这些隐含依赖关系总会带来维护问题。

类内聚努力确保类内部的所有元素都是紧密关联的。有一些经验原则牵涉到这一属性。

类应当只表示一个关键抽象。

一个关键抽象(key abstraction)被定义成领域模型中的一个主要实体。关键抽象经常以名词形式出现,并伴随着需求规约。每个关键抽象都应当只映射到一个类。如果它被映射到多个类,那么设计者可能是把每个功能都表示为一个类了。如果多个关键抽象被映射到了同一个类,那么设计者可能在创建一个集中化的系统。这些类经常被称为含糊的类(vague classes),并且需要分割成两个或多个类,每个类表示一个关键抽象。第3章我们将更详尽地探讨这两种不良设计。

把相关的数据和行为集中放置。

如果违反这条经验原则,那么开发者就不得不按以往方式编程。为了实现单一的系统需求,开发者不得不改动系统的两处或者多处。其实这两处(或者多处)是同一个关键抽象,所以应当用同一个类表示。设计者应当留意那些通过get之类操作从别的对象中获取数据的对象。这种类型的行为暗示着这条经验原则被违反了。考虑一下一个烤炉类的使用者想要在烧烤之前预热烤炉。用户应当只需要发送给烤炉一条are_you_preheated?()消息就可以了。烤炉应当可以测试自己的温度是否已经达到了需要的温度,并且测试其他预热需要满足的条件。如果用户为了知道烤炉是否已经预热,需要问烤炉目前温度、期待温度、燃气阀的状态、常燃火状态等等,那么就违反了这条经验原则。烤炉拥有这些温度和燃气烹饪设备的信息,它应当自行判断这个对象是否已经预热了。留意那些为了实现不正确的预热方式而需要用到的get方法(比如,get_actualtemp()、get_desiredtemp()、get_valvestatus()等等)是很重要的。

把不相关的信息放在另一个类中(也即:互不沟通的行为)。

开发者应当留意这样的类:方法的一个子集操作数据成员的一个真子集[5]。极端情况是,一个类有一半方法操作一半数据成员,另一半方法则操作另一半数据成员(见图2.6)。

图2.6 具有互不沟通的行为的类

这是一个更接近现实世界的例子。请考虑词典类。对于小型词典,最好的实现是属性列表(单词和它们定义的列表),但是对大型词典来说,哈希表更好(更快)。两种辞典的实现都需要提供增加单词和寻找单词的能力。图2.7展示了一个具有互不沟通的行为的词典类设计。

图2.7 互不沟通的行为(现实世界例子)

这个解决方案假设词典类的使用者知道词典将会有多大。他们需要做出决定,是使用哈希表实现的词典还是链表实现的词典。一般而言,在类名中显示实现细节并让用户来做这样的选择不是好主意。一个更好的解决方案留在第5章讲述,因为它要用到继承。在那个解决方案中,一个单一的词典类把它的实现隐藏为内部细节。如果词典的大小增长到了一个事先定下的临界值,词典类会决定改变实现。

除了固定的数据和行为的描述之外,对象在运行时还随着其数据描述的动态取值具有局部状态(即当时的“快照”)。类的对象的所有可能状态的集合以及状态间合法的变换称为类的动态语义(dynamic semantics)。动态语义允许对象对其生命期的两个不同时候发来的相同消息作出不同的回应。例如,看这个抽象例子:

   Method junk for the class X 
      if (local state #1) then
        do something
      else if (local state #2) then
        do something different
   End Method

对象的动态语义是任何面向对象设计的有机组成部分。一般而言,任何具有有意义动态语义的类都应当用一个状态转换图(参见图2.8)来把这些动态语义归档。具有有意义动态语义的类是指具有有限状态和精确定义的状态变换的类。图2.9所示的状态转换图详细描述了某个操作系统中进程的动态语义。它表明,进程的状态可以是就绪、当前进程、阻塞、睡眠和退出。此外,进程创建时只能是就绪状态,它们只能在退出状态被销毁,它们只能在当前进程状态时才能退出。这些信息对于为类及其对象创建测试集(test suite)非常有用。有些设计者偶尔会把动态语义建模成静态语义,这会导致类的数目大量膨胀——这是面向对象范型中的一个严重问题。我们将在第5章讨论继承关系的时候探讨这个问题以及避免这个问题的方法。

图2.8 状态转换图表示法

图2.9 某种操作系统中过程的状态转换

除了我们已经讨论过的类,还有一种重要的抽象类型是我们需要探讨的。请思考下列问题:你曾经吃过水果吗?你曾经吃过开胃菜吗?你曾经吃过甜点吗?很多人对这3个问题的答案都是“是”。只要你对这3个问题中的任一个回答了“是”,请你接着思考下面的问题:水果尝起来味道如何?一份甜点有多少卡路里的热量?一份开胃菜价格是多少?

我可以说,没有人吃过“水果”。很多人吃过苹果、香蕉或者桔子,但没有人吃过一个3斤重的、红色的就叫做“水果”的东西。类似地,当你坐在餐厅中,服务员走来问你想吃些什么时,你回答“一份开胃菜、一份主菜还有一份甜点”,如果这时服务员就转身走了,你就有麻烦了,因为你喜欢虾,而不喜欢瓜(两种可能的开胃菜)。我们认可,没有“水果”、“开胃菜”或者“甜点”这样的对象,但是这些名词确实表达了有用的信息。如果我拿起一只闹钟对你说:“你觉得我的水果怎么样?”你会认为我疯了;而如果我拿起一只苹果问同样的问题,你就会觉得很正常。“水果”这个称谓表达了有用的信息,虽然你不能创建水果对象。事实上,它是一个类(概念),但不知道如何实例化它这种类型的对象。

不知道如何实例化对象的类称为抽象类(abstract class)。

知道如何实例化对象的类称为具体类(concrete class)。

请留心我们经常使用的术语“抽象数据类型”(ADT)。有的时候,它被用作“类”的同义词,并且不区分抽象类和具体类。

在面向对象范型中,抽象类的一个重要用途是帮助创建继承层次结构。它们表达了类别名称(见图2.10)。我们将在第5章讨论它们的用处。

图2.10 类表示类属信息

确保你为之建模的抽象概念是类,而不只是对象扮演的角色。

“母亲”或者“父亲”是不是类,还是某个“人”对象所扮演的角色?答案取决于设计者为之建模的领域是什么。如果在给定的领域中,母亲和父亲具有不同的行为,那么或许他们应当被建模为类。如果他们的行为相同,那么他们只是“人”类的对象所扮演的不同角色。例如,我们可以把家庭看作“父亲”类的对象、“母亲”类的对象和几个“子女”类的对象所构成的对象,也可以把家庭看作一个称为“父亲”的“人”对象、一个称为“母亲”的“人”对象和一组称为“子女”的“人”对象构成的对象(参见图2.11)。区分只在于不同的行为。在创建不同的类之前,请确保它们的行为确实是不同的,而不是每个角色只使用“人”的能力的一个子集。请记住,一个对象只用到它的类的行为的一个子集是毫无问题的。

图2.11 一个家庭的两种视图

有些设计者的做法是,测试一下公有接口中有没有哪个成员对于特定的角色无法使用。如果有这样的成员,那么就意味着需要另一个类。如果它只是没有被用到,那么它只是被用作多个角色的同一个类。例如,如果“母亲”的一个操作是go_into_labor()(分娩),那么“父亲”最好实现为另一个独立的类,因为父亲是无法分娩的。但是,如果这个家庭生活在一个父系社会中,只有母亲才会执行change_diaper()(换尿布)方法,那么“母亲”只是“人”类所扮演的一个角色。得出这一结论的理由是因为如果有必要的话,父亲也可以执行change_diaper()方法。但是,在更抽象的领域,若那个领域中“无法执行”与设计者或者领域选择“不去执行”的差异并不明显,那么这种方法就难以奏效了。

在设计过程中,面向对象设计者需要决定是否把一个特定的角色塑造成一个类。这就意味着我们还需要一条经验原则来指导这一决定。下面的章节将尝试给出这样的经验原则,但我对结果并不完全满意,因为这条经验原则并不是在所有领域中都适用的。

Abstract class

抽象类。不知道如何实例化自身对象的类。

Class

类。以双向联系的方式封装数据和行为的构造。与现实世界中的一个概念对应。抽象数据类型(ADT)是类的同义词。

Concrete class

具体类。知道如何实例化自身对象的类。

Constructor

构造函数。类的一个特殊的操作,负责创建/初始化该类的对象。

Destructor

析构函数。类的一个特殊的操作,负责销毁/清除该类的对象。

Dynamic semantic

动态语义。类的对象所能具有的所有可能状态,以及这些状态之间被允许的转换的集合。常用状态转换图来表示。

Information hiding

信息隐藏。类向该类的对象的使用者隐藏它的实现细节的能力。

Instantiation relationship

实例化关系。类和它的对象之间的关系。我们说类实例化对象。

Key abstraction

关键抽象。关键抽象被定义成领域模型中的一个主要实体。关键抽象经常表现为领域词汇中的一个名词。

Message

消息。类中定义的操作的名称。在强类型语言中,消息可以包含名称、返回类型以及操作参数类型(也即操作的原型)。

Method

方法。消息的实现。

Object

对象。属于它的类的一个样例,包含它自己的标识、类的行为、类的接口、类的数据的一份拷贝。也称为类的实例。

Overloaded function

重载函数。系统中的两个函数可以有相同的名字的能力,只要它们的参数类型不同(类内重载)或者所属的类不同(类间重载)。

Protocol

协议。类能响应的消息列表。

Self object

Self对象。控制位于方法内部时,接受消息的对象的引用。

经验原则2.1 所有数据都应当隐藏在它所在的类内部。

经验原则2.2 类的使用者必须依赖类的公有接口,但类不能依赖它的使用者。

经验原则2.3 尽量减少类的协议中的消息。

经验原则 2.4 实现所有类都理解的最基本公有接口[例如,拷贝操作(深拷贝与浅拷贝)、相等性判断、正确输出内容、从ASCII描述解析等]。

经验原则2.5 不要把实现细节(例如放置共用代码的私有函数)放到类的公有接口中。

经验原则2.6 不要以用户无法使用或不感兴趣的东西扰乱类的公有接口。

经验原则 2.7 类之间应该零耦合,或者只有导出耦合关系。也即,一个类要么同另一个类毫无关系,要么只使用另一个类的公有接口中的操作。

经验原则2.8 类应当只表示一个关键抽象。

经验原则2.9 把相关的数据和行为集中放置。

经验原则2.10 把不相关的信息放在另一个类中(也即:互不沟通的行为)。

经验原则2.11 确保你为之建模的抽象概念是类,而不只是对象扮演的角色。

[1] 译注:原文为interface,既可译为“界面”也可译为“接口”。此中译本根据上下文选择其一。译者大体上的处理原则是,若interface是对人的,译作“界面”;若是对机器或程序的,译作“接口”。

[2] 译注:也有译为归纳、一般化的,此中译本选择“泛化”这一译法。

[3] 译注:特别是Framework设计尤其如此。很多Framework设计时都在根类中提供了这一最小公有接口(单根继承结构)。

[4] 译注:在Eiffel中可以显式地声明导出关系。

[5] 译注:子集(subset)和真子集(proper subset)的区别在于,一个集合是其本身的子集,但不是其本身的真子集。


相关图书

秒懂设计模式
秒懂设计模式
面向对象开发参考手册
面向对象开发参考手册

相关文章

相关课程