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

《重构改善既有代码的设计第2版》

Danger

所谓好,就是更少的坏味道

重构的时候不开发功能

开发功能的时候不重构

无论重构还是优化新能,都要先准备好测试代码,确保不改变代码行为

意义

  • 对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。
  • 不改变外在行为,而提高代码质量,以改进程序的内部结构
  • 使代码更容易被人读懂,更方便修改
  • 可以先实现,再优化;简化开发过程
  • 重构是理解软件的最快方式
  • 重构可以很好地帮助我们理解遗留系统。
  • 通过重构,我就把脑子里的理解转移到了代码本身

原则

Danger

最根本的原则是:将总是一起变化的东西放在一块儿。

所有超类都应该是抽象(abstract)的。

小步前进,情况越复杂,步子就要越小。

性能优化的一般指导方针,不用过早担心性能问题。

短函数常常能让编译器的优化功能运转更良好,因为短函数可以更容易地被缓存

营地法则:保证你离开时的代码库一定比来时更健康

  • 应该去追求编写人能够读懂的而不是仅机器能够读懂的代码
  • 重构过程的精髓所在:小步修改,每次修改后就运行测试
  • 如果重构引入了性能损耗,先完成重构,再做性能优化。
  • 请保持代码永远处于可工作状态
  • 添加新功能时,我不应该修改既有代码,只管添加新功能;重构时我就不能再添加功能,只管调整代码的结构。
  • 如果一种灵活性会增加软件复杂度,就必须先证明自己值得被引入

    要判断是否应该为未来的变化添加灵活性,我会评估"如果以后再重构有多困难",只有当未来重构会很困难时,我才考虑现在就添加灵活性机制。 YAGNI(you arenʼt going to need it)你不会需要它

  • 尽量控制作用域
  • 代码分层,单向调用
  • 封装,继承 消除重复代码
  • 多态 消除 switch
  • 数据查询、数据更新 区分开 (入参校验,数据查询,数据入库,日志,事件触发)
  • 里氏代换原则:任何基类可以出现的地方,子类一定可以出现。(子类不重写父类方法)
  • 如果你的某个抽象类其实没有太大作用,请运用折叠继承体系
  • 每当感觉需要以注释来说明点什么的时候,我们就把需要说明的东西写进一个独立函数中,并以其用途(而非实现手法)命名。
  • 即便只是少量的数据,我们也愿意将它封装起来,这是在软件演进过程中应对变化的关键所在。
  • 依恋去情节:判断哪个模块拥有的此函数使用的数据最多,然后就把这个函数和那些数据摆在一起

    一个函数跟另一个模块中的函数或者数据交流格外频繁,远胜于在自己所处模块内部的交流,这就是依恋情结的典型情况。

坏味道

  • 注释可以带我们找到各种坏味道
  • 如果可变数据的值能在其他地方计算出来,这就是一个特别刺鼻的坏味道。
  • 如果需要修改的代码散布四处,你不但很难找到它们,也很容易错过某个重要的修改。
  • 如果你有一组总是同时出现的基本类型数据,这就是数据泥团的征兆,应该运用提炼类和引入参数对象来处理。
  • 重复的switch语句【邪恶】 —— 多态给了我们对抗这种黑暗力量的武器,使我们得到更优雅的代码库。

场景-策略

  • 当你感觉需要撰写注释时,请先尝试重构,试着让所有注释都变得多余。
  • 如果你需要注释来解释一块代码做了什么,试试提炼函数
  • 如果函数已经提炼出来,但还是需要注释来解释其行为,试试用改变函数声明为它改名;
  • 如果你需要注释说明某些系统的需求规格,试试引入断言
  • 如果你不知道该做什么,这才是注释的良好运用时机(记述将来的打算之外,标记你并无十足把握的区域,为什么这么做)
  • 识别坏味道、测试先行、行为保持的变更动作,是重构的基本功
  • 重构的最佳时机就在添加新功能之前。
  • 当变量名表达不准确的时候修改变量名
  • 当变量可以通过其他变量计算得到时,用查询取代变量
  • 当需要手动写注释的时候,考虑封装函数
  • 当代码重复时,考虑封装函数
  • 当遍历过程做了多件事情时(如函数返回结果是两项关联很弱的数据),考虑拆分遍历
  • 当多个类使用同样的函数时,提炼函数到父类
  • 当父类的函数不被所有子类时,分类父类方法,使用委托调用取代继承
  • 当一组函数使用相同的变量组合时,考虑封装成类
  • 结对编程:在编程的过程中持续不断地进行代码复审。
  • 如何确定该提炼哪一段代码呢?一个很好的技巧是:寻找注释。
  • 条件表达式和循环常常也是提炼的信号
  • 使用类可以有效地缩短参数列表
  • 总是绑在一起出现的数据真应该拥有属于它们自己的对象
  • 继承常会造成密谋,因为子类对超类的了解总是超过后者的主观愿望。如果你觉得该让这个孩子独立生活了,请运用以委托取代子类或以委托取代超类让它离开继承体系。
  • 通常,如果类内的数个变量有着相同的前缀或后缀,这就意味着有机会把它们提炼到某个组件内。

    观察一个大类的使用者,经常能找到如何拆分类的线索。看看使用者是否只用到了这个类所有功能的一个子集,每个这样的子集都可能拆分成一个独立的类。

  • 过长的消息链 —— 使用隐藏委托关系

    通常更好的选择是:先观察消息链最终得到的对象是用来干什么的,看看能否以提炼函数把使用该对象的代码提炼到一个独立的函数中,再运用搬移函数把这个函数推入消息链

提炼函数(Extract Function)

顺序

Danger

编写测试代码 -> 循环执行 { 小步重构 -> 测试 -> 提交到版本库 } -> 压缩提交

为原函数添加足够的结构,以便我能更好地理解它,看清它的逻辑结构 -> 封装

处理输入 -> 参数校验 -> 数据查询 -> 数据更新 -> [日志、异步] -> 处理输出

  • 编写测试代码
  • 首先,把变量声明移动到使用处之前。
  • 定义变量考虑用最准确简洁的名称,必要时带上数据类型
  • 将关联的代码放在一起,没有关联的代码之间用空行隔开
  • 上层代码调用方法,展示数据处理顺序,下层代码处理具体逻辑
  • 把大函数封装成小函数,组合调用(建议一个函数不超过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),然后给客户端一点时间转为使用新函数。等你有信心所有客户端都已经从旧函数迁移到新函数,再移除旧函数的声明。

格言

Danger

全局数据的问题在于,从代码库的任何一个角落都可以修改它,而且没有任何机制可以探测出到底哪段代码做出了修改。

  • 唯有能写出人类容易理解的代码的,才是优秀的程序员。
  • 本质上说,重构就是在代码写好之后改进它的设计。
  • 改进设计的一个重要方向就是消除重复代码
  • 事不过三,三则重构
  • 好代码的检验标准就是人们是否能轻而易举地修改它。
  • 好代码应该直截了当:有人需要修改代码时,他们应能轻易找到修改点,应该能快速做出更改,而不易引入其他错误。
  • 我比较喜欢让每个函数都只返回一个值,所以我会安排多个函数,用以返回多个值
  • 代码被阅读和被修改的次数远远多于它被编写的次数。
  • 设计模式为重构提供了目标。然而"确定目标"只是问题的一部分而已,改造程序以达到目标,是另一个难题。
  • 尽管编写测试需要花费时间,但却为我节省下可观的调试时间
  • 要将我的理解转化到代码里,得先将这块代码抽取成一个独立的函数,按它所干的事情给它命名
  • 设计耐久性假说":通过投入精力改善内部设计,我们增加了软件的耐久性,从而可以更长时间地保持开发的快速。
  • 一旦我需要思考"这段代码到底在做什么",我就会自问:能不能重构这段代码,令其一目了然?
  • 重构不是与编程割裂的行为。你不会专门安排时间重构,正如你不会专门安排时间写if语句。
  • 优秀的程序员知道,添加新功能最快的方法往往是先修改现有的代码,使新功能容易被加入
  • 自测试代码是极限编程的另一个重要组成部分,也是持续交付的关键环节。
  • 糟糕的程序结构可以慢慢理顺,把程序从一块顽石打磨成美玉。
  • 哪怕你完全了解系统,也请实际度量它的性能,不要臆测。臆测会让你学到一些东西,但十有八九你是错的。
  • 三大实践——自测试代码、持续集成、重构——彼此之间有着很强的协同效应。
  • 如果你一视同仁地优化所有代码,90%的优化工作都是白费劲的,因为被你优化的代码大多很少被执行。
  • 很多人经常不愿意给程序元素改名,觉得不值得费这个劲,但好的名字能节省未来用在猜谜上的大把时间。
  • 如果你想不出一个好名字,说明背后很可能潜藏着更深的设计问题
  • 据我们的经验,活得最长、最好的程序,其中的函数都比较短
  • 就算只有一行代码,如果它需要以注释来说明,那也值得将它提炼到独立函数中去。
  • 不必在意数据泥团只用上新对象的一部分字段,只要以新对象取代两个(或更多)字段,就值得这么做。
  • 大多数编程环境都大量使用基本类型,即整数、浮点数和字符串等
  • 一个体面的类型,至少能包含一致的显示逻辑,在用户界面上需要显示时可以使用
  • 既然不愿意支持超类的接口,就不要虚情假意地糊弄继承体系
  • 当我想好代码中应该有哪些关节时,才能使代码随着我的理解而演进。

关于测试

Danger

测试愈发成为任何一个软件开发者所必备的技能

一个架构的好坏,很大程度要取决于它的可测试性,这是一个好的行业趋势。

一个值得养成的好习惯是,每当你遇见一个bug,先写一个测试来清楚地复现它。仅当测试通过时,才视为bug修完。

【你应该把测试集中在可能出错的地方。】当测试数量达到一定程度之后,继续增加测试带来的边际效用会递减;如果试图编写太多测试,你也可能因为工作量太大而气馁,最后什么都写不成。

  • 类应该包含它们自己的测试代码。
  • 事实上,撰写测试代码的最好时机是在开始动手编码之前。
  • 编写优良的测试程序,可以极大提高我的编程速度,即使不进行重构也一样如此
  • 编写测试代码其实就是在问自己:为了添加这个功能,我需要实现些什么?编写测试代码还能帮我把注意力集中于接口而非实现(这永远是一件好事)。
  • 之所以能够拥有如此强大的bug侦测能力,不仅仅是因为我的代码能够自测试,也得益于我频繁地运行它们。
  • 测试驱动开发的编程方式依赖于下面这个短循环:先编写一个(失败的)测试,编写代码使测试通过,然后进行重构以保证代码整洁。这个"测试、编码、重构"的循环应该在每个
  • 到红条时永远不许进行重构",意思是:测试集合中还有失败的测试时就不应该先去重构。
  • 测试应该是一种风险驱动的行为,我测试的目标是希望找出现在或未来可能出现的bug。
  • 测试的重点应该是那些我最担心出错的部分,这样就能从测试工作中得到最大利益。
  • 写未臻完善的测试并经常运行,好过对完美测试的无尽等待。

    为既有代码添加测试时最常用的方法:先随便填写一个期望值,再用程序产生的真实值来替换它,然后引入一个错误,最后恢复错误。

  • 虑可能出错的边界条件,把测试火力集中在那儿。

    无论何时,当我拿到一个集合,我总想看看集合为空时会发生什么。 如果拿到的是数值类型,0会是不错的边界条件,负值同样值得一试

关于注释

关于好代码

性能

相关书籍

  • 《解析极限编程》
  • 《修改代码的艺术》
  • 《数据库重构》

    并行修改:

    数据库重构最好是分散到多次生产发布来完成,这样即便某次修改在生产数据库上造成了问题,也比较容易回滚。 确定没有bug之后,我再删除已经没人使用的旧字段。