美文网首页
代码重构

代码重构

作者: Jason_Zeng | 来源:发表于2020-01-07 19:06 被阅读0次

    为什么要重构

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

    相关文章

      网友评论

          本文标题:代码重构

          本文链接:https://www.haomeiwen.com/subject/iyphactx.html