面向对象设计实践指南:Ruby语言描述

978-7-115-33245-5
作者: 【美】Sandi Metz
译者: 张雪平彭晓东
编辑: 杨海玲

图书目录:

详情

本书与面向对象软件设计有关。它不是一本学术巨著,它讲述的是程序员如何编写代码的故事。它讲授了如何编排软件以便让今天更富有成效,同时也能让下月以及明年继续保持住这种效果。它展示了如何让编写应用程序,让它不只是在当前能获得成功,同时还要适应未来的变化。它能让你在应用程序的整个生命周期里提高生产率,减少成本。

图书摘要

PEARSON

Practical Object-Oriented design in ruby An Agile Primer

面向对象设计实践指南 Ruby语言描述

[美]Sandi Metz 著

张雪平 彭晓东 译

人民邮电出版社

北京

图书在版编目(CIP)数据

面向对象设计实践指南:Ruby语言描述/(美)梅茨(Metz,s.)著;张雪平,彭晓东译.--北京:人民邮电出版社,2013.11

书名原文:Practical Object-oriented design in Ruby:an agile Primer

ISBN 978-7-115-33245-5

Ⅰ.①面… Ⅱ.①梅…②张…③彭… Ⅲ.①计算机网络—程序设计 Ⅳ.①TP393.09

中国版本图书馆CIP数据核字(2013)第230826号

内容提要

本书是对“如何编写更易维护、更易管理、更讨人喜爱且功能更为强大的Ruby应用程序”的全面指导。为帮助读者解决Ruby代码难以更改和不易扩展的问题,作者在书中运用了多种功能强大和实用的面向对象设计技术,并借助大量简单实用的Ruby示例对这些技术进行全面解释。

全书共9章,主要包含的内容有:如何使用面向对象编程技术编写更易于维护和扩展的Ruby代码,单个Ruby类所应包含的内容,避免将应该保持独立的对象交织在一起,在多个对象之间定义灵活的接口,利用鸭子类型减少编程间接成本,合理运用继承,通过组合构建对象,设计出最划算的测试,解决不良设计的Ruby代码所导致的常见问题等。

本书适合所有对面向对象设计和Ruby编程语言感兴趣的程序员阅读参考。

◆著 [美]Sandi Metz

译 张雪平 彭晓东

责任编辑 杨海玲

责任印制 程彦红 焦志炜

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

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

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

北京鑫正大印刷有限公司印刷

◆开本:800×1000 1/16

印张:14.25

字数:310千字  2014年1月第1版

印数:1-3000册  2014年1月北京第1次印刷

著作权合同登记号 图字:01-2013-1131号

定价:55.00元

读者服务热线:(010)81055410 印装质量热线:(010)81055316

反盗版热线:(010)81055315

广告经营许可证:京崇工商广字第0021号

版权声明

Authorized translation from the English language edition,entitled:Practical Object-Oriented Design in Ruby:An Agile Primer,9780321721334 by Sandi Metz,published by Pearson Education,Inc.,publishing as Addison-Wesley,Copyright © 2013 Pearson Education,Inc.

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 & TELECOM PRESS Copyright © 2013.

本书中文简体字版由Pearson Education Asia Ltd.授权人民邮电出版社独家出版。未经出版者书面许可,不得以任何方式复制或抄袭本书内容。

本书封面贴有Pearson Education(培生教育出版集团)激光防伪标签,无标签者不得销售。

版权所有,侵权必究。

译者序

让编写的应用程序具有可维护性和可扩展性,并在面对不断变化的需求时,它们也能泰然处之,这是每一位有责任心的程序员都在不懈追求的目标。但出于各种原因,并不是所有人都敢承认自己在这一点上已做得很出色。

本书是Sandi Metz对自己三十多年开发经验以及演讲的深度总结。在原书中,她以浅显易懂的文字和细腻的表达方式,对软件设计过程中常会遇到的问题做了深入的分析和细致的讲解。其中,既有对常见实用设计原则(如SOLID原则、DRY原则等)和设计模式(如工厂模式、组合模式、模板方法模式等)的介绍,也有对面向对象设计原则的详细分析和讲解,并且还通过大量的示例进行说明。全书重点突出、详略得当,可谓恰到好处。在阅读那些形象生动的示例时,你会不由自主地认同作者的观点。她认为:“具有透明性、合理性、可用性和典范性这四种特点(简写为TRUE)的代码,不仅能满足今天的需求,而且也易于更改,以满足未来的需求。”透过她的分析和介绍,你定会茅塞顿开;同时,为曾经可能疑惑的问题找到答案,并对问题的本质形成清晣的认识。

全书讲述的重点是实用面向对象设计技术,演示代码借用的是Ruby语言。尽管如此,Ruby语言像Smalltalk语言一样,也是一种完全面向对象的语言,而且它还是一种动态语言。因此,就编程语言本身而言,它并不会比其他语言更难。在阅读书中的示例时,只要你对其他任何一种面向对象语言(如 C++、Java、C#等)有所了解,那么语法上的差异并不会成为你阅读和理解这些示例的障碍。

本书由我和彭晓东共同翻译完成。正如原书前言里所描述的:“本书与面向对象软件设计有关。它不是一本学术巨著,它讲述的是程序员如何编写代码的故事。”希望大家在阅读本书后,能从中获得关于“如何编写能带给你快乐的代码”的实用方法。

在翻译过程中,除得到身边许多朋友及家人的支持外,还得到了人民邮电出版社的杨海玲女士和原书作者Sandi Metz给予的无私帮助。在此,向所有为本书的翻译及出版付出努力的人们表示衷心的感谢。鉴于个人能力所限,错漏之处在所难免,敬请大家批评指正;同时,对可能造成的误解深表歉意!

张雪平

2013年8月于上海

在软件开发过程中存在着一个不争的事实,即随着代码的增长和对正构建中的那个系统的需求发生变化,那些在当前系统里还不存在的附加逻辑将会被添加进去。几乎在所有的情况里,代码的可维护性在其整个生命周期中始终比优化其现有状态更加重要。

使用面向对象(OO)设计能保证你的代码比不使用它更易于维护和向前发展。如果你不了解编程,那么要如何才能解开那些使用OO实现可维护性的秘密呢?事实是,我们中的许多人在编写完全面向对象的代码方面从未进行过系统的训练。多数情况下,他们的技术很大程度上都是经由身边的同事、大量旧书籍和在线资源的潜移默化而获得的。如果说在学校期间我们还能在OO方面打下一定的基础的话,那么通常是使用类似Java或C++那样的语言实现的(幸运的话,也可能遇到有人使用Smalltalk教学)。

Sandi Metz所编写的这本书涵盖了使用Ruby语言来进行OO设计的基本内容。也就是说,它完全适合于无数想要成长为像熟练程序员那样具有专业开发技能的Ruby和Rails新人。

Ruby 自身很像 Smalltalk,也是一种完全面向对象的语言。在其内部,甚至包括很多基础数据结构(如字符串和数字),都是使用带有行为的对象来表示的。当在Ruby中编写应用程序时,你也可以通过自己编写对象来达到这一目的:只需各自封装好某个状态,并定义好自己的行为即可。如果你对OO还没有足够的经验,那么可能会感到有些气馁:不知道该如何开始才好。本书将一步一步给你指导:从“往一个类里放置什么内容”这样基础的问题,到“单一职责原则”这样基本的概念,再到“如何在继承和组合之间获得平衡”,以及弄清楚“如何测试隔离环境里的对象”。

不过,最精彩的部分还是Sandi 所传达出的信息。她拥有丰富的经验,是你在社区能遇见的最优秀成员之一(我认为她透过自己的写作充分地体现了这一点)。虽与Sandi相识多年,不过我曾经还是对她产生过怀疑:她的手稿是否真的能带给我们快乐,如同现实生活中认识的Sandi所带给我们的快乐一样。我很高兴地宣布:这点毋庸置疑,它的确带来了快乐。这也正是我非常欢迎她成为我们最新一位专业Ruby丛书作者的原因。

——Obie Fernandez

“Addison Wesley专业Ruby丛书”丛书编辑

前言

我们既要竭尽全力,也要做得有意义。与其他所有情形一样,我们要享受整个过程。

那些从事软件编写工作的人们真的很幸运。构建软件是一件让人感到快乐无比的事情,因为我们可以用创造力把事情做好。我们照这种方式编排的生活可以“鱼和熊掌,两者兼得”:既可以享受代码编写所带来的乐趣,也可以非常确定地认为我们所编写的代码能够派上用场。我们做的是有意义的事情。我们是现代工匠,正在建造构成当今现状的各式建筑,而且参与人数一点儿不会比泥瓦匠或桥梁建造者少。我们完全有理由对自己取得的成就引以为傲。

从热情澎湃的新手到厌世嫉俗的前辈,无论他是工作在最轻量级的互联网创业领域,还是呆在固步自封、历史悠久的企业里,所有的程序员都具有这样一个共同特点:我们既要竭尽全力,也要做得有意义,同时还要享受整个过程。

于是,当软件出现问题时特别让人感到不安。糟糕的软件会阻碍我们实现目标,扰乱我们的幸福生活。曾经我们还感觉生产力十足,可现在却屡遭挫败。它曾经的运行快速如飞,可现在却慢如蜗牛。我们曾经还是心平气和,可现在却心灰意冷。

当要投入更多成本才能把事情做好时,我们便会产生这样的挫折感。我们内部的计算器总是在不断地运作,不停地将总的完成量与总的投入精力进行对比。当完成工作的成本超过其价值时,便会认为我们的努力将白费。如果编程能带来快乐,那是因为它让我们变得很有用。当这种事情变成一种痛苦时,便会出现这样的迹象:我们相信自己还可以,而且也应该做更多的工作。我们的快乐要紧跟工作的步伐。

本书与面向对象软件设计有关。它不是一本学术巨著,它讲述的是程序员如何编写代码的故事。它教授了如何编排软件以便让今天更富有成效,同时也能让下月以及明年继续保持住这种效果。它展示了如何编写应用程序,让它不只是在当前能获得成功,同时还要适应未来的变化。它能让你在应用程序的整个生命周期里提高生产率,减少成本。

本书相信你会竭尽全力,而且也为你提供了最合适的工具。它完全注重实用,其本质上完全是一本关于“如何编写能带给你快乐的代码”的书。

读者对象

本书假设你曾经至少尝试过编写面向对象的软件。你是否觉得已获得成功,这一点无关紧要,只要你在某种面向对象语言方面做过尝试即可。第1章是简要概述了面向对象编程(OOP)其目的在于要定义一些常见的术语,而不是想对编程进行教学。

如果你想学习面向对象设计(OOD),但尚未在此方面做过任何编程,那么在阅读本书之前至少需要找点教程学习一下。OOD可以解决很多问题,而饱受这些问题所带来的苦楚,恰好是完全理解那些解决方案的一个先决条件。有经验的程序员可以跳过这一步,但对大多数读者来说,如果他们在开始翻阅本书之前能编写一些OO代码,则一定会感到更轻松。

本书使用Ruby语言来讲解OOD,但为理解本书里的概念,也不需要你对Ruby有过多的了解。书中有很多代码示例,但都很简单。如果你对某种OO语言有过编程经验,那么很快便会发现Ruby其实很容易理解。

如果你对某种静态类型的OO语言(如Java或C++)比较熟悉,那么你已经有了阅读本书所需要的背景知识。事实上,Ruby是门动态语言,这将大大简化示例的语法;同时,也会让设计思想直接体现其本质。但本书中的每一个概念都可以直接转换到静态类型OO语言。

本书组织

第1章首先概述了OO设计的原因和时机,其次概述了面向对象编程。这一章是相对独立的。你可以先阅读它,后阅读它,或者完全跳过它。尽管你可能正在开发某款应用程序,但如果苦于缺乏设计,你会发现这章还是很有意义的。

如果你已拥有编写面向对象应用程序的经验,想要跳跃式前进,那么完全可以从第2章开始。如果这样做,那么你偶尔会遇到某个陌生词汇。那时,你再回过头来浏览第1章对面向对象编程部分的介绍。这一章主要介绍和定义了本书用到的一些常见的面向对象术语。

第2章到第9章渐进式地对面向对象设计做了解释。第2章涉及的是如何决定单个类所包含的内容。第3章说明了对象之间为何彼此纠缠不清,同时也展示了如何将它们分离。这两章关注的重点是对象而非消息。

第4章强调的重点从以对象为中心的设计开始转向以消息为中心的设计。第4章与接口定义有关,它关心的是对象之间如何进行对话。第5章与鸭子类型(Duck Typing)技术有关,并引入了不同类的对象可以扮演公共角色的思想。

第6章讲解了与经典继承相关的技术。接着,这项技术在第7章里被用于创建鸭子类型的角色。第8章对通过组合构建对象的技术进行了解释,并对组合、继承和动态类型角色共享这三者如何进行选择提供了指导。第9章专注于测试设计,它使用了本书前面章节里的代码来进行说明。

每一章都建立在前一章的概念之上,并且都包含了很多代码,所以最好是能按顺序阅读。

使用方法

在具有不同背景的读者看来,本书所提供的内容肯定存在差异。对于那些已熟悉 OOD的人来说,他们会在本书里找到值得思考的事情,可能还会见到一些新的观点,也可能会对其中提出的某些建议持不同的意见。因为就 OOD 而言,没有绝对的权威;对原则(以及本作者)进行挑战,反而能够全面加深理解。最后,是你在主导自己的设计,因此需要你来提问、测试和选择。

虽然本书可能会引起各层次读者的兴趣,但其编写目标更偏向于新手。如果你是一位新手,那么这部分的介绍便是特别为你而准备的。请记住这一点:面向对象设计不是“黑魔法”。它是件很容易做到的事情,只是你还不太了解而已。事实上,当你读到这里的时候,已表明你很关心设计。这种对学习的渴望是能够从本书受益的唯一先决条件。

第2章到第9章对OOD原则进行了解释,并提供了直观的编程规则。这些规则所具有的意义,对专家和新手来说可能不尽相同。如果你是一位新手,那么如有必要,直接按照这些规则开始即可。这种早期的顺从可以避免很多麻烦,在你有了足够经验之后再自行决定。在这些规则真正发挥作用时,你一定已拥有了足够的经验来组成自己的规则,而你作为一名设计师的职业生涯也会就此开始。

致谢

本书能够诞生简直就是一个奇迹。事实上,它融入了太多人的付出与鼓励。

在整个漫长的编写过程中,Lori Evans和TJ Stankus对每一章都提供了前期的反馈。他们住在北卡罗来纳州的达勒姆,因此无法躲开我。尽管如此,我对他们的感激之情丝毫不减,感谢他们所给予的帮助。

在本书的编写中途,创作花费的时间差不多是最初估计时间的两倍。在这一情况发生之后,Mike Dalessio和Gregory Brown阅读了草稿,并给予了宝贵的反馈和支持。他们的鼓励和热情让这项工程在黑暗的日子里得以存活下去。

在接近完工时,Steve Klabnik、Desi McAdam和Seth Wax审阅了此书。他们就像是你(绅士般的读者)的替身,对审阅工作一丝不苟。他们的印象和建议所引起的变化让所有后来的人都受益无穷。

迟到的草稿交由Katrina Owen、Avdi Grimm和Rebecca Wirfs-Brock进行了仔细、全面的阅读。本书也根据他们宽容、全面的反馈进行了大量修订。在Katrina、Avdi和Rebecca卷入进来之前,我对他们还完全不了解。非常感谢他们的参与,也被他们的慷慨所感动。如果你发现本书能带来帮助的话,那么当你下次遇到他们时,请记得当面向他们表示感谢。

我很感激Gotham Ruby小组,也同样感激在GoRuCo 2009和2011由我所做的设计讨论会上,每一位对此表达出赞赏之情的人们。在GoRuCo,人们把握住了了解未知情况的机会;同时,也给了我一个可以表达这些思想的论坛,本书便是从那里开始的。Ian McFarland和Brian Ford记录下了这些谈话,而且他们对此项目所拥有持续的热情。这种热情既具有感染力,也非常令人信服。

在本书的创作过程中,还从Pearson Education的Michael Thurston那里获得了极大的帮助。他就像一艘平静和组织有序的远洋渡轮,驶过被我的捣蛋作品搞得波浪翻滚的大海。我希望你也能看到他所面对过的问题。带着无尽的耐心和仁慈,他坚持认为写作应该以一种方便阅读的结构进行编排。我相信他的努力得到了回报,也希望你能认同这一点。

同时,我还要感谢Debra Williams Cauley。她是我在Addison-Wesley的编辑。在2006年,于芝加哥举办的第一届Ruby on Rails大会上,她无意中听到走廊里传来一段刺耳的责骂。于是,她发起了这个运动,并最终导致了本书的出现。尽管我尽了最大努力,她也不肯接受任何否定的答案。她巧妙地从一个参数转移到下一个,直到最终找到了足以让她信服的那个。这点恰恰反映出了她的执着与奉献精神。

我欠整个面向对象设计社区一笔债。在这本书里,我没有对那些思想进行补充,我只是一名翻译,我站在了巨人的肩膀上。不用多说,对那些思想的所有赞誉都应该归于其他人——翻译的失败则由我一个人承担。

最后,本书的诞生还要归功于我的合伙人Amy Germuth。在此项目的开始之初,我根本无法想象要去编写一本书。在她看来,人们都在做这样的事情,这让创作变成了很自然的事情。你手里拿着的这本书正是表达了对她无尽耐心和不断支持的敬意。

感谢所有人!

第1章 面向对象设计

世界是过程式的。时间不停在向前流动,而事件也一个接一个地逝去。你每天早上的过程或许就是:起床、刷牙、煮咖啡、穿衣,然后上班。这些活动都可以使用过程软件来建模。因为了解事件的顺序,所以你可以编写代码来完成每一件事情,然后仔细地将这些事情一个接一个地串在一起。

世界也是面向对象的。与你互动的对象可能包括有你的老伴和猫,或者是车库里的旧汽车和一大堆的自行车零件,又或者是你的那颗扑通跳动的心脏,以及用来保持健康的锻炼计划。在这些对象中,每一个都拥有它们自己的行为,而且它们之间某些的交互可能还是可预测的。例如,你的老伴意外地把猫给踩了一下,从而引起一个激烈的反应,大家的心跳频率都迅速升高,同时也为你增添了一种新的锻炼方式,这是完全可能的。

在由对象构成的世界里,新的行为编排在很自然的情况下便会出现。你不用显式地为“老伴踩猫”这一流程编写代码,而你所需要的是这样两个对象:一个会抬脚的老伴和一只不喜欢被踩的猫。把这两个对象一起放到一个房间里,行为的意外组合便会出现。

本书与面向对象软件的设计有关,它将世界看作是对象之间一系列的自然交互。面向对象设计(OOD)要求你从认为世界是由一组预定义过程构成的观念,转变至认为世界是由多个对象之间的一系列消息传递构成。OOD的失败看起来很像是编码技术的失败,但它们实际上是视角的失败。学习如何进行面向对象设计的第一个要求便是要让自己沉浸在对象之中。一旦你采用面向对象的视角,那么其余部分也会水到渠成。

本书将通过沉浸式的过程给你指导。本章一开始会对 OOD 进行一般性的讨论。它首先是讨论设计的情况,接着会描述何时进行设计以及如何对它进行评判。在本章的结尾是对面向对象编程的概述,它定义了全书所用的术语。

1.1 设计赞歌

软件被建造出来总有它的原因。目标应用程序是全部的重点。无论这个程序是微不足道的游戏,还是要被用于指导辐射疗法,都一样。如果程序员们认为痛苦的编程工作就是以低成本方式生产工作软件,那么他们应该当机立断,要么忍气吞声地继续痛苦下去,要么另谋出路。

庆幸的是,你不必在快乐和生产力之间做出选择。实现快乐编码的编程技术与那些最高效地生产出软件的技术可以兼得。面向对象设计方法很好地解决了与编程相关的道德和技术两大难题。遵循这些方法能生产出低成本的软件,同时它所用的代码也一样让人乐于处理。

1.1.1 设计解决的问题

假设要编写一款新的应用程序。假设这款应用程序具备了一套完整、正确的需求。如果愿意,你也可以另外假设一件事情:一旦编写好了,这个应用程序绝不会再发生变化。

对于这种情况,设计就没那么重要了。像马戏团的表演者在一个没有摩擦力和重力的世界里旋转盘子一样,你可以将这个应用程序编成动画,然后自豪地退出来,并看着它一直不停地运转下去。无论多么不稳定,代码盘都会不断地旋转,虽然会有些摇晃,但绝不会掉下来。

只要什么都不改变,一切都会安好。

很不幸,事情总是会发生变化。这是永恒不变的。客户并不知道他们自己想要的是什么,他们没有说清自己的意图。你并不了解他们的需求,你已学会了如何把事情做得更好。即使那些在各方面都很完美的应用程序,也是不稳定的。该应用程序取得了巨大的成功,现在每个人都还想要得到更多。变化不可避免:它无处不在,无所不在,无法逃避。

变化的需求就是编程中的摩擦力和重力。它们会引入各种力,而这些力都会作用到周密的计划上,从而形成突发的和出人意料的压力。正是这种对变化的需求让设计变得如此重要。

易于更改的应用程序让人乐于去编写,也乐于去扩展。它们具有灵活性和适应性。那些抗拒变化的应用程序则正好相反:每一次的更改所付出的代价都很昂贵,每一次的更改都会让下一次的更改成本变得更高。几乎都没人愿意处理难以更改的应用程序。最严重的情况则会逐渐地变成了一部个人的恐怖电影:在里面你扮演的是一名倒霉的程序员,疯狂地从一个“转盘”跑到下一个,竭力想要逃离陶瓷摔碎的声音。

1.1.2 为何难以更改

面向对象的应用程序是由交互产生整体行为的各个零件组成。这些零件就是对象,而交互则体现在它们之间传递的那些消息里。想要获得发给正确目标对象的正确消息,需要这条消息的发送者对接收者有所了解。这一点会在这两者之间创建许多依赖关系,并且这些依赖关系还是处在不断变化之中。

面向对象设计与依赖关系管理(managing dependencies)相关。它是一套对依赖关系进行编排,以便各个对象能够容忍更改的编码技术。在缺乏设计的情形里,非托管的依赖关系很容易造成严重破坏,因为这些对象之间彼此了解太多。更改其中的任何一个对象便会强制要求其合作者也随之发生变化;与之相反,当其合作者发生变化时也一样会出现同样的情况……如此下去,永无止境。一个看似无关紧要的增强,会导致以同心圆交错方式向外辐射的破坏,最终无法更改任何代码。

当对象了解太多内容,它们对自己所在的世界便会有更多的期望。它们会变得很挑剔,它们会要求事情应该是“这个样子”。这些期望会对它们产生束缚。在不同的环境里,重用这些对象时,它们便会产生抗拒。它们测试起来是件痛苦的事,也难以被复制。

在一款小型应用程序里,糟糕的设计并无大碍。即使每一个对象与其他所有对象都连接在一起,只要在你的头脑里能够一次性全部掌控它们,那么你仍然可以改进这款应用程序。设计不当的小型应用程序所存在的问题是:如果它们成功了,那么它们将成长为设计不当的大型应用程序。它们会逐渐成为泥塘,那时你便不敢再涉足它;否则会沉得无影无踪。原本应该很简单的更改可能会引起应用程序的连锁反应,四处破坏代码,以及要求大量的重写。在这场交火中,测试也会被用上,然后它开始让人觉得更像是一种障碍,而非一种帮助。

1.1.3 实用的设计定义

每款应用程序都由代码集合而成,对代码的编排就是设计。对于两个独立的程序员,即使他们有着共同的设计构思,在解决同一问题时,也可能会采用不同的方式来编排代码。在流水线上同样训练有素的工人可以制造出相同的零件,但设计不是流水线。设计更像是一个工作室,在那里,志同道合的艺术家们雕刻出各具特色的应用程序。设计是一门艺术—一门编排代码的艺术。

设计之所以困难,部分原因在于每一个问题都涉及两个层面:你不仅要按今天的计划编写在将来要交付的代码,而且还必须要构建出能顾及到以后会发生变化的代码。在任何时间对过去所交付的测试版进行扩展,更改的成本最终都会侵蚀掉这个应用程序的最初成本。因为设计原则之间是交叉的,而且每一个问题都会涉及一个移动的时间表,所以设计挑战会有一大堆令人眼花缭乱的可行解决方案。你的工作具有很高的综合性:必须将对应用程序需求的总体理解,与各种设计选择的利与弊的知识组合在一起;然后设计出在当前算是低成本,而在将来也能继续保持那个样子的代码编排。

将未来情况考虑在内,似乎需要未卜先知的能力,这通常会被认为超出了编程领域。实际并非如此!设计所考虑的未来并不是指你可以预测未知的需求,并在当前从中选择一个来实现。程序员不是神仙。让设计预测未来的特定需求,几乎都不会有好的结果。实用的设计不会去预测未来将要发生什么,它只是接受什么事情会发生,并且也接受你现在所不知道的事情。不要对未来进行猜测,你对未来只保留有选择接受的权利。如果不做选择,则可以为以后的变化留出更大的余地。

设计的目的是允许你以后可以进行设计,而设计的首要目标是降低变化所带来的成本。

1.2 设计工具

设计可不是遵循一套固定规则就完事的动作。它是每次沿着一条分支前进的旅行,在这条路径上早期的选择关闭了某些选择,同时又会打开其他新的选择。在设计过程中,你会徘徊于各种错综复杂的需求中,这里的每个关键时刻都代表着一个决策点,它会对将来产生影响。

像雕塑家有凿子和文稿一样,面向对象的设计师也有自己的工具—原则和模式。

1.2.1 设计原则

SOLID原则首先由Michael Feathers提出,再由Robert Martin进行了推广。它代表了5个最有名的面向对象设计原则,即单一职责原则(Single Responsibility Principle,SRB)、开闭原则(Open-Closed Principle,OCP)、里氏替换原则(Liskov Substitution Principle,LSP)、接口隔离原则(Interface Segregation Principle,ISP)以及依赖倒置原则(Dependency Inversion Principle,DIP)。另外还有其他一些原则,如Andy Hunt和Dave Thomas提出的不重复原则(Don’t Repeat Yourself,DRY),以及源于美国东北大学迪米特项目的迪米特法则(Law of Demeter,LoD)。

这些原则都贯穿于全书。现在的问题是:“它们究竟从何而来?”这些原则很有价值,有证据证明吗?或者它们只是某些人的个人观点,你甚至可以对其随意贬损,情况是如此吗?归根结底,它们究竟是谁提出来的?

所有这些原则在一开始都只是人们在编写代码时所做的一些选择。早期的面向对象程序员发现有些代码编排可以让他们的工作变得更轻松,而其他的编排则会让他们感觉更累。这些经验引导他们形成了如何编写出优秀代码的观点。

后来,学者们参与了进来。为撰写研究论文,他们于是决定要量化“什么是优秀代码”。这种想法很值得称赞。如果能对事物进行统计(如计算代码的度量指标),并将这些度量指标与应用程序质量的高低关联起来(为此,我们还需要一个客观的测量标准),那么我们就可以多做能降低成本的事情,少做会增加成本的事情。能够对质量进行测量将OO设计从争论不休的观点变成为可测量的科学。

20世纪90年代,Chidamber、Kemerer[1]和Basili[2]正好从事这方面的研究。他们收集了许多面向对象的应用程序,想尽一切办法改善这些编码的质量。他们为很多事情进行命名和测量,如类的整体尺寸、多个类之间彼此的关联度、继承层次的深度和广度,以及以任何发送消息的结果形式获得调用的方法的数量。他们选择了自己认为很重要的代码进行编排,并设计出公式来对它们进行统计,然后将最终的度量与封闭程序的量进行关联。他们的研究显示:这些技术的使用和高质量的代码之间存在显著关联。

虽然这些研究看起来是证明了设计原则的有效性,但对于任何经验丰富的程序员来说,都需要慎重选用它们。早期的研究都只是检查了由一些研究生编写的非常小型的应用程序,仅此一点就足以证明需要审慎地看待这些结论。这些应用程序里的代码不可能完全代表真实世界里的OO应用程序。

不过,事实证明这种谨慎是多余的。2001年,Laing和Coleman检查了多个NASA Goddard太空飞行中心的应用程序(火箭科学),并希望能找到“一种能生产出更便宜和高质量软件的方法”[3]。他们检查了三个优劣各异的应用程序,其中一个包含1 617个类和500 000多行代码。他们的研究完全支持先前的研究成果,从而进一步证实了设计原则的重要性。

即使是你从未阅读过这些研究,也可以对他们的结论持有信心。那些实现优秀设计的原则所代表的是可测量的真理,遵循它们能改善你的代码。

1.2.2 设计模式

除了这些原则,面向对象设计还包含了许多模式。有一个所谓的“四人组”(Gang of Four,Gof),即Erich Gamma、Richard Helm、Ralph Johnson和Jon Vlissides,他们于1995年一起编写了关于模式的开创性著作。他们的书籍《设计模式》将模式描述为“在面向对象的软件设计中,针对特定问题的简单而优雅的解决方案”,你可以用它来“让自己的设计更加灵活、更具有模块化、更易重用和更易理解”。[4]

设计模式的概念极其强大。对常见问题进行命名,并使用常用的方法来解决这些问题,采用这种方式可以将模糊的内容聚焦在一起。设计模式为整整一代的程序员提供了交流和协作的手段。

在每位设计师的工具箱里总能见到多种模式的存在。对其所解决的问题来说,每一个无人不晓的模式都是一种近乎完美的开源解决方案。不过,模式的流行也导致有的模式被程序员新手过度滥用的问题。他们有时会热情过头,将非常优秀的模式应用到错误的问题上。模式的误用会导致代码变得非常复杂且混乱不堪,但是出现这样的结果并不是模式本身的问题。一种工具不能因其用途而受到责怪,使用者必须要掌握好这个工具。

本书不是要讨论模式,而是会想方设法让你理解它们,并且教你如何正确地选择和使用它们。

1.3 设计行为

随着常见设计原则和模式的出现与传播,所有的OOD 问题可能都已被解决。既然基础的规则都已知道,那么设计面向对象的软件还会有多难呢?

事实证明,它非常难。如果将软件理解为可定制的家具,那么原则和模式便像是木工的工具。了解软件在完成后会是什么样子,并不能让它自我构建成那个样子。应用程序之所以存在,是因为有程序员使用了这些工具。最终的结果可能是,它要么成为一个漂亮的橱柜,要么成为一张摇摇晃晃的椅子。具体是哪一种结果,则取决于程序员使用设计工具的经验。

1.3.1 设计失败

第一种设计失败的情形是缺乏设计知识。程序员最初对设计一无所知。不过,这还不构成威胁,因为在不知道设计首先该干什么的时候,也可以生产出能工作的应用程序。

除了部分语言以外,所有的OO语言比其他类型语言都更容易受到影响,这一情形千真万确。尤其是像Ruby这种“平易近人”的语言很容易“受伤”。Ruby非常友好,它几乎可以让任何人,通过创建脚本完成一些自动的重复性任务。而且,它还有一个像Ruby on Rails那样强悍的框架,将 Web 应用程序放置到每一位程序员能触及的地方。Ruby 语言的语法非常文雅,任何具备将想法串成逻辑顺序能力的人,都可以编写出能工作的应用程序。对面向对象设计一无所知的程序员,使用Ruby也能轻松地获得成功。

不过,虽然成功但却未进行设计的应用程序,总是存在自我毁灭的风险。它们在编写时很轻松,但更改起来则会变得越来越难。程序员过去的经验并不能预测到未来。无痛开发的早期承诺都会逐渐地变得无法兑现。如果程序员在面对每个变更请求时开始说:“是的,我可以添加这个功能,但这会把所有事情都给破坏掉”,那么这时原本乐观的情形也会变得绝望。

稍有经验的程序员也可能遭遇过另一种设计失败的情形。这些程序员对 OO 设计技术都有所了解,但对于如何应用它们仍然不太清楚。尽管是出于好心使用这些设计技术,但他们很快便会掉进过度设计的陷阱。只有“半桶水”是很危险的。伴随着知识的增加和希望的回归,他们开始无情地进行设计。在热情膨胀之后,他们会不合时宜地乱用这些原则。在不该有的地方,你也能看到他们在使用模式。他们一开始想要建造出一座复杂而又漂亮的代码城堡,最后却痛苦地发现自己早已被一面面石墙团团围住。你能把这种程序员立即辨别出来,因为他们在面对变更请求时总是会报怨:“不行,我不能添加这项功能。它不是设计来干这事儿的。”

最后,当设计行为与编程行为分开时,所开发的那个面向对象软件也注定会失败。设计是一个逐步发现的过程,它依赖于往复不断的反馈。这种往复反馈应该是适时的和递增的。敏捷软件运动(Agile software movement)的这种迭代技术,也因此非常适合用于创建优秀设计的面向对象应用程序。敏捷开发(Agile development)所提倡的这种迭代特性,让设计可以有规律地调整和很自然地演变。当设计是由很遥远的事情所决定时,就没必要进行调整,早期的理解错误能让代码更巩固。当程序员们被强迫编写由孤陋寡闻的专家所设计的应用程序时,他们便可以说:“没错,我当然可以写这个软件,但是这并不是你真正想要的结果,你最终是会后悔的。”

1.3.2 设计时机

敏捷开发方法相信:在客户看到具体的软件之前,他们对所想要的软件是没什么概念的,所以向他们展示软件的时机是宜早不宜迟。如果这个假设前提成立的话,那么在逻辑上你就应该以微量递增的方式来构建软件,逐步将你的方法迭代成满足客户真正需要的应用程序。敏捷开发方法认为:想要生产出客户真正需要的应用程序,最划算的方法是与他们一起合作,逐步地构建软件。这样,每次交付都有了对下一步想法进行更改的机会。敏捷开发的经验表明:这种合作产生出来的软件与最初想象的结果总是存在差异。因此,最终的软件是无法通过其他方式进行预测的。

如果敏捷开发方法正确,那么另外两件事情也会是真的:第一件事情,即大规模预先设计(Big Up Front Design,BUFD)完全没有意义(因为它不可能正确);第二件事情,即没人能预测应用程序什么时候会完成(因为你事先无法知道它最终会干什么)。

有人不太喜欢敏捷开发方法,这也是意料之中的事。“我们不知道在做什么”以及“我们不知道什么时候能完成”这两个问题很难解决。对BUFD的要求始终存在,因为在某种程度上,它提供了一种控制的感觉;否则,便会让人感觉无法控制。尽管这种感觉可能让人很安心,但认为这种编写应用程序的行为会无法继续下去,那只是一时的错觉。

BUFD不可避免会导致客户和程序员之间出现敌对关系。因为在软件真正形成之前的任何大规模设计都不可能是正确的,按照特定保证编写出的应用程序根本无法满足客户的需求。在客户尝试着使用这个应用程序时,便会发现这一点。接着,他们要求进行更改。程序员们都会抗拒这些更改,因为他们是在按计划行事,而实际情况是他们很可能已经落后于计划。项目的参与者开始从努力想要让它变得成功,转变为努力想要避免因其失败而被指责。随着这一情况的出现,这个项目便会逐渐走向消亡。

对于这种“潜规则”大家都心知肚明。当项目错过了它的交货期限时,哪怕出现这种情况的原因是因为修改了规定,但还是错在程序员。不过,如果项目是按时交付的,尽管不满足实际的需要,那么就可以肯定是规定出了问题,所以这时便可以怪罪到客户头上。BUFD的设计文档在开始时常被用作应用程序开发的路线图,但它们会逐渐成为争论的焦点。这些文档不会产生出高质量的软件,相反它们提供的都是一些被严重消化过的话语。在最后,这些话语会被大家竞相引用,没人想成为那个手持烫手山芋挨批的人。

一遍又一遍地做同样的事情,并且期望能得到不同的结果。如果说这很疯狂,那么敏捷宣言则让我们大家都开始有所觉悟。敏捷开发方法之所以有效,是因为它承认:在应用程序最终形成之前,确定性是遥不可及的。敏捷开发方法认可这一事实,因此它提供了许多策略,并将它们用来克服软件开发的各种障碍,同时也无须对具体的目标和时间表了解太多。

不过,敏捷开发方法所说的“不进行大规模的预先设计”,并不是认为完全不做任何设计。在BUFD里使用的“设计”一词与OOD里使用的“设计”有着不同的含义。BUFD几乎全部都是在指定和记录所涉及那个应用程序被期望将来应具备的全部功能及其内部工作原理。如果有某位软件架构师参与进来,则可能预先将决定扩展至如何编排所有的代码上来。OOD所关心的则是更为狭窄的领域,它主要是关于编排已有代码以便让它们更容易更改。

敏捷开发过程为更改提供了保证,而所能做出更改的能力则取决于应用程序的设计。如果无法编写出设计良好的代码,那么在每次迭代的时候,你就必须要重写这个应用程序。

因此,敏捷开发方法并不排斥设计,相反它还需要设计。不仅需要设计,它还需要非常优秀的设计。它需要你付出努力。它的成功离不开简单、灵活和可塑性强的代码。

1.3.3 设计评判

在以前有一段时间,大家常根据程序员所产生的代码行数(也被叫做源代码行数或SLOC)来评判他们。很明显,这种度量会变成什么样子:有的老板认为编程就是一条流水线,在那里同样训练有素的工人都能构建出相同的部件。这样的老板很容易形成这样一种观念,即单个生产力可以通过简单的权重输出进行评判。对于那些迫切需要一种可靠方法来对程序员进行比较和对软件进行评估的管理者来说,尽管SLOC存在有很多明显的问题,但总比什么都没有好。它至少是一种可再生的测量方式。

这种度量方法显然不是程序员想出来的。尽管SLOC可能提供了一种可用来衡量个人努力和应用程序复杂性的衡量标准,但它对整体质量却“只字未提”。它处罚的是有效率的程序员,奖励的却是那些编写冗长代码的人。它经常被专家用来与底层应用程序的危害相对照。当你了解到坐在你身旁的新手程序员常被认为更具效率,因为他或她为实现某项功能编写了很多的代码,而你却能用更少行数的代码来实现它,这个时候你会对此做何反应呢?这种度量方法以损害质量的方式改变了奖励制度。

在当代世界里,SLOC 是一个历史产物,它在很大程度上已被新的度量方法所替代。有许多Ruby程序包(在google里搜索一下“ruby metrics”,最靠前的那些就是它),它们可以帮你评估代码遵循 OOD 原则的情况。这些度量软件通过扫描源代码,并对预测的质量进行统计。针对你自己的代码运行某个度量套件,可能会出现启发、羞辱以及担忧这三种状况。看似精心设计的应用程序会出现大量违背OOD的情况。

糟糕的OOD 度量值无疑也标志着设计很糟糕:得分很低的代码将难以更改。不幸的是,得分很高的代码也不能就此证明它易于更改。即是说,这些度量无法保证你下一次的更改一定是轻松和廉价的。其中的问题在于,有可能创建出对未来过度预测的漂亮设计。虽然这些设计可能会得到非常高的OOD 度量值,但如果它们对未来预测错误,那么当真正的未来最终到来时,想要进行修正则会付出昂贵的代价。OOD度量无法辨别出那些“在方法上正确而在做法却是错误”的设计。

因此,对SLOC所存在问题的警示也要扩展到OOD度量上。对它们要半信半疑。度量非常有用,因为它们没有偏执,而是会产生出一些数据。根据这些数据,你便可以推断出与软件有关的某些东西。不过,它们不是衡量质量的直接指标,而是更深层测量的“代理人”。终极的软件度量应该是:在起关键作用的那段时间间隔里,每一项功能所花费的成本。但它很不好计算。成本、功能和时间,都难以单独定义、跟踪和测量。

即使你有可能将某项单独的功能隔离起来,并跟踪与它相关的所有成本,但至关重要的时间间隔也会对代码应该如何评判产生影响。有时,现在拥有该项功能的代价非常大,以致它会超越未来所有成本的增长。如果今天缺失了某项功能会迫使你破产,那么明天处理这些代码会花费再多的成本也没有关系,你必须要尽最大努力保证按期完成。做这样的设计妥协就像是向未来借用时间,也就是众所周知的要承担技术债务。这是一笔最终必须归还的贷款,极有可能还会带上利息。

即使你并非故意想要承担技术债务,设计也要占用时间和花费成本。因为你的目标是要编写每一项功能成本都保证在最低水平的软件,所以具体要做多少设计才合适,取决于这样两件事情:你的能力和时间表。如果这个月的设计占用了你一半的时间,并且在这一年之内都无法体现出它的好处,那么这样的设计就不值得去做。当设计行为阻碍软件按时交付时,你便会输掉。一个优秀设计的应用程序,如果只能交付一半,那么与完全不能交付所导致的后果是一样的。不过,如果设计在今天早上占用了你一半的时间,而在今天下午它就能让你得到回报,并且在这个应用程序的整个生命周期里都不断采用这种做法,那么在时间方面你便都能获得一种日积月累的好处。这种设计努力会一直带来好处。

设计的盈亏临界点依赖于程序员。那些没有经验的程序员会做很多预先设计,但可能永远无法获得这样的结果:早期的设计努力会得到回报。对于那些熟练的设计师,它们在今天早上还在编写精心设计的代码,而在今天下午便能节省成本。你的经历可能介于这两种极端情况之间,本书接下来的章节将教给大家一些技巧。它们可用来权衡设计,并为你带来好处。

1.4 面向对象编程简介

面向对象的应用程序由对象和它们之间传递的消息构成。其中,消息相对更为重要。但在本节的简介里(以及在本书的前面几个章节里),这两个概念都同等重要。

1.4.1 过程式语言

相对于非面向对象(或过程式)的编程来说,面向对象编程是面向对象的。依据这两种风格的差异来考虑它们很有意义。假设有这么一种通用的编程语言,它可用来创建简单的脚本。在这门语言里,你可以定义变量(即组成多个名称),并将这些名字与少量的数据相关联。一旦进行了分配,便可以通过引用这些变量来访问相关的数据。

与所有的过程式语言一样,这门语言只对少量固定集合的不同类型数据(诸如字符串、数字、数组、文件等)有所了解。这些不同类型的数据叫做数据类型。每一种数据类型都描述了某种非常具体的事物。字符串数据类型与文件数据类型是不同的。这门语言的语法还会包含许多内建操作,以完成某些对各种数据类型来说都很合理的事情,如字符串连接和文件读取。

因为创建了变量,所以你能够知道每一个容纳的内容是何种类型。哪些操作是可用的,你对这一点的期望都依托于你对变量数据类型知识的了解。往字符串里进行追加、使用数字进行数学计算、对数组进行索引、阅读文件等等,所有这些都是你所知道的。

每一种可能的数据类型和操作都已存在,所有这些内容都被构建在语言的语法里。这门语言可能允许你创建函数(将一些预定义操作以一个新的名字组合在一起),或者允许你定义复杂的数据结构(将某些预定义的数据类型组装成一个命名排列),但是你无法组成全新的操作或全新的数据类型。你所看到的内容便是你能得到的。

与所有过程式语言一样,在这门语言里的数据和行为之间都有一道鸿沟。数据是一回事,行为是与之完全不同的另一回事。数据被包裹在变量里,然后四处传递给行为(坦率地讲,可以对它做任何事情)。数据就像一个孩子,在每天早上行为都将其送至学校;当它离开你的视线后,你便无办法知道实际所发生的事情。对数据的影响是不可预知的,并且在很大程度上也是捉摸不定的。

1.4.2 面向对象语言

现在假设有另一门不一样的编程语言,它像Ruby一样是基于类和面向对象的。Ruby没有将数据和行为分裂成两个单独的部分(如两个绝不相遇的球体),它将这两者结合成为了一个事物(即对象)。对象拥有行为,也包含了可单独访问控制的数据。对象之间通过相互发送消息来调用彼此的行为。

Ruby有一个字符串对象,它没有字符串数据类型。处理字符串的操作都构建在字符串对象内部,而没有构建在语言的语法里。字符串对象各不相同,它们每一个都包含有属于自己的字符串数据;但是它们的行为又彼此很相似。每一个字符串都将其数据封装或隐藏起来,以便与外面隔离。每一个对象都要自己决定需要暴露多少数据。

因为字符串对象可以自己提供操作,所以 Ruby 没必要对字符串数据类型特别了解,它只需要提供一种通用的方式方便对象发送消息即可。例如,字符串能理解 concat 消息,因此Ruby没必要包含字符串连接的语法,它只需要提供某种方式,让一个对象可以将concat发送至另一个即可。

即便是最简单的应用程序也可能需要多个字符串、数字、文件或数组。事实上,虽然你可能偶尔会需要一个唯一、单独的对象,但更为常见的期望是需要产生一大堆的对象—它们拥有相同的行为,但却封装了不同的数据。

像Ruby那样基于类的OO语言允许定义类—它为构建相似对象提供了一个蓝图。一个类可以定义多个方法(定义行为)和多个属性(定义变量)。在响应消息时,方法会被调用。不同的对象可以定义具有相同名字的方法,Ruby会为发送来的任何消息查找和调用正确对象的合适方法。

在有了String类之后,你便可以用它来重复地实例化(或创建)新的字符串对象实例。每一个重新实例化的String 都会实现相同的方法,并使用相同的属性名称,但每一个 String对象都包含了它们自己个人的数据。它们共享相同的方法,因此它们都像是String。由于都包含不同的数据,因此它们又代表了不同的状态。

String类定义了一种类型,它远不只是数据那么简单。在知道某个对象的类型之后,你便可以对它将如何表现有所期待。在过程式语言里,变量都只有单一的数据类型。这个数据类型的知识允许你对哪些是有效的操作会有所期待。在Ruby里,一个对象可能有多种类型,其中任何一种都来自与它对应的类。因此对象类型的知识会让你对它能响应的消息有所期待。

Ruby 提供了一系列预定义好的类。最可容易立即辨别出来的是与过程式语言所使用的数据类型相重叠的那些。例如,String类定义了字符串、Fixnum类是定义了整数。对于你所期望的一门编程语言应该提供的每一种数据类型,都有一个预先存在的类。不过,面向对象的语言是使用对象来构建自己的,这一点让事情开始变得很有意思。

那个String类(即创建新字符串对象的蓝本),其自身也是一个对象。它是Class类的一个实例。如同每个字符串对象都是String类的特定数据实例一样,每个类对象(如String、Fixnum等)也都是Class类的特定数据实例。String类会生成新的字符串,Class类会生成新的类。

因此,OO 语言是开放式的。它们不会将你限制在一个很小的内建类型和事先预定义好的操作集合里。你可以创造出全新的属于自己的类型。每一个OO应用程序都会渐渐变成一门唯一的编程语言,它专属于你的领域。

这门语言最终带给你的是快乐,还是痛苦,都与设计有关,而这也正是本书关注的内容。

1.5 小结

如果某个应用程序存活了很长时间(也就是说,如果它成功了),那么它最大的问题将是如何应对变化。通过代码编排有效地应对变化是设计的事情。最常见的设计要素是原则和模式。不幸的是,即使正确地运用了原则,并且也恰当地使用了模式,也无法保证能够很好地创建出易于更改的应用程序。

OO 度量能暴露出应用程序在遵循 OO 设计原则方面的情况。糟糕的度量值强烈地表明将来可能会遭遇困难;不过,好的度量值也发挥不了太大的作用。一个做法有问题的设计也可能产生出很高的度量值,但更改时所花费的成本仍然可能很高。想要设计投入获得最佳效果,诀窍就是:采用让人易于理解的设计理论,并且恰当地应用这些理论;在正确的时间,以正确的数量应用它们。设计依赖于将理论付诸实践的能力。

理论与实践之间有区别吗?

理论上说,没有区别。如果说理论就是实践,那么你便需要学会 OOD的那些规则,坚持不懈地应用它们,并在今后创建出完美的代码。你的工作到这个时候才算完成。

不过,实践是检验真理的唯一标准。实践与理论有所不同,它会弄脏你的双手。是实践在放置砖块、建造桥梁和编写代码。实践处在不断变化、充满疑惑,以及很不确定的现实世界里。它要面临相互矛盾的选择,有时还需要扮成鬼脸,选择更小一点的恶魔。它要不断躲闪,要抢劫Peter以偿还Paul。它要竭尽所能、尽其所有来谋取生活。

理论很有用,也很必要。它是本章的重点,但已足够。接下来将开展实践。

[1].Chidamber,S.R.,& Kemerer,C.F.(1994).A metrics suite for object-oriented design.IEEE Trans.Softw.Eng.20(6):476–493.

[2].Basili Technical Report(1995).Univ.of Maryland,Dep.of Computer Science,College Park,MD,20742 USA.April 1995.A Validation of Object-Oriented Design Metrics as Quality Indicators.

[3].Laing,Victor & Coleman,Charles.(2001).Principal Components of Orthogonal Object-Oriented Metrics(323-08-14).

[4].Gamma,E.,Helm,R.,Johnson,R.,&Vlissides,J.(1995).Design Patterns,Elements of Reusable Object-Oriented Software.New York,NY: Addison-Wesley Publishing Company,Inc.

第8章 组合对象

组合(composition)是指将不同的部分结合成一个复杂整体的行为,这样整体会变得比单个部分的总和还要大。例如,音乐就是组合而成的。

你可不能将软件当作是音乐,那只是一种类比。贝多芬的第五交响曲乐谱是一长串独特而又独立的记号。你只听一遍就会明白:尽管它包含的是一些记号,但它不是记号。它是另一回事。

你可以按同样的方式来创建软件,使用面向对象的组合技术来将简单、独立的对象组合成更大、更复杂的整体。在组合过程中,较大的那个对象通过“有一个”关系与其部分相连。一辆自行车有多个零件。自行车就是那个包含对象,而零件则被包含在自行车里。“组合”定义的中心思想是:自行车不仅有多个零件,它还要通过接口与它们进行通信。零件是一个角色,而自行车很乐意与任何扮演这个角色的对象进行合作。

本章会对OO组合技术进行讲解。在开始时举了一个示例,接着会对组合与继承的相对优缺点进行讨论,然后得出如何选择替代设计技术的建议作为结论。

8.1 Parts组合成Bicycle

本节接着从第6章结尾那个Bicycle示例开始。如果你对那段代码已没了印象,那么请翻回到第6章的末尾,并温习一下。本节会利用这个示例,通过多次重构来推动它的更新,同时逐渐地使用组合来取代继承。

8.1.1 更新Bicycle类

在继承层次结构里,Bicycle类目前还是一个抽象父类。现在,想要将它转换来使用组合技术。第一步是忘掉现有的代码,然后好好想想应该如何组合出一辆自行车。

Bicycle类负责响应 spares 消息。这条信息应该返回一个备件列表。自行车有多个零件,因此“自行车—零件”关系很自然会让人感觉像组合。如果你创建了一个用于容纳所有自行车零件的对象,那么你就可以将备件信息委托给这个新对象。

将这个新类命名为Parts非常合理。Parts对象可以负责容纳自行车零件列表,并负责了解哪些零件需要备件。请注意,这个对象代表了一堆的零件,而不是单个零件。

图8-1里的那张时序图展示了这种思想。其中,Bicycle向它的Parts对象发送了spares消息。

每一个 Bicycle 都需要一个 Parts 对象。所谓零件,其意思是说,Bicycle 有一个Parts。图8-2里的那张图展示了这种关系。

这张图所展示的是:Bicycle与Parts类通过一根线连接在一起。这根线的黑色菱形那一端连接的是Bicycle。黑色菱形表示的是“组合”(composition),即它意味着Bicycle由Parts组合而成。在这根线的Parts那端有数字“1”。它表示的是每一个Bicycle都只有一个Parts对象。

将已有的Bicycle类转换成这种新的设计比较容易。删除其大部分代码,添加一个parts变量用于保存Parts对象,并将spares委托给parts。下面是新的Bicycle类:

Bicycle现在要负责三件事情:知道其size、保存Parts并回答spares。

8.1.2 创建Parts层次结构

上面那个很容易做到,不过那只是因为:在开始的时候,Bicycle类里并没有太多与自行车相关的行为(Bicycle的大部分代码都在处理parts)。你仍然需要那些刚从Bicycle里移除的parts行为。而让这段代码可以再次工作的最简单方法,就是简单地将这些代码转移到一个新的Parts层次结构,如下所示。

这段代码是几乎就是第6章的那个层次结构的翻版,不同之处在于类的名字被改了,并且删除了size变量。

图8-3里的类图展示了这种转变。现在有一个抽象的Parts类。Bicycle由Parts组合。Parts有两个子类:RoadBikeParts和MountainBikeParts。

在这个重构之后,所有事情都还可以正常地工作。如下面所示,不管它拥有的是RoadBikeParts 还是MountainBikeParts,Bicycle 对象都可以正确地回答出其 size和spares。

这个变化不大,并且没做大的改进。不过,这种重构揭示了一件很有用的事情,即,很明显,在开始时与Bicycle有关的代码非常少。上面的大部分代码都在处理单个零件,那个Parts层次结构现在迫切需要进行另外的重构。

8.2 组合成Parts对象

很明显,零件列表会包含一长串的单个零件。现在应该添加表示单个零件的类了。单个零件的类名显然应该为Part。不过,当你已拥有一个Parts类时,引入Part类会让交谈变得很困难。当同样的这个名字已经用于指代单个的Parts对象时,使用“parts”一词来指代一堆的Part对象很容易让人感到困惑。不过,前面的措辞说明了一种会顺带引起交流问题的技术。当在讨论Part和Parts时,你可以在类名之后带上“object”一词,如有必要还可以使用复数的“object”。

你可以在一开始就避免出现这种交流问题,方法是选择不同的类名。但其他的名字可能没那么好的表现力,并且很可能引入新的沟通问题。这种“Parts/Part”情形很常见,需要正面对待。选择这些类名称需要一次准确的交流,这才是其自身追求的目标。

因此,有一个Parts对象,它可能包含多个Part对象,就这么简单。

8.2.1 创建Part

图8-4展示了一张新的时序图,它说明的是Bicycle与其Parts对象之间,以及Parts对象同其Part对象之间的会话。Bicycle会将spares发送给Parts,接着Parts对象会将needs_spare发送给每一个Part。

以这种方式对设计进行更改,会要求创建新的Part对象。那个Parts对象现在由Part对象组合而成,如图8-5里的类图所示。在直线上靠近Part的“1..*”所表示的是:一个Parts拥有一个及以上的Part对象。

引入新的Part类,可以大大简化已有的Parts类。它现在已变成了一个简单的包裹器,将一组Part对象包裹在一起。Parts可以过滤Part对象列表,并返回那些需要备件的Part对象。下面的代码展示了三个类:现有的Bicycle类,更新后的Parts类和新引入的Part类。

有了三个类之后,你便可以创建单个的Part对象。下面的代码创建了多个不一样的零件,并将每一个保存在某个实例变量里。

单个的Part对象可以被组合成Parts。下面的代码将公路自行车的Part对象组合成了适合公路自行车的Parts。

当然,你也可以跳过这个中间步骤,在创建Bicycle时简单、迅速地构建Parts对象,如下面第4~6行和第22~25行所示。

正如从上面的第 8~17 行和第 27~34 行所看到的,这种新的代码编排很有效,并且其行为跟原来的那个 Bicycle 层次结构几乎完全一样。这里有一点差别,即 Bicycle 原有的spares方法会返回一个散列表,而新的spares方法返回的是一个Part对象数组。

虽然有也可以把这些对象当作是Part的实例,但是组合是要告诉你把它们当作扮演Part角色的对象。它们不一定是Part类类型,只需表现得像即可。也就是说,它们必须响应name、description和needs_spare。

8.2.2 让Parts对象更像一个数组

这段代码也可以工作,但很明显还有改进的空间。时间倒退片刻,请想想 Bicycle里的parts和spares。感觉这些消息应该返回相同的内容,然而回过头来一看,这些对象的表现方式并不相同。当你向每一个零件询问其大小时,会发生什么事情呢?一起来看看。

在下面的第1行,spares开心地报告它的size为3。然而,在向parts问同样的问题时,实际情况却并非如此,如第2~4行所示。

第1行可以工作,因为spares会返回一个数组(由Part对象组成),且Array能够明白size。第2行失败的因为在于parts会返回Parts实例,而它对size并不理解。

只要你拥有这种代码,类似的失败会不断缠绕着你。这两个事物看起来都很像数组。你不可避免地会把它们当成这个样子,尽管事实上恰好对了一半,但其结果就会像是踩在谚语常说的“院子里的钉耙”上。那个Parts对象并不像数组,所有把它当作数组的尝试都会失败。

往Parts里添加size方法,可以快速地解决眼前这个问题。实现一个方法,将size委托给实际的数组,这是件很简单的事情。如下所示。

不过,这种更改开始会让 Parts类走下坡路。如果这样做,那么过不了多久你就会想要Parts对each做出响应,接着响应sort,然后响应Array里的其他所有事情。永无止境!越让Parts像数组,你会越期望它是一个数组。

也许 Parts 就是一个数组,虽然它多了一点额外的行为。你可以让它成为一个数组。下面这个示例展示了一个新版的Parts类。现在它是作为Array的一个子类。

上面这段代码直截了当地表达了这样一个思想,即Parts是Array的特殊化。在完美的面向对象语言里,该解决方案完全正确。不幸的是,Ruby语言还不够完美,并且这个设计隐藏着一个缺陷。

下面这个示例可以说明这一问题。当Parts成为Array的子类时,它继承了Array的所有行为。这种行为包括了像“+”那样的方法,这个方法会将两个数组连接在一起,并且返回第三个。下面的第3、4行展示了这样一个过程:“+”将两个现有的Parts实例结合在一起,并将结果保存到combo_parts变量。

这个似乎可以工作:combo_parts现在会包含正确的零件数量(第7行)。然而,事情明显不正确。如第12行所示,combo_parts无法回答其spares。

这个问题的根源暴露在第15~17行。尽管“+”连接的对象是Parts实例,但“+”所返回的对象即是Array实例,而Array并不明白spares是什么回事。

结果表明:在Array里,有许多方法都会返回新的数组,并且不幸的是,这些方法会返回新的Array类实例,而不是那个新子类的实例。Parts类仍然会误导人,而你只是将一个问题变换成另外一个。一旦你失望地发现Parts并没有实现size,那么你现在可能会惊讶地发现:将两个Parts加在一起会返回一个让spares无法理解的结果。

你已看过了三种不同的Parts实现。第一种实现只响应了spares和parts消息。它不像数组,它只是包含一个数组。第二种Parts实现添加了size。它只是做了一点细微的改进,并返回了其内部的数组大小。最后那个Parts实现了Array子类,因此其外在表现就像是一个数组,但如上面的示例子所展示的,Parts实例仍然会表现出意想不到的行为。

现在已很明显,并没有完美的解决方案。因此,现在要做一个艰难的决定。尽管它不能响应size,但原来的Parts实现可能已经够好了。如果是这样,那么你可以接受它缺乏类似数组一样的行为,并恢复到该版本。如果你需要size,而size不存在,那么最好是只添加这一个方法。因此,第二个实现可接受。如果你能容忍出现错误混淆的问题,或者你非常确定你永远不会遇到它们,那么成为Array的子类并安静地走开也具有意义。

在复杂性和可用性之间的中间区域的某个地方,会有下面这样的解决方案。下面的Parts类将 size和each委托给了它的@parts数组,并包含 Enumerable,以获得公共的遍历和检索方法。Parts的这个版本并没有 Array的所有行为,但它宣称的所有事情至少都可以工作。

将“+”发送给自己的Parts的实例会导致NoMethodError异常。不过,由于Parts现在可以响应size、each以及所有的Enumerable消息,并且当你错误地将它当作是一个实际的数组时会合理地引发错误,所以这段代码已很不错了。下面的示例表明spares和parts现在都可以响应size。

又多了一版可工作的Bicycle、Parts和Part类。你现在应该重新考虑一下这个设计。

8.3 制造Parts

回顾一下上面的第4~7行。那些Part对象存放在chain、mountain_tire等变量里面。它们都是很久以前创建的,你可能已经把它们给忘了。请仔细想想这四行所代表的知识主体。在应用程序里的某个地方,会有对象必须要知道如何创建这些 Part 对象。而在上面的第4~7行,在那个地方必须要知道与山地自行车一起的这四个特定对象。

这里包含了很多的知识,它很容易在应用程序里泄漏掉。这种泄漏情况,既不幸也没必要。虽然有很多不同的单个零件,但有效的零件组合很少。如果你能描述不同的自行车,并且使用这些描述神奇般地为任何自行车制造出正确的Parts对象,那么一切都很简单了。

描述构成特定自行车的零件组合比较容易。下面的代码使用一个简单的二维数组来实现了这点,其中每一行包含了三种可能的列。第一列包含了零件名称(如'chain'、'tire_size'等),第二列是零件描述(如'10-speed'、'23'等),第三列(可选)是一个布尔值,用以指示这个零件是否需要留一个备用。只有下面第 9 行的'front_shock'会在第三列设置值,其他零件都默认为true,因为它们需要备用。

与散列表有所不同,这个简单的二维数组没有提供结构信息。但是,你明白这个结构是如何组织的,你可以将你的知识转化为一个制造Parts的新对象。

8.3.1 创建PartsFactory

正如在第3章做过的讨论,制造其他对象的对象叫工厂。当听到这个词时,你过去在其他语言上的经历很可能会让你有所退却,但是现在请把它当作是一次重拾信心的机会。“工厂”一词并不表示有多困难,或是过于复杂。它只是一个词组,OO设计师常用它来简明地交流像“一个对象创建其他多种对象”这样的思想。Ruby的工厂很简单,没有理由怕这只“纸老虎”。

下面的代码展示了一个新的PartsFactory 模块。它的工作是接收一个数组(上面所列出的数组当中的某一个),并且制造出一个Parts对象。采用这种方式,它也可以顺便创建Part对象,但这个动作是私有的。其公开的责任是创建一个Parts。

这是PartsFactory的第一版,它会接收三个参数,即config和分别用于Part跟Parts的类名。下面第6行用于创建新的Parts实例,它会使用根据config里的信息所建立的Part对象数组来执行初始化操作。

这个工厂知道 config 数组的结构。在上面的第 9~11 行,它期望 name 在第一列,description在第二列,而needs_spare在第三列。

将config的结构知识放置在这个工厂里,会有两种后果。第一个,config可以表达得非常简洁。因为PartsFactory了解config的内部结构,所以config可被指定为数组,而不用指定为散列表。第二个,一旦决定让config保持为数组,那么你就应该一直使用这个工厂来创建新的Parts对象。通过其他机制来创建新的Parts,需要复制编码在上面第9~11行里的知识。

既然有了 PartsFactory,那么你就可以使用上面定义的设置数组轻松地创建新的Parts。如下所示。

在PartsFactory与新的设置数组相结合之后,它会将所有创建有效Parts所需要的知识隔离起来。这种信息之前分散在整个应用程序里,但现在它被包含在这里的一个类和两个数组里。

8.3.2 借助PartsFactory

既然PartsFactory已被建立好,并可以运行起来,那么接下来看看Part类(重复如下)。它很简单。不仅如此,就连在PartsFactory里那段唯一有些复杂的代码(下面第7行的fetch)也被复制了过来。如果PartsFactory创建了所有的Part,那么Part就不会再需要这段代码。如果将这段代码从Part里删除,那么里面几乎什么都没了。可以将整个Part类更换为简单的OpenStruct。

Ruby的OpenStruct类与见过的那个Struct类很像,它提供了一种便捷方式,可以将若干属性汇集到一个对象。这两者的区别在于:Struct 接收的是按位置顺序排列的初始化参数,而OpenStruct在初始化时是接收一个散列表,然后从该散列表派生出属性。

删除Part类的理由很充分。这样做能简化代码,并且你可能永远不再需要像当前那样复杂的代码。删除Part类,并更改PartsFactory,以使用OpenStruct来创建扮演Part角色的对象,通过这样的方式你便可以清除掉 Part的所有痕迹。下面的代码展示了一个新版本的PartFactory,其中零件的创建已被重构为它自己的一个方法(第9行)。

上面的第13行,是这个应用程序里唯一的一处让needs_spare默认为true的地方。因此,PartsFactory必须全权负责制造Parts。

这个新版的PartsFactory 很有效。如下所示,它会返回一个 Parts,其中包含一个OpenStruct对象数组,而且每一个对象都扮演了Part角色。

8.4 组合成Bicycle

下面的代码展示了 Bicycle 使用组合的情况。它展示了 Bicycle、Parts、PartsFactory,以及针对公路和山地自行车的设置数组。

Bicycle有一个Parts,而Parts依次有一个Part对象集合。Parts和Part都可以以类形式存在,但包含它们的对象会把它们当成角色。Parts 是一个扮演 Parts 角色的类,它实现了spares。而Part的角色则由OpenStruct扮演,它会实现name、description和needs_spare。

下面的54行代码可以完全取代第6章里的那个66行继承层次结构。

这段新的代码与之前的那个Bicycle层次结构很像。唯一的区别在于:那个spares信息现在会返回一个像Part对象的数组,而不是返回一个散列表。如下面的第7行和第15行所示。

既然有了这些新类,那么创建新类型的自行车便是件轻而易举的事情。

在第6章,添加对卧式自行车的支持占用了19行新代码。现在,这项任务只使用3行的配置即可完成(如下面第2~4行)。

如上面的第11~23行所示,只需简单地对其零件进行描述,你便可以创建出一辆新的自行车。

聚合:一种特殊的组合

你已经对术语“委托”(delegation)有所了解。委托指的是当某个对象接收到消息时,它仅仅是将其转发给另外一个对象。委托会创建依赖关系:接收对象必须要能识别出这条消息,并要知道将它寄到哪里去。

组合通常会涉及委托,但这个术语还包含了更多的内容。一个组合对象由多个部分组成,它期望着通过定义良好的接口与这些单个部分进行交互。

组合描述了“有一个”关系。吃饭要有多样开胃菜,大学里有很多院系,自行车有多个零件。吃饭、大学和自行车都由多个对象组合而成。开胃菜、院系和零件都是角色。组合对象依赖于角色的接口。

因为吃饭与开胃菜之间是使用接口进行交互,所以希望表现为开胃菜的新对象只需要实现这个接口即可。不曾预料到的开胃菜可以“无缝地”出现在餐桌上,并且随时可更换。

术语“组合”(composition)可能让人感到有点迷惑,因为它被用在了两个稍有差异的概念身上。上面的定义是此术语最为广泛的用法。在大多数情况下,当你看到“组合”时,它通常都是指明两个对象之间的“有一个”关系。

不过,其正式的定义则表示了更为具体的内容。它表明的是这样一种关系,即被包含的对象离开了其容器就“活不下去”。严格意义上讲,你不仅要知道吃饭会有多种开胃菜,而且还要知道一旦开吃,那些开胃菜也会消失。

在这个定义里有一个缺口,正好可以由“聚合”(aggregation)一词补上。聚合基本上就是组合,不同之处在于那个被包含对象有自己独立的生命。大学有许多院系,而院系又进一步有许多教授。如果你的应用程序管理了许多大学,并且知道成百上千的教授,那么也会理所当然地期望:虽然某个院系在其所在的大学倒闭时会完全消失,但它的教授仍会继续存在。

这种“大学-院系”关系是一种组合(严格意义上来讲),而“院系-教授”关系是聚合。取消一个院系并不会连它的教授也会消失。他们有自己的存在方式和生命。

组合与聚合之间的这种区别对你的代码并没什么实际的影响。既然已熟悉这两个术语,那么你可以使用组合来指代这两种关系,的确需要时再进行区分。

8.5 继承和组合的抉择

请记住,经典继承是一种代码编排技术。行为分散在对象里面,而对象被组织成类关系,以便消息可以自动委托调用正确的行为。这个问题可以按这样一种方式来考虑:就某个层次结构里的对象编排成本而言,消息委托是免费的。

组合是将这些“利与弊”颠倒过来的另一种选择。在组合里,对象之间的关系并没有体现在类层次结构里。相反,对象独立存在。其结果就是,必须明确地了解消息,并将它们委托给另一个对象。组合支持对象之间的结构独立性,其代价是需要显式地进行消息委托。

既然对继承和组合的示例都已有所了解,那么现在可以开始考虑一下何时使用它们的问题。一般的规则是,如果所面对的问题能用组合技术解决,那么你应该倾向于这样做。如果你无法明确保证继承是一种更好的解决方案,那么请使用组合。组合比继承包含了更少的内建依赖关系,因此它常是最佳选择。

当继承能带来低风险和高回报的效果时,它则是更好的解决方案。本节会分析继承与组合的利与弊,并为选择最佳关系提供指导。

8.5.1 接受继承带来的后果

想要对使用继承做出明智的选择,需要清楚地理解它的利与弊。

1.继承的利

第2章列出了四个代码目标:代码应该满足透明、合理、可用和典范这四点要求。如果使用得当,继承在第二、第三和第四这三个目标方面表现突出。

定义在继承层次结构顶部附近的方法有着广泛的影响,因为层次结构的高度就像是一根让其影响力倍增的杠杆。对这些方法进行更改会触动到这棵继承树的下端。因此,正确建模的层次结构具有合理性。行为上的大变化可以通过代码的小改动来实现。

使用继承的代码可以用“开-闭”原则来描述。层次结构对扩展来说是开放的,而对修改则保持关闭。将新的子类添加到现有的层次结构时,不需要修改已有的代码。因此,这种层次结构具有可用性。你可以轻易地创建出新的子类以适应新的变化。

正确编写的层次结构易于扩展。这种层次结构体现了抽象,并且每一个新子类都会插入一点具体的差异。现有的模式很容易遵从,并且对于任何负责创建新子类的程序员来说,他们会很自然地选择重复这种模式。因此,这种层次结构具有典范性。从本质说,它们为编写扩展代码提供了指导。

你不用为了弄明白使用继承来组织代码的价值,而过多地追究面向对象语言自身的起源。在Ruby里,Numeric类是一个很好的示例。Integer和Float都构建为Numeric的子类。这恰恰就是“是一个”关系。整数和浮点数本质上都是数字。让这两个类共享同一个抽象是最节省成本的代码组织方式。

2.继承的弊

继承让人担心的地方分为两种。第一种担心,你可能因上当受骗,而选择继承来解决错误的问题。如果你犯了这种错误,那么某一天便会出现这样的情况:你需要添加行为,但却发现难以实现。由于该模型不正确,因此这个新行为也不适合。这种情况下,你不得不复制或重组代码。

第二种担心,即使继承对这个问题很有意义,但有可能你正编写的代码会被其他人用于你完全不曾预料到的目的。这些程序员都很想得到你已经创建的行为,但可能无法容忍这个继承所要求的依赖关系。

上一节讲的是继承的好处,它很小心地将其断言限定为只针对“正确建模的层次结构”。将合理性、可用性和模范性设定为具有双面性。好处这一面代表了继承所带来的美妙收获。如果将继承应用到一个不适合的问题,那么你会得到相反的结果,并会遭受同等效果的伤害。

合理性的反面:在错误建模的层次结构的顶层附近,更改所带来的成本极高。在这种情况下,杠杆效应就会发生在与你不利的那一面:很小的更改也会毁掉一切。

可用性的反面:当新子类表示的是混合类型时,很难实现行为的添加。第 6 章里的那个Bicycle 层次结构便无法满足添加卧式山地自行车的需要。这个层次结构已经包含了MountainBike和RecumbentBike 子类。在目前已有的这个层次结构里,将这两个类的特点结合成一个单一对象是不可能的。如果不进行更改,你无法重用现有的行为。

典范性的反面:当新手程序员试图对错误建模的层次结构进行扩展时,会引发混乱。这些有缺陷的层次结构不应被扩展,它们需要进行重构,但新手们通常没有能力这样做。新手们都会被迫复制现有的代码或添加对名称的依赖关系,而这两种做法都会加剧已有的设计问题。

因此,继承是一个“当我出错时,会发生什么呢?”这一问题显得特别重要的地方。很明显,继承伴随有深层嵌套的依赖关系集。子类不仅依赖于其父类里定义的方法,还会依赖于发送给父类的那些消息的自动委托。这是经典继承的最大的优点和最大的缺点。在这个层次结构里,子类与位于它们之上的那些类,以一种无法更改和故意的方式绑定在一起。这时内建依赖关系会放大更改父类所带来的影响。代码里的细微改动便会引发行为方面的巨大而又广泛的更改。

不管怎样,无论你是否会对此感到过后悔,这就是事实。

最后,选择使用继承也应顾及对代码使用人群的期望。如果你正在一个熟悉领域编写内部使用的应用程序代码,那么可能会对未来的预测很准确,并且坚信:对于你的设计问题,继承就是一个低成本的解决方案。当你编写面向大众的代码时,你的预测能力需要下调,而要求将继承作为接口部分的适宜性也会打折扣。

请避免编写出这样的框架:为了让用户获得代码的行为,而要求用户以子类方式继承对象。它们的应用程序对象有可能已被编排在某个层次结构里,而继承你的框架则可能无法实现。

8.5.2 接受组合带来的后果

使用组合构建的对象,与使用继承构建的对象存在两个方面的差别。组合对象不依赖于具体的类层次结构,它们只是委托自己的消息。这些差异带来了不同的“利与弊”集合。

1.组合的利

当使用组合时,自然趋势是会创建出许多包含简单责任的小对象,它们可通过明确定义的接口进行访问。以第2章的代码目标来衡量,这些组合良好的对象表现特别突出。

这些小对象都有一个单一职责,并且特定了它们自己的行为。它们都具有透明性:代码易于理解,并且如果有变化发生,所发生的事情也很明显。另外,组合对象独立于层次结构所代表的含义是:它继承了很少的代码。因此,它通常不会遭遇这种痛苦:当更改层次结构中位于它之上的类时,会产生副作用。

因为组合对象是通过接口来处理它们的部分,所以添加新类型的单个部分是简单的事情,只需插入一个遵从该接口的新对象即可。从组合对象的角度来看,添加一个已有部分的新变体是合理的,并且不需要更改其代码。

从本质上讲,参与组合的那些对象都很小,它们在结构上都是独立的,并且有着定义良好的接口。这使它们能够无缝地转换为可插入、可互换的组件。因此,在新的和意想不到的环境里,精心组合的对象更易于使用。

就好的一面而言,组合让构建在简单、可插拔对象上的应用程序易于扩展,且对变化有很高的容忍度。

2.组合的弊

与生活中的大部分事情一样,组合的长处也是其弱点。

一个组合对象依赖于许多部分。即使每个部分都很小且易于理解,但整个组合操作可能没那么明显。尽管每个部分可能确实是透明的,但并不能保证整体也是。

要获得结构性独立的好处,需要以消息自动委托为代价。组合对象必须明确地知道哪条消息需要委托给谁。相同的委托代码可以被许多不同的对象所利用,但组合无法提供这种共享代码的方式。

通过这些利与弊的分析可以看到,在将多个部分装配对象的规则方面,组合表现很优秀。但在对那些几乎完全相同的部分所构成的集合进行代码编排时,会遇到问题。它对此无法提供更多的帮助。

8.5.3 选择关系

经典继承(第 6 章)、通过模块的行为共享(第 7 章)和组合,它们每一个都是其所要解决的那个问题的完美解决方案。降低应用程序成本的诀窍在于将每一项技术都应用于正确的问题。

某些面向对象设计大师给出的建议是使用继承和组合。

“继承就是特殊化。”—Bertrand Meyer的《Touch of Class: Learning to Program Well with Objects and Contracts》

“当你要大量使用旧代码并添加相对少量的新代码时,继承是最适合用于完成往现有类进行功能性添加的操作。”—Erich Gamma、Richard Helm、Ralph Johnson和John Vlissides的《Design Patterns: Elements of Reusable Object-Oriented Software》

“当行为远超过其部分的总和时,请使用组合。”—Grady Booch的《Object-Oriented Analysis and Design》

1.将继承用于“是什么”关系

当选择继承而不选择组合时,你所下的赌注是希望积累的好处会因此超过成本。有些赌注可能相对更易偿还。有些真实世界的对象会很自然地进入到静态的、异常明显的特殊化层次结构,对于少量的这类对象可以使用经典继承来建模。

假设有一个自行车比赛游戏。玩家通过“购买”零件来组装他们的自行车。在可购买的零件中,有一种叫减震器(shock)。这个游戏总计提供了六种几乎一样的减震器,每一种都只是在价格和行为上稍微有些不同。

所有这些减震器都还是减震器。而“减震性”是其核心特性。减震器只存在于原子范畴。在减震器的各种变体之间,其相似程度远大于它们的差异程度。对于任何一种变体,你所能造出的最准确和最具有描述性的句子是:它“就是一个”减震器。

继承非常适合于这类问题。减震器可以被建模成一个浅窄的层次结构。层次结构的小尺寸让它更容易理解,意图也更明显,且易于扩展。因为这些对象符合成功使用继承的标准,因此犯错的风险很低。即使你在不太可能的情况下犯了错,改变想法所带来的成本也会很低。你可以在既获得继承好处的同时,又让自己少担风险。

就本章的示例而言,每种不同的减震器都在扮演Part的角色。它从其抽象父类Shock那里继承了公共的减震器行为,以及Part角色。PartsFactory当前假定每一个零件都由Part的OpenStruct表示,但你可以轻易地扩展零件配置数组,从而为特定的减震器提供类名。因为你已将 Part 当作是一个接口,所以很容易于插入新类型的零件,即使这个零件使用了继承来获得某一些行为,也一样可以插入。

如果需求发生了变化,如爆炸式地出现了各种减震器,那么这时需要重新评估这个设计决策。也许它仍然可用,也许已不可用。如果对一大堆新的减震器进行建模需要大幅扩展这个层次结构,或者如果这些新的减震器无法方便地与现有代码相融合,那么请在此时重新考虑其他的选择。

2.将鸭子类型用于“表现得像什么”关系

有些问题需要许多不同的对象扮演一个公共的角色。除了这些对象的核心职责以外,它们还可以扮演像schedulable、preparable、printable或persistable这样的角色。

有两种关键的方法可用于识别出存在的角色。第一种方法,尽管某个对象扮演了这个角色,但它不是该对象的主要职责。一辆自行车可以“表现得像一辆”可调度的自行车,但它“就是一辆”自行车。第二种方法,需求很宽泛。如有很多原本不相关的对象,它们都期望扮演同一个角色。

最有启发性的思考角色的方式是:从外部,以角色扮演者的承载者视角,而非以角色扮演者的视角来进行思考。一个schedulable的承载者会希望它实现Schedulable的接口,并且遵从Schedulable的契约。所有的schedulable都类似,因为它们都必须满足这些期望。

你的设计任务是将存在的角色识别出来,定义其鸭子类型的接口,并为每一位可能的扮演者提供此接口的实现。有些角色只由它们的接口构成,而其他的角色则需要共享公共的行为。在一个Ruby模块里定义这种公共的行为,让多个对象不用复制代码即可扮演这个角色。

3.将组合用于“有什么”关系

许多对象都包含了大量的单个部分,但它们远大于这些单个部分的总和。例如,Bicycle“有一个”Parts,但自行车自身却包含了更多的内容。除拥有其零件的行为外,它还拥有不同于零件的行为。假设当前对这个自行车示例有需求,那么最划算的构建 Bicycle 对象模型的方法是利用组合。

“是什么”和“有什么”的区别,是决定继承和组合的核心问题。一个对象的部分越多,它越有可能应该使用组合来建模。向下细分单个部分的程度越深,你越有可能发现拥有多种特定变体的特定部分,从而有更好的理由选择继承。对于每一个问题,都需要衡量各种可选设计技术的利与弊,并使用你的判断力和经验来做出最好的选择。

8.6 小结

组合让你可以将多个小部分结合起来,创建出更复杂的对象,从而让整体所包含的内容比各个部分的总和还要多。组合后的对象往往由简单、离散的实体组成,它们可以轻易地重新排列成新的组合。这些简单的对象易于理解、重用和测试,但因为它们组合成了一个更复杂的整体,所以对更大型应用程序的操作可能并不像理解各个部分一样那么容易。

组合、经典继承和通过模块的行为共享,都是相互独立的代码编排技术。每一个都有不同的利与弊特点。这些差异使它们更适合用于解决各种稍有不同的问题。

这些技术都是工具,仅此而已。如果你都对它们进行练习,那么你会成为一名更加出色的设计师。想要学会正确地使用它们,经验和判断力很重要。在获得经验的最好方法中,有一种就是从你自己的错误中学习。提高设计技能的关键是尝试这些技术,愉快地接受错误,抛弃过去的设计决定,并彻底重构。

在有了经验之后,你便可以在最初选择正确的技术时表现得更好,你的成本会下降,而应用程序也会得到改进。

相关图书

Ruby程序员修炼之道(第2版)
Ruby程序员修炼之道(第2版)
“笨办法”学Ruby(第3版)
“笨办法”学Ruby(第3版)
R和Ruby数据分析之旅
R和Ruby数据分析之旅

相关文章

相关课程