13、组织数据(下):将引用对象改为值对象、将值对象改为引用对象、以字面常量取代魔法数

这一章我们继续聊数据组织。上回讲了封装和自封装,这次要聊的这三个手法,说白了都是在问一个问题:这个数据,到底该是“谁”

你想想看,一个对象在系统里,是独一无二的个体,还是可以随便复制粘贴的数值?这个选择,直接影响你的代码会不会出 bug。我个人习惯在项目初期就想清楚这件事,不然等到后期改起来,那叫一个痛苦。

将引用对象改为值对象

先说说“引用对象”和“值对象”的区别。引用对象,就是有唯一标识的。比如一个用户、一个订单,每个都是独一无二的。值对象呢?它只关心“是什么”,不关心“是哪个”。比如金额、颜色、坐标。

我在项目中遇到过这样一个场景:一个货币类,里面存了金额和币种。一开始我把它设计成引用对象,每次比较两个货币对象,都得用 equals() 方法。后来发现,这玩意儿根本不需要唯一标识。100 美元就是 100 美元,不管你是哪个对象实例。

改成值对象后,代码清爽多了。直接比较属性值就行,不用管对象引用。而且值对象是不可变的,线程安全也顺带解决了。

核心判断标准: 如果两个对象相等,仅仅是因为它们的属性值相等,那就应该用值对象。

怎么改?其实很简单。把类里的 equals()hashCode() 方法实现好,然后让对象不可变。嗯,这里要注意:不可变意味着所有字段都是 final 的,而且不能提供修改方法。

// 改之前:引用对象
public class Money {
    private double amount;
    private String currency;
    // getter/setter...
}

// 改之后:值对象
public final class Money {
    private final double amount;
    private final String currency;
    
    public Money(double amount, String currency) {
        this.amount = amount;
        this.currency = currency;
    }
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Money)) return false;
        Money money = (Money) o;
        return Double.compare(money.amount, amount) == 0 &&
               currency.equals(money.currency);
    }
    
    @Override
    public int hashCode() {
        return Objects.hash(amount, currency);
    }
}
小技巧: 值对象最好提供工厂方法,比如 Money.of(100, "USD"),比直接 new 更语义化。

将值对象改为引用对象

反过来,有时候你发现某个值对象其实需要唯一标识。比如一个“客户”类,一开始你觉得只要名字和地址一样就是同一个人。后来业务告诉你:不对,同名同姓的人多了去了,得用客户 ID 来区分。

我曾经在一个电商项目里踩过这个坑。订单系统里有个“收货地址”类,我一开始把它当值对象。结果发现,同一个地址被多个订单引用时,改了一个订单的地址,其他订单也跟着变了。这显然不对。

为什么会这样?因为地址其实是有“身份”的。它属于某个用户,是那个用户的一个属性。改成引用对象后,每个地址实例都是唯一的,修改一个不影响其他。

避坑指南: 我曾经把“商品”设计成值对象,结果库存系统里同一个商品出现了多个副本,库存对不上。后来改成引用对象,用商品 ID 来标识,问题才解决。

改成引用对象,核心就是引入一个唯一标识字段。然后重写 equals()hashCode(),只比较这个标识。另外,最好用一个工厂或仓库来管理这些对象,保证同一个标识只创建一个实例。

// 改之前:值对象
public class Customer {
    private String name;
    private String phone;
    // equals 比较所有字段
}

// 改之后:引用对象
public class Customer {
    private String customerId;  // 唯一标识
    private String name;
    private String phone;
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Customer)) return false;
        Customer customer = (Customer) o;
        return customerId.equals(customer.customerId);
    }
    
    @Override
    public int hashCode() {
        return Objects.hash(customerId);
    }
}

以字面常量取代魔法数

这个手法,说白了就是:别在代码里写死数字。你想想看,一段代码里突然冒出一个 86400,谁能一眼看出这是啥?哦,是一天的秒数。但如果你写成 SECONDS_PER_DAY,是不是瞬间就懂了?

我个人习惯是:任何数字,只要不是 0、1、-1 这种显而易见的,都应该提取成常量。尤其是那些在多个地方用到的数字,改起来方便,也不容易出错。

我记得有一次接手一个老项目,里面有个 if (status == 3)。我查了半天文档,才知道 3 代表“已发货”。后来我把所有这种魔法数都改成了枚举或者常量,代码可读性直接提升一个档次。

黄金法则: 如果一个数字的含义不能从上下文中一目了然,那就给它取个名字。
// 改之前:魔法数
if (order.getStatus() == 3) {
    // 发货逻辑
}

// 改之后:常量
private static final int STATUS_SHIPPED = 3;

if (order.getStatus() == STATUS_SHIPPED) {
    // 发货逻辑
}

// 更好的方式:枚举
public enum OrderStatus {
    PENDING, PAID, SHIPPED, DELIVERED
}

if (order.getStatus() == OrderStatus.SHIPPED) {
    // 发货逻辑
}
进阶技巧: 如果魔法数有业务含义,比如折扣率、税率,建议用枚举或者专门的配置类来管理。这样改起来不用动代码,改配置文件就行。

知识体系总览

这三个手法,其实都是在回答同一个问题:这个数据,应该怎么被看待? 是独一无二的个体,还是可复制的数值,还是需要命名的常量?想清楚这个,代码的清晰度和可维护性就会好很多。

下面这张图,帮你理清这三个手法的适用场景和决策逻辑。

数据组织决策树 数据对象 需要唯一标识吗? 引用对象 特点:有唯一ID,可修改 场景:用户、订单、商品 值对象 特点:不可变,比较属性 场景:金额、颜色、坐标 还有魔法数? 提取为常量或枚举

说白了,这三个手法就是帮你理清数据的“身份”问题。引用对象有身份,值对象没身份,魔法数连名字都没有。给数据一个合适的身份,代码自然就整洁了。