代码重构

为什么要重构

  1. 重构改进软件的设计

设计欠佳的程序往往需要更多的代码,重构一个重要方向就是消除重复代码

软件变坏的途径: 一个有架构的软件 > 修改代码 > 没有理解架构设计 > 代码没有结构 > 修改代码 > 难以读懂原有设计 > 一个腐烂的架构软件

软件变好的途径: 一个腐烂的架构软件 > 修改代码 > 改进架构设计 > 更具有结构 > 修改代码 > 简单易懂更易扩展 > 一个好的架构软件

  1. 重构使软件更容易理解

编程的核心: 准确说出我想要干什么,除了告诉计算机,还有其他的读者

原来一个程序员要花一周时间来修改某段代码,在重构后更容易理解,现在只用花一小时就能搞定,这个就是时间成本,人力成本,软件成本,公司成本的体现

  1. 重构帮助找到bug

我不是一个特别好的程序员,我只是一个有着一些特别好的习惯的还不错的程序员

特别好的程序员可以盯着一大段代码可以找出bug, 我不行,但是重构了后,代码有了结构,脉络,bug会自动跑出来

  1. 重构提高编程速度
    我花在重构上的时间,难道不是在降低开发速度吗?

但是,经常会听到这样的故事: 一开始进展的很快,但如今想要添加一个新功能需要的时间越来越长,需要花很多时间想着怎么把新功能塞进现有的代码库(最好的当然不是塞进,是放进), 不断的有bug, 修复起来也越来越慢,不断的给补丁打补丁,逐渐变成了一个考古工作者


功能增加和需要时间的关系

何时重构

三次法则: 第一次去做某件事尽管去做,第二次做类似的事会有点反感,但是无论如何也要去做,第三次再做类似的事,你就该重构了。

  1. 预备性重构: 让增加新功能更容易
    增加新功能时,对老代码的微调,会使工作容易很多

例子: 增加一个功能时,发现有一个函数跟我功能很类似,但是里面几个字段或者值不一样,如果不重构,你就会把代码复制过来,修改几个值,这就导致重复代码,将来修改代码就要改两次,如果重构下老的函数,增加一个参数,这样就是预备性重构

  1. 帮助理解的重构: 使代码更易读懂

要把脑子里的理解转移到代码本身,这份知识才保存的更久,同事也能看到

给一两个变量改名,让他们更清晰的表达意图
一个长函数拆开几个小函数,更易理解
已经理解了代码意图,但是逻辑过于迂回复杂,精简下更好

  1. 有计划的重构和见机行事的重构
    上面两个都是见机行事的重构,但是当功能增加到一定的时候,简单的重构会有瓶颈,会发现一开始考虑不周的架构设计,那么现在就需要有计划的重构

  2. 长期重构
    但是很多重构会花费几个星期,几个月的时间,还有一大堆混乱的依赖关系,很多人参与,不可能停下来完全重构,那么可以每个人都达成共识,每天往想改进的方向推动一点点,但是保持基本的功能不变,比如要换掉一个库,可以引入新的抽象,兼容两个库的接口,等调用方慢慢切换过来,这样换掉原来的库就简单多了

  3. 代码复审的时候重构(code review)
    很多时候自己看不出,或者经验不足,重构后仍然不够好,那么就需要有专门的code review, 来帮助我们更好的重构代码

何时不该重构

  1. 看见一堆凌乱的代码,但是我不需要修改的时候,如果丑陋的代码被隐藏在一个API下,就可以容忍它的丑陋,等理解工作原理后,再重构
  2. 重写比重构还容易的,就别重构了

怎么重构

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 {...}

你可能感兴趣的:(代码重构)