引入参数对象
引入参数对象(Introduce Parameter Object)把总是结伴出现的几个参数合成一个对象,函数改为接收这个对象。 它解决的痛是:几个函数都传同一组参数,每个调用点都要按顺序填一遍,而这组值之间的规则没有地方放。
小吴给冷库写温度监控。每个冷库有一个允许的温度区间,比如 2 到 8 度。系统每小时读一次温度,要找出越界的读数、生成告警文案、在报表上显示区间。这三件事分别是三个函数,每个都接收 min_temp 和 max_temp 两个参数。
"""温度监控:重构前。"""
def describe_range(min_temp: float, max_temp: float) -> str: return f"{min_temp}~{max_temp}°C"
def readings_out_of_range(readings: list[dict], min_temp: float, max_temp: float) -> list[dict]: return [r for r in readings if r["temp"] < min_temp or r["temp"] > max_temp]
def alert_message(reading: dict, min_temp: float, max_temp: float) -> str: if reading["temp"] < min_temp: return f"{reading['time']} 温度 {reading['temp']} 低于下限 {min_temp}" return f"{reading['time']} 温度 {reading['temp']} 高于上限 {max_temp}"
def daily_report(readings: list[dict], min_temp: float, max_temp: float) -> list[str]: lines = [describe_range(min_temp, max_temp)] for reading in readings_out_of_range(readings, min_temp, max_temp): lines.append(alert_message(reading, min_temp, max_temp)) return lines
READINGS = [{"time": "08:00", "temp": 2.5}, {"time": "09:00", "temp": 9.1}, {"time": "10:00", "temp": 4.0}]
if __name__ == "__main__": print("\n".join(daily_report(READINGS, 2, 8)))
# 输出:# 2~8°C# 09:00 温度 9.1 高于上限 8// 温度监控:重构前。export interface Reading { time: string; temp: number;}
function describeRange(minTemp: number, maxTemp: number): string { return `${minTemp}~${maxTemp}°C`;}
function readingsOutOfRange(readings: Reading[], minTemp: number, maxTemp: number): Reading[] { return readings.filter((r) => r.temp < minTemp || r.temp > maxTemp);}
function alertMessage(reading: Reading, minTemp: number, maxTemp: number): string { if (reading.temp < minTemp) return `${reading.time} 温度 ${reading.temp} 低于下限 ${minTemp}`; return `${reading.time} 温度 ${reading.temp} 高于上限 ${maxTemp}`;}
export function dailyReport(readings: Reading[], minTemp: number, maxTemp: number): string[] { const lines = [describeRange(minTemp, maxTemp)]; for (const reading of readingsOutOfRange(readings, minTemp, maxTemp)) { lines.push(alertMessage(reading, minTemp, maxTemp)); } return lines;}
export const READINGS: Reading[] = [ { time: "08:00", temp: 2.5 }, { time: "09:00", temp: 9.1 }, { time: "10:00", temp: 4.0 },];
console.log(dailyReport(READINGS, 2, 8).join("\n"));
// 输出:// 2~8°C// 09:00 温度 9.1 高于上限 8测试走的是对外的 daily_report:
import unittest
from target import daily_report
READINGS = [{"time": "08:00", "temp": 2.5}, {"time": "09:00", "temp": 9.1}, {"time": "10:00", "temp": 1.0}]
class MonitorTest(unittest.TestCase): def test_header_shows_range(self): self.assertEqual(daily_report([], 2, 8), ["2~8°C"])
def test_high_reading_is_reported(self): self.assertIn("09:00 温度 9.1 高于上限 8", daily_report(READINGS, 2, 8))
def test_low_reading_is_reported(self): self.assertIn("10:00 温度 1.0 低于下限 2", daily_report(READINGS, 2, 8))
def test_boundary_values_are_in_range(self): self.assertEqual(daily_report([{"time": "11:00", "temp": 8}, {"time": "12:00", "temp": 2}], 2, 8), ["2~8°C"])
if __name__ == "__main__": unittest.main()import test from "node:test";import assert from "node:assert/strict";import { dailyReport, type Reading } from "./target";
const READINGS: Reading[] = [ { time: "08:00", temp: 2.5 }, { time: "09:00", temp: 9.1 }, { time: "10:00", temp: 1.0 },];
test("表头显示区间", () => { assert.deepEqual(dailyReport([], 2, 8), ["2~8°C"]);});
test("超上限的读数被报告", () => { assert.ok(dailyReport(READINGS, 2, 8).includes("09:00 温度 9.1 高于上限 8"));});
test("低于下限的读数被报告", () => { assert.ok(dailyReport(READINGS, 2, 8).includes("10:00 温度 1 低于下限 2"));});
test("边界值算在区间内", () => { assert.deepEqual( dailyReport( [ { time: "11:00", temp: 8 }, { time: "12:00", temp: 2 }, ], 2, 8, ), ["2~8°C"], );});如果现在要加一个需求:有些冷库的区间是“含边界”的,有些是“不含边界”的。这个开关要传给三个函数,每个签名各加一个参数,每个调用点各加一个实参。接下来再加“告警迟滞”(回到区间内 0.5 度才解除告警),又是一轮。参数列表每加一项,所有调用点都要动一遍。
第二个问题是没有人检查这两个数的关系。daily_report(readings, 8, 2) 把上下限传反了,语法完全合法,运行也不报错,只是所有读数都成了越界。小吴团队真的出过这个事故:一个调用点的参数顺序和别处不一样,三个月后才被发现。“下限不能大于上限”这条规则没有地方可以写。
第三个问题在读。alert_message(reading, 2, 8) 里的 2 和 8 是什么?要看函数定义才知道。而 min_temp 和 max_temp 这两个参数在四个函数、四个调用点里总是手拉手出现,它们其实是一个东西,叫“温度区间”,只是还没有名字。
第四个问题要等到写测试时才暴露。想测“读数正好等于上限算不算越界”,得调用 readings_out_of_range([...], 2, 8);想测“上下限传反了会怎样”,没法测,因为它根本不是一个可以单独检查的东西。区间的规则没有名字,就没有地方给它写测试。
这是数据泥团,也是过长参数列表的起点。Fowler 对数据泥团的判断标准很实用:把其中一个值删掉,剩下的还有没有意义?只有 min_temp 没有 max_temp 的区间没有意义,所以它们是一团。
"""温度监控:重构后。"""
from dataclasses import dataclass
@dataclass(frozen=True)class TemperatureRange: low: float high: float
def __post_init__(self) -> None: if self.low > self.high: raise ValueError(f"下限 {self.low} 不能大于上限 {self.high}")
def contains(self, temp: float) -> bool: return self.low <= temp <= self.high
def describe(self) -> str: return f"{self.low}~{self.high}°C"
def readings_out_of_range(readings: list[dict], limits: TemperatureRange) -> list[dict]: return [r for r in readings if not limits.contains(r["temp"])]
def alert_message(reading: dict, limits: TemperatureRange) -> str: if reading["temp"] < limits.low: return f"{reading['time']} 温度 {reading['temp']} 低于下限 {limits.low}" return f"{reading['time']} 温度 {reading['temp']} 高于上限 {limits.high}"
def daily_report(readings: list[dict], min_temp: float, max_temp: float) -> list[str]: limits = TemperatureRange(min_temp, max_temp) lines = [limits.describe()] for reading in readings_out_of_range(readings, limits): lines.append(alert_message(reading, limits)) return lines
READINGS = [{"time": "08:00", "temp": 2.5}, {"time": "09:00", "temp": 9.1}, {"time": "10:00", "temp": 4.0}]
if __name__ == "__main__": print("\n".join(daily_report(READINGS, 2, 8)))
# 输出:# 2~8°C# 09:00 温度 9.1 高于上限 8// 温度监控:重构后。export interface Reading { time: string; temp: number;}
class TemperatureRange { constructor( readonly low: number, readonly high: number, ) { if (low > high) throw new Error(`下限 ${low} 不能大于上限 ${high}`); }
contains(temp: number): boolean { return this.low <= temp && temp <= this.high; }
describe(): string { return `${this.low}~${this.high}°C`; }}
function readingsOutOfRange(readings: Reading[], limits: TemperatureRange): Reading[] { return readings.filter((r) => !limits.contains(r.temp));}
function alertMessage(reading: Reading, limits: TemperatureRange): string { if (reading.temp < limits.low) return `${reading.time} 温度 ${reading.temp} 低于下限 ${limits.low}`; return `${reading.time} 温度 ${reading.temp} 高于上限 ${limits.high}`;}
export function dailyReport(readings: Reading[], minTemp: number, maxTemp: number): string[] { const limits = new TemperatureRange(minTemp, maxTemp); const lines = [limits.describe()]; for (const reading of readingsOutOfRange(readings, limits)) { lines.push(alertMessage(reading, limits)); } return lines;}
export const READINGS: Reading[] = [ { time: "08:00", temp: 2.5 }, { time: "09:00", temp: 9.1 }, { time: "10:00", temp: 4.0 },];
console.log(dailyReport(READINGS, 2, 8).join("\n"));
// 输出:// 2~8°C// 09:00 温度 9.1 高于上限 8回头看四个问题。“含不含边界”现在是 TemperatureRange 的事,加一个字段、改 contains 一处,三个函数和它们的调用点一行不动。上下限传反会在构造时立刻抛错,而不是三个月后才被发现。alert_message(reading, limits) 读起来就是“用这个区间检查这条读数”,不用再猜 2 和 8 是什么。区间的规则也有了可以单独测试的对象:TemperatureRange(8, 2) 应该抛错、contains(8) 在上限上应该为真,都能直接写成测试,不用绕过三个函数去凑读数。
还有一个附带的变化:readings_out_of_range 里原来的 r["temp"] < min_temp or r["temp"] > max_temp 变成了 not limits.contains(r["temp"])。“是否在区间内”这个判断搬进了区间对象自己身上,这是引入参数对象最常带来的下一步:数据有了名字,围绕它的逻辑就有了家。
daily_report 的签名故意没改,它是对外的入口,测试和外部调用方都通过它。这也是个常见的做法:参数对象先在模块内部用起来,对外的签名等调用方准备好了再换。
怎么一步步做
Section titled “怎么一步步做”- 建一个类来装这组参数。Python 用
@dataclass(frozen=True),TS 用带readonly字段的类。先只放字段,不放方法。Fowler 建议做成值对象,也就是不可变、按内容比较,这样传来传去不会被谁改了。 - 跑测试。此时什么都没变,只是多了一个没人用的类。
- 选一个函数,用改变函数声明(第 6 章)给它加一个新参数,类型是这个类。先加参数,不删旧参数。
- 跑测试。
- 改每个调用点,传入新对象。每改一个跑一次测试。
- 函数内部改用新对象的字段,删掉旧参数。跑测试。
- 对其余函数重复第 3 到 6 步。
- 看看有没有逻辑该搬进新类里。
contains、describe就是这么来的。
第 3 到 6 步看起来绕:为什么不直接把旧参数换成新参数?因为“先加后删”让每一步都有可以运行的中间状态。调用点多的时候,你可以今天改一半、明天改另一半,中间照常发布。一次性替换则要求所有调用点在同一次改动里全部正确,出错时没有安全点可退。
如果这组参数本来就来自同一个已有对象,比如调用方手里有一个 freezer 对象,从里面取出 freezer.min_temp 和 freezer.max_temp 再传进来,那不要新建类,直接传 freezer,见第 45 章保持对象完整。引入参数对象处理的是“对象还不存在”的情况。
定义与适用场景
Section titled “定义与适用场景”引入参数对象:把一组总是一起出现的参数替换为一个对象,让这组数据有名字,也让围绕它的规则有地方放。
它最常出现在三种场合。一是区间:起止日期、上下限、价格区间,几乎每个系统都有,而且几乎总是以两个裸参数的形式散落各处。二是坐标和尺寸:x, y、width, height。三是一组配置:超时、重试次数、退避时间,见下面的真实例子。
Fowler 建议把参数对象做成值对象(Value Object):不可变,两个对象只要字段相同就相等。这样做的理由是参数对象会被传到很多函数里,如果可变,任何一个函数都可能悄悄改了它,别的函数读到的就是改过的值。Python 的 @dataclass(frozen=True) 自动提供了不可变和按内容比较;TS 里用 readonly 字段,相等比较要自己写一个 equals 方法。第 33 章会专门讲值对象和引用对象的区别。
参数对象建好之后,经常引出下一步。本章里 contains 和 describe 搬进了 TemperatureRange,这是搬移函数(第 21 章);如果围绕这个对象的函数越来越多,它就变成了一个真正的领域类,这是函数组合成类(第 10 章)。引入参数对象常常是这条路的第一步。
它没有反向手法。参数对象一旦建好,很少有理由拆回去。如果一个参数对象里的字段从来不一起用,那它当初就不该合起来。
相关的设计模式
Section titled “相关的设计模式”TemperatureRange 是一个值对象(Value Object):没有身份,只有值,两个 TemperatureRange(2, 8) 完全等价,构造之后不再改变。值对象不在 GoF 的 23 个模式里,它来自领域驱动设计和 Fowler 的《企业应用架构模式》,但它是引入参数对象几乎必然做出来的东西,也是第 33 章“将引用对象改为值对象”的主角。GoF 里和它最近的是享元(Flyweight):享元能被多处共享的前提,正是对象按值相等且不可变。享元模式见《设计模式》书第 15 章。
真实系统里的例子
Section titled “真实系统里的例子”HTTP 客户端的重试策略
Section titled “HTTP 客户端的重试策略”几乎每个 HTTP 客户端库都从“几个重试参数”走到了“一个重试策略对象”。下面模拟一个总是先失败两次的服务器,fetchV1 接收三个散参数,fetchV2 接收一个 RetryPolicy,两者的重试过程完全一样。
// 模拟 HTTP 客户端的重试:散参数 vs 策略对象。function flakyServer() { let calls = 0; return (): number => (++calls <= 2 ? 503 : 200);}
function fetchV1(server: () => number, retries: number, backoffMs: number, retryOn: number[]): string[] { const log: string[] = []; for (let attempt = 0; attempt <= retries; attempt++) { const status = server(); log.push(`第 ${attempt + 1} 次:${status}`); if (!retryOn.includes(status)) return log; log.push(`等待 ${backoffMs * 2 ** attempt}ms`); } return log;}
class RetryPolicy { constructor( readonly retries: number, readonly backoffMs: number, readonly retryOn: number[], ) {}
shouldRetry(status: number): boolean { return this.retryOn.includes(status); }
delayFor(attempt: number): number { return this.backoffMs * 2 ** attempt; }}
function fetchV2(server: () => number, policy: RetryPolicy): string[] { const log: string[] = []; for (let attempt = 0; attempt <= policy.retries; attempt++) { const status = server(); log.push(`第 ${attempt + 1} 次:${status}`); if (!policy.shouldRetry(status)) return log; log.push(`等待 ${policy.delayFor(attempt)}ms`); } return log;}
const v1 = fetchV1(flakyServer(), 3, 100, [502, 503]);const v2 = fetchV2(flakyServer(), new RetryPolicy(3, 100, [502, 503]));console.log(JSON.stringify(v1) === JSON.stringify(v2));console.log(v2.join(" | "));
// 输出:// true// 第 1 次:503 | 等待 100ms | 第 2 次:503 | 等待 200ms | 第 3 次:200shouldRetry 和 delayFor 是散参数版本里散落在循环中的两段逻辑,有了 RetryPolicy 之后它们搬进了对象,fetchV2 的循环只剩下“发请求、问策略、等待”。
urllib3 的 Retry 对象
Section titled “urllib3 的 Retry 对象”Python 的 urllib3 和 requests 用一个 Retry 对象描述重试策略,用户传 Retry(total=3, backoff_factor=0.5, status_forcelist=[502, 503]) 而不是三个散参数。下面是它的最小复刻。
"""模拟 urllib3 的 Retry 对象。"""
from dataclasses import dataclass, field
@dataclass(frozen=True)class Retry: total: int = 3 backoff_factor: float = 0.5 status_forcelist: tuple[int, ...] = field(default_factory=tuple)
def is_retry(self, status: int) -> bool: return status in self.status_forcelist
def get_backoff_time(self, attempt: int) -> float: return self.backoff_factor * (2 ** (attempt - 2)) if attempt > 1 else 0.0
def request(responses: list[int], retry: Retry) -> list[str]: log = [] for attempt, status in enumerate(responses[: retry.total + 1], start=1): wait = retry.get_backoff_time(attempt) log.append(f"第 {attempt} 次(先等 {wait}s):{status}") if not retry.is_retry(status): break return log
if __name__ == "__main__": policy = Retry(total=3, backoff_factor=0.5, status_forcelist=(502, 503)) print("\n".join(request([503, 503, 200], policy)))
# 输出:# 第 1 次(先等 0.0s):503# 第 2 次(先等 0.5s):503# 第 3 次(先等 1.0s):200真实的 Retry 还有十几个字段和更复杂的退避规则,但用法一样:策略是一个对象,可以复用、可以在配置里统一定义,而不是每个请求重复传三个数。这也是参数对象的另一个好处:它可以有名字地存在于配置文件、依赖注入容器或者常量里,比如 DEFAULT_RETRY = Retry(total=3, backoff_factor=0.5),而三个散参数没法被这样命名和共享。
什么时候别用
Section titled “什么时候别用”两个参数只在一个函数里一起出现,没有在别处结伴,建对象是多此一举。参数对象的回报来自“多处共用”和“有规则可放”,缺一个就先等等。
别把不相干的参数硬凑成一个对象。UserRequestContext 里塞着用户 ID、页码、是否发邮件,这不是参数对象,是把过长参数列表换了个壳。合进一个对象的参数应该在概念上是一个东西,有一个自然的名字。名字想不出来,往往说明这几个参数并不是一个东西。
也别用裸字典或者匿名对象代替类。{"min": 2, "max": 8} 看起来省事,但它放不下 contains 这样的方法,也没有地方检查 min <= max,键名拼错还不报错。参数对象的价值一半在名字,一半在它能承载的规则,裸字典两样都没有。Python 的数据类和 TS 的类都只要几行,省不出什么。
容易混淆的手法
Section titled “容易混淆的手法”| 手法 | 它关心什么 | 引入参数对象关心什么 |
|---|---|---|
| 保持对象完整(第 45 章) | 调用方已经有一个对象,不要拆成字段再传 | 对象还不存在,从散参数里把它建出来 |
| 以对象取代基本类型(第 15 章) | 一个值(电话号、金额)带着规则,该有自己的类 | 一组值一起出现,该合成一个类 |
| 函数组合成类(第 10 章) | 几个函数都操作同一份数据,把函数收进类 | 几个函数都接收同一组参数,把参数收进类;常常是前者的第一步 |
下面的函数处理民宿的旺季。season_start 和 season_end 在三个函数间传来传去。把它们合成一个 DateRange 对象,带 contains、days、describe 三个方法。booking_summary 的签名和输出不变,测试照常通过。
"""练习:民宿旺季。"""
from datetime import date
def in_period(day: str, start: str, end: str) -> bool: return start <= day <= end
def period_days(start: str, end: str) -> int: return (date.fromisoformat(end) - date.fromisoformat(start)).days + 1
def describe_period(start: str, end: str) -> str: return f"{start}~{end}({period_days(start, end)} 天)"
def booking_summary(booking_date: str, season_start: str, season_end: str) -> str: if in_period(booking_date, season_start, season_end): return f"{booking_date} 在旺季 {describe_period(season_start, season_end)}内" return f"{booking_date} 不在旺季内"
if __name__ == "__main__": print(booking_summary("2026-07-15", "2026-07-01", "2026-08-31")) print(booking_summary("2026-09-15", "2026-07-01", "2026-08-31"))
# 输出:# 2026-07-15 在旺季 2026-07-01~2026-08-31(62 天)内# 2026-09-15 不在旺季内import unittest
from target import booking_summary
class SeasonTest(unittest.TestCase): def test_inside_season(self): self.assertEqual( booking_summary("2026-07-15", "2026-07-01", "2026-08-31"), "2026-07-15 在旺季 2026-07-01~2026-08-31(62 天)内", )
def test_outside_season(self): self.assertEqual(booking_summary("2026-09-15", "2026-07-01", "2026-08-31"), "2026-09-15 不在旺季内")
def test_boundary_days_are_inside(self): self.assertIn("在旺季", booking_summary("2026-08-31", "2026-07-01", "2026-08-31"))
if __name__ == "__main__": unittest.main()// 练习:民宿旺季。function inPeriod(day: string, start: string, end: string): boolean { return start <= day && day <= end;}
function periodDays(start: string, end: string): number { const ms = Date.parse(end) - Date.parse(start); return ms / 86_400_000 + 1;}
function describePeriod(start: string, end: string): string { return `${start}~${end}(${periodDays(start, end)} 天)`;}
export function bookingSummary(bookingDate: string, seasonStart: string, seasonEnd: string): string { if (inPeriod(bookingDate, seasonStart, seasonEnd)) { return `${bookingDate} 在旺季 ${describePeriod(seasonStart, seasonEnd)}内`; } return `${bookingDate} 不在旺季内`;}
console.log(bookingSummary("2026-07-15", "2026-07-01", "2026-08-31"));console.log(bookingSummary("2026-09-15", "2026-07-01", "2026-08-31"));
// 输出:// 2026-07-15 在旺季 2026-07-01~2026-08-31(62 天)内// 2026-09-15 不在旺季内import test from "node:test";import assert from "node:assert/strict";import { bookingSummary } from "./target";
test("旺季内", () => { assert.equal( bookingSummary("2026-07-15", "2026-07-01", "2026-08-31"), "2026-07-15 在旺季 2026-07-01~2026-08-31(62 天)内", );});
test("旺季外", () => { assert.equal(bookingSummary("2026-09-15", "2026-07-01", "2026-08-31"), "2026-09-15 不在旺季内");});
test("边界日算在旺季内", () => { assert.match(bookingSummary("2026-08-31", "2026-07-01", "2026-08-31"), /在旺季/);});- 几个参数总是结伴出现,它们就是一个还没有名字的东西。给它一个类。
- 参数对象做成不可变的值对象,构造时校验字段间的规则。
- 一次改一个函数:先加新参数,改调用点,再删旧参数,每步跑测试。
- 对象建好后,看哪些逻辑该搬进去。
contains、describe这类方法会自己冒出来。 - 对外的入口可以先不改签名,参数对象先在内部用起来。