1. 起因
组里一个 DTO 层几年前全是 class,最近趁着升级 .NET 8 顺手把一批数据类改成了 record,图个简洁,init 属性 + 自动生成的相等性比较,写起来确实爽。结果上线第二天就被一个诡异的缓存 bug 找上门,查了小半天才发现是 record 的”值相等”跟我以为的不太一样。这里记一下踩到的几个坑,省得以后再犯。
2. 相等性不是你以为的深比较
record 默认重写了 Equals,两个实例只要属性值都相等就 ==,这点文档写得很清楚,用起来也确实方便:
1 | public record Order(int Id, string Customer); |
问题出在属性本身是引用类型、而且这个引用类型没有重写相等性的时候。比如:
1 | public record Order(int Id, List<string> Items); |
record 生成的 Equals 对每个属性调用 EqualityComparer<T>.Default,而 List<string> 没有重写 Equals,走的还是引用比较。所以只要你的 record 里塞了 List<T>、Dictionary<K,V> 这类集合类型,”值相等”这个卖点就直接失效了,跟普通 class 没区别。当时那个缓存 bug 就是拿 record 当 key 存 ConcurrentDictionary,里面恰好带了一个 List 属性,缓存永远命中不了。
想要真正的深比较,要么把集合换成 ImmutableArray<T>(它自己实现了值相等),要么自己重写 Equals。别指望 record 帮你把这事做了。
3. with 表达式是浅拷贝
with 用起来很顺手:
1 | var b = a with { Customer = "Jerry" }; |
但它只是把所有属性复制一份到新实例,如果属性是引用类型,复制的是引用,不是内容:
1 | var a = new Order(1, new List<string> { "A" }); |
a 和 b 的 Items 指向同一个 List,改一个另一个也跟着变。这跟”record 是不可变类型”这个第一印象是矛盾的——record 保证的只是顶层属性不能重新赋值,不保证整个对象图不可变。如果真的需要不可变语义,属性类型也得选不可变的(ImmutableList<T> 之类),或者在构造函数里做防御性拷贝。
4. 继承时相等性比较会比到你没想到的地方
record 支持继承,但相等性判断里还包含了运行时类型,这个设计是对的,可一旦跟多态一起用就容易看走眼:
1 | public record Animal(string Name); |
a 声明成 Animal,但运行时类型是 Dog,record 生成的 Equals 里有一个隐藏的 EqualityContract 属性,专门用来防止父子类型之间被误判为相等。逻辑上没毛病,但如果你习惯了普通 class 手写 Equals 时那种”只比较父类字段”的思路,这里会翻车。写单元测试比较基类引用的两个对象时,多加一个心眼。
5. record class vs record struct,别选错
C# 10 加了 record struct,很多人升级的时候图省事直接全局替换 record 为 record struct,然后传参的地方性能不升反降——因为 struct 是值类型,传递时是整份拷贝,属性稍微多一点,或者传递路径长,拷贝开销比想象中大。
一个简单的判断标准:
| 场景 | 建议 |
|---|---|
| 属性少(3、4 个以内),生命周期短,频繁创建销毁 | record struct |
| DTO、领域模型、需要在多处传递引用 | record class(默认的 record 就是它) |
| 需要继承 | 只能用 record class,struct 不支持继承 |
6. 小结
record 解决的核心问题是”少写样板代码”,相等性比较和不可变性是附赠品,不是无条件保证。用之前多问自己一句:属性里有没有集合类型?会不会跨类型比较?要不要继承?想清楚这三点基本能绕开我踩过的坑。