设计模式 01:订单身份和金额值为什么不能混为一谈
把金额 10.00 CNY 作为报价索引的键,保存后又在同一个对象上改成 11.00 CNY。此时“持有同一个对象引用”并不能保证还能从 HashMap 找回报价:Before.MutableAmount 的 hashCode 取决于可改的金额。实验先断言存入后能查到,再改金额,最后断言当前输入和 JDK 下查询结果是 null。这不是 HashMap 必然在所有键修改后都查不到的定律,而是说明键入表后的散列语义不再稳定。
第 00 篇守住了旧报价 50.00 × 2 = 100.00,并把会员新折扣另列断言。这里不改变报价契约:Before.total、After.total 和不建金额类的 Alternative.total 对同一 OrderLine 运行同一组参数化断言,三次均得到 100.00。新需求是金额要作为稳定的值传给多个协作者,同时订单改价不能改变订单的身份。
值相等不是引用相同
教学工程在 order-core 新增了 Money:一个 record,包含十进制金额和币种代码。构造时以 RoundingMode.UNNECESSARY 将金额归一到两位小数:10.0 可以无损成为 10.00,10.001 则被拒绝;不能偷偷替调用者决定如何舍入。非负金额与 Java Currency 能识别的币种代码也是本例输入约束,不是所有金融业务都应一律保留两位小数的主张。
1 | |
BigDecimal 自身的 equals 比较数值和 scale,2.0 不等于 2.00;Java SE 21 官方 Javadoc 的 equals 段落明确给出这个例子。所以仅把 BigDecimal 塞进 record,并不会自动实现本例期待的金额值相等;归一化是有具体需求支撑的设计选择。Money(10.0, CNY) 与 Money(10.00, CNY) 在构造后相等,放入 HashMap 再用另一个等值实例查找成功。加上 1.00 CNY 返回新值 11.00 CNY,原 10.00 CNY 及其键保持不变。
币种也是值的一部分。同样数字的 CNY 与 USD 不相等,Money.plus 在两者相异时拒绝相加。对象协作不是“把两个数字加起来”就结束:调用方传入 Money,Money 检查自身币种与参数币种,然后返回新值;调用方保存结果而非修改旧别名。实验还用 Alternative.sum(left, leftCurrency, right, rightCurrency) 证明:若只有一处加法,直接传金额与币种并检查也能满足契约。封装成对象的收益来自多处调用共享相同约束,不来自类名本身。
实体改价,身份不换
订单的 total 可以在报价修订后改变,但同一订单编号 o-01 不应因为金额变化就变成另一张订单。教学 Order 用不可改的 id 定义 equals/hashCode,让改价保持身份;reprice 先检查币种,再替换保存的 Money。同 ID 的两个 Java 对象在本例代表同一实体,不同 ID 即使金额相同也不是同一个订单。这一选择假定编号在讨论范围内唯一;多个租户使用相同局部编号时,要先确定身份作用域,不能机械套用这里的 equals。
| 问题 | Before 可变金额 |
Money 值 / Order 实体 |
简单替代 |
|---|---|---|---|
原报价 50.00 × 2 |
100.00 |
100.00 |
100.00 |
| 相等性 | 金额被改后键失稳 | 金额按归一化值和币种相等;订单按 ID 相等 | 直接传数字和币种,调用方显式检查 |
| 混币种与非法精度 | 未封装约束 | 跨币种相加拒绝;多于两位且需丢精度的输入拒绝 | 一处加法可直接检查,但不能遗漏参数 |
record 适合此处的值表示,不是看到 record 就能断定整个对象图不可变:组件若是可变集合,仍需防止共享引用。订单可以含可变状态,但用可变状态参与实体的散列键会重现本篇反例。类型的名字、private 可见性和 equals 的生成形式都不能替代对状态生命周期的检查。
运行结果与练习
2026-10-02 在 Ubuntu OpenJDK 21.0.12.1、Maven 3.9.9 下执行 ./mvnw -B -ntp verify,退出码 0;01 报告 Tests run: 7, Failures: 0, Errors: 0, Skipped: 0,00 回归另有 5 次通过。输入、原始输出、边界与判定保存在 examples/design-patterns/evidence/01/。这些是选定输入的验证,不是关于所有币种精度或所有 HashMap 实现的保证。
- 新需求允许
10.005 CNY按HALF_UP入账为10.01 CNY。先为新的舍入规则写断言,再说明要改哪些构造入口与调用方;检查 00 的报价合同是否仍成立,不把“允许舍入”偷换成“每步随意舍入”。 - 若两个租户都有
o-01,当前Order.equals会怎样?先写出两个对象的相等性断言,再提出最小的复合身份,并检查将其作为HashMap键时改价前后查找是否稳定。
参考与系列导航
- Java SE 21:BigDecimal.equals:数值和 scale 均影响相等性。
- Java SE 21:Record:record 组件相等与复制约定;本文仍单独核查组件的可变性。
- 00 导读 ← 01 对象、类与值(本篇) → 02 封装与信息隐藏。






