Main
重构:改善既有代码的设计(第2版)(异步图书)
重构:改善既有代码的设计(第2版)(异步图书)
马丁·福勒(Martin Fowler) [马丁·福勒(Martin Fowler)]
5.0 /
4.5
How much do you like this book?
What’s the quality of the file?
Download the book for quality assessment
What’s the quality of the downloaded files?
本书是经典著作《重构》出版20年后的新版。书中清晰揭示了重构的过程,解释了重构的原理和最佳实践方式,并给出了何时以及何地应该开始挖掘代码以求改善。书中给出了60多个可行的重构,每个重构都介绍了一种经过验证的代码变换手法的动机和技术。本书提出的重构准则将帮助开发人员一次一小步地修改代码,从而减少了开发过程中的风险。 本书适合软件开发人员、项目管理人员等阅读,也可作为高等院校计算机及相关专业师生的参考读物。
Categories:
Year:
2019
Publisher:
人民邮电出版社
Language:
chinese
ISBN 10:
7115508658
ISBN 13:
9787115508652
ISBN:
B07QKC6RN7
File:
PDF, 4.53 MB
Your tags:
Download (pdf, 4.53 MB)
- Open in Browser
- Checking other formats...
- Convert to EPUB
- Convert to FB2
- Convert to MOBI
- Convert to TXT
- Convert to RTF
- Converted file can differ from the original. If possible, download the file in its original format.
Report a problem
This book has a different problem? Report it to us
Check Yes if
Check Yes if
Check Yes if
Check Yes if
you were able to open the file
the file contains a book (comics are also acceptable)
the content of the book is acceptable
Title, Author and Language of the file match the book description. Ignore other fields as they are secondary!
Check No if
Check No if
Check No if
Check No if
- the file is damaged
- the file is DRM protected
- the file is not a book (e.g. executable, xls, html, xml)
- the file is an article
- the file is a book excerpt
- the file is a magazine
- the file is a test blank
- the file is a spam
you believe the content of the book is unacceptable and should be blocked
Title, Author or Language of the file do not match the book description. Ignore other fields.
Are you sure the file is of bad quality? Report about it
Change your answer
Thanks for your participation!
Together we will make our library even better
Together we will make our library even better
The file will be sent to your email address. It may take up to 1-5 minutes before you receive it.
The file will be sent to your Kindle account. It may takes up to 1-5 minutes before you received it.
Please note: you need to verify every book you want to send to your Kindle. Check your mailbox for the verification email from Amazon Kindle.
Please note: you need to verify every book you want to send to your Kindle. Check your mailbox for the verification email from Amazon Kindle.
Conversion to is in progress
Conversion to is failed
You may be interested in Powered by Rec2Me
Most frequent terms
function593
const486
arg141
constructor125
perf122
customer103
quantity87
employee82
invoice79
bird79
usage71
extends69
switch69
usd65
bug63
playfor60
fowler58
martin57
plays57
replace55
unknown50
format50
discount46
default45
let perf42
comedy41
plumage41
credits38
bliki38
areading38
anumber36
kent32
astring31
https30
salesman29
booking24
Related Booklists
1 comment
Heisenberg
从其他格式转过来的文字版,排版不太行
converted from another form, not a genuine pdf
converted from another form, not a genuine pdf
29 May 2022 (15:38)
You can write a book review and share your experiences. Other readers will always be interested in your opinion of the books you've read. Whether you've loved the book or not, if you give your honest and detailed thoughts then people will find new books that are right for them.
1
|
目 录 版权信息 内容提要 版权声明 对本书的赞誉 重读《重构》,呼唤匠艺(译者序) 译者简介 第1版序 前言 服务与支持 第1章 重构,第一个示例 1.1 起点 1.2 对此起始程序的评价 1.3 重构的第一步 1.4 分解statement函数 1.5 进展:大量嵌套函数 1.6 拆分计算阶段与格式化阶段 1.7 进展:分离到两个文件(和两个阶段) 1.8 按类型重组计算过程 1.9 进展:使用多态计算器来提供数据 1.10 结语 第2章 重构的原则 2.1 何谓重构 2.2 两顶帽子 2.3 为何重构 2.4 何时重构 2.5 重构的挑战 2.6 重构、架构和YAGNI 2.7 重构与软件开发过程 2.8 重构与性能 2.9 重构起源何处 2.10 自动化重构 2.11 延展阅读 第3章 代码的坏味道 3.1 神秘命名(Mysterious Name) 3.2 重复代码(Duplicated Code) 3.3 过长函数(Long Function) 3.4 过长参数列表(Long Parameter List) 3.5 全局数据(Global Data) 3.6 可变数据(Mutable Data) 3.7 发散式变化(Divergent Change) 3.8 霰弹式修改(Shotgun Surgery) 3.9 依恋情结(Feature Envy) 3.10 数据泥团(Data Clumps) 3.11 基本类型偏执(Primitive Obsession) 3.12 重复的switch (Repeated Switches) 3.13 循环语句(Loops) 3.14 冗赘的元素(Lazy Element) 3.15 夸夸其谈通用性(Speculative Generality) 3.16 临时字段(Temporary Field) 3.17 过长的消息链(Message Chains) 3.18 中间人(Middle Man) 3.19 内幕交易(Insider Trading) 3.20 过大的类(Large Class) 3.21 异曲同工的类(Alternative Classes with Different Interfaces) 3.22 纯数据类(Data Class) 3.23 被拒绝的遗赠(Refused Bequest) 3.24 注释(Comments) 第4章 构筑测试体系 4.1 自测试代码的价值 4.2 待测试的示例代码 4.3 第一个测试 4.4 再添加一个测试 4.5 修改测试夹具 4.6 探测边界条件 4.7 测试远不止如此 第5章 介绍重构名录 5.1 重构的记录格式 5.2 挑选重构的依据 第6章 第一组重构 6.1 提炼函数(Extract Function) 6.2 内联函数(Inline Function) 6.3 提炼变量(Extract Variable) 6.4 内联变量(Inline Variable) 6.5 改变函数声明(Change Function Declaration) 6.6 封装变量(Encapsulate Variable) 6.7 变量改名(Rename Variable) 6.8 引入参数对象(Introduce Parameter Object) 6.9 函数组合成类(Combine Functions into Class) 6.10 函数组合成变换(Combine Functions into Transform) 6.11 拆分阶段(Split Phase) 第7章 封装 7.1 封装记录(Encapsulate Record) 7.2 封装集合(Encapsulate Collection) 7.3 以对象取代基本类型(Replace Primitive with Object) 7.4 以查询取代临时变量(Replace Temp with Query) 7.5 提炼类(Extract Class) 7.6 内联类(Inline Class) 7.7 隐藏委托关系(Hide Delegate) 7.8 移除中间人(Remove Middle Man) 7.9 替换算法(Substitute Algorithm) 第8章 搬移特性 8.1 搬移函数(Move Function) 8.2 搬移字段(Move Field) 8.3 搬移语句到函数(Move Statements into Function) 8.4 搬移语句到调用者(Move Statements to Callers) 8.5 以函数调用取代内联代码(Replace Inline Code with Function Call) 8.6 移动语句(Slide Statements) 8.7 拆分循环(Split Loop) 8.8 ; 以管道取代循环(Replace Loop with Pipeline) 8.9 移除死代码(Remove Dead Code) 第9章 重新组织数据 9.1 拆分变量(Split Variable) 9.2 字段改名(Rename Field) 9.3 以查询取代派生变量(Replace Derived Variable with Query) 9.4 将引用对象改为值对象(Change Reference to Value) 9.5 将值对象改为引用对象(Change Value to Reference) 第10章 简化条件逻辑 10.1 分解条件表达式(Decompose Conditional) 10.2 合并条件表达式(Consolidate Conditional Expression) 10.3 以卫语句取代嵌套条件表达式(Replace Nested Conditional with Guard Clauses) 10.4 以多态取代条件表达式(Replace Conditional with Polymorphism) 10.5 引入特例(Introduce Special Case) 10.6 引入断言(Introduce Assertion) 第11章 重构API 11.1 将查询函数和修改函数分离(Separate Query from Modifier) 11.2 函数参数化(Parameterize Function) 11.3 移除标记参数(Remove Flag Argument) 11.4 保持对象完整(Preserve Whole Object) 11.5 以查询取代参数(Replace Parameter with Query) 11.6 以参数取代查询(Replace Query with Parameter) 11.7 移除设值函数(Remove Setting Method) 11.8 以工厂函数取代构造函数(Replace Constructor with Factory Function) 11.9 以命令取代函数(Replace Function with Command) 11.10 以函数取代命令(Replace Command with Function) 第12章 处理继承关系 12.1 函数上移(Pull Up Method) 12.2 字段上移(Pull Up Field) 12.3 构造函数本体上移(Pull Up Constructor Body) 12.4 函数下移(Push Down Method) 12.5 字段下移(Push Down Field) 12.6 以子类取代类型码(Replace Type Code with Subclasses) 12.7 移除子类(Remove Subclass) 12.8 提炼超类(Extract Superclass) 12.9 折叠继承体系(Collapse Hierarchy) 12.10 以委托取代子类(Replace Subclass with Delegate) 12.11 以委托取代超类(Replace Superclass with Delegate) 参考文献 重构列表 坏味道与重构手法速查表 版权信息 书名:重构:改善既有代码的设计(第2版) ISBN:978-7-115-50865-2 本书由人民邮电出版社发行数字版。版权所有,侵权必究。 您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复 制和传播本书内容。 我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。 如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权 措施,并可能追究法律责任。 著 [美] 马丁•福勒(Martin Fowler) 译 熊 节 林从羽 责任编辑 人民邮电出版社出版发行 电子邮件 北京市丰台区成寿寺路11号 邮编 100164 网址 http://www.ptpress.com.cn 315@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爱好者,纯键 盘工作者。 第1版序 “重构”这个概念来自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)——的时候,这份列表就会提醒我如何一步一步安全前进。我 希望这是值得你日后一再回顾的部分。 一本Web优先的书 1 万维网对我们的社会影响深远,尤其是改变了我们获取信息的方式。在撰写 本书第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展示代码范例,也不意味着本书介绍的技巧只适用于 JavaScript。本书的第1版采用了Java,但很多从未写过任何Java代码的程序员也同 样认为这些技巧很有用。我曾经尝试过用十多种不同的编程语言来呈现这些范 例,以此展示重构手法的通用性,不过这对普通读者而言只会带来困惑。本书是 为所有编程语言背景的程序员所作,除了阅读“范例”小节时需要一些基本的 JavaScript知识,本书的其余部分都不特定于任何具体的编程语言。我希望读者能 汲取本书的内容,并将其应用于自己日常使用的编程语言。具体而言,我希望读 者能先理解本书中的JavaScript范例代码,然后再将其适配到自己习惯的编程语 言。 因此,除了在特殊情况下,当我谈到“类”“模块”“函数”等词汇时,我都按照 它们在程序设计领域的一般含义来使用这些词,而不是以其在JavaScript语言模型 中的特殊含义来使用。 我只把JavaScript用作一种示例语言,因此我也会尽量避免使用其他程序员可 能不太熟悉的编程风格。这不是一本“用JavaScript进行重构”的书,而是一本关于 重构的通用书籍,只是采用了JavaScript作为示例。有很多JavaScript特有的重构 手法很有意思(如将回调重构成promise或async/await),但这些不是本书要讨论 的内容。 谁该阅读本书 本书的目标读者是专业程序员,也就是那些以编写软件为生的人。书中的范 例和讨论,涉及大量需要详细阅读和理解的代码。这些例子都用JavaScript写成, 不过这些重构手法应该适用于大部分编程语言。为了理解书中的内容,读者需要 有一定的编程经验,但需要的知识并不多。 本书的首要目标读者群是想要学习重构的软件开发者,同时对于已经理解重 构的人也有价值——本书可以作为一本教学辅助书。在本书中,我用了大量篇幅 详细解释各个重构手法的过程和原理,因此有经验的开发人员可以用本书来指导 同事。 尽管本书的关注对象是代码,但重构对于系统设计也有巨大影响。资深设计 师和架构师也很有必要了解重构原理,并在自己的项目中运用重构技术。最好是 由有威望的、经验丰富的开发人员来引入重构技术,因为这样的人最能够透彻理 解重构背后的原理,并根据情况加以调整,使之适用于特定工作领域。如果你使 用的不是JavaScript而是其他编程语言,这一点尤其重要,因为你必须把我给出的 范例用其他编程语言改写。 下面我要告诉你,如何能够在不通读全书的情况下充分用好它。 如果你想知道重构是什么,请阅读第1章,其中的示例会让你弄清楚重构的过 程。 如果你想知道为什么应该重构,请阅读前两章,它们会告诉你重构是什么以及 为什么应该重构。 如果你想知道该在什么地方重构,请阅读第3章,它会告诉你一些代码特征, 这些特征指出“这里需要重构”。 如果你想着手进行重构,请完整阅读前四章,然后选择性地阅读重构名录。一 开始只需概略浏览列表,看看其中有些什么,不必理解所有细节。一旦真正需 要实施某个重构手法,再详细阅读它,从中获取帮助。列表部分是供查阅的参 考性内容,你不必一次就把它全部读完。 给形形色色的重构手法命名是编写本书的重要部分。合适的词汇能帮助我们 彼此沟通。当一名开发者向另一名开发者提出建议,将一段代码提取成为一个函 数,或者将计算逻辑拆分成几个阶段,双方都能理解提炼函数(106)和拆分阶 段(154)是什么意思。这份词汇表也能帮助开发者选择自动化的重构手法。 1 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、 测试、前端、网络技术等。 异步社区 微信服务号 第1章 重构,第一个示例 我该从何说起呢?按照传统做法,一开始介绍某样东西时应该先大致讲讲它 的历史、主要原理等。可是每当有人在会场上介绍这些东西,总是诱发我的瞌睡 虫。我的思绪开始游荡,我的眼神开始迷离,直到主讲人秀出示例,我才能够提 起精神。 示例之所以可以拯救我于太虚之中,因为它让我看见事情在真正进行。谈原 理,很容易流于泛泛,又很难说明如何实际应用。给出一个示例,就可以帮助我 把事情认识清楚。 因此,我决定从一个示例说起。在此过程中我会谈到很多重构的工作方式, 并且让你对重构过程有一点点感觉。然后在下一章中我才能向你展开通常的原理 介绍。 但是,面对这个介绍性示例,我遇到了一个大问题。如果我选择一个大型程 序,那么对程序自身的描述和对整个重构过程的描述就太复杂了,任何读者都不 忍卒读(我试了一下,哪怕稍微复杂一点的例子都会超过100页)。如果我选择 一个容易理解的小程序,又恐怕看不出重构的价值。 和任何立志要介绍“应用于真实世界的程序中的有用技术”的人一样,我陷入 了一个十分典型的两难困境。我只能带你看看如何在一个我选择的小程序中进行 重构,然而坦白说,那个程序的规模根本不值得我们这么做。但是,如果我给你 看的代码是大系统的一部分,重构技术很快就变得重要起来。所以请你一边观赏 这个小例子,一边想象它身处于一个大得多的系统。 1.1 起点 在本书第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.json和plays.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 1.2 对此起始程序的评价 你觉得这个程序设计得怎么样?我的第一感觉是,代码组织不甚清晰,但这 还在可忍受的限度内。这样小的程序,不做任何深入的设计,也不会太难理解。 但我前面讲过,这是因为要保证例子足够小的缘故。如果这段代码身处于一个更 大规模——也许是几百行——的程序中,把所有代码放到一个函数里就很难理解 了。 尽管如此,这个程序还是能正常工作。那么是不是说,对其结构“不甚清 晰”的评价只是美学意义上的判断,只是对所谓丑陋代码的反感呢?毕竟编译器 也不会在乎代码好不好看。但是,当我们需要修改系统时,就涉及了人,而人在 乎这些。差劲的系统是很难修改的,因为很难找到修改点,难以了解做出的修改 与现有代码如何协作实现我想要的行为。如果很难找到修改点,我就很有可能犯 错,从而引入bug。 因此,如果我需要修改一个有几百行代码的程序,我会期望它有良好的结 构,并且已经被分解成一系列函数和其他程序要素,这能帮我更易于清楚地了解 这段代码在做什么。如果程序杂乱无章,先为它整理出结构来,再做需要的修 改,通常来说更加简单。 如果你要给程序添加一个特性,但发现代码因缺乏良好的结构而不易 于进行更改,那就先重构那个程序,使其比较容易添加该特性,然后再添加该 特性。 在这个例子里,我们的用户希望对系统做几个修改。首先,他们希望以 HTML格式输出详单。现在请你想一想,这个变化会带来什么影响。对于每处追 加字符串到result变量的地方我都得为它们添加分支逻辑。这会为函数引入更多 复杂度。遇到这种需求时,很多人会选择直接复制整个方法,在其中修改输出 HTML的部分。复制一遍代码似乎不算太难,但却给未来留下各种隐患:一旦计 费逻辑发生变化,我就得同时修改两个地方,以保证它们逻辑相同。如果你编写 的是一个永不需要修改的程序,这样剪剪贴贴就还好。但如果程序要保存很长时 间,那么重复的逻辑就会造成潜在的威胁。 现在,第二个变化来了:演员们尝试在表演类型上做更多突破,无论是历史 剧、田园剧、田园喜剧、田园史剧、历史悲剧还是历史田园悲喜剧,无论一成不 变的正统戏,还是千变万幻的新派戏,他们都希望有所尝试,只是还没有决定试 哪种以及何时试演。这对戏剧场次的计费方式、积分的计算方式都有影响。作为 一个经验丰富的开发者,我可以肯定:不论最终提出什么方案,他们一定会在6 个月之内再次修改它。毕竟,需求通常不来则已,一来便会接踵而至。 为了应对分类规则和计费规则的变化,程序必须对statement函数做出修改。 但如果我把statement内的代码复制到用以打印HTML详单的函数中,就必须确保将 来的任何修改在这两个地方保持一致。随着各种规则变得越来越复杂,适当的修 改点将越来越难找,不犯错的机会也越来越少。 我再强调一次,是需求的变化使重构变得必要。如果一段代码能正常工作, 并且不会再被修改,那么完全可以不去重构它。能改进之当然很好,但若没人需 要去理解它,它就不会真正妨碍什么。如果确实有人需要理解它的工作原理,并 且觉得理解起来很费劲,那你就需要改进一下代码了。 1.3 重构的第一步 每当我要进行重构的时候,第一个步骤永远相同:我得确保即将修改的代码 拥有一组可靠的测试。这些测试必不可少,因为尽管遵循重构手法可以使我避免 绝大多数引入bug的情形,但我毕竟是人,毕竟有可能犯错。程序越大,我的修 改不小心破坏其他代码的可能性就越大——在数字时代,软件的名字就是脆弱。 statement函数的返回值是一个字符串,我做的就是创建几张新的账单 (invoice),假设每张账单收取了几出戏剧的费用,然后使用这几张账单作为输 入调用statement函数,生成对应的对账单(statement)字符串。我会拿生成的字 符串与我已经手工检查过的字符串做比对。我会借助一个测试框架来配置好这些 测试,只要在开发环境中输入一行命令就可以把它们运行起来。运行这些测试只 需几秒钟,所以你会看到我经常运行它们。 测试过程中很重要的一部分,就是测试程序对于结果的报告方式。它们要么 变绿,表示所有新字符串都和参考字符串一样,要么就变红,然后列出失败清 单,显示问题字符串的出现行号。这些测试都能够自我检验。使测试能自我检验 至关重要,否则就得耗费大把时间来回比对,这会降低开发速度。现代的测试框 架都提供了丰富的设施,支持编写和运行能够自我检验的测试。 重构前,先检查自己是否有一套可靠的测试集。这些测试必须有自我 检验能力。 进行重构时,我需要依赖测试。我将测试视为bug检测器,它们能保护我不 被自己犯的错误所困扰。把我想要达成的目标写两遍——代码里写一遍,测试里 再写一遍——我就得犯两遍同样的错误才能骗过检测器。这降低了我犯错的概 率,因为我对工作进行了二次确认。尽管编写测试需要花费时间,但却为我节省 下可观的调试时间。构筑测试体系对重构来说实在太重要了,因此我将用第4章 一整章的笔墨来详细讨论它。 1.4 分解statement函数 每当看到这样长长的函数,我便下意识地想从整个函数中分离出不同的关注 点。第一个引起我注意的就是中间那段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),以便日后可以方便地引用。 首先,我需要检查一下,如果我将这块代码提炼到自己的一个函数里,有哪 些变量会离开原本的作用域。在此示例中,是perf、play和thisAmount这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,但我对这个参数另有安排。 移除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; } 这里我只展示了一步到位的改名结果,不过实际操作时,我还是一次只将一 个变量改名,并在每次改名后执行编译、测试、提交。 移除format变量 我们再看一下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步,每一步都伴随着一次编译、测试以及向本地代码库的提交: 使用拆分循环(227)分离出累加过程; 使用移动语句(223)将累加变量的声明与累加过程集中到一起; 使用提炼函数(106)提炼出计算总数的函数; 使用内联变量(123)完全移除中间变量。 我得坦白,我并非总是如此小步——但在事情变复杂时,我的第一反应就是 采用更小的步子。怎样算变复杂呢,就是当重构过程有测试失败而我又无法马上 看清问题所在并立即修复时,我就会回滚到最后一次可工作的提交,然后以更小 的步子重做。这得益于我如此频繁地提交。特别是与复杂代码打交道时,细小的 步子是快速前进的关键。 接着我要重复同样的步骤来移除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; } 1.5 进展:大量嵌套函数 重构至此,是时候停下来欣赏一下代码的全貌了。 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行代码,而且它 处理的都是与打印详单相关的逻辑。与计算相关的逻辑从主函数中被移走,改由 一组函数来支持。每个单独的计算过程和详单的整体结构,都因此变得更易理解 了。 1.6 拆分计算阶段与格式化阶段 到目前为止,我的重构主要是为原函数添加足够的结构,以便我能更好地理 解它,看清它的逻辑结构。这也是重构早期的一般步骤。把复杂的代码块分解为 更小的单元,与好的命名一样都很重要。现在,我可以更多关注我要修改的功能 部分了,也就是为这张详单提供一个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字段也搬移过去,这样我就可以移除掉renderPlainText的 invoice参数(编译、测试、提交)。 顶层作用域... 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字段的地方,可以把数据放进去。我需要对 playFor和statement函数应用搬移函数(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函数中。 function 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也能访问它。) 1.7 进展:分离到两个文件(和两个阶段) 现在正是停下来重新回顾一下代码的好时机,思考一下重构的进展。现在我 有了两个代码文件。 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版本的代码,而无须重复计算部分的逻 辑。 编程时,需要遵循营地法则:保证你离开时的代码库一定比来时更健 康。 其实打印逻辑还可以进一步简化,但当前的代码也够用了。我经常需要在所 有可做的重构与添加新特性之间寻找平衡。在当今业界,大多数人面临同样的选 择时,似乎多以延缓重构而告终——当然这也是一种选择。我的观点则与营地法 则无异:保证离开时的代码库一定比你来时更加健康。完美的境界很难达到,但 应该时时都勤加拂拭。 1.8 按类型重组计算过程 接下来我将注意力集中到下一个特性改动:支持更多类型的戏剧,以及支持 它们各自的价格计算和观众量积分计算。对于现在的结构,我只需要在计算函数 里添加分支逻辑即可。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)手法将performance的play字段传给计 算器。 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); } 1.9 进展:使用多态计算器来提供数据 又到了观摩代码的时刻,让我们来看看,为计算器引入多态会对代码库有什 么影响。 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); } } 代码量仍然有所增加,因为我再次整理了代码结构。新结构带来的好处是, 不同戏剧种类的计算各自集中到了一处地方。如果大多数修改都涉及特定类型的 计算,像这样按类型进行分离就很有意义。当添加新剧种时,只需要添加一个子 类,并在创建函数中返回它。 这个示例还揭示了一些关于此类继承方案何时适用的洞见。上面我将条件分 支的查找从两个不同的函数(amountFor和volumeCreditsFor)搬移到一个集中的 构造函数createPerformanceCalculator中。有越多的函数依赖于同一套类型进行 多态,这种继承方案就越有益处。 除了这样设计,还有另一种可能的方案,那就是让createStatementData返回 计算器实例本身,而非自己拿到计算器来填充中转数据结构。JavaScript的类设计 有不少好特性,例如,取值函数用起来就像普通的数据存取。我在考量是“直接 返回实例本身”还是“返回计算好的中转数据”时,主要看数据的使用者是谁。在 这个例子中,我更想通过中转数据结构来展示如何以此隐藏计算器背后的多态设 计。 1.10 结语 这是一个简单的例子,但我希望它能让你对“重构怎么做”有一点感觉。例中 我已经示范了数种重构手法,包括提炼函数(106)、内联变量(123)、搬移函 数(198)和以多态取代条件表达式(272)等。 本章的重构有3个较为重要的节点,分别是:将原函数分解成一组嵌套的函 数、应用拆分阶段(154)分离计算逻辑与输出格式化逻辑,以及为计算器引入 多态性来处理计算逻辑。每一步都给代码添加了更多的结构,以便我能更好地表 达代码的意图。 一般来说,重构早期的主要动力是尝试理解代码如何工作。通常你需要先通 读代码,找到一些感觉,然后再通过重构将这些感觉从脑海里搬回到代码中。清 晰的代码更容易理解,使你能够发现更深层次的设计问题,从而形成积极正向的 反馈环。当然,这个示例仍有值得改进的地方,但现在测试仍能全部通过,代码 相比初见时已经有了巨大的改善,所以我已经可以满足了。 我谈论的是如何改善代码,但什么样的代码才算好代码,程序员们有很多争 论。我偏爱小的、命名良好的函数,也知道有些人反对这个观点。如果我们说这 只关乎美学,只是各花入各眼,没有好坏高低之分,那除了诉诸个人品味,就没 有任何客观事实依据了。但我坚信,这不仅关乎个人品味,而且是有客观标准 的。我认为,好代码的检验标准就是人们是否能轻而易举地修改它。好代码应该 直截了当:有人需要修改代码时,他们应能轻易找到修改点,应该能快速做出更 改,而不易引入其他错误。一个健康的代码库能够最大限度地提升我们的生产 力,支持我们更快、更低成本地为用户添加新特性。为了保持代码库的健康,就 需要时刻留意现状与理想之间的差距,然后通过重构不断接近这个理想。 好代码的检验标准就是人们是否能轻而易举地修改它。 这个示例告诉我们最重要的一点就是重构的节奏感。无论何时,当我向人们 展示我如何重构时,无人不讶异于我的步子之小,并且每一步都保证代码处于编 译通过和测试通过的可工作状态。20年前,当Kent Beck在底特律的一家宾馆里向 我展示同样的手法时,我也报以同样的震撼。开展高效有序的重构,关键的心得 是:小的步子可以更快前进,请保持代码永远处于可工作状态,小步修改累积起 来也能大大改善系统的设计。这几点请君牢记,其余的我已无需多言。 第2章 重构的原则 前一章所举的例子应该已经让你对重构有了一个良好的感觉。现在,我们应 该回头看看重构的一些大原则。 2.1 何谓重构 一线的实践者们经常很随意地使用“重构”这个词——软件开发领域的很多词 汇都有此待遇。我使用这个词的方式比较严谨,并且我发现这种严谨的方式很有 好处。(下列定义与本书第1版中给出的定义一样。)“重构”这个词既可以用作 名词也可以用作动词。名词形式的定义是: 重构(名词):对软件内部结构的一种调整,目的是在不改变软件可观察行 为的前提下,提高其可理解性,降低其修改成本。 这个定义适用于我在前面的例子中提到的那些有名字的重构,例如提炼函数 (106)和以多态取代条件表达式(272)。 动词形式的定义是: 重构(动词):使用一系列重构手法,在不改变软件可观察行为的前提下, 调整其结构。 所以,我可能会花一两个小时进行重构(动词),其间我会使用几十个不同 的重构(名词)。 过去十几年,这个行业里的很多人用“重构”这个词来指代任何形式的代码清 理,但上面的定义所指的是一种特定的清理代码的方式。重构的关键在于运用大 量微小且保持软件行为的步骤,一步步达成大规模的修改。每个单独的重构要么 很小,要么由若干小步骤组合而成。因此,在重构的过程中,我的代码很少进入 不可工作的状态,即便重构没有完成,我也可以在任何时刻停下来。 如果有人说他们的代码在重构过程中有一两天时间不可用,基本上可 以确定,他们在做的事不是重构。 我会用“结构调整”(restructuring)来泛指对代码库进行的各种形式的重新组 织或清理,重构则是特定的一类结构调整。刚接触重构的人看我用很多小步骤完 成似乎可以一大步就能做完的事,可能会觉得这样很低效。但小步前进能让我走 得更快,因为这些小步骤能完美地彼此组合,而且——更关键的是——整个过程 中我不会花任何时间来调试。 在上述定义中,我用了“可观察行为”的说法。它的意思是,整体而言,经过 重构之后的代码所做的事应该与重构之前大致一样。这个说法并非完全严格,并 且我是故意保留这点儿空间的:重构之后的代码不一定与重构前行为完全一致。 比如说,提炼函数(106)会改变函数调用栈,因此程序的性能就会有所改变; 改变函数声明(124)和搬移函数(198)等重构经常会改变模块的接口。不过就 用户应该关心的行为而言,不应该有任何改变。如果我在重构过程中发现了任何 bug,重构完成后同样的bug应该仍然存在(不过,如果潜在的bug还没有被任何 人发现,也可以当即把它改掉)。 重构与性能优化有很多相似之处:两者都需要修改代码,并且两者都不会改 变程序的整体功能。两者的差别在于其目的:重构是为了让代码“更容易理解, 更易于修改”。这可能使程序运行得更快,也可能使程序运行得更慢。在性能优 化时,我只关心让程序运行得更快,最终得到的代码有可能更难理解和维护,对 此我有心理准备。 2.2 两顶帽子 Kent Beck提出了“两顶帽子”的比喻。使用重构技术开发软件时,我把自己的 时间分配给两种截然不同的行为:添加新功能和重构。添加新功能时,我不应该 修改既有代码,只管添加新功能。通过添加测试并让测试正常运行,我可以衡量 自己的工作进度。重构时我就不能再添加功能,只管调整代码的结构。此时我不 应该添加任何测试(除非发现有先前遗漏的东西),只在绝对必要(用以处理接 口变化)时才修改测试。 软件开发过程中,我可能会发现自己经常变换帽子。首先我会尝试添加新功 能,然后会意识到:如果把程序结构改一下,功能的添加会容易得多。于是我换 一顶帽子,做一会儿重构工作。程序结构调整好后,我又换上原先的帽子,继续 添加新功能。新功能正常工作后,我又发现自己的编码造成程序难以理解,于是 又换上重构帽子……整个过程或许只花10分钟,但无论何时我都清楚自己戴的是 哪一顶帽子,并且明白不同的帽子对编程状态提出的不同要求。 2.3 为何重构 我不想把重构说成是包治百病的万灵丹,它绝对不是所谓的“银弹”。不过它 的确很有价值,尽管它不是一颗“银弹”,却可以算是一把“银钳子”,可以帮你始 终良好地控制自己的代码。重构是一个工具,它可以(并且应该)用于以下几个 目的。 重构改进软件的设计 如果没有重构,程序的内部设计(或者叫架构)会逐渐腐败变质。当人们只 为短期目的而修改代码时,他们经常没有完全理解架构的整体设计,于是代码逐 渐失去了自己的结构。程序员越来越难通过阅读源码来理解原来的设计。代码结 构的流失有累积效应。越难看出代码所代表的设计意图,就越难保护其设计,于 是设计就腐败得越快。经常性的重构有助于代码维持自己该有的形态。 完成同样一件事,设计欠佳的程序往往需要更多代码,这常常是因为代码在 不同的地方使用完全相同的语句做同样的事,因此改进设计的一个重要方向就是 消除重复代码。代码量减少并不会使系统运行更快,因为这对程序的资源占用几 乎没有任何明显影响。然而代码量减少将使未来可能的程序修改动作容易得多。 代码越多,做正确的修改就越困难,因为有更多代码需要理解。我在这里做了点 儿修改,系统却不如预期那样工作,因为我没有修改另一处——那里的代码做着 几乎完全一样的事情,只是所处环境略有不同。消除重复代码,我就可以确定所 有事物和行为在代码中只表述一次,这正是优秀设计的根本。 重构使软件更容易理解 所谓程序设计,很大程度上就是与计算机对话:我编写代码告诉计算机做什 么事,而它的响应是按照我的指示精确行动。一言以蔽之,我所做的就是填 补“我想要它做什么”和“我告诉它做什么”之间的缝隙。编程的核心就在于“准确说 出我想要的”。然而别忘了,除了计算机外,源码还有其他读者:几个月之后可 能会有另一位程序员尝试读懂我的代码并对其做一些修改。我们很容易忘记这这 位读者,但他才是最重要的。计算机是否多花了几个时钟周期来编译,又有什么 关系呢?如果一个程序员花费一周时间来修改某段代码,那才要命呢——如果他 理解了我的代码,这个修改原本只需一小时。 问题在于,当我努力让程序运转的时候,我不会想到未来出现的那个开发 者。是的,我们应该改变一下开发节奏,让代码变得更易于理解。重构可以帮我 让代码更易读。开始进行重构前,代码可以正常运行,但结构不够理想。在重构 上花一点点时间,就可以让代码更好地表达自己的意图——更清晰地说出我想要 做的。 关于这一点,我没必要表现得多么无私。很多时候那个未来的开发者就是我 自己。此时重构就显得尤其重要了。我是一个很懒惰的程序员,我的懒惰表现形 式之一就是:总是记不住自己写过的代码。事实上,对于任何能够立刻查阅的东 西,我都故意不去记它,因为我怕把自己的脑袋塞爆。我总是尽量把该记住的东 西写进代码里,这样我就不必记住它了。这么一来,下班后我还可以喝上两杯 Maudite啤酒,不必太担心它杀光我的脑细胞。 重构帮助找到bug 对代码的理解,可以帮我找到bug。我承认我不太擅长找bug。有些人只要盯 着一大段代码就可以找出里面的bug,我不行。但我发现,如果对代码进行重 构,我就可以深入理解代码的所作所为,并立即把新的理解反映在代码当中。搞 清楚程序结构的同时,我也验证了自己所做的一些假设,于是想不把bug揪出来 都难。 这让我想起了Kent Beck经常形容自己的一句话:“我不是一个特别好的程序 员,我只是一个有着一些特别好的习惯的还不错的程序员。”重构能够帮助我更 有效地写出健壮的代码。 重构提高编程速度 最后,前面的一切都归结到了这一点:重构帮我更快速地开发程序。 听起来有点儿违反直觉。当我谈到重构时,人们很容易看出它能够提高质 量。改善设计、提升可读性、减少bug,这些都能提高质量。但花在重构上的时 间,难道不是在降低开发速度吗? 当我跟那些在一个系统上工作较长时间的软件开发者交谈时,经常会听到这 样的故事:一开始他们进展很快,但如今想要添加一个新功能需要的时间就要长 得多。他们需要花越来越多的时间去考虑如何把新功能塞进现有的代码库,不断 蹦出来的bug修复起来也越来越慢。代码库看起来就像补丁摞补丁,需要细致的 考古工作才能弄明白整个系统是如何工作的。这份负担不断拖慢新增功能的速 度,到最后程序员恨不得从头开始重写整个系统。 下面这幅图可以描绘他们经历的困境。 但有些团队的境遇则截然不同。他们添加新功能的速度越来越快,因为他们 能利用已有的功能,基于已有的功能快速构建新功能。 两种团队的区别就在于软件的内部质量。需要添加新功能时,内部质量良好 的软件让我可以很容易找到在哪里修改、如何修改。良好的模块划分使我只需要 理解代码库的一小部分,就可以做出修改。如果代码很清晰,我引入bug的可能 性就会变小,即使引入了bug,调试也会容易得多。理想情况下,我的代码库会 逐步演化成一个平台,在其上可以很容易地构造与其领域相关的新功能。 我把这种现象称为“设计耐久性假说”:通过投入精力改善内部设计,我们增 加了软件的耐久性,从而可以更长时间地保持开发的快速。我还无法科学地证明 这个理论,所以我说它是一个“假说”。但我的经验,以及我在职业生涯中认识的 上百名优秀程序员的经验,都支持这个假说。 20年前,行业的陈规认为:良好的设计必须在开始编程之前完成,因为一旦 开始编写代码,设计就只会逐渐腐败。重构改变了这个图景。现在我们可以改善 已有代码的设计,因此我们可以先做一个设计,然后不断改善它,哪怕程序本身 的功能也在不断发生着变化。由于预先做出良好的设计非常困难,想要既体面又 快速地开发功能,重构必不可少。 2.4 何时重构 在我编程的每个小时,我都会做重构。有几种方式可以把重构融入我的工作 过程里。 三次法则 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之下,我就可以容忍它继续保持丑陋。只有 当我需要理解其工作原理时,对其进行重构才有价值。 另一种情况是,如果重写比重构还容易,就别重构了。这是个困难的决定。 如果不花一点儿时间尝试,往往很难真实了解重构一块代码的难度。决定到底应 该重构还是重写,需要良好的判断力与丰富的经验,我无法给出一条简单的建 议。 2.5 重构的挑战 每当有人大力推荐一种技术、工具或者架构时,我总是会观察这东西会遇到 哪些挑战,毕竟生活中很少有晴空万里的好事。你需要了解一件事背后的权衡取 舍,才能决定何时何地应用它。我认为重构是一种很有价值的技术,大多数团队 都应该更多地重构,但它也不是完全没有挑战的。有必要充分了解重构会遇到的 挑战,这样才能做出有效应对。 延缓新功能开发 如果你读了前面一小节,我对这个挑战的回应便已经很清楚了。尽管重构的 目的是加快开发速度,但是,仍旧很多人认为,花在重构的时间是在拖慢新功能 的开发进度。“重构会拖慢进度”这种看法仍然很普遍,这可能是导致人们没有充 分重构的最大阻力所在。 重构的唯一目的就是让我们开发更快,用更少的工作量创造更大的价 值。 有一种情况确实需要权衡取舍。我有时会看到一个(大规模的)重构很有必 要进行,而马上要添加的功能非常小,这时我会更愿意先把新功能加上,然后再 做这次大规模重构。做这个决定需要判断力——这是我作为程序员的专业能力之 一。我很难描述决定的过程,更无法量化决定的依据。 我清楚地知道,预备性重构常会使修改更容易,所以如果做一点儿重构能让 新功能实现更容易,我一定会做。如果一个问题我已经见过,此时我也会更倾向 于重构它——有时我就得先看见一块丑陋的代码几次,然后才能提起劲头来重构 它。也就是说,如果一块代码我很少触碰,它不会经常给我带来麻烦,那么我就 倾向于不去重构它。如果我还没想清楚究竟应该如何优化代码,那么我可能会延 迟重构;当然,有的时候,即便没想清楚优化的方向,我也会先做些实验,试试 看能否有所改进。 我从同事那里听到的证据表明,在我们这个行业里,重构不足的情况远多于 重构过度的情况。换句话说,绝大多数人应该尝试多做重构。代码库的健康与 否,到底会对生产率造成多大的影响,很多人可能说不出来,因为他们没有太多 在健康的代码库上工作的经历——轻松地把现有代码组合配置,快速构造出复杂 的新功能,这种强大的开发方式他们没有体验过。 虽然我们经常批评管理者以“保障开发速度”的名义压制重构,其实程序员自 己也经常这么干。有时他们自己觉得不应该重构,其实他们的领导还挺希望他们 做一些重构的。如果你是一支团队的技术领导,一定要向团队成员表明,你重视 改善代码库健康的价值。合理判断何时应该重构、何时应该暂时不重构,这样的 判断力需要多年经验积累。对于重构缺乏经验的年轻人需要有意的指导,才能帮 助他们加速经验积累的过程。 有些人试图用“整洁的代码”“良好的工程实践”之类道德理由来论证重构的必 要性,我认为这是个陷阱。重构的意义不在于把代码库打磨得闪闪发光,而是纯 粹经济角度出发的考量。我们之所以重构,因为它能让我们更快——添加功能更 快,修复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,也叫扩展协议/expandcontract)[mf-pc]的一个实例。 2.6 重构、架构和YAGNI 重构极大地改变了人们考虑软件架构的方式。在我的职业生涯早期,我被告 知:在任何人开始写代码之前,必须先完成软件的设计和架构。一旦代码写出 来,架构就固定了,只会因为程序员的草率对待而逐渐腐败。 重构改变了这种观点。有了重构技术,即便是已经在生产环境中运行了多年 的软件,我们也有能力大幅度修改其架构。正如本书的副标题所指出的,重构可 以改善既有代码的设计。但我在前面也提到了,修改遗留代码经常很有挑战,尤 其当遗留代码缺乏恰当的测试时。 重构对架构最大的影响在于,通过重构,我们能得到一个设计良好的代码 库,使其能够优雅地应对不断变化的需求。“在编码之前先完成架构”这种做法最 大的问题在于,它假设了软件的需求可以预先充分理解。但经验显示,这个假设 很多时候甚至可以说大多数时候是不切实际的。只有真正使用了软件、看到了软 件对工作的影响,人们才会想明白自己到底需要什么,这样的例子不胜枚举。 应对未来变化的办法之一,就是在软件里植入灵活性机制。在编写一个函数 时,我会考虑它是否有更通用的用途。为了应对我预期的应用场景,我预测可以 给这个函数加上十多个参数。这些参数就是灵活性机制——跟大多数“机制”一 样,它不是免费午餐。把所有这些参数都加上的话,函数在当前的使用场景下就 会非常复杂。另外,如果我少考虑了一个参数,已经加上的这一堆参数会使新添 参数更麻烦。而且我经常会把灵活性机制弄错——可能是未来的需求变更并非以 我期望的方式发生,也可能我对机制的设计不好。考虑到所有这些因素,很多时 候这些灵活性机制反而拖慢了我响应变化的速度。 有了重构技术,我就可以采取不同的策略。与其猜测未来需要哪些灵活性、 需要什么机制来提供灵活性,我更愿意只根据当前的需求来构造软件,同时把软 件的设计质量做得很高。随着对用户需求的理解加深,我会对架构进行重构,使 其能够应对新的需要。如果一种灵活性机制不会增加复杂度(比如添加几个命名 良好的小函数),我可以很开心地引入它;但如果一种灵活性会增加软件复杂 度,就必须先证明自己值得被引入。如果不同的调用者不会传入不同的参数值, 那么就不要添加这个参数。当真的需要添加这个参数时,运用函数参数化 (310)也很容易。要判断是否应该为未来的变化添加灵活性,我会评估“如果以 后再重构有多困难”,只有当未来重构会很困难时,我才考虑现在就添加灵活性 机制。我发现这是一个很有用的决策方法。 这种设计方法有很多名字:简单设计、增量式设计或者YAGNI[mf-yagni] ——“你不会需要它”(you arenʼt going to need it)的缩写。YAGNI并不是“不做架 构性思考”的意思,不过确实有人以这种欠考虑的方式做事。我把YAGNI视为将 架构、设计与开发过程融合的一种工作方式,这种工作方式必须有重构作为基础 才可靠。 采用YAGNI并不表示完全不用预先考虑架构。总有一些时候,如果缺少预先 的思考,重构会难以开展。但两者之间的平衡点已经发生了很大的改变:如今我 更倾向于等一等,待到对问题理解更充分,再来着手解决。演进式架构[Ford et al.]是一门仍在不断发展的学科,架构师们在不断探索有用的模式和实践,充分 发挥迭代式架构决策的能力。 2.7 重构与软件开发过程 读完前面“重构的挑战”一节,你大概已经有这个印象:重构是否有效,与团 队采用的其他软件开发实践紧密相关。重构起初是作为极限编程(XP)[mf-xp] 的一部分被人们采用的,XP本身就融合了一组不太常见而又彼此关联的实践,例 如持续集成、自测试代码以及重构(后两者融汇成了测试驱动开发)。 极限编程是最早的敏捷软件开发方法[mf-nm]之一。在一段历史时期,极限 编程引领了敏捷的崛起。如今已经有很多项目使用敏捷方法,甚至敏捷的思维已 经被视为主流,但实际上大部分“敏捷”项目只是徒有其名。要真正以敏捷的方式 运作项目,团队成员必须在重构上有能力、有热情,他们采用的开发过程必须与 常规的、持续的重构相匹配。 重构的第一块基石是自测试代码。我应该有一套自动化的测试,我可以频繁 地运行它们,并且我有信心:如果我在编程过程中犯了任何错误,会有测试失 败。这块基石如此重要,我会专门用一章篇幅来讨论它。 如果一支团队想要重构,那么每个团队成员都需要掌握重构技能,能在需要 时开展重构,而不会干扰其他人的工作。这也是我鼓励持续集成的原因:有了 CI,每个成员的重构都能快速分享给其他同事,不会发生这边在调用一个接口那 边却已把这个接口删掉的情况;如果一次重构会影响别人的工作,我们很快就会 知道。自测试的代码也是持续集成的关键环节,所以这三大实践——自测试代 码、持续集成、重构——彼此之间有着很强的协同效应。 有这三大实践在手,我们就能运用前一节介绍的YAGNI设计方法。重构和 YAGNI交相呼应、彼此增效,重构(及其前置实践)是YAGNI的基础,YAGNI 又让重构更易于开展:比起一个塞满了想当然的灵活性的系统,当然是修改一个 简单的系统要容易得多。在这些实践之间找到合适的平衡点,你就能进入良性循 环,你的代码既牢固可靠又能快速响应变化的需求。 有这三大核心实践打下的基础,才谈得上运用敏捷思想的其他部分。持续交 付确保软件始终处于可发布的状态,很多互联网团队能做到一天多次发布,靠的 正是持续交付的威力。即便我们不需要如此频繁的发布,持续集成也能帮我们降 低风险,并使我们做到根据业务需要随时安排发布,而不受技术的局限。有了可 靠的技术根基,我们能够极大地压缩“从好点子到生产代码”的周期时间,从而更 好地服务客户。这些技术实践也会增加软件的可靠性,减少耗费在bug上的时 间。 这一切说起来似乎很简单,但实际做起来毫不容易。不管采用什么方法,软 件开发都是一件复杂而微妙的事,涉及人与人之间、人与机器之间的复杂交互。 我在这里描述的方法已经被证明可以应对这些复杂性,但——就跟其他所有方法 一样——对使用者的实践和技能有要求。 2.8 重构与性能 关于重构,有一个常被提出的问题:它对程序的性能将造成怎样的影响?为 了让软件易于理解,我常会做出一些使程序运行变慢的修改。这是一个重要的问 题。我并不赞成为了提高设计的纯洁性而忽视性能,把希望寄托于更快的硬件身 上也绝非正道。已经有很多软件因为速度太慢而被用户拒绝,日益提高的机器速 度也只不过略微放宽了速度方面的限制而已。但是,换个角度说,虽然重构可能 使软件运行更慢,但它也使软件的性能优化更容易。除了对性能有严格要求的实 时系统,其他任何情况下“编写快速软件”的秘密就是:先写出可调优的软件,然 后调优它以求获得足够的速度。 我看过3种编写快速软件的方法。其中最严格的是时间预算法,这通常只用 于性能要求极高的实时系统。如果使用这种方法,分解你的设计时就要做好预 算,给每个组件预先分配一定资源,包括时间和空间占用。每个组件绝对不能超 出自己的预算,就算拥有组件之间调度预配时间的机制也不行。这种方法高度重 视性能,对于心律调节器一类的系统是必需的,因为在这样的系统中迟来的数据 就是错误的数据。但对其他系统(例如我经常开发的企业信息系统)而言,如此 追求高性能就有点儿过分了。 第二种方法是持续关注法。这种方法要求任何程序员在任何时间做任何事 时,都要设法保持系统的高性能。这种方式很常见,感觉上很有吸引力,但通常 不会起太大作用。任何修改如果是为了提高性能,通常会使程序难以维护,继而 减缓开发速度。如果最终得到的软件的确更快了,那么这点损失尚有所值,可惜 通常事与愿违,因为性能改善一旦被分散到程序各个角落,每次改善都只不过是 从对程序行为的一个狭隘视角出发而已,而且常常伴随着对编译器、运行时环境 和硬件行为的误解。 劳而无获 克莱斯勒综合薪资系统的支付过程太慢了。虽然我们的开发还没结束,这个问题却已经开始困扰 我们,因为它已经拖累了测试速度。 Kent Beck、Martin Fowler和我决定解决这个问题。等待大伙儿会合的时间里,凭着对这个系统的 全盘了解,我开始推测:到底是什么让系统变慢了?我想到数种可能,然后和伙伴们谈了几种可能的 修改方案。最后,我们就“如何让这个系统运行更快”,提出了一些真正的好点子。 然后,我们拿Kent的工具度量了系统性能。我一开始所想的可能性竟然全都不是问题肇因。我们 发现:系统把一半时间用来创建“日期”实例(instance)。更有趣的是,所有这些实例都有相同的几个 值。 于是我们观察日期对象的创建逻辑,发现有机会将它优化。这些日期对象在创建时都经过了一个 字符串转换过程,然而这里并没有任何外部数据输入。之所以使用字符串转换方式,完全只是因为代 码写起来简单。好,也许我们可以优化它。 然后,我们观察这些日期对象是如何被使用的。我们发现,很多日期对象都被用来产生“日期区 间”实例——由一个起始日期和一个结束日期组成的对象。仔细追踪下去,我们发现绝大多数日期区间 是空的! 处理日期区间时我们遵循这样一个规则:如果结束日期在起始日期之前,这个日期区间就该是空 的。这是一条很好的规则,完全符合这个类的需要。采用此规则后不久,我们意识到,创建一个“起始 日期在结束日期之后”的日期区间,仍然不算是清晰的代码,于是我们把这个行为提炼成一个工厂函 数,由它专门创建“空的日期区间”。 我们做了上述修改,使代码更加清晰,也意外得到了一个惊喜:可以创建一个固定不变的“空日期 区间”对象,并让上述调整后的工厂函数始终返回该对象,而不再每次都创建新对象。这一修改把系统 速度提升了几乎一倍,足以让测试速度达到可接受的程度。这只花了我们大约五分钟。 我和团队成员(Kent和Martin谢绝参加)认真推测过:我们了若指掌的这个程序中可能有什么错 误?我们甚至凭空做了些改进设计,却没有先对系统的真实情况进行度量。 我们完全错了。除了一场很有趣的交谈,我们什么好事都没做。 教训是:哪怕你完全了解系统,也请实际度量它的性能,不要臆测。臆测会让你学到一些东西, 但十有八九你是错的。 ——Ron Jeffries 关于性能,一件很有趣的事情是:如果你对大多数程序进行分析,就会发现 它把大半时间都耗费在一小半代码身上。如果你一视同仁地优化所有代码,90% 的优化工作都是白费劲的,因为被你优化的代码大多很少被执行。你花时间做优 化是为了让程序运行更快,但如果因为缺乏对程序的清楚认识而花费时间,那些 时间就都被浪费掉了。 第三种性能提升法就是利用上述的90%统计数据。采用这种方法时,我编写 构造良好的程序,不对性能投以特别的关注,直至进入性能优化阶段——那通常 是在开发后期。一旦进入该阶段,我再遵循特定的流程来调优程序性能。 在性能优化阶段,我首先应该用一个度量工具来监控程序的运行,让它告诉 我程序中哪些地方大量消耗时间和空间。这样我就可以找出性能热点所在的一小 段代码。然后我应该集中关注这些性能热点,并使用持续关注法中的优化手段来 优化它们。由于把注意力都集中在热点上,较少的工作量便可显现较好的成果。 即便如此,我还是必须保持谨慎。和重构一样,我会小幅度进行修改。每走一步 都需要编译、测试,再次度量。如果没能提高性能,就应该撤销此次修改。我会 继续这个“发现热点,去除热点”的过程,直到获得客户满意的性能为止。 一个构造良好的程序可从两方面帮助这一优化方式。首先,它让我有比较充 裕的时间进行性能调整,因为有构造良好的代码在手,我能够更快速地添加功 能,也就有更多时间用在性能问题上(准确的度量则保证我把这些时间投在恰当 地点)。其次,面对构造良好的程序,我在进行性能分析时便有较细的粒度。度 量工具会把我带入范围较小的代码段中,而性能的调整也比较容易些。由于代码 更加清晰,因此我能够更好地理解自己的选择,更清楚哪种调整起关键作用。 我发现重构可以帮助我写出更快的软件。短期看来,重构的确可能使软件变 慢,但它使优化阶段的软件性能调优更容易,最终还是会得到好的效果。 2.9 重构起源何处 我曾经努力想找出“重构”(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世 界里。重构的流行也带来了负面效应:很多人随意地使用“重构”这个词,而他们 真正做的却是不严谨的结构调整。尽管如此,重构终归成了一项主流的软件开发 实践。 2.10 自动化重构 过去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.11 延展阅读 在第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]上都可以找到更多相 关材料的更新。 第3章 代码的坏味道 ——Kent Beck和Martin Fowler “如果尿布臭了,就换掉它。” ——语出Beck奶奶,论保持小孩清洁的哲学 现在,对于重构如何运作,你已经有了相当好的理解。但是知道“如何”不代 表知道“何时”。决定何时重构及何时停止和知道重构机制如何运转一样重要。 难题来了!解释“如何删除一个实例变量”或“如何产生一个继承体系”很容 易,因为这些都是很简单的事情,但要解释“该在什么时候做这些动作”就没那么 顺理成章了。除了露几手含混的编程美学(说实话,这就是咱们这些顾问常做的 事),我还希望让某些东西更具说服力一些。 撰写本书的第1版时,我正在为这个微妙的问题大伤脑筋。去苏黎世拜访 Kent Beck的时候,也许是因为受到刚出生的女儿的气味影响吧,他提出用味道来 形容重构的时机。 “味道,”你可能会说,“真的比含混的美学理论要好吗?”好吧,是的。我们 看过很多很多代码,它们所属的项目从大获成功到奄奄一息都有。观察这些代码 时,我们学会了从中找寻某些特定结构,这些结构指出(有时甚至就像尖叫呼 喊)重构的可能性。(本章主语换成“我们”,是为了反映一个事实:Kent和我共 同撰写本章。你应该可以看出我俩的文笔差异——插科打诨的部分是我写的,其 余都是他写的。) 我们并不试图给你一个何时必须重构的精确衡量标准。从我们的经验看来, 没有任何量度规矩比得上见识广博者的直觉。我们只会告诉你一些迹象,它会指 出“这里有一个可以用重构解决的问题”。你必须培养自己的判断力,学会判断一 个类内有多少实例变量算是太大、一个函数内有多少行代码才算太长。 如果你无法确定该采用哪一种重构手法,请阅读本章内容和书后附的“重构 列表”来寻找灵感。你可以阅读本章或快速浏览书后附的“坏味道与重构手法速查 表”来判断自己闻到的是什么味道,然后再看看我们所建议的重构手法能否帮到 你。也许这里所列的“坏味道条款”和你所检测的不尽相符,但愿它们能够为你指 引正确方向。 3.1 神秘命名(Mysterious Name) 读侦探小说时,透过一些神秘的文字猜测故事情节是一种很棒的体验;但如 果是在阅读代码,这样的体验就不怎么好了。我们也许会幻想自己是《王牌大贱 谍》中的国际特工1,但我们写下的代码应该直观明了。整洁代码最重要的一环 就是好的名字,所以我们会深思熟虑如何给函数、模块、变量和类命名,使它们 能清晰地表明自己的功能和用法。 然而,很遗憾,命名是编程中最难的两件事之一[mf-2h]。正因为如此,改名 可能是最常用的重构手法,包括改变函数声明(124)(用于给函数改名)、变 量改名(137)、字段改名(244)等。很多人经常不愿意给程序元素改名,觉得 不值得费这个劲,但好的名字能节省未来用在猜谜上的大把时间。 改名不仅仅是修改名字而已。如果你想不出一个好名字,说明背后很可能潜 藏着更深的设计问题。为一个恼人的名字所付出的纠结,常常能推动我们对代码 进行精简。 1 《王牌大贱谍》(International Man of Mystery)是1997年杰伊·罗奇执导的一部喜剧谍战片。——译者 注 3.2 重复代码(Duplicated Code) 如果你在一个以上的地点看到相同的代码结构,那么可以肯定:设法将它们 合而为一,程序会变得更好。一旦有重复代码存在,阅读这些重复的代码时你就 必须加倍仔细,留意其间细微的差异。如果要修改重复代码,你必须找出所有的 副本来修改。 最单纯的重复代码就是“同一个类的两个函数含有相同的表达式”。这时候你 需要做的就是采用提炼函数(106)提炼出重复的代码,然后让这两个地点都调 用被提炼出来的那一段代码。如果重复代码只是相似而不是完全相同,请首先尝 试用移动语句(223)重组代码顺序,把相似的部分放在一起以便提炼。如果重 复的代码段位于同一个超类的不同子类中,可以使用函数上移(350)来避免在 两个子类之间互相调用。 3.3 过长函数(Long Function) 据我们的经验,活得最长、最好的程序,其中的函数都比较短。初次接触到 这种代码库的程序员常常会觉得“计算都没有发生”——程序里满是无穷无尽的委 托调用。但和这样的程序共处几年之后,你就会明白这些小函数的价值所在。间 接性带来的好处——更好的阐释力、更易于分享、更多的选择——都是由小函数 来支持的。 早在编程的洪荒年代,程序员们就已认识到:函数越长,就越难理解。在早 期的编程语言中,子程序调用需要额外开销,这使得人们不太乐意使用小函数。 现代编程语言几乎已经完全免除了进程内的函数调用开销。固然,小函数也会给 代码的阅读者带来一些负担,因为你必须经常切换上下文,才能看明白函数在做 什么。但现代的开发环境让你可以在函数的调用处与声明处之间快速跳转,或是 同时看到这两处,让你根本不用来回跳转。不过说到底,让小函数易于理解的关 键还是在于良好的命名。如果你能给函数起个好名字,阅读代码的人就可以通过 名字了解函数的作用,根本不必去看其中写了些什么。 最终的效果是:你应该更积极地分解函数。我们遵循这样一条原则:每当感 觉需要以注释来说明点什么的时候,我们就把需要说明的东西写进一个独立函数 中,并以其用途(而非实现手法)命名。我们可以对一组甚至短短一行代码做这 件事。哪怕替换后的函数调用动作比函数自身还长,只要函数名称能够解释其用 途,我们也该毫不犹豫地那么做。关键不在于函数的长度,而在于函数“做什 么”和“如何做”之间的语义距离。 百分之九十九的场合里,要把函数变短,只需使用提炼函数(106)。找到 函数中适合集中在一起的部分,将它们提炼出来形成一个新函数。 如果函数内有大量的参数和临时变量,它们会对你的函数提炼形成阻碍。如 果你尝试运用提炼函数(106),最终就会把许多参数传递给被提炼出来的新函 数,导致可读性几乎没有任何提升。此时,你可以经常运用以查询取代临时变量 (178)来消除这些临时元素。引入参数对象(140)和保持对象完整(319)则 可以将过长的参数列表变得更简洁一些。 如果你已经这么做了,仍然有太多临时变量和参数,那就应该使出我们的杀 手锏——以命令取代函数(337)。 如何确定该提炼哪一段代码呢?一个很好的技巧是:寻找注释。它们通常能 指出代码用途和实现手法之间的语义距离。如果代码前方有一行注释,就是在提 醒你:可以将这段代码替换成一个函数,而且可以在注释的基础上给这个函数命 名。就算只有一行代码,如果它需要以注释来说明,那也值得将它提炼到独立函 数中去。 条件表达式和循环常常也是提炼的信号。你可以使用分解条件表达式 (260)处理条件表达式。对于庞大的switch语句,其中的每个分支都应该通过 提炼函数(106)变成独立的函数调用。如果有多个switch语句基于同一个条件 进行分支选择,就应该使用以多态取代条件表达式(272)。 至于循环,你应该将循环和循环内的代码提炼到一个独立的函数中。如果你 发现提炼出的循环很难命名,可能是因为其中做了几件不同的事。如果是这种情 况,请勇敢地使用拆分循环(227)将其拆分成各自独立的任务。 3.4 过长参数列表(Long Parameter List) 刚开始学习编程的时候,老师教我们:把函数所需的所有东西都以参数的形 式传递进去。这可以理解,因为除此之外就只能选择全局数据,而全局数据很快 就会变成邪恶的东西。但过长的参数列表本身也经常令人迷惑。 如果可以向某个参数发起查询而获得另一个参数的值,那么就可以使用以查 询取代参数(324)去掉这第二个参数。如果你发现自己正在从现有的数据结构 中抽出很多数据项,就可以考虑使用保持对象完整(319)手法,直接传入原来 的数据结构。如果有几项参数总是同时出现,可以用引入参数对象(140)将其 合并成一个对象。如果某个参数被用作区分函数行为的标记(flag),可以使用 移除标记参数(314)。 使用类可以有效地缩短参数列表。如果多个函数有同样的几个参数,引入一 个类就尤为有意义。你可以使用函数组合成类(144),将这些共同的参数变成 这个类的字段。如果戴上函数式编程的帽子,我们会说,这个重构过程创造了一 组部分应用函数(partially applied function)。 3.5 全局数据(Global Data) 刚开始学软件开发时,我们就听说过关于全局数据的惊悚故事——它们是如 何被来自地狱第四层的恶魔发明出来,胆敢使用它们的程序员如今在何处安息。 就算这些烈焰与硫黄的故事不那么可信,全局数据仍然是最刺鼻的坏味道之一。 全局数据的问题在于,从代码库的任何一个角落都可以修改它,而且没有任何机 制可以探测出到底哪段代码做出了修改。一次又一次,全局数据造成了那些诡异 的bug,而问题的根源却在遥远的别处,想要找到出错的代码难于登天。全局数 据最显而易见的形式就是全局变量,但类变量和单例(singleton)也有这样的问 题。 首要的防御手段是封装变量(132),每当我们看到可能被各处的代码污染 的数据,这总是我们应对的第一招。你把全局数据用一个函数包装起来,至少你 就能看见修改它的地方,并开始控制对它的访问。随后,最好将这个函数(及其 封装的数据)搬移到一个类或模块中,只允许模块内的代码使用它,从而尽量控 制其作用域。 可以被修改的全局数据尤其可憎。如果能保证在程序启动之后就不再修改, 这样的全局数据还算相对安全,不过得有编程语言提供这样的保证才行。 全局数据印证了帕拉塞尔斯的格言:良药与毒药的区别在于剂量。有少量的 全局数据或许无妨,但数量越多,处理的难度就会指数上升。即便只是少量的数 据,我们也愿意将它封装起来,这是在软件演进过程中应对变化的关键所在。 3.6 可变数据(Mutable Data) 对数据的修改经常导致出乎意料的结果和难以发现