将引用对象改为值对象 / 将值对象改为引用对象
将引用对象改为值对象(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信用额度在仓库里的那个客户对象上改了一次,三张订单看到的都是新值。
怎么一步步做
Section titled “怎么一步步做”将引用对象改为值对象:
- 检查这个类是否已经不可变,或者能否改成不可变。
- 把每个设值方法换成“返回一个新对象”的方法(Python 用
dataclasses.replace,TS 写withXxx),并把调用方从“改字段”改成“换对象”。每改一处跑一次测试。 - 加上按内容比较的相等判断。Python 加
@dataclass(frozen=True)后自动得到;TS 写equals。跑测试。 - 去掉所有设值方法,字段设为只读。
将值对象改为引用对象:
- 建一个仓库,按标识存放实体对象。
- 确认构造这个实体的地方都能拿到仓库。
- 把构造调用改成从仓库获取。每改一处跑一次测试。
定义与适用场景
Section titled “定义与适用场景”两种对象的区别在于“身份”。值对象没有身份,只有值:两个 TelephoneNumber("021", "6000 0000") 就是同一个号码,可以随时互换;日期、金额、坐标、颜色都是值对象。引用对象有身份:两个叫“张三”的客户可能是两个人,判断是不是同一个客户要看编号,不能看字段值;客户、订单、账户都是引用对象。
选择的原则:没有身份、可以按内容比较的东西做成不可变的值对象;有身份、数据需要被统一修改的东西,确保系统里只有一个对象,大家都引用它。
相关的设计模式
Section titled “相关的设计模式”管理引用对象的仓库(Repository)来自领域驱动设计,不在 GoF 的 23 个模式里,《设计模式》书第 30 章“走出 GoF”会讲它。仓库保证“同一个编号只有一个对象”的做法,和享元模式(《设计模式》书第 15 章)在形状上相似:都是按键缓存对象、重复请求返回同一个实例。区别在于享元共享的是不可变的值,目的是省内存;仓库共享的是可变的实体,目的是保证数据只有一份。值对象和享元的关系,第 9 章、第 15 章也提到过。
真实系统里的例子
Section titled “真实系统里的例子”标准库里的值对象
Section titled “标准库里的值对象”Python 标准库里有大量现成的值对象。datetime.date 不可变,“加一天”返回一个新日期;Decimal 不可变,加减返回新值;两个内容相同的对象相等,可以安全地当字典的键。
"""标准库里的值对象:date 和 Decimal。"""
from datetime import date, timedeltafrom 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,而是得到一个新日期,这就是值对象的“换而不改”。
什么时候别用
Section titled “什么时候别用”值对象很大、修改又很频繁时,每次改都复制一个新对象可能有性能代价;但在绝大多数业务代码里测不出来。引用对象的仓库会让对象的生命周期变长,在长期运行的服务里要注意仓库不能无限增长。
- 没有身份、可以按内容比较的东西做成不可变的值对象,改就是换一个新的。
- 不可变的对象可以随便共享,不会被谁意外修改。
- 有身份的实体在系统里只保留一个对象,通过仓库获取,改一处处处生效。
- Python 用
@dataclass(frozen=True)和dataclasses.replace;TS 用readonly字段、withXxx方法和自写的equals。