跳转到内容

以查询取代参数 / 以参数取代查询

以查询取代参数(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 自己查询;右边:target_temperature 不再读全局 thermostat,改由 control 读出设定温度再传入

两边的输出都没变。订单那边,八五折的需求现在只改 discount_level 和 discounted_price,都在 Order 里,调用方不知道也不需要知道等级怎么算。

供暖计划那边,HeatingPlan 不再提到 thermostat,它变成了一个纯函数:同样的设定温度,永远给出同样的目标温度。测试只要一行 HeatingPlan(16, 25).target_temperature(30) == 25,不用碰任何全局变量。多台温控器的需求也有了着落:每个房间把自己那台温控器的读数传进去就行。读全局变量这件事并没有消失,它被推到了 control 这个更靠近程序边缘的地方。把对外部状态的依赖往外推、让核心逻辑保持纯粹,这是以参数取代查询最常见的目的。

以查询取代参数,每一步之后跑测试:

  1. 如果参数的值是在调用方算出来的,先用第 4 章的提炼函数(Extract Function)把计算提成一个查询:discount_level 属性。(IDE 可自动完成)
  2. 在函数体里把对参数的引用换成对查询的调用:discount_level == 2 改成 self.discount_level == 2。
  3. 用第 6 章的改变函数声明去掉这个参数,调用方同时改掉。(IDE 可自动完成)

以参数取代查询,每一步之后跑测试:

  1. 先用提炼变量把函数里对外部状态的读取集中成一个局部变量:selected = thermostat.selected_temperature,函数体其余部分只用 selected。(IDE 可自动完成)
  2. 把函数体中除这行读取之外的部分提成一个新函数,selected 成为它的参数。
  3. 把原函数内联到每个调用方,读取那一行跟着搬到了调用方。(IDE 可自动完成)
  4. 把新函数改回原来的名字 target_temperature。

以查询取代参数:如果函数能从别的参数或自身状态推导出某个参数的值,就去掉这个参数,让函数自己查询。以参数取代查询:如果函数内部引用了全局变量或别的可变外部状态,就把这个引用改成参数,由调用方传入。

两者方向相反,取舍看一个问题:这份责任该由谁承担。参数越少,调用方越省事,但函数依赖的东西越多;参数越多,函数越纯粹、越好测,调用方要准备的东西也越多。值是函数所在对象自己就有的、和调用方无关的,像订单的折扣等级,就让函数自己查。值来自全局、来自外部环境,或者你希望函数对同样输入永远给出同样输出,就让调用方传。

一条可靠的经验是:查询自己对象上的字段没问题,查询全局状态要警惕。另一个信号是引用透明(referential transparency):如果一个函数对同样的参数可能返回不同结果,又没有好的理由,多半该用以参数取代查询把那个不确定的来源变成参数。读当前时间、读随机数、读环境变量的函数都是候选。

  • 参数的值函数自己能算出来,就去掉它,让函数查询;这样规则只在一处,调用方不会算错。
  • 函数读了全局或外部可变状态,就把读到的值改成参数;函数变纯,测试不用摆弄全局变量。
  • 两个方向的取舍是:这份责任该放在调用方还是函数里。查自己的字段可以,查全局状态要警惕。
  • 以参数取代查询并不消灭对外部状态的读取,而是把它推到程序边缘,让核心逻辑可以单独测试。