以命令取代函数 / 以函数取代命令
以命令取代函数(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 还在,签名没变,调用方毫无感觉。局部变量变成字段之后,每条规则提成一个方法,不需要参数,也不需要返回值,它们通过字段共享中间结果。扣分明细的需求现在有了落脚点:加一个 self._details 列表字段,每个规则方法扣分时顺手追加一条,最后和分数一起返回。新规则写成一个新方法,插进 execute 里合适的位置,execute 读起来就是规则的清单和顺序。
execute 开头把中间结果重置一遍,这样同一个 Scorer 被执行两次也得到同样的结果。
怎么一步步做
Section titled “怎么一步步做”以命令取代函数,每一步之后跑测试:
- 新建一个空类
Scorer,名字通常取自函数名。 - 用第 21 章的搬移函数把
score搬进这个类,改名为execute。原来的score保留,改为创建Scorer并调用execute,这样调用方不受影响。 - 把参数改成构造函数的参数,存成字段;
execute不再接收参数。 - 把局部变量逐个改成字段,一次改一个,每改一个跑一次测试。
- 现在可以放心地用提炼函数拆
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.00ChargeCalculator 没有可撤销的操作,不会被排队,也没有子类,它的每个调用方都是“创建、取一次 charge、扔掉”。两行公式被一个类、一个构造函数、三个字段包着,读的人要先看完这些才看到公式。变回函数后,调用处也从 ChargeCalculator(...).charge 变成了 charge(...)。
定义与适用场景
Section titled “定义与适用场景”以命令取代函数:把一个函数封装成一个对象,参数和局部变量变成字段,函数体变成执行方法。以函数取代命令:命令对象没有用到对象带来的能力时,把它变回普通函数。
命令对象比函数多了几种能力:可以把一次调用的状态保存下来,从而支持撤销;可以先创建、稍后再执行,从而支持排队、重试、记录日志;可以有子类,用继承定制部分步骤;内部可以随意拆成多个方法,靠字段共享状态。本章的例子用的是最后一种,它也是最常见的理由:函数长、局部变量多、提炼函数拆不动。
这些能力都有代价:多一个类、多一层间接。判断标准是用没用上:用上了任何一种,就值得是命令;一种都没用上,就变回函数。Python 和 TS 有一等函数,排队和回调直接传函数就行,值得写命令类的主要是撤销和“拆不动的长函数”两种场合。
相关的设计模式
Section titled “相关的设计模式”本章的结果就是命令(Command)模式:把一个请求封装成对象。对应到例子里,Scorer 是具体命令,execute 是模式里的执行方法,构造函数接收的投保人、体检报告、地区名单是命令执行所需的接收者数据,score 函数扮演创建并触发命令的调用者。本章的 Scorer 只用了命令模式“封装成对象”这一半,没有用撤销和排队;命令模式真正的用武之地是下面这种编辑器的撤销栈。命令模式见《设计模式》书第 19 章。
真实系统里的例子
Section titled “真实系统里的例子”编辑器的撤销
Section titled “编辑器的撤销”文本编辑器、绘图软件的撤销功能几乎都用命令对象实现。函数式的写法 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);
// 输出:// 你好,世界// 【草稿】你好,世界// 你好,世界撤销栈里存的是命令对象,每个命令知道怎么撤销自己;要加“重做”,再存一个栈就行。
什么时候别用
Section titled “什么时候别用”函数不长、局部变量不多时,别为了“面向对象”把它包成命令。先试提炼函数,再试第 16 章以查询取代临时变量,把一部分局部变量变成查询,变量少了,提炼函数往往就切得进去了。只有这些都拆不动时,才轮到命令对象。
命令类也别做成长期持有状态的对象。Scorer 的字段只是一次执行内部的草稿纸,保持“创建、执行、丢弃”的用法,或者像本章这样在 execute 开头重置中间状态。
- 函数太长、局部变量缠在一起、提炼函数拆不动时,把它变成命令对象:参数和局部变量变字段,再按规则拆方法。
- 保留原函数作为入口,内部创建命令并执行,调用方不受影响。
- 命令对象的额外能力是撤销、排队、继承和方便拆分;一种都没用上,就用以函数取代命令变回函数。
- 本章的结果就是命令模式,撤销栈是它最典型的用途。