解密 C# 中的记录类型:面向现代应用的不可变设计
C# 一直以来以其平衡的特性自豪——它本质上是面向对象的,但也稳步地融入了函数式编程的构造。随着 C# 9 引入记录类型(Record),并且在后续版本中(包括 C# 14)不断发展,开发者获得了一个强大的新工具,可以用最简洁的方式来建模不可变数据。但记录类型不仅仅是语法糖,它背后带来了对值语义、相等性和数据建模方式的深刻范式转变。
本文将带你深入了解 C# 中记录类型的使用,从其语义、功能到与传统类和结构体的区别。无论你是在处理 DTO(数据传输对象)、领域驱动设计(DDD)实体,还是构建函数式管道,记录类型都能提供一种简洁而表达力强的数据建模方式,简化并提升你的代码质量。
什么是记录类型?
从本质上讲,记录类型是一个引用类型,它采用基于值的相等语义。这意味着两个数据相同的记录实例被认为是相等的,而不管它们在内存中是否指向相同的引用。
传统上,C# 中的引用类型(类)默认使用引用相等。这意味着,只有当两个对象指向相同的内存地址时,它们才被认为是相等的。而记录类型改变了这一点,通过比较对象的值来进行相等性判断——这使得记录类型非常适合用于数据传输、状态建模以及表示不可变实体。
以下是一个最简单的示例:
public record User(string Name, int Age);
var u1 = new User("Alice", 30);
var u2 = new User("Alice", 30);
Console.WriteLine(u1 == u2); // True — 基于值的相等性
相比之下,如果 User 是一个类,比较结果会返回 False,因为它们是两个不同的引用。这种行为是有意为之,并且是一种强有力的转变,使 C# 更加贴近 F# 等函数式编程语言,在这些语言中,不可变性和结构相等性是默认的。
记录类型的分类:位置记录与名义记录
C# 支持两种主要的记录类型形式:
- 位置记录(Positional Records)
位置记录使用简洁的构造函数语法直接在声明中定义。它们自动提供 Deconstruct()、ToString() 和 Equals() 等方法。
public record Product(string Name, decimal Price);
这种形式非常简洁,特别适用于数据驱动的应用程序,在这些应用中,重点是持有状态而非行为。它们非常适合用作 DTO、消息传递系统中的消息或跨层传递的值。
- 名义记录(Nominal Records,带显式属性)
你也可以声明带有手动定义属性的记录,这样可以让你更好地控制验证、格式化或自定义访问器等行为。
public record Order
{
public string OrderId { get; init; }
public DateTime Date { get; init; }
}
这种变体在需要更丰富的行为、注解或自定义数据模型时非常有用,例如在领域驱动设计(DDD)中的聚合根或视图模型中。
不可变性与 with 表达式
记录类型的一个最优雅的特性是通过 with 表达式进行无损的变更。你可以基于现有实例创建一个新实例,仅更改你指定的值,而不是修改原始实例。
var original = new User("Alice", 30);
var updated = original with { Age = 31 };
Console.WriteLine(original); // User { Name = Alice, Age = 30 }
Console.WriteLine(updated); // User { Name = Alice, Age = 31 }
这种模式非常适合函数式编程、不可变状态管理和并发系统,因为共享的可变状态往往是 bug 的根源。with 表达式支持清晰的意图表达,使得编程风格更加简洁、安全。
值相等与引用相等
理解值相等(用于记录类型)与引用相等(用于类类型)之间的区别是避免意外行为的关键。例如:
public class Person(string Name);
public record Citizen(string Name);
var p1 = new Person("Bob");
var p2 = new Person("Bob");
var c1 = new Citizen("Bob");
var c2 = new Citizen("Bob");
Console.WriteLine(p1 == p2); // False
Console.WriteLine(c1 == c2); // True
这种区别不仅仅是理论性的,它影响到单元测试、字典查找、LINQ 查询和 UI 状态比较等方方面面。了解你的类型是通过结构还是通过身份进行比较,在大型代码库中至关重要。
继承与封闭性
记录类型支持继承,但默认情况下,记录类型是封闭的(sealed)。你可以使用 record class 或 record struct 语法创建记录类型层次结构,但重要的是要理解相等性比较是类型敏感的。
public record Animal(string Name);
public record Dog(string Name, string Breed) : Animal(Name);
Animal a = new Dog("Rex", "Labrador");
Animal b = new Dog("Rex", "Labrador");
Console.WriteLine(a == b); // True — 相同类型和相同值
当继承涉及到时,记录类型在多态和相等性方面引入了微妙的差异。基类记录无法轻易地覆盖派生类的行为,并且 with 表达式返回的是与原始记录相同的运行时类型——这样既保持了不可变性,也确保了类型安全。
记录类型在实际场景中的应用
以下是几个记录类型非常适用的实际场景:
- 建模不可变的领域事件
在事件溯源系统中,每个状态变化都是一个独立的、不可变的事件。记录类型非常适合表示这些事件,既清晰又不可变。
public record OrderShipped(Guid OrderId, DateTime ShippedAt);
- 函数式管道
当数据流经多个转换(例如,LINQ 或数据管道)时,你通常需要返回修改后的相同结构的版本。with 表达式能够优雅地实现这一点。
var transformed = inputData with { Status = "Processed" };
- 最小化 API 和数据契约
ASP.NET Core 的最小 API 风格与记录类型非常契合,尤其在请求/响应类型中,能够减少冗余并提高表达性。
C# 14 中记录的新特性
C# 14 对记录的可用性进行了多项优化,特别是当与主构造函数和必需成员结合使用时。你现在可以:
- 使用主构造函数,全面支持非记录类型,缩小记录与类之间的差距。
- 将必需成员与记录声明混合使用,实现更安全的初始化。
- 在组合基于记录的模型时依赖集合表达式和自然的 lambda 类型,尤其是在 API 中。
这些改进使得记录类型在 C# 中更加像一个一流的数据建模语言,它可以与 TypeScript 或 Kotlin 的简洁性相媲美,同时保持 C# 的强大鲁棒性。
结语:记录类型作为一种思维方式
理解记录类型不仅仅是学习一个语法特性——它更是改变我们设计和思考数据的方式。记录类型鼓励不可变性、清晰性和基于值的语义,这些都会带来更加可靠和可维护的代码。
无论你是在建模事件、构建 API 还是设计领域模型,记录类型提供了一种简洁而富有表现力的方式来封装状态,避免不必要的复杂性。它们不仅是类的替代品——它们代表了一种向更加声明式、意图明确的编程风格转变的根本性变化。
随着 C# 的不断发展,拥抱记录类型将帮助你编写出更短、更智能的代码。
注:转载文章,大家觉得上面文章如何?欢迎留言讨论。
本文使用chatgpt协助翻译。
作者:John Godel,版权归原作者John Godel所有
原文链接:c-sharpcorner.com/article/demystifying-records-in-c-sharp-immutable-design-for-modern-applications/