1. 起因

组里一个 DTO 层几年前全是 class,最近趁着升级 .NET 8 顺手把一批数据类改成了 record,图个简洁,init 属性 + 自动生成的相等性比较,写起来确实爽。结果上线第二天就被一个诡异的缓存 bug 找上门,查了小半天才发现是 record 的”值相等”跟我以为的不太一样。这里记一下踩到的几个坑,省得以后再犯。

2. 相等性不是你以为的深比较

record 默认重写了 Equals,两个实例只要属性值都相等就 ==,这点文档写得很清楚,用起来也确实方便:

1
2
3
4
5
6
public record Order(int Id, string Customer);

var a = new Order(1, "Tom");
var b = new Order(1, "Tom");

Console.WriteLine(a == b); // True

问题出在属性本身是引用类型、而且这个引用类型没有重写相等性的时候。比如:

1
2
3
4
5
6
public record Order(int Id, List<string> Items);

var a = new Order(1, new List<string> { "A" });
var b = new Order(1, new List<string> { "A" });

Console.WriteLine(a == b); // False,坑就在这

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
2
3
4
5
var a = new Order(1, new List<string> { "A" });
var b = a with { Id = 2 };

b.Items.Add("B");
Console.WriteLine(a.Items.Count); // 2,a 也被改了

abItems 指向同一个 List,改一个另一个也跟着变。这跟”record 是不可变类型”这个第一印象是矛盾的——record 保证的只是顶层属性不能重新赋值,不保证整个对象图不可变。如果真的需要不可变语义,属性类型也得选不可变的(ImmutableList<T> 之类),或者在构造函数里做防御性拷贝。

4. 继承时相等性比较会比到你没想到的地方

record 支持继承,但相等性判断里还包含了运行时类型,这个设计是对的,可一旦跟多态一起用就容易看走眼:

1
2
3
4
5
6
7
public record Animal(string Name);
public record Dog(string Name, string Breed) : Animal(Name);

Animal a = new Dog("Rex", "Husky");
Animal b = new Animal("Rex");

Console.WriteLine(a == b); // False,即使拿 Animal 类型的变量去比

a 声明成 Animal,但运行时类型是 Dogrecord 生成的 Equals 里有一个隐藏的 EqualityContract 属性,专门用来防止父子类型之间被误判为相等。逻辑上没毛病,但如果你习惯了普通 class 手写 Equals 时那种”只比较父类字段”的思路,这里会翻车。写单元测试比较基类引用的两个对象时,多加一个心眼。

5. record class vs record struct,别选错

C# 10 加了 record struct,很多人升级的时候图省事直接全局替换 recordrecord struct,然后传参的地方性能不升反降——因为 struct 是值类型,传递时是整份拷贝,属性稍微多一点,或者传递路径长,拷贝开销比想象中大。

一个简单的判断标准:

场景 建议
属性少(3、4 个以内),生命周期短,频繁创建销毁 record struct
DTO、领域模型、需要在多处传递引用 record class(默认的 record 就是它)
需要继承 只能用 record class,struct 不支持继承

6. 小结

record 解决的核心问题是”少写样板代码”,相等性比较和不可变性是附赠品,不是无条件保证。用之前多问自己一句:属性里有没有集合类型?会不会跨类型比较?要不要继承?想清楚这三点基本能绕开我踩过的坑。