组织数据(下):引用对象 vs 值对象,以及类型码的终结
好,咱们接着聊数据组织。上一章我们讲了数据结构的拆分和封装,这一章要聊的,是三个非常实际、也容易让人纠结的问题:什么时候该用引用对象?什么时候该用值对象?还有那个老掉牙的“类型码”,到底怎么彻底干掉它?
这三个问题,说白了就是一件事:你的数据,到底该怎么“认亲”。是各自独立、互不相干?还是彼此关联、共享同一份?
一、引用对象 vs 值对象:一个关于“身份”的选择
先讲个我自己的经历。几年前我重构一个订单系统,里面有个“客户”对象。一开始,每个订单都自己new一个客户,里面填上客户ID、姓名、地址。结果呢?同一个客户张三,在系统里可能有几十个“副本”。改地址的时候,得遍历所有订单,一个一个改。漏一个就出大问题。
这就是典型的值对象用法——每个对象都是独立的,你改你的,不影响我。但在这个场景下,它错了。客户应该是一个引用对象,所有订单共享同一个客户实例。
那怎么选?我总结了一个简单的判断标准:
| 特征 | 值对象 | 引用对象 |
|---|---|---|
| 核心问题 | “它是什么?” | “它是谁?” |
| 相等性 | 所有属性相等即相等 | 同一个ID即相等 |
| 可变性 | 通常不可变 | 可变,但需同步 |
| 典型例子 | 金额、颜色、日期范围 | 客户、产品、部门 |
| 共享需求 | 不需要共享 | 必须共享 |
你想想看,一个“金额100元”和另一个“金额100元”,它们就是一样的,没必要区分谁是谁。但“客户ID=123”和“客户ID=456”,哪怕名字一样,也是两个人。
1.1 将引用对象改为值对象
什么时候需要把引用对象改成值对象?我遇到过一种情况:一个对象很小,而且经常被创建和销毁,但它的“身份”其实不重要。比如一个“货币”对象,你只需要知道它是“USD”还是“CNY”,不需要跟踪它是从哪个数据库里来的。
重构手法很简单:
- 去掉唯一标识符(ID)
- 实现equals()和hashCode()方法,基于所有字段判断相等
- 考虑做成不可变对象(所有字段final)
// 重构前:引用对象风格
public class Currency {
private String code;
private long id; // 这个id其实没用
public boolean equals(Object o) {
return this.id == ((Currency)o).id;
}
}
// 重构后:值对象风格
public final class Currency {
private final String code;
public Currency(String code) {
this.code = code;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Currency)) return false;
return code.equals(((Currency) o).code);
}
@Override
public int hashCode() {
return code.hashCode();
}
}
1.2 将值对象改为引用对象
反过来,如果你发现同一个“东西”在内存里有多个副本,而且修改一个副本时其他副本不同步,那就该改成引用对象了。
我曾经重构过一个库存系统,每个订单行都自己存了一份“商品”信息。结果商品价格变了,已下单但未发货的订单还是老价格。财务对账的时候,那叫一个惨烈。
改成引用对象的核心步骤:
- 引入一个对象池或仓库(Repository)
- 确保同一个ID只创建一个对象实例
- 修改所有创建点,改为从仓库获取
// 重构前:每个订单行自己new一个Product
OrderLine line = new OrderLine(new Product("P001", "笔记本", 10.0));
// 重构后:从仓库获取共享实例
Product product = productRepository.getById("P001");
OrderLine line = new OrderLine(product);
二、以子类取代类型码:干掉那个switch
类型码,说白了就是用数字或字符串来表示“种类”。比如:
int employeeType = 1; // 1=工程师, 2=经理, 3=销售
这种写法,我见过太多太多。一开始觉得挺方便,后来就变成噩梦。为什么?因为每个用到类型码的地方,都可能出现switch或if-else。加一个新类型,就得改所有switch。漏一个就出bug。
我的建议是:只要类型码影响行为,就毫不犹豫地用子类取代它。
2.1 什么时候用子类?
不是所有类型码都值得改成子类。我有个简单的判断标准:
- 类型码影响对象的行为(比如不同员工计算薪资的方式不同)→ 用子类
- 类型码只是数据标记(比如性别、状态)→ 用枚举或常量
说白了,如果类型码后面跟着一堆if-else,那就该重构了。
2.2 重构步骤
以员工类型为例,我们一步步来:
// 重构前:类型码 + switch
public class Employee {
private int type;
public double calculatePay() {
switch (type) {
case ENGINEER: return ...;
case MANAGER: return ...;
case SALES: return ...;
}
}
}
第一步:把类型码定义为常量或枚举
public enum EmployeeType {
ENGINEER, MANAGER, SALES
}
第二步:为每种类型创建子类
public class Engineer extends Employee {
@Override
public double calculatePay() {
// 工程师的薪资计算逻辑
}
}
public class Manager extends Employee {
@Override
public double calculatePay() {
// 经理的薪资计算逻辑
}
}
第三步:用工厂方法取代构造函数
public static Employee create(EmployeeType type) {
switch (type) {
case ENGINEER: return new Engineer();
case MANAGER: return new Manager();
case SALES: return new Sales();
}
}
2.3 避坑指南
我曾经犯过一个错误:把所有类型码都一股脑改成子类。结果有个类型码只是用来做报表统计的,根本不影响行为。改完之后,代码反而变得更复杂了。
所以记住:子类化是为了多态,不是为了分类。如果类型码只是用来查询或过滤,用枚举就够了。
三、知识体系总览
这一章的内容,我画了一张图帮你理清思路:
这张图的核心逻辑很简单:先判断你的数据是需要共享还是独立,再决定用引用还是值对象。至于类型码,只有影响行为时才值得大动干戈改成子类。
四、总结
这一章的内容,说白了就是三个决策:
- 引用 vs 值:看是否需要共享和身份标识
- 值改引用:当副本不同步时,引入仓库
- 引用改值:当对象小而简单,且不需要身份时,做成不可变
- 子类取代类型码:当类型码驱动行为时,用多态干掉switch
嗯,这些手法看起来简单,但用好了,代码的灵活性和可维护性会提升一大截。我每次重构完这些地方,都感觉代码“呼吸”得更顺畅了。
记住:数据组织不是一次性的工作,而是持续演进的过程。今天看起来合理的结构,明天可能就成了瓶颈。保持敏感,随时调整。