跳转到内容

引入参数对象

引入参数对象(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

重构前 min_temp、max_temp 在四个函数间散落传递;重构后 daily_report 构造一个 TemperatureRange,其余函数只接收这个对象

回头看四个问题。“含不含边界”现在是 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 的签名故意没改,它是对外的入口,测试和外部调用方都通过它。这也是个常见的做法:参数对象先在模块内部用起来,对外的签名等调用方准备好了再换。

  1. 建一个类来装这组参数。Python 用 @dataclass(frozen=True),TS 用带 readonly 字段的类。先只放字段,不放方法。Fowler 建议做成值对象,也就是不可变、按内容比较,这样传来传去不会被谁改了。
  2. 跑测试。此时什么都没变,只是多了一个没人用的类。
  3. 选一个函数,用改变函数声明(第 6 章)给它加一个新参数,类型是这个类。先加参数,不删旧参数。
  4. 跑测试。
  5. 改每个调用点,传入新对象。每改一个跑一次测试。
  6. 函数内部改用新对象的字段,删掉旧参数。跑测试。
  7. 对其余函数重复第 3 到 6 步。
  8. 看看有没有逻辑该搬进新类里。contains、describe 就是这么来的。

第 3 到 6 步看起来绕:为什么不直接把旧参数换成新参数?因为“先加后删”让每一步都有可以运行的中间状态。调用点多的时候,你可以今天改一半、明天改另一半,中间照常发布。一次性替换则要求所有调用点在同一次改动里全部正确,出错时没有安全点可退。

如果这组参数本来就来自同一个已有对象,比如调用方手里有一个 freezer 对象,从里面取出 freezer.min_temp 和 freezer.max_temp 再传进来,那不要新建类,直接传 freezer,见第 45 章保持对象完整。引入参数对象处理的是“对象还不存在”的情况。

引入参数对象:把一组总是一起出现的参数替换为一个对象,让这组数据有名字,也让围绕它的规则有地方放。

它最常出现在三种场合。一是区间:起止日期、上下限、价格区间,几乎每个系统都有,而且几乎总是以两个裸参数的形式散落各处。二是坐标和尺寸:x, y、width, height。三是一组配置:超时、重试次数、退避时间,见下面的真实例子。

Fowler 建议把参数对象做成值对象(Value Object):不可变,两个对象只要字段相同就相等。这样做的理由是参数对象会被传到很多函数里,如果可变,任何一个函数都可能悄悄改了它,别的函数读到的就是改过的值。Python 的 @dataclass(frozen=True) 自动提供了不可变和按内容比较;TS 里用 readonly 字段,相等比较要自己写一个 equals 方法。第 33 章会专门讲值对象和引用对象的区别。

参数对象建好之后,经常引出下一步。本章里 contains 和 describe 搬进了 TemperatureRange,这是搬移函数(第 21 章);如果围绕这个对象的函数越来越多,它就变成了一个真正的领域类,这是函数组合成类(第 10 章)。引入参数对象常常是这条路的第一步。

它没有反向手法。参数对象一旦建好,很少有理由拆回去。如果一个参数对象里的字段从来不一起用,那它当初就不该合起来。

TemperatureRange 是一个值对象(Value Object):没有身份,只有值,两个 TemperatureRange(2, 8) 完全等价,构造之后不再改变。值对象不在 GoF 的 23 个模式里,它来自领域驱动设计和 Fowler 的《企业应用架构模式》,但它是引入参数对象几乎必然做出来的东西,也是第 33 章“将引用对象改为值对象”的主角。GoF 里和它最近的是享元(Flyweight):享元能被多处共享的前提,正是对象按值相等且不可变。享元模式见《设计模式》书第 15 章。

几乎每个 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 次:200

shouldRetry 和 delayFor 是散参数版本里散落在循环中的两段逻辑,有了 RetryPolicy 之后它们搬进了对象,fetchV2 的循环只剩下“发请求、问策略、等待”。

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),而三个散参数没法被这样命名和共享。

两个参数只在一个函数里一起出现,没有在别处结伴,建对象是多此一举。参数对象的回报来自“多处共用”和“有规则可放”,缺一个就先等等。

别把不相干的参数硬凑成一个对象。UserRequestContext 里塞着用户 ID、页码、是否发邮件,这不是参数对象,是把过长参数列表换了个壳。合进一个对象的参数应该在概念上是一个东西,有一个自然的名字。名字想不出来,往往说明这几个参数并不是一个东西。

也别用裸字典或者匿名对象代替类。{"min": 2, "max": 8} 看起来省事,但它放不下 contains 这样的方法,也没有地方检查 min <= max,键名拼错还不报错。参数对象的价值一半在名字,一半在它能承载的规则,裸字典两样都没有。Python 的数据类和 TS 的类都只要几行,省不出什么。

手法 它关心什么 引入参数对象关心什么
保持对象完整(第 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 这类方法会自己冒出来。
  • 对外的入口可以先不改签名,参数对象先在内部用起来。