为什么要重构
- 重构改进软件的设计
设计欠佳的程序往往需要更多的代码,重构一个重要方向就是消除重复代码
软件变坏的途径: 一个有架构的软件 > 修改代码 > 没有理解架构设计 > 代码没有结构 > 修改代码 > 难以读懂原有设计 > 一个腐烂的架构软件
软件变好的途径: 一个腐烂的架构软件 > 修改代码 > 改进架构设计 > 更具有结构 > 修改代码 > 简单易懂更易扩展 > 一个好的架构软件
- 重构使软件更容易理解
编程的核心: 准确说出我想要干什么,除了告诉计算机,还有其他的读者
原来一个程序员要花一周时间来修改某段代码,在重构后更容易理解,现在只用花一小时就能搞定,这个就是时间成本,人力成本,软件成本,公司成本的体现
- 重构帮助找到bug
我不是一个特别好的程序员,我只是一个有着一些特别好的习惯的还不错的程序员
特别好的程序员可以盯着一大段代码可以找出bug, 我不行,但是重构了后,代码有了结构,脉络,bug会自动跑出来
- 重构提高编程速度
我花在重构上的时间,难道不是在降低开发速度吗?
但是,经常会听到这样的故事: 一开始进展的很快,但如今想要添加一个新功能需要的时间越来越长,需要花很多时间想着怎么把新功能塞进现有的代码库(最好的当然不是塞进,是放进), 不断的有bug, 修复起来也越来越慢,不断的给补丁打补丁,逐渐变成了一个考古工作者
功能增加和需要时间的关系
何时重构
三次法则: 第一次去做某件事尽管去做,第二次做类似的事会有点反感,但是无论如何也要去做,第三次再做类似的事,你就该重构了。
- 预备性重构: 让增加新功能更容易
增加新功能时,对老代码的微调,会使工作容易很多
例子: 增加一个功能时,发现有一个函数跟我功能很类似,但是里面几个字段或者值不一样,如果不重构,你就会把代码复制过来,修改几个值,这就导致重复代码,将来修改代码就要改两次,如果重构下老的函数,增加一个参数,这样就是预备性重构
- 帮助理解的重构: 使代码更易读懂
要把脑子里的理解转移到代码本身,这份知识才保存的更久,同事也能看到
给一两个变量改名,让他们更清晰的表达意图
一个长函数拆开几个小函数,更易理解
已经理解了代码意图,但是逻辑过于迂回复杂,精简下更好
-
有计划的重构和见机行事的重构
上面两个都是见机行事的重构,但是当功能增加到一定的时候,简单的重构会有瓶颈,会发现一开始考虑不周的架构设计,那么现在就需要有计划的重构 -
长期重构
但是很多重构会花费几个星期,几个月的时间,还有一大堆混乱的依赖关系,很多人参与,不可能停下来完全重构,那么可以每个人都达成共识,每天往想改进的方向推动一点点,但是保持基本的功能不变,比如要换掉一个库,可以引入新的抽象,兼容两个库的接口,等调用方慢慢切换过来,这样换掉原来的库就简单多了 -
代码复审的时候重构(code review)
很多时候自己看不出,或者经验不足,重构后仍然不够好,那么就需要有专门的code review, 来帮助我们更好的重构代码
何时不该重构
- 看见一堆凌乱的代码,但是我不需要修改的时候,如果丑陋的代码被隐藏在一个API下,就可以容忍它的丑陋,等理解工作原理后,再重构
- 重写比重构还容易的,就别重构了
怎么重构
1. 命名规范
好的命名是整洁代码的核心,使用范围越广的越要注意命名
来看一句神秘的代码,用一个变量表示高度,单位m
var height_rice = 4; // 高度为4米的变量, rice写成米的英文
改变函数声明
好办法: 先写一句注释描述这个函数的作用,再把这句注释变成函数名字
function calc(height, width) {
return height * width;
}
function calcArea(height, width) {
return height * width;
}
变量改名
var a = height * width;
var area = height * width;
2. 重复代码
如果在一个地方以上看到相同的代码结构,就要设法将他们合二为一, 这个时候需要提炼函数来提供统一的使用方式:
提炼函数
什么时候把代码放进独立的函数: 将意图与实现分开
function printOwing(invoice) {
printBanner();
let outstanding = calculateOutstanding();
//print details
console.log(`name: ${invoice.customer}`);
console.log(`amount: ${outstanding}`);
}
可以看到上面是想要打印日志
的意图,至于怎么打印则是实现,所以提取函数如下,至于命名,则是秉承次函数是 "做什么" 来命名:
function printOwing(invoice) {
printBanner();
let outstanding = calculateOutstanding();
printDetails(outstanding);
function printDetails(outstanding) {
console.log(`name: ${invoice.customer}`);
console.log(`amount: ${outstanding}`);
}
}
如果代码是相似而不是完全相同,那么使用移动语句
来让相关的代码,结构在一起,这是提炼函数的前提,别看这个很简单, 很多的重构都是从这里开始
移动语句
下面是一段计算商品订单经费的代码,完全没有分类,很难理解业务流程
const pricingPlan = retrievePricingPlan();
const order = retreiveOrder();
const baseCharge = pricingPlan.base;
let charge;
const chargePerUnit = pricingPlan.unit;
const units = order.units;
let discount;
charge = baseCharge + units * chargePerUnit;
let discountableUnits = Math.max(units - pricingPlan.discountThreshold, 0);
discount = discountableUnits * pricingPlan.discountFactor;
if (order.isRepeat) discount += 20;
charge = charge - discount;
chargeOrder(charge);
采用移动语句之后,把相同的功能移动到一起分类,流程清晰,之后才能提取函数来进一步重构代码
// 报价计划
const pricingPlan = retrievePricingPlan();
const baseCharge = pricingPlan.base;
const chargePerUnit = pricingPlan.unit;
// 订单数量
const order = retreiveOrder();
const units = order.units;
// 折扣
let discount;
let discountableUnits = Math.max(units - pricingPlan.discountThreshold, 0);
discount = discountableUnits * pricingPlan.discountFactor;
// 具体经费
let charge;
if (order.isRepeat) discount += 20;
charge = baseCharge + units * chargePerUnit;
charge = charge - discount;
chargeOrder(charge);
函数上移
如果重复代码位于继承的子类中的时候,可以把相同的代码提到父类,避免子类之间互相调用
class Employee {...}
class Salesman extends Employee {
get name() {...}
}
class Engineer extends Employee {
get name() {...}
}
可以看到上诉子类都有相同的name()方法,可以把方法上移到父类中
class Employee {
get name() {...}
}
class Salesman extends Employee {...}
class Engineer extends Employee {...}
3. 过长的函数
老程序员的经验: 活的最长,最好的程序,其中的函数都比较短,函数越长,越难理解,小函数易于理解的关键还是在于良好的命名,好的命名就能让人了解函数的作用,可以参考我的一个原则: 每当感觉需要以注释来说明点什么的时候,我们就需要把说明的东西写进一个独立的函数里,并以其用途(而非实现手法)命名, 一定要注意函数 "做什么" 和 “怎么做”之间的语义理解,掌握了这点,就掌握了函数用法的精髓。
在把长函数分解成小函数过程中,常常会遇到函数内有大量的参数和临时变量,如果你只是提取函数,就会把许多参数传递给被提炼的函数,从可读性上面来说没有任何提升
以查询取代临时变量
const basePrice = this._quantity * this._itemPrice;
if (basePrice > 1000)
return basePrice * 0.95;
else
return basePrice * 0.98;
上面生成了临时变量basePrice
, 完全可以放到类属性里面,这样在提取函数的时候,就少了一个临时变量,不用当成参数传递了
class Price {
get basePrice() {this._quantity * this._itemPrice;}
}
...
if (this.basePrice > 1000)
return this.basePrice * 0.95;
else
return this.basePrice * 0.98;
引入参数对象
对于过长的参数列表,引入参数对象是个好办法,这样可以简化为一个参数结构
function amountInvoiced(startDate, endDate) {...}
function amountReceived(startDate, endDate) {...}
function amountOverdue(startDate, endDate) {...}
上面代码每个函数都在传递三个时间参数,就可以提炼一个时间的数据类来统一管理
class DateRange {
string startDate;
string middleDate
string endDate;
}
function amountInvoiced(dateRange) {...}
function amountReceived(dateRange) {...}
function amountOverdue(dateRange) {...}
划重点
:这项重构方法具有更深层的改变 *新的数据结构 -> 重组函数来使用新结构 -> 捕捉围绕新数据结构的公用函数 -> 构建新的类来组合新的数据结构和函数 -> 形成新的抽象概念 -> 改变整个软件架构图景, 所以说,新结构的一小步才会有软件架构的一大步
函数组合成类
当分成独立的函数之后,这不是代码的终点,如果发现一组函数形影不离的操作着同一块数据(做为参数传给函数),此时就是时候组建一个类了
例如上面引入参数对象后的函数和数据结构组合如下
class Amount { // 金额类
DateRange dateRange; // 时间范围字段
Invoiced() {...}; // 发票金额方法
received() {...}; // 收支金额方法
overdue() {...}; // 欠款金额方法
}
使用类的好处:当修改上面Amount类的dateRange这类核心数据时,依赖于此的数据,比如发票,收支,欠款等会与核心数据保持一致
4. 简化条件逻辑
分解条件表达式
复杂的条件逻辑是最常导致复杂度上升的地方之一,所以适当的分解他们可以更清楚的表明每个分支的作用
if (!aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd))
charge = quantity * plan.summerRate;
else
charge = quantity * plan.regularRate + plan.regularServiceCharge;
上面代码很难直观看出此条件是什么作用,把这些条件和实现提取为函数后就非常清晰了,夏天时候的支出和其他季节的支出不同
if (summer())
charge = summerCharge();
else
charge = regularCharge();
合并条件表达式
有时候发现一串条件检查:检查条件各不相同,最终行为却一致,这种情况可以使用‘逻辑或‘ 或‘逻辑与’合并为一个条件表达式
if (anEmployee.seniority < 2) return 0;
if (anEmployee.monthsDisabled > 12) return 0;
if (anEmployee.isPartTime) return 0;
上面都是返回0的情况,就可以提炼为一个函数统一返回
if (isNotEligibleForDisability()) return 0;
function isNotEligibleForDisability() {
return ((anEmployee.seniority < 2)
|| (anEmployee.monthsDisabled > 12)
|| (anEmployee.isPartTime));
}
简化嵌套条件表达式
条件表达式通常有两种风格,第一种:两个条件分支都属于正常行为,这个时候可以用 if...else...的条件表达式;第二种: 只有一个条件分支是正常行为,另一个则是异常行为,发生情况很罕见,此时应该单独检查该条件,改条件为真时立即返回
function getPayAmount() {
let result;
if (isDead)
result = deadAmount();
else {
if (isSeparated)
result = separatedAmount();
else {
if (isRetired)
result = retiredAmount();
else
result = normalPayAmount();
}
}
return result;
}
上面代码是一段根据不同员工状态发工资的逻辑,死了有抚恤金,辞退的有补偿金,退休了有退休金,平常就正常发工资,很显然,正常发工资是大概率事件,其他的都可以简化为独立判断语句然后返回,这样代码清晰
function getPayAmount() {
if (isDead) return deadAmount();
if (isSeparated) return separatedAmount();
if (isRetired) return retiredAmount();
return normalPayAmount();
}
以多态取代条件表达式
复杂的条件逻辑是编程中最难理解的东西,多态是面向对象编程的关键特征之一,大部分简单的条件判断用if...else..或者switch...case...无关紧要,但是如果有四五个或更多的复杂条件逻辑,多态是改善这种情况的有力工具
function plumage(bird) {
switch (bird.type) {
case 'EuropeanSwallow':
return "average";
case 'AfricanSwallow':
return (bird.numberOfCoconuts > 2) ? "tired" : "average";
case 'NorwegianBlueParrot':
return (bird.voltage > 100) ? "scorched" : "beautiful";
default:
return "unknown";
}
把具体的实现封装到类里方法,你可能会问,这不是还有switch和case吗?注意上面只是一个获取羽毛的方法里用了swtich和case,如果以后我们又要根据鸟的种类获取鸟的大小,寿命等情况呢,又要在很多方法里用这些讨厌的swtich..case, 但是把他们用多态抽象为类后,可以像下面使用类似构造工厂的方式来创建不同品种的鸟,他们的接口都相同,后面只管调用了,往深处说,可以继续用抽象工厂,或者控制反转(IOC)等特性(VanGo平台底层实现的精髓 ' . ' )彻底干掉这些swtich...case来实现动态创建对象,当然这些深入的东西这里就不讨论了。
function createBird(bird) {
switch (bird.type) {
case 'EuropeanSwallow':
return new EuropeanSwallow(bird);
case 'AfricanSwallow':
return new AfricanSwallow(bird);
case 'NorweigianBlueParrot':
return new NorwegianBlueParrot(bird);
default:
return new Bird(bird);
}
}
class EuropeanSwallow {
get plumage() {
return "average";
}
class AfricanSwallow {
get plumage() {
return (this.numberOfCoconuts > 2) ? "tired" : "average";
}
class NorwegianBlueParrot {
get plumage() {
return (this.voltage > 100) ? "scorched" : "beautiful";
}
5. 可变数据
对数据的经常修改是导致出乎意料的结果和难以发现的bug, 我在一处更新了数据,没有意识到另一处用期望着完全不同的数据,我们要约束数据更新
封装变量
一个好的习惯: 对于所有可变数据,只要它的作用域超出了单个函数,我就会将其封装起来,只允许通过函数访问,数据的作用域越大,封装就越重要
let defaultOwner = {firstName: "Martin", lastName: "Fowler"};
每次获取或者设置值的时候通过函数,可以监控或者统一修改内部来改变真正的值,避免了很多bug
let defaultOwnerData = {firstName: "Martin", lastName: "Fowler"};
export function defaultOwner() {return defaultOwnerData;}
export function setDefaultOwner(arg) {defaultOwnerData = arg;}
拆分变量
变量有各种不同的用途,要避免临时变量被多次赋值,如果变量承担多个责任,就应该被分解为多个有独立意义的变量
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);
将查询函数和修改函数分离
如果函数只提供一个值,没有任何看得到的副作用,证明是个好函数,一个好的规则是: 任何有返回值的函数,都不应该有看的见的副作用,如果遇到一个 “既有返回值又有副作用” 的函数,证明这里会有“看不见的”可变数据,就要试着将他们分离
function getTotalOutstandingAndSendBill() {
const result = customer.invoices.reduce((total, each) => each.amount + total, 0);
sendBill();
return result;
}
可以看到在上面get函数里,sendBill()和这个函数没有任何关系,这就是副作用,此时就要将它分离出来,这是要保证函数的纯净,所谓的“纯函数”,只有职责分离,才能干大事
function totalOutstanding() {
return customer.invoices.reduce((total, each) => each.amount + total, 0);
}
function sendBill() {
emailGateway.send(formatBill(customer));
}
6. 继承关系
子类父类功能隔离
比如一些子类公用函数,字段就要函数上移
或者字段上移
到父类来统一管理,相反如果是子类特有的函数,字段,就要用函数下移
或者字段下移
到子类分别实现,这里就不写具体例子了,希望读者可以自行领会
提炼超类
一般的面向对象的思想是:继承必须是真实的分类对象模型的继承
,比如鸭子继承动物;但是更实用的方法是: 发现一些共同的元素
,就把他们抽取到一起,于是有了继承关系.
class Department {
get totalAnnualCost() {...}
get name() {...}
get headCount() {...}
}
class Employee {
get annualCost() {...}
get name() {...}
get id() {...}
}
上面部门和职员都有名字和年成本这两个属性,那么我们把他们提到一个超类中,名叫组织,也有名字,和年成本,这样子类部门和职员可以通过覆盖实现自己的年成本计算,同时他们公司名字可能相同的,就复用父类代码
class Party {
get name() {...}
get annualCost() {...}
}
class Department extends Party {
get annualCost() {...}
get headCount() {...}
}
class Employee extends Party {
get annualCost() {...}
get id() {...}
}
以委托取代子类
继承是根据分类
用于把属于某一类
公共的数据和行为放到超类中,每个子类根据需求覆写部分属性,这是继承的本质,但是由于这种本质体系,体现了他的缺点:继承只能处理一个分类
方向上面的变化,但是子类上导致行为不同的原因有很多种
, 比如人我根据'年龄'来继承分类,分为‘年轻人’和'老人',但是对于'富人'和'穷人'这个分类来看,其实相同年龄的'年轻人'行为是很不同的,你们说是吧
class Order {
get daysToShip() {
return this._warehouse.daysToShip;
}
}
class PriorityOrder extends Order {
get daysToShip() {
return this._priorityPlan.daysToShip;
}
}
把继承的写法,提到超类的委托里面,这样就是组合,所谓“对象组合优于类继承”也是这个道理,一个原则是,先用继承解决代码复用问题,发现分类不对了,再改为委托
class Order {
get daysToShip() {
return (this._priorityDelegate)
? this._priorityDelegate.daysToShip
: this._warehouse.daysToShip;
}
}
class PriorityOrderDelegate {
get daysToShip() {
return this._priorityPlan.daysToShip
}
}
以委托取代超类
如果超类的一些函数对于子类并不适合,就说明我们不应该通过继承来获得超类的功能,而改为委托,合理的继承关系有一个重要特征: 子类的所有实例都应该是超类的实例,通过超类的接口来使用子类的实例应该完全不出问题
class List {...}
class Stack extends List {...}
比如我们实现的栈类(Stack)原本继承了列表类(List),但是发现很多列表的方法不适合栈,那就改用委托(组合)关系来把列表当成一个属性放在子类中,然后封装需要用到列表类的方法即可
class Stack {
constructor() {
this._storage = new List();
}
}
class List {...}
网友评论