过长的方法就像一个人讲了很多内容,但我仍然不知道他在讲什么,只能自己去找重点去归纳一下。过长方法会导致我们需要花大量的时间来理解方法的逻辑,并且我们的记忆力有限,很快又会忘记。在表达里面有一个很重要的方法叫金字塔原理,本质是对内容进行分类分层表述。有了分类才能便于我们快速理解逻辑,从而进行修改。代码也是一样,不仅为了让机器执行,还要人能读得懂,因此分类很重要,在代码里面最常见的的分类方法就是提炼方法。
提炼方法是 <重构> 第一个重构手段,我觉得也是最重要的。虽然看上去实现很简单,但我看了这么多代码,能把这个技巧在项目上用好的少之又少。从而导致上百行的代码比比皆是。
为什么是这样?
我觉得有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件事情
- 根据人数和剧目类型计算费用
- 根据人数和剧目类型计算积分
- 生成格式化账单
归纳是为了更好的理解这个程序,为了归纳出这个方法做了什么事情,我们需要把每一行代码都搞懂了。既然我们都归纳好了,为什么不展示在代码上面,提取成3个方法呢。当你在读一段代码的时候,把他归纳成不同步骤的时候,其实就应该提取方法了。
需求文档帮助分类
每个人的归纳方法都不一样,有时候我们会纠结怎么分比较好,分3个方法还是4个方法,哪些方法的抽象层次是一致的。除了凭借经验之外,我们也可以从需求文档去进行分析。其实上面的归纳也是基于需求文档来的。好的需求文档为了让你明白也会适当抽象,所以很适合直接映射到代码里面。如果实在不知道怎么分,也不用纠结,按照金字塔原理每一个层级不要超过7个步骤就好了。
注释
写注释也是方便大家理解,其实从这个点来看注释和提起方法的作用是一样的。
但注释会有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个特点,
- 逻辑多,多个分支逻辑自然多,想基于所有分支来归纳出条件语句的逻辑,是要花点时间的。
- 容易拓展,只要加一个类型就需要改逻辑,分支逻辑可能会越来越长,导致下次需要花更多时间理解。
果分支逻辑比较简单也不一定要这么以多态取代条件表达式 ,但提取方法是最起码要做的。提取方法包括整个条件语句和不同条件里面的逻辑。
循环条件适合抽取方法
并不是说把整个循环条件抽出来,为了编写方便和性能考虑,我们常常喜欢在一次循环里面做很多事情,包括上面的例子。一次循环里面包括了计算费用和计算积分。这样就是把两个步骤的实现逻辑混一块的。计算费用的实现包括了 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的方法会越来越多,到后面想在这个类找个方法都很难。
阿里的分层架构为了解决 service 过大的问题增加了一层通用处理层 (Manager层)。但这个通用处理层并不能解决问题,因为很多service的提取出来的方法并非是通用的。例如上面的统计积分,只是一个步骤,说不上通用。导致无法放到 Mannager层里面。真正通用的逻辑是少数。因此看来,4层架构也解决不了这个问题。
我认为真正的复用不能依靠 Manager 层,否则就重回了面向过程编程的老路,享受不到面向对象带来的好处。但我发现,一开始就设计出一个合理的对象是非常难的,因为一个合理的对象需要经过设计才能得到,而设计的依据则是需求分析。但我们面对需求一开始的思考过程就是面向过程的,因为需求本身就是面向过程的,我们需要经过面向过程的思考之后才能走向面向对象。要设计好对象我觉得可以从面向过程入手,再初步抽出对象。
从<重构>书中我们也可以看到是如何从一步步的提取方法,慢慢变成类的。
而面向过程则不用太在意复用性了,先把可读性和可拓展性提高了先。因此我更推崇使用领域服务替代通用处理层。领域服务是面向过程的,是按领域划分的,领域就是分类方法,从大的讲可以是业务模块划分,从小的讲可以是某个功能的划分。当一个功能的逻辑非常复杂,要提取很多方法时,可以单独把这个功能设计成一个类。
不用担心类会变多,现在更多的问题是类太少了。(服务却拆分太多了)
性能问题
其实经过上面的方法提取,你可能会说,这性能太差,下不了手。确实有部分方法提取是会降低性能的。例如前面提到的 查询替代临时变量,循环语句提取方法。都会让部分逻辑重复执行。虽然会降低性能,但是否真的会影响系统性能。这要看性能瓶颈在哪里。一开始就只关心性能,放弃代码的可维护性,是因小失大。
总结
提取方法是重构的基础,要做好有4个点
- 把实现(how)提取成意图(what),学会归纳总结,隐藏实现细节。
- 一个方法只做一件事,不同的意图涉及到变量和实现不要交叉。
- 提供良好的分层,让提取的方法能有合适的作用域,不过分强调复用性。
- 在性能优化前代码可读性大于性能。