重构:改善既有代码的设计(第2版)

978-7-115-50865-2
作者: [美]马丁•福勒(Martin Fowler)
译者: 熊节林从羽
编辑: 杨海玲

图书目录:

详情

本书清晰揭示了重构的过程,解释了重构的原理和最佳实践方式,并给出了何时以及何地应该开始挖掘代码以求改善。书中给出了60多个可行的重构,每个重构都介绍了一种经过验证的代码变换手法的动机和技术。本书提出的重构准则将帮助你一次一小步地修改你的代码,从而减少了开发过程中的风险。

图书摘要

版权信息

书名:重构:改善既有代码的设计(第2版)

ISBN:978-7-115-50865-2

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

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

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

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

著    [美] 马丁•福勒(Martin Fowler)

译    熊 节 林从羽

责任编辑 

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

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

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

读者服务热线:(010)81055410

反盗版热线:(010)81055315


本书是经典著作《重构》出版20年后的新版。书中清晰揭示了重构的过程,解释了重构的原理和最佳实践方式,并给出了何时以及何地应该开始挖掘代码以求改善。书中给出了60多个可行的重构,每个重构都介绍了一种经过验证的代码变换手法的动机和技术。本书提出的重构准则将帮助开发人员一次一小步地修改代码,从而减少了开发过程中的风险。

本书适合软件开发人员、项目管理人员等阅读,也可作为高等院校计算机及相关专业师生的参考读物。


Authorized Translation from the English language edition, entitled REFACTORING: IMPROVING THE DESIGN OF EXISTING CODE, 2nd Edition by FOWLER, MARTIN, published by Pearson Education, Inc, Copyright © 2019 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 POSTS & TELECOM PRESS, Copyright © 2019.

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

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

版权所有,侵权必究。


过去20年,《重构》一直是我案头常备的图书。每次重读,仍有感悟。对我而言,《重构》的意义不只在于指导代码重构,更在于让人从一开始就知道什么是好的代码,并且尽量写出没有“坏味道”的代码。Martin Fowler这次对本书进行的重构,体现了近年来编程领域的一些思潮变化。看来,既有设计,永远有改进空间。

——韩磊,《代码整洁之道》译者

重构早就成了软件开发从业者本能的一部分,每个IDE都内置了重构功能,每个程序员都定期重构自己的代码。技能上通常不再是问题,但是相对于当年第1版的读者,现在的程序员对于重构这个思想从何而来以及各种细节反而更陌生,这时候就更值得重新读一下这本书了。

——霍炬,PRESS.one CTO

有人说Martin Fowler改变了人类开发软件的模式,这一点也不过分,从《分析模式》《UML精粹》《领域特定语言》,到这本《重构》新版可以看得出来,他的每一本书都是软件开发人员必备的案头读物。此前他参与的“敏捷宣言”,更是引领了整个行业对敏捷开发的认识,一直到现在。Martin Fowler是我们QCon全球软件开发大会进入中国时的第一届讲师,也是在那次会议上,他让国内的技术社区领略了国际领先的开发模式,从此“敏捷”二字开始风行国内IT领域。

今年是QCon进入中国的第十个年头,我特别开心看到Martin Fowler又重写《重构》这本影响深远的书,他几乎完全替换了书中所引用的模式案例,并且基于现在用户的习惯,采用了JavaScript语言来做说明语言。数十年来他始终保持对技术的关注,对创新的热情,乐此不疲,这是Martin最令人敬佩的地方,也是非常值得我们每一个技术人学习的地方。

——霍泰稳,极客邦科技、InfoQ中国创始人兼CEO

当今软件开发的速度越来越快,带来的技术债也越来越多,我从CSDN自身的网站系统开发中充分认识到重构的重要性——如果我们的程序员能理解和掌握重构的原则和方法,我们的系统就不会有这么多沉重的债务。真正本质的东西是不变的,《重构》在出版20年后推出了第2版,再次证明:越本质的越长久,也越重要。衷心期待更多的新一代开发者能从这本书吸收营养,开发出好味道的系统。

——蒋涛,CSDN创始人、董事长

最早看到本书第1版的英文原版并决定引进国内,算起来已经是20年前的事了。虽然时间是最强大的重构工具,连书里的示例语言都从Java变成JavaScript了,但书中的理念和实践的价值并没有随时间流逝。这充分证明,即使在日新月异的IT技术世界里,不变的东西其实还是有的,这种书才是真正的经典,是技术人员应该优先研读并一读再读的。

——刘江,美团技术学院院长

“对于软件工程师来说,重构,并不是额外的工作,它就是编码本身。”直到我读过《重构》,并经过练习,才真正理解到这一点。真希望自己在20多年前写第一个软件时,就能读到这本书,从而能节省出大量调试或重复研究代码的时间。20年过去了,《重构》这本书也根据当前软件设计及相关工具的发展进行了一部分修订,更加贴近当前的软件开发者。希望更多的软件工程师能够应用这一技术节省出更多的时间。

——乔梁,腾讯高级管理顾问、《持续交付2.0》作者

重构是一项被低估了的技术能力。说起来,重构就是“不改变外在行为,而提高代码质量”这么简简单单的一句话,但其带来的影响却非常深远:它使我们在解决问题时可以放心地“先做对,再做好”——这种思路本身就可以极大地简化问题;它使我们消除无谓的意气之争——“所谓好,就是更少的坏味道”。我由衷地认为,切实地读懂了《重构》的程序员,在能力上都会获得一个数量级的提升。

——徐昊,ThoughtWorks中国区技术总监

当我还是编程菜鸟,想写出漂亮的代码而不得门道的时候,《重构》这本书就告诉了我,其实高手的代码也不是一次书就的,只要按这本书里的方法去做,谁都能把代码写得那么好;当我还是职场新人,没来得及写出太多垃圾代码的时候,这本书就教会了我,应该去追求编写人能够读懂的而不是仅机器能够读懂的代码。多年以后的某时某刻,当你编码自信而敏捷,因代码清晰而受人尊重时,你会庆幸读过这本书,你也会有些遗憾,应该再早一点去读这本书。无论过去了多少年,这本书,一直值得推荐。

——阎华,京东7FRESH架构师

在大获成功的《重构》第1版里,Martin Fowler传达的核心理念是:代码会随时间流逝而烂掉。写得再好的程序代码,若是发布了就一直保持原样,照样会风化、破碎乃至分崩离析。这是客观规律,避免这种命运的唯一出路是持续重构。要想成为高素质的软件工程师,必须认识这一点。

20年之后,Martin Fowler用现身说法证明,经典的《重构》也会变得不合时宜,也需要重构。如今,不但讲解语言从Java改成了JavaScript,原来的重构示例也做了很多调整,新增了15个示例,更重要的是,新版示例不再那么“面向对象”,应当会收获更广泛的读者群。

软件不死,重构不歇。

——余晟,《代码整洁之道:程序员的职业素养》译者

随着软件项目日积月累,系统维护成本变得越来越高昂是互联网团队共同面临的问题。用户在使用互联网系统的过程中,遇到的各类运行错误或者不可访问故障,以及开发团队面临的历史系统不可维护问题,很多时候是代码初次开发过程中各种细小的不规范引起的。持续优化已有代码是维护系统生命力最好的方法。《重构》是我推荐团队必读的技术图书之一。

——杨卫华(Tim Yang),微博研发副总经理

软件行业已经高速发展数十年,就好似一个崭新的城市,从一个个村屋矮房到高楼林立。而你的代码库就好比你手下的一个房间、一幢平房、一条街道、一片社区乃至是一座摩天大楼。作为一本经典的软件开发书,《重构》告诉我们的不仅仅是如何推倒重建、清理、装修,而是像一个规划师一样从目的、成本、手段、价值等综合维度来思考重构的意义。在开发业务的同时,《重构》常伴我左右,警醒我如何写出更有价值的软件。

——阴明,掘金社区创始人

重构,是一个优秀程序员的基本功,因为没人能保证其代码不随时间腐化,而重构会让代码重新焕发活力。整个软件行业对重构的认知始于Martin Fowler的《重构》,这本书让人们知道了“代码的坏味道”,见识到了“小步前行”的威力。时隔20年,Martin Fowler重新执笔改写《重构》,20年间的思维变迁就体现在这本书里,在第1版中,我们看到的是当时方兴未艾的面向对象,而第2版则透露出函数式编程的影响。如果说有什么程序员进阶秘笈,那就是不要错过Martin Fowler的任何一部著作,更何况是已经由时间证明过的重要著作《重构》的新版!

——郑晔,火币网首席架构师

如果看完本书,就兴冲冲地想要找一些代码来重构,那你可能就陷入某种“自嗨”之中了。

了解本书中列出的那些坏味道,不仅仅可以发现代码中的那些坏味道,更可以鞭策自己以及整个团队:在一开始的时候,就不写或者少些那种味道很坏的代码。还应该激励自己,深入地理解架构、理解业务、理解需求,减少因设计失误而导致徒劳无益地反复重构。

重构也是有成本的,所以应该思考如何降低重构的成本。我推荐每一个程序员都来学习“重构”这门手艺。因为学习《重构》,是为了减少“重构”!

——庄表伟,开源社理事、执行长,华为云DevCloud高级产品经理


2009年,在为《重构》第1版的中译本再版整理译稿时,我已经隐约察觉行业中对“重构”这个概念的矛盾张力。一方面,在这个“VUCA”(易变、不确定、复杂、模糊)横行的年代,有能力调整系统的内部结构,使其更具长期生命力,这是一个令人神往的期许。另一方面,重构的扎实功夫要学起来、做起来,颇不是件轻松的事,且不说详尽到近乎琐碎的重构手法,光是单元测试一事,怕是已有九成同行无法企及。结果,“重构”渐渐成了一块漂亮的招牌,大家都愿意挂上这个名号,可实际上干的却多是“刀劈斧砍”的勾当。

如今又是10年过去,只从国内的情况而论,“重构”概念的表里分离,大有愈演愈烈之势。随着当年的一线技术人员纷纷走上领导岗位,他们乐于将“重构”这块漂亮招牌用在更宽泛的环境下,例如系统架构乃至组织结构,都可以“重构”一下。然而基本功的欠缺,却也一路如影随形。当年在对象中的刀劈斧砍,如今被照搬到了架构、组织的调整。于是“重构”的痛苦回忆又一遍遍重演,甚而程度更深、影响更广、为害更烈。

此时转头看Martin Fowler时隔将近廿载后终于付梓的《重构》第2版,我不禁感叹于他对“微末功夫”的执着。在此书尚未成型之前,我和当时ThoughtWorks的同事曾有很多猜测,猜Fowler先生是否会在第2版中拔高层次,多谈谈设计乃至架构级别的重构手法,甚或跟随“敏捷组织”“精益企业”的风潮谈谈组织重构,也未为不可。孰料成书令我们跌破眼镜,Fowler先生不仅没有拔高,反而把工夫做得更扎实了。

对比前后两版的重构列表,可以发现:第2版收录的重构手法在用途上更加内聚,在操作上更加连贯,更重视重构手法之间的组合运用。第1版中占了整章篇幅的“大型重构”,在第2版中全数删去。一些较为复杂的重构手法,例如复制“被监视数据”、塑造模板函数等,第2版也不再收录。而第2版中新增的重构手法,则多是提炼变量、移动语句、拆分循环、拆分变量这样更加细致而微的操作。这些新增的手法看似简单,但直指大规模遗留代码中最常见的重构难点,正好补上了第1版中阙漏的细节。这一变化,正反映出Fowler先生对于重构一事一贯的态度:千里之行积于跬步,越是面对复杂多变的外部环境,越是要做好基本功、迈出扎实步。

识别坏味道、测试先行、行为保持的变更动作,是重构的基本功。在《重构》第2版里,重构手法的细节被再度打磨,重构过程比之第1版愈发流畅。细细品味重构手法中的前后步骤,琢磨作者是如何做到行为保持的,这是能启发读者举一反三的读书法。以保持对象完整重构手法为例,第1版中的做法是在原本函数上新添参数,而第2版的做法则是先新建一个空函数,在其中做完想要的调整之后,再整体替换原本函数。两相对比,无疑是新的做法更加可控、出错时测试失败的范围更小。

无独有偶,我在ThoughtWorks时的同事王健在开展大型的架构重构时,总结了重构的“十六字心法”,恰与保持对象完整重构手法在第2版中这个新的做法暗合。这十六字心法如是说:

旧的不变,

新的创建,

一步切换,

旧的再见。

从这个视角品味一个个重构巨细靡遗的做法,读者大概能感受到重构与“刀劈斧砍”之间最根本的分歧。在很多重构(例如最常用的改变函数声明)的做法中,Fowler先生会引入“很快就会再次修改甚至删除”的临时元素。假如只看起止状态,这些变更过程中的临时元素似乎是浪费:为何不直接一步到位改变到完善的结果状态呢?然而这些临时元素所代表的,是对变更过程(而非只是结果)的设计。缺乏对过程的精心设计与必要投入,只抱着对结果的美好憧憬提刀上阵,遇到困难就靠“奋斗精神”和加班解决,这种“刀劈斧砍”不止发生在缺乏审慎的“重构”现场,又何尝不是我们这个行业的缩影?

是以,重构这门技艺,以及Fowler先生撰写《重构》的态度,代表的是软件开发的匠艺——对“正确的做事方式”的重视。在一个浮躁之风日盛的行业中,很多人会强调“只看结果”,轻视做事的过程与方式。然而对于软件开发的专业人士而言,如果忽视了过程与方式,也就等于放弃了我们自己的立身之本。Fowler先生近廿载对这本书、对重构手法的精心打磨,给了我们一个榜样:一个对匠艺上心的专业人士,日积月累对过程与方式的重视,是能有所成就的。

17年前,我以菜鸟之身读到《重构》,深受其中蕴涵的工匠精神感召,在Fowler先生与侯捷老师的帮助下,完成了本书第1版的翻译工作。如今再译本书第2版,来自ThoughtWorks的青年才俊林从羽君主动请缨与我搭档合译,我亦将此视为匠艺传承的一桩美事。新一代程序员中,关注新工具、新框架、新商业模式者伙矣,关注面向对象、TDD、重构之类基本功者寥寥。林君年纪虽轻,却能平心静气磨砺技艺,对基本功学而时习,颇有老派工匠之风。当年藉由翻译《重构》一书,我从Fowler先生、侯捷老师身上学到他们的工匠精神,十余年来时时践行自勉。如今新一代软件工匠的代表人物林君接手此书,必会令工匠精神传承光大。

据说古时高僧有偈云:“时时勤拂拭,勿使惹尘埃。”代码当如是,专业人士的技艺亦当如是。与《重构》的诸位读者共勉。

熊节

2019年1月26日于成都


熊节 在IT行业已经打拼了18年,在金融、零售、政府、电信、制造业等行业的信息化建设方面有着丰富经验,是中国IT业敏捷浪潮的领军人物。熊节拥有利物浦大学MBA学位。

林从羽 ThoughtWorks软件开发工程师,曾服务于国内外多家大型企业,致力于帮助团队更快更好地交付可工作的软件。拥抱敏捷精神,TDD爱好者,纯键盘工作者。


“重构”这个概念来自Smalltalk圈子,没多久就进入了其他编程语言阵营之中。因为重构是框架开发中不可缺少的一部分,所以当框架设计者讨论自己的工作时,这个术语就诞生了。当他们精炼自己的类继承体系时,当他们叫喊自己可以拿掉多少多少行代码时,重构的概念慢慢浮出水面。框架设计者知道,这东西不可能一开始就完全正确,它将随着设计者的经验成长而进化;他们也知道,代码被阅读和被修改的次数远远多于它被编写的次数。保持代码易读、易修改的关键,就是重构——对框架而言如此,对一般软件也如此。

好极了,还有什么问题吗?问题很显然:重构有风险。它必须修改正在工作的程序,这可能引入一些不易察觉的错误。如果重构方式不恰当,可能毁掉你数天甚至数周的成果。如果重构时不做好准备,不遵守规则,风险就更大。你挖掘自己的代码,很快发现了一些值得修改的地方,于是你挖得更深。挖得越深,找到的重构机会就越多,于是你的修改也越多……最后你给自己挖了个大坑,却爬不出去了。为了避免自掘坟墓,重构必须系统化进行。我和三位合作者在写《设计模式》一书时曾经提过:设计模式为重构提供了目标。然而“确定目标”只是问题的一部分而已,改造程序以达到目标,是另一个难题。

Martin Fowler和本书另几位作者清楚地揭示了重构过程,他们为面向对象软件开发所做的贡献难以估量。本书解释了重构的原理和最佳实践,并指出何时何地你应该开始挖掘你的代码以求改善。本书的核心是一系列完整的重构方法,其中每一项都介绍一种经过实践检验的代码变换手法的动机和技术。某些项目(如“提炼函数”和“搬移字段”)看起来可能很浅显,但不要掉以轻心,因为理解这类技术正是有条不紊地进行重构的关键。本书所提的这些重构手法将帮助你一次一小步地修改你的代码,这就降低了设计演进过程中的风险。很快你就会把这些重构手法及其名称加入自己的开发词典中,并且朗朗上口。

我第一次体验有条不紊的、一次一小步的重构,是某次与Kent Beck在三万英尺高空的飞行旅途中结对编程。我们运用本书中收录的重构手法,保证每次只走一步。最后,我对这种实践方式的效果感到十分惊讶。我不但对产生的代码更有信心,而且开发压力也小了很多。因此,我极力推荐你试试这些重构手法,你和你的程序都将因此更美好。

——Erich Gamma

Object Technology International, Inc.

1999年1月


从前,有位咨询顾问造访客户调研其开发项目。该系统的核心是一个类继承体系,顾问看了开发人员所写的一些代码。他发现整个体系相当凌乱,上层超类对系统的工作方式做了一些假设,下层子类实现这些假设。但是这些假设并不适合所有子类,导致覆写(override)工作非常繁重。只要在超类做点修改,就可以减少许多覆写工作。在另一些地方,超类的某些意图并未被良好理解,因此其中某些行为在子类内重复出现。还有一些地方,好几个子类做相同的事情,其实可以把它们搬到继承体系的上层去做。

这位顾问于是建议项目经理看看这些代码,把它们整理一下,但是项目经理并不热衷于此,毕竟程序看上去还可以运行,而且项目面临很大的进度压力。于是项目经理说,晚些时候再抽时间做这些整理工作。

顾问也把他的想法告诉了在这个继承体系上工作的程序员,告诉他们可能发生的事情。程序员都很敏锐,马上就看出问题的严重性。他们知道这并不全是他们的错,有时候的确需要借助外力才能发现问题。程序员立刻用了一两天的时间整理好这个继承体系,并删掉了其中一半代码,功能毫发无损。他们对此十分满意,而且发现在继承体系中加入新的类或使用系统中的其他类都更快、更容易了。

项目经理并不高兴。进度排得很紧,有许多工作要做。系统必须在几个月之后发布,而这些程序员却白白耗费了两天时间,做的工作与未来几个月要交付的大量功能毫不相干。原先的代码运行起来还算正常。的确,新的设计更加“纯粹”、更加“整洁”。但项目要交付给客户的,是可以有效运行的代码,不是用以取悦学究的代码。顾问接下来又建议应该在系统的其他核心部分进行这样的整理工作,这会使整个项目停顿一至两个星期。所有这些工作只是为了让代码看起来更漂亮,并不能给系统添加任何新功能。

你对这个故事有什么感想?你认为这个顾问的建议(更进一步整理程序)是对的吗?你会遵循那句古老的工程谚语吗:“如果它还可以运行,就不要动它。”

我必须承认自己有某些偏见,因为我就是那个顾问。6个月之后这个项目宣告失败,很大的原因是代码太复杂,无法调试,也无法将性能调优到可接受的水平。

后来,这个项目重新启动,几乎从头开始编写整个系统,Kent Beck受邀做了顾问。他做了几件迥异以往的事,其中最重要的一件就是坚持以持续不断的重构行为来整理代码。这个团队效能的提升,以及重构在其中扮演的角色,启发了我撰写本书的第1版,如此一来我就能够把Kent和其他一些人已经学会的“以重构方式改进软件质量”的知识,传播给所有读者。

自本书第1版问世至今,读者的反馈甚佳,重构的理念已经被广泛接纳,成为编程的词汇表中不可或缺的部分。然而,对于一本与编程相关的书而言,18年已经太漫长,因此我感到,是时候回头重新修订这本书了。我几乎重写了全书的每一页,但从其内涵而言,整本书又几乎没有改变。重构的精髓仍然一如既往,大部分关键的重构手法也大体不变。我希望这次修订能帮助更多的读者学会如何有效地进行重构。

所谓重构(refactoring)是这样一个过程:在不改变代码外在行为的前提下,对代码做出修改,以改进程序的内部结构。重构是一种经千锤百炼形成的有条不紊的程序整理方法,可以最大限度地减小整理过程中引入错误的概率。本质上说,重构就是在代码写好之后改进它的设计。

“在代码写好之后改进它的设计”这种说法有点儿奇怪。在软件开发的大部分历史时期,大部分人相信应该先设计而后编码:首先得有一个良好的设计,然后才能开始编码。但是,随着时间流逝,人们不断修改代码,于是根据原先设计所得的系统,整体结构逐渐衰弱。代码质量慢慢沉沦,编码工作从严谨的工程堕落为胡砍乱劈的随性行为。

“重构”正好与此相反。哪怕手上有一个糟糕的设计,甚至是一堆混乱的代码,我们也可以借由重构将它加工成设计良好的代码。重构的每个步骤都很简单,甚至显得有些过于简单:只需要把某个字段从一个类移到另一个类,把某些代码从一个函数拉出来构成另一个函数,或是在继承体系中把某些代码推上推下就行了。但是,聚沙成塔,这些小小的修改累积起来就可以根本改善设计质量。这和一般常见的“软件会慢慢腐烂”的观点恰恰相反。

有了重构以后,工作的平衡点开始发生变化。我发现设计不是在一开始完成的,而是在整个开发过程中逐渐浮现出来。在系统构筑过程中,我学会了如何不断改进设计。这个“构筑-设计”的反复互动,可以让一个程序在开发过程中持续保有良好的设计。

本书是一本为专业程序员编写的重构指南。我的目的是告诉你如何以一种可控且高效的方式进行重构。你将学会如何有条不紊地改进程序结构,而且不会引入错误,这就是正确的重构方式。

按照传统,图书应该以概念介绍开头。尽管我也同意这个原则,但是我发现以概括性的讨论或定义来介绍重构,实在不是一件容易的事。因此,我决定用一个实例作为开路先锋。第1章展示了一个小程序,其中有些常见的设计缺陷,我把它重构得更容易理解和修改。其间你可以看到重构的过程,以及几个很有用的重构手法。如果你想知道重构到底是怎么回事,这一章不可不读。

第2章讨论重构的一般性原则、定义,以及进行重构的原因,我也大致介绍了重构面临的一些挑战。第3章由Kent Beck介绍如何嗅出代码中的“坏味道”,以及如何运用重构清除这些“坏味道”。测试在重构中扮演着非常重要的角色,第4章介绍如何在代码中构筑测试。

从第5章往后的篇幅就是本书的核心部分——重构名录。尽管不能说是一份巨细靡遗的列表,却足以覆盖大多数开发者可能用到的关键重构手法。这份重构名录的源头是20世纪90年代后期我开始学习重构时的笔记,直到今天我仍然不时查阅这些笔记,作为对我不甚可靠的记忆力的补充。每当我想做点什么——例如拆分阶段(154)——的时候,这份列表就会提醒我如何一步一步安全前进。我希望这是值得你日后一再回顾的部分。

万维网对我们的社会影响深远,尤其是改变了我们获取信息的方式。在撰写本书第1版时,关于软件开发的知识大多通过出版物传播。而时至今日,我的大部分信息都来自网上。这个趋势给像我这样的写作者带来了一个挑战:今日世界还有图书的一席之地吗?今天的图书应该是什么形态?

我相信像这样一本书仍然有其价值,但也需要作出改变。图书的价值在于把大量信息以内聚的方式整合起来。在撰写本书的过程中,我尝试用连贯一致的方式来组织和涵盖大量各有特色的重构手法。

但这个聚合的整体是一个抽象的文学作品,尽管传统上只能以纸质图书的形式呈现,未来却未必非得如此。出版行业仍然将纸质图书视为首要的呈现形式,虽然我们已经满怀热情地接纳了电子书,但是电子图书毕竟也只是在原来纸质图书结构的基础上做了电子化的呈现。

我想通过这本书探索一条不同的路径。本书的权威版本是它的网站(或者叫“Web版”)。如果你购买了纸质版或者电子版,就会同时获得访问Web版的权限。(关于如何在InformIT网站上注册你的商品,请留意下文的提示。)纸质版图书是网站内容的精选,并整理成适合印刷的形式。纸质版并不尝试包含网站上的所有重构手法,尤其是考虑到未来我很有可能在Web版中增加更多重构手法。与此相似,电子书又是Web版的另一个呈现,其中包含的重构手法列表可能与纸质版不同,毕竟电子书在售出之后也可以相对容易地更新和添加内容。

在写下这些文字时,我无从知晓你正在阅读的是在线Web版、手机上的电子书、纸质版图书还是别的什么超乎我想象的形式。我尽力写一本有用的书,不论你用什么形式来汲取其中的知识。

如果你想查看本书Web版(只有英文版),并及时获得内容更新和勘误,请到InformIT网站注册这本书。你需要首先打开informit.com/register页面,登录你的InformIT账户(如果没有InformIT账户的话,需要先注册一个),然后(进入“Registered Products”标签)输入本书英文原版的ISBN“9780134757599”,单击“Submit”按钮。然后网站会向你提出一个与本书内容有关的问题,所以请确保纸质书或电子书就放在手边。成功注册以后,进入“Account”页面,打开“Digital Purchases”标签,单击本书标题下面的“Launch”按钮,就能看到本书的Web版。

For access to the web edition (English only) and updates or corrections as they become available, register your copy on the InformIT web site. To start the registration process, go to informit.com/register and log in (or create an account if you don’t have one). Enter 9780134757599 in the box labeled ISBN and click Submit. You will be asked a challenge question, so be sure to have your copy of the book available. After you’ve successfully registered your copy, open the “Digital Purchases” tab on your Account page and click on the link under this title to “Launch” the web edition.

与软件开发中的大多数技术性领域一样,代码范例对于概念的阐释至关重要。不过,即使在不同的编程语言中,重构手法看上去也是大同小异的。虽然会有一些值得留心的语言特性,但重构手法的核心要素都是一样的。

我选择了用JavaScript来展现本书中的重构手法,因为我感到大多数读者都能看懂这种语言。不过,即便你眼下正在使用的是别的编程语言,采用这些重构手法也应该不困难。我尽量不使用JavaScript任何复杂的特性,这样即便你对这门编程语言只有粗浅的了解,应该也能跟上重构的过程。另外,使用JavaScript展示重构手法,并不代表我推荐这门编程语言。

使用JavaScript展示代码范例,也不意味着本书介绍的技巧只适用于JavaScript。本书的第1版采用了Java,但很多从未写过任何Java代码的程序员也同样认为这些技巧很有用。我曾经尝试过用十多种不同的编程语言来呈现这些范例,以此展示重构手法的通用性,不过这对普通读者而言只会带来困惑。本书是为所有编程语言背景的程序员所作,除了阅读“范例”小节时需要一些基本的JavaScript知识,本书的其余部分都不特定于任何具体的编程语言。我希望读者能汲取本书的内容,并将其应用于自己日常使用的编程语言。具体而言,我希望读者能先理解本书中的JavaScript范例代码,然后再将其适配到自己习惯的编程语言。

因此,除了在特殊情况下,当我谈到“类”“模块”“函数”等词汇时,我都按照它们在程序设计领域的一般含义来使用这些词,而不是以其在JavaScript语言模型中的特殊含义来使用。

我只把JavaScript用作一种示例语言,因此我也会尽量避免使用其他程序员可能不太熟悉的编程风格。这不是一本“用JavaScript进行重构”的书,而是一本关于重构的通用书籍,只是采用了JavaScript作为示例。有很多JavaScript特有的重构手法很有意思(如将回调重构成promise或async/await),但这些不是本书要讨论的内容。

本书的目标读者是专业程序员,也就是那些以编写软件为生的人。书中的范例和讨论,涉及大量需要详细阅读和理解的代码。这些例子都用JavaScript写成,不过这些重构手法应该适用于大部分编程语言。为了理解书中的内容,读者需要有一定的编程经验,但需要的知识并不多。

本书的首要目标读者群是想要学习重构的软件开发者,同时对于已经理解重构的人也有价值——本书可以作为一本教学辅助书。在本书中,我用了大量篇幅详细解释各个重构手法的过程和原理,因此有经验的开发人员可以用本书来指导同事。

尽管本书的关注对象是代码,但重构对于系统设计也有巨大影响。资深设计师和架构师也很有必要了解重构原理,并在自己的项目中运用重构技术。最好是由有威望的、经验丰富的开发人员来引入重构技术,因为这样的人最能够透彻理解重构背后的原理,并根据情况加以调整,使之适用于特定工作领域。如果你使用的不是JavaScript而是其他编程语言,这一点尤其重要,因为你必须把我给出的范例用其他编程语言改写。

下面我要告诉你,如何能够在不通读全书的情况下充分用好它。

给形形色色的重构手法命名是编写本书的重要部分。合适的词汇能帮助我们彼此沟通。当一名开发者向另一名开发者提出建议,将一段代码提取成为一个函数,或者将计算逻辑拆分成几个阶段,双方都能理解提炼函数(106)拆分阶段(154)是什么意思。这份词汇表也能帮助开发者选择自动化的重构手法。

1这一节中关于各个版本的表述仅适用于本书的英文原版,中文版的相关版本可能会与此略有不同。——编者注

就在本书一开始的此时此刻,我必须说:这本书让我欠了一大笔人情债,欠那些在20世纪90年代做了大量研究工作并开创重构领域的人一大笔债。学习他们的经验启发了我撰写本书第1版,尽管已经过去了很多年,我仍然必须感谢他们打下的基础。这本书原本应该由他们之中的某个人来写,但最后却让我这个有时间、有精力的人捡了便宜。

重构技术的两位最早倡导者是Ward Cunningham和Kent Beck。他们很早就把重构作为软件开发过程的一块基石,并且在自己的开发过程中运用它。尤其需要说明的是,正因为和Kent合作,我才真正看到了重构的重要性,并直接受到激励写了这本书。

Ralph Johnson在UIUC(伊利诺伊大学厄巴纳-香槟分校)领导了一个小组,这个小组因其在对象技术方面的实用贡献而声名远扬。Ralph很早就是重构的拥护者,他的一些学生也在重构领域的发展前期做出重要研究。Bill Opdyke的博士论文是重构研究的第一份详细的书面成果。John Brant和Don Roberts则早已不满足于写文章了,他们创造了第一个自动化的重构工具,这个叫作Refactoring Browser(重构浏览器)的工具可以用于重构Smalltalk程序。

自本书第1版问世以来,很多人推动了重构领域的发展。尤其是,开发工具中的自动化重构功能,让程序员的生活轻松了许多。如今我只要简单地敲几下键盘就可以给一个被大量使用的函数改名,对此我已经习以为常,但在这快捷的操作背后,离不开IDE开发团队的辛勤劳动。

尽管有这些研究成果可以借鉴,我还是需要很多协助才能写成本书。本书的第1版极大地得益于Kent Beck的经验与鼓励。起初向我介绍重构的是他,鼓励我开始书面记录重构手法的是他,帮助我把重构手法组织成型的也是他,提出“代码味道”这个概念的还是他。我常常感觉,他本可以把本书的第1版写得更好——如果当时他不是在忙着撰写极限编程的奠基之作《解析极限编程》的话。

我认识的所有技术图书作者都会提到,技术审稿人提供了巨大的帮助。我们的作品都会有巨大的缺陷,只有同行审稿人能发现这些缺陷。我自己并不常做技术审稿,部分原因是我认为自己并不擅长,所以我对优秀的技术审稿人总是满怀敬意。帮别人审稿所得的报酬微不足道,所以这完全是一项慷慨之举。

正式开始写这本书时,我建了一个邮件列表,其中都是能给我提供反馈的建议者。随着写作的进展,我不断把新的草稿发到这个小组里,请他们给我反馈。我要感谢这些人在邮件列表中提供的反馈:Arlo Belshee、Avdi Grimm、Beth Anders-Beck、Bill Wake、Brian Guthrie、Brian Marick、Chad Wathington、Dave Farley、David Rice、Don Roberts、Fred George、Giles Alexander、Greg Doench、Hugo Corbucci、Ivan Moore、James Shore、Jay Fields、Jessica Kerr、Joshua Kerievsky、Kevlin Henney、Luciano Ramalho、Marcos Brizeno、Michael Feathers、Patrick Kua、Pete Hodgson、Rebecca Parsons和Trisha Gee。

在这群人中,我要特别感谢Beth Anders-Beck、James Shore和Pete Hodgson在JavaScript方面给我的帮助。

有了一个比较完整的初稿之后,我将它发送出去,寻求更多的审阅意见,因为我希望有一些全新的眼光来纵览全书。William Chargin和Michael Hunger提供了极其详尽的审阅意见。我还从Bob Martin和Scott Davis那里得到了很多有用的意见。Bill Wake也对本书初稿做了完整的审阅,并在邮件列表中给出了他的意见。

我在ThoughtWorks的同事一直给我的写作提供想法和反馈。数不胜数的问题、评论和观点推动了本书的思考与写作。作为ThoughtWorks员工最好的一件事,就是这家公司允许我花大量时间来写作。我尤其要感谢Rebecca Parsons(我们的CTO)经常与我交流,给了我很多想法。

在培生出版集团,Greg Doench是负责本书的策划编辑,他解决了无数的问题,最终使本书得以出版;Julie Nahil是责任编辑;Dmitry Kirsanov负责文字编辑工作;Alina Kirsanova负责排版和制作索引。我也很高兴与他们合作。


本书由异步社区出品,社区(https://www.epubit.com/)为您提供相关资源和后续服务。

作者和编辑尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎您将发现的问题反馈给我们,帮助我们提升图书的质量。

当您发现错误时,请登录异步社区,按书名搜索,进入本书页面,点击“提交勘误”,输入勘误信息,点击“提交”按钮即可。本书的作者和编辑会对您提交的勘误进行审核,确认并接受后,您将获赠异步社区的100积分。积分可用于在异步社区兑换优惠券、样书或奖品。

我们的联系邮箱是contact@epubit.com.cn。

如果您对本书有任何疑问或建议,请您发邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。

如果您有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发邮件给我们;有意出版图书的作者也可以到异步社区在线提交投稿(直接访问www.epubit.com/selfpublish/submission即可)。

如果您是学校、培训机构或企业,想批量购买本书或异步社区出版的其他图书,也可以发邮件给我们。

如果您在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请您将怀疑有侵权行为的链接发邮件给我们。您的这一举动是对作者权益的保护,也是我们持续为您提供有价值的内容的动力之源。

“异步社区”是人民邮电出版社旗下IT专业图书社区,致力于出版精品IT技术图书和相关学习产品,为作译者提供优质出版服务。异步社区创办于2015年8月,提供大量精品IT技术图书和电子书,以及高品质技术文章和视频课程。更多详情请访问异步社区官网https://www.epubit.com。

“异步图书”是由异步社区编辑团队策划出版的精品IT专业图书的品牌,依托于人民邮电出版社近30年的计算机图书出版积累和专业编辑团队,相关图书在封面上印有异步图书的LOGO。异步图书的出版领域包括软件开发、大数据、AI、测试、前端、网络技术等。

异步社区

微信服务号


我该从何说起呢?按照传统做法,一开始介绍某样东西时应该先大致讲讲它的历史、主要原理等。可是每当有人在会场上介绍这些东西,总是诱发我的瞌睡虫。我的思绪开始游荡,我的眼神开始迷离,直到主讲人秀出示例,我才能够提起精神。

示例之所以可以拯救我于太虚之中,因为它让我看见事情在真正进行。谈原理,很容易流于泛泛,又很难说明如何实际应用。给出一个示例,就可以帮助我把事情认识清楚。

因此,我决定从一个示例说起。在此过程中我会谈到很多重构的工作方式,并且让你对重构过程有一点点感觉。然后在下一章中我才能向你展开通常的原理介绍。

但是,面对这个介绍性示例,我遇到了一个大问题。如果我选择一个大型程序,那么对程序自身的描述和对整个重构过程的描述就太复杂了,任何读者都不忍卒读(我试了一下,哪怕稍微复杂一点的例子都会超过100页)。如果我选择一个容易理解的小程序,又恐怕看不出重构的价值。

和任何立志要介绍“应用于真实世界的程序中的有用技术”的人一样,我陷入了一个十分典型的两难困境。我只能带你看看如何在一个我选择的小程序中进行重构,然而坦白说,那个程序的规模根本不值得我们这么做。但是,如果我给你看的代码是大系统的一部分,重构技术很快就变得重要起来。所以请你一边观赏这个小例子,一边想象它身处于一个大得多的系统。

在本书第1版中,我使用的示例程序是为影片出租店的顾客打印一张详单。放到今天,很多人可能要问了:“影片出租店是什么?”为了避免过多回答这个问题,我翻新了一下示例,将其包装成一个仍有古典韵味又尚未消亡的现代示例。

设想有一个戏剧演出团,演员们经常要去各种场合表演戏剧。通常客户(customer)会指定几出剧目,而剧团则根据观众(audience)人数及剧目类型来向客户收费。该团目前出演两种戏剧:悲剧(tragedy)和喜剧(comedy)。给客户发出账单时,剧团还会根据到场观众的数量给出“观众量积分”(volume credit)优惠,下次客户再请剧团表演时可以使用积分获得折扣——你可以把它看作一种提升客户忠诚度的方式。

该剧团将剧目的数据存储在一个简单的JSON文件中。

plays.json...
{
  "hamlet": {"name": "Hamlet", "type": "tragedy"},
  "as-like": {"name": "As You Like It", "type": "comedy"},
  "othello": {"name": "Othello", "type": "tragedy"}
}

他们开出的账单也存储在一个JSON文件里。

invoices.json...
[
  {
    "customer": "BigCo",
    "performances": [
      {
        "playID": "hamlet",
        "audience": 55
      },
      {
        "playID": "as-like",
        "audience": 35
      },
      {
        "playID": "othello",
        "audience": 40
      }
    ]
  }
]

下面这个简单的函数用于打印账单详情。

function statement (invoice, plays) {
  let totalAmount = 0;
  let volumeCredits = 0;
  let result = `Statement for ${invoice.customer}\n`;
  const format = new Intl.NumberFormat("en-US",
                        { style: "currency", currency: "USD",
                          minimumFractionDigits: 2 }).format;
  for (let perf of invoice.performances) { 
    const play = plays[perf.playID];
    let thisAmount = 0;

    switch (play.type) {
    case "tragedy":
      thisAmount = 40000;
      if (perf.audience > 30) {
        thisAmount += 1000 * (perf.audience - 30);
      }
      break;
    case "comedy":
      thisAmount = 30000;
      if (perf.audience > 20) {
        thisAmount += 10000 + 500 * (perf.audience - 20);
      }
      thisAmount += 300 * perf.audience;
      break;
    default:
        throw new Error(`unknown type: ${play.type}`);
    }

    // add volume credits
    volumeCredits += Math.max(perf.audience - 30, 0);
    // add extra credit for every ten comedy attendees
    if ("comedy" === play.type) volumeCredits += Math.floor(perf.audience / 5);

    // print line for this order
    result += ` ${play.name}: ${format(thisAmount/100)} (${perf.audience} seats)\n`;
    totalAmount += thisAmount;
  }
  result += `Amount owed is ${format(totalAmount/100)}\n`;
  result += `You earned ${volumeCredits} credits\n`;
  return result;
}

用上面的数据文件(invoices.jsonplays.json)作为测试输入,运行这段代码,会得到如下输出:

Statement for BigCo
  Hamlet: $650.00 (55 seats)
  As You Like It: $580.00 (35 seats)
  Othello: $500.00 (40 seats)
Amount owed is $1,730.00
You earned 47 credits

你觉得这个程序设计得怎么样?我的第一感觉是,代码组织不甚清晰,但这还在可忍受的限度内。这样小的程序,不做任何深入的设计,也不会太难理解。但我前面讲过,这是因为要保证例子足够小的缘故。如果这段代码身处于一个更大规模——也许是几百行——的程序中,把所有代码放到一个函数里就很难理解了。

尽管如此,这个程序还是能正常工作。那么是不是说,对其结构“不甚清晰”的评价只是美学意义上的判断,只是对所谓丑陋代码的反感呢?毕竟编译器也不会在乎代码好不好看。但是,当我们需要修改系统时,就涉及了人,而人在乎这些。差劲的系统是很难修改的,因为很难找到修改点,难以了解做出的修改与现有代码如何协作实现我想要的行为。如果很难找到修改点,我就很有可能犯错,从而引入bug。

因此,如果我需要修改一个有几百行代码的程序,我会期望它有良好的结构,并且已经被分解成一系列函数和其他程序要素,这能帮我更易于清楚地了解这段代码在做什么。如果程序杂乱无章,先为它整理出结构来,再做需要的修改,通常来说更加简单。

 如果你要给程序添加一个特性,但发现代码因缺乏良好的结构而不易于进行更改,那就先重构那个程序,使其比较容易添加该特性,然后再添加该特性。

在这个例子里,我们的用户希望对系统做几个修改。首先,他们希望以HTML格式输出详单。现在请你想一想,这个变化会带来什么影响。对于每处追加字符串到result变量的地方我都得为它们添加分支逻辑。这会为函数引入更多复杂度。遇到这种需求时,很多人会选择直接复制整个方法,在其中修改输出HTML的部分。复制一遍代码似乎不算太难,但却给未来留下各种隐患:一旦计费逻辑发生变化,我就得同时修改两个地方,以保证它们逻辑相同。如果你编写的是一个永不需要修改的程序,这样剪剪贴贴就还好。但如果程序要保存很长时间,那么重复的逻辑就会造成潜在的威胁。

现在,第二个变化来了:演员们尝试在表演类型上做更多突破,无论是历史剧、田园剧、田园喜剧、田园史剧、历史悲剧还是历史田园悲喜剧,无论一成不变的正统戏,还是千变万幻的新派戏,他们都希望有所尝试,只是还没有决定试哪种以及何时试演。这对戏剧场次的计费方式、积分的计算方式都有影响。作为一个经验丰富的开发者,我可以肯定:不论最终提出什么方案,他们一定会在6个月之内再次修改它。毕竟,需求通常不来则已,一来便会接踵而至。

为了应对分类规则和计费规则的变化,程序必须对statement函数做出修改。但如果我把statement内的代码复制到用以打印HTML详单的函数中,就必须确保将来的任何修改在这两个地方保持一致。随着各种规则变得越来越复杂,适当的修改点将越来越难找,不犯错的机会也越来越少。

我再强调一次,是需求的变化使重构变得必要。如果一段代码能正常工作,并且不会再被修改,那么完全可以不去重构它。能改进之当然很好,但若没人需要去理解它,它就不会真正妨碍什么。如果确实有人需要理解它的工作原理,并且觉得理解起来很费劲,那你就需要改进一下代码了。

每当我要进行重构的时候,第一个步骤永远相同:我得确保即将修改的代码拥有一组可靠的测试。这些测试必不可少,因为尽管遵循重构手法可以使我避免绝大多数引入bug的情形,但我毕竟是人,毕竟有可能犯错。程序越大,我的修改不小心破坏其他代码的可能性就越大——在数字时代,软件的名字就是脆弱。

statement函数的返回值是一个字符串,我做的就是创建几张新的账单(invoice),假设每张账单收取了几出戏剧的费用,然后使用这几张账单作为输入调用statement函数,生成对应的对账单(statement)字符串。我会拿生成的字符串与我已经手工检查过的字符串做比对。我会借助一个测试框架来配置好这些测试,只要在开发环境中输入一行命令就可以把它们运行起来。运行这些测试只需几秒钟,所以你会看到我经常运行它们。

测试过程中很重要的一部分,就是测试程序对于结果的报告方式。它们要么变绿,表示所有新字符串都和参考字符串一样,要么就变红,然后列出失败清单,显示问题字符串的出现行号。这些测试都能够自我检验。使测试能自我检验至关重要,否则就得耗费大把时间来回比对,这会降低开发速度。现代的测试框架都提供了丰富的设施,支持编写和运行能够自我检验的测试。

 重构前,先检查自己是否有一套可靠的测试集。这些测试必须有自我检验能力。

进行重构时,我需要依赖测试。我将测试视为bug检测器,它们能保护我不被自己犯的错误所困扰。把我想要达成的目标写两遍——代码里写一遍,测试里再写一遍——我就得犯两遍同样的错误才能骗过检测器。这降低了我犯错的概率,因为我对工作进行了二次确认。尽管编写测试需要花费时间,但却为我节省下可观的调试时间。构筑测试体系对重构来说实在太重要了,因此我将用第4章一整章的笔墨来详细讨论它。

每当看到这样长长的函数,我便下意识地想从整个函数中分离出不同的关注点。第一个引起我注意的就是中间那段switch语句。

function statement (invoice, plays) {
  let totalAmount = 0;
  let volumeCredits = 0;
  let result = `Statement for ${invoice.customer}\n`;
  const format = new Intl.NumberFormat("en-US",
                        { style: "currency", currency: "USD",
                          minimumFractionDigits: 2 }).format;
  for (let perf of invoice.performances) {
    const play = plays[perf.playID];
    let thisAmount = 0;

    switch (play.type) {
    case "tragedy":
      thisAmount = 40000;
      if (perf.audience > 30) {
        thisAmount += 1000 * (perf.audience - 30);
      }
      break;
    case "comedy":
      thisAmount = 30000;
      if (perf.audience > 20) {
        thisAmount += 10000 + 500 * (perf.audience - 20);
      }
      thisAmount += 300 * perf.audience;
      break;
    default:
        throw new Error(`unknown type: ${play.type}`);
    }

    // add volume credits
    volumeCredits += Math.max(perf.audience - 30, 0);
    // add extra credit for every ten comedy attendees
    if ("comedy" === play.type) volumeCredits += Math.floor(perf.audience / 5);

    // print line for this order
    result += ` ${play.name}: ${format(thisAmount/100)} (${perf.audience} seats)\n`;
    totalAmount += thisAmount;
  }
  result += `Amount owed is ${format(totalAmount/100)}\n`;
  result += `You earned ${volumeCredits} credits\n`; 
  return result;
}

看着这块代码,我就知道它在计算一场戏剧演出的费用。这是我的直觉。不过正如Ward Cunningham所说,这种理解只是我脑海中转瞬即逝的灵光。我需要梳理这些灵感,将它们从脑海中搬回到代码里去,以免忘记。这样当我回头看时,代码就能告诉我它在干什么,我不需要重新思考一遍。

要将我的理解转化到代码里,得先将这块代码抽取成一个独立的函数,按它所干的事情给它命名,比如叫amountFor(performance)。每次想将一块代码抽取成一个函数时,我都会遵循一个标准流程,最大程度减少犯错的可能。我把这个流程记录了下来,并将它命名为提炼函数(106),以便日后可以方便地引用。

首先,我需要检查一下,如果我将这块代码提炼到自己的一个函数里,有哪些变量会离开原本的作用域。在此示例中,是perfplaythisAmount这3个变量。前两个变量会被提炼后的函数使用,但不会被修改,那么我就可以将它们以参数方式传递进来。我更关心那些会被修改的变量。这里只有唯一一个——thisAmount,因此可以将它从函数中直接返回。我还可以将其初始化放到提炼后的函数里。修改后的代码如下所示。

function statement...
function amountFor(perf, play) {
  let thisAmount = 0;
  switch (play.type) {
  case "tragedy":
    thisAmount = 40000;
    if (perf.audience > 30) {
      thisAmount += 1000 * (perf.audience - 30);
    }
    break;
  case "comedy":
    thisAmount = 30000;
    if (perf.audience > 20) {
      thisAmount += 10000 + 500 * (perf.audience - 20);
    }
    thisAmount += 300 * perf.audience;
    break;
  default:
      throw new Error(`unknown type: ${play.type}`);
  }
  return thisAmount;
}

当我在代码块上方使用了斜体(中文对应为楷体)标记的题头“ function xxx ”时,表明该代码块位于题头所在函数、文件或类的作用域内。通常该作用域内还有其他的代码,但由于不是讨论重点,因此把它们隐去不展示。

现在原statement函数可以直接调用这个新函数来初始化thisAmount

顶层作用域...
function statement (invoice, plays) {
  let totalAmount = 0;
  let volumeCredits = 0;
  let result = `Statement for ${invoice.customer}\n`;
  const format = new Intl.NumberFormat("en-US",
                        { style: "currency", currency: "USD",
                          minimumFractionDigits: 2 }).format;
  for (let perf of invoice.performances) {
    const play = plays[perf.playID];
    let thisAmount = amountFor(perf, play);

    // add volume credits
    volumeCredits += Math.max(perf.audience - 30, 0);
    // add extra credit for every ten comedy attendees
    if ("comedy" === play.type) volumeCredits += Math.floor(perf.audience / 5);

    // print line for this order
    result += ` ${play.name}: ${format(thisAmount/100)} (${perf.audience} seats)\n`;
    totalAmount += thisAmount;
  }
  result += `Amount owed is ${format(totalAmount/100)}\n`;
  result += `You earned ${volumeCredits} credits\n`;
  return result;

做完这个改动后,我会马上编译并执行一遍测试,看看有无破坏了其他东西。无论每次重构多么简单,养成重构后即运行测试的习惯非常重要。犯错误是很容易的——至少我知道我是很容易犯错的。做完一次修改就运行测试,这样在我真的犯了错时,只需要考虑一个很小的改动范围,这使得查错与修复问题易如反掌。这就是重构过程的精髓所在:小步修改,每次修改后就运行测试。如果我改动了太多东西,犯错时就可能陷入麻烦的调试,并为此耗费大把时间。小步修改,以及它带来的频繁反馈,正是防止混乱的关键。

这里我使用的“编译”一词,指的是将JavaScript变为可执行代码之前的所有步骤。虽然JavaScript可以直接执行,有时可能不需任何步骤,但有时可能需要将代码移动到一个输出目录,或使用Babel这样的代码处理器等。

因为是JavaScript,我可以直接将amountFor提炼成为statement的一个内嵌函数。这个特性十分有用,因为我就不需要再把外部作用域中的数据传给新提炼的函数。这个示例中可能区别不大,但也是少了一件要操心的事。

 重构技术就是以微小的步伐修改程序。如果你犯下错误,很容易便可发现它。

做完上面的修改,测试是通过的,因此下一步我要把代码提交到本地的版本控制系统。我会使用诸如git或mercurial这样的版本控制系统,因为它们可以支持本地提交。每次成功的重构后我都会提交代码,如果待会不小心搞砸了,我便能轻松回滚到上一个可工作的状态。把代码推送(push)到远端仓库前,我会把零碎的修改压缩成一个更有意义的提交(commit)。

提炼函数(106)是一个常见的可自动完成的重构。如果我是用Java编程,我会本能地使用IDE的快捷键来完成这项重构。在我撰写本书时,JavaScript工具对此重构的支持仍不是很健壮,因此我必须手动重构。这不是很难,当然我还是需要小心处理那些局部作用域的变量。

完成提炼函数(106)手法后,我会看看提炼出来的函数,看是否能进一步提升其表达能力。一般我做的第一件事就是给一些变量改名,使它们更简洁,比如将thisAmount重命名为result

function statement...
function amountFor(perf, play) {
  let result = 0;
  switch (play.type) {
  case "tragedy":
    result = 40000;
    if (perf.audience > 30) {
      result += 1000 * (perf.audience - 30);
    }
    break;
  case "comedy":
    result = 30000;
    if (perf.audience > 20) {
      result += 10000 + 500 * (perf.audience - 20);
    }
    result += 300 * perf.audience;
    break;
  default:
      throw new Error(`unknown type: ${play.type}`);
  }
  return result;
}

这是我个人的编码风格:永远将函数的返回值命名为“result”,这样我一眼就能知道它的作用。然后我再次编译、测试、提交代码。接着,我前往下一个目标——函数参数。

function statement...
function amountFor(aPerformance, play) {
  let result = 0;
  switch (play.type) {
  case "tragedy":
    result = 40000;
    if (aPerformance.audience > 30) {
      result += 1000 * (aPerformance.audience - 30);
    }
    break;
  case "comedy":
    result = 30000;
    if (aPerformance.audience > 20) {
      result += 10000 + 500 * (aPerformance.audience - 20);
    }
    result += 300 * aPerformance.audience;
    break;
  default:
      throw new Error(`unknown type: ${play.type}`);
  }
  return result;
}

这是我的另一个编码风格。使用一门动态类型语言(如JavaScript)时,跟踪变量的类型很有意义。因此,我为参数取名时都默认带上其类型名。一般我会使用不定冠词修饰它,除非命名中另有解释其角色的相关信息。这个习惯是从Kent Beck那里学的[Beck SBPP],到现在我还一直觉得很有用。

 傻瓜都能写出计算机可以理解的代码。唯有能写出人类容易理解的代码的,才是优秀的程序员。

这次改名是否值得我大费周章呢?当然值得。好代码应能清楚地表明它在做什么,而变量命名是代码清晰的关键。只要改名能够提升代码的可读性,那就应该毫不犹豫去做。有好的查找替换工具在手,改名通常并不困难;此外,你的测试以及语言本身的静态类型支持,都可以帮你揪出漏改的地方。如今有了自动化的重构工具,即便要给一个被大量调用的函数改名,通常也不在话下。

本来下一个要改名的变量是play,但我对这个参数另有安排。

观察amountFor函数时,我会看看它的参数都从哪里来。aPerformance是从循环变量中来,所以自然每次循环都会改变,但play变量是由performance变量计算得到的,因此根本没必要将它作为参数传入,我可以在amountFor函数中重新计算得到它。当我分解一个长函数时,我喜欢将play这样的变量移除掉,因为它们创建了很多具有局部作用域的临时变量,这会使提炼函数更加复杂。这里我要使用的重构手法是以查询取代临时变量(178)

我先从赋值表达式的右边部分提炼出一个函数来。

function statement...
function playFor(aPerformance) {
  return plays[aPerformance.playID];
}
顶层作用域...
function statement (invoice, plays) {
  let totalAmount = 0;
  let volumeCredits = 0;
  let result = `Statement for ${invoice.customer}\n`;
  const format = new Intl.NumberFormat("en-US",
                        { style: "currency", currency: "USD",
                          minimumFractionDigits: 2 }).format;
  for (let perf of invoice.performances) {
    const play = playFor(perf);
    let thisAmount = amountFor(perf, play);

    // add volume credits
    volumeCredits += Math.max(perf.audience - 30, 0);
    // add extra credit for every ten comedy attendees
    if ("comedy" === play.type) volumeCredits += Math.floor(perf.audience / 5);

    // print line for this order
    result += ` ${play.name}: ${format(thisAmount/100)} (${perf.audience} seats)\n`;
    totalAmount += thisAmount;
  }
  result += `Amount owed is ${format(totalAmount/100)}\n`;
  result += `You earned ${volumeCredits} credits\n`; 
  return result;

编译、测试、提交,然后使用内联变量(123)手法内联play变量。

顶层作用域...
function statement (invoice, plays) {
  let totalAmount = 0;
  let volumeCredits = 0;
  let result = `Statement for ${invoice.customer}\n`;
  const format = new Intl.NumberFormat("en-US",
                        { style: "currency", currency: "USD",
                          minimumFractionDigits: 2 }).format;
  for (let perf of invoice.performances) {
    const play = playFor(perf);    
    let thisAmount = amountFor(perf, playFor(perf));

    // add volume credits
    volumeCredits += Math.max(perf.audience - 30, 0);
    // add extra credit for every ten comedy attendees
    if ("comedy" === playFor(perf).type) volumeCredits += Math.floor(perf.audience / 5);

    // print line for this order
    result += ` ${playFor(perf).name}: ${format(thisAmount/100)} (${perf.audience} seats)\n`;
    totalAmount += thisAmount;
  }
  result += `Amount owed is ${format(totalAmount/100)}\n`;
  result += `You earned ${volumeCredits} credits\n`;
  return result;

编译、测试、提交。完成变量内联后,我可以对amountFor函数应用改变函数声明(124),移除play参数。我会分两步走。首先在amountFor函数内部使用新提炼的函数。

function statement...
function amountFor(aPerformance, play) {
  let result = 0;
  switch (playFor(aPerformance).type) {
  case "tragedy":
    result = 40000;
    if (aPerformance.audience > 30) {
      result += 1000 * (aPerformance.audience - 30);
    }
    break;
  case "comedy":
    result = 30000;
    if (aPerformance.audience > 20) {
      result += 10000 + 500 * (aPerformance.audience - 20);
    }
    result += 300 * aPerformance.audience;
    break;
  default:
      throw new Error(`unknown type: ${playFor(aPerformance).type}`);
  }
  return result;
}

编译、测试、提交,最后将参数删除。

顶层作用域...
function statement (invoice, plays) {
  let totalAmount = 0;
  let volumeCredits = 0;
  let result = `Statement for ${invoice.customer}\n`;
  const format = new Intl.NumberFormat("en-US",
                        { style: "currency", currency: "USD",
                          minimumFractionDigits: 2 }).format;
  for (let perf of invoice.performances) {
    let thisAmount = amountFor(perf , playFor(perf) );

    // add volume credits
    volumeCredits += Math.max(perf.audience - 30, 0);
    // add extra credit for every ten comedy attendees
    if ("comedy" === playFor(perf).type) volumeCredits += Math.floor(perf.audience / 5);

    // print line for this order
    result += ` ${playFor(perf).name}: ${format(thisAmount/100)} (${perf.audience} seats)\n`;
    totalAmount += thisAmount;
  }
  result += `Amount owed is ${format(totalAmount/100)}\n`;
  result += `You earned ${volumeCredits} credits\n`;
  return result;
function statement...
function amountFor(aPerformance , play ) {
  let result = 0;
  switch (playFor(aPerformance).type) {
  case "tragedy":
    result = 40000;
    if (aPerformance.audience > 30) {
      result += 1000 * (aPerformance.audience - 30);
    }
    break;
  case "comedy":
    result = 30000;
    if (aPerformance.audience > 20) {
      result += 10000 + 500 * (aPerformance.audience - 20);
    }
    result += 300 * aPerformance.audience;
    break;
  default:
      throw new Error(`unknown type: ${playFor(aPerformance).type}`);
  }
  return result;
}

然后再一次编译、测试、提交。

这次重构可能在一些程序员心中敲响警钟:重构前,查找play变量的代码在每次循环中只执行了1次,而重构后却执行了3次。我会在后面探讨重构与性能之间的关系,但现在,我认为这次改动还不太可能对性能有严重影响,即便真的有所影响,后续再对一段结构良好的代码进行性能调优,也容易得多。

移除局部变量的好处就是做提炼时会简单得多,因为需要操心的局部作用域变少了。实际上,在做任何提炼前,我一般都会先移除局部变量。

处理完amountFor的参数后,我回过头来看一下它的调用点。它被赋值给一个临时变量,之后就不再被修改,因此我又采用内联变量(123)手法内联它。

顶层作用域...
function statement (invoice, plays) {
  let totalAmount = 0;
  let volumeCredits = 0;
  let result = `Statement for ${invoice.customer}\n`;
  const format = new Intl.NumberFormat("en-US",
                        { style: "currency", currency: "USD",
                          minimumFractionDigits: 2 }).format;
    for (let perf of invoice.performances) {

    // add volume credits
    volumeCredits += Math.max(perf.audience - 30, 0);
    // add extra credit for every ten comedy attendees
    if ("comedy" === playFor(perf).type) volumeCredits += Math.floor(perf.audience / 5);

    // print line for this order
    result += ` ${playFor(perf).name}: ${format(amountFor(perf)/100)} (${perf.audience} seats)\n`;
    totalAmount += amountFor(perf);
  }
  result += `Amount owed is ${format(totalAmount/100)}\n`;
  result += `You earned ${volumeCredits} credits\n`;
  return result;

现在statement函数的内部实现是这样的。

顶层作用域...
function statement (invoice, plays) {
  let totalAmount = 0;
  let volumeCredits = 0;
  let result = `Statement for ${invoice.customer}\n`;
  const format = new Intl.NumberFormat("en-US",
                        { style: "currency", currency: "USD",
                          minimumFractionDigits: 2 }).format;
  for (let perf of invoice.performances) {

    // add volume credits
    volumeCredits += Math.max(perf.audience - 30, 0);
    // add extra credit for every ten comedy attendees
    if ("comedy" === playFor(perf).type) volumeCredits += Math.floor(perf.audience / 5);

    // print line for this order
    result += ` ${playFor(perf).name}: ${format(amountFor(perf)/100)} (${perf.audience} seats)\n`;
    totalAmount += amountFor(perf);
  }
  result += `Amount owed is ${format(totalAmount/100)}\n`;
  result += `You earned ${volumeCredits} credits\n`;
  return result;

这会儿我们就看到了移除play变量的好处,移除了一个局部作用域的变量,提炼观众量积分的计算逻辑又更简单一些。

我仍需要处理其他两个局部变量。perf同样可以轻易作为参数传入,但volumeCredits变量则有些棘手。它是一个累加变量,循环的每次迭代都会更新它的值。因此最简单的方式是,将整块逻辑提炼到新函数中,然后在新函数中直接返回volumeCredits

function statement...
function volumeCreditsFor(perf) {
  let volumeCredits = 0;
  volumeCredits += Math.max(perf.audience - 30, 0);
  if ("comedy" === playFor(perf).type) volumeCredits += Math.floor(perf.audience / 5);
  return volumeCredits;
}
顶层作用域...
function statement (invoice, plays) {
  let totalAmount = 0;
  let volumeCredits = 0;
  let result = `Statement for ${invoice.customer}\n`;
  const format = new Intl.NumberFormat("en-US",
                        { style: "currency", currency: "USD",
                          minimumFractionDigits: 2 }).format;
  for (let perf of invoice.performances) {
    volumeCredits += volumeCreditsFor(perf);

    // print line for this order
    result += ` ${playFor(perf).name}: ${format(amountFor(perf)/100)} (${perf.audience} seats)\n`;
    totalAmount += amountFor(perf);
  }
  result += `Amount owed is ${format(totalAmount/100)}\n`;
  result += `You earned ${volumeCredits} credits\n`;
  return result;

我还顺便删除了多余(并且会引起误解)的注释。

编译、测试、提交,然后对新函数里的变量改名。

function statement...
function volumeCreditsFor(aPerformance) {
  let result = 0;
  result += Math.max(aPerformance.audience - 30, 0);
  if ("comedy" === playFor(aPerformance).type) result += Math.floor(aPerformance.audience / 5); 
  return result;
}

这里我只展示了一步到位的改名结果,不过实际操作时,我还是一次只将一个变量改名,并在每次改名后执行编译、测试、提交。

我们再看一下statement这个主函数。

顶层作用域...
function statement (invoice, plays) {
  let totalAmount = 0;
  let volumeCredits = 0;
  let result = `Statement for ${invoice.customer}\n`;
  const format = new Intl.NumberFormat("en-US",
                        { style: "currency", currency: "USD",
                          minimumFractionDigits: 2 }).format;
  for (let perf of invoice.performances) {
    volumeCredits += volumeCreditsFor(perf);

    // print line for this order
    result += ` ${playFor(perf).name}: ${format(amountFor(perf)/100)} (${perf.audience} seats)\n`;
    totalAmount += amountFor(perf);
  }
  result += `Amount owed is ${format(totalAmount/100)}\n`;
  result += `You earned ${volumeCredits} credits\n`;
  return result;

正如我上面所指出的,临时变量往往会带来麻烦。它们只在对其进行处理的代码块中有用,因此临时变量实质上是鼓励你写长而复杂的函数。因此,下一步我要替换掉一些临时变量,而最简单的莫过于从format变量入手。这是典型的“将函数赋值给临时变量”的场景,我更愿意将其替换为一个明确声明的函数。

function statement...
function format(aNumber) {
  return new Intl.NumberFormat("en-US",
                      { style: "currency", currency: "USD",
                        minimumFractionDigits: 2 }).format(aNumber);
}
顶层作用域...
function statement (invoice, plays) {
  let totalAmount = 0;
  let volumeCredits = 0;
  let result = `Statement for ${invoice.customer}\n`;
  for (let perf of invoice.performances) {
    volumeCredits += volumeCreditsFor(perf);

    // print line for this order
    result += ` ${playFor(perf).name}: ${format(amountFor(perf)/100)} (${perf.audience} seats)\n`;
    totalAmount += amountFor(perf);
  }
  result += `Amount owed is ${format(totalAmount/100)}\n`;
  result += `You earned ${volumeCredits} credits\n`;
  return result;

尽管将函数变量改变成函数声明也是一种重构手法,但我既未为此手法命名,也未将它纳入重构名录。还有很多的重构手法我都觉得没那么重要。我觉得上面这个函数改名的手法既十分简单又不太常用,不值得在重构名录中占有一席之地。

我对提炼得到的函数名称不很满意——format未能清晰地描述其作用。formatAsUSD很表意,但又太长,特别它仅是小范围地被用在一个字符串模板中。我认为这里真正需要强调的是,它格式化的是一个货币数字,因此我选取了一个能体现此意图的命名,并应用了改变函数声明(124)手法。

顶层作用域...
function statement (invoice, plays) {
  let totalAmount = 0;
  let volumeCredits = 0;
  let result = `Statement for ${invoice.customer}\n`;
  for (let perf of invoice.performances) {
    volumeCredits += volumeCreditsFor(perf);

    // print line for this order
    result += ` ${playFor(perf).name}: ${usd(amountFor(perf))} (${perf.audience} seats)\n`;
    totalAmount += amountFor(perf);
  }
  result += `Amount owed is ${usd(totalAmount)}\n`; 
  result += `You earned ${volumeCredits} credits\n`; 
  return result;
function statement...
function usd(aNumber) {
  return new Intl.NumberFormat("en-US",
                      { style: "currency", currency: "USD",
                        minimumFractionDigits: 2 }).format(aNumber/100);
}

好的命名十分重要,但往往并非唾手可得。只有恰如其分地命名,才能彰显出将大函数分解成小函数的价值。有了好的名称,我就不必通过阅读函数体来了解其行为。但要一次把名取好并不容易,因此我会使用当下能想到最好的那个。如果稍后想到更好的,我就会毫不犹豫地换掉它。通常你需要花几秒钟通读更多代码,才能发现最好的名称是什么。

重命名的同时,我还将重复的除以100的行为也搬移到函数里。将钱以美分为单位作为正整数存储是一种常见的做法,可以避免使用浮点数来存储货币的小数部分,同时又不影响用数学运算符操作它。不过,对于这样一个以美分为单位的整数,我又需要以美元为单位进行展示,因此让格式化函数来处理整除的事宜再好不过。

我的下一个重构目标是volumeCredits。处理这个变量更加微妙,因为它是在循环的迭代过程中累加得到的。第一步,就是应用拆分循环(227)volumeCredits的累加过程分离出来。

顶层作用域...
function statement (invoice, plays) {
  let totalAmount = 0;
  let volumeCredits = 0;
  let result = `Statement for ${invoice.customer}\n`;

  for (let perf of invoice.performances) {

    // print line for this order
    result += ` ${playFor(perf).name}: ${usd(amountFor(perf))} (${perf.audience} seats)\n`;
    totalAmount += amountFor(perf);
  }
  for (let perf of invoice.performances) {
    volumeCredits += volumeCreditsFor(perf);
  }

result += `Amount owed is ${usd(totalAmount)}\n`;
result += `You earned ${volumeCredits} credits\n`;
return result;

完成这一步,我就可以使用移动语句(223)手法将变量声明挪动到紧邻循环的位置。

top level…
function statement (invoice, plays) {
  let totalAmount = 0;
  let result = `Statement for ${invoice.customer}\n`;
  for (let perf of invoice.performances) {

    // print line for this order
    result += ` ${playFor(perf).name}: ${usd(amountFor(perf))} (${perf.audience} seats)\n`;
    totalAmount += amountFor(perf);
  }
  let volumeCredits = 0;
  for (let perf of invoice.performances) {
    volumeCredits += volumeCreditsFor(perf);
  }
  result += `Amount owed is ${usd(totalAmount)}\n`;
  result += `You earned ${volumeCredits} credits\n`;
  return result;

把与更新volumeCredits变量相关的代码都集中到一起,有利于以查询取代临时变量(178)手法的施展。第一步同样是先对变量的计算过程应用提炼函数(106)手法。

function statement...
function totalVolumeCredits() {
  let volumeCredits = 0;
  for (let perf of invoice.performances) {
    volumeCredits += volumeCreditsFor(perf);
  }
  return volumeCredits;
}
顶层作用域...
function statement (invoice, plays) {
  let totalAmount = 0;
  let result = `Statement for ${invoice.customer}\n`;
  for (let perf of invoice.performances) {

    // print line for this order
    result += ` ${playFor(perf).name}: ${usd(amountFor(perf))} (${perf.audience} seats)\n`;
    totalAmount += amountFor(perf);
  }
  let volumeCredits = totalVolumeCredits();
  result += `Amount owed is ${usd(totalAmount)}\n`;
  result += `You earned ${volumeCredits} credits\n`;
  return result;

完成函数提炼后,我再应用内联变量(123)手法内联totalVolumeCredits函数。

顶层作用域...
function statement (invoice, plays) {
  let totalAmount = 0;
  let result = `Statement for ${invoice.customer}\n`;
  for (let perf of invoice.performances) {

    // print line for this order
    result += ` ${playFor(perf).name}: ${usd(amountFor(perf))} (${perf.audience} seats)\n`;
    totalAmount += amountFor(perf);
  }

  result += `Amount owed is ${usd(totalAmount)}\n`;
  result += `You earned ${totalVolumeCredits()} credits\n`;
  return result;

重构至此,让我先暂停一下,谈谈刚刚完成的修改。首先,我知道有些读者会再次对此修改可能带来的性能问题感到担忧,我知道很多人本能地警惕重复的循环。但大多数时候,重复一次这样的循环对性能的影响都可忽略不计。如果你在重构前后进行计时,很可能甚至都注意不到运行速度的变化——通常也确实没什么变化。许多程序员对代码实际的运行路径都所知不足,甚至经验丰富的程序员有时也未能避免。在聪明的编译器、现代的缓存技术面前,我们很多直觉都是不准确的。软件的性能通常只与代码的一小部分相关,改变其他的部分往往对总体性能贡献甚微。

当然,“大多数时候”不等同于“所有时候”。有时,一些重构手法也会显著地影响性能。但即便如此,我通常也不去管它,继续重构,因为有了一份结构良好的代码,回头调优其性能也容易得多。如果我在重构时引入了明显的性能损耗,我后面会花时间进行性能调优。进行调优时,可能会回退我早先做的一些重构——但更多时候,因为重构我可以使用更高效的调优方案。最后我得到的是既整洁又高效的代码。

因此对于重构过程的性能问题,我总体的建议是:大多数情况下可以忽略它。如果重构引入了性能损耗,先完成重构,再做性能优化。

其次,我希望你能注意到:我们移除volumeCredits的过程是多么小步。整个过程一共有4步,每一步都伴随着一次编译、测试以及向本地代码库的提交:

我得坦白,我并非总是如此小步——但在事情变复杂时,我的第一反应就是采用更小的步子。怎样算变复杂呢,就是当重构过程有测试失败而我又无法马上看清问题所在并立即修复时,我就会回滚到最后一次可工作的提交,然后以更小的步子重做。这得益于我如此频繁地提交。特别是与复杂代码打交道时,细小的步子是快速前进的关键。

接着我要重复同样的步骤来移除totalAmount。我以拆解循环开始(编译、测试、提交),然后下移累加变量的声明语句(编译、测试、提交),最后再提炼函数。这里令我有点头疼的是:最好的函数名应该是totalAmount,但它已经被变量名占用,我无法起两个同样的名字。因此,我在提炼函数时先给它随便取了一个名字(然后编译、测试、提交)。

function statement...
function appleSauce() {
  let totalAmount = 0;
  for (let perf of invoice.performances) {
    totalAmount += amountFor(perf);
  }
  return totalAmount;
}
顶层作用域...
function statement (invoice, plays) {
  let result = `Statement for ${invoice.customer}\n`;
  for (let perf of invoice.performances) {
    result += ` ${playFor(perf).name}: ${usd(amountFor(perf))} (${perf.audience} seats)\n`;
  }
  let totalAmount = appleSauce();

  result += `Amount owed is ${usd(totalAmount)}\n`;
  result += `You earned ${totalVolumeCredits()} credits\n`;
  return result;

接着我将变量内联(编译、测试、提交),然后将函数名改回totalAmount(编译、测试、提交)。

顶层作用域...
function statement (invoice, plays) {
  let result = `Statement for ${invoice.customer}\n`;
  for (let perf of invoice.performances) {
    result += ` ${playFor(perf).name}: ${usd(amountFor(perf))} (${perf.audience} seats)\n`;
  }
  result += `Amount owed is ${usd(totalAmount())}\n`;
  result += `You earned ${totalVolumeCredits()} credits\n`;
  return result;
function statement...
function totalAmount() {
  let totalAmount = 0;
  for (let perf of invoice.performances) {
    totalAmount += amountFor(perf);
  }
  return totalAmount;
}

趁着给新提炼的函数改名的机会,我顺手一并修改了函数内部的变量名,以便保持我一贯的编码风格。

function statement...
function totalAmount() {
  let result = 0;
  for (let perf of invoice.performances) {
    result += amountFor(perf);
  }
  return result;
}
function totalVolumeCredits() {
  let result = 0;
  for (let perf of invoice.performances) {
    result += volumeCreditsFor(perf);
  }
  return result;
}

重构至此,是时候停下来欣赏一下代码的全貌了。

function statement (invoice, plays) {
  let result = `Statement for ${invoice.customer}\n`;
  for (let perf of invoice.performances) {
    result += ` ${playFor(perf).name}: ${usd(amountFor(perf))} (${perf.audience} seats)\n`;
  }
  result += `Amount owed is ${usd(totalAmount())}\n`;
  result += `You earned ${totalVolumeCredits()} credits\n`;
  return result;

  function totalAmount() {
    let result = 0;
    for (let perf of invoice.performances) {
      result += amountFor(perf);
    }
    return result;
  }
  function totalVolumeCredits() {
    let result = 0;
    for (let perf of invoice.performances) {
      result += volumeCreditsFor(perf);
    }
    return result;
  }
  function usd(aNumber) {
    return new Intl.NumberFormat("en-US",
                        { style: "currency", currency: "USD",
                          minimumFractionDigits: 2 }).format(aNumber/100);
  }
  function volumeCreditsFor(aPerformance) {
    let result = 0;
    result += Math.max(aPerformance.audience - 30, 0);
    if ("comedy" === playFor(aPerformance).type) result += Math.floor(aPerformance.audience / 5);
    return result;
  }
  function playFor(aPerformance) {
    return plays[aPerformance.playID];
  }
  function amountFor(aPerformance) {
    let result = 0;
    switch (playFor(aPerformance).type) {
    case "tragedy":
      result = 40000;
      if (aPerformance.audience > 30) {
        result += 1000 * (aPerformance.audience - 30);
      }
      break;
    case "comedy":
      result = 30000;
      if (aPerformance.audience > 20) {
        result += 10000 + 500 * (aPerformance.audience - 20);
      }
      result += 300 * aPerformance.audience;
      break;
    default:
      throw new Error(`unknown type: ${playFor(aPerformance).type}`);
    }
    return result;
  }
}

现在代码结构已经好多了。顶层的statement函数现在只剩7行代码,而且它处理的都是与打印详单相关的逻辑。与计算相关的逻辑从主函数中被移走,改由一组函数来支持。每个单独的计算过程和详单的整体结构,都因此变得更易理解了。

function statement...

到目前为止,我的重构主要是为原函数添加足够的结构,以便我能更好地理解它,看清它的逻辑结构。这也是重构早期的一般步骤。把复杂的代码块分解为更小的单元,与好的命名一样都很重要。现在,我可以更多关注我要修改的功能部分了,也就是为这张详单提供一个HTML版本。不管怎么说,现在改起来更加简单了。因为计算代码已经被分离出来,我只需要为顶部的7行代码实现一个HTML的版本。问题是,这些分解出来的函数嵌套在打印文本详单的函数中。无论嵌套函数组织得多么良好,我总不想将它们全复制粘贴到另一个新函数中。我希望同样的计算函数可以被文本版详单和HTML版详单共用。

要实现复用有许多种方法,而我最喜欢的技术是拆分阶段(154)。这里我的目标是将逻辑分成两部分:一部分计算详单所需的数据,另一部分将数据渲染成文本或HTML。第一阶段会创建一个中转数据结构,再把它传递给第二阶段。

要开始拆分阶段(154),我会先对组成第二阶段的代码应用提炼函数(106)。在这个例子中,这部分代码就是打印详单的代码,其实也就是statement函数的全部内容。我要把它们与所有嵌套的函数一起抽取到一个新的顶层函数中,并将其命名为renderPlainText

function statement (invoice, plays) {
  return renderPlainText(invoice, plays);
}

function renderPlainText(invoice, plays) {
  let result = `Statement for ${invoice.customer}\n`;
  for (let perf of invoice.performances) {
    result += ` ${playFor(perf).name}: ${usd(amountFor(perf))} (${perf.audience} seats)\n`;
  }
  result += `Amount owed is ${usd(totalAmount())}\n`;
  result += `You earned ${totalVolumeCredits()} credits\n`;
  return result;

function totalAmount() {...}
  function totalVolumeCredits() {...}
  function usd(aNumber) {...}
  function volumeCreditsFor(aPerformance) {...}
  function playFor(aPerformance) {...}
  function amountFor(aPerformance) {...}

编译、测试、提交,接着创建一个对象,作为在两个阶段间传递的中转数据结构,然后将它作为第一个参数传递给renderPlainText(然后编译、测试、提交)。

function statement (invoice, plays) {
  const statementData = {};
  return renderPlainText(statementData, invoice, plays);
}

function renderPlainText(data, invoice, plays) {
  let result = `Statement for ${invoice.customer}\n`;
  for (let perf of invoice.performances) {
    result += ` ${playFor(perf).name}: ${usd(amountFor(perf))} (${perf.audience} seats)\n`;
  }
  result += `Amount owed is ${usd(totalAmount())}\n`;
  result += `You earned ${totalVolumeCredits()} credits\n`;
  return result;

function totalAmount() {...}
  function totalVolumeCredits() {...}
  function usd(aNumber) {...}
  function volumeCreditsFor(aPerformance) {...}
  function playFor(aPerformance) {...}
  function amountFor(aPerformance) {...}

现在我要检查一下renderPlainText用到的其他参数。我希望将它们挪到这个中转数据结构里,这样所有计算代码都可以被挪到statement函数中,让renderPlainText只操作通过data参数传进来的数据。

第一步是将顾客(customer)字段添加到中转对象里(编译、测试、提交)。

function statement (invoice, plays) {
  const statementData = {};
  statementData.customer = invoice.customer;
  return renderPlainText(statementData, invoice, plays);
}

function renderPlainText(data, invoice, plays) {
  let result = `Statement for ${data.customer}\n`;
  for (let perf of invoice.performances) {
    result += ` ${playFor(perf).name}: ${usd(amountFor(perf))} (${perf.audience} seats)\n`;
  }
  result += `Amount owed is ${usd(totalAmount())}\n`;
  result += `You earned ${totalVolumeCredits()} credits\n`;
  return result;

我将performances字段也搬移过去,这样我就可以移除掉renderPlainTextinvoice参数(编译、测试、提交)。

顶层作用域...
function statement (invoice, plays) {
  const statementData = {};
  statementData.customer = invoice.customer;
  statementData.performances = invoice.performances;
  return renderPlainText(statementData,  invoice,  plays);
}

function renderPlainText(data, plays) {
  let result = `Statement for ${data.customer}\n`;
  for (let perf of data.performances) {
    result += ` ${playFor(perf).name}: ${usd(amountFor(perf))} (${perf.audience} seats)\n`;
  }
  result += `Amount owed is ${usd(totalAmount())}\n`;
  result += `You earned ${totalVolumeCredits()} credits\n`;
  return result;
function renderPlainText...
function totalAmount() {
  let result = 0;
  for (let perf of data.performances) {
    result += amountFor(perf);
  }
  return result;
}
function totalVolumeCredits() { 
  let result = 0;
  for (let perf of data.performances) {
    result += volumeCreditsFor(perf);
  }
  return result;
}

现在,我希望“剧目名称”信息也从中转数据中获得。为此,需要使用play中的数据填充aPerformance对象(记得编译、测试、提交)。

function statement (invoice, plays) {
  const statementData = {};
  statementData.customer = invoice.customer;
  statementData.performances = invoice.performances.map(enrichPerformance);
  return renderPlainText(statementData, plays);

  function enrichPerformance(aPerformance) {
    const result = Object.assign({}, aPerformance);
    return result;
}

现在我只是简单地返回了一个aPerformance对象的副本,但马上我就会往这条记录中添加新的数据。返回副本的原因是,我不想修改传给函数的参数,我总是尽量保持数据不可变(immutable)——可变的状态会很快变成烫手的山芋。

在不熟悉 JavaScript 的人看来,result = Object.assign({}, aPerformance)的写法可能十分奇怪。它返回的是一个浅副本。虽然我更希望有个函数来完成此功能,但这个用法已经约定俗成,如果我自己写个函数,在JavaScript程序员看来反而会格格不入。

现在我们已经有了安放play字段的地方,可以把数据放进去。我需要对playForstatement函数应用搬移函数(198)(然后编译、测试、提交)。

function statement...
function enrichPerformance(aPerformance) {
  const result = Object.assign({}, aPerformance);
  result.play = playFor(result);
  return result;
  }

  function playFor(aPerformance) {
    return plays[aPerformance.playID];
}

然后替换renderPlainText中对playFor的所有引用点,让它们使用新数据(编译、测试、提交)。

function renderPlainText...
let result = `Statement for ${data.customer}\n`;
for (let perf of data.performances) {
  result += ` ${perf.play.name}: ${usd(amountFor(perf))} (${perf.audience} seats)\n`;
}
result += `Amount owed is ${usd(totalAmount())}\n`;
result += `You earned ${totalVolumeCredits()} credits\n`;
return result;

function volumeCreditsFor(aPerformance) {
  let result = 0;
  result += Math.max(aPerformance.audience - 30, 0);
  if ("comedy" === aPerformance.play.type) result += Math.floor(aPerformance.audience / 5);
  return result;
}

functionamountFor(aPerformance){
  let result = 0;
  switch (aPerformance.play.type) {
  case "tragedy":
    result = 40000;
    if (aPerformance.audience > 30) {
      result += 1000 * (aPerformance.audience - 30);
    }
    break;
  case "comedy":
    result = 30000;
    if (aPerformance.audience > 20) {
      result += 10000 + 500 * (aPerformance.audience - 20);
    }
    result += 300 * aPerformance.audience;
    break;
  default:
    throw new Error(`unknown type: ${aPerformance.play.type}`);
  }
  return result;
}

接着我使用类似的手法搬移amountFor函数(编译、测试、提交)。

function statement...
function enrichPerformance(aPerformance) {
  const result = Object.assign({}, aPerformance);
  result.play = playFor(result);
  result.amount = amountFor(result);
  return result;
}

function amountFor(aPerformance) {...}
function renderPlainText...
let result = `Statement for ${data.customer}\n`;
for (let perf of data.performances) {
  result += ` ${perf.play.name}: ${usd(perf.amount)} (${perf.audience} seats)\n`;
}
result += `Amount owed is ${usd(totalAmount())}\n`;
result += `You earned ${totalVolumeCredits()} credits\n`;
return result;

function totalAmount() {
  let result = 0;
  for (let perf of data.performances) {
    result += perf.amount;
  }
  return result;
}

接下来搬移观众量积分的计算(编译、测试、提交)。

function statement...
function enrichPerformance(aPerformance) {
  const result = Object.assign({}, aPerformance);
  result.play = playFor(result);
  result.amount = amountFor(result);
  result.volumeCredits = volumeCreditsFor(result);
  return result;
}

function volumeCreditsFor(aPerformance) {...}
function renderPlainText...
function totalVolumeCredits() {
  let result = 0;
  for (let perf of data.performances) {
    result += perf.volumeCredits;
  }
  return result;
}

最后,我将两个计算总数的函数搬移到statement函数中。

const statementData = {};
statementData.customer = invoice.customer;
statementData.performances = invoice.performances.map(enrichPerformance);
statementData.totalAmount = totalAmount(statementData);
statementData.totalVolumeCredits = totalVolumeCredits(statementData);
return renderPlainText(statementData, plays);

 function totalAmount(data) {...} 
   function totalVolumeCredits(data) {...}
function renderPlainText...
let result = `Statement for ${data.customer}\n`;
for (let perf of data.performances) {
  result += ` ${perf.play.name}: ${usd(perf.amount)} (${perf.audience} seats)\n`;
}
result += `Amount owed is ${usd(data.totalAmount)}\n`;
result += `You earned ${data.totalVolumeCredits} credits\n`;
return result;

尽管我可以修改函数体,让这些计算总数的函数直接使用statementData变量(反正它在作用域内),但我更喜欢显式地传入函数参数。

等到搬移完成,编译、测试、提交也做完,我便忍不住以管道取代循环(231)对几个地方进行重构。

function renderPlainText...
function totalAmount(data) {
  return data.performances
    .reduce((total, p) => total + p.amount, 0);
}
function totalVolumeCredits(data) {
  return data.performances
    .reduce((total, p) => total + p.volumeCredits, 0);
}

现在我可以把第一阶段的代码提炼到一个独立的函数里了(编译、测试、提交)。

顶层作用域...
function statement (invoice, plays) {
  return renderPlainText(createStatementData(invoice, plays));
}

function createStatementData(invoice, plays) {
  const statementData = {};
  statementData.customer = invoice.customer;
  statementData.performances = invoice.performances.map(enrichPerformance);
  statementData.totalAmount = totalAmount(statementData);
  statementData.totalVolumeCredits = totalVolumeCredits(statementData);
  return statementData;

由于两个阶段已经彻底分离,我干脆把它搬移到另一个文件里去(并且修改了返回结果的变量名,与我一贯的编码风格保持一致)。

statement.js...
import createStatementData from './createStatementData.js';
createStatementData.js...
export default function createStatementData(invoice, plays) {
  const result = {};
  result.customer = invoice.customer;
  result.performances = invoice.performances.map(enrichPerformance);
  result.totalAmount = totalAmount(result);
  result.totalVolumeCredits = totalVolumeCredits(result);
  return result;

  function enrichPerformance(aPerformance) {...} 
    function playFor(aPerformance) {...}
    function amountFor(aPerformance) {...}
    function volumeCreditsFor(aPerformance) {...}
    function totalAmount(data) {...}
    function totalVolumeCredits(data) {...}

最后再做一次编译、测试、提交,接下来,要编写一个HTML版本的对账单就很简单了。

statement.js...
function htmlStatement (invoice, plays) {
  return renderHtml(createStatementData(invoice, plays));
}
function renderHtml (data) {
  let result = `<h1>Statement for ${data.customer}</h1>\n`;
  result += "<table>\n";
  result += "<tr><th>play</th><th>seats</th><th>cost</th></tr>";
  for (let perf of data.performances) {
    result += ` <tr><td>${perf.play.name}</td><td>${perf.audience}</td>`;
    result += `<td>${usd(perf.amount)}</td></tr>\n`;
  }
  result += "</table>\n";
  result += `<p>Amount owed is <em>${usd(data.totalAmount)}</em></p>\n`;
  result += `<p>You earned <em>${data.totalVolumeCredits}</em> credits</p>\n`;
  return result;
}

function usd(aNumber) {...}

(我把usd函数也搬移到顶层作用域中,以便renderHtml也能访问它。)

现在正是停下来重新回顾一下代码的好时机,思考一下重构的进展。现在我有了两个代码文件。

statement.js
import createStatementData from './createStatementData.js';
function statement (invoice, plays) {
  return renderPlainText(createStatementData(invoice, plays));
}
function renderPlainText(data, plays) {
  let result = `Statement for ${data.customer}\n`;
  for (let perf of data.performances) {
    result += ` ${perf.play.name}: ${usd(perf.amount)} (${perf.audience} seats)\n`;
  }
  result += `Amount owed is ${usd(data.totalAmount)}\n`;
  result += `You earned ${data.totalVolumeCredits} credits\n`;
  return result;
}
function htmlStatement (invoice, plays) {
  return renderHtml(createStatementData(invoice, plays));
}
function renderHtml (data) {
  let result = `<h1>Statement for ${data.customer}</h1>\n`;
  result += "<table>\n";
  result += "<tr><th>play</th><th>seats</th><th>cost</th></tr>";
  for (let perf of data.performances) {
    result += ` <tr><td>${perf.play.name}</td><td>${perf.audience}</td>`;
    result += `<td>${usd(perf.amount)}</td></tr>\n`;
  }
  result += "</table>\n";
  result += `<p>Amount owed is <em>${usd(data.totalAmount)}</em></p>\n`; 
  result += `<p>You earned <em>${data.totalVolumeCredits}</em> credits</p>\n`;
  return result;
}
function usd(aNumber) {
  return new Intl.NumberFormat("en-US",
                                { style: "currency", currency: "USD",
                                  minimumFractionDigits: 2 }).format(aNumber/100);
}
createStatementData.js
export default function createStatementData(invoice, plays) {
  const result = {};
  result.customer = invoice.customer;
  result.performances = invoice.performances.map(enrichPerformance);
  result.totalAmount = totalAmount(result);
  result.totalVolumeCredits = totalVolumeCredits(result);
  return result;

  function enrichPerformance(aPerformance) {
    const result = Object.assign({}, aPerformance);
    result.play = playFor(result);
    result.amount = amountFor(result);
    result.volumeCredits = volumeCreditsFor(result);
    return result;
  }
  function playFor(aPerformance) {
    return plays[aPerformance.playID]
  }
  function amountFor(aPerformance) {
    let result = 0;
    switch (aPerformance.play.type) {
    case "tragedy":
      result = 40000;
      if (aPerformance.audience > 30) {
        result += 1000 * (aPerformance.audience - 30);
      }
      break;
    case "comedy": 
      result = 30000;
      if (aPerformance.audience > 20) {
        result += 10000 + 500 * (aPerformance.audience - 20);
      }
      result += 300 * aPerformance.audience;
      break;
    default:
        throw new Error(`unknown type: ${aPerformance.play.type}`);
    }
    return result;
  }
  function volumeCreditsFor(aPerformance) { 
    let result = 0;
    result += Math.max(aPerformance.audience - 30, 0);
    if ("comedy" === aPerformance.play.type) result += Math.floor(aPerformance.audience / 5);
    return result;
  }
  function totalAmount(data) {
    return data.performances
      .reduce((total, p) => total + p.amount, 0);
  }
  function totalVolumeCredits(data) {
    return data.performances
      .reduce((total, p) => total + p.volumeCredits, 0);
  }

代码行数由我开始重构时的44行增加到了70行(不算htmlStatement),这主要是将代码抽取到函数里带来的额外包装成本。虽然代码的行数增加了,但重构也带来了代码可读性的提高。额外的包装将混杂的逻辑分解成可辨别的部分,分离了详单的计算逻辑与样式。这种模块化使我更容易辨别代码的不同部分,了解它们的协作关系。虽说言以简为贵,但可演化的软件却以明确为贵。通过增强代码的模块化,我可以轻易地添加HTML版本的代码,而无须重复计算部分的逻辑。

 编程时,需要遵循营地法则:保证你离开时的代码库一定比来时更健康。

其实打印逻辑还可以进一步简化,但当前的代码也够用了。我经常需要在所有可做的重构与添加新特性之间寻找平衡。在当今业界,大多数人面临同样的选择时,似乎多以延缓重构而告终——当然这也是一种选择。我的观点则与营地法则无异:保证离开时的代码库一定比你来时更加健康。完美的境界很难达到,但应该时时都勤加拂拭。

接下来我将注意力集中到下一个特性改动:支持更多类型的戏剧,以及支持它们各自的价格计算和观众量积分计算。对于现在的结构,我只需要在计算函数里添加分支逻辑即可。amountFor函数清楚地体现了,戏剧类型在计算分支的选择上起着关键的作用——但这样的分支逻辑很容易随代码堆积而腐坏,除非编程语言提供了更基础的编程语言元素来防止代码堆积。

要为程序引入结构、显式地表达出“计算逻辑的差异是由类型代码确定”有许多途径,不过最自然的解决办法还是使用面向对象世界里的一个经典特性——类型多态。传统的面向对象特性在JavaScript世界一直备受争议,但新的ECMAScript 2015规范有意为类和多态引入了一个相当实用的语法糖。这说明,在合适的场景下使用面向对象是合理的——显然我们这个就是一个合适的使用场景。

我的设想是先建立一个继承体系,它有“喜剧”(comedy)和“悲剧”(tragedy)两个子类,子类各自包含独立的计算逻辑。调用者通过调用一个多态的amount函数,让语言帮你分发到不同的子类的计算过程中。volumeCredits函数的处理也是如法炮制。为此我需要用到多种重构方法,其中最核心的一招是以多态取代条件表达式(272),将多个同样的类型码分支用多态取代。但在施展以多态取代条件表达式(272)之前,我得先创建一个基本的继承结构。我需要先创建一个类,并将价格计算函数和观众量积分计算函数放进去。

我先从检查计算代码开始。(之前的重构带来的一大好处是,现在我大可以忽略那些格式化代码,只要不改变中转数据结构就行。我可以进一步添加测试来保证中转数据结构不会被意外修改。)

createStatementData.js...
export default function createStatementData(invoice, plays) {
  const result = {};
  result.customer = invoice.customer;
  result.performances = invoice.performances.map(enrichPerformance);
  result.totalAmount = totalAmount(result);
  result.totalVolumeCredits = totalVolumeCredits(result);
  return result;

  function enrichPerformance(aPerformance) {
    const result = Object.assign({}, aPerformance);
    result.play = playFor(result);
    result.amount = amountFor(result);
    result.volumeCredits = volumeCreditsFor(result);
    return result;
  }
  function playFor(aPerformance) {
    return plays[aPerformance.playID]
  }
  function amountFor(aPerformance) {
    let result = 0;
    switch (aPerformance.play.type) {
    case "tragedy":
      result = 40000;
      if (aPerformance.audience > 30) {
        result += 1000 * (aPerformance.audience - 30);
      }
      break;
    case "comedy":
      result = 30000;
      if (aPerformance.audience > 20) {
        result += 10000 + 500 * (aPerformance.audience - 20);
      }
      result += 300 * aPerformance.audience;
      break;
    default:
        throw new Error(`unknown type: ${aPerformance.play.type}`);
    }
    return result;
  }
  function volumeCreditsFor(aPerformance) {
    let result = 0;
    result += Math.max(aPerformance.audience - 30, 0);
    if ("comedy" === aPerformance.play.type) result += Math.floor(aPerformance.audience / 5);
    return result;
  }
  function totalAmount(data) {
    return data.performances
      .reduce((total, p) => total + p.amount, 0);
  }
  function totalVolumeCredits(data) {
    return data.performances
      .reduce((total, p) => total + p.volumeCredits, 0);
  }

enrichPerformance函数是关键所在,因为正是它用每场演出的数据来填充中转数据结构。目前它直接调用了计算价格和观众量积分的函数,我需要创建一个类,通过这个类来调用这些函数。由于这个类存放了与每场演出相关数据的计算函数,于是我把它称为演出计算器(performance calculator)。

function createStatementData...
function enrichPerformance(aPerformance) {
  const calculator = new PerformanceCalculator(aPerformance);
  const result = Object.assign({}, aPerformance);
  result.play = playFor(result);
  result.amount = amountFor(result);
  result.volumeCredits = volumeCreditsFor(result);
  return result;
}
顶层作用域...
class PerformanceCalculator {
  constructor(aPerformance) {
    this.performance = aPerformance;
  }
}

到目前为止,这个新对象还没做什么事。我希望将函数行为搬移进来,这可以从最容易搬移的东西——play字段开始。严格来讲,我不需要搬移这个字段,因为它并未体现出多态性,但这样可以把所有数据转换集中到一处地方,保证了代码的一致性和清晰度。

为此,我将使用改变函数声明(124)手法将performanceplay字段传给计算器。

function createStatementData...
function enrichPerformance(aPerformance) {
  const calculator = new PerformanceCalculator(aPerformance, playFor(aPerformance));
  const result = Object.assign({}, aPerformance);
  result.play = calculator.play;
  result.amount = amountFor(result);
  result.volumeCredits = volumeCreditsFor(result);
  return result;
}
class PerformanceCalculator...
class PerformanceCalculator { 
  constructor(aPerformance, aPlay) {
    this.performance = aPerformance;
    this.play = aPlay;
  }
}

(以下行文中我将不再特别提及“编译、测试、提交”循环,我猜你也已经读得有些厌烦了。但我仍会不断重复这个循环。的确,有时我也会厌烦,直到错误又跳出来咬我一下,我才又学会进入小步的节奏。)

我要搬移的下一块逻辑,对计算一场演出的价格(amount)来说就尤为重要了。在调整嵌套函数的层级时,我经常将函数挪来挪去,但接下来需要改动到更深入的函数上下文,因此我将小心使用搬移函数(198)来重构它。首先,将amount函数的逻辑复制一份到新的上下文中,也就是PerformanceCalculator类中。然后微调一下代码,将aPerformance改为this.performance,将playFor(aPerformance)改为this.play,使代码适应这个新家。

class PerformanceCalculator...
get amount() {
  let result = 0;
  switch (this.play.type) {
    case "tragedy":
      result = 40000;
      if (this.performance.audience > 30) {
        result += 1000 * (this.performance.audience - 30);
      }
      break;
    case "comedy":
      result = 30000;
      if (this.performance.audience > 20) {
        result += 10000 + 500 * (this.performance.audience - 20);
      }
      result += 300 * this.performance.audience;
      break;
    default:
      throw new Error(`unknown type: ${this.play.type}`);
  }
  return result;
}

搬移完成后可以编译一下,看看是否有编译错误。我在本地开发环境运行代码时,编译会自动发生,我实际需要做的只是运行一下Babel。编译能帮我发现新函数中潜在的语法错误,语法之外的就帮不上什么忙了。尽管如此,这一步还是很有用。

使新函数适应新家后,我会将原来的函数改造成一个委托函数,让它直接调用新函数。

function createStatementData...
function amountFor(aPerformance) {
  return new PerformanceCalculator(aPerformance, playFor(aPerformance)).amount;
}

现在,我可以执行一次编译、测试、提交,确保代码搬到新家后也能如常工作。之后,我应用内联函数(115),让引用点直接调用新函数(然后编译、测试、提交)。

function createStatementData...
function enrichPerformance(aPerformance) {
  const calculator = new PerformanceCalculator(aPerformance, playFor(aPerformance)); 
  const result = Object.assign({}, aPerformance);
  result.play = calculator.play; 
  result.amount = calculator.amount;
  result.volumeCredits = volumeCreditsFor(result); 
  return result;
}

搬移观众量积分计算也遵循同样的流程。

function createStatementData...
function enrichPerformance(aPerformance) {
  const calculator = new PerformanceCalculator(aPerformance, playFor(aPerformance));
  const result = Object.assign({}, aPerformance);
  result.play = calculator.play;
  result.amount = calculator.amount;
  result.volumeCredits = calculator.volumeCredits;
  return result;
}
class PerformanceCalculator...
get volumeCredits() {
  let result = 0;
  result += Math.max(this.performance.audience - 30, 0);
  if ("comedy" === this.play.type) result += Math.floor(this.performance.audience / 5);
  return result;
}

我已将全部计算逻辑搬移到一个类中,是时候将它多态化了。第一步是应用以子类取代类型码(362)引入子类,弃用类型代码。为此,我需要为演出计算器创建子类,并在createStatementData中获取对应的子类。要得到正确的子类,我需要将构造函数调用替换为一个普通的函数调用,因为JavaScript的构造函数里无法返回子类。于是我使用以工厂函数取代构造函数(334)

function createStatementData...
function enrichPerformance(aPerformance) {
  const calculator = createPerformanceCalculator(aPerformance, playFor(aPerformance));
  const result = Object.assign({}, aPerformance);
  result.play = calculator.play;
  result.amount = calculator.amount;
  result.volumeCredits = calculator.volumeCredits;
  return result;
}
顶层作用域...
function createPerformanceCalculator(aPerformance, aPlay) {
  return new PerformanceCalculator(aPerformance, aPlay);
}

改造成普通函数后,我就可以在里面创建演出计算器的子类,然后由创建函数决定返回哪一个子类的实例。

顶层作用域...
function createPerformanceCalculator(aPerformance, aPlay) {
    switch(aPlay.type) {
    case "tragedy": return new TragedyCalculator(aPerformance, aPlay);
    case "comedy" : return new ComedyCalculator(aPerformance, aPlay);
    default:
        throw new Error(`unknown type: ${aPlay.type}`);
    }
}

class TragedyCalculator extends PerformanceCalculator {
}
class ComedyCalculator extends PerformanceCalculator {
}

准备好实现多态的类结构后,我就可以继续使用以多态取代条件表达式(272)手法了。

我先从悲剧的价格计算逻辑开始搬移。

class TragedyCalculator...
get amount() {
  let result = 40000;
  if (this.performance.audience > 30) {
    result += 1000 * (this.performance.audience - 30);
  }
  return result;
}

虽说子类有了这个方法已足以覆盖超类对应的条件分支,但要是你也和我一样偏执,你也许还想在超类的分支上抛一个异常。

class PerformanceCalculator...
get amount() {
  let result = 0;
  switch (this.play.type) {
    case "tragedy":
      throw 'bad thing';
    case "comedy":
      result = 30000;
      if (this.performance.audience > 20) {
        result += 10000 + 500 * (this.performance.audience - 20);
      }
      result += 300 * this.performance.audience;
      break;
    default:
      throw new Error(`unknown type: ${this.play.type}`);
  }
  return result;
}

虽然我也可以直接删掉处理悲剧的分支,将错误留给默认分支去抛出,但我更喜欢显式地抛出异常——何况这行代码只能再活个几分钟了(这也是我直接抛出一个字符串而不用更好的错误对象的原因)。

再次进行编译、测试、提交。之后,将处理喜剧类型的分支也下移到子类中去。

class ComedyCalculator...
get amount() {
  let result = 30000;
  if (this.performance.audience > 20) {
    result += 10000 + 500 * (this.performance.audience - 20);
  }
  result += 300 * this.performance.audience;
  return result;
}

理论上讲,我可以将超类的amount方法一并移除了,反正它也不应再被调用到。但不删它,给未来的自己留点纪念品也是极好的,顺便可以提醒后来者记得实现这个函数。

class PerformanceCalculator...
get amount() {
  throw new Error('subclass responsibility');
}

下一个要替换的条件表达式是观众量积分的计算。我回顾了一下前面关于未来戏剧类型的讨论,发现大多数剧类在计算积分时都会检查观众数是否达到30,仅一小部分品类有所不同。因此,将更为通用的逻辑放到超类作为默认条件,出现特殊场景时按需覆盖它,听起来十分合理。于是我将一部分喜剧的逻辑下移到子类。

class PerformanceCalculator...
get volumeCredits() {
  return Math.max(this.performance.audience - 30, 0);
}
class ComedyCalculator...
get volumeCredits() {
  return super.volumeCredits + Math.floor(this.performance.audience / 5);
}

又到了观摩代码的时刻,让我们来看看,为计算器引入多态会对代码库有什么影响。

createStatementData.js
export default function createStatementData(invoice, plays) {
  const result = {};
  result.customer = invoice.customer;
  result.performances = invoice.performances.map(enrichPerformance);
  result.totalAmount = totalAmount(result);
  result.totalVolumeCredits = totalVolumeCredits(result);
  return result;

  function enrichPerformance(aPerformance) {
    const calculator = createPerformanceCalculator(aPerformance, playFor(aPerformance));
    const result = Object.assign({}, aPerformance);
    result.play = calculator.play;
    result.amount = calculator.amount;
    result.volumeCredits = calculator.volumeCredits;
    return result;
  }
  function playFor(aPerformance) {
    return plays[aPerformance.playID]
  }
  function totalAmount(data) {
    return data.performances
      .reduce((total, p) => total + p.amount, 0);
  }
  function totalVolumeCredits(data) {
    return data.performances
      .reduce((total, p) => total + p.volumeCredits, 0);
  }
}
function createPerformanceCalculator(aPerformance, aPlay) {
    switch(aPlay.type) {
    case "tragedy": return new TragedyCalculator(aPerformance, aPlay);
    case "comedy" : return new ComedyCalculator(aPerformance, aPlay);
    default:
        throw new Error(`unknown type: ${aPlay.type}`);
    }
}
class PerformanceCalculator {
  constructor(aPerformance, aPlay) {
    this.performance = aPerformance;
    this.play = aPlay;
  }
  get amount() {
    throw new Error('subclass responsibility');
  }
  get volumeCredits() {
    return Math.max(this.performance.audience - 30, 0);
  }
}
class TragedyCalculator extends PerformanceCalculator {
  get amount() {
    let result = 40000;
    if (this.performance.audience > 30) {
      result += 1000 * (this.performance.audience - 30);
    }
    return result;
  }
}
class ComedyCalculator extends PerformanceCalculator {
  get amount() {
    let result = 30000;
    if (this.performance.audience > 20) {
      result += 10000 + 500 * (this.performance.audience - 20);
    }
    result += 300 * this.performance.audience;
    return result;
  }
  get volumeCredits() {
    return super.volumeCredits + Math.floor(this.performance.audience / 5);
  }
}

代码量仍然有所增加,因为我再次整理了代码结构。新结构带来的好处是,不同戏剧种类的计算各自集中到了一处地方。如果大多数修改都涉及特定类型的计算,像这样按类型进行分离就很有意义。当添加新剧种时,只需要添加一个子类,并在创建函数中返回它。

这个示例还揭示了一些关于此类继承方案何时适用的洞见。上面我将条件分支的查找从两个不同的函数(amountForvolumeCreditsFor)搬移到一个集中的构造函数createPerformanceCalculator中。有越多的函数依赖于同一套类型进行多态,这种继承方案就越有益处。

除了这样设计,还有另一种可能的方案,那就是让createStatementData返回计算器实例本身,而非自己拿到计算器来填充中转数据结构。JavaScript的类设计有不少好特性,例如,取值函数用起来就像普通的数据存取。我在考量是“直接返回实例本身”还是“返回计算好的中转数据”时,主要看数据的使用者是谁。在这个例子中,我更想通过中转数据结构来展示如何以此隐藏计算器背后的多态设计。

这是一个简单的例子,但我希望它能让你对“重构怎么做”有一点感觉。例中我已经示范了数种重构手法,包括提炼函数(106)内联变量(123)搬移函数(198)以多态取代条件表达式(272)等。

本章的重构有3个较为重要的节点,分别是:将原函数分解成一组嵌套的函数、应用拆分阶段(154)分离计算逻辑与输出格式化逻辑,以及为计算器引入多态性来处理计算逻辑。每一步都给代码添加了更多的结构,以便我能更好地表达代码的意图。

一般来说,重构早期的主要动力是尝试理解代码如何工作。通常你需要先通读代码,找到一些感觉,然后再通过重构将这些感觉从脑海里搬回到代码中。清晰的代码更容易理解,使你能够发现更深层次的设计问题,从而形成积极正向的反馈环。当然,这个示例仍有值得改进的地方,但现在测试仍能全部通过,代码相比初见时已经有了巨大的改善,所以我已经可以满足了。

我谈论的是如何改善代码,但什么样的代码才算好代码,程序员们有很多争论。我偏爱小的、命名良好的函数,也知道有些人反对这个观点。如果我们说这只关乎美学,只是各花入各眼,没有好坏高低之分,那除了诉诸个人品味,就没有任何客观事实依据了。但我坚信,这不仅关乎个人品味,而且是有客观标准的。我认为,好代码的检验标准就是人们是否能轻而易举地修改它。好代码应该直截了当:有人需要修改代码时,他们应能轻易找到修改点,应该能快速做出更改,而不易引入其他错误。一个健康的代码库能够最大限度地提升我们的生产力,支持我们更快、更低成本地为用户添加新特性。为了保持代码库的健康,就需要时刻留意现状与理想之间的差距,然后通过重构不断接近这个理想。

 好代码的检验标准就是人们是否能轻而易举地修改它。

这个示例告诉我们最重要的一点就是重构的节奏感。无论何时,当我向人们展示我如何重构时,无人不讶异于我的步子之小,并且每一步都保证代码处于编译通过和测试通过的可工作状态。20年前,当Kent Beck在底特律的一家宾馆里向我展示同样的手法时,我也报以同样的震撼。开展高效有序的重构,关键的心得是:小的步子可以更快前进,请保持代码永远处于可工作状态,小步修改累积起来也能大大改善系统的设计。这几点请君牢记,其余的我已无需多言。


前一章所举的例子应该已经让你对重构有了一个良好的感觉。现在,我们应该回头看看重构的一些大原则。

一线的实践者们经常很随意地使用“重构”这个词——软件开发领域的很多词汇都有此待遇。我使用这个词的方式比较严谨,并且我发现这种严谨的方式很有好处。(下列定义与本书第1版中给出的定义一样。)“重构”这个词既可以用作名词也可以用作动词。名词形式的定义是:

重构(名词):对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。

这个定义适用于我在前面的例子中提到的那些有名字的重构,例如提炼函数(106)以多态取代条件表达式(272)

动词形式的定义是:

重构(动词):使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构。

所以,我可能会花一两个小时进行重构(动词),其间我会使用几十个不同的重构(名词)。

过去十几年,这个行业里的很多人用“重构”这个词来指代任何形式的代码清理,但上面的定义所指的是一种特定的清理代码的方式。重构的关键在于运用大量微小且保持软件行为的步骤,一步步达成大规模的修改。每个单独的重构要么很小,要么由若干小步骤组合而成。因此,在重构的过程中,我的代码很少进入不可工作的状态,即便重构没有完成,我也可以在任何时刻停下来。

 如果有人说他们的代码在重构过程中有一两天时间不可用,基本上可以确定,他们在做的事不是重构。

我会用“结构调整”(restructuring)来泛指对代码库进行的各种形式的重新组织或清理,重构则是特定的一类结构调整。刚接触重构的人看我用很多小步骤完成似乎可以一大步就能做完的事,可能会觉得这样很低效。但小步前进能让我走得更快,因为这些小步骤能完美地彼此组合,而且——更关键的是——整个过程中我不会花任何时间来调试。

在上述定义中,我用了“可观察行为”的说法。它的意思是,整体而言,经过重构之后的代码所做的事应该与重构之前大致一样。这个说法并非完全严格,并且我是故意保留这点儿空间的:重构之后的代码不一定与重构前行为完全一致。比如说,提炼函数(106)会改变函数调用栈,因此程序的性能就会有所改变;改变函数声明(124)搬移函数(198)等重构经常会改变模块的接口。不过就用户应该关心的行为而言,不应该有任何改变。如果我在重构过程中发现了任何bug,重构完成后同样的bug应该仍然存在(不过,如果潜在的bug还没有被任何人发现,也可以当即把它改掉)。

重构与性能优化有很多相似之处:两者都需要修改代码,并且两者都不会改变程序的整体功能。两者的差别在于其目的:重构是为了让代码“更容易理解,更易于修改”。这可能使程序运行得更快,也可能使程序运行得更慢。在性能优化时,我只关心让程序运行得更快,最终得到的代码有可能更难理解和维护,对此我有心理准备。

Kent Beck提出了“两顶帽子”的比喻。使用重构技术开发软件时,我把自己的时间分配给两种截然不同的行为:添加新功能和重构。添加新功能时,我不应该修改既有代码,只管添加新功能。通过添加测试并让测试正常运行,我可以衡量自己的工作进度。重构时我就不能再添加功能,只管调整代码的结构。此时我不应该添加任何测试(除非发现有先前遗漏的东西),只在绝对必要(用以处理接口变化)时才修改测试。

软件开发过程中,我可能会发现自己经常变换帽子。首先我会尝试添加新功能,然后会意识到:如果把程序结构改一下,功能的添加会容易得多。于是我换一顶帽子,做一会儿重构工作。程序结构调整好后,我又换上原先的帽子,继续添加新功能。新功能正常工作后,我又发现自己的编码造成程序难以理解,于是又换上重构帽子……整个过程或许只花10分钟,但无论何时我都清楚自己戴的是哪一顶帽子,并且明白不同的帽子对编程状态提出的不同要求。

我不想把重构说成是包治百病的万灵丹,它绝对不是所谓的“银弹”。不过它的确很有价值,尽管它不是一颗“银弹”,却可以算是一把“银钳子”,可以帮你始终良好地控制自己的代码。重构是一个工具,它可以(并且应该)用于以下几个目的。

如果没有重构,程序的内部设计(或者叫架构)会逐渐腐败变质。当人们只为短期目的而修改代码时,他们经常没有完全理解架构的整体设计,于是代码逐渐失去了自己的结构。程序员越来越难通过阅读源码来理解原来的设计。代码结构的流失有累积效应。越难看出代码所代表的设计意图,就越难保护其设计,于是设计就腐败得越快。经常性的重构有助于代码维持自己该有的形态。

完成同样一件事,设计欠佳的程序往往需要更多代码,这常常是因为代码在不同的地方使用完全相同的语句做同样的事,因此改进设计的一个重要方向就是消除重复代码。代码量减少并不会使系统运行更快,因为这对程序的资源占用几乎没有任何明显影响。然而代码量减少将使未来可能的程序修改动作容易得多。代码越多,做正确的修改就越困难,因为有更多代码需要理解。我在这里做了点儿修改,系统却不如预期那样工作,因为我没有修改另一处——那里的代码做着几乎完全一样的事情,只是所处环境略有不同。消除重复代码,我就可以确定所有事物和行为在代码中只表述一次,这正是优秀设计的根本。

所谓程序设计,很大程度上就是与计算机对话:我编写代码告诉计算机做什么事,而它的响应是按照我的指示精确行动。一言以蔽之,我所做的就是填补“我想要它做什么”和“我告诉它做什么”之间的缝隙。编程的核心就在于“准确说出我想要的”。然而别忘了,除了计算机外,源码还有其他读者:几个月之后可能会有另一位程序员尝试读懂我的代码并对其做一些修改。我们很容易忘记这这位读者,但他才是最重要的。计算机是否多花了几个时钟周期来编译,又有什么关系呢?如果一个程序员花费一周时间来修改某段代码,那才要命呢——如果他理解了我的代码,这个修改原本只需一小时。

问题在于,当我努力让程序运转的时候,我不会想到未来出现的那个开发者。是的,我们应该改变一下开发节奏,让代码变得更易于理解。重构可以帮我让代码更易读。开始进行重构前,代码可以正常运行,但结构不够理想。在重构上花一点点时间,就可以让代码更好地表达自己的意图——更清晰地说出我想要做的。

关于这一点,我没必要表现得多么无私。很多时候那个未来的开发者就是我自己。此时重构就显得尤其重要了。我是一个很懒惰的程序员,我的懒惰表现形式之一就是:总是记不住自己写过的代码。事实上,对于任何能够立刻查阅的东西,我都故意不去记它,因为我怕把自己的脑袋塞爆。我总是尽量把该记住的东西写进代码里,这样我就不必记住它了。这么一来,下班后我还可以喝上两杯Maudite啤酒,不必太担心它杀光我的脑细胞。

对代码的理解,可以帮我找到bug。我承认我不太擅长找bug。有些人只要盯着一大段代码就可以找出里面的bug,我不行。但我发现,如果对代码进行重构,我就可以深入理解代码的所作所为,并立即把新的理解反映在代码当中。搞清楚程序结构的同时,我也验证了自己所做的一些假设,于是想不把bug揪出来都难。

这让我想起了Kent Beck经常形容自己的一句话:“我不是一个特别好的程序员,我只是一个有着一些特别好的习惯的还不错的程序员。”重构能够帮助我更有效地写出健壮的代码。

最后,前面的一切都归结到了这一点:重构帮我更快速地开发程序。

听起来有点儿违反直觉。当我谈到重构时,人们很容易看出它能够提高质量。改善设计、提升可读性、减少bug,这些都能提高质量。但花在重构上的时间,难道不是在降低开发速度吗?

当我跟那些在一个系统上工作较长时间的软件开发者交谈时,经常会听到这样的故事:一开始他们进展很快,但如今想要添加一个新功能需要的时间就要长得多。他们需要花越来越多的时间去考虑如何把新功能塞进现有的代码库,不断蹦出来的bug修复起来也越来越慢。代码库看起来就像补丁摞补丁,需要细致的考古工作才能弄明白整个系统是如何工作的。这份负担不断拖慢新增功能的速度,到最后程序员恨不得从头开始重写整个系统。

下面这幅图可以描绘他们经历的困境。

但有些团队的境遇则截然不同。他们添加新功能的速度越来越快,因为他们能利用已有的功能,基于已有的功能快速构建新功能。

两种团队的区别就在于软件的内部质量。需要添加新功能时,内部质量良好的软件让我可以很容易找到在哪里修改、如何修改。良好的模块划分使我只需要理解代码库的一小部分,就可以做出修改。如果代码很清晰,我引入bug的可能性就会变小,即使引入了bug,调试也会容易得多。理想情况下,我的代码库会逐步演化成一个平台,在其上可以很容易地构造与其领域相关的新功能。

我把这种现象称为“设计耐久性假说”:通过投入精力改善内部设计,我们增加了软件的耐久性,从而可以更长时间地保持开发的快速。我还无法科学地证明这个理论,所以我说它是一个“假说”。但我的经验,以及我在职业生涯中认识的上百名优秀程序员的经验,都支持这个假说。

20年前,行业的陈规认为:良好的设计必须在开始编程之前完成,因为一旦开始编写代码,设计就只会逐渐腐败。重构改变了这个图景。现在我们可以改善已有代码的设计,因此我们可以先做一个设计,然后不断改善它,哪怕程序本身的功能也在不断发生着变化。由于预先做出良好的设计非常困难,想要既体面又快速地开发功能,重构必不可少。

在我编程的每个小时,我都会做重构。有几种方式可以把重构融入我的工作过程里。

三次法则

 

Don Roberts给了我一条准则:第一次做某件事时只管去做;第二次做类似的事会产生反感,但无论如何还是可以去做;第三次再做类似的事,你就应该重构。

正如老话说的:事不过三,三则重构。

重构的最佳时机就在添加新功能之前。在动手添加新功能之前,我会看看现有的代码库,此时经常会发现:如果对代码结构做一点微调,我的工作会容易得多。也许已经有个函数提供了我需要的大部分功能,但有几个字面量的值与我的需要略有冲突。如果不做重构,我可能会把整个函数复制过来,修改这几个值,但这就会导致重复代码——如果将来我需要做修改,就必须同时修改两处(更麻烦的是,我得先找到这两处)。而且,如果将来我还需要一个类似又略有不同的功能,就只能再复制粘贴一次,这可不是个好主意。所以我戴上重构的帽子,使用函数参数化(310)。做完这件事以后,接下来我就只需要调用这个函数,传入我需要的参数。

这就好像我要往东去100公里。我不会往东一头把车开进树林,而是先往北开20公里上高速,然后再向东开100公里。后者的速度比前者要快上3倍。如果有人催着你“赶快直接去那儿”,有时你需要说:“等等,我要先看看地图,找出最快的路径。”这就是预备性重构于我的意义。

——Jessica Kerr

修复bug时的情况也是一样。在寻找问题根因时,我可能会发现:如果把3段一模一样且都会导致错误的代码合并到一处,问题修复起来会容易得多。或者,如果把某些更新数据的逻辑与查询逻辑分开,会更容易避免造成错误的逻辑纠缠。用重构改善这些情况,在同样场合再次出现同样bug的概率也会降低。

我需要先理解代码在做什么,然后才能着手修改。这段代码可能是我写的,也可能是别人写的。一旦我需要思考“这段代码到底在做什么”,我就会自问:能不能重构这段代码,令其一目了然?我可能看见了一段结构糟糕的条件逻辑,也可能希望复用一个函数,但花费了几分钟才弄懂它到底在做什么,因为它的函数命名实在是太糟糕了。这些都是重构的机会。

看代码时,我会在脑海里形成一些理解,但我的记性不好,记不住那么多细节。正如Ward Cunningham所说,通过重构,我就把脑子里的理解转移到了代码本身。随后我运行这个软件,看它是否正常工作,来检查这些理解是否正确。如果把对代码的理解植入代码中,这份知识会保存得更久,并且我的同事也能看到。

重构带来的帮助不仅发生在将来——常常是立竿见影。我会先在一些小细节上使用重构来帮助理解,给一两个变量改名,让它们更清楚地表达意图,以方便理解,或是将一个长函数拆成几个小函数。当代码变得更清晰一些时,我就会看见之前看不见的设计问题。如果不做前面的重构,我可能永远都看不见这些设计问题,因为我不够聪明,无法在脑海中推演所有这些变化。Ralph Johnson说,这些初步的重构就像扫去窗上的尘埃,使我们得以看到窗外的风景。在研读代码时,重构会引领我获得更高层面的理解,如果只是阅读代码很难有此领悟。有些人以为这些重构只是毫无意义地把玩代码,他们没有意识到,缺少了这些细微的整理,他们就无法看到隐藏在一片混乱背后的机遇。

帮助理解的重构还有一个变体:我已经理解代码在做什么,但发现它做得不好,例如逻辑不必要地迂回复杂,或者两个函数几乎完全相同,可以用一个参数化的函数取而代之。这里有一个取舍:我不想从眼下正要完成的任务上跑题太多,但我也不想把垃圾留在原地,给将来的修改增加麻烦。如果我发现的垃圾很容易重构,我会马上重构它;如果重构需要花一些精力,我可能会拿一张便笺纸把它记下来,完成当下的任务再回来重构它。

当然,有时这样的垃圾需要好几个小时才能解决,而我又有更紧急的事要完成。不过即便如此,稍微花一点工夫做一点儿清理,通常都是值得的。正如野营者的老话所说:至少要让营地比你到达时更干净。如果每次经过这段代码时都把它变好一点点,积少成多,垃圾总会被处理干净。重构的妙处就在于,每个小步骤都不会破坏代码——所以,有时一块垃圾在好几个月之后才终于清理干净,但即便每次清理并不完整,代码也不会被破坏。

上面的例子——预备性重构、帮助理解的重构、捡垃圾式重构——都是见机行事的:我并不专门安排一段时间来重构,而是在添加功能或修复bug的同时顺便重构。这是我自然的编程流的一部分。不管是要添加功能还是修复bug,重构对我当下的任务有帮助,而且让我未来的工作更轻松。这是一件很重要而又常被误解的事:重构不是与编程割裂的行为。你不会专门安排时间重构,正如你不会专门安排时间写if语句。我的项目计划上没有专门留给重构的时间,绝大多数重构都在我做其他事的过程中自然发生。

 肮脏的代码必须重构,但漂亮的代码也需要很多重构。

还有一种常见的误解认为,重构就是人们弥补过去的错误或者清理肮脏的代码。当然,如果遇上了肮脏的代码,你必须重构,但漂亮的代码也需要很多重构。在写代码时,我会做出很多权衡取舍:参数化需要做到什么程度?函数之间的边界应该划在哪里?对于昨天的功能完全合理的权衡,在今天要添加新功能时可能就不再合理。好在,当我需要改变这些权衡以反映现实情况的变化时,整洁的代码重构起来会更容易。

每次要修改时,首先令修改很容易(警告:这件事有时会很难),然后再进行这次容易的修改。

——Kent Beck

长久以来,人们认为编写软件是一个累加的过程:要添加新功能,我们就应该增加新代码。但优秀的程序员知道,添加新功能最快的方法往往是先修改现有的代码,使新功能容易被加入。所以,软件永远不应该被视为“完成”。每当需要新能力时,软件就应该做出相应的改变。越是在已有代码中,这样的改变就越显重要。

不过,说了这么多,并不表示有计划的重构总是错的。如果团队过去忽视了重构,那么常常会需要专门花一些时间来优化代码库,以便更容易添加新功能。在重构上花一个星期的时间,会在未来几个月里发挥价值。有时,即便团队做了日常的重构,还是会有问题在某个区域逐渐累积长大,最终需要专门花些时间来解决。但这种有计划的重构应该很少,大部分重构应该是不起眼的、见机行事的。

我听过的一条建议是:将重构与添加新功能在版本控制的提交中分开。这样做的一大好处是可以各自独立地审阅和批准这些提交。但我并不认同这种做法。重构常常与新添功能紧密交织,不值得花工夫把它们分开。并且这样做也使重构脱离了上下文,使人看不出这些“重构提交”的价值。每个团队应该尝试并找出适合自己的工作方式,只是要记住:分离重构提交并不是毋庸置疑的原则,只有当你真的感到有益时,才值得这样做。

大多数重构可以在几分钟——最多几小时——内完成。但有一些大型的重构可能要花上几个星期,例如要替换一个正在使用的库,或者将整块代码抽取到一个组件中并共享给另一支团队使用,再或者要处理一大堆混乱的依赖关系,等等。

即便在这样的情况下,我仍然不愿让一支团队专门做重构。可以让整个团队达成共识,在未来几周时间里逐步解决这个问题,这经常是一个有效的策略。每当有人靠近“重构区”的代码,就把它朝想要改进的方向推动一点。这个策略的好处在于,重构不会破坏代码——每次小改动之后,整个系统仍然照常工作。例如,如果想替换掉一个正在使用的库,可以先引入一层新的抽象,使其兼容新旧两个库的接口。一旦调用方已经完全改为使用这层抽象,替换下面的库就会容易得多。(这个策略叫作Branch By Abstraction[mf-bba]。)

一些公司会做常规的代码复审(code review),因为这种活动可以改善开发状况。代码复审有助于在开发团队中传播知识,也有助于让较有经验的开发者把知识传递给比较欠缺经验的人,并帮助更多人理解大型软件系统中的更多部分。代码复审对于编写清晰代码也很重要。我的代码也许对我自己来说很清晰,对他人则不然。这是无法避免的,因为要让开发者设身处地为那些不熟悉自己所作所为的人着想,实在太困难了。代码复审也让更多人有机会提出有用的建议,毕竟我在一个星期之内能够想出的好点子很有限。如果能得到别人的帮助,我的生活会滋润得多,所以我总是期待更多复审。

我发现,重构可以帮助我复审别人的代码。开始重构前我可以先阅读代码,得到一定程度的理解,并提出一些建议。一旦想到一些点子,我就会考虑是否可以通过重构立即轻松地实现它们。如果可以,我就会动手。这样做了几次以后,我可以更清楚地看到,当我的建议被实施以后,代码会是什么样。我不必想象代码应该是什么样,我可以真实看见。于是我可以获得更高层次的认识。如果不进行重构,我永远无法得到这样的认识。

重构还可以帮助代码复审工作得到更具体的结果。不仅获得建议,而且其中许多建议能够立刻实现。最终你将从实践中得到比以往多得多的成就感。

至于如何在代码复审的过程中加入重构,这要取决于复审的形式。在常见的pull request模式下,复审者独自浏览代码,代码的作者不在旁边,此时进行重构效果并不好。如果代码的原作者在旁边会好很多,因为作者能提供关于代码的上下文信息,并且充分认同复审者进行修改的意图。对我个人而言,与原作者肩并肩坐在一起,一边浏览代码一边重构,体验是最佳的。这种工作方式很自然地导向结对编程:在编程的过程中持续不断地进行代码复审。

“该怎么跟经理说重构的事?”这是我最常被问到的一个问题。毋庸讳言,我见过一些场合,“重构”被视为一个脏词——经理(和客户)认为重构要么是在弥补过去犯下的错误,要么是不增加价值的无用功。如果团队又计划了几周时间专门做重构,情况就更糟糕了——如果他们做的其实还不是重构,而是不加小心的结构调整,然后又对代码库造成了破坏,那可就真是糟透了。

如果这位经理懂技术,能理解“设计耐久性假说”,那么向他说明重构的意义应该不会很困难。这样的经理应该会鼓励日常的重构,并主动寻找团队日常重构做得不够的征兆。虽然“团队做了太多重构”的情况确实也发生过,但比起做得不够的情况要罕见得多了。

当然,很多经理和客户不具备这样的技术意识,他们不理解代码库的健康对生产率的影响。这种情况下我会给团队一个较有争议的建议:不要告诉经理!

这是在搞破坏吗?我不这样想。软件开发者都是专业人士。我们的工作就是尽可能快速创造出高效软件。我的经验告诉我,对于快速创造软件,重构可带来巨大帮助。如果需要添加新功能,而原本设计却又使我无法方便地修改,我发现先重构再添加新功能会更快些。如果要修补错误,就得先理解软件的工作方式,而我发现重构是理解软件的最快方式。受进度驱动的经理要我尽可能快速完成任务,至于怎么完成,那就是我的事了。我领这份工资,是因为我擅长快速实现新功能;我认为最快的方式就是重构,所以我就重构。

听起来好像我一直在提倡重构,但确实有一些不值得重构的情况。

如果我看见一块凌乱的代码,但并不需要修改它,那么我就不需要重构它。如果丑陋的代码能被隐藏在一个API之下,我就可以容忍它继续保持丑陋。只有当我需要理解其工作原理时,对其进行重构才有价值。

另一种情况是,如果重写比重构还容易,就别重构了。这是个困难的决定。如果不花一点儿时间尝试,往往很难真实了解重构一块代码的难度。决定到底应该重构还是重写,需要良好的判断力与丰富的经验,我无法给出一条简单的建议。

每当有人大力推荐一种技术、工具或者架构时,我总是会观察这东西会遇到哪些挑战,毕竟生活中很少有晴空万里的好事。你需要了解一件事背后的权衡取舍,才能决定何时何地应用它。我认为重构是一种很有价值的技术,大多数团队都应该更多地重构,但它也不是完全没有挑战的。有必要充分了解重构会遇到的挑战,这样才能做出有效应对。

如果你读了前面一小节,我对这个挑战的回应便已经很清楚了。尽管重构的目的是加快开发速度,但是,仍旧很多人认为,花在重构的时间是在拖慢新功能的开发进度。“重构会拖慢进度”这种看法仍然很普遍,这可能是导致人们没有充分重构的最大阻力所在。

 重构的唯一目的就是让我们开发更快,用更少的工作量创造更大的价值。

有一种情况确实需要权衡取舍。我有时会看到一个(大规模的)重构很有必要进行,而马上要添加的功能非常小,这时我会更愿意先把新功能加上,然后再做这次大规模重构。做这个决定需要判断力——这是我作为程序员的专业能力之一。我很难描述决定的过程,更无法量化决定的依据。

我清楚地知道,预备性重构常会使修改更容易,所以如果做一点儿重构能让新功能实现更容易,我一定会做。如果一个问题我已经见过,此时我也会更倾向于重构它——有时我就得先看见一块丑陋的代码几次,然后才能提起劲头来重构它。也就是说,如果一块代码我很少触碰,它不会经常给我带来麻烦,那么我就倾向于不去重构它。如果我还没想清楚究竟应该如何优化代码,那么我可能会延迟重构;当然,有的时候,即便没想清楚优化的方向,我也会先做些实验,试试看能否有所改进。

我从同事那里听到的证据表明,在我们这个行业里,重构不足的情况远多于重构过度的情况。换句话说,绝大多数人应该尝试多做重构。代码库的健康与否,到底会对生产率造成多大的影响,很多人可能说不出来,因为他们没有太多在健康的代码库上工作的经历——轻松地把现有代码组合配置,快速构造出复杂的新功能,这种强大的开发方式他们没有体验过。

虽然我们经常批评管理者以“保障开发速度”的名义压制重构,其实程序员自己也经常这么干。有时他们自己觉得不应该重构,其实他们的领导还挺希望他们做一些重构的。如果你是一支团队的技术领导,一定要向团队成员表明,你重视改善代码库健康的价值。合理判断何时应该重构、何时应该暂时不重构,这样的判断力需要多年经验积累。对于重构缺乏经验的年轻人需要有意的指导,才能帮助他们加速经验积累的过程。

有些人试图用“整洁的代码”“良好的工程实践”之类道德理由来论证重构的必要性,我认为这是个陷阱。重构的意义不在于把代码库打磨得闪闪发光,而是纯粹经济角度出发的考量。我们之所以重构,因为它能让我们更快——添加功能更快,修复bug更快。一定要随时记住这一点,与别人交流时也要不断强调这一点。重构应该总是由经济利益驱动。程序员、经理和客户越理解这一点,“好的设计”那条曲线就会越经常出现。

很多重构手法不仅会影响一个模块内部,还会影响该模块与系统其他部分的关系。比如我想给一个函数改名,并且我也能找到该函数的所有调用者,那么我只需运用改变函数声明(124),在一次重构中修改函数声明和调用者。但即便这么简单的一个重构,有时也无法实施:调用方代码可能由另一支团队拥有,而我没有权限写入他们的代码库;这个函数可能是一个提供给客户的API,这时我根本无法知道是否有人使用它,至于谁在用、用得有多频繁就更是一无所知。这样的函数属于已发布接口(published interface):接口的使用者(客户端)与声明者彼此独立,声明者无权修改使用者的代码。

代码所有权的边界会妨碍重构,因为一旦我自作主张地修改,就一定会破坏使用者的程序。这不会完全阻止重构,我仍然可以做很多重构,但确实会对重构造成约束。为了给一个函数改名,我需要使用函数改名(124),但同时也得保留原来的函数声明,使其把调用传递给新的函数。这会让接口变复杂,但这就是为了避免破坏使用者的系统而不得不付出的代价。我可以把旧的接口标记为“不推荐使用”(deprecated),等一段时间之后最终让其退休;但有些时候,旧的接口必须一直保留下去。

由于这些复杂性,我建议不要搞细粒度的强代码所有制。有些组织喜欢给每段代码都指定唯一的所有者,只有这个人能修改这段代码。我曾经见过一支只有三个人的团队以这种方式运作,每个程序员都要给另外两人发布接口,随之而来的就是接口维护的种种麻烦。如果这三个人都直接去代码库里做修改,事情会简单得多。我推荐团队代码所有制,这样一支团队里的成员都可以修改这个团队拥有的代码,即便最初写代码的是别人。程序员可能各自分工负责系统的不同区域,但这种责任应该体现为监控自己责任区内发生的修改,而不是简单粗暴地禁止别人修改。

这种较为宽容的代码所有制甚至可以应用于跨团队的场合。有些团队鼓励类似于开源的模型:B团队的成员也可以在一个分支上修改A团队的代码,然后把提交发送给A团队去审核。这样一来,如果团队想修改自己的函数,他们就可以同时修改该函数的客户端的代码;只要客户端接受了他们的修改,就可以删掉旧的函数声明了。对于涉及多个团队的大系统开发,在“强代码所有制”和“混乱修改”两个极端之间,这种类似开源的模式常常是一个合适的折中。

很多团队采用这样的版本控制实践:每个团队成员各自在代码库的一条分支上工作,进行相当大量的开发之后,才把各自的修改合并回主线分支(这条分支通常叫master或trunk),从而与整个团队分享。常见的做法是在分支上开发完整的功能,直到功能可以发布到生产环境,才把该分支合并回主线。这种做法的拥趸声称,这样能保持主线不受尚未完成的代码侵扰,能保留清晰的功能添加的版本记录,并且在某个功能出问题时能容易地撤销修改。

这样的特性分支有其缺点。在隔离的分支上工作得越久,将完成的工作集成(integrate)回主线就会越困难。为了减轻集成的痛苦,大多数人的办法是频繁地从主线合并(merge)或者变基(rebase)到分支。但如果有几个人同时在各自的特性分支上工作,这个办法并不能真正解决问题,因为合并与集成是两回事。如果我从主线合并到我的分支,这只是一个单向的代码移动——我的分支发生了修改,但主线并没有。而“集成”是一个双向的过程:不仅要把主线的修改拉(pull)到我的分支上,而且要把我这里修改的结果推(push)回到主线上,两边都会发生修改。假如另一名程序员Rachel正在她的分支上开发,我是看不见她的修改的,直到她将自己的修改与主线集成;此时我就必须把她的修改合并到我的特性分支,这可能需要相当的工作量。其中困难的部分是处理语义变化。现代版本控制系统都能很好地合并程序文本的复杂修改,但对于代码的语义它们一无所知。如果我修改了一个函数的名字,版本控制工具可以很轻松地将我的修改与Rachel的代码集成。但如果在集成之前,她在自己的分支里新添调用了这个被我改名的函数,集成之后的代码就会被破坏。

分支合并本来就是一个复杂的问题,随着特性分支存在的时间加长,合并的难度会指数上升。集成一个已经存在了4个星期的分支,较之集成存在了2个星期的分支,难度可不止翻倍。所以很多人认为,应该尽量缩短特性分支的生存周期,比如只有一两天。还有一些人(比如我本人)认为特性分支的生命还应该更短,我们采用的方法叫作持续集成(Continuous Integration,CI),也叫“基于主干开发”(Trunk-Based Development)。在使用CI时,每个团队成员每天至少向主线集成一次。这个实践避免了任何分支彼此差异太大,从而极大地降低了合并的难度。不过CI也有其代价:你必须使用相关的实践以确保主线随时处于健康状态,必须学会将大功能拆分成小块,还必须使用特性开关(feature toggle,也叫特性旗标,feature flag)将尚未完成又无法拆小的功能隐藏掉。

CI的粉丝之所以喜欢这种工作方式,部分原因是它降低了分支合并的难度,不过最重要的原因还是CI与重构能良好配合。重构经常需要对代码库中的很多地方做很小的修改(例如给一个广泛使用的函数改名),这样的修改尤其容易造成合并时的语义冲突。采用特性分支的团队常会发现重构加剧了分支合并的困难,并因此放弃了重构,这种情况我们曾经见过多次。CI和重构能够良好配合,所以Kent Beck在极限编程中同时包含了这两个实践。

我并不是在说绝不应该使用特性分支。如果特性分支存在的时间足够短,它们就不会造成大问题。(实际上,使用CI的团队往往同时也使用分支,但他们会每天将分支与主线合并。)对于开源项目,特性分支可能是合适的做法,因为不时会有你不熟悉(因此也不信任)的程序员偶尔提交修改。但对全职的开发团队而言,特性分支对重构的阻碍太严重了。即便你没有完全采用CI,我也一定会催促你尽可能频繁地集成。而且,用上CI的团队在软件交付上更加高效,我真心希望你认真考虑这个客观事实[Forsgren et al]。

不会改变程序可观察的行为,这是重构的一个重要特征。如果仔细遵循重构手法的每个步骤,我应该不会破坏任何东西,但万一我犯了个错误怎么办?(呃,就我这个粗心大意的性格来说,请去掉“万一”两字。)人总会有出错的时候,不过只要及时发现,就不会造成大问题。既然每个重构都是很小的修改,即便真的造成了破坏,我也只需要检查最后一步的小修改——就算找不到出错的原因,只要回滚到版本控制中最后一个可用的版本就行了。

这里的关键就在于“快速发现错误”。要做到这一点,我的代码应该有一套完备的测试套件,并且运行速度要快,否则我会不愿意频繁运行它。也就是说,绝大多数情况下,如果想要重构,我得先有可以自测试的代码[mf-stc]。

有些读者可能会觉得,“自测试的代码”这个要求太高,根本无法实现。但在过去20年中,我看到很多团队以这种方式构造软件。的确,团队必须投入时间与精力在测试上,但收益是绝对划算的。自测试的代码不仅使重构成为可能,而且使添加新功能更加安全,因为我可以很快发现并干掉新近引入的bug。这里的关键在于,一旦测试失败,我只需要查看上次测试成功运行之后修改的这部分代码;如果测试运行得很频繁,这个查看的范围就只有几行代码。知道必定是这几行代码造成bug的话,排查起来会容易得多。

这也回答了“重构风险太大,可能引入bug”的担忧。如果没有自测试的代码,这种担忧就是完全合理的,这也是为什么我如此重视可靠的测试。

缺乏测试的问题可以用另一种方式来解决。如果我的开发环境很好地支持自动化重构,我就可以信任这些重构,不必运行测试。这时即便没有完备的测试套件,我仍然可以重构,前提是仅仅使用那些自动化的、一定安全的重构手法。这会让我损失很多好用的重构手法,不过剩下可用的也不少,我还是能从中获益。当然,我还是更愿意有自测试的代码,但如果没有,自动化重构的工具包也很好。

缺乏测试的现状还催生了另一种重构的流派:只使用一组经过验证是安全的重构手法。这个流派要求严格遵循重构的每个步骤,并且可用的重构手法是特定于语言的。使用这种方法,团队得以在测试覆盖率很低的大型代码库上开展一些有用的重构。这个重构流派比较新,涉及一些很具体、特定于编程语言的技巧与做法,行业里对这种方法的介绍和了解都还不足,因此本书不对其多做介绍。(不过我希望未来在我自己的网站上多讨论这个主题。感兴趣的读者可以查看Jay Bazuzi关于如何在C++中安全地运用提炼函数(106)的描述[Bazuzi],借此获得一点儿对这个重构流派的了解。)

毫不意外,自测试代码与持续集成紧密相关——我们仰赖持续集成来及时捕获分支集成时的语义冲突。自测试代码是极限编程的另一个重要组成部分,也是持续交付的关键环节。

大多数人会觉得,有一大笔遗产是件好事,但从程序员的角度来看就不同了。遗留代码往往很复杂,测试又不足,而且最关键的是,是别人写的(瑟瑟发抖)。

重构可以很好地帮助我们理解遗留系统。引人误解的函数名可以改名,使其更好地反映代码用途;糟糕的程序结构可以慢慢理顺,把程序从一块顽石打磨成美玉。整个故事都很棒,但我们绕不开关底的恶龙:遗留系统多半没测试。如果你面对一个庞大而又缺乏测试的遗留系统,很难安全地重构清理它。

对于这个问题,显而易见的答案是“没测试就加测试”。这事听起来简单(当然工作量必定很大),操作起来可没那么容易。一般来说,只有在设计系统时就考虑到了测试,这样的系统才容易添加测试——可要是如此,系统早该有测试了,我也不用操这份心了。

这个问题没有简单的解决办法,我能给出的最好建议就是买一本《修改代码的艺术》[Feathers],照书里的指导来做。别担心那本书太老,尽管已经出版十多年,其中的建议仍然管用。一言以蔽之,它建议你先找到程序的接缝,在接缝处插入测试,如此将系统置于测试覆盖之下。你需要运用重构手法创造出接缝——这样的重构很危险,因为没有测试覆盖,但这是为了取得进展必要的风险。在这种情况下,安全的自动化重构简直就是天赐福音。如果这一切听起来很困难,因为它确实很困难。很遗憾,一旦跌进这个深坑,没有爬出来的捷径,这也是我强烈倡导从一开始就写能自测试的代码的原因。

就算有了测试,我也不建议你尝试一鼓作气把复杂而混乱的遗留代码重构成漂亮的代码。我更愿意随时重构相关的代码:每次触碰一块代码时,我会尝试把它变好一点点——至少要让营地比我到达时更干净。如果是一个大系统,越是频繁使用的代码,改善其可理解性的努力就能得到越丰厚的回报。

在本书的第1版中,我说过数据库是“重构经常出问题的一个领域”。然而在第1版问世之后仅仅一年,情况就发生了改变:我的同事Pramod Sadalage发展出一套渐进式数据库设计[mf-evodb]和数据库重构[Ambler & Sadalage]的办法,如今已经被广泛使用。这项技术的精要在于:借助数据迁移脚本,将数据库结构的修改与代码相结合,使大规模的、涉及数据库的修改可以比较容易地开展。

假设我们要对一个数据库字段(列)改名。和改变函数声明(124)一样,我要找出结构的声明处和所有调用处,然后一次完成所有修改。但这里的复杂之处在于,原来基于旧字段的数据,也要转为使用新字段。我会写一小段代码来执行数据转化的逻辑,并把这段代码放进版本控制,跟数据结构声明与使用代码的修改一并提交。此后如果我想把数据库迁移到某个版本,只要执行当前数据库版本与目标版本之间的所有迁移脚本即可。

跟通常的重构一样,数据库重构的关键也是小步修改并且每次修改都应该完整,这样每次迁移之后系统仍然能运行。由于每次迁移涉及的修改都很小,写起来应该容易;将多个迁移串联起来,就能对数据库结构及其中存储的数据做很大的调整。

与常规的重构不同,很多时候,数据库重构最好是分散到多次生产发布来完成,这样即便某次修改在生产数据库上造成了问题,也比较容易回滚。比如,要改名一个字段,我的第一次提交会新添一个字段,但暂时不使用它。然后我会修改数据写入的逻辑,使其同时写入新旧两个字段。随后我就可以修改读取数据的地方,将它们逐个改为使用新字段。这步修改完成之后,我会暂停一小段时间,看看是否有bug冒出来。确定没有bug之后,我再删除已经没人使用的旧字段。这种修改数据库的方式是并行修改(Parallel Change,也叫扩展协议/expand-contract)[mf-pc]的一个实例。

重构极大地改变了人们考虑软件架构的方式。在我的职业生涯早期,我被告知:在任何人开始写代码之前,必须先完成软件的设计和架构。一旦代码写出来,架构就固定了,只会因为程序员的草率对待而逐渐腐败。

重构改变了这种观点。有了重构技术,即便是已经在生产环境中运行了多年的软件,我们也有能力大幅度修改其架构。正如本书的副标题所指出的,重构可以改善既有代码的设计。但我在前面也提到了,修改遗留代码经常很有挑战,尤其当遗留代码缺乏恰当的测试时。

重构对架构最大的影响在于,通过重构,我们能得到一个设计良好的代码库,使其能够优雅地应对不断变化的需求。“在编码之前先完成架构”这种做法最大的问题在于,它假设了软件的需求可以预先充分理解。但经验显示,这个假设很多时候甚至可以说大多数时候是不切实际的。只有真正使用了软件、看到了软件对工作的影响,人们才会想明白自己到底需要什么,这样的例子不胜枚举。

应对未来变化的办法之一,就是在软件里植入灵活性机制。在编写一个函数时,我会考虑它是否有更通用的用途。为了应对我预期的应用场景,我预测可以给这个函数加上十多个参数。这些参数就是灵活性机制——跟大多数“机制”一样,它不是免费午餐。把所有这些参数都加上的话,函数在当前的使用场景下就会非常复杂。另外,如果我少考虑了一个参数,已经加上的这一堆参数会使新添参数更麻烦。而且我经常会把灵活性机制弄错——可能是未来的需求变更并非以我期望的方式发生,也可能我对机制的设计不好。考虑到所有这些因素,很多时候这些灵活性机制反而拖慢了我响应变化的速度。

有了重构技术,我就可以采取不同的策略。与其猜测未来需要哪些灵活性、需要什么机制来提供灵活性,我更愿意只根据当前的需求来构造软件,同时把软件的设计质量做得很高。随着对用户需求的理解加深,我会对架构进行重构,使其能够应对新的需要。如果一种灵活性机制不会增加复杂度(比如添加几个命名良好的小函数),我可以很开心地引入它;但如果一种灵活性会增加软件复杂度,就必须先证明自己值得被引入。如果不同的调用者不会传入不同的参数值,那么就不要添加这个参数。当真的需要添加这个参数时,运用函数参数化(310)也很容易。要判断是否应该为未来的变化添加灵活性,我会评估“如果以后再重构有多困难”,只有当未来重构会很困难时,我才考虑现在就添加灵活性机制。我发现这是一个很有用的决策方法。

这种设计方法有很多名字:简单设计、增量式设计或者YAGNI[mf-yagni]——“你不会需要它”(you arenʼt going to need it)的缩写。YAGNI并不是“不做架构性思考”的意思,不过确实有人以这种欠考虑的方式做事。我把YAGNI视为将架构、设计与开发过程融合的一种工作方式,这种工作方式必须有重构作为基础才可靠。

采用YAGNI并不表示完全不用预先考虑架构。总有一些时候,如果缺少预先的思考,重构会难以开展。但两者之间的平衡点已经发生了很大的改变:如今我更倾向于等一等,待到对问题理解更充分,再来着手解决。演进式架构[Ford et al.]是一门仍在不断发展的学科,架构师们在不断探索有用的模式和实践,充分发挥迭代式架构决策的能力。

读完前面“重构的挑战”一节,你大概已经有这个印象:重构是否有效,与团队采用的其他软件开发实践紧密相关。重构起初是作为极限编程(XP)[mf-xp]的一部分被人们采用的,XP本身就融合了一组不太常见而又彼此关联的实践,例如持续集成、自测试代码以及重构(后两者融汇成了测试驱动开发)。

极限编程是最早的敏捷软件开发方法[mf-nm]之一。在一段历史时期,极限编程引领了敏捷的崛起。如今已经有很多项目使用敏捷方法,甚至敏捷的思维已经被视为主流,但实际上大部分“敏捷”项目只是徒有其名。要真正以敏捷的方式运作项目,团队成员必须在重构上有能力、有热情,他们采用的开发过程必须与常规的、持续的重构相匹配。

重构的第一块基石是自测试代码。我应该有一套自动化的测试,我可以频繁地运行它们,并且我有信心:如果我在编程过程中犯了任何错误,会有测试失败。这块基石如此重要,我会专门用一章篇幅来讨论它。

如果一支团队想要重构,那么每个团队成员都需要掌握重构技能,能在需要时开展重构,而不会干扰其他人的工作。这也是我鼓励持续集成的原因:有了CI,每个成员的重构都能快速分享给其他同事,不会发生这边在调用一个接口那边却已把这个接口删掉的情况;如果一次重构会影响别人的工作,我们很快就会知道。自测试的代码也是持续集成的关键环节,所以这三大实践——自测试代码、持续集成、重构——彼此之间有着很强的协同效应。

有这三大实践在手,我们就能运用前一节介绍的YAGNI设计方法。重构和YAGNI交相呼应、彼此增效,重构(及其前置实践)是YAGNI的基础,YAGNI又让重构更易于开展:比起一个塞满了想当然的灵活性的系统,当然是修改一个简单的系统要容易得多。在这些实践之间找到合适的平衡点,你就能进入良性循环,你的代码既牢固可靠又能快速响应变化的需求。

有这三大核心实践打下的基础,才谈得上运用敏捷思想的其他部分。持续交付确保软件始终处于可发布的状态,很多互联网团队能做到一天多次发布,靠的正是持续交付的威力。即便我们不需要如此频繁的发布,持续集成也能帮我们降低风险,并使我们做到根据业务需要随时安排发布,而不受技术的局限。有了可靠的技术根基,我们能够极大地压缩“从好点子到生产代码”的周期时间,从而更好地服务客户。这些技术实践也会增加软件的可靠性,减少耗费在bug上的时间。

这一切说起来似乎很简单,但实际做起来毫不容易。不管采用什么方法,软件开发都是一件复杂而微妙的事,涉及人与人之间、人与机器之间的复杂交互。我在这里描述的方法已经被证明可以应对这些复杂性,但——就跟其他所有方法一样——对使用者的实践和技能有要求。

关于重构,有一个常被提出的问题:它对程序的性能将造成怎样的影响?为了让软件易于理解,我常会做出一些使程序运行变慢的修改。这是一个重要的问题。我并不赞成为了提高设计的纯洁性而忽视性能,把希望寄托于更快的硬件身上也绝非正道。已经有很多软件因为速度太慢而被用户拒绝,日益提高的机器速度也只不过略微放宽了速度方面的限制而已。但是,换个角度说,虽然重构可能使软件运行更慢,但它也使软件的性能优化更容易。除了对性能有严格要求的实时系统,其他任何情况下“编写快速软件”的秘密就是:先写出可调优的软件,然后调优它以求获得足够的速度。

我看过3种编写快速软件的方法。其中最严格的是时间预算法,这通常只用于性能要求极高的实时系统。如果使用这种方法,分解你的设计时就要做好预算,给每个组件预先分配一定资源,包括时间和空间占用。每个组件绝对不能超出自己的预算,就算拥有组件之间调度预配时间的机制也不行。这种方法高度重视性能,对于心律调节器一类的系统是必需的,因为在这样的系统中迟来的数据就是错误的数据。但对其他系统(例如我经常开发的企业信息系统)而言,如此追求高性能就有点儿过分了。

第二种方法是持续关注法。这种方法要求任何程序员在任何时间做任何事时,都要设法保持系统的高性能。这种方式很常见,感觉上很有吸引力,但通常不会起太大作用。任何修改如果是为了提高性能,通常会使程序难以维护,继而减缓开发速度。如果最终得到的软件的确更快了,那么这点损失尚有所值,可惜通常事与愿违,因为性能改善一旦被分散到程序各个角落,每次改善都只不过是从对程序行为的一个狭隘视角出发而已,而且常常伴随着对编译器、运行时环境和硬件行为的误解。

劳而无获

 

克莱斯勒综合薪资系统的支付过程太慢了。虽然我们的开发还没结束,这个问题却已经开始困扰我们,因为它已经拖累了测试速度。

Kent Beck、Martin Fowler和我决定解决这个问题。等待大伙儿会合的时间里,凭着对这个系统的全盘了解,我开始推测:到底是什么让系统变慢了?我想到数种可能,然后和伙伴们谈了几种可能的修改方案。最后,我们就“如何让这个系统运行更快”,提出了一些真正的好点子。

然后,我们拿Kent的工具度量了系统性能。我一开始所想的可能性竟然全都不是问题肇因。我们发现:系统把一半时间用来创建“日期”实例(instance)。更有趣的是,所有这些实例都有相同的几个值。

于是我们观察日期对象的创建逻辑,发现有机会将它优化。这些日期对象在创建时都经过了一个字符串转换过程,然而这里并没有任何外部数据输入。之所以使用字符串转换方式,完全只是因为代码写起来简单。好,也许我们可以优化它。

然后,我们观察这些日期对象是如何被使用的。我们发现,很多日期对象都被用来产生“日期区间”实例——由一个起始日期和一个结束日期组成的对象。仔细追踪下去,我们发现绝大多数日期区间是空的!

处理日期区间时我们遵循这样一个规则:如果结束日期在起始日期之前,这个日期区间就该是空的。这是一条很好的规则,完全符合这个类的需要。采用此规则后不久,我们意识到,创建一个“起始日期在结束日期之后”的日期区间,仍然不算是清晰的代码,于是我们把这个行为提炼成一个工厂函数,由它专门创建“空的日期区间”。

我们做了上述修改,使代码更加清晰,也意外得到了一个惊喜:可以创建一个固定不变的“空日期区间”对象,并让上述调整后的工厂函数始终返回该对象,而不再每次都创建新对象。这一修改把系统速度提升了几乎一倍,足以让测试速度达到可接受的程度。这只花了我们大约五分钟。

我和团队成员(Kent和Martin谢绝参加)认真推测过:我们了若指掌的这个程序中可能有什么错误?我们甚至凭空做了些改进设计,却没有先对系统的真实情况进行度量。

我们完全错了。除了一场很有趣的交谈,我们什么好事都没做。

教训是:哪怕你完全了解系统,也请实际度量它的性能,不要臆测。臆测会让你学到一些东西,但十有八九你是错的。

——Ron Jeffries

关于性能,一件很有趣的事情是:如果你对大多数程序进行分析,就会发现它把大半时间都耗费在一小半代码身上。如果你一视同仁地优化所有代码,90%的优化工作都是白费劲的,因为被你优化的代码大多很少被执行。你花时间做优化是为了让程序运行更快,但如果因为缺乏对程序的清楚认识而花费时间,那些时间就都被浪费掉了。

第三种性能提升法就是利用上述的90%统计数据。采用这种方法时,我编写构造良好的程序,不对性能投以特别的关注,直至进入性能优化阶段——那通常是在开发后期。一旦进入该阶段,我再遵循特定的流程来调优程序性能。

在性能优化阶段,我首先应该用一个度量工具来监控程序的运行,让它告诉我程序中哪些地方大量消耗时间和空间。这样我就可以找出性能热点所在的一小段代码。然后我应该集中关注这些性能热点,并使用持续关注法中的优化手段来优化它们。由于把注意力都集中在热点上,较少的工作量便可显现较好的成果。即便如此,我还是必须保持谨慎。和重构一样,我会小幅度进行修改。每走一步都需要编译、测试,再次度量。如果没能提高性能,就应该撤销此次修改。我会继续这个“发现热点,去除热点”的过程,直到获得客户满意的性能为止。

一个构造良好的程序可从两方面帮助这一优化方式。首先,它让我有比较充裕的时间进行性能调整,因为有构造良好的代码在手,我能够更快速地添加功能,也就有更多时间用在性能问题上(准确的度量则保证我把这些时间投在恰当地点)。其次,面对构造良好的程序,我在进行性能分析时便有较细的粒度。度量工具会把我带入范围较小的代码段中,而性能的调整也比较容易些。由于代码更加清晰,因此我能够更好地理解自己的选择,更清楚哪种调整起关键作用。

我发现重构可以帮助我写出更快的软件。短期看来,重构的确可能使软件变慢,但它使优化阶段的软件性能调优更容易,最终还是会得到好的效果。

我曾经努力想找出“重构”(refactoring)一词的真正起源,但最终失败了。优秀程序员肯定至少会花一些时间来清理自己的代码。这么做是因为,他们知道整洁的代码比杂乱无章的代码更容易修改,而且他们知道自己几乎无法一开始就写出整洁的代码。

重构不止如此。本书中我把重构看作整个软件开发过程的一个关键环节。最早认识重构重要性的两个人是Ward Cunningham和Kent Beck,他们早在20世纪80年代就开始使用Smalltalk,那是一个特别适合重构的环境。Smalltalk是一个十分动态的环境,用它可以很快写出功能丰富的软件。Smalltalk的“编译-链接-执行”周期非常短,因此很容易快速修改代码——要知道,当时很多编程环境做一次编译就需要整晚时间。它支持面向对象,也有强大的工具,最大限度地将修改的影响隐藏于定义良好的接口背后。Ward和Kent努力探索出一套适合这类环境的软件开发过程(如今,Kent把这种风格叫作极限编程)。他们意识到:重构对于提高生产力非常重要。从那时起他们就一直在工作中运用重构技术,在正式的软件项目中使用它,并不断精炼重构的过程。

Ward和Kent的思想对Smalltalk社区产生了极大影响,重构概念也成为Smalltalk文化中的一个重要元素。Smalltalk社区的另一位领袖是Ralph Johnson,伊利诺伊大学厄巴纳-香槟分校教授,著名的GoF[gof]之一。Ralph最大的兴趣之一就是开发软件框架。他揭示了重构有助于灵活高效框架的开发。

Bill Opdyke是Ralph的博士研究生,对框架也很感兴趣。他看到了重构的潜在价值,并看到重构应用于Smalltalk之外的其他语言的可能性。他的技术背景是电话交换系统的开发。在这种系统中,大量的复杂情况与日俱增,而且非常难以修改。Bill的博士研究就是从工具构筑者的角度来看待重构。Bill对C++的框架开发中用得上的重构手法特别感兴趣。他也研究了极有必要的“语义保持的重构” (semantics-preserving refactoring),并阐明了如何证明这些重构是语义保持的,以及如何用工具实现重构。Bill的博士论文[Opdyke]是重构领域中第一部丰硕的研究成果。

我还记得1992年OOPSLA大会上见到Bill的情景。我们坐在一间咖啡厅里,Bill跟我谈起他的研究成果,我还记得自己当时的想法:“有趣,但并非真的那么重要。”唉,我完全错了。

John Brant和Don Roberts将“重构工具”的构想发扬光大,开发了一个名为Refactoring Browser (重构浏览器)的重构工具。这是第一个自动化的重构工具,多亏Smalltalk提供了适合重构的编程环境。

那么,我呢?我一直有清理代码的倾向,但从来没有想到这会如此重要。后来我和Kent一起做一个项目,看到他使用重构手法,也看到重构对开发效能和质量带来的影响。这份体验让我相信:重构是一门非常重要的技术。但是,在重构的学习和推广过程中我遇到了挫折,因为我拿不出任何一本书给程序员看,也没有任何一位专家打算写这样一本书。所以,在这些专家的帮助下,我写下了这本书的第1版。

幸运的是,重构的概念被行业广泛接受了。本书第1版销量不错,“重构”一词也走进了大多数程序员的词汇库。更多的重构工具涌现出来,尤其是在Java世界里。重构的流行也带来了负面效应:很多人随意地使用“重构”这个词,而他们真正做的却是不严谨的结构调整。尽管如此,重构终归成了一项主流的软件开发实践。

过去10年中,重构领域最大的变化可能就是出现了一批支持自动化重构的工具。如果我想给一个Java的方法改名,在IntelliJ IDEA或者Eclipse这样的开发环境中,我只需要从菜单里点选对应的选项,工具会帮我完成整个重构过程,而且我通常都可以相信,工具完成的重构是可靠的,所以用不着运行测试套件。

第一个自动化重构工具是Smalltalk的Refactoring Browser,由John Brandt和Don Roberts开发。在21世纪初,Java世界的自动化重构工具如雨后春笋般涌现。在JetBrains的IntelliJ IDEA集成开发环境(IDE)中,自动化重构是最亮眼的特性之一。IBM也紧随其后,在VisualAge的Java版中也提供了重构工具。VisualAge的影响力有限,不过其中很多能力后来被Eclipse继承,包括对重构的支持。

重构也进入了C#世界,起初是通过JetBrains的Resharper,这是一个Visual Studio插件。后来Visual Studio团队直接在IDE里提供了一些重构能力。

如今的编辑器和开发工具中常能找到一些对重构的支持,不过真实的重构能力各有高低。重构能力的差异既有工具的原因,也受限于不同语言对自动化重构的支持程度。在这里,我不打算分析各种工具的能力,不过谈谈重构工具背后的原则还是有点儿意思的。

一种粗糙的自动化重构方式是文本操作,比如用查找/替换的方式给函数改名,或者完成提炼变量(119)所需的简单结构调整。这种方法太粗糙了,做完之后必须重新运行测试,否则不能信任。但这可以是一个便捷的起步。在用Emacs编程时,没有那些更完善的重构支持,我也会用类似的文本操作宏来加速重构。

要支持体面的重构,工具只操作代码文本是不行的,必须操作代码的语法树,这样才能更可靠地保持代码行为。所以,今天的大多数重构功能都依附于强大的IDE,因为这些IDE原本就在语法树上实现了代码导航、静态检查等功能,自然也可以用于重构。不仅能处理文本,还能处理语法树,这是IDE相比于文本编辑器更先进的地方。

重构工具不仅需要理解和修改语法树,还要知道如何把修改后的代码写回编辑器视图。总而言之,实现一个体面的自动化重构手法,是一个很有挑战的编程任务。尽管我一直开心地使用重构工具,对它们背后的实现却知之甚少。

在静态类型语言中,很多重构手法会更加安全。假设我想做一次简单的函数改名(124):在Salesman类和Server类中都有一个叫作addClient的函数,当然两者各有其用途。我想对Salesman中的addClient函数改名,Server类中的函数则保持不变。如果不是静态类型,工具很难识别调用addClient的地方到底是在使用哪个类的函数。Smalltalk的Refactoring Browser会列出所有调用点,我需要手工决定修改哪些调用点。这个重构是不安全的,我必须重新运行所有测试。这样的工具仍然有用,但在Java中的函数改名(124)重构则可以是完全安全、完全自动的,因为在静态类型的帮助下,工具可以识别函数所属的类,所以它只会修改应该修改的那些函数调用点,对此我可以完全放心。

一些重构工具走得更远。如果我给一个变量改名,工具会提醒我修改使用了旧名字的注释。如果我使用提炼函数(106),工具会找出与新函数体重复的代码片段,建议代之以对新函数的调用。在编程时可以使用如此强大的重构功能,这就是为什么我们要使用一个体面的IDE,而不是固执于熟悉的文本编辑器。我个人很喜欢用Emacs,但在使用Java时,我更愿意用IntelliJ IDEA或者Eclipse,很大程度上就是为了获得重构支持。

尽管这些强大的重构工具有着魔法般的能力,可以安全地重构代码,但还是会有闪失出现。通过反射进行的调用(例如Java中的Method.invoke)会迷惑不够成熟的重构工具,但比较成熟的工具则可以很好地应对。所以,即便是最安全的重构,也应该经常运行测试套件,以确保没有什么东西在不经意间被破坏。我经常会间杂进行自动重构和手动重构,所以运行测试的频度是足够的。

能借助语法树来分析和重构程序代码,这是IDE与普通文本编辑器相比具有的一大优势。但很多程序员又喜欢用得顺手的文本编辑器的灵活性,希望鱼与熊掌兼得。语言服务器(Language Server)是一种正在引起关注的新技术:用软件生成语法树,给文本编辑器提供API。语言服务器可以支持多种文本编辑器,并且为强大的代码分析和重构操作提供了命令。

在第2章就开始谈延展阅读,这似乎有点儿奇怪。不过,有大量关于重构的材料已经超出了本书的范围,早些让读者知道这些材料的存在也是件好事。

本书的第1版教很多人学会了重构,不过我的关注点是组织一本重构的参考书,而不是带领读者走过学习过程。如果你需要一本面向入门者的教材,我推荐Bill Wake的《重构手册》[Wake],其中包含了很多有用的重构练习。

很多重构的先行者同时也活跃于软件模式社区。Josh Kerievsky在《重构与模式》[Kerievsky]一书中紧密连接了这两个世界。他审视了影响巨大的GoF[gof]书中一些最有价值的模式,并展示了如何通过重构使代码向这些模式的方向演化。

本书聚焦讨论通用编程语言中的重构技巧。还有一些专门领域的重构,例如已经引起关注的《数据库重构》[Ambler & Sadalage](由Scott Ambler和Pramod Sadalage所著)和《重构HTML》[Harold](由Elliotte Rusty Harold所著)。

尽管标题中没有“重构”二字,Michael Feathers的《修改代码的艺术》[Feathers]也不得不提。这本书主要讨论如何在缺乏测试覆盖的老旧代码库上开展重构。

本书(及其前一版)对读者的编程语言背景没有要求。也有人写专门针对特定语言的重构书籍。我的两位前同事Jay Fields和Shane Harvey就撰写了Ruby版的《重构》[Fields et al.]。

在本书的Web版和重构网站(refactoring.com)[ref.com]上都可以找到更多相关材料的更新。


在重构名录的开头,我首先介绍一组我认为最有用的重构。

我最常用到的重构就是用提炼函数(106)将代码提炼到函数中,或者用提炼变量(119)来提炼变量。既然重构的作用就是应对变化,你应该不会感到惊讶,我也经常使用这两个重构的反向重构——内联函数(115)内联变量(123)

提炼的关键就在于命名,随着理解的加深,我经常需要改名。改变函数声明(124)可以用于修改函数的名字,也可以用于添加或删减参数。变量也可以用变量改名(137)来改名,不过需要先做封装变量(132)。在给函数的形式参数改名时,不妨先用引入参数对象(140)把常在一起出没的参数组合成一个对象。

形成函数并给函数命名,这是低层级重构的精髓。有了函数以后,就需要把它们组合成更高层级的模块。我会使用函数组合成类(144),把函数和它们操作的数据一起组合成类。另一条路径是用函数组合成变换(149)将函数组合成变换式(transform),这对于处理只读数据尤为便利。再往前一步,常常可以用拆分阶段(154)将这些模块组成界限分明的处理阶段。

曾用名:提炼函数(Extract Method)

反向重构:内联函数(115)

function printOwing(invoice) {
 printBanner();
 let outstanding = calculateOutstanding();

 //print details
 console.log(`name: ${invoice.customer}`);
 console.log(`amount: ${outstanding}`);
}

function printOwing(invoice) {
 printBanner();
 let outstanding = calculateOutstanding();
 printDetails(outstanding);

 function printDetails(outstanding) {
  console.log(`name: ${invoice.customer}`);
  console.log(`amount: ${outstanding}`);
 }
}

提炼函数是我最常用的重构之一。(在这儿我用了“函数/function”这个词,但换成面向对象语言中的“方法/method”,或者其他任何形式的“过程/procedure”或者“子程序/subroutine”,也同样适用。)我会浏览一段代码,理解其作用,然后将其提炼到一个独立的函数中,并以这段代码的用途为这个函数命名。

对于“何时应该把代码放进独立的函数”这个问题,我曾经听过多种不同的意见。有的观点从代码的长度考虑,认为一个函数应该能在一屏中显示。有的观点从复用的角度考虑,认为只要被用过不止一次的代码,就应该单独放进一个函数;只用过一次的代码则保持内联(inline)的状态。但我认为最合理的观点是“将意图与实现分开”:如果你需要花时间浏览一段代码才能弄清它到底在干什么,那么就应该将其提炼到一个函数中,并根据它所做的事为其命名。以后再读到这段代码时,你一眼就能看到函数的用途,大多数时候根本不需要关心函数如何达成其用途(这是函数体内干的事)。

一旦接受了这个原则,我就逐渐养成一个习惯:写非常小的函数——通常只有几行的长度。在我看来,一个函数一旦超过6行,就开始散发臭味。我甚至经常会写一些只有1行代码的函数。Kent Beck曾向我展示最初的Smalltalk系统中的一个例子,从那时起我就接受了“函数名的长度不重要”的观念。那时运行Smalltalk的计算机只有黑白屏显示器,如果你想高亮突显某些文本或图像,就需要反转视频的显示。为此,Smalltalk用于控制图像显示的类有一个叫作highlight的方法,其中的实现就只是调用reverse方法。在这个例子里,highlight方法的名字比实现还长,但这并不重要,因为在这个方法中,代码的意图与实现之间有着相当大的距离。

有些人担心短函数会造成大量函数调用,因而影响性能。在我尚且年轻时,有时确实会有这个问题;但如今“由于函数调用影响性能”的情况已经非常罕见了。短函数常常能让编译器的优化功能运转更良好,因为短函数可以更容易地被缓存。所以,应该始终遵循性能优化的一般指导方针,不用过早担心性能问题。

小函数得有个好名字才行,所以你必须在命名上花心思。起好名字需要练习,不过一旦你掌握了其中的技巧,就能写出很有自描述性的代码。

我经常会看见这样的情况:在一个大函数中,一段代码的顶上放着一句注释,说明这段代码要做什么。在把这段代码提炼到自己的函数中时,这样的注释往往会提示一个好名字。

如果想要提炼的代码非常简单,例如只是一个函数调用,只要新函数的名称能够以更好的方式昭示代码意图,我还是会提炼它;但如果想不出一个更有意义的名称,这就是一个信号,可能我不应该提炼这块代码。不过,我不一定非得马上想出最好的名字,有时在提炼的过程中好的名字才会出现。有时我会提炼一个函数,尝试使用它,然后发现不太合适,再把它内联回去,这完全没问题。只要在这个过程中学到了东西,我的时间就没有白费。

如果编程语言支持嵌套函数,就把新函数嵌套在源函数里,这能减少后面需要处理的超出作用域的变量个数。我可以稍后再使用搬移函数(198)把它从源函数中搬移出去。

如果提炼出的新函数嵌套在源函数内部,就不存在变量作用域的问题了。

这些“作用域限于源函数”的变量通常是局部变量或者源函数的参数。最通用的做法是将它们都作为参数传递给新函数。只要没在提炼部分对这些变量赋值,处理起来就没什么难度。

如果某个变量是在提炼部分之外声明但只在提炼部分被使用,就把变量声明也搬移到提炼部分代码中去。

如果变量按值传递给提炼部分又在提炼部分被赋值,就必须多加小心。如果只有一个这样的变量,我会尝试将提炼出的新函数变成一个查询(query),用其返回值给该变量赋值。

但有时在提炼部分被赋值的局部变量太多,这时最好是先放弃提炼。这种情况下,我会考虑先使用别的重构手法,例如拆分变量(240)或者以查询取代临时变量(178),来简化变量的使用情况,然后再考虑提炼函数。

如果编程语言支持编译期检查的话,在处理完所有变量之后做一次编译是很有用的,编译器经常会帮你找到没有被恰当处理的变量。

有些重构工具直接支持这一步。如果工具不支持,可以快速搜索一下,看看别处是否还有重复代码。

在最简单的情况下,提炼函数易如反掌。请看下列函数:

function printOwing(invoice) {
 let outstanding = 0;

 console.log("***********************");
 console.log("**** Customer Owes ****");
 console.log("***********************");

 // calculate outstanding
 for (const o of invoice.orders) {
  outstanding += o.amount;
 }

 // record due date
 const today = Clock.today;
 invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30);

 //print details
 console.log(`name: ${invoice.customer}`);
 console.log(`amount: ${outstanding}`);
 console.log(`due: ${invoice.dueDate.toLocaleDateString()}`);
}

你可能会好奇Clock.today是干什么的。这是一个Clock Wrapper[mf-cw],也就是封装系统时钟调用的对象。我尽量避免在代码中直接调用Date.now()这样的函数,因为这会导致测试行为不可预测,以及在诊断故障时难以复制出错时的情况。

我们可以轻松提炼出“打印横幅”的代码。我只需要剪切、粘贴再插入一个函数调用动作就行了:

function printOwing(invoice) {
 let outstanding = 0;

 printBanner();

 // calculate outstanding
 for (const o of invoice.orders) {
  outstanding += o.amount;
 }

 // record due date
 const today = Clock.today;
 invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30);

 //print details
 console.log(`name: ${invoice.customer}`); 
 console.log(`amount: ${outstanding}`);
 console.log(`due: ${invoice.dueDate.toLocaleDateString()}`);
}
function printBanner() {
 console.log("***********************");
 console.log("**** Customer Owes ****");
 console.log("***********************");
}

同样,我还可以把“打印详细信息”部分也提炼出来:

function printOwing(invoice) {
 let outstanding = 0;

 printBanner();

 // calculate outstanding
 for (const o of invoice.orders) {
  outstanding += o.amount;
 }

 // record due date
 const today = Clock.today;
 invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30);

 printDetails();

 function printDetails() { 
  console.log(`name: ${invoice.customer}`); 
  console.log(`amount: ${outstanding}`);
  console.log(`due: ${invoice.dueDate.toLocaleDateString()}`);
}

看起来提炼函数是一个极其简单的重构。但很多时候,情况会变得比较复杂。

在上面的例子中,我把printDetails函数嵌套在printOwing函数内部,这样前者就能访问到printOwing内部定义的所有变量。如果我使用的编程语言不支持嵌套函数,就没法这样操作了,那么我就要面对“提炼出一个顶层函数”的问题。此时我必须细心处理“只存在于源函数作用域”的变量,包括源函数的参数以及源函数内部定义的临时变量。

局部变量最简单的情况是:被提炼代码段只是读取这些变量的值,并不修改它们。这种情况下我可以简单地将它们当作参数传给目标函数。所以,如果我面对下列函数:

function printOwing(invoice) {
 let outstanding = 0;

 printBanner();

 // calculate outstanding
 for (const o of invoice.orders) {
  outstanding += o.amount;
 }

 // record due date
 const today = Clock.today;
 invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30);

 //print details
 console.log(`name: ${invoice.customer}`);
 console.log(`amount: ${outstanding}`);
 console.log(`due: ${invoice.dueDate.toLocaleDateString()}`);

就可以将“打印详细信息”这一部分提炼为带两个参数的函数:

function printOwing(invoice) {
 let outstanding = 0;

 printBanner();

 // calculate outstanding
 for (const o of invoice.orders) {
  outstanding += o.amount;
 }

 // record due date
 const today = Clock.today;
 invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30);

 printDetails(invoice, outstanding);
}
function printDetails(invoice, outstanding) {
 console.log(`name: ${invoice.customer}`);
 console.log(`amount: ${outstanding}`);
 console.log(`due: ${invoice.dueDate.toLocaleDateString()}`);
}

如果局部变量是一个数据结构(例如数组、记录或者对象),而被提炼代码段又修改了这个结构中的数据,也可以如法炮制。所以,“设置到期日”的逻辑也可以用同样的方式提炼出来:

function printOwing(invoice) {
 let outstanding = 0;

 printBanner();

 // calculate outstanding
 for (const o of invoice.orders) {
  outstanding += o.amount;
 }

 recordDueDate(invoice);
 printDetails(invoice, outstanding);
}
function recordDueDate(invoice) {
 const today = Clock.today;
 invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30);
}

如果被提炼代码段对局部变量赋值,问题就变得复杂了。这里我们只讨论临时变量的问题。如果你发现源函数的参数被赋值,应该马上使用拆分变量(240)将其变成临时变量。

被赋值的临时变量也分两种情况。较简单的情况是:这个变量只在被提炼代码段中使用。若果真如此,你可以将这个临时变量的声明移到被提炼代码段中,然后一起提炼出去。如果变量的初始化和使用离得有点儿远,可以用移动语句(223)把针对这个变量的操作放到一起。

比较糟糕的情况是:被提炼代码段之外的代码也使用了这个变量。此时我需要返回修改后的值。我会用下面这个已经很眼熟的函数来展示该怎么做:

function printOwing(invoice) {
 let outstanding = 0;

 printBanner();

 // calculate outstanding
 for (const o of invoice.orders) {
  outstanding += o.amount;
 }

 recordDueDate(invoice);
 printDetails(invoice, outstanding);
}

前面的重构我都一步到位地展示了结果,因为它们都很简单。但这次我会一步一步展示“做法”里的每个步骤。

首先,把变量声明移动到使用处之前。

function printOwing(invoice) {
 printBanner();

 // calculate outstanding
 let outstanding = 0;
 for (const o of invoice.orders) {
  outstanding += o.amount;
 }

 recordDueDate(invoice);
 printDetails(invoice, outstanding);
}

然后把想要提炼的代码复制到目标函数中。

function printOwing(invoice) {
 printBanner();

 // calculate outstanding
 let outstanding = 0;
 for (const o of invoice.orders) {
  outstanding += o.amount;
 }

 recordDueDate(invoice);
 printDetails(invoice, outstanding);
}
function calculateOutstanding(invoice) {
 let outstanding = 0;
 for (const o of invoice.orders) {
  outstanding += o.amount;
 }
 return outstanding;
}

由于outstanding变量的声明已经被搬移到提炼出的新函数中,就不需要再将其作为参数传入了。outstanding是提炼代码段中唯一被重新赋值的变量,所以我可以直接返回它。

我的JavaScript环境在编译期提供不了任何价值——简直还不如文本编辑器的语法分析有用,所以“做法”里的“编译”一步可以跳过了。下一件事是修改原来的代码,令其调用新函数。新函数返回了修改后的outstanding变量值,我需要将其存入原来的变量中。

function printOwing(invoice) {
 printBanner();
 let outstanding = calculateOutstanding(invoice);
 recordDueDate(invoice);
 printDetails(invoice, outstanding);
}
function calculateOutstanding(invoice) {
 let outstanding = 0;
 for (const o of invoice.orders) {
  outstanding += o.amount;
 }
 return outstanding;
}

在收工之前,我还要修改返回值的名字,使其符合我一贯的编码风格。

function printOwing(invoice) {
 printBanner();
 const outstanding = calculateOutstanding(invoice);
 recordDueDate(invoice);
 printDetails(invoice, outstanding);
}
function calculateOutstanding(invoice) {
 let result = 0;
 for (const o of invoice.orders) {
  result += o.amount;
 }
 return result;
}

我还顺手把原来的outstanding变量声明成const的,令其在初始化之后不能再次被赋值。

这时候,你可能会问:“如果需要返回的变量不止一个,又该怎么办呢?”

有几种选择。最好的选择通常是:挑选另一块代码来提炼。我比较喜欢让每个函数都只返回一个值,所以我会安排多个函数,用以返回多个值。如果真的有必要提炼一个函数并返回多个值,可以构造并返回一个记录对象—不过通常更好的办法还是回过头来重新处理局部变量,我常用的重构手法有以查询取代临时变量(178)拆分变量(240)

如果我想把提炼出的函数搬移到别的上下文(例如变成顶层函数),会引发一些有趣的问题。我偏好小步前进,所以我本能的做法是先提炼成嵌套函数,然后再将其移入新的上下文。但这种做法的麻烦在于处理局部变量,而这个困难无法提前发现,直到我开始最后的搬移时才突然暴露。从这个角度考虑,即便可以先提炼成嵌套函数,或许也应该至少将目标函数放在源函数的同级,这样我就能立即看出提炼的范围是否合理。

曾用名:内联函数(Inline Method)

反向重构:提炼函数(106)

function getRating(driver) {
 return moreThanFiveLateDeliveries(driver) ? 2 : 1;
}

function moreThanFiveLateDeliveries(driver) {
 return driver.numberOfLateDeliveries > 5;
}

function getRating(driver) {
 return (driver.numberOfLateDeliveries > 5) ? 2 : 1;
}

本书经常以简短的函数表现动作意图,这样会使代码更清晰易读。但有时候你会遇到某些函数,其内部代码和函数名称同样清晰易读。也可能你重构了该函数的内部实现,使其内容和其名称变得同样清晰。若果真如此,你就应该去掉这个函数,直接使用其中的代码。间接性可能带来帮助,但非必要的间接性总是让人不舒服。

另一种需要使用内联函数的情况是:我手上有一群组织不甚合理的函数。可以将它们都内联到一个大型函数中,再以我喜欢的方式重新提炼出小函数。

如果代码中有太多间接层,使得系统中的所有函数都似乎只是对另一个函数的简单委托,造成我在这些委托动作之间晕头转向,那么我通常都会使用内联函数。当然,间接层有其价值,但不是所有间接层都有价值。通过内联手法,我可以找出那些有用的间接层,同时将无用的间接层去除。

如果该函数属于一个类,并且有子类继承了这个函数,那么就无法内联。

不必一次完成整个内联操作。如果某些调用点比较难以内联,可以等到时机成熟后再来处理。

被我这样一写,内联函数似乎很简单。但情况往往并非如此。对于递归调用、多返回点、内联至另一个对象中而该对象并无访问函数等复杂情况,我可以写上好几页。我之所以不写这些特殊情况,原因很简单:如果你遇到了这样的复杂情况,就不应该使用这个重构手法。

在最简单的情况下,这个重构简单得不值一提。一开始的代码是这样:

function rating(aDriver) {
 return moreThanFiveLateDeliveries(aDriver) ? 2 : 1;
}
function moreThanFiveLateDeliveries(aDriver) { 
 return aDriver.numberOfLateDeliveries > 5;
}

我只要把被调用的函数的return语句复制出来,粘贴到调用处,取代原本的函数调用,就行了。

function rating(aDriver) {
  return aDriver.numberOfLateDeliveries &gt; 5 ? 2 : 1;
}

不过实际情况可能不会这么简单,需要我多做一点儿工作,帮助代码融入它的新家。例如,开始时的代码与前面稍有不同:

function rating(aDriver) {
 return moreThanFiveLateDeliveries(aDriver) ? 2 : 1;
}

function moreThanFiveLateDeliveries(dvr) {
 return dvr.numberOfLateDeliveries > 5;
}

几乎是一样的代码,但moreThanFiveLateDeliveries函数声明的形式参数名与调用处使用的变量名不同,所以我在内联时需要对代码做些微调。

function rating(aDriver) {
  return aDriver.numberOfLateDeliveries > 5 ? 2 : 1;
}

情况还可能更复杂。例如,请看下列代码:

function reportLines(aCustomer) {
 const lines = [];
 gatherCustomerData(lines, aCustomer);
 return lines;
}
function gatherCustomerData(out, aCustomer) {
 out.push(["name", aCustomer.name]);
 out.push(["location", aCustomer.location]);
}

我要把gatherCustomerData内联到reportLines中,这时简单的剪切和粘贴就不够了。这段代码还不算很麻烦,大多数时候我还是一步到位地完成了重构,只是需要做些调整。如果想更谨慎些,也可以每次搬移一行代码:可以首先对第一行代码使用搬移语句到调用者(217)——我还是用简单的“剪切-粘贴-调整”方式进行。

function reportLines(aCustomer) {
 const lines = [];
 lines.push(["name", aCustomer.name]);
 gatherCustomerData(lines, aCustomer); 
 return lines;
}
function gatherCustomerData(out, aCustomer) {
 out.push(["name", aCustomer.name]); 
 out.push(["location", aCustomer.location]);
}

然后继续处理后面的代码行,直到完成整个重构。

function reportLines(aCustomer) {
 const lines = [];
 lines.push(["name", aCustomer.name]);
 lines.push(["location", aCustomer.location]);
 return lines;
}

重点在于始终小步前进。大多数时候,由于我平时写的函数都很小,内联函数可以一步完成,顶多需要一点代码调整。但如果遇到了复杂的情况,我会每次内联一行代码。哪怕只是处理一行代码,也可能遇到麻烦,那么我就会使用更精细的重构手法搬移语句到调用者(217),将步子再拆细一点。有时我会自信满满地快速完成重构,然后测试却失败了,这时我会回退到上一个能通过测试的版本,带着一点儿懊恼,以更小的步伐再次重构。

曾用名:引入解释性变量(Introduce Explaining Variable)

反向重构:内联变量(123)

return order.quantity * order.itemPrice -
 Math.max(0, order.quantity - 500) * order.itemPrice * 0.05 +
 Math.min(order.quantity * order.itemPrice * 0.1, 100);

const basePrice = order.quantity * order.itemPrice;
const quantityDiscount = Math.max(0, order.quantity - 500) * order.itemPrice * 0.05;
const shipping = Math.min(basePrice * 0.1, 100);
return basePrice - quantityDiscount + shipping;

表达式有可能非常复杂而难以阅读。这种情况下,局部变量可以帮助我们将表达式分解为比较容易管理的形式。在面对一块复杂逻辑时,局部变量使我能给其中的一部分命名,这样我就能更好地理解这部分逻辑是要干什么。

这样的变量在调试时也很方便,它们给调试器和打印语句提供了便利的抓手。

如果我考虑使用提炼变量,就意味着我要给代码中的一个表达式命名。一旦决定要这样做,我就得考虑这个名字所处的上下文。如果这个名字只在当前的函数中有意义,那么提炼变量是个不错的选择;但如果这个变量名在更宽的上下文中也有意义,我就会考虑将其暴露出来,通常以函数的形式。如果在更宽的范围可以访问到这个名字,就意味着其他代码也可以用到这个表达式,而不用把它重写一遍,这样能减少重复,并且能更好地表达我的意图。

“将新的名字暴露得更宽”的坏处则是需要额外的工作量。如果工作量很大,我会暂时搁下这个想法,稍后再用以查询取代临时变量(178)来处理它。但如果处理其他很简单,我就会立即动手,这样马上就可以使用这个新名字。有一个好的例子:如果我处理的这段代码属于一个类,对这个新的变量使用提炼函数(106)会很容易。

如果该表达式出现了多次,请用这个新变量逐一替换,每次替换之后都要执行测试。

我们从一个简单计算开始:

function price(order) {
 //price is base price - quantity discount + shipping
 return order.quantity * order.itemPrice -
  Math.max(0, order.quantity - 500) * order.itemPrice * 0.05 +
  Math.min(order.quantity * order.itemPrice * 0.1, 100);
}

这段代码还算简单,不过我可以让它变得更容易理解。首先,我发现,底价(base price)等于数量(quantity)乘以单价(item price)。

function price(order) {
 //price is base price - quantity discount + shipping
 return order.quantity * order.itemPrice -
  Math.max(0, order.quantity - 500) * order.itemPrice * 0.05 +
  Math.min(order.quantity * order.itemPrice * 0.1, 100);
}

我把这一新学到的知识放进代码里,创建一个变量,并给它起个合适的名字:

function price(order) {
 //price is base price - quantity discount + shipping
 const basePrice = order.quantity * order.itemPrice;
 return order.quantity * order.itemPrice -
  Math.max(0, order.quantity - 500) * order.itemPrice * 0.05 +
  Math.min(order.quantity * order.itemPrice * 0.1, 100);
}

当然,仅仅声明并初始化一个变量没有任何作用,我还得使用它才行。所以,我用这个变量取代了原来的表达式:

function price(order) {
 //price is base price - quantity discount + shipping
 const basePrice = order.quantity * order.itemPrice;
 return basePrice -
  Math.max(0, order.quantity - 500) * order.itemPrice * 0.05 +
  Math.min(order.quantity * order.itemPrice * 0.1, 100);
}

稍后的代码还用到了同样的表达式,也可以用新建的变量取代之。

function price(order) {
 //price is base price - quantity discount + shipping
 const basePrice = order.quantity * order.itemPrice;
 return basePrice -
  Math.max(0, order.quantity - 500) * order.itemPrice * 0.05 +
  Math.min(basePrice * 0.1, 100);
}

下一行是计算批发折扣(quantity discount)的逻辑,我也将它提炼出来:

function price(order) {
 //price is base price - quantity discount + shipping
 const basePrice = order.quantity * order.itemPrice;
 const quantityDiscount = Math.max(0, order.quantity - 500) * order.itemPrice * 0.05;
 return basePrice -
  quantityDiscount +
  Math.min(basePrice * 0.1, 100);
}

最后,我再把运费(shipping)计算提炼出来。同时我还可以删掉代码中的注释,因为现在代码已经可以完美表达自己的意义了:

function price(order) {
 const basePrice = order.quantity * order.itemPrice;
 const quantityDiscount = Math.max(0, order.quantity - 500) * order.itemPrice * 0.05;
 const shipping = Math.min(basePrice * 0.1, 100);
 return basePrice - quantityDiscount + shipping;
}

下面是同样的代码,但这次它位于一个类中:

class Order {
 constructor(aRecord) {
  this._data = aRecord;
 }


 get quantity() {return this._data.quantity;}
 get itemPrice() {return this._data.itemPrice;}

 get price() {
  return this.quantity * this.itemPrice -
   Math.max(0, this.quantity - 500) * this.itemPrice * 0.05 +
   Math.min(this.quantity * this.itemPrice * 0.1, 100);
 }
}

我要提炼的还是同样的变量,但我意识到:这些变量名所代表的概念,适用于整个Order类,而不仅仅是“计算价格”的上下文。既然如此,我更愿意将它们提炼成方法,而不是变量。

class Order {
 constructor(aRecord) {
  this._data = aRecord;
 }
 get quantity() {return this._data.quantity;}
 get itemPrice() {return this._data.itemPrice;}

 get price() {
  return this.basePrice - this.quantityDiscount + this.shipping;
 }
 get basePrice()        {return this.quantity * this.itemPrice;}
 get quantityDiscount() {return Math.max(0, this.quantity - 500) * this.itemPrice * 0.05;}
 get shipping()         {return Math.min(this.basePrice * 0.1, 100);}
}

这是对象带来的一大好处:它们提供了合适的上下文,方便分享相关的逻辑和数据。在如此简单的情况下,这方面的好处还不太明显;但在一个更大的类当中,如果能找出可以共用的行为,赋予它独立的概念抽象,给它起一个好名字,对于使用对象的人会很有帮助。

曾用名:内联临时变量(Inline Temp)

反向重构:提炼变量(119)

let basePrice = anOrder.basePrice;
return (basePrice > 1000);

return anOrder.basePrice > 1000;

在一个函数内部,变量能给表达式提供有意义的名字,因此通常变量是好东西。但有时候,这个名字并不比表达式本身更具表现力。还有些时候,变量可能会妨碍重构附近的代码。若果真如此,就应该通过内联的手法消除变量。

这是为了确保该变量只被赋值一次。

别名:函数改名(Rename Function)

曾用名:函数改名(Rename Method)

曾用名:添加参数(Add Parameter)

曾用名:移除参数(Remove Parameter)

别名:修改签名(Change Signature)

function circum(radius) {...}

function circumference(radius) {...}

函数是我们将程序拆分成小块的主要方式。函数声明则展现了如何将这些小块组合在一起工作——可以说,它们就是软件系统的关节。和任何构造体一样,系统的好坏很大程度上取决于关节。好的关节使得给系统添加新部件很容易;而糟糕的关节则不断招致麻烦,让我们难以看清软件的行为,当需求变化时难以找到合适的地方进行修改。还好,软件是软的,我可以改变这些关节,只是要小心修改。

对于这些关节而言,最重要的元素当属函数的名字。一个好名字能让我一眼看出函数的用途,而不必查看其实现代码。但起一个好名字并不容易,我很少能第一次就把名字起对。“就算这个名字有点迷惑人,还是放着别管吧——说到底,不过就是一个名字而已。”邪恶的混乱魔王就是这样引诱我的。为了拯救程序的灵魂,绝不能上了他的当。如果我看到一个函数的名字不对,一旦发现了更好的名字,就得尽快给函数改名。这样,下一次再看到这段代码时,我就不用再费力搞懂其中到底在干什么。(有一个改进函数名字的好办法:先写一句注释描述这个函数的用途,再把这句注释变成函数的名字。)

对于函数的参数,道理也是一样。函数的参数列表阐述了函数如何与外部世界共处。函数的参数设置了一个上下文,只有在这个上下文中,我才能使用这个函数。假如有一个函数的用途是把某人的电话号码转换成特定的格式,并且该函数的参数是一个人(person),那么我就没法用这个函数来处理公司(company)的电话号码。如果我把函数接受的参数由“人”改成“电话号码”,这段处理电话号码格式的代码就能被更广泛地使用。

修改参数列表不仅能增加函数的应用范围,还能改变连接一个模块所需的条件,从而去除不必要的耦合。在前面这个例子中,修改参数列表之后,“处理电话号码格式”的逻辑所在的模块就无须了解“人”这个概念。减少模块彼此之间的信息依赖,当我要做出修改时就能减轻我大脑的负担——毕竟我的脑容量已经不如从前那么大了(跟我脑袋的大小没关系)。

如何选择正确的参数,没有简单的规则可循。我可能有一个简单的函数,用于判断支付是否逾期——如果超期30天未付款,那么这笔支付就逾期了。这个函数的参数应该是“支付”(payment)对象,还是支付的到期日呢?如果使用支付对象,会使这个函数与支付对象的接口耦合,但好处是可以很容易地访问后者的其他属性,当“逾期”的逻辑发生变化时就不用修改所有调用该函数的代码——换句话说,提高了该函数的封装度。

对这道难题,唯一正确的答案是“没有正确答案”,而且答案还会随着时间变化。所以我发现掌握改变函数声明重构手法至关重要,这样当我想好代码中应该有哪些关节时,才能使代码随着我的理解而演进。

在本书中引用重构手法时,我通常只使用它的主名称。但“改名”(rename)是改变函数声明的重要应用场景,所以,如果只是用于改名,我会将这个重构称作函数改名(Rename Function),这样能更清晰地表达我的用意。从做法的角度,不管是给函数改名还是修改参数列表,做法都是一样的。

对于本书中的大部分重构,我只展示了一套做法。这并非因为只有这一套做法,而是因为大部分情况下,一套标准的做法都管用。不过,改变函数声明是一个例外。它有一套简单的做法,这套做法常常够用;但在很多时候,有必要以更渐进的方式逐步迁移到达最终结果。所以,在进行此重构时,我会查看变更的范围,自问是否能一步到位地修改函数声明及其所有调用者。如果可以,我就采用简单的做法。迁移式的做法让我可以逐步修改调用方代码,如果函数被很多地方调用,或者修改不容易,或者要修改的是一个多态函数,或者对函数声明的修改比较复杂,能渐进式地逐步修改就很重要。

简单的做法

最好能把大的修改拆成小的步骤,所以如果你既想修改函数名,又想添加参数,最好分成两步来做。(并且,不论何时,如果遇到了麻烦,请撤销修改,并改用迁移式做法。)

迁移式做法

如果你打算沿用旧函数的名字,可以先给新函数起一个易于搜索的临时名字。

如果要重构的函数属于一个具有多态性的类,那么对于该函数的每个实现版本,你都需要通过“提炼出一个新函数”的方式添加一层间接,并把旧函数的调用转发给新函数。如果该函数的多态性是在一个类继承体系中体现,那么只需要在超类上转发即可;如果各个实现类之间并没有一个共同的超类,那么就需要在每个实现类上做转发。

如果要重构一个已对外发布的API,在提炼出新函数之后,你可以暂停重构,将原来的函数声明为“不推荐使用”(deprecated),然后给客户端一点时间转为使用新函数。等你有信心所有客户端都已经从旧函数迁移到新函数,再移除旧函数的声明。

下列函数的名字太过简略了:

function circum(radius) {
  return 2 * Math.PI * radius;
}

我想把它改得更有意义一点儿。首先修改函数的声明:

function circumference(radius) {
  return 2 * Math.PI * radius;
}

然后找出所有调用circum函数的地方,将其改为circumference

在不同的编程语言环境中,“找到所有调用旧函数的地方”这件事的难度也各异。静态类型加上趁手的IDE能提供最好的体验,通常可以全自动地完成函数改名,出错的概率极低。如果没有静态类型,就需要多花些工夫:即便再好的搜索工具,也可能会找出很多同名但并非同一函数的地方。

增减参数的做法也相同:找出所有调用者,修改函数声明,然后修改调用者。最好是能分步骤修改:如果既想给函数改名,又想添加参数,我会先完成改名,测试,然后添加参数,然后再次测试。

这个重构的简单做法缺点在于,我必须一次性修改所有调用者和函数声明(或者说,所有的函数声明,如果有多态的话)。如果只有不多的几处调用者,或者如果有可靠的自动化重构工具,这样做是没问题的。但如果调用者很多,事情就会变得很棘手。另外,如果函数的名字并不唯一,也可能造成问题。例如,我想给代表“人”的Person类的changeAddress函数改名,但同时在代表“保险合同”的InsuranceAgreement类中也有一个同名的函数,而我并不想修改后者的名字。修改越是复杂,我就越不希望一步到位地完成。如果有这些问题出现,我就会改为使用迁移式做法。同样,如果使用简单做法时出了什么错,我也会把代码回滚到上一个已知正确的状态,并改用迁移式做法再来一遍。

还是这个名字太过简略的函数:

function circum(radius) {
  return 2 * Math.PI * radius;
}

按照迁移式做法,我首先要对整个函数体使用提炼函数(106)

function circum(radius) {
  return circumference(radius);
}
function circumference(radius) {
  return 2 * Math.PI * radius;
}

此时我要执行测试,然后对旧函数使用内联函数(115):找出所有调用旧函数的地方,将其改为调用新函数。每次修改之后都可以执行测试,这样我就可以小步前进,每次修改一处调用者。所有调用者都修改完之后,我就可以删除旧函数。

大多数重构手法只用于修改我有权修改的代码,但这个重构手法同样适用于已发布API——使用这些API的代码我无权修改。以上面的代码为例,创建出circumference函数之后,我就可以暂停重构,并(如果可以的话)将circum函数标记为deprecated。然后我就耐心等待客户端改用circumference函数,等他们都改完了,我再删除circum函数。即便永远也抵达不了“删除circum函数”这个快乐的终点,至少新代码有了一个更好的名字。

想象一个管理图书馆的软件,其中有代表“图书”的Book类,它可以接受顾客(customer)的预订(reservation):

class Book...
addReservation(customer) {
  this._reservations.push(customer);
}

现在我需要支持“高优先级预订”,因此我要给addReservation额外添加一个参数,用于标记这次预订应该进入普通队列还是优先队列。如果能很容易地找到并修改所有调用方,我可以直接修改;但如果不行,我仍然可以采用迁移式做法,下面是详细的过程。

首先,我用提炼函数(106)addReservation的函数体提炼出来,放进一个新函数。这个新函数最终会叫addReservation,但新旧两个函数不能同时占用这个名字,所以我会先给新函数起一个容易搜索的临时名字。

class Book...
addReservation(customer) {
  this.zz_addReservation(customer);
}
zz_addReservation(customer) { 
  this._reservations.push(customer);
}

然后我会在新函数的声明中增加参数,同时修改旧函数中调用新函数的地方(也就是采用简单做法完成这一步)。

class Book...
addReservation(customer) {
  this.zz_addReservation(customer, false);
}

zz_addReservation(customer, isPriority) {
  this._reservations.push(customer);
}

在修改调用方之前,我喜欢利用JavaScript的语言特性先应用引入断言(302),确保调用方一定会用到这个新参数。

class Book...
zz_addReservation(customer, isPriority) {
  assert(isPriority === true || isPriority === false);
  this._reservations.push(customer);
}

现在,如果我在修改调用方时出了错,没有提供新参数,这个断言会帮我抓到错误——以我过去的经验来看,比我更容易出错的程序员怕是不多。

现在,我可以对源函数使用内联函数(115),使其调用者转而使用新函数。这样我可以每次只修改一个调用者。

现在我就可以把新函数改回原来的名字了。一般而言,此时用简单做法就够了;但如果有必要,也可以再用一遍迁移式做法。

此前的范例都很简单:改个名,增加一个参数。有了迁移式做法以后,这个重构手法可以相当利落地处理更复杂的情况。下面就是一个更复杂的例子。

假设我有一个函数,用于判断顾客(customer)是不是来自新英格兰(New England)地区:

function inNewEngland(aCustomer) {
  return ["MA", "CT", "ME", "VT", "NH", "RI"].includes(aCustomer.address.state);
}

下面是一个调用该函数的地方:

调用方...
const newEnglanders = someCustomers.filter(c => inNewEngland(c));

inNewEngland函数只用到了顾客所在的州(state)这项信息,基于这个信息来判断顾客是否来自新英格兰地区。我希望重构这个函数,使其接受州代码(state code)作为参数,这样就能去掉对“顾客”概念的依赖,使这个函数能在更多的上下文中使用。

在使用改变函数声明时,我通常会先运用提炼函数(106),但在这里我会先对函数体做一点重构,使后面的重构步骤更简单。我先用提炼变量(119)提炼出我想要的新参数:

function inNewEngland(aCustomer) {
  const stateCode = aCustomer.address.state;
  return ["MA", "CT", "ME", "VT", "NH", "RI"].includes(stateCode);
}

然后再用提炼函数(106)创建新函数:

function inNewEngland(aCustomer) {
  const stateCode = aCustomer.address.state;
  return xxNEWinNewEngland(stateCode);
}

function xxNEWinNewEngland(stateCode) {
  return ["MA", "CT", "ME", "VT", "NH", "RI"].includes(stateCode);
}

我会给新函数起一个好记又独特的临时名字,这样回头要改回原来的名字时也会简单一些。(你也看到,对于怎么起这些临时名字,我并没有统一的标准。)

我会在源函数中使用内联变量(123),把刚才提炼出来的参数内联回去:

function inNewEngland(aCustomer) {
  return xxNEWinNewEngland(aCustomer.address.state);
}

然后我会用内联函数(115)把旧函数内联到调用处,其效果就是把旧函数的调用处改为调用新函数。我可以每次修改一个调用处。

调用方...
const newEnglanders = someCustomers.filter(c => xxNEWinNewEngland(c.address.state));

旧函数被内联到各调用处之后,我就再次使用改变函数声明,把新函数改回旧名字:

调用方...
const newEnglanders = someCustomers.filter(c => inNewEngland(c.address.state));
顶层作用域...
function inNewEngland(stateCode) {
  return ["MA", "CT", "ME", "VT", "NH", "RI"].includes(stateCode);
}

自动化重构工具减少了迁移式做法的用武之地,同时也使迁移式做法更加高效。自动化重构工具可以安全地处理相当复杂的改名、参数变更等情况,所以迁移式做法的用武之地就变少了,因为自动化重构工具经常能提供足够的支持。如果遇到类似这里的例子,尽管工具无法自动完成整个重构,还是可以更快、更安全地完成关键的提炼和内联步骤,从而简化整个重构过程。

曾用名:自封装字段(Self-Encapsulate Field)

曾用名:封装字段(Encapsulate Field)

let defaultOwner = {firstName: "Martin", lastName: "Fowler"};

let defaultOwnerData = {firstName: "Martin", lastName: "Fowler"};
export function defaultOwner()       {return defaultOwnerData;}
export function setDefaultOwner(arg) {defaultOwnerData = arg;}

重构的作用就是调整程序中的元素。函数相对容易调整一些,因为函数只有一种用法,就是调用。在改名或搬移函数的过程中,总是可以比较容易地保留旧函数作为转发函数(即旧代码调用旧函数,旧函数再调用新函数)。这样的转发函数通常不会存在太久,但的确能够简化重构过程。

数据就要麻烦得多,因为没办法设计这样的转发机制。如果我把数据搬走,就必须同时修改所有引用该数据的代码,否则程序就不能运行。如果数据的可访问范围很小,比如一个小函数内部的临时变量,那还不成问题。但如果可访问范围变大,重构的难度就会随之增大,这也是说全局数据是大麻烦的原因。

所以,如果想要搬移一处被广泛使用的数据,最好的办法往往是先以函数形式封装所有对该数据的访问。这样,我就能把“重新组织数据”的困难任务转化为“重新组织函数”这个相对简单的任务。

封装数据的价值还不止于此。封装能提供一个清晰的观测点,可以由此监控数据的变化和使用情况;我还可以轻松地添加数据被修改时的验证或后续逻辑。我的习惯是:对于所有可变的数据,只要它的作用域超出单个函数,我就会将其封装起来,只允许通过函数访问。数据的作用域越大,封装就越重要。处理遗留代码时,一旦需要修改或增加使用可变数据的代码,我就会借机把这份数据封装起来,从而避免继续加重耦合一份已经广泛使用的数据。

面向对象方法如此强调对象的数据应该保持私有(private),背后也是同样的原理。每当看见一个公开(public)的字段时,我就会考虑使用封装变量(在这种情况下,这个重构手法常被称为封装字段)来缩小其可见范围。一些更激进的观点认为,即便在类内部,也应该通过访问函数来使用字段——这种做法也称为“自封装”。大体而言,我认为自封装有点儿过度了——如果一个类大到需要将字段自封装起来的程度,那么首先应该考虑把这个类拆小。不过,在分拆类之前,自封装字段倒是一个有用的步骤。

封装数据很重要,不过,不可变数据更重要。如果数据不能修改,就根本不需要数据更新前的验证或者其他逻辑钩子。我可以放心地复制数据,而不用搬移原来的数据——这样就不用修改使用旧数据的代码,也不用担心有些代码获得过时失效的数据。不可变性是强大的代码防腐剂。

有时没办法阻止直接访问变量。若果真如此,可以试试将变量改名,再执行测试,找出仍在直接使用该变量的代码。

下面这个全局变量中保存了一些有用的数据:

let defaultOwner = {firstName: "Martin", lastName: "Fowler"};

使用它的代码平淡无奇:

spaceship.owner = defaultOwner;

更新这段数据的代码是这样:

defaultOwner = {firstName: "Rebecca", lastName: "Parsons"};

首先我要定义读取和写入这段数据的函数,给它做个基础的封装。

function getDefaultOwner() {return defaultOwner;}
function setDefaultOwner(arg) {defaultOwner = arg;}

然后就开始处理使用defaultOwner的代码。每看见一处引用该数据的代码,就将其改为调用取值函数。

spaceship.owner = getDefaultOwner();

每看见一处给变量赋值的代码,就将其改为调用设值函数。

setDefaultOwner({firstName: "Rebecca", lastName: "Parsons"});

每次替换之后,执行测试。

处理完所有使用该变量的代码之后,我就可以限制它的可见性。这一步的用意有两个,一来是检查是否遗漏了变量的引用,二来可以保证以后的代码也不会直接访问该变量。在JavaScript中,我可以把变量和访问函数搬移到单独一个文件中,并且只导出访问函数,这样就限制了变量的可见性。

defaultOwner.js...
let defaultOwner = {firstName: "Martin", lastName: "Fowler"}; 
export function getDefaultOwner()    {return defaultOwner;} 
export function setDefaultOwner(arg) {defaultOwner = arg;}

如果条件不允许限制对变量的访问,可以将变量改名,然后再次执行测试,检查是否仍有代码在直接使用该变量。这阻止不了未来的代码直接访问变量,不过可以给变量起个有意义又难看的名字(例如__privateOnly_defaultOwner),提醒后来的客户端。

我不喜欢给取值函数加上get前缀,所以我对这个函数改名。

defaultOwner.js...
let defaultOwnerData = {firstName: "Martin", lastName: "Fowler"}; 
export function getdefaultOwner()      {return defaultOwnerData;} 
export function setDefaultOwner(arg) {defaultOwnerData = arg;}

JavaScript有一种惯例:给取值函数和设值函数起同样的名字,根据有没有传入参数来区分。我把这种做法称为“重载取值/设值函数”(Overloaded Getter Setter)[mf-orgs],并且我强烈反对这种做法。所以,虽然我不喜欢get前缀,但我会保留set前缀。

前面介绍的基本重构手法对数据结构的引用做了封装,使我能控制对该数据结构的访问和重新赋值,但并不能控制对结构内部数据项的修改:

const owner1 = defaultOwner(); 
assert.equal("Fowler", owner1.lastName, "when set"); 
const owner2 = defaultOwner();
owner2.lastName = "Parsons";
assert.equal("Parsons", owner1.lastName, "after change owner2"); // is this ok?

前面的基本重构手法只封装了对最外层数据的引用。很多时候这已经足够了。但也有很多时候,我需要把封装做得更深入,不仅控制对变量引用的修改,还要控制对变量内容的修改。

这有两个办法可以做到。最简单的办法是禁止对数据结构内部的数值做任何修改。我最喜欢的一种做法是修改取值函数,使其返回该数据的一份副本。

defaultOwner.js...
let defaultOwnerData = {firstName: "Martin", lastName: "Fowler"};
export function defaultOwner()       {return Object.assign({}, defaultOwnerData);} 
export function setDefaultOwner(arg) {defaultOwnerData = arg;}

对于列表数据,我尤其常用这一招。如果我在取值函数中返回数据的一份副本,客户端可以随便修改它,但不会影响到共享的这份数据。但在使用副本的做法时,我必须格外小心:有些代码可能希望能修改共享的数据。若果真如此,我就只能依赖测试来发现问题了。另一种做法是阻止对数据的修改,比如通过封装记录(162)就能很好地实现这一效果。

let defaultOwnerData = {firstName: "Martin", lastName: "Fowler"};
export function defaultOwner()       {return new Person(defaultOwnerData);} 
export function setDefaultOwner(arg) {defaultOwnerData = arg;}

class Person { 
 constructor(data) {
  this._lastName = data.lastName; 
  this._firstName = data.firstName
 }
 get lastName() {return this._lastName;}
 get firstName() {return this._firstName;}
 // and so on for other properties

现在,如果客户端调用defaultOwner函数获得“默认拥有人”数据、再尝试对其属性(即lastNamefirstName)重新赋值,赋值不会产生任何效果。对于侦测或阻止修改数据结构内部的数据项,各种编程语言有不同的方式,所以我会根据当下使用的语言来选择具体的办法。

“侦测和阻止修改数据结构内部的数据项”通常只是个临时处置。随后我可以去除这些修改逻辑,或者提供适当的修改函数。这些都处理完之后,我就可以修改取值函数,使其返回一份数据副本。

到目前为止,我都在讨论“在取数据时返回一份副本”,其实设值函数也可以返回一份副本。这取决于数据从哪儿来,以及我是否需要保留对源数据的连接,以便知悉源数据的变化。如果不需要这样一条连接,那么设值函数返回一份副本就有好处:可以防止因为源数据发生变化而造成的意外事故。很多时候可能没必要复制一份数据,不过多一次复制对性能的影响通常也都可以忽略不计。但是,如果不做复制,风险则是未来可能会陷入漫长而困难的调试排错过程。

请记住,前面提到的数据复制、类封装等措施,都只在数据记录结构中深入了一层。如果想走得更深入,就需要更多层级的复制或是封装。

如你所见,数据封装很有价值,但往往并不简单。到底应该封装什么,以及如何封装,取决于数据被使用的方式,以及我想要修改数据的方式。不过,一言以蔽之,数据被使用得越广,就越是值得花精力给它一个体面的封装。

let a = height * width;

let area = height * width;

好的命名是整洁编程的核心。变量可以很好地解释一段程序在干什么——如果变量名起得好的话。但我经常会把名字起错——有时是因为想得不够仔细,有时是因为我对问题的理解加深了,还有时是因为程序的用途随着用户的需求改变了。

使用范围越广,名字的好坏就越重要。只在一行的lambda表达式中使用的变量,跟踪起来很容易——像这样的变量,我经常只用一个字母命名,因为变量的用途在这个上下文中很清晰。同理,短函数的参数名也常常很简单。不过在JavaScript这样的动态类型语言中,我喜欢把类型信息也放进名字里(于是变量名可能叫aCustomer)。

对于作用域超出一次函数调用的字段,则需要更用心命名。这是我最花心思的地方。

如果在另一个代码库中使用了该变量,这就是一个“已发布变量”(published variable),此时不能进行这个重构。

如果变量值从不修改,可以将其复制到一个新名字之下,然后逐一修改使用代码,每次修改后执行测试。

如果要改名的变量只作用于一个函数(临时变量或者参数),对其改名是最简单的。这种情况太简单,根本不需要范例:找到变量的所有引用,修改过来就行。完成修改之后,我会执行测试,确保没有破坏什么东西。

如果变量的作用域不止于单个函数,问题就会出现。代码库的各处可能有很多地方使用它:

let tpHd = "untitled";

有些地方是在读取变量值:

result += `<h1>${tpHd}</h1>`;

另一些地方则更新它的值:

tpHd = obj['articleTitle'];

对于这种情况,我通常的反应是运用封装变量(132)

result += `<h1>${title()}</h1>`;

setTitle(obj['articleTitle']); 

 function title()       {return tpHd;}
 function setTitle(arg) {tpHd = arg;}

现在就可以给变量改名:

let _title = "untitled";

function title()       {return _title;}
function setTitle(arg) {_title = arg;}

我可以继续重构下去,将包装函数内联回去,这样所有的调用者就变回直接使用变量的状态。不过我很少这样做。如果这个变量被广泛使用,以至于我感到需要先做封装才敢改名,那就有必要保持这个状态,将变量封装在函数后面。

如果我确实想内联,在重构过程中,我就会将取值函数命名为getTitle,并且其中的变量名也不会以下划线开头。

如果我想改名的是一个常量(或者在客户端看来就像是常量的元素),我可以复制这个常量,这样既不需要封装,又可以逐步完成改名。假如原来的变量声明是这样:

const cpyNm = "Acme Gooseberries";

改名的第一步是复制这个常量:

const companyName = "Acme Gooseberries"; 
const cpyNm = companyName;

有了这个副本,我就可以逐一修改引用旧常量的代码,使其引用新的常量。全部修改完成后,我会删掉旧的常量。我喜欢先声明新的常量名,然后把新常量复制给旧的名字。这样最后删除旧名字时会稍微容易一点,如果测试失败,再把旧常量放回来也稍微容易一点。

这个做法不仅适用于常量,也同样适用于客户端只能读取的变量(例如JavaScript模块中导出的变量)。

function amountInvoiced(startDate, endDate) {...} 
function amountReceived(startDate, endDate) {...} 
function amountOverdue(startDate, endDate) {...}

function amountInvoiced(aDateRange) {...} 
function amountReceived(aDateRange) {...} 
function amountOverdue(aDateRange) {...}

我常会看见,一组数据项总是结伴同行,出没于一个又一个函数。这样一组数据就是所谓的数据泥团,我喜欢代之以一个数据结构。

将数据组织成结构是一件有价值的事,因为这让数据项之间的关系变得明晰。使用新的数据结构,参数的参数列表也能缩短。并且经过重构之后,所有使用该数据结构的函数都会通过同样的名字来访问其中的元素,从而提升代码的一致性。

但这项重构真正的意义在于,它会催生代码中更深层次的改变。一旦识别出新的数据结构,我就可以重组程序的行为来使用这些结构。我会创建出函数来捕捉围绕这些数据的共用行为——可能只是一组共用的函数,也可能用一个类把数据结构与使用数据的函数组合起来。这个过程会改变代码的概念图景,将这些数据结构提升为新的抽象概念,可以帮助我更好地理解问题域。果真如此,这个重构过程会产生惊人强大的效用——但如果不用引入参数对象开启这个过程,后面的一切都不会发生。

我倾向于使用类,因为稍后把行为放进来会比较容易。我通常会尽量确保这些新建的数据结构是值对象[mf-vo]。

下面要展示的代码会查看一组温度读数(reading),检查是否有任何一条读数超出了指定的运作温度范围(range)。温度读数的数据如下:

const station = { name: "ZB1",
         readings: [
          {temp: 47, time: "2016-11-10 09:10"},
          {temp: 53, time: "2016-11-10 09:20"},
          {temp: 58, time: "2016-11-10 09:30"},
          {temp: 53, time: "2016-11-10 09:40"},
          {temp: 51, time: "2016-11-10 09:50"},
         ]
        };

下面的函数负责找到超出指定范围的温度读数:

function readingsOutsideRange(station, min, max) { 
 return station.readings
  .filter(r => r.temp < min || r.temp > max);
}

调用该函数的代码可能是下面这样的。

调用方
alerts = readingsOutsideRange(station,
               operatingPlan.temperatureFloor, 
               operatingPlan.temperatureCeiling);

请注意,这里的调用代码从另一个对象中抽出两项数据,转手又把这一对数据传递给readingsOutsideRange。代表“运作计划”的operatingPlan对象用了另外的名字来表示温度范围的下限和上限,与readingsOutsideRange中所用的名字不同。像这样用两项各不相干的数据来表示一个范围的情况并不少见,最好是将其组合成一个对象。我会首先为要组合的数据声明一个类:

class NumberRange { 
 constructor(min, max) {
  this._data = {min: min, max: max};
 }
 get min() {return this._data.min;} 
 get max() {return this._data.max;}
}

我声明了一个类,而不是基本的JavaScript对象,因为这个重构通常只是一系列重构的起点,随后我会把行为搬移到新建的对象中。既然类更适合承载数据与行为的组合,我就直接从声明一个类开始。同时,在这个新类中,我不会提供任何更新数据的函数,因为我有可能将其处理成值对象(Value Object)[mf-vo]。在使用这个重构手法时,大多数情况下我都会创建值对象。

然后我会运用改变函数声明(124),把新的对象作为参数传给readingsOutsideRange

function readingsOutsideRange(station, min, max, range) { 
 return station.readings
  .filter(r => r.temp < min || r.temp > max);
}

在JavaScript中,此时我不需要修改调用方代码,但在其他语言中,我必须在调用处为新参数传入null值,就像下面这样。

调用方
alerts = readingsOutsideRange(station,
               operatingPlan.temperatureFloor, 
               operatingPlan.temperatureCeiling, 
               null);

到目前为止,我还没有修改任何行为,所以测试应该仍然能通过。随后,我会挨个找到函数的调用处,传入合适的温度范围。

调用方
const range = new NumberRange(operatingPlan.temperatureFloor, operatingPlan.temperatureCeiling);
alerts = readingsOutsideRange(station,
               operatingPlan.temperatureFloor, 
               operatingPlan.temperatureCeiling, 
               range);

此时我还是没有修改任何行为,因为新添的参数没有被使用。所有测试应该仍然能通过。

现在我可以开始修改使用参数的代码了。先从“最大值”开始:

function readingsOutsideRange(station, min, max, range) { 
 return station.readings
  .filter(r => r.temp < min || r.temp > range.max);
}
调用方
const range = new NumberRange(operatingPlan.temperatureFloor, operatingPlan.temperatureCeiling); 
alerts = readingsOutsideRange(station,
               operatingPlan.temperatureFloor, 
               operatingPlan.temperatureCeiling,
               range);

此时要执行测试。如果测试通过,我再接着处理另一个参数。

function readingsOutsideRange(station, min, range) { 
 return station.readings
  .filter(r => r.temp < range.min || r.temp > range.max);
}
调用方
const range = new NumberRange(operatingPlan.temperatureFloor, operatingPlan.temperatureCeiling); 
alerts = readingsOutsideRange(station,
               operatingPlan.temperatureFloor,
               range);

这项重构手法到这儿就完成了。不过,将一堆参数替换成一个真正的对象,这只是长征第一步。创建一个类是为了把行为搬移进去。在这里,我可以给“范围”类添加一个函数,用于测试一个值是否落在范围之内。

function readingsOutsideRange(station, range) { 
 return station.readings
  .f ilter(r => !range.contains(r.temp));
}
class NumberRange...
contains(arg) {return (arg >= this.min && arg <= this.max);}

这样我就迈出了第一步,开始逐渐打造一个真正有用的“范围”[mf-range]类。一旦识别出“范围”这个概念,那么每当我在代码中发现“最大/最小值”这样一对数字时,我就会考虑是否可以将其改为使用“范围”类。(例如,我马上就会考虑把“运作计划”类中的temperatureFloortemperatureCeiling替换为temperatureRange。)在观察这些成对出现的数字如何被使用时,我会发现一些有用的行为,并将其搬移到“范围”类中,简化其使用方法。比如,我可能会先给这个类加上“基于数值判断相等性”的函数,使其成为一个真正的值对象。

function base(aReading) {...}
function taxableCharge(aReading) {...} 
function calculateBaseCharge(aReading) {...}

class Reading { 
  base() {...}
  taxableCharge() {...} 
  calculateBaseCharge() {...}
}

类,在大多数现代编程语言中都是基本的构造。它们把数据与函数捆绑到同一个环境中,将一部分数据与函数暴露给其他程序元素以便协作。它们是面向对象语言的首要构造,在其他程序设计方法中也同样有用。

如果发现一组函数形影不离地操作同一块数据(通常是将这块数据作为参数传递给函数),我就认为,是时候组建一个类了。类能明确地给这些函数提供一个共用的环境,在对象内部调用这些函数可以少传许多参数,从而简化函数调用,并且这样一个对象也可以更方便地传递给系统的其他部分。

除了可以把已有的函数组织起来,这个重构还给我们一个机会,去发现其他的计算逻辑,将它们也重构到新的类当中。

将函数组织到一起的另一种方式是函数组合成变换(149)。具体使用哪个重构手法,要看程序整体的上下文。使用类有一大好处:客户端可以修改对象的核心数据,通过计算得出的派生数据则会自动与核心数据保持一致。

类似这样的一组函数不仅可以组合成一个类,而且可以组合成一个嵌套函数。通常我更倾向于类而非嵌套函数,因为后者测试起来会比较困难。如果我想对外暴露多个函数,也必须采用类的形式。

在有些编程语言中,类不是一等公民,而函数则是。面对这样的语言,可以用“函数作为对象”(Function As Object)[mf-fao]的形式来实现这个重构手法。

如果多个函数共用的数据还未组织成记录结构,则先运用引入参数对象(140)将其组织成记录。

如果函数调用时传入的参数已经是新类的成员,则从参数列表中去除之。

我在英格兰长大,那是一个热爱喝茶的国度。(个人而言,我不喜欢在英格兰喝到的大部分茶,对中国茶和日本茶倒是情有独钟。)所以,我虚构了一种用于向老百姓供给茶水的公共设施。每个月会有软件读取茶水计量器的数据,得到类似这样的读数(reading):

reading = {customer: "ivan", quantity: 10, month: 5, year: 2017};

浏览处理这些数据记录的代码,我发现有很多地方在做着相似的计算,于是我找到了一处计算“基础费用”(base charge)的逻辑。

客户端1...
const aReading = acquireReading();
const baseCharge = baseRate(aReading.month, aReading.year) * aReading.quantity;

在英格兰,一切生活必需品都得交税,茶自然也不例外。不过,按照规定,只要不超出某个必要用量,就不用交税。

客户端2...
const aReading = acquireReading();
const base = (baseRate(aReading.month, aReading.year) * aReading.quantity); 
const taxableCharge = Math.max(0, base - taxThreshold(aReading.year));

我相信你也发现了:计算基础费用的公式被重复了两遍。如果你跟我有一样的习惯,现在大概已经在着手提炼函数(106)了。有趣的是,好像别人已经动过这个脑筋了。

客户端3...
const aReading = acquireReading();
const basicChargeAmount = calculateBaseCharge(aReading);

function calculateBaseCharge(aReading) {
  return baseRate(aReading.month, aReading.year) * aReading.quantity;
}

看到这里,我有一种自然的冲动,想把前面两处客户端代码都改为使用这个函数。但这样一个顶层函数的问题在于,它通常位于一个文件中,读者不一定能想到来这里寻找它。我更愿意对代码多做些修改,让该函数与其处理的数据在空间上有更紧密的联系。为此目的,不妨把数据本身变成一个类。

我可以运用封装记录(162)将记录变成类。

class Reading {
 constructor(data) {
  this._customer = data.customer;
  this._quantity = data.quantity;
  this._month = data.month;
  this._year = data.year;
 }
 get customer() {return this._customer;}
 get quantity() {return this._quantity;}
 get month()    {return this._month;}
 get year()     {return this._year;}
}

首先,我想把手上已有的函数calculateBaseCharge搬到新建的Reading类中。一得到原始的读数数据,我就用Reading类将它包装起来,然后就可以在函数中使用Reading类了。

客户端3...
const rawReading = acquireReading(); 
const aReading = new Reading(rawReading);
const basicChargeAmount = calculateBaseCharge(aReading);

然后我用搬移函数(198)calculateBaseCharge搬到新类中。

class Reading...
get calculateBaseCharge() {
  return baseRate(this.month, this.year) * this.quantity;
}
客户端3...
const rawReading = acquireReading(); 
const aReading = new Reading(rawReading);
const basicChargeAmount = aReading.calculateBaseCharge;

搬移的同时,我会顺便运用函数改名(124),按照我喜欢的风格对这个函数改名。

get baseCharge() {
  return baseRate(this.month, this.year) * this.quantity;
}
客户端3...
const rawReading = acquireReading(); 
const aReading = new Reading(rawReading);
const basicChargeAmount = aReading.baseCharge;

用这个名字,Reading类的客户端将不知道baseCharge究竟是一个字段还是推演计算出的值。这是好事,它符合“统一访问原则”(Uniform Access Principle)[mf-ua]。

现在我可以修改客户端1的代码,令其调用新的方法,不要重复计算基础费用。

客户端1...
const rawReading = acquireReading(); 
const aReading = new Reading(rawReading);
const baseCharge = aReading.baseCharge;

很有可能我会顺手用内联变量(123)baseCharge变量给去掉。不过,我们当下介绍的重构手法更关心“计算应税费用”的逻辑。同样,我先将那里的客户端代码改为使用新建的baseCharge属性。

客户端2...
const rawReading = acquireReading(); 
const aReading = new Reading(rawReading);
const taxableCharge = Math.max(0, aReading.baseCharge - taxThreshold(aReading.year));

运用提炼函数(106)将计算应税费用(taxable charge)的逻辑提炼成函数:

function taxableChargeFn(aReading) {
  return Math.max(0, aReading.baseCharge - taxThreshold(aReading.year));
}
客户端3...
const rawReading = acquireReading(); 
const aReading = new Reading(rawReading);
const taxableCharge = taxableChargeFn(aReading);

然后我运用搬移函数(198)将其移入Reading类:

class Reading...
get taxableCharge() {
  return Math.max(0, this.baseCharge - taxThreshold(this.year));
}
客户端3...
const rawReading = acquireReading(); 
const aReading = new Reading(rawReading);
const taxableCharge = aReading.taxableCharge;

由于所有派生数据都是在使用时计算得出的,所以对存储下来的读数进行修改也没问题。一般而论,我更倾向于使用不可变的数据;但很多时候我们必须得使用可变数据(比如JavaScript整个语言生态在设计时就没有考虑数据的不可变性)。如果数据确有可能被更新,那么用类将其封装起来会很有帮助。

function base(aReading) {...}
function taxableCharge(aReading) {...}

function enrichReading(argReading) {
  const aReading = _.cloneDeep(argReading);
  aReading.baseCharge = base(aReading);
  aReading.taxableCharge = taxableCharge(aReading);
  return aReading;
}

在软件中,经常需要把数据“喂”给一个程序,让它再计算出各种派生信息。这些派生数值可能会在几个不同地方用到,因此这些计算逻辑也常会在用到派生数据的地方重复。我更愿意把所有计算派生数据的逻辑收拢到一处,这样始终可以在固定的地方找到和更新这些逻辑,避免到处重复。

一个方式是采用数据变换(transform)函数:这种函数接受源数据作为输入,计算出所有的派生数据,将派生数据以字段形式填入输出数据。有了变换函数,我就始终只需要到变换函数中去检查计算派生数据的逻辑。

函数组合成变换的替代方案是函数组合成类(144),后者的做法是先用源数据创建一个类,再把相关的计算逻辑搬移到类中。这两个重构手法都很有用,我常会根据代码库中已有的编程风格来选择使用其中哪一个。不过,两者有一个重要的区别:如果代码中会对源数据做更新,那么使用类要好得多;如果使用变换,派生数据会被存储在新生成的记录中,一旦源数据被修改,我就会遭遇数据不一致。

我喜欢把函数组合起来的原因之一,是为了避免计算派生数据的逻辑到处重复。从道理上来说,只用提炼函数(106)也能避免重复,但孤立存在的函数常常很难找到,只有把函数和它们操作的数据放在一起,用起来才方便。引入变换(或者类)都是为了让相关的逻辑找起来方便。

这一步通常需要对输入的记录做深复制(deep copy)。此时应该写个测试,确保变换不会修改原来的记录。

如果计算逻辑比较复杂,先用提炼函数(106)提炼之。

在我长大的国度,茶是生活中的重要部分,以至于我想象了这样一种特别的公共设施,专门给老百姓供应茶水。每个月,从这个设备上可以得到读数(reading),从而知道每位顾客取用了多少茶。

reading = {customer: "ivan", quantity: 10, month: 5, year: 2017};

几个不同地方的代码分别根据茶的用量进行计算。一处是计算应该向顾客收取的基本费用。

客户端1...
const aReading = acquireReading();
const baseCharge = baseRate(aReading.month, aReading.year) * aReading.quantity;

另一处是计算应该交税的费用—比基本费用要少,因为政府明智地认为,每个市民都有权免税享受一定量的茶水。

客户端2...
const aReading = acquireReading();
const base = (baseRate(aReading.month, aReading.year) * aReading.quantity); 
const taxableCharge = Math.max(0, base - taxThreshold(aReading.year));

浏览处理这些数据记录的代码,我发现有很多地方在做着相似的计算。这样的重复代码,一旦需要修改(我打赌这只是早晚的问题),就会造成麻烦。我可以用提炼函数(106)来处理这些重复的计算逻辑,但这样提炼出来的函数会散落在程序中,以后的程序员还是很难找到。说真的,我还真在另一块代码中找到了一个这样的函数。

客户端3...
const aReading = acquireReading();
const basicChargeAmount = calculateBaseCharge(aReading);

function calculateBaseCharge(aReading) {
  return baseRate(aReading.month, aReading.year) * aReading.quantity;
}

处理这种情况的一个办法是,把所有这些计算派生数据的逻辑搬移到一个变换函数中,该函数接受原始的“读数”作为输入,输出则是增强的“读数”记录,其中包含所有共用的派生数据。

我先要创建一个变换函数,它要做的事很简单,就是复制输入的对象:

function enrichReading(original) { 
  const result = _.cloneDeep(original); 
  return result;
}

我用了Lodash库的cloneDeep函数来进行深复制。

这个变换函数返回的本质上仍是原来的对象,只是添加了更多的信息在上面。对于这种函数,我喜欢用“enrich”(增强)这个词来给它命名。如果它生成的是跟原来完全不同的对象,我就会用“transform”(变换)来命名它。

然后我挑选一处想要搬移的计算逻辑。首先,我用现在的enrichReading函数来增强“读数”记录,尽管该函数暂时还什么都没做。

客户端3...
const rawReading = acquireReading();
const aReading = enrichReading(rawReading);
const basicChargeAmount = calculateBaseCharge(aReading);

然后我运用搬移函数(198)calculateBaseCharge函数搬移到增强过程中:

function enrichReading(original) {
  const result = _.cloneDeep(original);
  result.baseCharge = calculateBaseCharge(result);
  return result;
}

在变换函数内部,我乐得直接修改结果对象,而不是每次都复制一个新对象。我喜欢不可变的数据,但在大部分编程语言中,保持数据完全不可变很困难。在程序模块的边界处,我做好了心理准备,多花些精力来支持不可变性。但在较小的范围内,我可以接受可变的数据。另外,我把这里用到的变量命名为aReading,表示它是一个累积变量(accumulating variable)。这样当我把更多的逻辑搬移到变换函数enrichReading中时,这个变量名也仍然适用。

修改客户端代码,令其改用增强后的字段:

客户端3...
const rawReading = acquireReading();
const aReading = enrichReading(rawReading); 
const basicChargeAmount = aReading.baseCharge;

当所有调用calculateBaseCharge的地方都修改完成后,就可以把这个函数内嵌到enrichReading函数中,从而更清楚地表明态度:如果需要“计算基本费用”的逻辑,请使用增强后的记录。

在这里要当心一个陷阱:在编写enrichReading函数时,我让它返回了增强后的读数记录,这背后隐含的意思是原始的读数记录不会被修改。所以我最好为此加个测试。

it('check reading unchanged', function() {
  const baseReading = {customer: "ivan", quantity: 15, month: 5, year: 2017}; 
  const oracle = _.cloneDeep(baseReading);
  enrichReading(baseReading); 
  assert.deepEqual(baseReading, oracle);
});

现在我可以修改客户端1的代码,让它也使用这个新添的字段。

客户端1...
const rawReading = acquireReading();
const aReading = enrichReading(rawReading);
const baseCharge = aReading.baseCharge;

此时可以考虑用内联变量(123)去掉baseCharge变量。

现在我转头去看“计算应税费用”的逻辑。第一步是把变换函数用起来:

const rawReading = acquireReading();
const aReading = enrichReading(rawReading);
const base = (baseRate(aReading.month, aReading.year) * aReading.quantity); 
const taxableCharge = Math.max(0, base - taxThreshold(aReading.year));

基本费用的计算逻辑马上就可以改用变换得到的新字段代替。如果计算逻辑比较复杂,我可以先运用提炼函数(106)。不过这里的情况足够简单,一步到位修改过来就行。

const rawReading = acquireReading();
const aReading = enrichReading(rawReading); 
const base = aReading.baseCharge;
const taxableCharge = Math.max(0, base - taxThreshold(aReading.year));

执行测试之后,我就用内联变量(123)去掉base变量:

const rawReading = acquireReading();
const aReading = enrichReading(rawReading);
const taxableCharge = Math.max(0, aReading.baseCharge - taxThreshold(aReading.year));

然后把计算逻辑搬移到变换函数中:

function enrichReading(original) { 
  const result = _.cloneDeep(original);
  result.baseCharge = calculateBaseCharge(result);
  result.taxableCharge = Math.max(0, result.baseCharge - taxThreshold(result.year));
  return result;
}

修改使用方代码,让它使用新添的字段。

const rawReading = acquireReading();
const aReading = enrichReading(rawReading); 
const taxableCharge = aReading.taxableCharge;

测试。现在我可以再次用内联变量(123)taxableCharge变量也去掉。

增强后的读数记录有一个大问题:如果某个客户端修改了一项数据的值,会发生什么?比如说,如果某处代码修改了quantity字段的值,就会导致数据不一致。在JavaScript中,避免这种情况最好的办法是不要使用本重构手法,改用函数组合成类(144)。如果编程语言支持不可变的数据结构,那么就没有这个问题了,那样的语言中会更常用到变换。但即便编程语言不支持数据结构不可变,如果数据是在只读的上下文中被使用(例如在网页上显示派生数据),还是可以使用变换。

const orderData = orderString.split(/\s+/);
const productPrice = priceList[orderData[0].split("-")[1]]; 
const orderPrice = parseInt(orderData[1]) * productPrice;

const orderRecord = parseOrder(order);
const orderPrice = price(orderRecord, priceList);

function parseOrder(aString) {
 const values = aString.split(/\s+/); 
 return ({
  productID: values[0].split("-")[1], 
  quantity: parseInt(values[1]),
 });
}
function price(order, priceList) {
 return order.quantity * priceList[order.productID];
}

每当看见一段代码在同时处理两件不同的事,我就想把它拆分成各自独立的模块,因为这样到了需要修改的时候,我就可以单独处理每个主题,而不必同时在脑子里考虑两个不同的主题。如果运气够好的话,我可能只需要修改其中一个模块,完全不用回忆起另一个模块的诸般细节。

最简洁的拆分方法之一,就是把一大段行为分成顺序执行的两个阶段。可能你有一段处理逻辑,其输入数据的格式不符合计算逻辑的要求,所以你得先对输入数据做一番调整,使其便于处理。也可能是你把数据处理逻辑分成顺序执行的多个步骤,每个步骤负责的任务全然不同。

编译器是最典型的例子。编译器的任务很直观:接受文本(用某种编程语言编写的代码)作为输入,将其转换成某种可执行的格式(例如针对某种特定硬件的目标码)。随着经验加深,我们发现把这项大任务拆分成一系列阶段会很有帮助:首先对文本做词法分析,然后把token解析成语法树,然后再对语法树做几步转换(如优化),最后生成目标码。每一步都有边界明确的范围,我可以聚焦思考其中一步,而不用理解其他步骤的细节。

在大型软件中,类似这样的阶段拆分很常见,例如编译器的每个阶段又包含若干函数和类。即便只有不大的一块代码,只要我发现了有益的将其拆分成多个阶段的机会,同样可以运用拆分阶段重构手法。如果一块代码中出现了上下几段,各自使用不同的一组数据和函数,这就是最明显的线索。将这些代码片段拆分成各自独立的模块,能更明确地标示出它们之间的差异。

有时第二阶段根本不应该使用某个参数。果真如此,就把使用该参数得到的结果全都提炼成中转数据结构的字段,然后用搬移语句到调用者(217)把使用该参数的代码行搬移到“第二阶段函数”之外。

也可以把第一阶段提炼成一个变换(transform)对象。

我手上有一段“计算订单价格”的代码,至于订单中的商品是什么,我们从代码中看不出来,也不太关心。

function priceOrder(product, quantity, shippingMethod) { 
 const basePrice = product.basePrice * quantity;
 const discount = Math.max(quantity - product.discountThreshold, 0)
     * product.basePrice * product.discountRate;
 const shippingPerCase = (basePrice > shippingMethod.discountThreshold)
     ? shippingMethod.discountedFee : shippingMethod.feePerCase; 
 const shippingCost = quantity * shippingPerCase;
 const price = basePrice - discount + shippingCost; 
 return price;
}

虽然只是个常见的、过于简单的范例,从中还是能看出有两个不同阶段存在的。前两行代码根据商品(product)信息计算订单中与商品相关的价格,随后的两行则根据配送(shipping)信息计算配送成本。后续的修改可能还会使价格和配送的计算逻辑变复杂,但只要这两块逻辑相对独立,将这段代码拆分成两个阶段就是有价值的。

我首先用提炼函数(106)把计算配送成本的逻辑提炼出来。

function priceOrder(product, quantity, shippingMethod) { 
 const basePrice = product.basePrice * quantity;
 const discount = Math.max(quantity - product.discountThreshold, 0)
     * product.basePrice * product.discountRate;
 const price = applyShipping(basePrice, shippingMethod, quantity, discount);
 return price;
}
function applyShipping(basePrice, shippingMethod, quantity, discount) {
 const shippingPerCase = (basePrice > shippingMethod.discountThreshold)
     ? shippingMethod.discountedFee : shippingMethod.feePerCase; 
 const shippingCost = quantity * shippingPerCase;
 const price = basePrice - discount + shippingCost; 
 return price;
}

第二阶段需要的数据都以参数形式传入。在真实环境下,参数的数量可能会很多,但我对此并不担心,因为很快就会将这些参数消除掉。

随后我会引入一个中转数据结构,使其在两阶段之间沟通信息。

function priceOrder(product, quantity, shippingMethod) { 
 const basePrice = product.basePrice * quantity;
 const discount = Math.max(quantity - product.discountThreshold, 0)
     * product.basePrice * product.discountRate;
 const priceData = {};
 const price = applyShipping(priceData, basePrice, shippingMethod, quantity, discount); 
 return price;
}

function applyShipping(priceData, basePrice, shippingMethod, quantity, discount) { 
 const shippingPerCase = (basePrice > shippingMethod.discountThreshold)
     ? shippingMethod.discountedFee : shippingMethod.feePerCase; 
 const shippingCost = quantity * shippingPerCase;
 const price = basePrice - discount + shippingCost; 
 return price;
}

现在我会审视applyShipping的各个参数。第一个参数basePrice是在第一阶段代码中创建的,因此我将其移入中转数据结构,并将其从参数列表中去掉。

function priceOrder(product, quantity, shippingMethod) { 
 const basePrice = product.basePrice * quantity;
 const discount = Math.max(quantity - product.discountThreshold, 0)
     * product.basePrice * product.discountRate; 
 const priceData = {basePrice: basePrice};
 const price = applyShipping(priceData, basePrice, shippingMethod, quantity, discount); 
 return price;
}
function applyShipping(priceData, basePrice, shippingMethod, quantity, discount) { 
 const shippingPerCase = (priceData.basePrice > shippingMethod.discountThreshold)
     ? shippingMethod.discountedFee : shippingMethod.feePerCase; 
 const shippingCost = quantity * shippingPerCase;
 const price = priceData.basePrice - discount + shippingCost; 
 return price;
}

下一个参数是shippingMethod。第一阶段中没有使用这项数据,所以它可以保留原样。

再下一个参数是quantity。这个参数在第一阶段中用到,但不是在那里创建的,所以其实可以将其留在参数列表中。但我更倾向于把尽可能多的参数搬移到中转数据结构中。

function priceOrder(product, quantity, shippingMethod) { 
 const basePrice = product.basePrice * quantity;
 const discount = Math.max(quantity - product.discountThreshold, 0)
     * product.basePrice * product.discountRate;
 const priceData = {basePrice: basePrice, quantity: quantity};
 const price = applyShipping(priceData, shippingMethod, quantity, discount); 
 return price;
}
function applyShipping(priceData, shippingMethod, quantity, discount) {
 const shippingPerCase = (priceData.basePrice > shippingMethod.discountThreshold)
     ? shippingMethod.discountedFee : shippingMethod.feePerCase; 
 const shippingCost = priceData.quantity * shippingPerCase;
 const price = priceData.basePrice - discount + shippingCost; 
 return price;
}

discount参数我也如法炮制。

function priceOrder(product, quantity, shippingMethod) { 
 const basePrice = product.basePrice * quantity;
 const discount = Math.max(quantity - product.discountThreshold, 0)
     * product.basePrice * product.discountRate;
 const priceData = {basePrice: basePrice, quantity: quantity, discount:discount}; 
 const price = applyShipping(priceData, shippingMethod, discount);
 return price;
}
function applyShipping(priceData, shippingMethod, discount) {
 const shippingPerCase = (priceData.basePrice > shippingMethod.discountThreshold)
     ? shippingMethod.discountedFee : shippingMethod.feePerCase; 
 const shippingCost = priceData.quantity * shippingPerCase;
 const price = priceData.basePrice - priceData.discount + shippingCost; 
 return price;
}

处理完参数列表后,中转数据结构得到了完整的填充,现在我可以把第一阶段代码提炼成独立的函数,令其返回这份数据。

function priceOrder(product, quantity, shippingMethod) { 
 const priceData = calculatePricingData(product, quantity); 
 const price = applyShipping(priceData, shippingMethod); 
 return price;
}
function calculatePricingData(product, quantity) {
 const basePrice = product.basePrice * quantity;
 const discount = Math.max(quantity - product.discountThreshold, 0)
     * product.basePrice * product.discountRate;
 return {basePrice: basePrice, quantity: quantity, discount:discount};
}
function applyShipping(priceData, shippingMethod) {
 const shippingPerCase = (priceData.basePrice > shippingMethod.discountThreshold)
     ? shippingMethod.discountedFee : shippingMethod.feePerCase; 
 const shippingCost = priceData.quantity * shippingPerCase;
 const price = priceData.basePrice - priceData.discount + shippingCost; 
 return price;
}

两个函数中,最后一个const变量都是多余的,我忍不住洁癖,将它们内联消除掉。

function priceOrder(product, quantity, shippingMethod) { 
 const priceData = calculatePricingData(product, quantity); 
 return applyShipping(priceData, shippingMethod);
}


function calculatePricingData(product, quantity) { 
 const basePrice = product.basePrice * quantity;
 const discount = Math.max(quantity - product.discountThreshold, 0)
     * product.basePrice * product.discountRate;
 return {basePrice: basePrice, quantity: quantity, discount:discount};
}
function applyShipping(priceData, shippingMethod) {
 const shippingPerCase = (priceData.basePrice > shippingMethod.discountThreshold)
     ? shippingMethod.discountedFee : shippingMethod.feePerCase; 
 const shippingCost = priceData.quantity * shippingPerCase;
 return priceData.basePrice - priceData.discount + shippingCost;
}


相关图书

程序员的README
程序员的README
有限元基础与COMSOL案例分析
有限元基础与COMSOL案例分析
现代控制系统(第14版)
现代控制系统(第14版)
现代软件工程:如何高效构建软件
现代软件工程:如何高效构建软件
GitLab CI/CD 从入门到实战
GitLab CI/CD 从入门到实战
科学知识图谱:工具、方法与应用
科学知识图谱:工具、方法与应用

相关文章

相关课程