跳转到内容

保持对象完整

保持对象完整(Preserve Whole Object)在调用方先从一个对象里取出几个值、再把这些值传给函数时,改成直接把整个对象传过去。 它解决的痛是:函数需要的数据一变,每个调用方都得跟着多取一个字段、多传一个参数。

小韩维护一个楼宇自控系统。每个房间有一个当天的温度区间(最低、最高),供暖计划规定了允许的温度范围。巡检程序每天把每个房间的区间拿去和计划比,超出范围就发提醒。小韩写的 HeatingPlan.within_range 接收两个数:最低温度和最高温度,调用方从房间对象里把这两个数取出来再传进去。

"""供暖计划:重构前。"""
from dataclasses import dataclass
@dataclass(frozen=True)
class TempRange:
low: int
high: int
@dataclass
class Room:
name: str
days_temp_range: TempRange
class HeatingPlan:
def __init__(self, temperature_range: TempRange) -> None:
self._range = temperature_range
def within_range(self, bottom: int, top: int) -> bool:
return self._range.low <= bottom and top <= self._range.high
def check_room(plan: HeatingPlan, room: Room) -> str:
low = room.days_temp_range.low
high = room.days_temp_range.high
return f"{room.name}:{'正常' if plan.within_range(low, high) else '温度超出计划范围'}"
if __name__ == "__main__":
plan = HeatingPlan(TempRange(16, 26))
print(check_room(plan, Room("会议室", TempRange(18, 24))))
print(check_room(plan, Room("机房", TempRange(14, 22))))
# 输出:
# 会议室:正常
# 机房:温度超出计划范围
// 供暖计划:重构前。
export interface TempRange {
readonly low: number;
readonly high: number;
}
export interface Room {
name: string;
daysTempRange: TempRange;
}
export class HeatingPlan {
constructor(private readonly range: TempRange) {}
withinRange(bottom: number, top: number): boolean {
return this.range.low <= bottom && top <= this.range.high;
}
}
export function checkRoom(plan: HeatingPlan, room: Room): string {
const low = room.daysTempRange.low;
const high = room.daysTempRange.high;
return `${room.name}:${plan.withinRange(low, high) ? "正常" : "温度超出计划范围"}`;
}
const plan = new HeatingPlan({ low: 16, high: 26 });
console.log(checkRoom(plan, { name: "会议室", daysTempRange: { low: 18, high: 24 } }));
console.log(checkRoom(plan, { name: "机房", daysTempRange: { low: 14, high: 22 } }));
// 输出:
// 会议室:正常
// 机房:温度超出计划范围

如果现在要加一个需求:温度区间多了一个字段“平均温度”,计划要求平均温度也不能偏离设定值太远。within_range 得加第三个参数,于是巡检程序、能耗报表、前台的房间状态页,所有调用它的地方都要多写一行 avg = room.days_temp_range.avg 再传进去。改一个判断规则,却要动每一个调用方。

调用处还有一个小陷阱:within_range(low, high) 的两个参数都是整数,有人写成 within_range(high, low),类型检查不会报错,结果却全错。low 和 high 本来就是一个区间对象里成对出现的两个值,调用方把它们拆开、再按顺序摆回去,凭空制造了一次出错的机会。

用第 2 章的名字说,这是过长参数列表:参数现在只有两个,但它们是从同一个对象里拆出来的,每加一个字段就多一个。拆出来的 low、high 在每个调用方各取一遍,也有一点数据泥团的味道。

"""供暖计划:重构后。"""
from dataclasses import dataclass
@dataclass(frozen=True)
class TempRange:
low: int
high: int
@dataclass
class Room:
name: str
days_temp_range: TempRange
class HeatingPlan:
def __init__(self, temperature_range: TempRange) -> None:
self._range = temperature_range
def within_range(self, days_range: TempRange) -> bool:
return self._range.low <= days_range.low and days_range.high <= self._range.high
def check_room(plan: HeatingPlan, room: Room) -> str:
return f"{room.name}:{'正常' if plan.within_range(room.days_temp_range) else '温度超出计划范围'}"
if __name__ == "__main__":
plan = HeatingPlan(TempRange(16, 26))
print(check_room(plan, Room("会议室", TempRange(18, 24))))
print(check_room(plan, Room("机房", TempRange(14, 22))))
# 输出:
# 会议室:正常
# 机房:温度超出计划范围
// 供暖计划:重构后。
export interface TempRange {
readonly low: number;
readonly high: number;
}
export interface Room {
name: string;
daysTempRange: TempRange;
}
export class HeatingPlan {
constructor(private readonly range: TempRange) {}
withinRange(daysRange: TempRange): boolean {
return this.range.low <= daysRange.low && daysRange.high <= this.range.high;
}
}
export function checkRoom(plan: HeatingPlan, room: Room): string {
return `${room.name}:${plan.withinRange(room.daysTempRange) ? "正常" : "温度超出计划范围"}`;
}
const plan = new HeatingPlan({ low: 16, high: 26 });
console.log(checkRoom(plan, { name: "会议室", daysTempRange: { low: 18, high: 24 } }));
console.log(checkRoom(plan, { name: "机房", daysTempRange: { low: 14, high: 22 } }));
// 输出:
// 会议室:正常
// 机房:温度超出计划范围

调用处只剩 plan.within_range(room.days_temp_range)。平均温度那个需求,现在只改 within_range 的函数体,调用方一行不用动。low 和 high 不会再被传反,因为它们没有离开过 TempRange。

改完之后再看 within_range:它读的全是 days_range 的字段,和计划自己的区间做比较。两个都是 TempRange,这个比较也许更该是 TempRange 自己的方法,比如 plan_range.contains(days_range)。这是第 21 章搬移函数的事了,保持对象完整常常让这种下一步变得明显。

每一步之后都跑测试。

  1. 新建一个空函数,参数是整个对象。名字暂时起一个不会冲突的,比如 xx_within_range(days_range)。
  2. 在新函数里调用旧函数,把对象拆开传进去:return self.within_range(days_range.low, days_range.high)。
  3. 逐个修改调用方,改成调用新函数。改完一处,调用方里那两行取 low、high 的代码通常就成了死代码,删掉它们。每改一处跑一次测试。
  4. 所有调用方都改完后,用第 4 章的内联函数(Inline Function)把旧函数内联进新函数。(IDE 可自动完成)
  5. 把新函数改回原来的名字 within_range,同时改掉所有调用方。(IDE 可自动完成)

这正是第 6 章讲过的迁移式改法,每一步都可以单独提交。

保持对象完整:如果调用方从一个对象中取出若干值,作为参数传给某个函数,就改为传递整个对象,让函数自己取需要的值。

它常出现在对象之间协作的边界上:一个类的方法需要另一个类的两三个字段,调用方负责搬运。还有一种情形是参数的值能从同一个对象推出来,例如传了 order 又传了 order.total,后者是多余的,函数自己能算。

这个手法和第 6 章改变函数声明的例子方向正好相反。第 6 章把 in_free_zone(customer) 改成 in_free_zone(province),是因为函数只需要一个省份,却要了整个顾客对象,还逼着只有省份字符串的调用方去造一个假顾客。本章把 within_range(low, high) 改成 within_range(days_range),是因为 low 和 high 总是成对出现、属于同一个概念,函数需要的就是那个概念。两个方向的判断标准是同一个:函数真正需要什么。需要的是一个完整的概念,就传对象;需要的只是对象身上一个无关紧要的属性,而且这个对象来自别的模块、结构说变就变,就传那个值。

前端组件的参数(props)也面临同样的选择。头像组件可以接收 url 和 name 两个字符串,也可以接收整个 user。下面用普通函数模拟组件:用户对象加了“头像审核中”这个状态之后,传字段的版本要改每一个使用处,传对象的版本只改组件本身。

// 用普通函数模拟前端组件,返回渲染出的 HTML 字符串。
interface User {
name: string;
avatarUrl: string;
avatarPending: boolean;
}
// 传字段:每个使用处都要从 user 里取值,新加的 avatarPending 也得每处补传。
function AvatarFields(props: { url: string; name: string; pending: boolean }): string {
const src = props.pending ? "/default.png" : props.url;
return `<img src="${src}" alt="${props.name}">`;
}
// 传对象:使用处只写 user={user},新字段在组件内部处理。
function AvatarUser(props: { user: User }): string {
const { user } = props;
const src = user.avatarPending ? "/default.png" : user.avatarUrl;
return `<img src="${src}" alt="${user.name}">`;
}
const user: User = { name: "小韩", avatarUrl: "/u/42.png", avatarPending: true };
console.log(AvatarFields({ url: user.avatarUrl, name: user.name, pending: user.avatarPending }));
console.log(AvatarUser({ user }));
// 输出:
// <img src="/default.png" alt="小韩">
// <img src="/default.png" alt="小韩">

这里的取舍和正文一样。业务页面里的头像组件,展示的就是“这个用户”,传整个 user 更省事。设计系统里的通用头像组件则不同,它会被用在用户、群组、机器人等各种对象上,这时接收 url 和 name 两个字段反而让它不依赖任何一种业务对象。

函数只需要对象的一个字段、而这个对象又大又来自别的模块时,别传整个对象。传进去之后,函数就依赖了那个对象的整个结构,测试时也得造一个完整的对象,而不是传一个数。第 6 章的 in_free_zone 就是这样从传对象改回了传值。

还有一种情况是你不希望函数依赖那个类。比如一个通用的工具函数 clamp(value, low, high),它接收三个数正是它通用的原因,改成接收 TempRange 就只能给温度用了。如果调用方总是从同一个对象里取这几个值,可以在那个对象上加一个方法转调工具函数,而不是改工具函数的参数。

  • 调用方先从对象里取出几个值、再一起传给函数,就改成传整个对象。
  • 好处:函数要的数据变了,只改函数,不改调用方;成对的值不会被传反。
  • 用迁移式改法:先建接收对象的新函数转调旧函数,逐个改调用方,最后内联、改名。
  • 方向和第 6 章的例子相反,标准相同:函数需要的是完整概念就传对象,只需要一个无关属性就传值。