重构的方法论

Posted by CodingWithAlice on August 17, 2022

重构的方法论

重构经历那么多年,也有很明确的方法论,可以让我们以成本最小的方式,对有问题的代码,进行重构(书中给出的方法,其实和设计模式一样,是一种经验的理论化,产生了具体的方法论,每一种重构方式并不是只有书中提到的方式,但是书中提到的路径一定是相对可执行度比较高,也不容易出错的)

接下来按照书中页码顺序,记录一下学习重构案例的一些心得(笔记让我可以快速搜索关键词找到内容)

ps.这里记录的名录主要是根据第一章:重构的一个示例,其中谈及的名录,我都会先看一遍名录说明,再回到第一章案例中用案例实现一遍,还有一些第一章没有提及的重构手法并没有记录下来

第6章:放在最前面的 - 最有用的重构

提炼函数(106)

将一段代码提取为一个函数

  • 时机:如果你需要 花时间浏览一段代码才能弄清楚它到底在干什么,那么就应该将其提炼到一个函数中,并根据它所做的事为其命名 –> 以后再读到,一眼就能明确函数用途

  • 注意:1、在做提炼前,可以先移除局部变量;2、区分哪些变量会离开原来作用域,哪些在函数中会被修改/哪些只被使用 ; 3、不会被修改的作为参数传入,会被修改的作为返回值

  • 技巧:写一些非常小的函数(作者推荐6行)

内联函数(115)- 反向重构

  • 时机:如果函数内部代码和函数名称一样清晰易读,那就不需要函数名称 –> 不是所有的间接层都有价值

内联变量(123)

将变量替换成直接使用赋值语句的右侧表达式

  • 时机:变量名称不比表达式本身更具表现力,可能会妨碍重构附近的代码的时候

  • 技巧: 将变量改为 const,确认变量是否只被赋值了一次
  • 案例:
let basePrice = anOrder.basePrice;
return basePrice > 1000;
// - - - -> 改为
return anOrder.basePrice > 1000;

改变函数声明/函数改名/修改签名(124)

函数名称和参数的修改

  • 时机:修改参数,可以 减少模块之间的信息依赖,从而去除不必要的耦合

  • 技巧:先对函数体内部加以重构
    • 如果要修改的是一个具有多态性的类,那么每个实现版本都需要提炼出一个新的函数的方式,来添加一层间接,把旧函数调用转发给新函数
  • 案例:给函数添加参数
addReservation(customer) {
    this._reservations.push(customer);
}
// - - - -> 接下来可以通过提炼一个新的函数这种方式,每次只修改一个调用者到新函数,进行渐进式推进修改
addReservation(customer) {
    this.zz_addReservation(customer, false);
}
zz_addReservation(customer, isPriority) {
    alert(isPriority === true || isPriority === false);
    this._reservations.push(customer);
}

封装变量/封装字段(132)

主要用于搬移一些被广泛使用的可变数据,先以函数形式封装所有对该数据的访问 -> 把搬移数据这项比较困难的事情,转换成搬移函数这项相对简单的任务

  • 案例:封装变量 - 管控数据变化入口
let default = {name:Alice, cat:Aqiu};

// ----> 封装变量,且防止数据使用方修改源数据
let defaultData = {name:Alice, cat:Aqiu};
export function default() {return Object.assign({}, defaultData);}
export function setDefault(arg) {return defaultData = arg;}

变量改名(137)

好的命名是整洁编程的核心

  • 技巧:先声明新的常量名 -> 然后把新常量复制给旧的名字 -> 这样最后删除旧的名字,如果错了,可与你把旧常量放回来

引入参数对象(140)

对结伴使用的数据警醒,将它们组成结构

  • 技巧:观察成对出现的值如何被使用,将一些有用的行为搬进类中

image-20220817210811140

  • 案例:将有用行为搬进类中

image-20220817210942292

函数组合成类(144)

利用 class ,在 constructor 中获取一些反复被使用的数据,通过 get() 实现变量、计算结果都通过属性访问

  • 和下方「函数组合成变换」的区别点:如果在代码中对源数据做更新,那么使用类好得多 -> 使用下面的变换的话,派生数据存储在新记录,源数据一旦被修改,派生数据和源数据就不一致了
  • 案例:例如将访问全部收敛到一个类中,以属性访问

image-20220818205215724

函数组合成变换/数据变换函数(149)

用来限制需要对变量进行修改的代码量 -> 将所有计算派生数据的逻辑收拢到一处 -> 可以在固定地方找到/更新这些逻辑

  • 时机:在遇到有些函数的功能是转化或者充实数据结构的场景
  • 技巧:创建变换函数,入参为需要变换的 record,输出则是增强的 record,包含所有共用的派生数据
  • 步骤:1、创建变换函数,入参为需要变换的记录,出参直接返回入参 2、移入逻辑,把结果作为字段添加到输出记录中

拆分阶段(154)

将计算逻辑拆分成几个阶段

  • 时机:一段代码处理两件事 –> 后续修改的时候无法最小颗粒度得修改

  • 技巧: 创建一个中转数据结构 –> 大致划分好记个阶段后,可以 通过明确后一个阶段用到的前一个阶段的哪些值,来确定前一个阶段的返回值

  • 案例 :设置中转数据结构(编译器是一个很好的例子)

image-20220817211816431

第7章:封装

封装记录(162)

数据可变时,可以使用类对象,用于隐藏结构细节;数据不可变时,直接保存在 record 中读取即可

  • 案例:数据可变的情况,使用类封装 -> 封装记录不仅仅意味着替换变量,还可以控制它的使用方式
class Org {
  constructor(data) {
    this._data = data;
  }
  set name(aString) { this._data.name = aString; }
  get name() { return this._data.name; }
}

封装集合(170)

只封装记录的访问是不够的,应该把更新操作也封装起来

案例:比如为上面的记录添加接口

addOrg(aOrg) {
	this._data.push(aOrg);
}
removeOrg(aOrg,cb1) {
  const this._data.indexOf(aOrg);
  if(index === -1) cb();
	else this._data.splice(index,1);
}

以对象取代基本类型/以类取代类型码(174)

创建新类无需太大的工作量 -> 一开始这个类也许只是简单包装一下简单类型的数据

  • 不过只要类有了,日后添加的业务逻辑就有地可去了

  • 案例:创建一个新的 Priority 后,其他业务逻辑(几个比较函数)也可以放在当前类中

image-20220818205638737

私以为。上面重构完的代码不够精彩,最精彩的是调用非常非常优雅

image-20220818205855039

以查询取代临时变量(178)

只适用于临时变量,那些创建出来用于保存某段代码的返回值,提炼查询以便后续直接访问变量 -> 那些只被计算一次之后不再被修改的变量

  • 不适用:用作快照的临时变量,不适合使用本手法

  • 案例:

const basePrice = this._quatity * this._itemPrice;
if ( basePrice > 100 ) {
  return basePrice * 0.95;
} else {
  return basePrice * 0.98;
}

// 改为查询获取变量
get basePrice() { this._quatity * this._itemPrice; }

if ( this.basePrice > 100 ) {
  return this.basePrice * 0.95;
} else {
  return this.basePrice * 0.98;
}

提炼类(182)

拆分不同作用的代码,分解类所负的责任

  • 步骤:1、创建新类 -> 2、在构造旧类时创建新类的实例 -> 3、搬移字段
  • 案例:把电话号码相关行为提炼到一个新的独立的类中

image-20220818210442443

内联类(186)

【反向重构】把本不该分散的逻辑拽回一处 -> 和提炼类正好相反

  • 技巧:在目标类中创建委托函数,让调用方调用委托函数,然后再搬移代码测试

隐藏委托关系(189)

“封装”意味着每个模块都尽可能少得了解系统的其他部分

  • 步骤:创建一个委托函数,隐藏委托关系,客户端只调用委托函数,减少理解成本,去除依赖关系

  • 案例:

const manager = aPerson.department.manager; // 揭露了 Department 的内部原理

// --> 改为:隐藏 Department,减少耦合
class Person {
  get manager() {return this._department.manager;}
}
const manager = aPerson.manager;

替换算法(195)

如果做一件事有更清晰的方式,则用它来替换复杂的东西。(例如引入一些程序库后,替换原先一些功能。)

  • 技巧:先只为这个函数准备测试,以便固定它的行为;先别删除,用新旧代码一起执行以便对比结果

第8章 搬移特性

搬移函数(198)

  • 动机:写代码的时候,需要频繁使用其他上下文,而不怎么关心当前上下文
  • 搬移时需要关注: 1、函数的调用者都有谁 2、自身又调用了哪些函数 3、被调用的函数需要什么数据
  • 技巧:从依赖最少的那个函数入手;搬移一般都意味这为函数参数起个新名字,使它更符合新的上下文

搬移字段(207)

和搬移函数一样,为了把所有需要修改的代码放进同一个模块 -> 为了后续简化数据操作的复杂度

  • 步骤:1、创建相应的访问函数 2、修改读取和更新记录的调用方

  • 案例:搬移的时候需要注意创建和调用时机,避免访问时还未创建

image-20220818211011001

搬移语句到函数(213)

代码库的健康发展依赖着一些黄金守则,其中一条就是 -> 消除重复。

  • 时机:调用某个函数时,总有相同的代码需要执行,就可以考虑将此段代码搬移进函数里头 -> 日后对这段代码的修改只需修改一处地方,对所有调用者都生效 -> 如果将来不同调用者有不同的行为,再搬移出来也十分简单

搬移语句到调用者(217)

  • 时机:随着系统能力演进,函数边界可能会偏移 - 以往很多地方共用的行为,如今某些调用点表现出不同行为
  • 做法:1、提炼函数 - 将希望保留的函数功能提取到一个临时函数 -> 2、内联函数,移除函数 -> 3、将临时函数重命名为原函数名字
  • 案例:

image-20220818212946498

以函数调用取代内联代码(222)

库函数的合理使用

let appliesToMass = false;
for (const s of states) {
    if (s === "MA") appliesToMass = true;
}
// ---- 改为
const appliesToMass = states.includes("MA");

移动语句(223)

让存在关联的代码一起出现,一般是在提炼函数之前使用

  • 推荐将函数的声明语句靠近使用语句,让声明点和使用点相互靠近

拆分循环(227)

一个循环只做一件事 -> 意义不在于拆分循环本身,而在于为进一步优化提供良好的起点

  • 时机:在代码中,存在一些为了只循环一次,在一个循环里面做了很多事,每当修改时,需要理解循环里面的每件事

  • 步骤:1、复制一遍循环代码 –> 2、识别移除重复代码,使每个循环只做一件事 –> 3、先进行重构,再进行性能优化(斟酌)

以管道取代循环(231)

集合管道:使用一组运算来描述集合的迭代过程,其中每种运算的入参和返回值都是一个集合

  • 案例 1:累积场景 -> 善于利用 reduce 本身自带的循环
function totalSalary() {
    let totalSalary = 0;
    for(const p of people) {
        totalSalary += p.salary;
    }
    return totalSalary
}
// ----> 改为
function totalSalary() {
    return people.reduce((total, p) => total + p.salary, 0);
}
  • 案例 2:for + if 一般可以用 filter + map 解决
const name = [];
for(const i of input){
    if(i.job === programmer) {
        name.push(i.name);
    }
}
// ----> 改为
const name = input
    .filter(i => i.job === "programmer”)
    .map(it => it.name);
  • 案例 3:使用 slice + filter + map 解决

image-20220818213823078

移除死代码

对于一些无用代码,之前业界的处理态度都是注释,但是完全可以通过版本控制找到对应代码,放心删除

第9章 重新组织数据

拆分变量(240)

每个变量只承担一个责任

  • 时机:如果变量承担多个责任,它就应该被替换/分解为多个变量

字段改名(244)

  • 步骤:1、(针对一些非常复杂的数据结构)封装记录 -> 2、改名时注意 取值函数、设值函数、构造函数、内部数据结构
  • 案例:
const org = {name: "Alice", city: "HangZhou"};

// ---> 改为:将 name 属性改为 title
// 第一步:封装记录
class Org {
  constructor(data) {
    this._name = data.name;
    this._city = data.city;
  }
  
  get name() { return this._name };
  set name(aString) { this._name = aString };
  get city() { return this._city };
  set city(aCity) { this._city = aCity };
}

const org = new Org({name: "Alice", city: "HangZhou"});

// 第二步:逐步改动几个相关函数
class Org {
  constructor(data) {
    this._title = data.title || data.name; // 这是很好的兼容步骤,后续测试完毕就可以删除 或逻辑 了
    this._city = data.city;
  }
  
  get title() { return this._title };
  set title(aString) { this._title = aString };
  get city() { return this._city };
  set city(aCity) { this._city = aCity };
}

const org = new Org({title: "Alice", city: "HangZhou"});

以查询取代派生变量(248)

可变数据是软件最大的错误源头之一,尽可能把可变数据的作用域限制在最小范围,避免源头数据改变后,派生变量没变导致的问题

  • 步骤:1、找到所有对变量做更新的点 -> 2、新建一个函数,专门用于计算当前变量的值 -> 3、测试、替换、删除死代码

  • 案例:

class plan {
  get pord() { return this._prod;}
  ajust(anAjust) {
    this._ajust.push(anAjust);
    this._prod += anAjust.amount;
  }
}
// --> 中间步骤:拎出一个单一函数计算 _prod 的值
class plan {
  get pord() { 
    assert(this._prod === this.calculate); // 通过断言保证准确性
    return this.calculate;
  }
  get calculate() {
    return this._ajust.reduce((sum, a) => sum + a.amount, 0);
  }
}
// --> 改为
class plan {
  get pord() { 
		return this._ajust.reduce((sum, a) => sum + a.amount, 0);
  }
  ajust() {
    this._ajust.push(anAjust);
  }
}

将引用对象改为值对象(252)

将重构目标,都修改为不可变对象(值对象) -> 是不是真正的值对象,要看是否基于值 判断相等性(js中这个概念很弱)

  • 背景说明:引用对象的更新方式:保留原对象,更新其内部属性;值对象的更新方式:替换整个对象,新换上的对象有想要的属性

  • 场景:值对象在分布式系统、并发系统中尤为有用,因为值对象是不可变的数据结构,不必操心维护内存链接
  • 步骤:1、把对象改为值对象,对它的字段运用移除设值函数:把这两个字段的初始值加到构造函数中,并迫使构造函数调用设值函数 -> 2、逐一查看设值函数的调用者,并将其改为重新赋值整个对象 -> 3、此时修改的对象已经是不可变的类, 将其变成真正的值对象 - 提供基于值判断相等性的函数
  • image-20220818214305879
  • 案例:在类初始化的时候使用设值函数

image-20220818214404059

将值对象改为引用对象(256)

如果共享的数据需要更新,需要改为引用对象

  • 步骤:1、需要为相关对象创建一个仓库 -> 2、确保构造函数有办法找到关联对象的正确实例 -> 3、修改宿主对象的构造函数,令其从仓库中获取关联对象
  • 核心:有一个仓库可以管理引用对象,如果出现过就返回之前创建的,如果没有出现过就创建新的
  • 案例:将数据的访问和修改都指向同一个数据
let _data;
export function init() {
  _data = {};
  _data.customers = new Map();
}
// 注册顾客函数
export function register(id) {
  // 判断顾客id是否已存在
  if(!_data.customers.has(id)) {
    // 不存在则创建
    _data.customers.set(id, new Customer(id));
  }
  return findCustomer(id);
}
export function findCustomer(id) {
  // 存在则返回之前的实例
  return _data.customers.get(id);
}

第10章 简化条件逻辑

分解条件表达式(260) - 提炼函数的一个应用场景

条件逻辑会使代码很难阅读,需要理解两部分:检查条件分支的代码、实现功能的代码 -> 提炼函数,让条件分支清晰,再利用函数名称明确功能

  • 技巧:条件判断比较复杂的时候,就抽离一个独立函数专门用于计算判断条件 -> 简单的分支逻辑可以被三元运算符替代
  • 案例:具体函数就不放了,放一下提炼后的结果,含义很明确
const charge = summer() ? summerCharge() : regularCharge();

合并条件表达式(263)

将罗列的条件明确成“多个并列条件需要检查”的含义 -> 这个重构从描述“做什么”的语句,换成了“为什么这样做”的代码

  • 时机:遇到一串条件检查 ,它们的检查条件不同,但是最终行为却一致 -> 使用 逻辑或、逻辑与 合并条件表达式

  • 案例

if (anEmployee.seniority < 2) return 0; 
if (anEmployee.monthsDisabled > 12) return 0; 
if (anEmployee.isPartTime) return 0;
// --> 改为
if ((anEmployee.seniority < 2) || (anEmployee.monthsDisabled > 12) || (anEmployee.isPartTime)) return 0;
// --> 可以进一步改为:独立条件表达式为一个判断函数
if (isNotEligableForDisability()) return 0;
function isNotEligableForDisability() { return ((anEmployee.seniority < 2) || (anEmployee.monthsDisabled > 12) || (anEmployee.isPartTime)); }

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

给某一条分支以特别的重视 -> 明确这不是本函数的核心逻辑所关心的

  • 时机:一个条件分支是正常行为,另一个分支是异常情况的条件表达式 -> 特殊情况应该单独检查该条件,这样的单独检查常常被称为卫语句
  • 技巧:常常以将条件表达式反转,从而实现以卫语句取代嵌套条件表达式
  • 案例

image-20220906220508480

以多态取代条件表达式(272)

复杂的条件逻辑(常见于 switch 函数) -> 类和多态,但是注意不要滥用,只针对复杂逻辑

  • 时机 : 针对 switch 语句每个分支逻辑都创建一个类,用多态来承载各个类型特有的行为;或者遇到有一个基础逻辑,在其上又有一些变体
  • 方法:把基础逻辑放到超类,各种变体逻辑单独在子类实现
  • 案例:基础类 Bird ,在其之上再根据 switch 添加一些变体

image-20220818215216985

引入特例/引入null对象(289)

一种常见的重复代码是在处理某个特殊值,可以将处理逻辑收拢到一处

  • 步骤:创建一个特例元素,表达对这种特例的共用行为的处理
  • 案例:特例对象应该是始终不变的
// 新建一个特例类
class UnknownCustomer { 
  get isUnknown() { return true }
  get name() { return 'occupant' }
  get plan() { return registry.plans.basic }
  
  set plan(arg) { /* 保留设值函数,但其中什么都不做 */ }
}

// 给原来的类添加一个 isUnknown 的属性
class Customer { get isUnknown() {return false;} }

// 修改创建 customer 的地方
class Site {
  get customer() { 
    return (this._customer === "unknown") ? new UnknownCustomer() : this._customer; 
  }
}

// 调用形式
if(aCustomer.isUnknown) xxxxxx;
const customerName = aCustomer.name; // 由于特殊类已经将 name 返回,所以不需要判断逻辑了
const plan = aCustomer.plan;
aCustomer.plan = newPlan;

第11 章 重构 API

将查询函数和修改函数分离(306)

保证调用者不会调用到有副作用(调用时会修改无关的数据)的代码

  • 原则:任何有返回值的函数,都不应该有看得到的副作用(命名与查询分离)

函数参数化/令函数携带参数(310)

如果两个函数逻辑非常相似,只有一些字面量的值不同,可以将 其合并成一个函数,以参数形式传入不同值

  • 案例:
const amount = bottomBand(usage) * 0.03 + middleBand(usage) * 0.05 + topBand(usage) * 0.07; 

function bottomBand(usage) { return Math.min(usage, 100); }
function middleBand(usage) { return usage > 100 ? Math.min(usage, 200) - 100 : 0; }
function topBand(usage) { return usage > 200 ? usage - 200 : 0; }

// --> 从中间范围的 middleBand 入手
function withinBand(usage, bottom, top) { return usage > bottom ? Math.min(usage, top) - bottom : 0; }
const amount = withinBand(usage, 0, 100) * 0.03 
						 + withinBand(usage, 100, 200) * 0.05
						 + withinBand(usage, 200, Infinity) * 0.07;

移除标记参数/以明确函数取代参数(314)

标记参数是指:被调用者用来指示函数应该执行哪一部分的逻辑(影响了内部的控制流) -> 会增加后续使用者的理解成本:难以理解一个函数该如何调用,可以调用执行哪部分代码 -> 标记参数隐藏了函数调用中存在的差异性

  • 时机:遇到不能清晰得传达含义的标记参数就可以重构,布尔型的标记尤其糟糕
  • 目的:用明确的一个函数来完成一项单独的任务
  • 案例:遇到非常复杂的内嵌逻辑,可以尝试用包装函数来明确功能
// --> 改为 :两个包装函数
function rushDeliveryDate (anOrder) {return deliveryDate(anOrder, true);}
function regularDeliveryDate(anOrder) {return deliveryDate(anOrder, false);}

保持对象完整(319)

用于将过长的参数列表变得简洁一点

  • 时机:如果是把一个记录里面的多个值拆开传入下一个函数 -> 可以改为传递整个记录,而且传递整个记录可以更好地应对变化:如果将来要从记录中导出更多数据,则不需要再修改参数列表

以查询取代参数/以参数取代查询(324/327)

去除重复参数(如果传入的参数,函数自己也能轻易计算获得,这也是一种重复) -> 原则:把责任移交给函数本身,简化调用方 /

反向重构时,一般是改变代码的依赖关系的情况 -> 原则:将处理引用关系的责任转交给调用方

移除设值函数(331)

如果不希望字段在对象创建之后还可以被改变,则不要提供设置函数(set) -> 这样就只能在构建的时候赋值

以工厂函数取代构造函数(334)

工厂函数的内部实现可以调用构造函数或者换成别的方式实现,并实现使用清晰的函数名称明确功能,也不需要使用特殊关键词(new)

  • 本质:使用函数包裹构造函数,事该函数变成工厂函数

  • 案例:

class Employee {
  constructor (name, typeCode) { 
    this._name = name; 
    this._typeCode = typeCode; 
  }
  get name() {return this._name;} 
  get type() { return Employee.legalTypeCodes[this._typeCode]; }
  static get legalTypeCodes() { return {"E": "Engineer", "M": "Manager", "S": "Salesman"}; }
}
// 使用方
candidate = new Employee(document.name, document.empType);
const leadEngineer = new Employee(document.leadEngineer, 'E');

// --> 改为 工厂函数
function createEmployee(name, typeCode) { 
  return new Employee(name, typeCode); 
}
candidate = createEmployee(document.name, document.empType);
const leadEngineer = createEmployee(document.leadEngineer, 'E');

以命令取代构造函数/以函数取代命令(337/344)

命令对象可以提供方法来设置命令的参数值,从而支持更丰富的生命周期管理(命令对象一般比较复杂,包含了多个函数,彼此之间通过字段共享状态) -> 如果函数不复杂,则不适用

第12章 处理继承关系

函数上移/字段上移(350/353)

如果有一些逻辑/特性在子类中重复出现,则适合将函数上移到超类中

构造函数本体上移(355)

构造函数比较特殊,如果子类存在重复,在超类中定义/使用构造函数 -> 需要在子类中调用超类的构造函数 super();

函数下移/字段下移(359/361)

只在某些子类中关心的逻辑/字段 ,可以直接下放到子类

以子类取代类型码(362)

有点像 272 的以多态取代条件表达式

  • 案例:和 272 案例很像

image-20220818215352219

移除子类(369)

如果子类的用处太少,就不值得存在了 -> 替换为超类中的一个字段

以委托取代子类(381)

继承的缺点:只能用于处理一个方向上的变化 -> 例如人可以根据「年龄段」划分成“年轻人”和“老人”,但是如果我还想根据「性别」划分,就需要另一套继承体系了

流行的原则:对象组合(委托)优于继承

以委托取代超类(399)

如果超类的一些函数,对子类并不适用,那就说明不应该通过继承来获得超类的功能 -> 可以使用将超类的部分职能委托给另一个对象

合理的继承关系的重要特征:子类的所有实例都是超类的实例,通过超类的接口来使用子类的实例应该完全不出问题