以查询取代参数 / 以参数取代查询
以查询取代参数(Replace Parameter with Query)去掉一个函数自己就能算出来的参数;以参数取代查询(Replace Query with Parameter)反过来,把函数内部对外部状态的读取改成由调用方传进来。 它们解决的痛是:前者让调用方替函数做了本该函数自己做的事;后者让函数悄悄依赖一个全局变量,结果随时可能变,测试也没法写。
两件事发生在同一个星期。小严在订单模块里看到 discounted_price(base_price, discount_level),而 discount_level 是调用方用订单数量算出来的,订单自己明明知道数量。隔壁的小钱在写空调控制程序,供暖计划的 target_temperature 直接读了全局的 thermostat 对象,他想给它写个单元测试,结果每个用例都得先改全局变量、测完再改回去。
先看订单。
"""订单价格:重构前。"""
class Order: def __init__(self, quantity: int, item_price: float) -> None: self.quantity = quantity self.item_price = item_price
@property def final_price(self) -> float: base_price = self.quantity * self.item_price discount_level = 2 if self.quantity > 100 else 1 return self.discounted_price(base_price, discount_level)
def discounted_price(self, base_price: float, discount_level: int) -> float: if discount_level == 2: return base_price * 0.9 return base_price * 0.95
if __name__ == "__main__": print(f"{Order(50, 10).final_price:.2f}") print(f"{Order(200, 10).final_price:.2f}")
# 输出:# 475.00# 1800.00// 订单价格:重构前。export class Order { constructor( readonly quantity: number, readonly itemPrice: number, ) {}
get finalPrice(): number { const basePrice = this.quantity * this.itemPrice; const discountLevel = this.quantity > 100 ? 2 : 1; return this.discountedPrice(basePrice, discountLevel); }
discountedPrice(basePrice: number, discountLevel: number): number { if (discountLevel === 2) return basePrice * 0.9; return basePrice * 0.95; }}
console.log(new Order(50, 10).finalPrice.toFixed(2));console.log(new Order(200, 10).finalPrice.toFixed(2));
// 输出:// 475.00// 1800.00再看供暖计划。thermostat 是整个程序共用的一个全局对象,代表墙上那台温控器。
"""供暖计划:重构前。"""
class Thermostat: def __init__(self, selected: int, current: int) -> None: self.selected_temperature = selected self.current_temperature = current
thermostat = Thermostat(selected=28, current=19)
class HeatingPlan: def __init__(self, low: int, high: int) -> None: self._min = low self._max = high
@property def target_temperature(self) -> int: if thermostat.selected_temperature > self._max: return self._max if thermostat.selected_temperature < self._min: return self._min return thermostat.selected_temperature
def control(plan: HeatingPlan) -> str: target = plan.target_temperature if target > thermostat.current_temperature: return f"目标 {target} 度,制热" if target < thermostat.current_temperature: return f"目标 {target} 度,制冷" return f"目标 {target} 度,待机"
if __name__ == "__main__": plan = HeatingPlan(16, 25) print(control(plan)) thermostat.selected_temperature = 12 print(control(plan))
# 输出:# 目标 25 度,制热# 目标 16 度,制冷// 供暖计划:重构前。export const thermostat = { selectedTemperature: 28, currentTemperature: 19 };
export class HeatingPlan { constructor( private readonly min: number, private readonly max: number, ) {}
get targetTemperature(): number { if (thermostat.selectedTemperature > this.max) return this.max; if (thermostat.selectedTemperature < this.min) return this.min; return thermostat.selectedTemperature; }}
export function control(plan: HeatingPlan): string { const target = plan.targetTemperature; if (target > thermostat.currentTemperature) return `目标 ${target} 度,制热`; if (target < thermostat.currentTemperature) return `目标 ${target} 度,制冷`; return `目标 ${target} 度,待机`;}
const plan = new HeatingPlan(16, 25);console.log(control(plan));thermostat.selectedTemperature = 12;console.log(control(plan));
// 输出:// 目标 25 度,制热// 目标 16 度,制冷先说订单。如果现在要加一个需求:数量超过 500 的订单打八五折。小严得改两个地方:final_price 里算 discount_level 的那一行,和 discounted_price 里的判断。等级怎么算和等级对应多少折扣是同一条规则的两半,却被参数拆在了调用方和被调用方两头。更糟的是,如果还有第二个调用方也调 discounted_price,它也得自己算一遍等级,迟早有一处算得和别处不一样。调用方传进来的这个参数,函数自己用 self.quantity 就能算,传它只是给调用方添负担、给错误留机会。
再说供暖计划。如果现在要加一个需求:一栋楼里有好几台温控器,每个房间读自己的那台。target_temperature 写死了读全局的 thermostat,根本没法换。测试也难写:要测“设定值高于上限时取上限”,就得先改全局的 thermostat.selected_temperature,测完还要改回去,否则下一个用例拿到的是上一个用例留下的值。上面的入口代码里,同一个 plan 两次调用给出不同结果,原因藏在另一行对全局变量的赋值里,读 HeatingPlan 的代码完全看不出来。
用第 2 章的名字说,订单那边是过长参数列表:多了一个本可以省掉的参数;供暖计划那边是全局数据:函数的结果取决于一个谁都能改的全局对象。
订单:discount_level 变成订单自己的一个查询,discounted_price 只接收基础价。
"""订单价格:重构后。"""
class Order: def __init__(self, quantity: int, item_price: float) -> None: self.quantity = quantity self.item_price = item_price
@property def final_price(self) -> float: base_price = self.quantity * self.item_price return self.discounted_price(base_price)
@property def discount_level(self) -> int: return 2 if self.quantity > 100 else 1
def discounted_price(self, base_price: float) -> float: if self.discount_level == 2: return base_price * 0.9 return base_price * 0.95
if __name__ == "__main__": print(f"{Order(50, 10).final_price:.2f}") print(f"{Order(200, 10).final_price:.2f}")
# 输出:# 475.00# 1800.00// 订单价格:重构后。export class Order { constructor( readonly quantity: number, readonly itemPrice: number, ) {}
get finalPrice(): number { const basePrice = this.quantity * this.itemPrice; return this.discountedPrice(basePrice); }
get discountLevel(): number { return this.quantity > 100 ? 2 : 1; }
discountedPrice(basePrice: number): number { if (this.discountLevel === 2) return basePrice * 0.9; return basePrice * 0.95; }}
console.log(new Order(50, 10).finalPrice.toFixed(2));console.log(new Order(200, 10).finalPrice.toFixed(2));
// 输出:// 475.00// 1800.00供暖计划:target_temperature 从属性变成接收设定温度的方法,读全局变量的事交给调用方。
"""供暖计划:重构后。"""
class Thermostat: def __init__(self, selected: int, current: int) -> None: self.selected_temperature = selected self.current_temperature = current
thermostat = Thermostat(selected=28, current=19)
class HeatingPlan: def __init__(self, low: int, high: int) -> None: self._min = low self._max = high
def target_temperature(self, selected_temperature: int) -> int: if selected_temperature > self._max: return self._max if selected_temperature < self._min: return self._min return selected_temperature
def control(plan: HeatingPlan) -> str: target = plan.target_temperature(thermostat.selected_temperature) if target > thermostat.current_temperature: return f"目标 {target} 度,制热" if target < thermostat.current_temperature: return f"目标 {target} 度,制冷" return f"目标 {target} 度,待机"
if __name__ == "__main__": plan = HeatingPlan(16, 25) print(control(plan)) thermostat.selected_temperature = 12 print(control(plan))
# 输出:# 目标 25 度,制热# 目标 16 度,制冷// 供暖计划:重构后。export const thermostat = { selectedTemperature: 28, currentTemperature: 19 };
export class HeatingPlan { constructor( private readonly min: number, private readonly max: number, ) {}
targetTemperature(selectedTemperature: number): number { if (selectedTemperature > this.max) return this.max; if (selectedTemperature < this.min) return this.min; return selectedTemperature; }}
export function control(plan: HeatingPlan): string { const target = plan.targetTemperature(thermostat.selectedTemperature); if (target > thermostat.currentTemperature) return `目标 ${target} 度,制热`; if (target < thermostat.currentTemperature) return `目标 ${target} 度,制冷`; return `目标 ${target} 度,待机`;}
const plan = new HeatingPlan(16, 25);console.log(control(plan));thermostat.selectedTemperature = 12;console.log(control(plan));
// 输出:// 目标 25 度,制热// 目标 16 度,制冷两边的输出都没变。订单那边,八五折的需求现在只改 discount_level 和 discounted_price,都在 Order 里,调用方不知道也不需要知道等级怎么算。
供暖计划那边,HeatingPlan 不再提到 thermostat,它变成了一个纯函数:同样的设定温度,永远给出同样的目标温度。测试只要一行 HeatingPlan(16, 25).target_temperature(30) == 25,不用碰任何全局变量。多台温控器的需求也有了着落:每个房间把自己那台温控器的读数传进去就行。读全局变量这件事并没有消失,它被推到了 control 这个更靠近程序边缘的地方。把对外部状态的依赖往外推、让核心逻辑保持纯粹,这是以参数取代查询最常见的目的。
怎么一步步做
Section titled “怎么一步步做”以查询取代参数,每一步之后跑测试:
- 如果参数的值是在调用方算出来的,先用第 4 章的提炼函数(Extract Function)把计算提成一个查询:
discount_level属性。(IDE 可自动完成) - 在函数体里把对参数的引用换成对查询的调用:
discount_level == 2改成self.discount_level == 2。 - 用第 6 章的改变函数声明去掉这个参数,调用方同时改掉。(IDE 可自动完成)
以参数取代查询,每一步之后跑测试:
- 先用提炼变量把函数里对外部状态的读取集中成一个局部变量:
selected = thermostat.selected_temperature,函数体其余部分只用selected。(IDE 可自动完成) - 把函数体中除这行读取之外的部分提成一个新函数,
selected成为它的参数。 - 把原函数内联到每个调用方,读取那一行跟着搬到了调用方。(IDE 可自动完成)
- 把新函数改回原来的名字
target_temperature。
定义与适用场景
Section titled “定义与适用场景”以查询取代参数:如果函数能从别的参数或自身状态推导出某个参数的值,就去掉这个参数,让函数自己查询。以参数取代查询:如果函数内部引用了全局变量或别的可变外部状态,就把这个引用改成参数,由调用方传入。
两者方向相反,取舍看一个问题:这份责任该由谁承担。参数越少,调用方越省事,但函数依赖的东西越多;参数越多,函数越纯粹、越好测,调用方要准备的东西也越多。值是函数所在对象自己就有的、和调用方无关的,像订单的折扣等级,就让函数自己查。值来自全局、来自外部环境,或者你希望函数对同样输入永远给出同样输出,就让调用方传。
一条可靠的经验是:查询自己对象上的字段没问题,查询全局状态要警惕。另一个信号是引用透明(referential transparency):如果一个函数对同样的参数可能返回不同结果,又没有好的理由,多半该用以参数取代查询把那个不确定的来源变成参数。读当前时间、读随机数、读环境变量的函数都是候选。
- 参数的值函数自己能算出来,就去掉它,让函数查询;这样规则只在一处,调用方不会算错。
- 函数读了全局或外部可变状态,就把读到的值改成参数;函数变纯,测试不用摆弄全局变量。
- 两个方向的取舍是:这份责任该放在调用方还是函数里。查自己的字段可以,查全局状态要警惕。
- 以参数取代查询并不消灭对外部状态的读取,而是把它推到程序边缘,让核心逻辑可以单独测试。