C# 9.0 Record类型实战:5个真实场景教你如何替代传统类

📅 发布时间:2026/7/8 1:41:44 👁️ 浏览次数:
C# 9.0 Record类型实战:5个真实场景教你如何替代传统类
C# 9.0 Record类型实战5个真实场景教你如何替代传统类如果你是一位C#开发者最近在维护一个遗留的订单系统或者正在设计一套新的微服务API大概率会和我一样对项目中那些充斥着样板代码的“数据传输对象”或“值对象”感到头疼。它们通常有着一长串的属性定义、一个手动编写的构造函数、以及为了比较相等性而重写的Equals和GetHashCode方法。更不用说为了调试方便还得重写ToString。这些代码本身并不复杂但重复、冗长且极易出错——比如当你新增一个属性时很容易忘记更新Equals方法。C# 9.0 引入的record类型正是为了解决这类痛点而生的。它远不止是一个语法糖而是一种全新的、用于建模数据的语义。对于已经熟悉C#基础语法的中级开发者而言理解record的核心优势并能在实际项目中熟练应用是提升代码质量、减少潜在Bug的关键一步。本文将从五个真实的开发场景出发通过具体的代码对比带你看看record如何优雅地替代那些传统的类让你的代码库变得更简洁、更安全、更具表达力。1. 场景一订单系统中的不可变值对象在电商或ERP系统中订单的核心信息如订单号、创建时间、总金额一旦生成通常就不应被修改。传统的做法是定义一个类将所有属性设置为只读init访问器或私有set并手动实现值相等性比较。传统类的实现方式public class Order { public string OrderId { get; } public DateTime CreatedTime { get; } public decimal TotalAmount { get; } public string CustomerName { get; } public Order(string orderId, DateTime createdTime, decimal totalAmount, string customerName) { OrderId orderId; CreatedTime createdTime; TotalAmount totalAmount; CustomerName customerName; } // 必须重写 Equals 和 GetHashCode 以实现基于值的比较 public override bool Equals(object? obj) { return obj is Order order OrderId order.OrderId CreatedTime order.CreatedTime TotalAmount order.TotalAmount CustomerName order.CustomerName; } public override int GetHashCode() { return HashCode.Combine(OrderId, CreatedTime, TotalAmount, CustomerName); } // 可选但建议重写 ToString 以便于调试 public override string ToString() { return $Order {{ OrderId {OrderId}, CreatedTime {CreatedTime}, TotalAmount {TotalAmount}, CustomerName {CustomerName} }}; } }这个类有近40行代码但核心数据只有4个属性。大量的代码都在处理“基础设施”构造、相等性、字符串表示。使用record重构后public record Order(string OrderId, DateTime CreatedTime, decimal TotalAmount, string CustomerName);是的就这一行。这一行声明实现的功能与上面近40行的类完全等价自动生成包含所有属性的主构造函数。所有属性都是init-only的即只能在初始化时赋值保证了不可变性。自动生成基于所有属性值的Equals、GetHashCode和ToString方法。注意record的ToString输出格式是固定的例如Order { OrderId ORD001, CreatedTime 2023-10-27 10:00:00, ... }如果你需要完全自定义的格式仍然可以手动重写ToString方法。实战对比与优势// 创建实例 var order1 new Order(ORD001, DateTime.Now, 299.99m, 张三); // order1.OrderId ORD002; // 编译错误属性是只读的。 // 基于值的相等性比较 var order2 new Order(ORD001, order1.CreatedTime, 299.99m, 张三); Console.WriteLine(order1 order2); // 输出True // 清晰的调试输出 Console.WriteLine(order1); // 输出Order { OrderId ORD001, CreatedTime 2023/10/27 10:00:00, TotalAmount 299.99, CustomerName 张三 }优势总结代码量锐减从 ~40 行到 1 行消除了样板代码。安全性提升不可变性由语言特性保证避免了意外修改。正确性内置值语义的相等性比较由编译器自动生成杜绝了手写错误。2. 场景二用户配置或API请求模型的非破坏性修改我们经常需要基于一个现有对象创建其副本并仅修改其中几个字段。例如更新用户配置或处理API的PATCH请求。对于不可变对象传统做法是编写一个“WithXxx”方法或者使用笨重的对象映射器。传统类的“With”模式繁琐且易漏public class UserSettings { public string Theme { get; init; } public int PageSize { get; init; } public bool NotificationsEnabled { get; init; } public UserSettings WithTheme(string newTheme) new UserSettings { Theme newTheme, PageSize this.PageSize, NotificationsEnabled this.NotificationsEnabled }; public UserSettings WithPageSize(int newPageSize) new UserSettings { Theme this.Theme, PageSize newPageSize, NotificationsEnabled this.NotificationsEnabled }; // ... 每个属性都需要一个对应的方法 }使用record的with表达式record原生支持with表达式可以极其优雅地实现非破坏性修改也称为“复制-修改”。public record UserSettings(string Theme, int PageSize, bool NotificationsEnabled); // 使用示例 var defaultSettings new UserSettings(Light, 20, true); // 创建一份副本仅修改 Theme 属性 var darkModeSettings defaultSettings with { Theme Dark }; Console.WriteLine(darkModeSettings); // 输出UserSettings { Theme Dark, PageSize 20, NotificationsEnabled True } // 可以同时修改多个属性 var customSettings defaultSettings with { Theme Dark, PageSize 50 };with表达式在底层创建了一个新对象复制了原对象所有字段的值然后应用你指定的修改。这个过程是类型安全的编译器会检查属性名是否正确。在API模型中的应用假设一个更新用户偏好的API端点// API 请求模型 public record UpdateUserPreferenceRequest(string? Theme, int? PageSize); // 服务层处理 public UserSettings ApplyUpdate(UserSettings current, UpdateUserPreferenceRequest request) { // 使用 with 表达式仅应用非空的更新字段 var updated current; if (request.Theme ! null) updated updated with { Theme request.Theme }; if (request.PageSize.HasValue) updated updated with { PageSize request.PageSize.Value }; return updated; }这种方式清晰、安全并且完全避免了反射或复杂的对象映射配置。3. 场景三API响应与DTO的清晰结构在Web API开发中我们经常需要定义各种响应模型Response DTO。这些模型主要用于承载数据没有行为且结构需要非常清晰。record因其简洁的声明式语法和内置的格式化输出非常适合此场景。传统类作为DTOpublic class ApiResponseT { public bool Success { get; set; } public string Message { get; set; } string.Empty; public T? Data { get; set; } } public class ProductDto { public int Id { get; set; } public string Name { get; set; } string.Empty; public decimal Price { get; set; } }使用record重构DTOpublic record ApiResponseT(bool Success, string Message, T? Data); public record ProductDto(int Id, string Name, decimal Price);进阶用法嵌套record与解构record支持解构Deconstruction这为处理复杂响应提供了便利。public record PagedListT(IEnumerableT Items, int TotalCount, int Page, int PageSize); public record ProductApiResponse : ApiResponsePagedListProductDto; // 在客户端或测试代码中可以方便地解构 var response new ProductApiResponse( true, 查询成功, new PagedListProductDto( new ListProductDto { new(1, 笔记本, 5999), new(2, 鼠标, 99) }, 2, 1, 10 ) ); if (response is { Success: true, Data: not null }) { // 解构 PagedList var (items, total, page, pageSize) response.Data; Console.WriteLine($第{page}页共{total}条记录); foreach (var product in items) { // 解构 ProductDto var (id, name, price) product; Console.WriteLine(${id}: {name} - ¥{price}); } }解构功能让数据提取变得直观减少了临时变量和冗余的属性访问代码。4. 场景四领域驱动设计DDD中的值对象在DDD中值对象Value Object是其相等性由属性值决定且通常不可变的对象。货币、地址、坐标等都是典型的例子。record的特性与值对象的定义完美契合。传统值对象实现以Money为例需要手动确保不可变性、重写相等性方法并考虑实现相应的操作符重载。使用record实现值对象public record Money(decimal Amount, string Currency) { // 可以添加验证逻辑 public Money { init { if (Amount 0) throw new ArgumentException(金额不能为负数); if (string.IsNullOrWhiteSpace(Currency)) throw new ArgumentException(货币单位不能为空); } } // 添加领域行为/方法 public Money Add(Money other) { if (Currency ! other.Currency) throw new InvalidOperationException(货币单位不一致); return this with { Amount Amount other.Amount }; } public Money Multiply(decimal factor) this with { Amount Amount * factor }; // 可以重写 ToString 以提供更友好的显示 public override string ToString() ${Currency} {Amount:F2}; } // 使用 var salary new Money(10000m, CNY); var bonus new Money(2000m, CNY); var total salary.Add(bonus); // Money { Amount 12000.00, Currency CNY } Console.WriteLine(total); // 输出CNY 12000.00 // 值相等性 var money1 new Money(100m, USD); var money2 new Money(100m, USD); Console.WriteLine(money1 money2); // True在这个例子中record提供了值对象的骨架不可变性、值相等性而我们只需专注于添加领域特定的验证和行为。这使得值对象的定义既严谨又简洁。5. 场景五模式匹配与数据解构的完美搭档C# 自7.0以来不断增强的模式匹配功能与record类型结合能产生强大的化学反应尤其是在处理树形结构数据如AST、UI组件树、复杂配置时。场景处理一个简化的UI组件树public abstract record UIComponent; public record ContainerComponent(string Id, ListUIComponent Children) : UIComponent; public record TextComponent(string Id, string Content) : UIComponent; public record ButtonComponent(string Id, string Label, Action OnClick) : UIComponent;现在我们需要一个函数来计算渲染这棵树所需的“深度”。使用record的模式匹配可以写得非常优雅public int CalculateDepth(UIComponent component) { return component switch { // 递归匹配 ContainerComponent ContainerComponent(var id, var children) 1 (children.Any() ? children.Max(CalculateDepth) : 0), // 匹配叶子组件 TextComponent or ButtonComponent 1, // 处理 null 或未知类型虽然本例中不会发生 _ 0 }; } // 或者使用属性模式进行更精细的判断 public string DescribeComponent(UIComponent component) { return component switch { // 匹配特定属性的值 ButtonComponent { Label: Submit } 这是一个提交按钮, ButtonComponent { Label: var label } $这是一个标签为{label}的按钮, TextComponent { Content: var c } when c.Length 100 这是一段长文本, TextComponent { Content: var c } $这是一段文本{c[..Math.Min(10, c.Length)]}..., ContainerComponent { Children.Count: 0 } 这是一个空容器, ContainerComponent c $这是一个包含{c.Children.Count}个子组件的容器, _ 未知组件 }; }与普通类的对比如果使用传统的类层次结构实现同样的模式匹配通常需要依赖is类型检查和类型转换代码会冗长且容易出错if (component is ContainerComponent container) { // 需要手动解构或访问属性 var children container.Children; // ... 计算逻辑 } else if (component is TextComponent text) { // ... }record的声明式语法使得编译器能更好地理解其结构从而在模式匹配中支持位置模式如ContainerComponent(var id, var children)和属性模式让代码的意图更清晰逻辑更紧凑。6. 深入理解Record与Class的核心差异与选择指南经过前面五个场景的洗礼你应该已经感受到了record的威力。但何时该用record何时该坚持用class呢下表从几个关键维度进行了对比特性维度record(引用类型)class核心语义数据载体强调值的不可变性与相等性。行为与状态的封装强调对象身份与可变状态。相等性值相等性。基于所有属性的值进行比较可通过Equals自定义。引用相等性默认。基于内存地址。需要手动重写Equals/GetHashCode以实现值相等。可变性默认可控的不可变。主构造函数参数生成init-only属性。可使用with创建新实例。默认可变。属性通常有get/set。继承支持从其他record继承但不支持从class继承。支持完整的类继承体系。ToString()自动生成格式化的输出包含类型名和所有属性名值对。默认返回类型全名。需要手动重写。解构支持通过Deconstruct方法自动解构位置记录。需要手动实现Deconstruct方法。性能考量由于不可变性和值语义在作为字典键或集合元素时表现更佳、更安全。可变对象作为键存在风险。手写正确的值语义方法可能引入Bug。选择指南优先使用record的场景DTO、VO、配置项、消息、事件这些对象的核心是数据行为很少或没有。需要基于值的比较例如作为Dictionary的键或在HashSet中用于去重。希望对象不可变在多线程环境或函数式编程风格中不可变性是安全的基石。需要频繁进行“复制-修改”with表达式是无可替代的利器。坚持使用class的场景实体Entity具有唯一标识ID即使属性值相同也是不同对象。实体的相等性基于ID而非属性值。具有复杂行为或状态变迁的对象例如一个需要管理内部状态机、执行复杂计算的业务逻辑对象。需要从现有的类层次结构继承record不能继承自class。需要精细控制内存布局或性能极度敏感高级场景。关于record structC# 10 引入了record struct它是值类型的记录。与引用类型的record即record class相比主要区别在于存储位置record struct分配在栈上通常record class分配在堆上。默认可变性record struct的属性默认是可变的get; set;除非显式声明为readonly record struct。而record class的属性默认是init-only。相等性两者都提供基于值的相等性但record struct避免了装箱。选择record struct通常用于对性能有极致要求的小型数据块例如坐标点、RGB颜色值等。7. 高级技巧与实战陷阱1. 自定义相等性逻辑虽然record自动生成值相等性但有时你可能需要忽略某些属性如自增ID、时间戳。你可以通过重写Equals或提供自定义的EqualityContract来实现但更简单的方式是使用IEquatableT接口或重写操作符。不过更常见的做法是将不参与比较的属性从主构造函数中移除改为手动定义的属性。public record Order { // 主构造函数中的属性参与相等性比较 public string OrderNumber { get; init; } public decimal Amount { get; init; } // 此属性不参与相等性比较 public long InternalId { get; init; } public Order(string orderNumber, decimal amount) { OrderNumber orderNumber; Amount amount; } }2. 与序列化库的协作主流序列化库如System.Text.Json, Newtonsoft.Json对record的支持已经很好。它们通常能正确处理主构造函数参数和属性。但需要注意确保属性有可访问的setterinit足够。如果使用主构造函数序列化器通常能通过构造函数反序列化。对于System.Text.Json可能需要配置JsonSerializerOptions { IncludeFields true }或在构造函数参数上使用[JsonInclude]特性具体取决于版本。3. 避免在GetHashCode中引入可变字段虽然record的GetHashCode是自动生成的但如果你手动重写了Equals必须同时重写GetHashCode并确保其逻辑与Equals一致且计算不依赖于可变字段如果对象是可变的否则在哈希集合中会导致未定义行为。4.with表达式的性能with表达式会创建原对象的完整副本然后修改指定字段。对于包含大型数组或集合的record这可能会有性能开销。如果遇到性能瓶颈需要考虑其他模式或者将大型数据拆分为独立的引用。在我最近负责的一个微服务项目中我们系统地将所有跨服务传递的消息、API请求/响应模型、以及系统内部的配置值对象都迁移到了record。最直观的感受是代码评审时关于“为什么这个DTO的Equals没写对”的讨论彻底消失了with表达式也让实现不可变数据流的代码变得赏心悦目。当然对于核心的领域实体我们依然使用class因为它们的身份ID才是关键。这种清晰的区分让代码的意图更加明确。