《重构-改善既有代码的设计》

  1. 马丁.福勒(美)

1689043131893-3d33f46a-ea6e-42ec-adb2-866222b58624.png

一句话总结

学习《重构》是为了减少重构。通过读本书可以鞭策开发者及所在团队深入地理解架构、理解业务、理解需求,减少因设计失误导致的徒劳无益地反复重构。

脑图

1689337010461-5af0cf39-50f8-4ad6-8a25-76a2aed61198.jpeg

详情

译者序

  • ThoughtWorks 的王健在开展大型架构重构时总结了十六字心法:旧的不变,新的创建,一键切换,旧的再见
  • 所谓重构:在不改变代码外在行为的前提下,对代码进行修改,以改进程序的内部结构。本质上说,重构就是在代码写好之后再次改进他的设计

第1章:重构,第一个示例

本章举了一个例子说明怎样重构,并总结了一些原则:

  • 如果你要给程序添加一个特性,但发现代码因缺乏良好的结构而不易于进行改进,那就先重构这个程序,使其比较容易添加该特性,然后再添加该特性
  • 测试驱动开发:重构前,先检查自己是否有一套可靠的测试集,这些测试必须有自我检验能力
  • 重构技术就是以微小的步伐修改程序。如果你犯下错误,很容易就能发现他
  • 傻瓜都能写出计算机可以理解的代码。唯有能写出人类容易理解的代码才是优秀的程序员
  • 可变的数据很容易编程烫手山芋,尽量保持数据的不可变性(Redux的设计思想)
  • 编程时,需要遵循营地法则:保证你离开时的代码库一定比来时更健康
  • 好代码的检验标准就是人们可以轻而易举地修改它

最后:重构的关键是小的步伐可以更快地前进,请保持代码永远处于可工作状态,小步修改积累起来也能大幅改善系统的设计。

第2章:重构的原则

何谓重构

  • 重构(名词):对软件内部结构的一种调整目的是不改变软件可观测行为的前提下,提高其可理解性,降低修改成本
  • 重构(动词):使用一系列的重构手法,在不改变软件可观测行为的前提下,调整其结构

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

两顶帽子

Kent Beck 提出了两顶帽子的比喻:

  • 添加新功能
  • 调整代码结构

我们在开发时经常需要再这两顶帽子之间进行切换

为何重构

  • 重构改进软件的设计:经常性的重构有助于代码维持自己该有的形态并消除重复代码
  • 重构使软件更容易理解:代码不只给计算机用,还给其他的维护者用,经常的重构可以让人更能看懂代码的含义
  • 重构帮助找到 bug
  • 重构提高编程速度:之前行业的陈规认为良好的设计必须在开始编程之前完成,因为开始编写代码后,设计就只会腐败,重构改变了这个图景。我们可以先做一个设计,然后随着需求的迭代不断改进它

何时重构

  • Don Roberts 的三次法则:第一次做时尽管去做,第二次做时可能会反感,但是还能做,第三次做类似的事情你就需要重构——事不过三,三则重构
  • 长期重构:基于微小的步骤,我们可以把重构放到长期的规划中
  • 如果重写比重构还容易,那就重写吧~

重构的挑战

  • 延缓新功能的开发:作者认为这仅仅是表象,重构的唯一目的就是开发的更快,用更少的工作量创造更大的价值。重构应该总是由经济利益驱动,因此重构不是为了美观而做的,而是切实解决问题的。(当然产品经理与项目经理大概率意识不到这玩意的价值········,作者的经验是不要告诉项目经理与产品经理~)

重构、架构和 YAGNI

重构使得增量式设计、简单设计变成可能(you aren’t going to need it)。YAGNI 并不是不做架构性思考的意思,而是先等一等,等到问题理解的更充分,再来着手解决问题。

重构与软件开发过程

自测试代码、持续集成、重构这三大软件开发的实践过程彼此之间有着很强的协同效应

重构与性能

除了对性能有要求的实时系统,我们应该先对架构进行重构,基于重构的架构才能更好地实现性能调优。
作者的经验:不要怕重构会影响性能,哪怕你完全了解系统,也需要实际测量一下性能指标,不要臆测。

第3章:代码的坏味道

如果你的代码出现了本章中的一些坏味道,这就意味着你需要进行重构了。

  • 神秘命名:无法通过命名直观地知道变量或者方法的作用
  • 重复代码:万恶之源,这种代码应该直接被干掉
  • 过长函数:函数的职责不够单一,导致直接违反开闭原则
    • 有一条原则:每当感觉需要注释些什么的时候,就说明你应该将这里的实现放到一个单独的函数里
  • 过长参数列表:过长的参数列表本身就会让人很迷惑
  • 全局数据:全局数据代表全局任何一个地方都可以改变他而不受监控,一般需要通过封装变量的方式将这种全局的数据进行封装,通过方法进行改变
    • 封装变量的好处:1. 利于监控与定位问题 2. 如果这个变量需要跟一些协同变量同时改变,函数可以做到收口
  • 可变数据:Redux 的设计思想
  • 发散式变化:某个模块经常因为不同的原因在不同的方向上发生变化,这就意味着这个模块已经违反了单一职责原则,需要进行分割
  • 霰弹式修改:每次遇到某种变化,你必须在很多不同的类中进行小幅修改,这就说明你的模块拆分的过细
  • 依恋情结:一个函数跟另外的模块的函数或者数据交流格外频繁,远胜于自己所处模块内部的交流。
    • 解法:将这个函数跟另外的模块放到一起
  • 数据泥团:如果你能在多个函数的参数中看到几个字段经常一起出现,这就是典型的数据泥团,应该将这些字段封装成类进行传递
  • 基本类型偏执:比如区号、血型等业务逻辑,不要单纯的用 string 去表示这种类型,可以通过封装对象的方式取代基本数据类型进行业务逻辑表达
  • 重复的 switch:用多态替代重复的 switch
  • 循环语句:作者提倡使用管道(比如 js 的 map filter等操作符)取代循环,但是我不认为循环就是个坏味道
  • 夸夸其谈的通用性:即过度设计
  • 过大的类:类的职责过度,需要进行拆分
  • 被拒绝的遗赠:如果拒绝父类的实现&接口,那继承父类就完全没有意义,不如通过以委托取代子类去代替继承关系
  • 注释:注释意味着这里的函数可以再次拆分成更小的函数 & 函数的命名可以再次优化。这里并不是说完全不可以写注释

第4章:构筑测试体系

作者认为:编写优良的测试程序,可以极大提高开发者的编程速度,即使不做重构也一样如此。
本章作者梳理了一些自测 case 的原则:

  • 让所有的测试都完全自动化,让它们自己检查测试结果
  • 一套测试就是一个强大的 bug 侦测器,能够大大缩减查找 bug 所需的时间
  • 确保测试case在不该通过时真的会失败
  • 频繁地运行测试,对于你正在处理的代码,与其对应的测试至少每隔几分钟就要运行一次,每天至少运行一遍所有的测试
  • 测试 case 不要求过度完美,编写不完美的 case 经常运行好过完美测试的无尽等待
  • 将UI与业务逻辑隔离,这样才能写测试用例直接测逻辑部分
  • 考虑可能出错的边界,把测试火力集中到那里
    • 不断探索程序的错误边界反过来可以提高我们正向编程的能力
  • 不要幻想把所有场景都覆盖测试,只覆盖那些关键、容易出错的地方就够了,不然会因为工作量太大而气馁
  • 每当你收到一个 bug 时,请先写一个单元测试来暴露这个问题

第5章:介绍重构的目录

接下来的所有篇幅都是介绍各种重构手法,大家可以根据自己的业务代码特点挑选自己需要的重构手法使用~
谨记一点:小步前进,情况越复杂,步子就要越小

第6章:第一组重构

提炼函数

作者认为好的程序应该由“小函数”组成,小函数的定义是看到函数名就知道这个函数在干什么,如果函数名不能表达函数要做的事情,那就继续拆

内联函数

如果函数名与函数体基本一致,那这个函数就没有存在的必要。比如 function filter(array,fuc) {array.filter(fuc)}

提炼变量

如果一个表达式非常长/有魔法数,那就通过提炼变量让代码看起来更清晰

let result = height * width * 2;
// 应该改成
let area = height * width;
let doubleArea = area * 2;

内联变量

如果变量的名字跟表达式的名字相同,那就不需要这个变量,直接内联到调用的地方就行了
But!!! 我不认为这是种好方法,内联变量后不利于 debug

改变函数声明(函数改名)

如果函数的名字不能表达他正在做的事情,需要通过改名来让代码更清晰易懂。两种做法:

  1. 直接改名,全局搜索替换
  2. 旧的函数保留做转发(如果涉及到外部用户正在调用你的 api)

封装变量

如果一个变量好多地方都在改变他的值,那就需要通过函数把这个变量封装起来。好处:

  1. 方便调试与收口,知道是谁改的变量的值
  2. 如果这个变量与其他变量需要做联动,直接在方法内部做联动就行了,不需要大范围改别地方的代码

变量改名

好的命名是编程的核心

引入参数对象

即把函数的入参改成对象形式。如果一组数据总是结伴而行,出没于一个又一个函数,这就是所谓的数据泥团,需要将这些数据封装到一个类里面。这样实现所有用数据的地方存取的标准。

function bookHotel(startData,endData)
function unBook(startData,endData)
//应该改成
function bookHotel(bookData)
function unBook(bookData)

函数组合成类

如果几个函数总是形影不离地操作同一个数据,function A 的返回是 function B 的入参,那就将 A 与 B 组合到同一个类里面。好处:

  1. 减少传参,可以通过类的上下文实现参数传递
  2. 方便继续抽象,将这些方法设计的更合理

函数组合成变换

多个函数组合成一个大函数,跟组合成类异曲同工,没啥新鲜玩意

拆分阶段

如果一段代码同时在处理两件事情,那就把他们拆到各自的模块中,实现了这种分层后我们在改其中一个模块的事情完全不用回想其他模块的细节。
方法:将一大段行为分解成顺序执行的两个阶段,典型的就是编译器:文本转成 AST,AST 生成目标代码,这些阶段都非常明确

第7章:封装

封装的意义是隐藏内部实现细节不对外暴露,外部直接使用暴露出来的能力就好了。

封装记录

典型的就是数据库中存储的记录都是一个一个的 POJO

封装集合

开发者进行封装时通常会忘记屏蔽集合对象本身不对外暴露

class Persion{
	getList(){return this._list}
  setList(list){this._list = list}
}



// 上面的封装会把集合直接暴露出去,并没有起到封装的效果
class Persion{
  add(item){
    this._list.add(item)
  }

}

以对象取代基本类型

比如区号这种业务,很多程序员通常思维固化会想使用 String 或者 Int 来表达。我们完全可以封装一个区号对象来表达区号。类似的还有金额(钱类型、数量)等。

以查询取代临时变量

如果某个临时变量只被赋值一次,那就没必要用这个变量了,直接通过方法拿到这个变量的值用就行了。
PS:这个方法难以被 debug ,我个人认为这玩意没用

提炼类

如果一个类同时做了很多事情,那就变得职责不够单一,很容易改动某处时导致其他地方的 bug,信号:

  • 大类中的某些数据总是相依相靠,并且经常一起被改动
  • 大类的职责不够单一,经常因为多个原因改动他
class Person{
  get officeAreaCode() {return this._officeAreaCode}
  get officeNumber(){return this._officeNumber}
}



// 需要改成
class Person{
  get officeAreaCode() {return this._telephoneNumber.officeAreaCode}
  get officeNumber(){return this._telephoneNumber.officeNumber}
}



class TelephoneNumber{
  get areaCode() {return this._areaCode}
  get number(){return this._number}
}

内联类

与 3.7.5 方法相反
如果一个类不足以承担责任,那就通过内联的方法将他塞进另一个类中。本质上还是单一职责原则,如果他不能承担相应的职责,那就干掉他

隐藏委托关系

通过封装只对外暴露能力,使用者不需要知道具体的实现细节。好处:如果调用关系出现了变化,使用者不需要感知,被调用者做桥接就行了

移除中间人

与 3.7.7 方法相反
3.7.7 的方法优势是调用者不感知具体的细节,劣势是每次被调用者增加了新的能力供别人调用时,中间人就需要增加一层转发函数,如果中间人需要的转发过多并且系统没有频繁调用关系变化的诉求,那直接移除中间人就好了

替换算法

经过不断地优化将你业务逻辑中那坨屎一样的代码用简短、高效、清晰的算法进行描述

第8章:搬移特性

上面讲述的方法都是通过新建、删除、重命名等方式进行重构,本章主要讲的是通过在不同的上下文之间搬移元素实现重构。

搬移函数

把函数搬移到更需要他的模块中去。原因:如果一个函数频繁使用其他上下文中的元素而对自身上下文中的元素知之甚少,那就把他搬移到它关心的上下文中

搬移字段

如果发现调用某些函数时,经常需要传入多个字段,这些字段由不在一个地方,那就通过搬移字段的形式将这些字段放到同一个模块中维护,这样改的时候只需要改一个地方。
延伸:好好设计一下你的数据结构。数据结构是一个健壮程序的根基,一个适应问题域的良好数据结构可以让行为代码变得更简单,而一个糟糕的数据结构则会招来很多无用的代码。

搬移语句到函数

本质上是要消除重复,如果好多调用者调用某个函数时都前置做了一些相同的事情,那就把这些相同的事情直接放到函数里面,这样调用者就不用做相同的事情,代码的重复也会被消除

function callerA(){
  // check sth
  print();
}




function callerB(){
  // check sth
  print();
}



function print(){
  do sth
}


// 应该改成 
function print(){
  // check sth
  do sth
}

搬移语句到调用者

如果很多调用同一个函数的调用者在调用点面前经常出现不同的行为,那就将这些不同的行为从函数里面拆出来放到调用者内部。不要在函数内部通过 from 这种字段进行区分。

以函数调用取代内联代码

如果在一个大函数里包含了实现某块特定功能的代码,那就将这些代码单独抽成一个函数并附上良好的命名。好处:后续的维护者可以通过看到这个小函数名知道这块的代码在干什么,冗杂的代码会让后来人看懵逼

function getNameById(id){
  let result = allPersion.find((item) => {return item.id === id})
  return result.name
}



// 改成
function getNameById(id){
  let persion = getPersionById(id)
  return persion.name
}



function getPersonById(id){
  return allPersion.find((item) => {return item.id === id})
}

移动语句

作者认为:操作同一个变量的语句尽量放到一起,方便查找与阅读
PS:我个人觉得应该是基于业务逻辑耦合程度决定是否放到一起而不是单纯的是否是一个变量

拆分循环

如果你在同一次循环中干了两件事,那分别改动这两件事情的时候都要改这个循环体,可能互相影响,违反单一职责原则。作者认为这时候应该把循环拆开,哪怕多次循环,也要保证循环内只做一件事情。
PS:这样会影响性能,谨慎使用

以管道取代循环

使用 .map .filter 等管道操作符代替循环
PS:花里胡哨,没啥用

移除死代码

如果没人调用的代码直接干掉就行了,不要注掉,你完全可以通过 git 记录找到这些代码,不要把这些死代码还放到文件里

第9章:重新组织数据

拆分变量

每个变量只应该表达一件事情,如果同一个变量身兼数职,那就需要新启变量来使用(循环体内的临时变量另当别论 for(let i=0;i<100;i++))

let temp = persion.name;
console.log(temp)
temp = persion.sex
console.log(temp)
// 上面的代码应该拆成两个变量  name  sex

字段改名

如果是程序中广泛使用的数据结构,字段的命名非常重要,这对读者阅读代码非常有帮助。通常你可以通过 ide 的自动分析工具帮助你改名,不需要全局搜索代码。

以查询取代派生变量

作者认为可变的数据结构是软件最大的错误源头之一,所以尽量将可变的数据结构限定在一个范围内可以最大程度避免这些错误。因此,如果一个变量非常简单,那就用的时候通过调用某个函数来获取就行了,没必要单独起一个临时变量存储。
PS: 个人认为没啥用

将引用对象改成值对象

本质上是保持对象的不可变性,还是为了消除可变数据源,但是如果多个地方都要修改这个对象,这种值对象则非常麻烦(需要来回复制),还是需要保留引用对象的

将值对象改成引用对象

上面方法的逆向,如果多个地方需要共享某个对象,那就都共享对象的引用,直接操作这个对象的属性就行了

第10章:简化条件逻辑

分解条件表达式

这个只是提炼函数的一个应用场景,目的是将大坨的计算代码从 if else 语句中拆出来,方便维护者快速理清楚程序的走向。
提炼函数两个步骤:

  1. 将大函数分解成小的函数
  2. 将小的函数配上合适的命名
if(a){
  // 1
  // 2 
  // 3
}else{
  // 4
  // 5
  // 6
}




// 替换成
if(a){
  callA();
}else{
  callB();
}

function callA(){
  // 1
  // 2 
  // 3
}

合并条件表达式

如果检查条件各不相同,最终的行为却一致,可以通过合并条件表达式来做。
PS:这玩意有待商榷,如果后面这些行为不一致的时候还得拆分

if(a) return 0

if(b) return 0

if(c) return 0

// 替换成
if(a || b || c){
  return 0
}


以卫语句取代嵌套条件表达式

卫语句:如果判断条件为真,直接 return
作者认为没有必要始终保持程序只有一个入口与出口,保持代码清晰才是最关键的。

if(a) return 0

if(b) return 0

if(c) return 0

以多态取代条件表达式

老生常谈,使用多态的能力一致性去取代大量的 switch case,如果是比较简单的判断语句,那就不用劳师动众的使用多态。如果判断逻辑特别复杂,那引入多态可以有效的解决复杂度问题。

引入特例

书上说的太复杂了,简单点:如果很多地方都在检查一个值的合法性,那就单独抽一个函数去消除这个重复就行了

引入断言

每种语言都不一样,看情况而定吧

第11章:重构 API

将查询函数与修改函数分离

有副作用(可以 set 值)的函数与没有副作用(只 return)的函数应该分离开,这样后续的维护者在调试的时候就不担心没有副作用的函数偷偷改了某些值。

函数参数化

如果你的代码里面有很多函数在干着80%相似20%有差异的事情,那就应该考虑将这 20% 的内容通过参数的形式传入到函数内,然后把这些函数重复的地方干掉

移除标记参数

如果一个函数内部通过入参的某些参数分别执行不同的逻辑,那不如直接拆成两个函数,这样更清晰。当然这个手法也得视情况而定,如果有多个标记参数,那你也拆不动~

function setWidthOrHeight(size,isWidth){
  if(isWidth){
    width = size
  }else{
    height = size
  }
}


// 直接拆成两个函数就可以了
function setWidth(width)
function setHeight(height)

保持对象的完整

如果在函数调用前先导出了几个值,然后又把这些值传给了某个函数,那就直接把这个对象传给函数,函数内部去解构。

const {w,h} = square;
function area(w,h){return w*h}
// 直接改成 
function area(square){
  const {w,h} = square;
  return w * h;  
}


以查询取代参数

如果某个函数有很多入参,某些入参可以通过其他入参推导出来,那就干掉这些可被推导的入参,通过推导的方式取代。
好处:保持最小耦合
另外,保持函数的幂等非常重要,这种函数非常容易被测试

以参数取代查询

上面方法的逆向操作方式
如果你的函数内部引用了一些全局变量,那就把这些参数抽离到函数的入参中,通过调用的形式传入。好处:保持函数的幂等!

移除设值函数

这种方法有点极端,本质上作者是希望让字段保持不可变性。如果某个字段你不想让别人再改,那就干掉设值函数并保持此字段是 private ,对外部不可见

以工厂函数取代构造函数

没看出来有啥好处,盲猜好处是将构造对象的地方收口到同一个地方

以命令取代函数

与普通的函数调用相比,命令对象提供了更强大的控制灵活性与更强的表达能力。除了函数调用本身,命令对象可以支持附加操作,例如撤销操作。(当然过于灵活不是什么好事,难以调试与管控)作者也认为自己 95% 的场景都不会选择用命令取代函数。

以函数取代命令

正如上面说的命令对象缺点一样,如果你的业务场景并不复杂,直接用函数调用就行了,不要搞命令

处理继承关系

函数上移

如果同一个功能在很多子类中都需要,那就将这个功能的代码直接上移到父类里

字段上移

如果子类有很多重复的特新,那就将他们重复的字段与操作字段的函数同时上移到父类中。本质上还是消除重复。

构造函数本体上移

如果很多子类在构造函数里都有相同的行为,那就将这些相同的行为上移到父类中,子类在构造函数中调用 super(xxx) 就好了

函数下移

如果超类中某个(或某几个)函数只与某个子类有关系,那就将这些玩意从超类中挪走

字段下移

同上,如果某个字段及其处理函数只被某个子类引用,那就将这些东西从超类里挪走

以子类取代类型码

没咋用过,示例代码如下

function createEmployee(name,type){
  return new Employee(nanme,type)
}
// 改成
function createEmployee(name,type){
  switch (name){
    case 'man' return createManEmplo(name);
    case 'woman' return createWomanEmplo(name)
  }

}

移除子类

随着软件的演进,子类所支持的功能可能会被挪到别的地方,如果子类承载的功能太少,那就直接干掉这个子类就行了。子类的存在就会有成本,人们需要理解他的用处。

提炼超类

**合理的继承关系是在演进中得到的,不是一开始就设计出来的。**我们需要在继承与委托两种方案之间进行选择,目的是把共同的元素提炼到同一个地方。

以委托取代子类

继承是具备消除重复代码的优势的,但是他的缺点也很明显:

  1. 继承这张牌只能打一次,如果后续子类朝着不同的方向演化,那继承就明显不合适
  2. 继承给类之间引入了非常重的耦合关系,在超类上做任何修改都会影响到子类

上面的两个问题通过委托都可以解决,不同的调用点通过委托的方式调用同一个类中的方法,这就是组合优于继承的表现

以委托取代超类

同上

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYloNzbf' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片