跳转到内容

以命令取代函数 / 以函数取代命令

以命令取代函数(Replace Function with Command)把一个函数变成一个对象,函数的参数和局部变量变成这个对象的字段,函数体变成它的一个方法;以函数取代命令(Replace Command with Function)反过来,把一个只有一个方法、没有用到对象灵活性的命令对象变回普通函数。 它们解决的痛是:一个函数长到局部变量互相缠绕,想拆小却因为要传一大串参数而拆不动;或者反过来,一个简单的计算被包成类,读的人要先看懂构造函数和字段才看到那一行公式。

小陶在保险公司写核保评分。评分函数接收投保人、体检报告和一张“低资质地区”名单,从零分开始,按吸烟、地区、两者叠加等规则扣分。下面是简化后的样子,真实版本里这样的规则有几十条,局部变量十来个,前面算出的 health_level、certification_grade 被后面的规则反复读写。

"""核保评分:重构前。"""
def score(candidate: dict, medical_exam: dict, low_cert_provinces: set[str]) -> int:
result = 0
health_level = 0
high_medical_risk = False
if medical_exam["is_smoker"]:
health_level += 10
high_medical_risk = True
certification_grade = "regular"
if candidate["province"] in low_cert_provinces:
certification_grade = "low"
result -= 5
result -= max(health_level - 5, 0)
if high_medical_risk and certification_grade == "low":
result -= 10
return result
if __name__ == "__main__":
print(score({"province": "甲省"}, {"is_smoker": True}, {"甲省"}))
print(score({"province": "乙省"}, {"is_smoker": False}, {"甲省"}))
# 输出:
# -20
# 0
// 核保评分:重构前。
export function score(
candidate: { province: string },
medicalExam: { isSmoker: boolean },
lowCertProvinces: Set<string>,
): number {
let result = 0;
let healthLevel = 0;
let highMedicalRisk = false;
if (medicalExam.isSmoker) {
healthLevel += 10;
highMedicalRisk = true;
}
let certificationGrade = "regular";
if (lowCertProvinces.has(candidate.province)) {
certificationGrade = "low";
result -= 5;
}
result -= Math.max(healthLevel - 5, 0);
if (highMedicalRisk && certificationGrade === "low") result -= 10;
return result;
}
console.log(score({ province: "甲省" }, { isSmoker: true }, new Set(["甲省"])));
console.log(score({ province: "乙省" }, { isSmoker: false }, new Set(["甲省"])));
// 输出:
// -20
// 0

如果现在要加一个需求:核保员要看到扣分明细,每条规则扣了几分、为什么扣。小陶想先把每条规则提成一个小函数,再在每个小函数里记明细。可是“吸烟”这条规则会同时改 health_level 和 high_medical_risk,“地区”这条会同时改 certification_grade 和 result。用第 4 章的提炼函数,每个小函数要接收三四个参数、返回两三个值,提出来的样子比原来还难看:health_level, high_medical_risk = score_smoking(medical_exam, health_level, high_medical_risk)。真实版本里有十来个这样的变量,每提一个函数都要算一遍哪些进、哪些出。

于是函数只能继续长下去,每加一条规则就往中间插几行。

用第 2 章的名字说,这是过长函数。它难拆的原因不是逻辑复杂,而是局部变量太多、彼此缠绕,提炼函数这把最常用的刀切不进去。

"""核保评分:重构后。"""
class Scorer:
def __init__(self, candidate: dict, medical_exam: dict, low_cert_provinces: set[str]) -> None:
self._candidate = candidate
self._medical_exam = medical_exam
self._low_cert_provinces = low_cert_provinces
def execute(self) -> int:
self._result = 0
self._health_level = 0
self._high_medical_risk = False
self._certification_grade = "regular"
self._score_smoking()
self._score_certification()
self._result -= max(self._health_level - 5, 0)
self._score_combined_risk()
return self._result
def _score_smoking(self) -> None:
if self._medical_exam["is_smoker"]:
self._health_level += 10
self._high_medical_risk = True
def _score_certification(self) -> None:
if self._candidate["province"] in self._low_cert_provinces:
self._certification_grade = "low"
self._result -= 5
def _score_combined_risk(self) -> None:
if self._high_medical_risk and self._certification_grade == "low":
self._result -= 10
def score(candidate: dict, medical_exam: dict, low_cert_provinces: set[str]) -> int:
return Scorer(candidate, medical_exam, low_cert_provinces).execute()
if __name__ == "__main__":
print(score({"province": "甲省"}, {"is_smoker": True}, {"甲省"}))
print(score({"province": "乙省"}, {"is_smoker": False}, {"甲省"}))
# 输出:
# -20
# 0
// 核保评分:重构后。
type Candidate = { province: string };
type MedicalExam = { isSmoker: boolean };
class Scorer {
private result = 0;
private healthLevel = 0;
private highMedicalRisk = false;
private certificationGrade = "regular";
constructor(
private readonly candidate: Candidate,
private readonly medicalExam: MedicalExam,
private readonly lowCertProvinces: Set<string>,
) {}
execute(): number {
this.result = 0;
this.healthLevel = 0;
this.highMedicalRisk = false;
this.certificationGrade = "regular";
this.scoreSmoking();
this.scoreCertification();
this.result -= Math.max(this.healthLevel - 5, 0);
this.scoreCombinedRisk();
return this.result;
}
private scoreSmoking(): void {
if (this.medicalExam.isSmoker) {
this.healthLevel += 10;
this.highMedicalRisk = true;
}
}
private scoreCertification(): void {
if (this.lowCertProvinces.has(this.candidate.province)) {
this.certificationGrade = "low";
this.result -= 5;
}
}
private scoreCombinedRisk(): void {
if (this.highMedicalRisk && this.certificationGrade === "low") this.result -= 10;
}
}
export function score(candidate: Candidate, medicalExam: MedicalExam, lowCertProvinces: Set<string>): number {
return new Scorer(candidate, medicalExam, lowCertProvinces).execute();
}
console.log(score({ province: "甲省" }, { isSmoker: true }, new Set(["甲省"])));
console.log(score({ province: "乙省" }, { isSmoker: false }, new Set(["甲省"])));
// 输出:
// -20
// 0

重构前 score 是一个带四个局部变量的函数;重构后三个参数和四个局部变量都成了 Scorer 的字段,每条规则一个不带参数的方法,score 只负责创建 Scorer 并调用 execute

原来的 score 还在,签名没变,调用方毫无感觉。局部变量变成字段之后,每条规则提成一个方法,不需要参数,也不需要返回值,它们通过字段共享中间结果。扣分明细的需求现在有了落脚点:加一个 self._details 列表字段,每个规则方法扣分时顺手追加一条,最后和分数一起返回。新规则写成一个新方法,插进 execute 里合适的位置,execute 读起来就是规则的清单和顺序。

execute 开头把中间结果重置一遍,这样同一个 Scorer 被执行两次也得到同样的结果。

以命令取代函数,每一步之后跑测试:

  1. 新建一个空类 Scorer,名字通常取自函数名。
  2. 用第 21 章的搬移函数把 score 搬进这个类,改名为 execute。原来的 score 保留,改为创建 Scorer 并调用 execute,这样调用方不受影响。
  3. 把参数改成构造函数的参数,存成字段;execute 不再接收参数。
  4. 把局部变量逐个改成字段,一次改一个,每改一个跑一次测试。
  5. 现在可以放心地用提炼函数拆 execute 了:选中“吸烟”那几行,提炼成 _score_smoking,它读写的都是字段,不需要参数。(IDE 可自动完成)

以函数取代命令,步骤相反:先把命令对象的构造过程和执行调用合在一个函数里;再把命令里的辅助方法逐个内联到 execute;然后把字段改成 execute 的局部变量、把构造参数改成函数参数;最后把 execute 搬出类,删掉这个类。下面是一个该反过来做的例子:

"""电费计算:重构前,一个只有一个方法的命令对象。"""
class ChargeCalculator:
def __init__(self, customer: dict, usage: int, provider: dict) -> None:
self._customer = customer
self._usage = usage
self._provider = provider
@property
def charge(self) -> float:
base_charge = self._customer["base_rate"] * self._usage
return base_charge + self._provider["connection_charge"]
if __name__ == "__main__":
print(f"{ChargeCalculator({'base_rate': 0.5}, 120, {'connection_charge': 10}).charge:.2f}")
# 输出:
# 70.00
"""电费计算:重构后,变回一个函数。"""
def charge(customer: dict, usage: int, provider: dict) -> float:
base_charge = customer["base_rate"] * usage
return base_charge + provider["connection_charge"]
if __name__ == "__main__":
print(f"{charge({'base_rate': 0.5}, 120, {'connection_charge': 10}):.2f}")
# 输出:
# 70.00
// 电费计算:重构前,一个只有一个方法的命令对象。
export class ChargeCalculator {
constructor(
private readonly customer: { baseRate: number },
private readonly usage: number,
private readonly provider: { connectionCharge: number },
) {}
get charge(): number {
const baseCharge = this.customer.baseRate * this.usage;
return baseCharge + this.provider.connectionCharge;
}
}
console.log(new ChargeCalculator({ baseRate: 0.5 }, 120, { connectionCharge: 10 }).charge.toFixed(2));
// 输出:
// 70.00
// 电费计算:重构后,变回一个函数。
export function charge(customer: { baseRate: number }, usage: number, provider: { connectionCharge: number }): number {
const baseCharge = customer.baseRate * usage;
return baseCharge + provider.connectionCharge;
}
console.log(charge({ baseRate: 0.5 }, 120, { connectionCharge: 10 }).toFixed(2));
// 输出:
// 70.00

ChargeCalculator 没有可撤销的操作,不会被排队,也没有子类,它的每个调用方都是“创建、取一次 charge、扔掉”。两行公式被一个类、一个构造函数、三个字段包着,读的人要先看完这些才看到公式。变回函数后,调用处也从 ChargeCalculator(...).charge 变成了 charge(...)。

以命令取代函数:把一个函数封装成一个对象,参数和局部变量变成字段,函数体变成执行方法。以函数取代命令:命令对象没有用到对象带来的能力时,把它变回普通函数。

命令对象比函数多了几种能力:可以把一次调用的状态保存下来,从而支持撤销;可以先创建、稍后再执行,从而支持排队、重试、记录日志;可以有子类,用继承定制部分步骤;内部可以随意拆成多个方法,靠字段共享状态。本章的例子用的是最后一种,它也是最常见的理由:函数长、局部变量多、提炼函数拆不动。

这些能力都有代价:多一个类、多一层间接。判断标准是用没用上:用上了任何一种,就值得是命令;一种都没用上,就变回函数。Python 和 TS 有一等函数,排队和回调直接传函数就行,值得写命令类的主要是撤销和“拆不动的长函数”两种场合。

本章的结果就是命令(Command)模式:把一个请求封装成对象。对应到例子里,Scorer 是具体命令,execute 是模式里的执行方法,构造函数接收的投保人、体检报告、地区名单是命令执行所需的接收者数据,score 函数扮演创建并触发命令的调用者。本章的 Scorer 只用了命令模式“封装成对象”这一半,没有用撤销和排队;命令模式真正的用武之地是下面这种编辑器的撤销栈。命令模式见《设计模式》书第 19 章。

文本编辑器、绘图软件的撤销功能几乎都用命令对象实现。函数式的写法 insert_text(doc, pos, text) 执行完就结束了,要撤销得另写一个函数、自己记住插在哪、插了多长。改成命令对象后,执行时需要的位置和文本本来就存在字段里,undo 直接用它们,历史记录就是一个命令对象的栈。下面用字符串模拟文档。

// 模拟编辑器:命令对象记住自己做了什么,所以能撤销。
interface Doc {
text: string;
}
// 函数版:执行完什么都没留下,撤销无从做起。
function insertText(doc: Doc, pos: number, text: string): void {
doc.text = doc.text.slice(0, pos) + text + doc.text.slice(pos);
}
// 命令版:位置和文本存在字段里,undo 直接用。
class InsertCommand {
constructor(
private readonly doc: Doc,
private readonly pos: number,
private readonly text: string,
) {}
execute(): void {
this.doc.text = this.doc.text.slice(0, this.pos) + this.text + this.doc.text.slice(this.pos);
}
undo(): void {
this.doc.text = this.doc.text.slice(0, this.pos) + this.doc.text.slice(this.pos + this.text.length);
}
}
const plain: Doc = { text: "你好" };
insertText(plain, 2, ",世界");
console.log(plain.text);
const doc: Doc = { text: "你好" };
const undoStack: InsertCommand[] = [];
for (const command of [new InsertCommand(doc, 2, ",世界"), new InsertCommand(doc, 0, "【草稿】")]) {
command.execute();
undoStack.push(command);
}
console.log(doc.text);
undoStack.pop()?.undo();
console.log(doc.text);
// 输出:
// 你好,世界
// 【草稿】你好,世界
// 你好,世界

撤销栈里存的是命令对象,每个命令知道怎么撤销自己;要加“重做”,再存一个栈就行。

函数不长、局部变量不多时,别为了“面向对象”把它包成命令。先试提炼函数,再试第 16 章以查询取代临时变量,把一部分局部变量变成查询,变量少了,提炼函数往往就切得进去了。只有这些都拆不动时,才轮到命令对象。

命令类也别做成长期持有状态的对象。Scorer 的字段只是一次执行内部的草稿纸,保持“创建、执行、丢弃”的用法,或者像本章这样在 execute 开头重置中间状态。

  • 函数太长、局部变量缠在一起、提炼函数拆不动时,把它变成命令对象:参数和局部变量变字段,再按规则拆方法。
  • 保留原函数作为入口,内部创建命令并执行,调用方不受影响。
  • 命令对象的额外能力是撤销、排队、继承和方便拆分;一种都没用上,就用以函数取代命令变回函数。
  • 本章的结果就是命令模式,撤销栈是它最典型的用途。