重构的基本思想
开展高效有序的重构, 关键的心得是:小步子可以更快前进,请保持代码永远处于可工作状态,小步修改累积起来也能大大改善系统的设计。
好代码的检验标准就是人们是否能轻而易举地修改它。 好代码应该直截了当:有人需要修改代码时,他们应能轻易找到修改点,应该能快速做出更改,而不易引入其他错误。
小程序的规模根本不值得重构。但是代码量不断上升,重构技术很快就变得重要起来。
提炼逻辑,使得代码的意图越来越明显,使得后续修改越来越容易,是重构的基本心法。
重构早期的主要动力是尝试理解代码如何工作。通常你需要先通 读代码,找到一些感觉,然后再通过重构将这些感觉从脑海里搬回到代码中。
例子背景及原始代码
设想有一个戏剧演出团,演员们经常要去各种场合表演戏剧。通常客户 (customer)会指定几出剧目,而剧团则根据观众(audience)人数及剧目类型来向客户收费。
该团目前出演两种戏剧:悲剧(tragedy)和喜剧(comedy)。给客户发出账单时,剧团还会根据到场观众的数量给出“观众量积分”(volume credit)优惠,下次客户再请剧团表演时可以使用积分获得折扣——你可以把它看作一种提升客户忠诚度的方式。
剧目数据存放在plays.json
中:
{
"hamlet": {
"name": "Hamlet",
"type": "tragedy"
},
"as-like": {
"name": "As You Like It",
"type": "comedy"
},
"othello": {
"name": "Othello",
"type": "tragedy"
}
}
开出的账单也存储在一个JSON文件里。
[
{
"customer": "BigCo",
"performances": [
{
"playID": "hamlet",
"audience": 55
},
{
"playID": "as-like",
"audience": 35
},
{
"playID": "othello",
"audience": 40
}
]
}
]
使用下述原始代码用于打印账单详情(statement)
def statement(invoice, plays):
total_amount = 0
volume_credits = 0
result = f"Statement for {invoice['customer']}\n"
def format(amount):
return "${:,.2f}".format(amount / 100)
for perf in invoice["performances"]:
play = plays[perf["playID"]]
this_amount = 0
if play["type"] == "tragedy":
this_amount = 40000
if perf["audience"] > 30:
this_amount += 1000 * (perf["audience"] - 30)
elif play["type"] == "comedy":
this_amount = 30000
if perf["audience"] > 20:
this_amount += 10000 + 500 * (perf["audience"] - 20)
this_amount += 300 * perf["audience"]
else:
raise ValueError(f"unknown type: {play['type']}")
# add volume credits
volume_credits += max(perf["audience"] - 30, 0)
# add extra credit for every ten comedy attendees
if play["type"] == "comedy":
volume_credits += perf["audience"] // 5
# print line for this order
result += f" {play['name']}: {format(this_amount)} ({perf['audience']} seats)\n"
total_amount += this_amount
result += f"Amount owed is {format(total_amount)}\n"
result += f"You earned {volume_credits} credits\n"
return result
打印账单:
import json
# 打开并读取 JSON 文件
with open("invoice.json", "r", encoding="utf-8") as file:
invoice = json.load(file)[0]
with open("play.json", "r", encoding="utf-8") as file:
plays = json.load(file)
print(statement(invoice, plays))
得到的输出为:
Statement for BigCo
Hamlet: $650.00 (55 seats)
As You Like It: $580.00 (35 seats)
Othello: $500.00 (40 seats)
Amount owed is $1,730.00
You earned 47 credits
对原始程序的评价和思索
如果你要对这个程序进行改动,目前有两个需求:
需求1: 需要将输出账单结果转换为HTML形式。此时,你会怎么去做?比较直接的方式是直接复制代码,然后一行一行对其中添加字符串进行格式修改。这通常不是一个好办法。而是应该按照如下思路进行:
“如果你要给程序添加一个特性,但发现代码因缺乏良好的结构而不易于进行更改,那就先重构那个程序,使其比较容易添加该特性,然后再添加该特性。”
复制一遍代码不难,但却给未来留下各种隐患: 一旦函数中的计算逻辑发生变化,我就得同时修改两个地方,以保证它们逻辑相同。”
需求2:除了悲剧(tragedy)和喜剧(comedy),未来需要兼容更多类型的戏剧。此时,你会怎么去做?把代码再Copy几份?随着这种需求不断复杂化,如果不进行代码结构的设计,一个简单的新特性,需要在代码中改动的地方会越来越多。
“是需求的变化使重构变得必要。如果一段代码能正常工作, 并且不会再被修改,那么完全可以不去重构它。 能改进之当然很好,但若没人需 要去理解它,它就不会真正妨碍什么。如果确实有人需要理解它的工作原理,并且觉得理解起来很费劲,那你就需要改进一下代码了。”
重构第一步:拥有可靠的测试
每当我要进行重构的时候,第一个步骤永远相同:我得确保即将修改的代码 拥有一组可靠的测试。这些测试必须有自我检验能力。
statement函数的返回值是一个字符串,我做的就是创建几张新的账单 (invoice),假设每张账单收取了几出戏剧的费用,然后使用这几张账单作为输 入调用statement函数,生成对应的对账单(statement)字符串。我会拿生成的字 符串与我已经手工检查过的字符串做比对。我会借助一个测试框架来配置好这些 测试,只要在开发环境中输入一行命令就可以把它们运行起来。运行这些测试只 需几秒钟,所以你会看到我经常运行它们。
尽管编写测试需要花费时间,但却为我节省下可观的调试时间。构筑测试体系对重构来说实在太重要了
分解statement函数为一堆嵌套函数
手法1:提炼函数+及时改名统一风格
要将我的理解转化到代码里,得先将这块代码抽取成一个独立的函数,按它所干的事情给它命名,比如叫amountFor(performance)。每次想将一块代码抽取成一个函数时,我都会遵循一个标准流程,最大程度减少犯错的可能。我把这个流程记录了下来,并将它命名为提炼函数(106),以便日后可以方便地引用。
提炼函数的流程:
- 首先检查一下,如果我将这块代码提炼到自己的一个函数里, 有哪些变量会离开原本的作用域:
在此示例中,是perf、play和this_amount 这3个变 量。前两个变量会被提炼后的函数使用,但不会被修改,那么我就可以将它们以 参数方式传递进来。我更关心那些会被修改的变量。这里只有唯一一个 ——this_amount ,因此可以将它从函数中直接返回。我还可以将其初始化放到提炼后的函数里。”
- 无论每次重构多么简单,养成重构后即运行测试的习惯非常重要。犯错误是 很容易的——至少我知道我是很容易犯错的。做完一次修改就运行测试,这样在我真的犯了错时,只需要考虑一个很小的改动范围,这使得查错与修复问题易如反掌。这就是重构过程的精髓所在:小步修改,每次修改后就运行测试。如果我 改动了太多东西,犯错时就可能陷入麻烦的调试,并为此耗费大把时间。小步修 改,以及它带来的频繁反馈,正是防止混乱的关键。
- 做完上面的修改,测试是通过的,因此下一步我要把代码提交到本地的版本控制系统。我会使用诸如git或mercurial这样的版本控制系统, 因为它们可以支持本地提交。每次成功的重构后我都会提交代码,如果待会不小心搞砸了,我便能轻松回滚到上一个可工作的状态。把代码推送(push)到远端仓库前,我会把零碎的修改压缩成一个更有意义的提交(commit)。
- 完成提炼函数(106)手法后,我会看看提炼出来的函数,看是否能进一步 提升其表达能力。一般我做的第一件事就是给一些变量改名, 使它们更简洁,比 如将this_amount 重命名为result。一些编程风格:
- 永远将函数的返回值命名为“result”,这样我一眼就 能知道它的作用。
- 使用一门动态类型语言(如JavaScript)时,跟踪变量的类型很有意义。因此,我为参数取名时都默认带上其类型名。一般我会使 用不定冠词修饰它,除非命名中另有解释其角色的相关信息。好代码应能清楚地表明它在做 什么,而变量命名是代码清晰的关键。 只要改名能够提升代码的可读性,那就应该毫不犹豫去做。
手法2:以查询取代临时变量
观察amountFor函数时,我会看看它的参数都从哪里来。aPerformance是从循 环变量中来,所以自然每次循环都会改变,但play变量是由performance变量计算 得到的,因此根本没必要将它作为参数传入,我可以在amountFor函数中重新计 算得到它。当我分解一个长函数时,我喜欢将play这样的变量移除掉,因为它们 创建了很多具有局部作用域的临时变量,这会使提炼函数更加复杂。这里我要使 用的重构手法是以查询取代临时变量(178)。
手法3:减少临时变量可以简化重构的难度(使用内联+提炼函数)
手法4:拆分循环+移动变量+提炼函数,将一个变量的累加过程分离出来,抽象为函数
移动变量:将相关代码块使用的变量放到紧靠循环的位置,更便于拆分
因此对于重构过程的性能问题,我总体的建议是:大多数情况下可以忽略它。如果重构引入了性能损耗,先完成重构,再做性能优化。
修改之后效果—原函数中出现大量嵌套函数
顶层的statement函数现在只剩7行代码,而且它处理的都是与打印详单相关的逻辑。与计算相关的逻辑从主函数中被移走,改由 一组函数来支持。每个单独的计算过程和详单的整体结构,都因此变得更易理解 了。
实现需求1—输出HTML格式—拆分计算逻辑和格式化逻辑
现在,计算代码已经被分离出来,只需要为顶部的7行代码实现一个 HTML的版本。问题是,这些分解出来的函数嵌套在打印文本详单的函数中。无论嵌套函数组织得多么良好,我总不想将它们全复制粘贴到另一个新函数中。我希望同样的计算函数可以被文本版详单和HTML版详单共用。
要实现复用有许多种方法,而我最喜欢的技术是拆分阶段(154)。这里我的目标是将逻辑分成两部分:
- 一部分计算详单所需的数据
- 另一部分将数据渲染成文本或HTML。
第一阶段会创建一个中转数据结构,再把它传递给第二阶段。
手法1:提炼函数+创建中转数据结构+内联变量+逐步替换函数内部计算逻辑
- 将原来的statement函数重新命名为renderPlainText,他已经不再适用于statement这个称呼了。statement作为顶层函数,在内部调用renderPlainText函数。
- 创建中转数据结构statement_data,逐步替换renderPlainText中需要用于计算的变量。将计算逻辑不断提取到statement函数中,renderPlainText中仅保存打印逻辑,将计算逻辑都慢慢挪移到创建中转数据的函数creat_statement_data中。
- 等到两个阶段完全分离的时候,直接拆分为两个文件。
注意:尽量不要修改传入函数的参数,保持数据的不可变性,也就是对参数进行一次拷贝。
虽然代码的行数增加了,但重构也带来了代码可读性的提高。额外的包装将混杂的逻辑分解成可辨别的部分,分 离了详单的计算逻辑与样式。这种模块化使我更容易辨别代码的不同部分,了解它们的协作关系。 虽说言以简为贵,但可演化的软件却以明确为贵。通过增强代 码的模块化,我可以轻易地添加HTML版本的代码,而无须重复计算部分的逻辑。
其实打印逻辑还可以进一步简化,但当前的代码也够用了。我经常需要在所有可做的重构与添加新特性之间寻找平衡。在当今业界,大多数人面临同样的选 择时,似乎多以延缓重构而告终。编程时,需要遵循营地法则:保证你离开时的代码库一定比来时更健康。(无需追求完美,但是需要日拱一卒)
实现需求2—按照类型增量添加—以多态实现按照类型重组计算过程
支持更多类型的戏剧,以及支持 它们各自的价格计算和观众量积分计算。对于现在的结构,我只需要在计算函数 里添加分支逻辑即可。amountFor函数清楚地体现了,戏剧类型在计算分支的选 择上起着关键的作用——但这样的分支逻辑很容易随代码堆积而腐坏,除非编程 语言提供了更基础的编程语言元素来防止代码堆积。
要为程序引入结构、显式地表达出“计算逻辑的差异是由类型代码确定”有许多途径,不过最自然的解决办法还是使用面向对象世界里的一个经典特性——类型多态。
手法1:创建基类
将更为通用的逻辑放到超类作为默认条件,出 现特殊场景时按需覆盖它,听起来十分合理。