重构的方法论
重构经历那么多年,也有很明确的方法论,可以让我们以成本最小的方式,对有问题的代码,进行重构(书中给出的方法,其实和设计模式一样,是一种经验的理论化,产生了具体的方法论,每一种重构方式并不是只有书中提到的方式,但是书中提到的路径一定是相对可执行度比较高,也不容易出错的)
接下来按照书中页码顺序,记录一下学习重构案例的一些心得(笔记让我可以快速搜索关键词找到内容)
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)
对结伴使用的数据警醒,将它们组成结构
- 技巧:观察成对出现的值如何被使用,将一些有用的行为搬进类中
- 案例:将有用行为搬进类中
函数组合成类(144)
利用 class ,在 constructor 中获取一些反复被使用的数据,通过 get() 实现变量、计算结果都通过属性访问
- 和下方「函数组合成变换」的区别点:如果在代码中对源数据做更新,那么使用类好得多 -> 使用下面的变换的话,派生数据存储在新记录,源数据一旦被修改,派生数据和源数据就不一致了
- 案例:例如将访问全部收敛到一个类中,以属性访问
函数组合成变换/数据变换函数(149)
用来限制需要对变量进行修改的代码量 -> 将所有计算派生数据的逻辑收拢到一处 -> 可以在固定地方找到/更新这些逻辑
- 时机:在遇到有些函数的功能是转化或者充实数据结构的场景
- 技巧:创建变换函数,入参为需要变换的 record,输出则是增强的 record,包含所有共用的派生数据
- 步骤:1、创建变换函数,入参为需要变换的记录,出参直接返回入参 2、移入逻辑,把结果作为字段添加到输出记录中
拆分阶段(154)
将计算逻辑拆分成几个阶段
-
时机:一段代码处理两件事 –> 后续修改的时候无法最小颗粒度得修改
-
技巧: 创建一个中转数据结构 –> 大致划分好记个阶段后,可以 通过明确后一个阶段用到的前一个阶段的哪些值,来确定前一个阶段的返回值
-
案例 :设置中转数据结构(编译器是一个很好的例子)
第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 后,其他业务逻辑(几个比较函数)也可以放在当前类中
私以为。上面重构完的代码不够精彩,最精彩的是调用非常非常优雅
以查询取代临时变量(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、搬移字段
- 案例:把电话号码相关行为提炼到一个新的独立的类中
内联类(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、修改读取和更新记录的调用方
-
案例:搬移的时候需要注意创建和调用时机,避免访问时还未创建
搬移语句到函数(213)
代码库的健康发展依赖着一些黄金守则,其中一条就是 -> 消除重复。
- 时机:调用某个函数时,总有相同的代码需要执行,就可以考虑将此段代码搬移进函数里头 -> 日后对这段代码的修改只需修改一处地方,对所有调用者都生效 -> 如果将来不同调用者有不同的行为,再搬移出来也十分简单
搬移语句到调用者(217)
- 时机:随着系统能力演进,函数边界可能会偏移 - 以往很多地方共用的行为,如今某些调用点表现出不同行为
- 做法:1、提炼函数 - 将希望保留的函数功能提取到一个临时函数 -> 2、内联函数,移除函数 -> 3、将临时函数重命名为原函数名字
- 案例:
以函数调用取代内联代码(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 解决
移除死代码
对于一些无用代码,之前业界的处理态度都是注释,但是完全可以通过版本控制找到对应代码,放心删除
第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、此时修改的对象已经是不可变的类, 将其变成真正的值对象 - 提供基于值判断相等性的函数
- 案例:在类初始化的时候使用设值函数
将值对象改为引用对象(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)
给某一条分支以特别的重视 -> 明确这不是本函数的核心逻辑所关心的
- 时机:一个条件分支是正常行为,另一个分支是异常情况的条件表达式 -> 特殊情况应该单独检查该条件,这样的单独检查常常被称为卫语句
- 技巧:常常以将条件表达式反转,从而实现以卫语句取代嵌套条件表达式
- 案例
以多态取代条件表达式(272)
复杂的条件逻辑(常见于 switch 函数) -> 类和多态,但是注意不要滥用,只针对复杂逻辑
- 时机 : 针对 switch 语句每个分支逻辑都创建一个类,用多态来承载各个类型特有的行为;或者遇到有一个基础逻辑,在其上又有一些变体
- 方法:把基础逻辑放到超类,各种变体逻辑单独在子类实现
- 案例:基础类 Bird ,在其之上再根据 switch 添加一些变体
引入特例/引入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 案例很像
移除子类(369)
如果子类的用处太少,就不值得存在了 -> 替换为超类中的一个字段
以委托取代子类(381)
继承的缺点:只能用于处理一个方向上的变化 -> 例如人可以根据「年龄段」划分成“年轻人”和“老人”,但是如果我还想根据「性别」划分,就需要另一套继承体系了
流行的原则:对象组合(委托)优于继承
以委托取代超类(399)
如果超类的一些函数,对子类并不适用,那就说明不应该通过继承来获得超类的功能 -> 可以使用将超类的部分职能委托给另一个对象
合理的继承关系的重要特征:子类的所有实例都是超类的实例,通过超类的接口来使用子类的实例应该完全不出问题