过长方法重构策略 - 提取方法

256 阅读6分钟

过长的方法就像一个人讲了很多内容,但我仍然不知道他在讲什么,只能自己去找重点去归纳一下。过长方法会导致我们需要花大量的时间来理解方法的逻辑,并且我们的记忆力有限,很快又会忘记。在表达里面有一个很重要的方法叫金字塔原理,本质是对内容进行分类分层表述。有了分类才能便于我们快速理解逻辑,从而进行修改。代码也是一样,不仅为了让机器执行,还要人能读得懂,因此分类很重要,在代码里面最常见的的分类方法就是提炼方法。

提炼方法是 <重构> 第一个重构手段,我觉得也是最重要的。虽然看上去实现很简单,但我看了这么多代码,能把这个技巧在项目上用好的少之又少。从而导致上百行的代码比比皆是。

为什么是这样?

我觉得有3个原因

  1. 提取方法费时费力
  2. 对代码不敏感,不知道什么时候抽
  3. 性能问题,缺乏良好分层导致有心无力

提取方法费时费力

提取方法是要花时间和精力的。人习惯面向过程,想到那里写到那里,一气呵成,不喜欢归纳总结。作为优秀的程序员不能只是自己爽,不顾维护者(可能是以后的自己)的感受。

对代码不敏感,不知道什么时候抽

下面举个实际的例子,我们分析一下哪些地方应该抽取方法。

需求文档: 设想有一个戏剧演出团,演员们经常要去各种场合表演戏剧。通常客户(customer)会指定几出剧目,而剧团则根据观众(audience)人数及剧目类型来向客户收费。该团目前出演两种戏剧:悲剧(tragedy)和喜剧(comedy)。给客户发出账单时,剧团还会根据到场观众的数量给出“观众量积分”(volume credit)优惠,下次客户再请剧团表演时可以使用积分获得折扣——你可以把它看作一种提升客户忠诚度的方式。

function formatBill (invoice, plays) {
  let totalAmount = 0;
  let volumeCredits = 0;
  let result = `Statement for ${invoice.customer}\n`;
  const format = new Intl.NumberFormat("en-US",
                        { style: "currency", currency: "USD",
                          minimumFractionDigits: 2 }).format;
  for (let perf of invoice.performances) { 
    const play = plays[perf.playID];
    let thisAmount = 0;

    switch (play.type) {
    case "tragedy":
      thisAmount = 40000;
      if (perf.audience > 30) {
        thisAmount += 1000 * (perf.audience - 30);
      }
      break;
    case "comedy":
      thisAmount = 30000;
      if (perf.audience > 20) {
        thisAmount += 10000 + 500 * (perf.audience - 20);
      }
      thisAmount += 300 * perf.audience;
      break;
    default:
        throw new Error(`unknown type: ${play.type}`);
    }

    // add volume credits
    volumeCredits += Math.max(perf.audience - 30, 0);
    // add extra credit for every ten comedy attendees
    if ("comedy" === play.type) volumeCredits += Math.floor(perf.audience / 5);
    
    
    // print line for this order
    result += ` ${play.name}: ${format(thisAmount/100)} (${perf.audience} seats)\n`;
    totalAmount += thisAmount;
  }
  result += `Amount owed is ${format(totalAmount/100)}\n`;
  result += `You earned ${volumeCredits} credits\n`;
  return result;
}

归纳总结法

方法看上上去其实不长,但你分析代码的逻辑,很自然会归纳出这个方法做了3件事情

  1. 根据人数和剧目类型计算费用
  2. 根据人数和剧目类型计算积分
  3. 生成格式化账单

归纳是为了更好的理解这个程序,为了归纳出这个方法做了什么事情,我们需要把每一行代码都搞懂了。既然我们都归纳好了,为什么不展示在代码上面,提取成3个方法呢。当你在读一段代码的时候,把他归纳成不同步骤的时候,其实就应该提取方法了。

需求文档帮助分类

每个人的归纳方法都不一样,有时候我们会纠结怎么分比较好,分3个方法还是4个方法,哪些方法的抽象层次是一致的。除了凭借经验之外,我们也可以从需求文档去进行分析。其实上面的归纳也是基于需求文档来的。好的需求文档为了让你明白也会适当抽象,所以很适合直接映射到代码里面。如果实在不知道怎么分,也不用纠结,按照金字塔原理每一个层级不要超过7个步骤就好了。

image.png

注释

写注释也是方便大家理解,其实从这个点来看注释和提起方法的作用是一样的。

但注释会有3个问题

  1. 边界不清晰,不知道一行注释的解释范围是什么,包含多少行代码
  2. 不方便折叠,还得四处找注释看,注释的层次还不一样
  3. 难以复用

所以这种情况下把注释变成方法会更好一些。我觉得比较好的表达是用注释表达 why,用方法命名表达 what ,用代码实现表达how。

用流式操作(管道)替代临时变量

例如统计积分。上面的实现逻辑是

// 定义一个初始积分
let volumeCredits = 0;
// 遍历所有剧场
 for (let perf of invoice.performances) { 
     // add volume credits
     // 如果观众人数 > 30 则 volumeCredits 加上超出 30 的部分
    volumeCredits += Math.max(perf.audience - 30, 0);
    // add extra credit for every ten comedy attendees
    // 如果是戏剧, 则 volumeCredits 加上人数/5
    if ("comedy" === play.type) volumeCredits += Math.floor(perf.audience / 5);
 }

volumeCredits 是有2个作用,一个作为临时变量参与过程运算,另一个是作为总积分。因为需求并不会说要先定义一个 volumeCredits,然后遍历所有剧场的积分并加到 volumeCredits 变量里面。需求只会这么描述 每个剧场的积分规则是基础分(人数超出30那部分的值)加上剧场类型的加分项,然后把所有剧场积分全部加起来就是总积分。中间根本没有提过这个临时变量,这里的 volumeCredits 只是为了方便汇总而已。

更接近需求的表达形式我觉得应该是


invoice.performances.all().sum({ //所有剧场的积分加起来
    // 基础分
    var baseCredits = Math.max(perf.audience - 30, 0);
    // 不同剧场类型的加分项, 如果是戏剧, 则 volumeCredits 加上人数/10
    var playTypeRredits = if ("comedy" === play.type) volumeCredits += Math.floor(perf.audience / 10);
    return baseCredits + playTypeRredits;
})

上面的代码很自然的表达了需求,让人一看就能理解。因为他没用到临时变量,把最本质的需求呈现了出来。所以在<重构>中更建议使用管道取代循环。虽然不是所有的操作都可以用流式操作替代,但起码也要尽可能减少临时变量的作用域。

上面其实也有临时变量,就是 baseCredits 和 playTypeRredits。临时变量除了方便计算,还有一个作用是为了表达方法的作用。而这里的临时变量就是这个目的,这点和注释非常像,只是临时变量的表达范围只有一行代码。这种情况提取方法我觉得都可以。提取方法就是以查询代替临时变量会让代码更简洁一些,但需要下钻才能看到实现逻辑。


invoice.performances.all() /所有剧场的积分加起来
.sum({ 
    // 每个剧场积分包括基本分和加分项
    return baseCredits(it) + playTypeRredits(it)
})

条件语句适合抽取方法

有一个重构技巧叫 以多态取代条件表达式,这是因为条件表达式有2个特点,

  1. 逻辑多,多个分支逻辑自然多,想基于所有分支来归纳出条件语句的逻辑,是要花点时间的。
  2. 容易拓展,只要加一个类型就需要改逻辑,分支逻辑可能会越来越长,导致下次需要花更多时间理解。

果分支逻辑比较简单也不一定要这么以多态取代条件表达式 ,但提取方法是最起码要做的。提取方法包括整个条件语句和不同条件里面的逻辑。

循环条件适合抽取方法

并不是说把整个循环条件抽出来,为了编写方便和性能考虑,我们常常喜欢在一次循环里面做很多事情,包括上面的例子。一次循环里面包括了计算费用和计算积分。这样就是把两个步骤的实现逻辑混一块的。计算费用的实现包括了 for 和临时变量 totalAmount,计算积分也包括了 for,和临时变量 volumeCredits。为了方便把两个计算逻辑混在一起,大大降低了可读性。如果能分开两个循环,则意图会更加明确。管道取代循环 其实也在强迫程序员一步一步来计算值,而不是并行计算。

根据不同的关注点提取方法

业务和技术实现分离

我们写代码免不了要用 mysql,redis ,http 等等。从业务的角度来看,这些都是实现逻辑,因为业务不关心你用的是什么技术。

fun logout(token){
    
    var userKey = RedisKey.UserKey.format(token)
    var userInfo = redisCacheService.getByKey(userKey)
    
    recordLogout(userInfo);
    
    redisCacheService.deleteKey(userKey)
}

像上面的代码。redis 的访问就是一种实现逻辑,应该根据实现的意图提取方法。

fun logout(token){
    
    var userInfo = getUserInfoByToken(token)
    
    recordLogout(userInfo);
    
    clearUserSession(token)
}

计算和 UI 分离

因为计算和 UI 的修改频率和实现有非常大的差异,为了方便理解和后续拓展,把两者分开会比较合适。不要在 UI 操作中有计算的实现逻辑。

大家可以基于这些方法来对最开始的例子进行方法抽取。我初步抽取的如下


function calculateComedyAmount(perf) {
    let thisAmount = 30000;
    if (perf.audience > 20) {
        thisAmount += 10000 + 500 * (perf.audience - 20);
    }
    thisAmount += 300 * perf.audience;
    return thisAmount
}

function calculateTragedyAmount(perf) {

    let thisAmount = 40000;
    if (perf.audience > 30) {
        thisAmount += 1000 * (perf.audience - 30);
    }
    return thisAmount
}

function calculateAmount(perf, plays) {
    switch (getPlay(plays, perf).type) {
        case "tragedy":
            return calculateTragedyAmount(perf);
        case "comedy":
            return calculateComedyAmount(perf);
        default:
            throw new Error(`unknown type: ${getPlay(plays, perf).type}`);
    }
}

function getPlay(plays, perf) {
    return plays[perf.playID];
}

function calculateVolumeCredits(perf, plays) {
    // add volume credits
    let volumeCredits = Math.max(perf.audience - 30, 0);
    // add extra credit for every ten comedy attendees
    if ("comedy" === getPlay(plays, perf).type) volumeCredits += Math.floor(perf.audience / 5);
    return volumeCredits;
}

function formatMoney(money) {
    return new Intl.NumberFormat("en-US",
        {
            style: "currency", currency: "USD",
            minimumFractionDigits: 2
        }).format(money);
}

function calculateTotalAmount(invoice, plays) {
    return sum(invoice.performances, (perf) => {
            calculateAmount(perf, plays)
        }
    );
}


function calculateVolumeCredits(invoice, plays) {
    return sum(invoice.performances, (perf) => {
        return calculateVolumeCredits(perf, plays)
    });
}

function sum(array, func) {
    return array.reduce((acc, curr) => acc + func(curr), 0);

}
function join(array, func ) {
    return array.map(func).reduce((accumulator, currentValue) => {
        return accumulator + currentValue;
    }, '\r\n');
}


function formatBill(invoice, plays) {

    let result = `Statement for ${invoice.customer}\n`;

    result += join(invoice.performances,(perf) => {
       return ` ${getPlay(plays, perf).name}: ${formatMoney(calculateAmount(perf, plays) / 100)} (${perf.audience} seats)\n`;
    })

    result += `Amount owed is ${formatMoney(calculateTotalAmount(invoice, plays) / 100)}\n`;

    result += `You earned ${(calculateVolumeCredits(invoice, plays))} credits\n`;
    return result;
}


function getBill() {
    let invoice = {
        customer: "BigCo",
        performances: [
            {
                playID: "hamlet",
                audience: 55
            },
            {
                playID: "as-like",
                audience: 35
            },
            {
                playID: "othello",
                audience: 40
            }
        ]
    };
    let plays = {
        "hamlet": {"name": "Hamlet", "type": "tragedy"},
        "as-like": {"name": "As You Like It", "type": "comedy"},
        "othello": {"name": "Othello", "type": "tragedy"}
    };
}

getBill()

为什么难以提取

分层问题

传统三层架构提取出业务层作为单独一层,却没有对业务层再进行细分。导致我们把业务逻辑都堆到了service,service越来越庞大。不仅包含很多方法,而且每个方法都非常的大。提取私有方法又会导致service的方法会越来越多,到后面想在这个类找个方法都很难。

image.png

阿里的分层架构为了解决 service 过大的问题增加了一层通用处理层 (Manager层)。但这个通用处理层并不能解决问题,因为很多service的提取出来的方法并非是通用的。例如上面的统计积分,只是一个步骤,说不上通用。导致无法放到 Mannager层里面。真正通用的逻辑是少数。因此看来,4层架构也解决不了这个问题。

image.png

我认为真正的复用不能依靠 Manager 层,否则就重回了面向过程编程的老路,享受不到面向对象带来的好处。但我发现,一开始就设计出一个合理的对象是非常难的,因为一个合理的对象需要经过设计才能得到,而设计的依据则是需求分析。但我们面对需求一开始的思考过程就是面向过程的,因为需求本身就是面向过程的,我们需要经过面向过程的思考之后才能走向面向对象。要设计好对象我觉得可以从面向过程入手,再初步抽出对象。

从<重构>书中我们也可以看到是如何从一步步的提取方法,慢慢变成类的。

而面向过程则不用太在意复用性了,先把可读性和可拓展性提高了先。因此我更推崇使用领域服务替代通用处理层。领域服务是面向过程的,是按领域划分的,领域就是分类方法,从大的讲可以是业务模块划分,从小的讲可以是某个功能的划分。当一个功能的逻辑非常复杂,要提取很多方法时,可以单独把这个功能设计成一个类。

不用担心类会变多,现在更多的问题是类太少了。(服务却拆分太多了)

性能问题

其实经过上面的方法提取,你可能会说,这性能太差,下不了手。确实有部分方法提取是会降低性能的。例如前面提到的 查询替代临时变量,循环语句提取方法。都会让部分逻辑重复执行。虽然会降低性能,但是否真的会影响系统性能。这要看性能瓶颈在哪里。一开始就只关心性能,放弃代码的可维护性,是因小失大。

总结

提取方法是重构的基础,要做好有4个点

  1. 把实现(how)提取成意图(what),学会归纳总结,隐藏实现细节。
  2. 一个方法只做一件事,不同的意图涉及到变量和实现不要交叉。
  3. 提供良好的分层,让提取的方法能有合适的作用域,不过分强调复用性。
  4. 在性能优化前代码可读性大于性能。