组织数据(下):引用对象 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();
    }
}
关键点:工厂方法里的switch是唯一一个可以存在的switch。它是“创建”的入口,其他地方都不应该再有基于类型码的分支。

2.3 避坑指南

我曾经犯过一个错误:把所有类型码都一股脑改成子类。结果有个类型码只是用来做报表统计的,根本不影响行为。改完之后,代码反而变得更复杂了。

所以记住:子类化是为了多态,不是为了分类。如果类型码只是用来查询或过滤,用枚举就够了。

三、知识体系总览

这一章的内容,我画了一张图帮你理清思路:

组织数据(下):核心决策树 数据对象 引用对象 值对象 需要共享、有身份标识 修改需同步所有引用 不可变、无身份标识 相等性基于所有属性 类型码 影响行为 → 子类化 | 仅标记 → 枚举

这张图的核心逻辑很简单:先判断你的数据是需要共享还是独立,再决定用引用还是值对象。至于类型码,只有影响行为时才值得大动干戈改成子类。

四、总结

这一章的内容,说白了就是三个决策:

  1. 引用 vs 值:看是否需要共享和身份标识
  2. 值改引用:当副本不同步时,引入仓库
  3. 引用改值:当对象小而简单,且不需要身份时,做成不可变
  4. 子类取代类型码:当类型码驱动行为时,用多态干掉switch

嗯,这些手法看起来简单,但用好了,代码的灵活性和可维护性会提升一大截。我每次重构完这些地方,都感觉代码“呼吸”得更顺畅了。

记住:数据组织不是一次性的工作,而是持续演进的过程。今天看起来合理的结构,明天可能就成了瓶颈。保持敏感,随时调整。


公众号:蓝海资料掘金营,微信deep3321