<p>值对象必须重写Equals和GetHashCode以实现结构相等,推荐用record(C# 9+)或ValueTuple简化实现;record struct适合小对象栈分配,record class更灵活;需确保深层不可变性及序列化一致性。</p>

值对象必须重写 Equals 和 GetHashCode
默认的引用相等逻辑对值对象完全无效。比如两个 Money 实例,金额和币种都相同,但 == 或 .Equals() 返回 false,这就是没重写的典型表现。
必须显式覆盖这两个方法,且逻辑要一致:所有用于判断“值相等”的字段,都要参与 GetHashCode 计算。常见错误是只改了 Equals 忘了 GetHashCode,导致放进 Dictionary 或 HashSet 时行为异常。
- 用
record是最简方案(C# 9+):public record Money(decimal Amount, string Currency);—— 自动生成不可变、结构相等、正确哈希 - 手动实现时,推荐用
ValueTuple简化比较:return (Amount, Currency).Equals((other.Amount, other.Currency)); - 若字段含可空引用类型(如
string?),注意?.Equals()或用string.Equals(a, b, StringComparison.Ordinal)避免 NullReference
record struct 与 record class 的关键区别
两者都提供值语义,但底层行为差异直接影响性能和使用场景。
record struct 是栈分配的值类型,无 GC 开销,适合高频创建的小对象(如 Point、Range);但不支持继承、不能为 null,且装箱后失去值语义优势。
- 用
record struct时,确保总大小合理(一般 ≤ 16 字节较安全),否则复制开销反而更高 -
record class是引用类型,但通过编译器生成的Equals实现结构相等——它仍是“值对象”,只是分配在堆上 - 不要因为“值对象”就盲目选
struct:若需作为泛型约束(如T : notnull)、或要传给期望引用类型的 API,record class更稳妥
如何让值对象真正不可变
值对象的核心契约是“值不变”,但 C# 不自动保证这点。即使用了 record,如果字段是可变引用类型(如 List<int>),外部仍可修改其内部状态。
- 所有公开字段/属性必须是
init或get-only,且背后字段用readonly(record默认满足) - 避免暴露可变集合:用
IReadOnlyList<T>替代List<T>,构造时用AsReadOnly()或Array.AsReadOnly() - 若含自定义类型字段(如
Address),确保该类型本身也是不可变值对象,否则“深层可变性”会破坏值语义
序列化与 JSON 交互时的坑
值对象常被序列化传输,但默认行为可能违背预期:比如 record 的位置参数构造函数在反序列化时可能被跳过,导致字段为默认值。
- Newtonsoft.Json 需启用
preserveReferencesHandling或显式配置ContractResolver,否则嵌套值对象可能被当成新实例重复创建 - System.Text.Json(.NET 6+)对
record支持较好,但仍建议加[JsonConstructor]标记主构造函数,避免依赖隐式约定 - 若值对象含计算属性(如
public string Code => $"{Prefix}-{Id}";),默认不会序列化——需要[JsonPropertyName]或[JsonIgnore]显式控制
值对象不是语法糖,而是建模意图的声明。最容易被忽略的是“深层不可变性”和跨层(序列化、ORM、API)的一致性——只要其中一环允许突变或绕过相等逻辑,整个值语义就塌了。









