C# 从 9.0 开始引入 record 类型,它并不是简单增加一个新关键字,而是把不可变数据模型所需的样板代码沉淀到编译器中。对于需要表达数据快照、DTO、事件消息等场景,record 能显著减少属性声明、构造函数、相等性比较等重复代码。

理解 record 的关键在于它默认采用值语义:两个 record 实例只要属性值相同就会被认为相等,而不是比较它们在内存中的引用地址。同时,record 的属性默认通过 init 访问器暴露,在完成初始化后不能随意修改,从而让对象状态更加稳定。下面从声明方式、复制机制、值相等和实际使用几个方面展开。
一、record 类型的声明与构造函数
最简洁的 record 声明方式是位置记录。只需要在 record 名称后的小括号中列出属性参数,编译器就会自动生成主构造函数、公开属性、Equals、GetHashCode、ToString 以及 Deconstruct 方法。例如下面一行代码就完成了一个包含两个不可变属性的人员模型。
public record Person(string FirstName, string LastName);
上面的代码等价于声明了一个带有 FirstName 和 LastName 两个只读属性的 record。编译器生成的主构造函数会把参数分别赋值给同名属性,同时生成基于属性值的相等性比较和格式化输出。调用方可以直接使用 new Person("San", "Zhang") 创建实例,也可以通过对象初始化器补充赋值。
如果需要自定义属性默认值或增加派生属性,也可以使用普通属性语法配合 init 访问器。init 允许在对象初始化器或 with 表达式中赋值,但在初始化完成后不允许修改。下面是自定义订单模型的写法。
public record Order
{
public string Id { get; init; }
public decimal Amount { get; init; }
public string Currency { get; init; } = "CNY";
}
这种写法与 class 很像,但编译器仍会按 record 的值语义生成相等性比较和格式化输出。要注意 init 访问器只保护属性本身不可变,如果属性类型是 List 或其他引用类型,仍然可以修改其内部内容。要构建真正不可变的数据模型,应优先使用只读集合或不可变类型作为属性类型。
二、with 表达式如何复制并修改 record
不可变对象的一个常见问题是更新某个字段时必须创建新对象。record 提供了 with 表达式,让复制和局部修改变得非常直观。with 会调用编译器生成的复制构造函数,再执行对象初始化器中的赋值,因此原对象不会被改变。
var original = new Person("San", "Zhang");
var updated = original with { LastName = "Wang" };
Console.WriteLine(original);
Console.WriteLine(updated);
这里 updated 与 original 是两个独立实例,FirstName 相同,LastName 不同。with 表达式不是逐个字段手写复制,而是由编译器生成的 protected copy constructor 以及初始化器完成。它可以只写变化的部分,其他字段自动沿用原值,因此在包含大量属性的 record 中尤其方便。
需要注意 with 执行的是浅拷贝。如果 record 中某个属性是数组、List 或自定义引用类型,新对象和旧对象会共享同一个引用。例如 Address 属性指向同一个对象,修改 updated.Address.City 也会影响 original.Address.City。若要深拷贝,需要显式处理该属性的复制逻辑,或让属性类型本身不可变。
三、值相等与 record 继承规则
record 的默认相等性比较基于属性值而不是引用。C# 编译器会为 record 生成 Equals 和 GetHashCode,覆盖所有声明字段。这意味着 new Person("A", "B") 与另一个相同值的实例做比较时结果相等。
var p1 = new Person("A", "B");
var p2 = new Person("A", "B");
Console.WriteLine(p1 == p2);
Console.WriteLine(p1.Equals(p2));
record 也可以继承自另一个 record。派生 record 会继承基类属性,同时编译器会生成 EqualityContract 来保证比较时运行时类型一致。如果基类实例和派生类实例属性相同,Equals 仍返回 false,因为它们是不同的 record 类型。
public record Person(string FirstName, string LastName);
public record Student(string FirstName, string LastName, int Grade)
: Person(FirstName, LastName);
位置记录的继承语法需要将参数传递给基类主构造函数。在上面的示例中 Student 同时包含三个属性。值比较会依次比较 FirstName、LastName 和 Grade,并检查 EqualityContract 是否匹配。这样继承体系下的相等性判断既考虑数据,也考虑具体运行时类型。
如果只想让类型拥有值语义但不需要引用类型分配,可以使用 C# 10 引入的 record struct。它也具有值相等和 with 表达式,但属于值类型,不支持继承。选择 record class 还是 record struct 主要看数据模型是否需要多态和引用语义。
四、record 在数据模型中的实践建议
record 非常适合作为 API 的请求响应 DTO、领域事件、配置快照和消息体。由于不可变,多线程读取时不需要加锁;由于值相等,可以用于断言和缓存键。配合 with 表达式,处理更新场景也很自然。下面是一个典型的接口响应模型。
public record ApiResponse(int Code, string Message, string TraceId);
在 System.Text.Json 中,record 可以正常序列化和反序列化。反序列化时,JSON 属性名需要与构造函数参数名匹配,且参数名不区分大小写。如果构造函数参数与 JSON 名称不一致,可以使用 JsonConstructor 或 JsonPropertyName 标注。这样可以避免因为不可变特性导致反序列化失败。
与 EF Core 协作时需要谨慎。数据库实体通常需要可变状态,以便跟踪更改,因此 record 不一定适合作为实体类型。不过对于无跟踪查询、临时投影或只读统计结果,record 可以简化查询结果的承载。用 EF Core 的 AsNoTracking 和 Select 投影到 record,能获得清晰不可变的读取模型。
在面向并发的场景中,record 提供的不可变默认行为可以减少共享状态的风险。但它不是银弹:引用类型属性仍可能引入间接修改。要充分利用不可变数据模型的优势,应把 record 设计成只包含基础类型、不可变集合或其他 record 类型的组合,这样才能让复制、比较和并发行为保持一致。
C# Record类型不可变数据模型with表达式修改时间:2026-08-27 07:25:50