跳转到内容

将引用对象改为值对象 / 将值对象改为引用对象

将引用对象改为值对象(Change Reference to Value)让一个可变的、被共享的对象变成不可变的值,要“改”就换一个新的;将值对象改为引用对象(Change Value to Reference)反过来,让同一个实体在系统里只有一个对象,大家都引用它。 它们解决的痛是:一个对象被几处共享,一处改了别处意外跟着变;或者反过来,同一个客户在十张订单里各有一份副本,改信用额度只改到了一份。

小罗维护一个通讯录。每个人有一个电话号码对象,包含区号和号码。同一家公司的两个员工被录入时共用了同一个电话对象(总机号)。后来其中一个人换了分机,代码直接改了电话对象的号码。

"""电话号码:重构前,可变对象被共享。"""
class TelephoneNumber:
def __init__(self, area_code: str, number: str) -> None:
self.area_code = area_code
self.number = number
def __str__(self) -> str:
return f"({self.area_code}) {self.number}"
class Person:
def __init__(self, name: str, phone: TelephoneNumber) -> None:
self.name = name
self.phone = phone
if __name__ == "__main__":
a = Person("小罗", TelephoneNumber("021", "6000 0000"))
b = Person("小王", TelephoneNumber("021", "6000 0000"))
a.phone.number = "6000 0123"
print(a.name, a.phone)
print(b.name, b.phone)
# 输出:
# 小罗 (021) 6000 0123
# 小王 (021) 6000 0000
// 电话号码:重构前,可变对象被共享。
class TelephoneNumber {
constructor(
public areaCode: string,
public number: string,
) {}
toString(): string {
return `(${this.areaCode}) ${this.number}`;
}
}
class Person {
constructor(
readonly name: string,
public phone: TelephoneNumber,
) {}
}
const a = new Person("小罗", new TelephoneNumber("021", "6000 0000"));
const b = new Person("小王", new TelephoneNumber("021", "6000 0000"));
a.phone.number = "6000 0123";
console.log(a.name, a.phone.toString());
console.log(b.name, b.phone.toString());
// 输出:
// 小罗 (021) 6000 0123
// 小王 (021) 6000 0000

上面的代码运行正常,因为两人各有一个电话对象。可场景里说过,录入同一家公司的员工时,他们常常共用总机号的同一个对象:

switchboard = TelephoneNumber("021", "6000 0000")
a, b = Person("小罗", switchboard), Person("小王", switchboard)
a.phone.number = "6000 0123" # 小王的号码也变成了 6000 0123

小罗只想改自己的号码,结果小王的号码也变了,因为 a.phone.number = ... 改的是那个共享对象本身。如果现在要加一个需求“员工可以自己改联系电话”,每一处改电话的代码都得先想清楚:这个对象有没有被别人共享?要不要先复制一份?这种问题在测试里很难发现,因为只有在两个人恰好共用了一个对象时才会出现。

这是可变数据:一个可变对象被多处引用,修改的影响范围看不见。

"""电话号码:重构后,不可变的值对象。"""
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class TelephoneNumber:
area_code: str
number: str
def __str__(self) -> str:
return f"({self.area_code}) {self.number}"
class Person:
def __init__(self, name: str, phone: TelephoneNumber) -> None:
self.name = name
self.phone = phone
def change_number(self, number: str) -> None:
self.phone = replace(self.phone, number=number)
if __name__ == "__main__":
a = Person("小罗", TelephoneNumber("021", "6000 0000"))
b = Person("小王", TelephoneNumber("021", "6000 0000"))
a.change_number("6000 0123")
print(a.name, a.phone)
print(b.name, b.phone)
# 输出:
# 小罗 (021) 6000 0123
# 小王 (021) 6000 0000
// 电话号码:重构后,不可变的值对象。
class TelephoneNumber {
constructor(
readonly areaCode: string,
readonly number: string,
) {}
withNumber(number: string): TelephoneNumber {
return new TelephoneNumber(this.areaCode, number);
}
equals(other: TelephoneNumber): boolean {
return this.areaCode === other.areaCode && this.number === other.number;
}
toString(): string {
return `(${this.areaCode}) ${this.number}`;
}
}
class Person {
constructor(
readonly name: string,
public phone: TelephoneNumber,
) {}
changeNumber(number: string): void {
this.phone = this.phone.withNumber(number);
}
}
const a = new Person("小罗", new TelephoneNumber("021", "6000 0000"));
const b = new Person("小王", new TelephoneNumber("021", "6000 0000"));
a.changeNumber("6000 0123");
console.log(a.name, a.phone.toString());
console.log(b.name, b.phone.toString());
// 输出:
// 小罗 (021) 6000 0123
// 小王 (021) 6000 0000

引用对象:两个人指向同一个可变的电话对象;值对象:改号就换一个新对象,各自独立。反向的引用对象场景里,所有订单经仓库指向同一个客户

电话对象变成了不可变的:Python 用 frozen=True,改字段直接报错;TS 的字段全是 readonly。“改号码”变成“换一个新的号码对象”。两版的输出完全一样;区别在共享的场景:即使两人共用同一个电话对象,小罗的 change_number 也只替换了自己手里的引用,小王手里还是原来那个,因为已经没人能修改那个对象本身了。TelephoneNumber("021", "6000 0000") == b.phone 为真。两个电话号码是否相等按内容判断:Python 的数据类自动做到,TS 要自己写 equals。

现在共享电话对象是安全的,因为没人能改它。这正是值对象的好处:不可变的东西可以随便共享、随便传递,不用担心被谁改了。

反方向:将值对象改为引用对象。 有时问题恰好相反。订单系统里,每张订单录入时都根据客户编号新建了一个客户对象,于是同一个客户在十张订单里有十份副本。财务调整了这个客户的信用额度,只改到了其中一份。客户是一个有身份的实体,它的数据应该只有一份。办法是建一个仓库(repository),按编号存放客户对象,订单都从仓库里取,拿到的就是同一个对象:

"""客户:从各持一份副本改为引用仓库里的唯一对象。"""
class Customer:
def __init__(self, customer_id: str) -> None:
self.id = customer_id
self.credit_limit = 1000
class CustomerRepository:
def __init__(self) -> None:
self._customers: dict[str, Customer] = {}
def get(self, customer_id: str) -> Customer:
if customer_id not in self._customers:
self._customers[customer_id] = Customer(customer_id)
return self._customers[customer_id]
class Order:
def __init__(self, number: int, customer_id: str, repo: CustomerRepository) -> None:
self.number = number
self.customer = repo.get(customer_id)
if __name__ == "__main__":
repo = CustomerRepository()
orders = [Order(n, "C001", repo) for n in range(1, 4)]
repo.get("C001").credit_limit = 5000
print([o.customer.credit_limit for o in orders])
print(orders[0].customer is orders[2].customer)
# 输出:
# [5000, 5000, 5000]
# True

信用额度在仓库里的那个客户对象上改了一次,三张订单看到的都是新值。

将引用对象改为值对象:

  1. 检查这个类是否已经不可变,或者能否改成不可变。
  2. 把每个设值方法换成“返回一个新对象”的方法(Python 用 dataclasses.replace,TS 写 withXxx),并把调用方从“改字段”改成“换对象”。每改一处跑一次测试。
  3. 加上按内容比较的相等判断。Python 加 @dataclass(frozen=True) 后自动得到;TS 写 equals。跑测试。
  4. 去掉所有设值方法,字段设为只读。

将值对象改为引用对象:

  1. 建一个仓库,按标识存放实体对象。
  2. 确认构造这个实体的地方都能拿到仓库。
  3. 把构造调用改成从仓库获取。每改一处跑一次测试。

两种对象的区别在于“身份”。值对象没有身份,只有值:两个 TelephoneNumber("021", "6000 0000") 就是同一个号码,可以随时互换;日期、金额、坐标、颜色都是值对象。引用对象有身份:两个叫“张三”的客户可能是两个人,判断是不是同一个客户要看编号,不能看字段值;客户、订单、账户都是引用对象。

选择的原则:没有身份、可以按内容比较的东西做成不可变的值对象;有身份、数据需要被统一修改的东西,确保系统里只有一个对象,大家都引用它。

管理引用对象的仓库(Repository)来自领域驱动设计,不在 GoF 的 23 个模式里,《设计模式》书第 30 章“走出 GoF”会讲它。仓库保证“同一个编号只有一个对象”的做法,和享元模式(《设计模式》书第 15 章)在形状上相似:都是按键缓存对象、重复请求返回同一个实例。区别在于享元共享的是不可变的值,目的是省内存;仓库共享的是可变的实体,目的是保证数据只有一份。值对象和享元的关系,第 9 章、第 15 章也提到过。

Python 标准库里有大量现成的值对象。datetime.date 不可变,“加一天”返回一个新日期;Decimal 不可变,加减返回新值;两个内容相同的对象相等,可以安全地当字典的键。

"""标准库里的值对象:date 和 Decimal。"""
from datetime import date, timedelta
from decimal import Decimal
if __name__ == "__main__":
start = date(2026, 10, 7)
deadline = start + timedelta(days=30)
print(start, deadline)
print(date(2026, 10, 7) == start)
price = Decimal("0.10") + Decimal("0.20")
print(price, price == Decimal("0.30"))
holidays = {date(2026, 10, 1): "国庆"}
print(holidays[date(2026, 10, 1)])
# 输出:
# 2026-10-07 2026-11-06
# True
# 0.30 True
# 国庆

start + timedelta(days=30) 没有改 start,而是得到一个新日期,这就是值对象的“换而不改”。

值对象很大、修改又很频繁时,每次改都复制一个新对象可能有性能代价;但在绝大多数业务代码里测不出来。引用对象的仓库会让对象的生命周期变长,在长期运行的服务里要注意仓库不能无限增长。

  • 没有身份、可以按内容比较的东西做成不可变的值对象,改就是换一个新的。
  • 不可变的对象可以随便共享,不会被谁意外修改。
  • 有身份的实体在系统里只保留一个对象,通过仓库获取,改一处处处生效。
  • Python 用 @dataclass(frozen=True) 和 dataclasses.replace;TS 用 readonly 字段、withXxx 方法和自写的 equals。