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 来区分。
我曾经在一个电商项目里踩过这个坑。订单系统里有个“收货地址”类,我一开始把它当值对象。结果发现,同一个地址被多个订单引用时,改了一个订单的地址,其他订单也跟着变了。这显然不对。
为什么会这样?因为地址其实是有“身份”的。它属于某个用户,是那个用户的一个属性。改成引用对象后,每个地址实例都是唯一的,修改一个不影响其他。
改成引用对象,核心就是引入一个唯一标识字段。然后重写 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) {
// 发货逻辑
}
知识体系总览
这三个手法,其实都是在回答同一个问题:这个数据,应该怎么被看待? 是独一无二的个体,还是可复制的数值,还是需要命名的常量?想清楚这个,代码的清晰度和可维护性就会好很多。
下面这张图,帮你理清这三个手法的适用场景和决策逻辑。
说白了,这三个手法就是帮你理清数据的“身份”问题。引用对象有身份,值对象没身份,魔法数连名字都没有。给数据一个合适的身份,代码自然就整洁了。