[读书笔记] 重构 – 改善既有代码的设计第二版

  • Post author:
  • Post category:其他



refactoring-cheat-sheet


《重构 改善既有代码的设计第二版》中文版


注意:《重构》书籍中的代码示范,有些展示的不合理,没有与文字解释同步好



第 1 章 重构,第一个示例

将业务流转逻辑与计算逻辑拆分,简单点说,流转逻辑中使用的计算参数使用计算函数进行拆分出来,消除局部变量。

在重构和添加新特性之间寻找平衡。

重复的代码片段需要进行提取,抽象成公共的函数,便于维护。

代码复审有助于在开发团队中传播知识,也有助于让较有经验的开发者把知识传递给比较欠缺经验的人,并帮助更多人理解大型软件系统中的更多部分。

代码复审也让更多人有机会提出有用的建议,毕竟我在一个星期之内能够想出的好点子很有限。如果能得到别人的帮助,我的生活会滋润得多,所以我总是期待更多复审。

使用CI持续将分支与主线合并。

自测试代码、持续集成、重构。



第 2 章 重构的原则

重构的一些原则(略)。



第 3 章 代码的坏味道

设计API时,可以将查询和修改函数分离确保调用者不会调到有副作用的代码。

如果一个类,针对两种不太相关的逻辑变更,一种需要使用其中3个函数,一种需要使用其中4个函数,比如数据库操作和金融逻辑修改,那么建立将二者独立开来(感觉是单一职责原理)。

依恋情结,就是一个模块一个函数依赖于另一模块的某个函数或者数据,比如当前计算函数需要另一个对象的半数数据,那么考虑把当前函数强依赖另一个对象的数据部分迁移到另一个对象中。

一个好的评判办法是:删掉众多数据中的一项。如果这么做,其他数据有没有因而失去意义?如果它们不再有意义,这就是一个明确信号:你应该为它们产生一个新对象。

创建自己领域的基本类型,比如钱,坐标,范围,电话号码,而不是简单的使用基本类型,可以避免不同单位数值错误进行运算的问题(比如钱,元与角,1+1=2,最终的单位不管是元还是角都是错误的)

使用多态取代重复的switch-case或者if-else语句,从而得到更优雅的代码库(工厂/策略模式)

函数作为一等公民,可以使用管道取代循环(java8 in action 中的 stream 流和管道操作函数,对应参数是函数式接口)

如果一个类只有一个函数,可以是重构后的剩余,也可能是希望未来变大,目前建议的行为是使用内联函数或者内联类或者折叠继承体系将这个类消除(所谓折叠继承体系,就是子类与超类差别很小,将子类合并到超类中去)

企图以各式各样的钩子和特殊情况来处理一些非必要的事情,是非常不值得推荐的。一个分界线就是,如果所有装置都会被用到,就值得做,反之就不值得。

如果客户端对于需要的结果需要进行过长的显示消息链调用(getPerson().getDepartment().getAddress().getStreet()),我们可以考虑使用隐藏委托关系,一旦其中某个对象发生变化,那么按照此调用方式的所有客户端代码均需要作修改,如果使用一个函数隐藏这种委托关系,客户端调用这个封装好的函数,函数内部的修改对于客户端代码来说没有感知,符合迪米特法则。

如果一个类有一半的函数都将其实现委托给其他类,这就属于过度运用,那么考虑将这些委托函数拆分,让外部调用和实际的对象建立起连接,移除中间人。或者将这个类用来取代对应的超类或者子类,如果这个中间人还伴随其他行为。

如果两个模块有共同的兴趣,可以尝试再新建一个模块,把这些共用的数据放在一个管理良好的地方;或者用隐藏委托关系,把另一个模块变成两者的中介。

如果两个类可以相互替换,那么这两个类的接口一致的,如果两个类中存在重复代码,可以运用提炼超类补偿一下。

如果一个类只是用超类中的部分函数和数据,那么这个继承体系设计就存在问题(“传统说法”),考虑新建一个兄弟类,将超类中不需要的部分移到这个兄弟类中,超类仅存放所有子类中共享的东西(面向接口编程)。这条建议也不是每次都要区分的很清楚,关键在于子类复用超类方法(实现,行为)时是否会出现拒绝实现超类的接口。



第 4 章 构筑测试体系

确保所有的测试都完全自动化,让他们检查自己的测试结果。

建议“测试-编码-重构”的循环工作方式。

总是确保测试不该通过时真的会失败(引入一个边界错误,保证程序真的会失败)。

测试的重点应该是那些我最担心出错的部分,这样就能从测试工作中得到最大利益。

测试中的重复代码,比如new一个新对象这种操作,不要直接把同一个对象的new动作提取到测试的第一行,因为每一次的测试都会使用对象,甚至操作对象的值,(可能)从而测试结果会随着测试编排的顺序变化,也与自身的预期不符造成测试失败(交叉污染)。建议每个测试case前使用生成一个,可以把相同的new对象过程封装在一个函数中,其他案例中调用即可,或者使用类似于aop的方式也可。

基于前一段的描述,如果可以100%确保对象的值不会在测试case中不会变更,那么可以尝试上述所说的直接使用共享夹具的方式。

对于函数传入的参数,任何时候都考虑边界条件,比如集合为空,对象为空,数值为0或为负数的情况,对异常值进行必要的处理,增强代码的健壮性。

把测试集中在可能出错和不确定的地方,使得测试带来的效益最大化。



第 5 章 介绍重构名录

重构手法5部分:名称、速写、动机、做法、范例。



第 6 章 第一组重构

提炼函数:如果你需要花时间浏览一段代码才能弄懂他到底在干什么,那么就应该将其提炼到一个函数中,并根据他所做的事为其命名。读代码时间就可以一眼看到函数的用途而不用关心他具体的实现。

内联函数:如果直接使用代码比提取函数的方式更加清新,那么建议取消提取函数,直接使用其中的代码。

提炼变量:表达式有可能非常复杂,引入解释性变量分解表达式,使得便于阅读和管理表达式。

内联变量:如果一个临时变量并不比表达式本身更具表现力,那么将变量内联到表达式从而消除变量。

改变函数声明:函数签名的修改,保证函数语义清晰和广泛使用(函数名修改和参数增减分步来做)。

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

如果一个函数名字起的比较草率,或者随着时间的推移,函数名不足以表明其实现的含义,并且该函数是已发布的API,可以使用迁移式做法,然后就函数标记为deprecated,等待客户端等完全将就函数名替换,即使无法完全推动外部改名,我也能在当前代码范围内得到一个好的命名,只不过共存了一个不好的命名。可以看一眼如下案例:

// 改造前
// circum 是一个求圆周长的一个函数,但当前名字是环绕、周围的意思,不够好
function circum(radius) {
 return 2 * Math.PI * radius;
}
// 改造后
function circum(radius) {        // 旧的函数名,可以标记为 @Deprecated
  return circumference(radius);  // 原来的实现迁移到新的函数中
}
function circumference(radius) { // 新的推荐使用的函数
  return 2 * Math.PI * radius;
}

对于一个函数添加参数,如果没有趁手的工具一步完成当前函数和所有调用方参数的添加,也可以诶采用上述前一函数的做法。

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

// isPriority 实际情况的使用的新增变量,这里省略其真实用法,仅仅演示如何新增参数而不破坏历史的调用
zz_addReservation(customer, isPriority) {
  assert(isPriority === true || isPriority === false); 
  this._reservations.push(customer);
}

封装变量:对于所有可变的数据,只要它的作用域超出单个函数,就将其封装起来,只允许通过函数访问。(go的当前代码大多直接访问变量,变量定义都是大写,外部可直接访问)

常量改名的方法,建立改好后的新常量名,然后让旧的常量引用新的常量名,接着逐步更新代码中引用旧常量名的地方,最后将旧常量名删除完成替换过程。

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

引入参数对象:比如范围这种成对出现的变量,可以考虑将这两个变量封装在一个类中,这样可以减少函数传参,也能将数据相之间的关系变的明晰。封装成类的好处还有,我们可以为类提供行为,将一些通用的行为迁移封装到类中,使得外部调用更加简洁清晰。

函数组合成类:如果发现一组函数形影不离地操作同一块数据(通常是将这块数据作为参数传递给函数),那么是时候组建一个类了。这么做的原因在于封装和简化,封装很好理解,简化在于,同一个类中,函数调用可以少传很多参数。

函数组合成变换:数据变换函数接受源数据作为输入,计算出所有的派生数据,将派生数据以字段的形式填入输出数据。有了变换函数,我们就始终只需要到变换函数中去检查计算派生数据的逻辑。

拆分阶段:将有明显阶段层次的代码重新组织与抽象,避免代码堆积在一处。



第 7 章 封装

我在“Refactoring Code to Load a Document”[mf-ref-doc]这篇文章中讨论了更多的细节,有兴趣的读者可移步阅读。

封装记录(以数据类取代记录):对于多个存在关联的可变数据(记录型结构),使用封装可以隐藏实现/计算的细节(类),避免出错。

封装集合:集合的不当修改可能会带来意想不到的bug,我们建议将集合的修改操作限制在封装集合的类中(通常是在类中设置“添加”和“移除”方法),但是这回依赖团队成员的良好编程习惯,因此更好的做法是,不要让集合的取值函数妇女会原始集合,这就避免了客户端的意外修改。



注意:以下文字确认后需要删除

// 如何理解
使用数据代理和数据复制的另一个区别是,对源数据的修改会反映到代理上,但不会反映到副本上。大多数时候这个区别影响不大,因为通过此种方式访问的列表通常生命周期都不长。

以对象取代基本类型:如果某个数据的操作不仅仅局限于打印时,那么考虑为它创建一个新类。一开始这个类很简单,不过只要有了这个类,以后添加的新业务逻辑就有地可去了。

以查询取代临时变量:以查询取代临时变量手法只适用于处理某些类型的临时变量,那些只被计算一次且之后不再被修改的变量。

    constructor(quantity, item) {
    this._quantity = quantity;
    this._item = item;
  }

get price() {
    var basePrice = this._quantity * this._item.price; // basePrice 临时变量可以变成函数
    var discountFactor = 0.98;                         // discountFactor 临时变量可以变成函数
    if (basePrice > 1000) discountFactor -= 0.03;
    return basePrice * discountFactor;
  }
}
get price() {
  return this.basePrice * this.discountFactor;
}

get basePrice() {
  return this._quantity * this._item.price;
 }

get discountFactor() {
  var discountFactor = 0.98;
  if (this.basePrice > 1000) discountFactor -= 0.03;
  return discountFactor;
}

提炼类:随着责任的不断增加,类会变得很复杂且因这种复杂而变得不容易理解,那么考虑哪些部分可以分离出去,使得当前类只处理一些明确的责任。(单一职责原理)

内联类:内联类正好与提炼类相反。如果一个类不再承担足够责任,不再有单独存在的理由(这通常是因为此前的重构动作已走了这个类的责任),我就会挑选这一“萎缩类”的最频繁用户(也是一个类),以本手法将“萎缩类”塞进另一个类中。

隐藏委托关系:如果某些客户端先通过服务对象的字段得到另一个对象(受托类),然后调用后者的函数,那么客户端就必须知晓这一层委托关系关系。万一受托类修改了接口,变化就会波及通过服务对象使用它的所有客户端。我们可以在服务对象上放置一个简单的委托函数,将委托关系隐藏起来,从而去除这种依赖。

移除中间人:随着受托类的特性(功能)越来越多,更多的转发函数就会使人烦躁。服务类完全变成一个中间人,此时就应该让客户直接调用受托类。(某种意义上了违反迪米特法则)

替换算法:随着对问题有更多的理解,发现在原先的算法逻辑下,有更简单的解决方案,此时就需要改变原先的算法;或者自己的某些实现与使用的程序库代码重复,也需要改变原先的算法。



第 8 章 搬移特性

搬移函数:搬移函数最直接的一个动因是,它频繁引用其他上下文中的元素,而对自身上下文中的元素却关心甚少。此时,让它去与那些更亲密的元素相会,通常能取得更好的封装效果,因为系统别处就可以减少对当前模块的依赖。同样,如果我在整理代码时,发现需要频繁调用一个别处的函数,我也会考虑搬移这个函数。有时你在函数内部定义了一个帮助函数,而该帮助函数可能在别的地方也有用处,此时就可以将它搬移到某些更通用的地方。同理,定义在一个类上的函数,可能搬移到另一个类中去可以更方便我们的调用。

搬移字段:如果当前字段总是与其他结构的某个字段一同出现,相互紧密联系,那么当前字段的位置可能需要调整,调整手段就是搬移字段。

搬移语句到函数:消除重复,即将相同的代码片段合并到一个函数里,方便调用和修改。

搬移语句到调用者:一个函数的边界发生变化,我们得把表现不同的行为从这个函数挪出,并搬移到其调用处。注意这个重构手法比较适合处理边界仅有些许便宜的场景,但有时调用点和调用者之间的边界已经相去甚远,此时便只能重新进行设计了。

以函数调用取代内联代码:如果一些内联代码所做的事情仅仅是已有函数的重复,我通常会以一个函数调用取代内联代码。(将内联代码替代为对一个既有函数的调用)

移动语句:让存在关联的东西一起出现,可以使代码更容易理解。如果有几行代码去用了同一个数据结构,那么最好是让他们在一起出现,而不是夹杂在去用其他数据结构的代码中间。

拆分循环:让一个循环只做一件事情,那就能确保每次修改时你只要理解待修改的那块代码的行为就可以了。一般拆分循环后,紧接着可以对拆分的循环应用提炼函数。(注意:如果对于性能要求很高的场景,并且循环确实成了性能瓶颈,那么谨慎使用拆分循环,甚者需要把之前拆开的循环合并到一起,牺牲可读性,不过循环本身很少成为性能瓶颈)

以管道取代循环:集合管道允许我们使用一组运算来描述集合的迭代过程,其中每种运算接受的入参和返回值都是一个集合。(

目前 go 语言实现的类似 Java Stream API 使用反射实现,建议学习用,不建议用于生产

移除死代码:一旦代码不再被使用,我们就该立马删除它。



第 9 章 重新组织数据

拆分变量:一个变量应该承担一种责任,如果他们在函数中承担了两种及以上的责任,这会令代码阅读者糊涂,因此建议分解承担多个责任的变量,使得每个变量承担一种责任。(请看如下代码)

let temp = 2 * (height + width);
console.log(temp);
temp = height * width;
console.log(temp);

const perimeter = 2 * (height + width);
console.log(perimeter);
const area = height * width;
console.log(area);

字段改名:如果使用的字段在小范围内使用,需要改名时,我可以直接完成替换;如果这个字段在多个地方大范围使用,为了减少犯错误,在类中使用需要改成的名字(新名字)去接受旧名字的值,对外提供的还是旧名字的访问函数,不过返回的是新变量的值,最后改变旧变量的访问函数为新变量的访问函数。(可以复制一份旧变量的访问函数,名字改成新的,然后逐步替换使用旧变量访问函数名字的地方,直到没有地方引用旧的函数名后旧的函数名删除即可)

以查询取代派生变量:强烈建议把可变数据的作用域范围限制在最小范围,这里的派生变量是指基于源数据计算出来的派生可变变量,我们尽量避免的他的原因是,如果源数据发生变量,那么直接使用这个之前计算出的派生变量将发生错误。所以对应可变源数据采用对象计算风格,也就是每次使用调用他的计算函数得到;如果源数据不可变,那么使用将计算结果封装在数据结构中可以避免重复计算将是不错的选择。

// 原始状态
get production() {return this._production;}
applyAdjustment(anAdjustment) {
 this._adjustments.push(anAdjustment);
 this._production += anAdjustment.amount; // 存入一次计算一次,如果改变一个对象的值,忘记更新这行代码将会产生错误
}
// 中间状态
get production() {
 assert(this._production === this.calculatedProduction);
 return this._production;
}

get calculatedProduction() { // 使用查询取代派生变量
 return this._adjustments
  .reduce((sum, a) => sum + a.amount, 0);
}
// 最终状态
get production() {            // 使用内联取代不必要的临时变量
  return this._adjustments
    .reduce((sum, a) => sum + a.amount, 0);
}

将引用对象改成值对象:如果引用的对象数值具有不可变性,建议使用值对象。

将值对象改成引用对象:如果共享的数据需要更新,将其复制多份的做法就会遇到巨大的困难,因为此时我需要找到所有的共享副本并将需要改动的数据作更新,不合理,此时就需要将值对象改成引用对象。



第 10 章 简化条件逻辑

分解条件表达式:将复杂的逻辑计算中的每一个分支(if-else,switch-case)独立成一个小块代码以避免代码堆积。

合并条件表达式:如果条件检查的结果一致,那么建议将多个检查表达式提取为一个检查函数。

以卫语句取代嵌套条件表达式:选中最外层需要被替换的条件逻辑,将其替换为卫语句。

以多态取代条件表达式:一些复杂且重复的逻辑(if-else,switch-case),针对每个分支逻辑创建一个类,用多态来承载各个类型特有的行为,从而去除重复的分支逻辑。(多态+工厂,多态+单例map)

以上就是我们所说的标准多态情况,有时候我们会遇到两个if处理方式中都有相同的一段代码,那么考虑将两段if中的相同代码抽象出一个类,将这个类中填充这段特殊的代码(子类特殊逻辑),并将其与非特殊的代码抽象成一个超类,使得超类处理通用逻辑,子类处理差异化的特殊逻辑。

引入特例(引入 null 对象):将通用的边界处理逻辑抽象成一类(特殊边界类),这个类是当前类的兄弟类,共同继承一个超类,然后逐步将所有的特殊逻辑去除,因为多态的存在,如果是特殊边界值会走特殊边界类的处理逻辑,从而使得客户端的调用无差别且保证程序的健壮性。(实际情况下,没咋使用这种方式的,都会直接if判断值的合法性)

引入断言:断言是一个条件表达式,应该总是为真。如果它失败,表示程序员犯了错误。如果你发现代码假设某个条件始终为真,就加入一个断言明确说明这种情况(只用来检查“必须为真”的条件)。



第 11 章 重构 API

将查询函数和修改函数分离:将修改函数中的查询代码块提取成一个查询函数,修改函数中根据查询函数的结果做相应的修改操作。

函数参数化(令函数携带参数):如果发现两个函数逻辑非常相似,只有一些字面量值不同,可以将其合并成一个函数,以函数的参数形式传入不同的值,从而消除重复。

移除标记参数(以明确函数取代参数):移除标记参数,已明确的函数取代参数。

保持对象完整:如果我看见代码从一个记录结构中导出几个值,然后又把这几个值一起传递给一个函数,我会更愿意把整个记录传给这个函数,在函数体内部导出所需的值。

以查询取代参数(以函数取代参数):如果有必要,使用提炼函数(106)将参数的计算过程提炼到一个独立的函数中。

以参数取代查询:如果一个函数对于特定的输入总会有特定的输出,那么这个函数具有引用透明性,使用参数取代查询的好处在于可以将函数变成具有引用透明性。但这会增加调用者的负担,因为原先的查询参数需要调用者传入,那么调用者需要保证参数的正确性,增加调用者的复杂度。

移除设值函数:如果为某个字段提供了设值函数,就暗示这个字段可以被改变。如果不希望对象在创建之后此字段还有机会被改变,那么就不要为它提供设值函数,同时将该字段声明为不可变。

以工厂函数取代构造函数:使用工厂函数可以避免一些构造函数的局限性。

以命令取代函数:将函数封装成自己的对象被称之为命令对象(command object),或者简称命令(command)。这种对象大多只服务于单一函数,获得对该函数的请求,执行该函数,就是这种对象存在的意义。

以函数取代命令:命令对象为处理复杂计算提供了强大的机制,借助命令对象,可以将原本复杂的函数拆解为多个方法,彼此之间通过字段共享状态;拆解后的方法可以分别调用;开始调用之前的数据状态也可以逐步构建。如果命令对象封装的函数并不是那么复杂,那么考虑使用普通函数取代命令对象将是不错的决定。



第 12 章 处理继承关系

继承本身是一个强有力的工具,但有时它可能被用于错误的地方,有时本来适合使用继承的场景变得不再合适,此时我会用委托取代子类或以委托取代超类,从而将继承体系转化成委托调用。

函数上移:如果某个函数在各个子类中的函数体都相同,这就是显而易见的函数上移适用场合。如果被提升的函数引用只在子类中出现而不出现于超类的特性。此时就需要使用字段上移和函数上移讲这些特性(类或者函数)提升到超类。如果两个函数工作流程大体相似,但实现细节略有差异,那么我会考虑先接住塑造模板函数构造出相同的函数,然后再提升它们。

如果一个类某个函数明确需要子类去实现,可以在当前类的函数中设置一个行进函数。

// 父类
get monthlyCost() {
  throw new SubclassResponsibilityError();
}

字段上移:如果各子类是分别开发的,或者在重构过程中组合起来的,你会发现它们拥有重复特性,比如字段。这样的字段有时拥有近似的名字,并且被使用的方式也很相似,我们可以将这些字段提升到超类中去。

构造函数本体上移:如果一个字段(比如姓名),在各子类中的都会使用,那么这个字段的初始化可以统一放到超类中去初始化,子类中构造的时候调用超类的构造函数去初始化这个字段。

// before
class Party {...}

class Employee extends Party {
 constructor(name, id, monthlyCost) {
  super();
  this._id = id;
  this._name = name;
  this._monthlyCost = monthlyCost;
 }
}


// after
class Party {
 constructor(name) {
  this._name = name;
 }
}

class Employee extends Party {
 constructor(name, id, monthlyCost) {
  super(name);
  this._id = id;
  this._monthlyCost = monthlyCost;
 }
}

函数下移:如果超类中某个函数只与一个(或少数几个)子类有关,那么最好将其从超类中挪走,放到真正关心它的子类中去。如果不知道或者不明确哪些子类需要这个函数时,那就得用多态取代条件表达式,只留些共用的行为在超类。

字段下移:如果某个字段值被一个子类(或者一小部分子类)用到,就将其搬移到该字段的子类中。

以子类取代类型码(Replace Type Code with Subclass):如果需要根据类型码来执行不同的分支逻辑,可以考虑引入子类,将分支逻辑放在子类中,使用多态取代条件表达式。

移除子类:又名以字段取代子类(Replace Subclass with Fields),如果子类的用处太少,那么就不值得存在了,此时最好的选择是移除子类,将其替换为超类中的一个字段。

提炼超类:如果两个类在做相似的事情,可以利用基本的集成机制把他们的相似之处提炼到超类。

折叠继承体系:随着继承体系的演化,如果发现一个类与其超类已经没有多大的区别了,不值得再作为对独立的类存在,此时应该将子类与超类合并起来。

以委托取代子类:继承给类之间引入了非常紧密的关系,在超类上做任何修改,都很可能破坏子类。如果两个类的逻辑分处不同的模块、由不同的团队负责,问题就会更麻烦。委托(组合)是对象之间的常规关系,与继承相比,使用委托关系时接口更清晰、耦合更少。

// 原代码
class Sanitation
{
public:
    string washHands()
    {
        return "Cleaned ...";
    }
};

class Child : public Sanitation
{

};
//  以委托取代继承
class Child
{
public:
    Child() 
    {
        m_pSanitation = new Sanitation();
    }

    string washHands() 
    {
        // 这里使用委托获得 washHands() 方法 
        return (m_pSanitation->washHands());
    }
    
private:
    Sanitation *m_pSanitation; // 具有委托对象 Sanitation 的实例
};

以委托取代超类:一个经典的误用继承的例子:让栈(stack)继承列表(list),其想法是复用列表类的数据存储和操作能力。虽说服用是一件好事,但这个继承关系有问题:列表类的所有操作都会出现在栈类接口上,然而其中大部分操作对一个站来说并不适用。更好的做法应该把列表作为栈的字段,把必要的操作委派给列表就行了。(如果超类的一些函数对子类并不适用,就说明我不应该通过继承来获得超类的功能)

// before
class List {...}
class Stack extends List {...}

// after
class Stack {
  constructor() {
    this._storage = new List();
  }
}
class List {...}



版权声明:本文为weixin_41876385原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。