深入理解 C#.NET record:不可变对象与值语义的现代实践

陆景行
陆景行
发布时间:2026-08-23 | 43人浏览过
原创

前言

很多 C# 项目已经开始把 `record` 当作 DTO、配置对象甚至领域模型的默认选项,但它和传统 `class` 的区别并不只是写法更短。真正需要理解的是:`record` 改变了对象的相等语义、复制方式和不可变设计习惯;看懂这些差异,你才能判断它该用在数据模型、值对象,还是该继续保留 `class`/`struct`。

很多 C# 开发者第一次接触 record,会把它理解成“语法更短的 class”。但它真正改变的并不只是声明方式,而是对象相等性、复制方式和数据模型的设计习惯。本文按“它是什么、解决什么问题、什么时候该用”的顺序展开,结合 C# 9 与 C# 10+ 的能力,把 record、record struct 以及与 class/struct 的差异一次讲清。

record 到底是什么

record 是 C# 9 引入的新型引用类型(Reference Type),主要面向数据导向(Data-Oriented)的对象建模。它特别适合不可变的数据传输对象(DTO)、值对象,以及强调“内容是否相同”而不是“是不是同一个实例”的领域模型。

它的几个核心特征可以先记住:

  • 内置值相等性:两个 record 实例只要属性值相同,就会被视为相等。
  • 语法更紧凑:可通过主构造函数直接声明属性。
  • 天然适合不可变设计:通常与 init 搭配使用。
  • 对模式匹配友好:with、解构和模式匹配配合自然。

基本语法:为什么它比 class 更像“数据模型”

简单声明

public record Person(string FirstName, string LastName);

这段代码里,record 关键字定义的是一个不可变引用类型,而 (string FirstName, string LastName) 不只是构造参数,也会生成对应的自动 init 属性。

它大致等价于下面这种传统写法:

public class Person
{
    public string FirstName { get; init; }
    public string LastName { get; init; }

    public Person(string firstName, string lastName)
        => (FirstName, LastName) = (firstName, lastName);

    // 自动生成值相等比较、Deconstruct 方法等
}

基础使用方式如下:

var p1 = new Person("Alice", "Smith");
Console.WriteLine(p1.FirstName); // Alice

如果你的对象本质上只是“携带一组数据”,这种写法通常比手动写属性、构造函数和比较逻辑更直接。

不可变对象怎么写:init-only 与 with 的配合

init-only 为什么适合 record

record 最常见的使用方式就是不可变设计。属性通常采用 init-only,也就是只能在初始化阶段赋值,实例创建后不能再随意改动。

public record Person(string FirstName, string LastName, int Age);

// 创建 record 实例
var person = new Person("John", "Doe", 30);

// 编译错误:不能修改属性
// person.Age = 31;

这种约束的价值在于:对象状态更稳定,传递时更容易推断,也更适合并发和函数式风格的数据处理。

with 表达式如何做非破坏性复制

不可变对象不能原地修改,因此 record 提供了 with 表达式来复制对象并修改局部字段。

var p1 = new Person("Alice", "Smith");
var p2 = p1 with { LastName = "Johnson" };

Console.WriteLine(p2.FirstName); // Alice
Console.WriteLine(p2.LastName);  // Johnson
  • p1 保持不变。
  • p2 是一个新实例。

这正是不可变对象最常见的更新方式:保留原值,生成新值,避免隐式副作用。

record 最关键的变化:值相等而不是引用相等

class、struct、record 的默认相等性区别

类型相等比较(默认)
class引用相等(Reference Equality)
struct值相等(字段逐一比较)
record值相等(自动生成 Equals/==)

这意味着 record 虽然默认仍是引用类型,但比较行为更像“值对象”。

展示 C# record 的核心语义,包括不可变、值相等、with 复制和解构能力的白底信息图
record 的四个核心语义用一张图快速看懂 `record` 与普通 `class` 在对象语义上的区别。
var p1 = new Person("Alice", "Smith");
var p2 = new Person("Alice", "Smith");
Console.WriteLine(p1 == p2);      // True
Console.WriteLine(p1.Equals(p2)); // True

即使 p1 和 p2 是不同实例,只要属性值相同,它们就相等。对于 DTO、消息体、查询结果这类对象,这种行为通常比引用相等更符合直觉。

编译器会为 record 自动生成什么

编译器会为 record 自动补齐一批围绕值语义设计的成员:

  • Equals(object?) 和 GetHashCode():基于属性值计算。
  • == 和 != 运算符。
  • Deconstruct 方法。
  • ToString():输出形如 Person { FirstName = 具体值, LastName = 具体值 }。

这也是为什么 record 很适合作为 DTO、查询结果和配置数据模型:常见样板代码几乎都由编译器接管了。

解构、模式匹配与继承:record 的配套能力

解构可以直接使用

编译器会自动生成 Deconstruct 方法,因此位置记录可以直接按元组风格拆开。

var person = new Person("Alice", "Smith");
var (first, last) = person;
Console.WriteLine($"{first} {last}");

同样的方式也适用于其他位置记录:

public record Order(int Id, decimal Price);
var order = new Order(1001, 99.99m);
var (id, price) = order; // 自动解构

为什么它特别适合模式匹配

record 非常适合和属性模式、类型模式一起使用:

if (person is Person { FirstName: "Alice" })
    Console.WriteLine("Found Alice!");

如果再结合继承层次,它会更接近代数数据类型的写法。

public abstract record Shape;
public record Circle(double Radius) : Shape;
public record Rectangle(double Width, double Height) : Shape;
public record Triangle(double Base, double Height) : Shape;

public static double CalculateArea(Shape shape)
{
    return shape switch
    {
        Circle c => Math.PI * c.Radius * c.Radius,
        Rectangle r => r.Width * r.Height,
        Triangle t => 0.5 * t.Base * t.Height,
        _ => throw new ArgumentException("Unknown shape")
    };
}

// 使用模式匹配
var circle = new Circle(5);
var rectangle = new Rectangle(4, 6);

Console.WriteLine(CalculateArea(circle)); // 78.53981633974483
Console.WriteLine(CalculateArea(rectangle)); // 24

record 的继承与多态要注意什么

record 支持继承,而且会保留值相等语义:

public record Person(string Name);
public record Student(string Name, int Grade) : Person(Name);

Person p = new Student("Alice", 5);
Console.WriteLine(p is Student); // True

不过它的 Equals 会带上类型检查,因此父类和子类即使属性值相同,也不会因为“值相同”就被判定为相等。这个规则很重要,因为它能避免继承体系下出现语义混乱的比较结果。

C# 10+ 的 record struct:把值语义带到值类型

C# 10 引入了 record struct,它把 record 的值相等语义和 struct 的值类型特性结合到一起。

// Record struct(值类型)
public record struct Point(int X, int Y);

// 使用 record struct
var point1 = new Point(3, 4);
var point2 = new Point(3, 4);

Console.WriteLine(point1 == point2); // True
Console.WriteLine(point1); // Point { X = 3, Y = 4 }

// 修改 record struct(因为是值类型,所以可以修改)
point1.X = 5;
Console.WriteLine(point1); // Point { X = 5, Y = 4 }

// 只读 record struct
public readonly record struct ImmutablePoint(int X, int Y);

var immutablePoint = new ImmutablePoint(3, 4);
// immutablePoint.X = 5; // 编译错误:不能修改只读字段

这里要区分两个点:

  • record 默认是引用类型。
  • record struct 是值类型,可变与否取决于你是否使用 readonly 等约束。

怎么在 class、struct、record、record struct 之间做选择

特性classstructrecord(class)record struct
类型引用类型值类型引用类型值类型
相等性引用相等值相等值相等值相等
默认不可变❌(可变)❌(可变)✅(推荐 init)✅(推荐 readonly)
内存分配堆栈/堆堆栈/堆
继承支持仅接口支持仅接口
with 表达式❌❌✅✅

不使用主构造函数也可以写 record

如果你不想用位置记录,也可以像普通 class 一样显式声明属性:

public record Car
{
    public string Brand { get; init; }
    public string Model { get; init; }
}

这种写法的关键区别不在语法,而在于它仍然保留了 record 自动生成的值相等比较能力。

实际应用场景:哪些模型最适合用 record

DTO 与 API 数据模型

对于 API 请求、响应、查询结果这类“主要负责携带数据”的对象,record 往往比传统 class 更贴切。

// API 响应 DTO
public record ApiResponse<T>(bool Success, string Message, T Data);

// API 请求 DTO
public record CreateUserRequest(string Username, string Email, string Password);

// 使用 record DTO
public class UserController : ControllerBase
{
    [HttpPost]
    public IActionResult CreateUser(CreateUserRequest request)
    {
        // 处理请求...
        var response = new ApiResponse<User>(true, "User created successfully", user);
        return Ok(response);
    }
}

这类模型通常不强调身份,而强调“字段内容是否一致”,因此与值语义天然契合。

配置对象

配置对象也是 record 的常见落点,因为配置天然更偏向初始化后只读。

// 应用程序配置
public record AppSettings
{
    public string ConnectionString { get; init; }
    public int MaxRetryAttempts { get; init; } = 3;
    public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30);
    public LoggingSettings Logging { get; init; }
}

public record LoggingSettings
{
    public string Level { get; init; } = "Information";
    public string FilePath { get; init; } = "logs/app.log";
}

// 使用配置
var settings = new AppSettings
{
    ConnectionString = "Server=localhost;Database=MyDb",
    MaxRetryAttempts = 5,
    Logging = new LoggingSettings { Level = "Debug" }
};

推荐与不推荐的边界

推荐使用 record 的场景:

  • 不可变的数据模型:DTO、API 响应对象。
  • 配置项、设置类。
  • 数据库查询结果(EF Core 中很常见)。
  • 事件/消息模型(Event Sourcing、CQRS)。

不推荐使用 record 的场景:

  • 对象需要频繁修改。
  • 需要严格的引用语义,例如缓存中的唯一实例。

性能与使用注意事项

record 并不意味着零成本,实际使用时有几件事要提前判断:

  • record 默认仍是引用类型(除非是 record struct),因此仍会在堆上分配,存在 GC 开销。
  • 值比较会带来额外的 Equals/GetHashCode 计算成本。
  • with 表达式会创建新对象,在高频更新场景中不宜滥用。

简单说,record 优先解决的是建模表达力和语义正确性,而不是无条件追求运行时性能最优。

总结:把 record 用在“按值看待对象”的地方

特性说明
核心目标简化不可变数据类型的声明和比较
类型引用类型(默认)/值类型(record struct)
相等性自动实现基于值的相等性(Equals、==)
不可变性推荐使用 init 或 readonly
复制with 表达式实现非破坏性复制
解构自动生成 Deconstruct,支持元组解构
典型应用DTO、API 数据模型、配置对象、事件/消息对象

如果一个对象更像“数据本身”,你通常应该优先考虑 record;如果它更强调身份、生命周期或可变行为,传统 class 往往仍然更合适。把这个判断标准抓住,record 的使用边界就会清晰很多。

相关标签:

本站声明

本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。

Python 相关产品和应用

全部下载

专题列表