重构——改善既有代码的设计(第2版)
所谓好,就是更少的坏味道
重构的时候不开发功能
开发功能的时候不重构
无论重构还是优化新能,都要先准备好测试代码,确保不改变代码行为
意义
- 对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。
- 不改变外在行为,而提高代码质量,以改进程序的内部结构
- 使代码更容易被人读懂,更方便修改
- 可以先实现,再优化;简化开发过程
- 重构是理解软件的最快方式
- 重构可以很好地帮助我们理解遗留系统。
- 通过重构,我就把脑子里的理解转移到了代码本身
原则
最根本的原则是:将总是一起变化的东西放在一块儿。
所有超类都应该是抽象(abstract)的。
小步前进,情况越复杂,步子就要越小。
性能优化的一般指导方针,不用过早担心性能问题。
短函数常常能让编译器的优化功能运转更良好,因为短函数可以更容易地被缓存
营地法则:保证你离开时的代码库一定比来时更健康
- 应该去追求编写人能够读懂的而不是仅机器能够读懂的代码
- 重构过程的精髓所在:小步修改,每次修改后就运行测试
- 如果重构引入了性能损耗,先完成重构,再做性能优化。
- 请保持代码永远处于可工作状态
- 添加新功能时,我不应该修改既有代码,只管添加新功能;重构时我就不能再添加功能,只管调整代码的结构。
- 如果一种灵活性会增加软件复杂度,就必须先证明自己值得被引入
要判断是否应该为未来的变化添加灵活性,我会评估"如果以后再重构有多困难",只有当未来重构会很困难时,我才考虑现在就添加灵活性机制。 YAGNI(you arenʼt going to need it)你不会需要它
- 尽量控制作用域
- 代码分层,单向调用
- 封装,继承 消除重复代码
- 多态 消除 switch
- 数据查询、数据更新 区分开 (入参校验,数据查询,数据入库,日志,事件触发)
- 里氏代换原则:任何基类可以出现的地方,子类一定可以出现。(子类不重写父类方法)
- 如果你的某个抽象类其实没有太大作用,请运用折叠继承体系
- 每当感觉需要以注释来说明点什么的时候,我们就把需要说明的东西写进一个独立函数中,并以其用途(而非实现手法)命名。
- 即便只是少量的数据,我们也愿意将它封装起来,这是在软件演进过程中应对变化的关键所在。
- 依恋去情节:判断哪个模块拥有的此函数使用的数据最多,然后就把这个函数和那些数据摆在一起
一个函数跟另一个模块中的函数或者数据交流格外频繁,远胜于在自己所处模块内部的交流,这就是依恋情结的典型情况。
坏味道
- 注释可以带我们找到各种坏味道
- 如果可变数据的值能在其他地方计算出来,这就是一个特别刺鼻的坏味道。
- 如果需要修改的代码散布四处,你不但很难找到它们,也很容易错过某个重要的修改。
- 如果你有一组总是同时出现的基本类型数据,这就是数据泥团的征兆,应该运用提炼类和引入参数对象来处理。
- 重复的switch语句【邪恶】 —— 多态给了我们对抗这种黑暗力量的武器,使我们得到更优雅的代码库。
场景-策略
- 当你感觉需要撰写注释时,请先尝试重构,试着让所有注释都变得多余。
- 如果你需要注释来解释一块代码做了什么,试试提炼函数
- 如果函数已经提炼出来,但还是需要注释来解释其行为,试试用改变函数声明为它改名;
- 如果你需要注释说明某些系统的需求规格,试试引入断言
- 如果你不知道该做什么,这才是注释的良好运用时机(记述将来的打算之外,标记你并无十足把握的区域,为什么这么做)
- 识别坏味道、测试先行、行为保持的变更动作,是重构的基本功
- 重构的最佳时机就在添加新功能之前。
- 当变量名表达不准确的时候修改变量名
- 当变量可以通过其他变量计算得到时,用查询取代变量
- 当需要手动写注释的时候,考虑封装函数
- 当代码重复时,考虑封装函数
- 当遍历过程做了多件事情时(如函数返回结果是两项关联很弱的数据),考虑拆分遍历
- 当多个类使用同样的函数时,提炼函数到父类
- 当父类的函数不被所有子类时,分类父类方法,使用委托调用取代继承
- 当一组函数使用相同的变量组合时,考虑封装成类
- 结对编程:在编程的过程中持续不断地进行代码复审。
- 如何确定该提炼哪一段代码呢?一个很好的技巧是:寻找注释。
- 条件表达式和循环常常也是提炼的信号
- 使用类可以有效地缩短参数列表
- 总是绑在一起出现的数据真应该拥有属于它们自己的对象
- 继承常会造成密谋,因为子类对超类的了解总是超过后者的主观愿望。如果你觉得该让这个孩子独立生活了,请运用以委托取代子类或以委托取代超类让它离开继承体系。
- 通常,如果类内的数个变量有着相同的前缀或后缀,这就意味着有机会把它们提炼到某个组件内。
观察一个大类的使用者,经常能找到如何拆分类的线索。看看使用者是否只用到了这个类所有功能的一个子集,每个这样的子集都可能拆分成一个独立的类。
- 过长的消息链 —— 使用隐藏委托关系
通常更好的选择是:先观察消息链最终得到的对象是用来干什么的,看看能否以提炼函数把使用该对象的代码提炼到一个独立的函数中,再运用搬移函数把这个函数推入消息链
提炼函数(Extract Function)
顺序
编写测试代码 -> 循环执行 { 小步重构 -> 测试 -> 提交到版本库 } -> 压缩提交
为原函数添加足够的结构,以便我能更好地理解它,看清它的逻辑结构 -> 封装
处理输入 -> 参数校验 -> 数据查询 -> 数据更新 -> [日志、异步] -> 处理输出
- 编写测试代码
- 首先,把变量声明移动到使用处之前。
- 定义变量考虑用最准确简洁的名称,必要时带上数据类型
- 将关联的代码放在一起,没有关联的代码之间用空行隔开
- 上层代码调用方法,展示数据处理顺序,下层代码处理具体逻辑
- 把大函数封装成小函数,组合调用(建议一个函数不超过1屏,强烈建议一个函数不超过2屏)
- 多个函数使用相同参数,考虑封装数据处理类
- 多个类使用相同函数,考虑抽象父类
- 父类的函数被子类覆盖,考虑用委托调用取代父类函数
方法
- 首要的防御手段是封装变量
- 移除局部变量
- 变量改名
- 移动代码
- 函数改名
- 移动函数
- 拆分遍历
- 以查询取代派生变量
- 管道操作(如filter和map)可以帮助我们更快地看清被处理的元素以及处理它们的动作。
- 迁移式做法:如果有必要的话,先对函数体内部加以重构,使后面的提炼步骤易于开展。
提炼函数(Extract Function)
title | content |
---|---|
意义 | 将意图与实现分开 |
定义 | 浏览一段代码,理解其作用,然后将其提炼到一个独立的函数中,并以这段代码的用途为这个函数命名。 |
做法 | 创造一个新函数,根据这个函数的意图来对它命名(以它"做什么"来命名,而不是以它"怎样做"命名) |
注意 | 如果想不出一个更有意义的名称,这就是一个信号,可能我不应该提炼这块代码。 如果变量按值传递给提炼部分又在提炼部分被赋值,就必须多加小心 |
内联函数(Extract Function)
title | content |
---|---|
意义 | 间接性可能带来帮助,但非必要的间接性总是让人不舒服 |
定义 | |
做法 | 检查函数,确定它不具多态性。 如果该函数属于一个类,并且有子类继承了这个函数,那么就无法内联。 找出这个函数的所有调用点。 将这个函数的所有调用点都替换为函数本体。 每次替换之后,执行测试。 不必一次完成整个内联操作。如果某些调用点比较难以内联,可以等到时机成熟后再来处理。 删除该函数的定义。 |
注意 |
提炼变量(Extract Variable)
title | content |
---|---|
场景 | 表达式有可能非常复杂而难以阅读。 如果这个变量名在更宽的上下文中也有意义,我就会考虑将其暴露出来,通常以函数的形式。 |
意义 | 局部变量可以帮助我们将表达式分解为比较容易管理的形式 |
定义 | |
做法 | 确认要提炼的表达式没有副作用。 声明一个不可修改的变量,把你想要提炼的表达式复制一份,以该表达式的结果值给这个变量赋值。 用这个新变量取代原来的表达式。 测试。 |
注意 |
内联变量(Inline Variable)
title | content |
---|---|
场景 | |
意义 | |
定义 | |
做法 | 检查确认变量赋值语句的右侧表达式没有副作用。 如果变量没有被声明为不可修改,先将其变为不可修改,并执行测试。 这是为了确保该变量只被赋值一次。 |
找到第一处使用该变量的地方,将其替换为直接使用赋值语句的右侧表达式。 测试。 重复前面两步,逐一替换其他所有使用该变量的地方。 删除该变量的声明点和赋值语句。 测试。 | |
注意 |
改变函数声明(Change Function Declaration)
修改函数名:在确保不重复的情况下,新函数使用旧的函数名,将旧的函数名添加Old后缀,测试函数的行为没有改变后,修改新函数名称,替换旧函数的调用
title | content |
---|---|
场景 | 如果我看到一个函数的名字不对,一旦发现了更好的名字,就得尽快给函数改名。 |
意义 | 下一次再看到这段代码时,我就不用再费力搞懂其中到底在干什么。 修改参数列表不仅能增加函数的应用范围,还能改变连接一个模块所需的条件,从而去除不必要的耦合 |
定义 | |
做法 | 先写一句注释描述这个函数的用途,再把这句注释变成函数的名字 如果想要移除一个参数,需要先确定函数体内没有使用该参数。 修改函数声明,使其成为你期望的状态。 找出所有使用旧的函数声明的地方,将它们改为使用新的函数声明。 测试。 |
注意 | 最好能把大的修改拆成小的步骤,所以如果你既想修改函数名,又想添加参数,最好分成两步来做。 |
封装变量(Encapsulate Variable)
title | content |
---|---|
场景 | |
意义 | |
定义 | |
做法 | |
注意 |
渐进式修改
如果要重构一个已对外发布的API,在提炼出新函数之后,你可以暂停重构,将原来的函数声明为"不推荐使用"(deprecated),然后给客户端一点时间转为使用新函数。等你有信心所有客户端都已经从旧函数迁移到新函数,再移除旧函数的声明。
格言
全局数据的问题在于,从代码库的任何一个角落都可以修改它,而且没有任何机制可以探测出到底哪段代码做出了修改。
- 唯有能写出人类容易理解的代码的,才是优秀的程序员。
- 本质上说,重构就是在代码写好之后改进它的设计。
- 改进设计的一个重要方向就是消除重复代码
- 事不过三,三则重构
- 好代码的检验标准就是人们是否能轻而易举地修改它。
- 好代码应该直截了当:有人需要修改代码时,他们应能轻易找到修改点,应该能快速做出更改,而不易引入其他错误。
- 我比较喜欢让每个函数都只返回一个值,所以我会安排多个函数,用以返回多个值
- 代码被阅读和被修改的次数远远多于它被编写的次数。
- 设计模式为重构提供了目标。然而"确定目标"只是问题的一部分而已,改造程序以达到目标,是另一个难题。
- 尽管编写测试需要花费时间,但却为我节省下可观的调试时间
- 要将我的理解转化到代码里,得先将这块代码抽取成一个独立的函数,按它所干的事情给它命名
- 设计耐久性假说":通过投入精力改善内部设计,我们增加了软件的耐久性,从而可以更长时间地保持开发的快速。
- 一旦我需要思考"这段代码到底在做什么",我就会自问:能不能重构这段代码,令其一目了然?
- 重构不是与编程割裂的行为。你不会专门安排时间重构,正如你不会专门安排时间写if语句。
- 优秀的程序员知道,添加新功能最快的方法往往是先修改现有的代码,使新功能容易被加入
- 自测试代码是极限编程的另一个重要组成部分,也是持续交付的关键环节。
- 糟糕的程序结构可以慢慢理顺,把程序从一块顽石打磨成美玉。
- 哪怕你完全了解系统,也请实际度量它的性能,不要臆测。臆测会让你学到一些东西,但十有八九你是错的。
- 三大实践——自测试代码、持续集成、重构——彼此之间有着很强的协同效应。
- 如果你一视同仁地优化所有代码,90%的优化工作都是白费劲的,因为被你优化的代码大多很少被执行。
- 很多人经常不愿意给程序元素改名,觉得不值得费这个劲,但好的名字能节省未来用在猜谜上的大把时间。
- 如果你想不出一个好名字,说明背后很可能潜藏着更深的设计问题
- 据我们的经验,活得最长、最好的程序,其中的函数都比较短
- 就算只有一行代码,如果它需要以注释来说明,那也值得将它提炼到独立函数中去。
- 不必在意数据泥团只用上新对象的一部分字段,只要以新对象取代两个(或更多)字段,就值得这么做。
- 大多数编程环境都大量使用基本类型,即整数、浮点数和字符串等
- 一个体面的类型,至少能包含一致的显示逻辑,在用户界面上需要显示时可以使用
- 既然不愿意支持超类的接口,就不要虚情假意地糊弄继承体系
- 当我想好代码中应该有哪些关节时,才能使代码随着我的理解而演进。
关于测试
测试愈发成为任何一个软件开发者所必备的技能
一个架构的好坏,很大程度要取决于它的可测试性,这是一个好的行业趋势。
一个值得养成的好习惯是,每当你遇见一个bug,先写一个测试来清楚地复现它。仅当测试通过时,才视为bug修完。
【你应该把测试集中在可能出错的地方。】当测试数量达到一定程度之后,继续增加测试带来的边际效用会递减;如果试图编写太多测试,你也可能因为工作量太大而气馁,最后什么都写不成。
- 类应该包含它们自己的测试代码。
- 事实上,撰写测试代码的最好时机是在开始动手编码之前。
- 编写优良的测试程序,可以极大提高我的编程速度,即使不进行重构也一样如此
- 编写测试代码其实就是在问自己:为了添加这个功能,我需要实现些什么?编写测试代码还能帮我把注意力集中于接口而非实现(这永远是一件好事)。
- 之所以能够拥有如此强大的bug侦测能力,不仅仅是因为我的代码能够自测试,也得益于我频繁地运行它们。
- 测试驱动开发的编程方式依赖于下面这个短循环:先编写一个(失败的)测试,编写代码使测试通过,然后进行重构以保证代码整洁。这个"测试、编码、重构"的循环应该在每个
- 到红条时永远不许进行重构",意思是:测试集合中还有失败的测试时就不应该先去重构。
- 测试应该是一种风险驱动的行为,我测试的目标是希望找出现在或未来可能出现的bug。
- 测试的重点应该是那些我最担心出错的部分,这样就能从测试工作中得到最大利益。
- 写未臻完善的测试并经常运行,好过对完美测试的无尽等待。
为既有代码添加测试时最常用的方法:先随便填写一个期望值,再用程序产生的真实值来替换它,然后引入一个错误,最后恢复错误。
- 虑可能出错的边界条件,把测试火力集中在那儿。
无论何时,当我拿到一个集合,我总想看看集合为空时会发生什么。 如果拿到的是数值类型,0会是不错的边界条件,负值同样值得一试
关于注释
关于好代码
性能
相关书籍
- 《解析极限编程》
- 《修改代码的艺术》
-
《数据库重构》
并行修改:
数据库重构最好是分散到多次生产发布来完成,这样即便某次修改在生产数据库上造成了问题,也比较容易回滚。 确定没有bug之后,我再删除已经没人使用的旧字段。