跳转到内容

函数参数化

函数参数化(Parameterize Function)把几个逻辑相同、只差几个字面量的函数合成一个,差别的那几个值变成参数。 它解决的痛是:同一段逻辑被复制成了 ten_percent_raise、five_percent_raise 好几份,规则一改要改好几处,新来一个取值又得再复制一份。

最典型的样子是人事系统里的 ten_percent_raise(person) 和 five_percent_raise(person):两个函数体一模一样,只有 1.1 和 1.05 不同,合成 raise_salary(person, factor) 就行。实际代码里更常见的是不那么显眼的版本。小冯在给电力公司算阶梯电价:每月前 100 度每度 0.5 元,100 到 200 度之间每度 0.6 元,超过 200 度的部分每度 0.8 元。他给三个档位各写了一个函数,算出用量落在这一档里的度数。

"""阶梯电价:重构前。"""
def bottom_band(usage: int) -> int:
return min(usage, 100)
def middle_band(usage: int) -> int:
return min(usage, 200) - 100 if usage > 100 else 0
def top_band(usage: int) -> int:
return usage - 200 if usage > 200 else 0
def base_charge(usage: int) -> float:
return bottom_band(usage) * 0.5 + middle_band(usage) * 0.6 + top_band(usage) * 0.8
if __name__ == "__main__":
for usage in (50, 150, 250):
print(f"{usage} 度:{base_charge(usage):.2f} 元")
# 输出:
# 50 度:25.00 元
# 150 度:80.00 元
# 250 度:150.00 元
// 阶梯电价:重构前。
function bottomBand(usage: number): number {
return Math.min(usage, 100);
}
function middleBand(usage: number): number {
return usage > 100 ? Math.min(usage, 200) - 100 : 0;
}
function topBand(usage: number): number {
return usage > 200 ? usage - 200 : 0;
}
export function baseCharge(usage: number): number {
return bottomBand(usage) * 0.5 + middleBand(usage) * 0.6 + topBand(usage) * 0.8;
}
for (const usage of [50, 150, 250]) {
console.log(`${usage} 度:${baseCharge(usage).toFixed(2)} 元`);
}
// 输出:
// 50 度:25.00 元
// 150 度:80.00 元
// 250 度:150.00 元

如果现在要加一个需求:物价部门新设一档,超过 400 度的部分每度 1.0 元。小冯要做两件事:把 top_band 的上限从“无穷”改成 400,再复制一份写 peak_band。复制时他得想清楚 top_band 里的 usage - 200 怎么变成 min(usage, 400) - 200,而 top_band 本来的写法和 middle_band 长得不一样,看不出它们其实是同一个公式。

三个函数乍看各不相同:一个只有 min,一个有 min 也有 if,一个只有 if。要看出它们是同一件事,得在纸上把边界值代进去:bottom_band 是“下限 0、上限 100”,top_band 是“下限 200、上限无穷”。这种隐藏起来的相同最危险,档位边界改了,比如第二档从 100 改成 120,bottom_band 的 100 和 middle_band 的两个 100 都得改,漏掉一个,就有 20 度电被算了两次或者一次都没算。

用第 2 章的名字说,这是重复代码:不是字面上的复制,而是同一个公式被写成了三种样子。

"""阶梯电价:重构后。"""
def with_band(usage: int, bottom: int, top: float) -> float:
return min(usage, top) - bottom if usage > bottom else 0
def base_charge(usage: int) -> float:
return (
with_band(usage, 0, 100) * 0.5
+ with_band(usage, 100, 200) * 0.6
+ with_band(usage, 200, float("inf")) * 0.8
)
if __name__ == "__main__":
for usage in (50, 150, 250):
print(f"{usage} 度:{base_charge(usage):.2f} 元")
# 输出:
# 50 度:25.00 元
# 150 度:80.00 元
# 250 度:150.00 元
// 阶梯电价:重构后。
function withBand(usage: number, bottom: number, top: number): number {
return usage > bottom ? Math.min(usage, top) - bottom : 0;
}
export function baseCharge(usage: number): number {
return withBand(usage, 0, 100) * 0.5 + withBand(usage, 100, 200) * 0.6 + withBand(usage, 200, Infinity) * 0.8;
}
for (const usage of [50, 150, 250]) {
console.log(`${usage} 度:${baseCharge(usage).toFixed(2)} 元`);
}
// 输出:
// 50 度:25.00 元
// 150 度:80.00 元
// 250 度:150.00 元

三个函数变成一个 with_band,档位边界都摆在 base_charge 里,相邻两档的上下限挨在一起,一眼能核对 100 接 100、200 接 200。加 400 度那一档,是把最后一行的 float("inf") 改成 400、再加一行 with_band(usage, 400, float("inf")) * 1.0,不需要写新函数。改档位边界时,每个数字在同一屏里出现两次,漏改会很显眼。

再往前走一步,档位可以变成一张表 [(0, 100, 0.5), (100, 200, 0.6), (200, inf, 0.8)],base_charge 对它求和。电价表从代码变成数据,以后从配置文件读进来也行。参数化常常是“代码变数据”的第一步。

每一步之后都跑测试。

  1. 从几个相似函数里挑一个,最好是最容易加参数的那个。middle_band 同时有下限和上限,最适合当模板。
  2. 用第 6 章的改变函数声明(Change Function Declaration)给它加上参数,把字面量换成参数:middle_band(usage) 改成 with_band(usage, bottom, top),函数体里的 100 换成 bottom,200 换成 top。调用处改成 with_band(usage, 100, 200)。(改名和加参数可由 IDE 的 Change Signature 自动完成)
  3. 逐个替换其他相似函数的调用。bottom_band(usage) 换成 with_band(usage, 0, 100),top_band(usage) 换成 with_band(usage, 200, float("inf"))。每换一个跑一次测试。
  4. 如果某个调用换过去之后测试失败,说明那个函数和模板不完全一样,先调整模板,让它对所有取值都成立,再继续换。本例中 bottom_band 没有 if,但 usage > 0 时 with_band(usage, 0, 100) 和它结果相同,所以能直接换。
  5. 删掉不再被调用的旧函数。

函数参数化:把几个只在字面量上有差异的函数合并成一个函数,用参数表示这些差异。

它常出现在两种场合。一是显眼的复制,比如按折扣率、按税率、按档位各写一个函数,名字里就带着那个数:five_percent_raise、tax_for_beijing。二是隐蔽的复制,比如本章的三个档位函数,写法各异,只有代入边界值才看得出是同一个公式。后一种更值得找,因为它们一旦改漏,症状往往是金额差几块钱,很难追。

判断标准和第 44 章移除标记参数一样:参数是参与计算的数据,就合成一个函数;参数只是选择走哪段代码的开关,就拆成几个函数。如果合并后的函数体里冒出了 if factor == 0.1: 这样的判断,说明几个原函数并不真的只差一个值,参数化做过头了,该退回去。

Python 的 logging:参数化版本和便捷函数并存

Section titled “Python 的 logging:参数化版本和便捷函数并存”

logging 模块里有一个参数化的 logger.log(level, msg),也有 debug、info、warning 这几个只差一个级别的便捷函数,它们内部都转调同一个实现。这是参数化之后再保留几个有名字的入口:常用取值给名字,少见的取值、或者级别来自配置时,用参数化的版本。下面模拟这个结构。

"""模拟 logging:参数化的 log 加上几个便捷函数。"""
DEBUG, INFO, WARNING = 10, 20, 30
NAMES = {DEBUG: "DEBUG", INFO: "INFO", WARNING: "WARNING"}
class Logger:
def __init__(self, threshold: int) -> None:
self.threshold = threshold
self.records: list[str] = []
def log(self, level: int, msg: str) -> None:
if level >= self.threshold:
self.records.append(f"{NAMES[level]}: {msg}")
def info(self, msg: str) -> None:
self.log(INFO, msg)
def warning(self, msg: str) -> None:
self.log(WARNING, msg)
if __name__ == "__main__":
logger = Logger(threshold=INFO)
logger.info("服务启动")
logger.log(DEBUG, "连接池大小 10")
configured_level = WARNING # 级别来自配置文件时,用参数化的版本
logger.log(configured_level, "磁盘使用率 91%")
print("\n".join(logger.records))
# 输出:
# INFO: 服务启动
# WARNING: 磁盘使用率 91%

假如 logging 只有便捷函数,级别来自配置的那一行就得写成一串 if;假如只有 log(level, msg),每天写几百次的 logger.info(...) 都得多带一个常量。两种入口共用一个实现,规则只有一份。

几个函数只是碰巧长得像、但业务含义不同时,别合。会员折扣和员工内购折扣今天都是乘一个系数,明天会员折扣可能要加封顶、员工内购要按工龄算,合成一个 apply_discount(price, rate) 之后,这些差异会以 if 的形式回到函数里。判断方法是问一句:这两个值将来会因为同一个原因一起变吗?不会,就让它们各自独立。

参数个数也要克制。为了把五个函数合成一个,引入四个参数,调用处就成了一串数字,比原来五个有名字的函数更难读。这时可以像 logging 那样,合成一个参数化的实现,同时保留几个有名字的入口。

  • 几个函数只差几个字面量,就合成一个,差别变成参数。名字里带着数字的函数(five_percent_raise)是最明显的信号。
  • 写法不同的函数也可能是同一个公式,代入边界值检查一下。
  • 挑最容易加参数的那个当模板,用改变函数声明加参数,再逐个替换其他调用。
  • 参数参与计算才参数化;只是开关的参数,见第 44 章移除标记参数。