第6章 敏捷编码
任何一个笨蛋都能够让事情变得越来越笨重、越来越复杂、越来越极端。需要天才的指点以及许多的勇气,才能让事情向相反的方向发展。
——John Dryden[1],书信集10:至Congreve
新项目刚开始着手开发时,它的代码很容易理解和上手。然而,随着开发过程的推进,项目不知不觉中演变为一个庞然怪物。发展到最后,往往需要投入更多的精力、人力和物力来让它继续下去。
开始看起来非常正常的项目,是什么让它最终变得难以掌控?开发人员在完成任务时,可能会难以抵挡诱惑为节省时间而走“捷径”。然而,这些“捷径”往往只会推迟问题的爆发时间,而不是把它彻底解决掉(如同第15页习惯2中的情况一样)。当项目时间上的压力增加时,问题最终还是会在项目团队面前出现,让大家心烦意乱。
如何保证项目开发过程中压力正常,而不是在后期面对过多的压力,以致噩梦连连呢?最简单的方式,就是在开发过程中便细心“照看”代码。在编写代码时,每天付出一点小的努力,可以避免代码“腐烂”,并且保证应用程序不至变得难以理解和维护。
开发人员使用本章的实践习惯,可以保证开发出的代码无论是在项目进行中还是在项目完成后,都易于理解、扩展和维护。这些习惯会帮助你对代码进行“健康检查”,以防止它们变成庞然怪物。
首先,第100页中的习惯是:代码要清晰地表达意图。这样的代码清晰易懂,仅凭小聪明写出的程序很难维护。注释可以帮助理解,也可能导致不好的干扰,应该总是用代码沟通(见第105页)。在工程项目中没有免费的午餐,开发人员必须判断哪些东西更加重要,每个决策会造成什么后果,也就是说要动态评估取舍(见第110页)以得到最佳的决策。
项目是以增量式方式进行开发的,写程序时也应该进行增量式编程(见第113页)。在编写代码的时候,要想保持简单很难做到——实际上,想写出简单的代码要远比写出令人厌恶的、过分复杂的代码难得多。不过这样做绝对值得,见第115页。
我们将在第117页谈到,良好的面向对象设计原则建议:应该编写内聚的代码。要保持代码条理清晰,应该遵循如第121页上所述的习惯:告知,不要询问。最后,通过设计能够根据契约进行替换的系统(见124页),可以在不确定的未来中保持代码的灵活性。
25 代码要清晰地表达意图
“可以工作而且易于理解的代码当然好,但是让人觉得聪明更加重要。别人给你钱是因为你脑子好使,让我们看看你到底有多聪明。”
Hoare 谈软件设计
设计软件有两种方式。一种是设计得尽量简单,并且明显没有缺陷。另一种方式是设计得尽量复杂,并且没有明显的缺陷。
——C.A.R. Hoare[2]
我们大概都见过不少难以理解和维护的代码,而且(最坏的是)还有错误。当开发人员们像一群旁观者见到UFO一样围在代码四周,同样也感到恐惧、困惑与无助时,这个代码的质量就可想而知了。如果没有人理解一段代码的工作方式,那这段代码还有什么用呢?
开发代码时,应该更注重可读性,而不是只图自己方便。代码阅读的次数要远远超过编写的次数,所以在编写的时候值得花点功夫让它读起来更加简单。实际上,从衡量标准上来看,代码清晰程度的优先级应该排在执行效率之前。
例如,如果默认参数或可选参数会影响代码可读性,使其更难以理解和调试,那最好明确地指明参数,而不是在以后让人觉得迷惑。
在改动代码以修复bug或者添加新功能时,应该有条不紊地进行。首先,应该理解代码做了什么,它是如何做的。接下来,搞清楚将要改变哪些部分,然后着手修改并进行测试。作为第1步的理解代码,往往是最难的。如果别人给你的代码很容易理解,接下来的工作就省心多了。要敬重这个黄金法则[3],你欠他们一份情,因此也要让你自己的代码简单、便于阅读。
明白地告诉阅读程序的人,代码都做了什么,这是让其便于理解的一种方式。让我们看一些例子。
通过阅读上面的代码,可以大致明白这是要在咖啡店中下一个订单。但是,2到底是什么意思?是意味着要两杯咖啡?要再加两次?还是杯子的大小?要想搞清楚,唯一的方式就是去看方法定义或者文档,因为这段代码没有做到清晰易懂。
所以我们不妨添加一些注释。
现在看起来好一点了,不过请注意,注释有时候是为了帮写得不好的代码补漏(见第105页习惯26:用代码沟通)。
Java 5与.NET中有枚举值的概念,我们不妨使用一下。使用C#,我们可以定义一个名为CoffeeCupSize的枚举,如下所示。
接下来就可以用它来下单要咖啡了。
这段代码就很明白了,我们是要一个大杯[4]的咖啡。
作为一个开发者,应该时常提醒自己是否有办法让写出的代码更容易理解。下面是另一个例子。
第3行中的位移操作符是用来干什么的?如果善于进行位运算,或者熟悉逻辑设计或汇编编程,就会明白我们所做的只是把val的值乘以2。
PIE[5]原则
代码必须明确说出你的意图,而且必须富有表达力。这样可以让代码更易于被别人阅读和理解。代码不让人迷惑,也就减少了发生潜在错误的可能。一言以蔽之,代码应意图清晰,表达明确。
但对没有类似背景的人们来说,又会如何——他们能明白吗?也许团队中有一些刚刚转行做开发、没有太多经验的成员。他们会挠头不已,直到把头发抓下来[6]。代码执行效率也许很高,但是缺少明确的意图和表现力。
用位移做乘法,是在对代码进行不必要且危险的性能优化。result=val*2看起来更加清晰,也可以达到目的,而且对于某种给定的编译器来说,可能效率更高(懂得丢弃,见第34页习惯7)。不要表现得好像很聪明似的,要遵循PIE原则:代码要清晰地表达意图。
要是违反了PIE原则,造成的问题可就不只是代码可读性那么简单了——它会影响到代码的正确性。下列代码是一个C#方法,试图同步对CoffeeMaker中MakeCoffee()方法进行调用。
这个方法的作者想设置一个临界区(critical section)——任何时候最多只能有一个线程来执行操作中的代码。要达到这个目的,作者在CoffeeMaker实例中声明了一个锁。一个线程只有获得这个锁,才能执行这个方法。(在Java中,会使用synchronized而不是lock,不过想法是一样的。)
对于Java或.NET程序员来说,这样写顺理成章,但是其中有两个小问题。首先,锁的使用影响范围过大;其次,对一个全局可见的对象使用了锁。我们进一步来看看这两个问题。
假设Coffeemaker同时可以提供热水,因为有些人希望早上能够享用一点伯爵红茶。我想同步GetWater()方法,因此调用其中的lock(this)。这会同步任何在CoffeeMaker上使用lock的代码,也就意味着不能同时制作咖啡以及获取热水。这是开发者原本的意图吗?还是锁的影响范围太大了?通过阅读代码并不能明白这一点,使用代码的人也就迷惑不已了。
同时,MakeCoffee()方法的实现在CoffeeMaker对象上声明了一个锁,而应用的其他部分都可以访问CoffeeMaker对象。如果在一个线程中锁定了CoffeeMaker对象实例,然后在另外一个线程中调用那个实例之上的MakeCoffee()方法呢?最好的状况也会执行效率很差,最坏的状况会带来死锁。
让我们在这段代码上应用PIE原则,通过修改让它变得更加明确吧。我们不希望同时有两个或更多的线程来执行MakeCoffee()方法。那为什么不能为这个目的创建一个对象并锁定它呢?
这段代码解决了上面的两个问题——我们通过指定一个外部对象来进行同步操作,而且更加明确地表达了意图。
在编写代码时,应该使用语言特性来提升表现力。使用方法名来传达意向,对方法参数的命名要帮助读者理解背后的想法。异常传达的信息是哪些可能会出问题,以及如何进行防御式编程,要正确地使用和命名异常。好的编码规范可以让代码变得易于理解,同时减少不必要的注释和文档。
要编写清晰的而不是讨巧的代码。向代码阅读者明确表明你的意图。可读性差的代码一点都不聪明。
切身感受
应该让自己或团队的其他任何人,可以读懂自己一年前写的代码,而且只读一遍就知道它的运行机制。
平衡的艺术
□ 现在对你显而易见的事情,对别人可能并非如此,对于一年以后的你来说,也不一定显而易见。不妨将代码视作不知道会在未来何时打开的一个时间胶囊。
□ 不要明日复明日。如果现在不做的话,以后你也不会做的。
□ 有意图的编程并不是意味着创建更多的类或者类型。这不是进行过分抽象的理由。
□ 使用符合当时情形的耦合。例如,通过散列表进行松耦合,这种方式适用于在实际状况中就是松耦合的组件。不要使用散列表存储紧密耦合的组件,因为这样没有明确表示出你的意图。
26 用代码沟通
“如果代码太杂乱以至于无法阅读,就应该使用注释来说明。精确地解释代码做了什么,每行代码都要加注释。不用管为什么要这样编码,只要告诉我们到底是怎么做的就好了。”
通常程序员都很讨厌写文档。这是因为大部分文档都与代码没有关系,并且越来越难以保证其符合目前的最新状况。这不只违反了DRY原则(不要重复你自己,Don’t Repeat Yourself,见[HT00]),还会产生使人误解的文档,这还不如没有文档。
建立代码文档无外乎两种方式:利用代码本身;利用注释来沟通代码之外的问题。
如果必须通读一个方法的代码才能了解它做了什么,那么开发人员先要投入大量的时间和精力才能使用它。反过来讲,只需短短几行注释说明方法行为,就可以让生活变得轻松许多。开发人员可以很快了解到它的意图、它的期待结果,以及应该注意之处——这可省了你不少劲儿。
不要用注释来包裹你的代码
Don’t comment to cover up
应该文档化你所有的代码吗?在某种程度上说,是的。但这并不意味着要注释绝大部分代码,特别是在方法体内部。源代码可以被读懂,不是因为其中的注释,而应该是由于它本身优雅而清晰——变量名运用正确、空格使用得当、逻辑分离清晰,以及表达式非常简洁。
如何命名很重要。程序元素的命名是代码读者必读的部分。[7]通过使用细心挑选的名称,可以向阅读者传递大量的意图和信息。反过来讲,使用人造的命名范式(比如现在已经无人问津的匈牙利表示法)会让代码难以阅读和理解。这些范式中包括的底层数据类型信息,会硬编码在变量名和方法名中,形成脆弱、僵化的代码,并会在将来造成麻烦。
使用细心挑选的名称和清晰的执行路径,代码几乎不需要注释。实际上,当Andy和Dave Thomas联手写作第一本关于Ruby编程语言的书籍时(即参考文献[TH01]),他们只要阅读将会在Ruby解释器中执行的代码,几乎就可以把整个Ruby语言的相关细节记录下来了。代码能够自解释,而不用依赖注释,是一件很好的事情。Ruby的创建者松本行弘是日本人,而Andy和Dave除了“sukiyaki”(一种日式火锅)和“sake”(日本清酒)之外,一句日语也不会。
如何界定一个良好的命名?良好的命名可以向读者传递大量的正确信息。不好的命名不会传递任何信息,糟糕的命名则会传递错误的信息。
例如,一个名为readAccount()的方法实际所做的却是向硬盘写入地址信息,这样的命名就被认为是很糟糕的(是的,这确实发生过,参见[HT00])。
foo是一个具有历史意义、很棒的临时变量名称,但是它没有传递作者的任何意图。要尽量避免使用神秘的变量名。不是说命名短小就等同于神秘:在许多编程语言中通常使用i来表示循环索引变量,s常被用来表示一个字符串。这在许多语言中都是惯用法,虽然都很短,但并不神秘。在这些环境中使用s作为循环索引变量,可真的不是什么好主意,名为indexvar的变量也同样不好。不必费尽心机去用繁复冗长的名字替换大家已习惯的名称。
对于显而易见的代码增加注释,也会有同样的问题,比如在一个类的构造方法后面添加注释//Constructor就是多此一举。但很不幸,这种注释很常见——通常是由过于热心的IDE插入的。最好的状况下,它不过是为代码添加了“噪音”。最坏的状况下,随着时间推进,这些注释则会过时,变得不再正确。
许多注释没有传递任何有意义的信息。例如,对于passthrough()方法,它的注释是“这个方法允许你传递”,但读者能从中得到什么帮助呢?这种注释只会分散注意力,而且很容易失去时效性[假使方法最后又被改名为sendToHost()]。
注释可用来为读者指定一条正确的代码访问路线图。为代码中的每个类或模块添加一个短小的描述,说明其目的以及是否有任何特别需求。对于类中的每个方法,可能要说明下列信息。
□ 目的:为什么需要这个方法?
□ 需求(前置条件):方法需要什么样的输入,对象必须处于何种状态,才能让这个方法工作?
□ 承诺(后置条件):方法成功执行后,对象现在处于什么状态,有哪些返回值?
□ 异常:可能会发生什么样的问题?会抛出什么样的异常?
要感谢如RDoc、javadoc和ndoc这样的工具,使用它们可以很方便地直接从代码注释创建有用的、格式优美的文档。这些工具抽取注释,并生成样式漂亮且带有超链接的HTML输出。
下面是一段C#文档化代码的摘录。通常的注释用//开头,要生成文档的注释用///开头(当然这仍然是合法的注释)。
图6-1展示了从C#代码示例中抽取出来的注释生成的文档。用于Java的Javadoc、用于Ruby的Rdoc等工具也都以类似的方式工作。
图6-1 使用ndoc从代码中抽取出来的文档
这种文档不只是为团队或组织之外的人准备的。假定你要修改几个月之前所写的代码,如果只要看一下方法头上的注释,就知道需要了解的重要细节,那么修改起来是不是会方便很多?不管怎么说,如果一个方法只有在发生日全食的时候才能正常工作,那么先了解到这个情况(而不必管代码细节)是有好处的,否则岂不是要白白等上10年才有这个机会?
代码被阅读的次数要远超过被编写的次数,所以在编程时多付出一点努力来做好文档,会让你在将来受益匪浅。
用注释沟通。使用细心选择的、有意义的命名。用注释描述代码意图和约束。注释不能替代优秀的代码。
切身感受
注释就像是可以帮助你的好朋友,可以先阅读注释,然后快速浏览代码,从而完全理解它做了什么,以及为什么这样做。
平衡的艺术
□ Pascal定理的创始人Blaise Pascal[8]曾说,他总是没有时间写短信,所以只好写长信。请花时间去写简明扼要的注释吧。
□ 在代码可以传递意图的地方不要使用注释。
□ 解释代码做了什么的注释用处不那么大。相反,注释要说明为什么会这样写代码。
□ 当重写方法时,保留描述原有方法意图和约束的注释。
27 动态评估取舍
“性能、生产力、优雅、成本以及上市时间,在软件开发过程中都是至关重要的因素。每一项都必须达到最理想状态。”
你可能曾经身处这样的团队:管理层和客户将很大一部分注意力都放在应用的界面展示上。也有这样的团队,其客户认为性能表现非常重要。在团队中,你可能会发现,有这样一个开发主管或者架构师,他会强调遵守“正确”的范式比其他任何事情都重要。对任何单个因素如此独断地强调,而不考虑它是否是项目成功的必要因素,必然导致灾难的发生。
强调性能的重要性情有可原,因为恶劣的性能表现会让一个应用在市场上铩羽而归。然而,如果应用的性能已经足够好了,还有必要继续投入精力让其运行得更快一点吗?大概不用了吧。一个应用还有很多其他方面的因素同样重要。与其花费时间去提升千分之一的性能表现,也许减少开发投入,降低成本,并尽快让应用程序上市销售更有价值。
举例来说,考虑一个必须要与远程Windows服务器进行通讯的.NET Windows应用程序。可以选择使用.NET Remoting技术或Web Service来实现这个功能。现在,针对使用Web Service的提议,有些开发者会说:“我们要在Windows之间进行通信,通常此类情况下,推荐使用.NET Remoting。而且,Web Service很慢,我们会遇到性能问题。”嗯,一般来说确实是这样。
然而,在这个例子中,使用Web Service很容易开发。对Web Service的性能测试表明XML[9]文档很小,并且相对应用程序自己的响应时间来讲,花在创建和解析XML上的时间几乎可以忽略不计。使用Web Service不但可以在短期内节省开发时间,且在此后团队被迫使用第三方提供的服务时,Web Service也是个明智的选择。
过犹不及
我曾经遇到这样一个客户,他们坚信可配置性的重要性,致使他们的应用有大概10 000个可配置变量。新增代码变得异常艰难,因为要花费大量时间来维护配置应用程序和数据库。但是他们坚信需要这种程度的灵活性,因为每个客户都有不同的需求,需要不同的设置。
可实际上,他们只有19个客户,而且预计将来也不会超过50个。他们并没有很好地去权衡。
考虑这样一个应用,从数据库中读取数据,并以表格方式显示。你可以使用一种优雅的、面向对象的方式,从数据库中取数据,创建对象,再将它们返回给UI层。在UI层中,你再从对象中拆分出数据,并组织为表格方式显示。除了看起来优雅之外,这样做还有什么好处吗?
也许你只需要让数据层返回一个数据集(dataset)或数据集合,然后用表格显示这些数据即可。这样还可以避免对象创建和销毁所耗费的资源。如果需要的只是数据展示,为什么要创建对象去自找麻烦呢?不按书上说的OO方式来做,可以减少投入,同时获得性能上的提升。当然,这种方式有很多缺点,但问题的关键是要多长个心眼儿,而不是总按照习惯的思路去解决问题。
总而言之,要想让应用成功,降低开发成本与缩短上市时间,二者的影响同样重要。由于计算机硬件价格日益便宜,处理速度日益加快,所以可在硬件上多投入以换取性能的提升,并将节省下来的时间放在应用的其他方面。
当然,这也不完全对。如果硬件需求非常庞大,需要一个巨大的计算机网格以及众多的支持人员才能维持其正常运转(比如类似Google那样的需求),那么考虑就要向天平的另一端倾斜了。
但是谁来最终判定性能表现已经足够好,或是应用的展现已经足够“炫”了呢?客户或是利益相关者必须进行评估,并做出相关决定(见第45页习惯10)。如果团队认为性能上还有提升的空间,或者觉得可以让某些界面看起来更吸引人,那么就去咨询一下利益相关者,让他们决定应将重点放在哪里。
没有适宜所有状况的最佳解决方案。你必须对手上的问题进行评估,并选出最合适的解决方案。每个设计都是针对特定问题的——只有明确地进行评估和权衡,才能得出更好的解决方案。
没有最佳解决方案
No best solution
动态评估权衡。考虑性能、便利性、生产力、成本和上市时间。如果性能表现足够了,就将注意力放在其他因素上。不要为了感觉上的性能提升或者设计的优雅,而将设计复杂化。
切身感受
即使不能面面俱到,你也应该觉得已经得到了最重要的东西——客户认为有价值的特性。
平衡的艺术
□ 如果现在投入额外的资源和精力,是为了将来可能得到的好处,要确认投入一定要得到回报(大部分情况下,是不会有回报的)。
□ 真正的高性能系统,从一开始设计时就在向这个方向努力。
□ 过早的优化是万恶之源。[10]
□ 过去用过的解决方案对当前的问题可能适用,也可能不适用。不要事先预设结论,先看看现在是什么状况。
28 增量式编程
“真正的程序员写起代码来,一干就是几个小时,根本不停,甚至连头都不抬。不要停下来去编译你的代码,只要一直往下写就好了!”
当你开车进行长途旅行时,两手把住方向盘,固定在一个位置,两眼直盯前方,油门一踩到底几个小时,这样可能吗?当然不行了,你必须掌控方向,必须经常注意交通状况,必须检查油量表,必须停车加油、吃饭,准备其他必需品,以及诸如此类的活动。[11]
如果不对自己编写的代码进行测试,保证没有问题,就不要连续几个小时,甚至连续几分钟进行编程。相反,应该采用增量式的编程方式。增量式编程可以精炼并结构化你的代码。代码被复杂化、变成一团乱麻的几率减少了。所开发的代码基于即时的反馈,这些反馈来自以小步幅方式编写代码和测试的过程。
采取增量式编程和测试,会倾向于创建更小的方法和更具内聚性的类。你不是在埋头盲目地一次性编写一大堆代码。相反,你会经常评估代码质量,并不时地进行许多小调整,而不是一次修改许多东西。
在编写代码的时候,要经常留心可以改进的微小方面。这可能会改善代码的可读性。也许你会发现可以把一个方法拆成几个更小的方法,使其变得更易于测试。在重构的原则指导下,可以做出许多细微改善(见Martin Fowler的《重构:改善既有代码的设计》[12][FBB+99]一书中的相关讨论)。可以使用测试优先开发方式(见第82页习惯20),作为强制进行增量式编程的方式。关键在于持续做一些细小而有用的事情,而不是做一段长时间的编程或重构。
这就是敏捷的方式。
在很短的编辑/构建/测试循环中编写代码。这要比花费长时间仅仅做编写代码的工作好得多。可以创建更加清晰、简单、易于维护的代码。
切身感受
在写了几行代码之后,你会迫切地希望进行一次构建/测试循环。在没有得到反馈时,你不想走得太远。
平衡的艺术
□ 如果构建和测试循环花费的时间过长,你就不会希望经常运行它们了。要保证测试可以快速运行。
□ 在编译和测试运行中,停下来想一想,并暂时远离代码细节,这是保证不会偏离正确方向的好办法。
□ 要休息的话,就要好好休息。休息时请远离键盘。
□ 要像重构你的代码那样,重构你的测试,而且要经常重构测试。
29 保持简单
“软件是很复杂的东西。随便哪个笨蛋都可以编写出简单、优雅的软件。通过编写史上最复杂的程序,你将会得到美誉和认可,更不用提保住你的工作了。”
也许你看过这样一篇文章,其中提到了一个设计想法,表示为一个带有花哨名称的模式。放下杂志,眼前的代码似乎马上就可以用到这种模式。这时要扪心自问,是不是真的需要用它,以及它将如何帮你解决眼前的问题。问问自己,是不是特定的问题强迫你使用这个解决方案。不要让自己被迫进行过分设计,也不要将代码过分复杂化。
Andy曾经认识一个家伙,他对设计模式非常着迷,想把它们全都用起来。有一次,要写一个大概几百行代码的程序。在被别人发现之前,他已经成功地将GoF那本书[GHJV95]中的17个模式,都运用到那可怜的程序中。
这不应该是编写敏捷代码的方式。
问题在于,许多开发人员倾向于将投入的努力与程序复杂性混同起来。如果你看到别人给出的解决方案,并评价说“非常简单且易于理解”,很有可能你会让设计者不高兴。许多开发人员以自己程序的复杂性为荣,如果能听到说:“Wow,这很难,一定是花了很多时间和精力才做出来的吧。”他们就会面带自豪的微笑了。其实应当恰恰相反,开发人员更应该为自己能够创建出一个简单并且可用的设计而骄傲。
“简单性”这个词汇被人们大大误解了(在软件开发工作以及人们的日常生活中,皆是如此)。它并不意味着简陋、业余或是能力不足。恰恰相反,相比一个过分复杂、拙劣的解决方案,简单的方案通常更难以获得。
简单不是简陋
Simple is not simplistic
简单性,在编程或是写作中,就像是厨师的收汁调料。从大量的葡萄酒、主料和配料开始,你小心地进行烹调,到最后得到了最浓缩的精华部分。这就是好的代码应该带给人的感觉——不是一大锅黏糊糊的、乱七八糟的东西,而是真正的、富含营养的、口味上佳的酱汁。
怎样才算优雅?
优雅的代码第一眼看上去,就知道它的用处,而且很简洁。但是这样的解决方案不是那么容易想出来的。这就是说,优雅是易于理解和辨识的,但是要想创建出来就困难得多了。
评价设计质量的最佳方式之一,就是听从直觉。直觉不是魔术,它是经验和技能的厚积薄发之产物。在查看一个设计时,听从头脑中的声音。如果觉得什么地方不对,那就好好想想,是哪里出了问题。一个好的设计会让人觉得很舒服。
开发可以工作的、最简单的解决方案。除非有不可辩驳的原因,否则不要使用模式、原则和高难度技术之类的东西。
切身感受
当你觉得所编写的代码中没有一行是多余的,并且仍能交付全部的功能时,这种感觉就对了。这样的代码容易理解和改正。
平衡的艺术
□ 代码几乎总是可以得到进一步精炼,但是到了某个点之后,再做改进就不会带来任何实质性的好处了。这时开发人员就该停下来,去做其他方面的工作了。
□ 要将目标牢记在心:简单、可读性高的代码。强行让代码变得优雅与过早优化类似,同样会产生恶劣的影响。
□ 当然,简单的解决方案必须要满足功能需求。为了简单而在功能上妥协,这就是过分简化了。
□ 太过简洁不等于简单,那样无法达到沟通的目的。
□ 一个人认为简单的东西,可能对另一个人就意味着复杂。
30 编写内聚的代码
“你要编写一些新的代码,首先要决定的就是把这些代码放在什么地方。其实放在什么地方问题不大,你就赶紧开始吧,看看IDE中现在打开的是哪个类,直接加进去就是了。如果所有的代码都在一个类或组件里面,要找起来是很方便的。”
内聚性用来评估一个组件(包、模块或配件)中成员的功能相关性。内聚程度高,表明各个成员共同完成了一个功能特性或是一组功能特性。内聚程度低的话,表明各个成员提供的功能是互不相干的。
假定把所有的衣服都扔到一个抽屉里面。当需要找一双袜子的时候,要翻遍里面所有的衣服——裤子、内衣、T恤等——才能找到。这很麻烦,特别是在赶时间的时候。现在,假定把所有的袜子都放在一个抽屉里面(而且是成双放置的),全部的T恤放在另外一个抽屉中,其他衣服也分门别类。要找到一双袜子,只要打开正确的抽屉就可以了。
与此类似,如何组织一个组件中的代码,会对开发人员的生产力和全部代码的可维护性产生重要影响。在决定创建一个类的时候,问问自己,这个类的功能是不是与组件中其他某个类的功能类似,而且功能紧密相关。这就是组件级的内聚性。
类也要遵循内聚性。如果一个类的方法和属性共同完成了一个功能(或是一系列紧密相关的功能),这个类就是内聚的。
看看Charles Hess先生于1866年申请的专利,“可变换的钢琴、睡椅和五斗柜”(见图6-2)。根据他的专利说明,它提供了“……附加的睡椅和五斗柜……以填满钢琴下未被使用的空间……”。接下来他说明了为什么要发明这个可变换的钢琴。读者可能已经见过类似这种发明的项目代码结构了,而且也许其中有你的份。这个发明不具备任何内聚性,任何一个人都可以想象得到,要维护这个怪物(比如换垫子、调钢琴等)会是多么困难。
图6-2 美国专利56413:可变换的钢琴、睡椅和五斗柜
看看最近的例子。Venkat曾经见过一个用ASP开发的、有20个页面的Web应用。每个页面都以HTML开头,并包含大量VBScript脚本,其中还内嵌了访问数据库的SQL语句。客户当然会认为这个应用的开发已经失去了控制,并且无法维护。如果每个页面都包括展示逻辑、业务逻辑和访问数据的代码,就有太多的东西都堆在一个地方了。
假定要对数据库的表结构进行一次微调。这个微小的变化会导致应用中所有的页面发生变化,而且每个页面中都会有多处改变——这个应用很快就变成了一场灾难。
如果应用使用了中间层对象(比如一个COM组件)来访问数据库,数据库表结构变更所造成的影响就可以控制在一定的范围之内,代码也更容易维护。
低内聚性的代码会造成很严重的后果。假设有这样一个类,实现了五种完全不相干的功能。如果这5个功能的需求或细节发生了变化,这个类也必须跟着改变。如果一个类(或者一个组件)变化得过于频繁,这样的改变会对整个系统形成“涟漪效应”,并导致更多的维护和成本的发生。考虑另一个只实现了一种功能的类,这个类变化的频度就没有那么高。类似地,一个更具内聚性的组件不会有太多导致其变化的原因,也因此而更加稳定。根据单一职责原则(查看《敏捷软件开发:原则、模式与实践》[Mar02]),一个模块应该只有一个发生变化的原因。
一些设计技巧可以起到帮助作用。举例来说,我们常常使用模型—视图—控制器(MVC)模式来分离展示层逻辑、控制器和模型。这个模式非常有效,因为它可以让开发人员获得更高的内聚性。模型中的类包含一种功能,在控制器中的类包含另外的功能,而视图中的类则只关心UI。
内聚性会影响一个组件的可重用性。组件粒度是在设计时要考虑的一个重要因素。根据重用发布等价原则([Mar02]):重用的粒度与发布的粒度相同。这就是说,程序库用户所需要的,是完整的程序库,而不只是其中的一部分。如果不能遵循这个原则,组件用户就会被强迫只能使用所发布组件的一部分。很不幸的是,他们仍然会被不关心的那一部分的更新所影响。软件包越大,可重用性就越差。
让类的功能尽量集中,让组件尽量小。要避免创建很大的类或组件,也不要创建无所不包的大杂烩类。
切身感受
感觉类和组件的功能都很集中:每个类或组件只做一件事,而且做得很好。bug很容易跟踪,代码也易于修改,因为类和组件的责任都很清晰。
平衡的艺术
□ 有可能会把一些东西拆分成很多微小的部分,而使其失去了实用价值。当你需要一只袜子的时候,一盒棉线不能带给你任何帮助。[13]
□ 具有良好内聚性的代码,可能会根据需求的变化,而成比例地进行变更。考虑一下,实现一个简单的功能变化需要变更多少代码。[14]
31 告知,不要询问
“不要相信其他的对象。毕竟,它们是由别人写的,甚至有可能是你自己上个月头脑发昏的时候写的呢。从别人那里去拿你需要的信息,然后自己处理,自己决策。不要放弃控制别人的机会!”
“面向过程的代码取得信息,然后做出决策。面向对象的代码让别的对象去做事情。”Alec Sharp[Sha97]通过观察后,一针见血地指出了这个关键点。但是这种说法并不仅限于面向对象的开发,任何敏捷的代码都应该遵循这个方式。
作为某段代码的调用者,开发人员绝对不应该基于被调用对象的状态来做出任何决策,更不能去改变该对象的状态。这样的逻辑应该是被调用对象的责任,而不是你的。在该对象之外替它做决策,就违反了它的封装原则,而且为bug提供了滋生的土壤。
David Bock使用“送报男孩和钱包的故事”很好地诠释了这一点。[15]假定送报男孩来到你的门前,要求付给他本周的报酬。你转过身去,让送报男孩从你的后屁股兜里掏出钱包,并且从中拿走两美元(你希望是这么多),再把钱包放回去。然后,送报男孩就会开着他崭新的美洲豹汽车扬长而去了。
在这个过程中,送报男孩作为“调用者”,应该告诉客户付他两美元。他不能探询客户的财务状况,或是钱包的薄厚,他也不能代替客户做任何决策。这都是客户的责任,而不属于送报男孩。敏捷代码也应该以同样的方式工作。
将命令与查询分离开来
Keep commands separate from queries
与告知,不要询问相关的一个很有用的技术是:命令与查询相分离模式(command-query separation)。就是要将功能和方法分为“命令”和“查询”两类,并在源码中记录下来(这样做可以帮助将所有的“命令”代码放在一起,并将所有的“查询”代码放在一起)。
一个常规的“命令”可能会改变对象的状态,而且有可能返回一些有用的值,以方便使用。一个“查询”仅仅提供给开发人员对象的状态,并不会对其外部的可见状态进行修改。
小心副作用
是不是听到有人说过:“噢,我们刚调用了那个方法,是因为它的副作用。”这种说法等同于为代码中的诡异之处辩护说:“嗯,它现在是这个样子,是因为过去就是这个样子……”
类似这样的话就是一个明显的警告信号,表明存在一个敏感易碎的而不是敏捷的设计。
对副作用的依赖,或是与一个不断扭曲、与现实不符的设计共存,说明你必须马上开始重新设计以及重构你的代码了。
这就是说,从外部看来,“查询”不应该有任何副作用(如果需要的话,开发人员可能想在后台做一些事先的计算或是缓存处理,但是取得对象中X的值,不应该改变Y的值)。
像“命令”这种会产生内部影响的方法,强化了告知,不要询问的建议。此外,保证“查询”没有副作用,也是很好的编码实践,因为开发人员可以在单元测试中自由使用它们,在断言或者调试器中调用它们,而不会改变应用的状态。
从外部将“查询”与“命令”隔离开来,还会给开发人员机会询问自己为什么要暴露某些特定的数据。真的需要这么做吗?调用者会如何使用它?也许应该有一个相关的“命令”来替代它。
告知,不要询问。不要抢别的对象或是组件的工作。告诉它做什么,然后盯着你自己的职责就好了。
切身感受
Smalltalk使用“信息传递”的概念,而不是方法调用。告知,不要询问感觉起来就像你在发送消息,而不是调用函数。
平衡的艺术
□ 一个对象,如果只是用作大量数据容器,这样的做法很可疑。有些情况下会需要这样的东西,但并不像想象的那么频繁。
□ 一个“命令”返回数据以方便使用是没有问题的(如果需要的话,创建单独读取数据的方法也是可以的)。
□ 绝对不能允许一个看起来无辜的“查询”去修改对象的状态。
32 根据契约进行替换
“深层次的继承是很棒的。如果你需要其他类的函数,直接继承它们就好了!不要担心你创建的新类会造成破坏,你的调用者可以改变他们的代码。这是他们的问题,而不是你的问题。”
保持系统灵活性的关键方式,是当新代码取代原有代码之后,其他已有的代码不会意识到任何差别。例如,某个开发人员可能想为通信的底层架构添加一种新的加密方式,或者使用同样的接口实现更好的搜索算法。只要接口保持不变,开发人员就可以随意修改实现代码,而不影响其他任何现有代码。然而,说起来容易,做起来难。所以需要一点指导来帮助我们正确地实现。因此,去看看Barbara Liskov的说法。
Liskov替换原则[Lis88]告诉我们:任何继承后得到的派生类对象,必须可以替换任何被使用的基类对象,而且使用者不必知道任何差异。换句话说,某段代码如果使用了基类中的方法,就必须能够使用派生类的对象,并且自己不必进行任何修改。
这到底意味着什么?假定某个类中有一个简单的方法,用来对一个字符串列表进行排序,然后返回一个新的列表。并用如下的方式进行调用:
现在假定开发人员派生了一个BasicUtils的子类,并写了一个新的sort()方法,使用了更快、更好的排序算法:
注意对sort()的调用是完全一样的,一个FasterUtils对象完美地替换了一个BasicUtils对象。调用utils.sort()的代码可以处理任何类型的utils对象,而且可以正常工作。
但如果开发人员派生了一个BasicUtils的子类,并改变了排序的意义——也许返回的列表以相反的顺序进行排列——那就严重违反了Liskov替换原则。
要遵守Liskov替换原则,相对基类的对应方法,派生类服务(方法)应该不要求更多,不承诺更少;要可以进行自由的替换。在设计类的继承层次时,这是一个非常重要的考虑因素。
继承是OO建模和编程中被滥用最多的概念之一。如果违反了Liskov替换原则,继承层次可能仍然可以提供代码的可重用性,但是将会失去可扩展性。类继承关系的使用者现在必须要检查给定对象的类型,以确定如何针对其进行处理。当引入了新的类之后,调用代码必须经常重新评估并修正。这不是敏捷的方式。
但是可以借用一些帮助。编译器可以帮助开发人员强制执行Liskov替换原则,至少在某种程度上是可以达到的。例如,针对方法的访问修饰符。在Java中,重写方法的访问修饰符必须与被重写方法的修饰符相同,或者可访问范围更加宽大。也就是说,如果基类方法是受保护的,那么派生重写方法的修饰符必须是受保护的或者公共的。在C#和VB.NET中,被重写方法与重写方法的访问保护范围必须完全相同。
考虑一个带有findLargest()方法的类Base,方法中抛出一个IndexOut-OfRangeException异常。基于文档,类的使用者会准备抓住可能被抛出的异常。现在,假定你从Base类继承得到类Derived,并重写了findLargest()方法,在新的方法中抛出了一个不同的异常。现在,如果某段代码期待使用Base类对象,并调用了Derived类的实例,这段代码就有可能接收到一个意想不到的异常。你的Derived类就不能替换使用到Base类的地方。在Java中,通过不允许重写方法抛出任何新的检查异常避免了这个问题,除非异常本身派生自被重写方法抛出的异常类(当然,对于像RuntimeException这样的未检查异常,编译器就不能帮你了)。
不幸的是,Java也违背了Liskov替换原则。java.util.Stack类派生自java. util. Vector类。如果开发人员(不小心)将Stack对象发送给一个期待Vector实例的方法,Stack中的元素就可能被以与期望的行为不符的顺序被插入或删除。
当使用继承时,要想想派生类是否可以替换基类。如果答案是不能,就要问问自己为什么要使用继承。如果答案是希望在编写新类的时候,还要重用基类中的代码,也许要考虑转而使用聚合。聚合是指在类中包含一个对象,并且该对象是其他类的实例,开发人员将责任委托给所包含的对象来完成(该技术同样被称为委托)。
针对is-a关系使用继承;针对has-a或uses-a关系使用委托
Use inheritance for is-a; use delegation for has-a or uses-a
图6-3中展示了委托与继承之间的差异。在图中,一个调用者调用了Called Class中的methodA(),而它将会通过继承直接调用Base Class中的方法。在委托的模型中,Called Class必须要显式地将方法调用转向包含的委托方法。
图6-3 委托与继承
那么继承和委托分别在什么时候使用呢?
□ 如果新类可以替换已有的类,并且它们之间的关系可以通过is-a来描述,就要使用继承。
□ 如果新类只是使用已有的类,并且两者之间的关系可以描述为has-a或是uses-a,就使用委托吧。
开发人员可能会争辩说,在使用委托时,必须要写很多小方法,来将方法调用指向所包含的对象。在继承中,不需要这样做,因为基类中的公共方法在派生类中就已经是可用的了。仅凭这一点,并不能构成使用继承足够好的理由。
你可以开发一个好的脚本或是好的IDE宏,来帮助编写这几行代码,或者使用一种更好的编程语言/环境,以支持更自动化形式的委托(比如Ruby这一点就做得不错)。
通过替换代码来扩展系统。通过替换遵循接口契约的类,来添加并改进功能特性。要多使用委托而不是继承。
切身感受
这会让人觉得有点鬼鬼祟祟的,你可以偷偷地替换组件代码到代码库中,而且其他代码对此一无所知,它们还获得了新的或改进后的功能。
平衡的艺术
□ 相对继承来说,委托更加灵活,适应力也更强。
□ 继承不是魔鬼,只是长久以来被大家误解了。
□ 如果你不确定一个接口做出了什么样的承诺,或是有什么样的需求,那就很难提供一个对其有意义的实现了。
【注释】
[1]John Dryden(1631—1700),英国第一位受封的“桂冠诗人”,英国古典主义时期重要的批评家和戏剧家,英国古典主义代表人物之一。——译者注
[2]Hoare,全名Charles Antony Richard Hoare,简称C.A.R. Hoare,生于1934年1月11日,英国计算机科学家,发明了排序算法中的“快速排序”算法。图灵奖得主。——译者注
[3]黄金法则(Golden Rule),起源于《圣经》(太7:12):“无论何事,你们愿意人怎样待你们,你们也要怎样待人。”——编者注
[4]对星巴克的粉丝来说,这是指venti。
[5]PIE=Program Intently and Expressively,即意图清楚而且表达明确地编程。——编者注
[6]没错,那不是一块秃顶,而是一个编程机器的太阳能电池板。
[7]在《地海巫师》(The Wizard of Earthsea)系列书籍中,知道一件事物的真实名称可以让一个人对它实施完全的控制。通过名称来进行魔法控制,是文学和神话中一种常用的主题,在软件开发中也是如此。
[8]布莱兹·帕斯卡尔(Blaise Pascal,1623—1662),生于法国奥弗涅,卒于巴黎。他是早慧的神童,早夭的天才。主要的数学成就是射影几何方面的Pascal定理,他与Fermat是概率论的奠基者。不过对后世影响最大的,是他的宗教性著作《沉思录》。——译者注
[9]XML文档就像人类一样——它们在小时候很可爱,并且与它们在一起也很有意思,但是长大之后,就会变得特别让人厌烦。
[10]Donald Knuth对Hoare格言的强有力概括[Knu92]。
[11]Kent Beck在《解析极限编程》一书中引入了开车(以及掌控方向盘的重要性)作为比喻。
[12]本书即将由人民邮电出版社出版。——编者注
[13]你可以把这个叫作“意大利面OO”系统。
[14]本书的一位检阅者告诉我们这样一个系统,向一个表单中添加一个字段,需要16名团队成员和6名经理的同意。这是一个很清晰的警告信号,说明系统的内聚性很差。