函数参数化
函数参数化(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 对它求和。电价表从代码变成数据,以后从配置文件读进来也行。参数化常常是“代码变数据”的第一步。
怎么一步步做
Section titled “怎么一步步做”每一步之后都跑测试。
- 从几个相似函数里挑一个,最好是最容易加参数的那个。
middle_band同时有下限和上限,最适合当模板。 - 用第 6 章的改变函数声明(Change Function Declaration)给它加上参数,把字面量换成参数:
middle_band(usage)改成with_band(usage, bottom, top),函数体里的 100 换成bottom,200 换成top。调用处改成with_band(usage, 100, 200)。(改名和加参数可由 IDE 的 Change Signature 自动完成) - 逐个替换其他相似函数的调用。
bottom_band(usage)换成with_band(usage, 0, 100),top_band(usage)换成with_band(usage, 200, float("inf"))。每换一个跑一次测试。 - 如果某个调用换过去之后测试失败,说明那个函数和模板不完全一样,先调整模板,让它对所有取值都成立,再继续换。本例中
bottom_band没有if,但usage > 0时with_band(usage, 0, 100)和它结果相同,所以能直接换。 - 删掉不再被调用的旧函数。
定义与适用场景
Section titled “定义与适用场景”函数参数化:把几个只在字面量上有差异的函数合并成一个函数,用参数表示这些差异。
它常出现在两种场合。一是显眼的复制,比如按折扣率、按税率、按档位各写一个函数,名字里就带着那个数:five_percent_raise、tax_for_beijing。二是隐蔽的复制,比如本章的三个档位函数,写法各异,只有代入边界值才看得出是同一个公式。后一种更值得找,因为它们一旦改漏,症状往往是金额差几块钱,很难追。
判断标准和第 44 章移除标记参数一样:参数是参与计算的数据,就合成一个函数;参数只是选择走哪段代码的开关,就拆成几个函数。如果合并后的函数体里冒出了 if factor == 0.1: 这样的判断,说明几个原函数并不真的只差一个值,参数化做过头了,该退回去。
真实系统里的例子
Section titled “真实系统里的例子”Python 的 logging:参数化版本和便捷函数并存
Section titled “Python 的 logging:参数化版本和便捷函数并存”logging 模块里有一个参数化的 logger.log(level, msg),也有 debug、info、warning 这几个只差一个级别的便捷函数,它们内部都转调同一个实现。这是参数化之后再保留几个有名字的入口:常用取值给名字,少见的取值、或者级别来自配置时,用参数化的版本。下面模拟这个结构。
"""模拟 logging:参数化的 log 加上几个便捷函数。"""
DEBUG, INFO, WARNING = 10, 20, 30NAMES = {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(...) 都得多带一个常量。两种入口共用一个实现,规则只有一份。
什么时候别用
Section titled “什么时候别用”几个函数只是碰巧长得像、但业务含义不同时,别合。会员折扣和员工内购折扣今天都是乘一个系数,明天会员折扣可能要加封顶、员工内购要按工龄算,合成一个 apply_discount(price, rate) 之后,这些差异会以 if 的形式回到函数里。判断方法是问一句:这两个值将来会因为同一个原因一起变吗?不会,就让它们各自独立。
参数个数也要克制。为了把五个函数合成一个,引入四个参数,调用处就成了一串数字,比原来五个有名字的函数更难读。这时可以像 logging 那样,合成一个参数化的实现,同时保留几个有名字的入口。
- 几个函数只差几个字面量,就合成一个,差别变成参数。名字里带着数字的函数(
five_percent_raise)是最明显的信号。 - 写法不同的函数也可能是同一个公式,代入边界值检查一下。
- 挑最容易加参数的那个当模板,用改变函数声明加参数,再逐个替换其他调用。
- 参数参与计算才参数化;只是开关的参数,见第 44 章移除标记参数。