50 行样板代码 → 1 行。这不是语法糖——这是对"数据"这个概念的重新定义
来,写一个最简单的"人"类。不需要任何业务逻辑——只要存姓名和年龄:
// 需求:存 Name + Age,能比较是否相等,能打印,能解构
public class Person
{
public string Name { get; }
public int Age { get; }
public Person(string name, int age)
{
Name = name;
Age = age;
}
// 值相等?手动写的
public override bool Equals(object obj)
{
if (obj is not Person other) return false;
return Name == other.Name && Age == other.Age;
}
// GetHashCode?手动写的
public override int GetHashCode() => HashCode.Combine(Name, Age);
// ToString?手动写的
public override string ToString() => $"Person {{ Name = {Name}, Age = {Age} }}";
// 解构?手动写的
public void Deconstruct(out string name, out int age)
{
name = Name;
age = Age;
}
}
50 行。其中真正有业务含义的只有两行半——"Name"和"Age"。其余全是样板代码Boilerplate——编译器知道该生成什么,但你不得不手写。
更糟的是,样板代码会腐烂:你加了 Email 属性却忘了更新 Equals → 两个同 Name、同 Age、不同 Email 的 Person 被判为"相等"→ 静默 Bug。而且这只是三个属性的情况——五个、八个呢?
这就是记录类型Records要解决的问题。C# 9 说:让编译器来写样板代码——你只需要声明数据结构是什么。
// C# 9: 位置记录——等价于上面 50 行的 Person 类
public record Person(string Name, int Age);
// 使用:
var alice = new Person("Alice", 30);
var alice2 = new Person("Alice", 30);
// 值相等——两个不同引用的对象,属性值相同 → 被视为相等
Console.WriteLine(alice == alice2); // True ← class 的话是 False!
Console.WriteLine(alice.Equals(alice2)); // True
// 自动生成 ToString()
Console.WriteLine(alice); // Person { Name = Alice, Age = 30 }
// 自动生成 Deconstruct()——支持解构
var (name, age) = alice; // name = "Alice", age = 30
public record Person(string Name, int Age);——这一行叫位置记录Positional Record,括号里是位置参数Positional Parameters。编译器自动生成:
Name 和 Age)——带 init 访问器Equals(object) + Equals(Person) + == / != ——基于属性值比较GetHashCode() ——基于所有属性值ToString() ——格式化输出所有属性和值Deconstruct() ——解构支持<Clone>$() ——为 with 表达式提供拷贝基础(后面讲)record 是引用类型Reference Type——和 class 一样在堆上分配。但它用值相等Value Equality而不是引用相等来判断两个对象是否"相同"。这是 record 和 class 最本质的区别。
Records 是不可变的Immutable——创建后不能修改。这个不可变性由 init 访问器保证:
var alice = new Person("Alice", 30);
// ❌ 编译错误:init 属性只能在初始化时赋值
alice.Name = "Bob"; // CS8852: Init-only property can only be assigned in an object initializer or constructor
// ✅ init 属性可以在对象初始化器中赋值
var bob = new Person { Name = "Bob", Age = 25 };
// ✅ 或者在构造函数中赋值
public record Person
{
public Person(string name) { Name = name; Age = 0; }
}
init 是 C# 9 引人的第三种访问器——介于 set(随时可设)和 get(永远读)之间:只能在对象构造阶段设一次,之后永远只读。位置记录的位置参数属性默认就是 init。
class 和 struct 的属性加 init 访问器——它独立于 record 存在。但在实践中,init 和 record 是天然的搭档:record 提供值语义,init 保证不可变性。
当你不需要 record 全套功能(值相等、ToString 等),只想要"不可变性"这一项能力时,class + init 是更轻量的选择:
public class DatabaseOptions
{
public string ConnectionString { get; init; }
public int MaxRetries { get; init; } = 3;
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30);
}
// ✅ 对象初始化器中赋值——没问题
var opts = new DatabaseOptions
{
ConnectionString = "Server=...",
MaxRetries = 5
};
// ❌ 创建后再改——编译错误 CS8852
opts.MaxRetries = 10; // Init-only property can only be assigned
// in an object initializer or constructor
三种访问器的能力对比:
| 访问器 | 构造函数中赋值 | 对象初始化器中赋值 | with 表达式中赋值 | 对象创建后赋值 |
|---|---|---|---|---|
set | ✅ | ✅ | ✅ | ✅ |
init | ✅ | ✅ | ✅(仅 record) | ❌ |
纯 get | ✅(backing field) | ❌ | ❌ | ❌ |
场景一:配置对象——创建时一次性赋值,之后全局只读,防止被业务代码意外修改:
public class EmailServiceOptions
{
public string SmtpHost { get; init; }
public int Port { get; init; } = 587;
public string ApiKey { get; init; }
}
// 注册进 DI 后,任何服务都无法修改——天然的线程安全
场景二:DTO 必填字段保护——防止消费者拿到 DTO 后偷偷修改:
public class CreateOrderRequest
{
public string CustomerId { get; init; }
public decimal Amount { get; init; }
// 如果用 set,下游 Handler 可能随手改 Amount——静默 bug
}
场景三:init 访问器内加验证——set 能做的逻辑,init 都能做:
public class Person
{
private readonly string _name;
public string Name
{
get => _name;
init
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Name is required");
_name = value;
}
}
public int Age { get; init; }
}
| 特性 | init 属性 | readonly 字段 |
|---|---|---|
| 本质 | 属性(有访问器逻辑) | 字段(直接存储) |
| 对象初始化器中赋值 | ✅ new X { Prop = val } | ❌ |
| 可定义在接口中 | ✅ | ❌(字段不能进接口) |
| 适用场景 | 暴露给外部的不可变数据 | 内部实现细节 |
init 属性在底层被编译为一个 readonly 的 backing field + 带 modreq 标记的 set 访问器。CLR 通过 modreq 验证调用方是否在合法的构造阶段——不在则拒绝执行。这就是为什么你绕不过它的限制:不是编译器的语法检查,而是 CLR 级别的强制。
如果 record 不可变,那"改"一个对象的属性怎么办?答案:不改——创建一个副本,修改你需要的属性。这叫非破坏性变异Non-destructive Mutation:
var alice = new Person("Alice", 30);
// with 表达式:创建 alice 的副本,只修改 Age
var aliceNextYear = alice with { Age = 31 };
Console.WriteLine(alice); // Person { Name = Alice, Age = 30 } ← 原对象不变
Console.WriteLine(aliceNextYear); // Person { Name = Alice, Age = 31 } ← 新对象
with 的语义:① 浅拷贝Shallow Copy整个对象 → ② 应用括号里的属性修改 → ③ 返回新对象。原对象毫发无损。
with 被编译为调用 <Clone>$() 方法(一个编译器生成的 protected 方法,做 memberwise clone),然后对指定的属性赋值。不需要你写任何 Clone 逻辑——编译器从位置参数推导出所有细节。
public record Address(string City, string Street);
public record Person(string Name, Address Address);
var alice = new Person("Alice", new Address("Beijing", "长安街"));
// 嵌套 with:改地址的城市
var aliceMoved = alice with
{
Address = alice.Address with { City = "Shanghai" }
};
Console.WriteLine(aliceMoved); // Person { Name = Alice, Address = Address { City = Shanghai, Street = 长安街 } }
两个 record 何时相等?类型相同 + 所有属性值相等。看看具体行为:
public record Person(string Name, int Age);
var a = new Person("Alice", 30);
var b = new Person("Alice", 30);
var c = new Person("Alice", 31);
Console.WriteLine(a == b); // True —— 不同引用,但值相同 → 相等
Console.WriteLine(a == c); // False —— Age 不同 → 不相等
// 注意:record 的 == 被重载为值相等,不是引用相等!
Console.WriteLine(ReferenceEquals(a, b)); // False —— 它们的引用确实不同
== 比较地址(除非重载)
两个 new 出来的 class 永远 !=
用于有"身份"的对象——Entity
== 比较所有属性值
两个属性值相同的 record 就是 ==
用于有"值"的对象——DTO、消息
编译器的 Equals 实现不是简单的 == 连用——它知道每个属性类型的相等语义:
public record Order(string Id, decimal Amount, List<string> Tags);
var a = new Order("A", 100m, new List<string> { "urgent" });
var b = new Order("A", 100m, new List<string> { "urgent" });
Console.WriteLine(a == b); // False!
// 为什么?List<T> 是引用类型,没有实现 IEquatable<T> 的值相等
// 所以两个不同的 List 实例在 Equals 中比较引用 → False
Equals。但如果某个属性类型本身不支持值相等(比如 List<T>),结果可能出乎预料。解决方案:用 ImmutableList<T>、IReadOnlyList<T> 包裹数组,或在 record 中使用自定义 Equals。
输入这一行:
public record Person(string Name, int Age);
编译器生成的东西近似如下(简化但结构准确):
// ===== 编译器生成的 Person record =====
public record Person : IEquatable<Person>
{
// ① init-only 属性
public string Name { get; init; }
public int Age { get; init; }
// ② 主构造函数
public Person(string Name, int Age)
{
this.Name = Name;
this.Age = Age;
}
// ③ 值相等 —— Equals(R other) + Equals(object) + == / !=
public virtual bool Equals(Person other)
{
if (other is null) return false;
return EqualityComparer<string>.Default.Equals(Name, other.Name)
&& EqualityComparer<int>.Default.Equals(Age, other.Age);
}
public override bool Equals(object obj)
=> Equals(obj as Person);
public static bool operator ==(Person left, Person right)
=> left?.Equals(right) ?? right is null;
public static bool operator !=(Person left, Person right)
=> !(left == right);
// ④ GetHashCode —— 组合所有属性
public override int GetHashCode()
{
var hc = new HashCode();
hc.Add(Name);
hc.Add(Age);
return hc.ToHashCode();
}
// ⑤ ToString —— PrintMembers 模式
public override string ToString()
{
var sb = new StringBuilder();
sb.Append("Person { ");
PrintMembers(sb);
sb.Append("}");
return sb.ToString();
}
protected virtual bool PrintMembers(StringBuilder sb)
{
sb.Append("Name = ").Append(Name);
sb.Append(", Age = ").Append(Age);
return true;
}
// ⑥ Deconstruct —— 位置解构
public void Deconstruct(out string Name, out int Age)
{
Name = this.Name;
Age = this.Age;
}
// ⑦ <Clone>$ —— with 表达式的底层机制(编译器命名,不可手动调用)
protected Person <Clone>$()
{
return (Person)MemberwiseClone(); // 浅拷贝
}
}
GetHashCode 行为不符合预期时,你知道去哪看;③ 性能:with 做的是浅拷贝,不是深拷贝——明白这个就不会在引用类型属性上踩坑。
你在第二节见过这行代码:
var (name, age) = alice; // name = "Alice", age = 30
这背后工作的就是解构函数——一个叫 Deconstruct 的方法。它不是 C# 9 的新特性(早在 C# 7 就引入了),但 record 让它从"手动写"变成了"自动生成"。
解构函数不依赖任何接口、不需要任何特性标注。编译器只认一个模式:
// 只要你的类型有一个签名匹配的方法……
public void Deconstruct(out T1 p1, out T2 p2, out T3 p3, ...)
编译器看到 var (a, b, c) = obj,就把它翻译成:
obj.Deconstruct(out var a, out var b, out var c);
你可以重载多个 Deconstruct 方法,按 out 参数的数量和类型区分:
public void Deconstruct(out string name, out int age) { ... }
public void Deconstruct(out string name, out int age, out string email) { ... }
// 编译器根据你接收的变量数量自动选择正确的重载
var (n, a) = person; // 调用 2 参数版本
var (n, a, e) = person; // 调用 3 参数版本
中文非常容易混淆,但它们毫无关系:
| 术语 | 英文 | 方法签名 | 含义 | 引入版本 |
|---|---|---|---|---|
| 解构 | Deconstruct | void Deconstruct(out T1, ...) | 把对象拆成多个变量 | C# 7 |
| 析构 | Destructor / Finalizer | ~ClassName() | GC 回收前的清理回调 | C# 1.0 |
out 参数),析构是销毁清理(Destructor → ~ClassName())。两者在语法、语义、运行时机制上没有任何联系。
解构是纯命名约定,不依赖任何接口。以下全部支持:
// ✅ class —— 手动定义 Deconstruct
public class Person
{
public string Name { get; }
public void Deconstruct(out string name, out int age) { ... }
}
// ✅ struct —— 同样可以
public struct Point
{
public void Deconstruct(out int x, out int y) { ... }
}
// ✅ record —— 位置记录编译器自动生成 Deconstruct
public record Person(string Name, int Age); // Deconstruct 自动生成
// ✅ 甚至可以用扩展方法给别人的类型加解构!
public static class DateTimeExtensions
{
public static void Deconstruct(this DateTime dt,
out int year, out int month, out int day)
{
year = dt.Year; month = dt.Month; day = dt.Day;
}
}
var (y, m, d) = DateTime.Now; // 解构系统类型!
DateTime、KeyValuePair)加上解构能力——而不需要修改源码。这是"命名约定优于接口继承"设计哲学的绝佳例子:灵活性极高,代价是"解构"没有编译期契约保障(你无法强制一个类型一定可解构)。
| 记录类型 | 编译器生成 Deconstruct? | 为什么 |
|---|---|---|
位置记录 record Person(string Name, int Age) | ✅ 自动 | 位置参数一一对应属性,编译器知道解构出什么 |
| 命名记录(手动定义属性) | ❌ 不生成 | 属性和构造函数参数不一定一一对应,编译器不做猜测 |
如果命名记录也需要解构——自己写一个 Deconstruct 方法即可,和 class 里面一模一样。
位置记录提供了一行声明法,但你也可以手动定义 record——当属性名字和构造函数参数名不一致,或需要自定义行为时:
// 命名记录:手动声明属性和构造函数
public record Person
{
public string Name { get; init; }
public int Age { get; init; }
// 自定义构造函数——做一些验证
public Person(string name, int age)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Name is required");
Name = name;
Age = age;
}
}
命名记录Nominal Record和位置记录Positional Record的唯一区别:编译器不会自动生成位置参数的构造函数和 Deconstruct。但 Equals、GetHashCode、ToString、<Clone>$ 依然自动生成——因为它们基于所有属性,和构造函数参数无关。
public record Person(string Name, int Age);
public record Student(string Name, int Age, string School) : Person(Name, Age);
var s = new Student("Bob", 20, "Tsinghua");
Console.WriteLine(s); // Student { Name = Bob, Age = 20, School = Tsinghua }
继承规则很严格:
| 行为 | 允许? | 说明 |
|---|---|---|
| Record 继承 Record | ✅ | |
| Record 继承 Class | ❌ | Record 只能继承 Record 或 object |
| Class 继承 Record | ❌ | Record 基类有编译器生成的方法,class 无法兼容 |
sealed record | ✅ | 阻止进一步继承,推荐作为默认做法 |
Person p = new Student("Bob", 20, "Tsinghua"),p.Equals(anotherPerson) 会同时检查运行时类型和属性值。两个不同的子类——即使父类属性完全相同——也不会相等。因为编译器插入了一个 EqualityContract 属性来标识类型,确保不会把 Student 和 Teacher(碰巧同名同年龄)判为相等。
| ✅ 用 Record | ❌ 不用 Record |
|---|---|
| DTO / API 响应模型——数据进来出去,不需要改 | EF Core 实体——需要变更追踪Change Tracking |
| 消息 / 事件——值语义保证幂等比较 | 需要独立身份Identity的对象——比如 User(即使所有属性相同也是不同的人) |
| 配置快照——不可变保证线程安全 | 频繁修改的大对象——每次 with 都创建新对象,GC 压力大 |
| 领域值对象Value Object——Money、Address、Color | 需要引用相等语义的场景——比如缓存键依赖引用 |
| 需要无损变异Non-destructive Mutation的场景 | 所有属性都可变的简单数据容器——struct 可能更适合 |
record struct 部分缓解了这个问题,但那是另一个话题。
| 特性 | 一句话 | 本课覆盖? |
|---|---|---|
| Records | 不可变引用类型,值相等语义 | ✅ 本课 |
| Init-only Properties | set 只能在初始化时调用 | ✅ 本课 |
| With Expressions | 基于旧对象创建新对象 | ✅ 本课 |
| Top-level Statements | 没有 Main 方法的 Program.cs | 📅 下一课 |
| Pattern Matching 增强 | is not、and/or 模式、关系模式 | 📅 下一课 |
Target-typed new | Person p = new(); 省略类型 | 📅 下一课 |
| Covariant Returns | 重写方法可以返回更具体的类型 | 📅 下一课 |
public record Point(int X, int Y);
var a = new Point(1, 2);
var b = new Point(1, 2);
Console.WriteLine(a == b);
Console.WriteLine(ReferenceEquals(a, b));
with 表达式,以下哪句是正确的?init 访问器允许在哪些场景下赋值?Person record,ReferenceEquals 返回 False?Deconstruct,以下哪个说法是错误的?public class Options
{
public string Url { get; init; }
public int Retries { get; init; } = 3;
}
var opts = new Options { Url = "https://api.example.com" };
opts.Retries = 5; // ← A
var opts2 = new Options { Url = "https://api2.example.com", Retries = 10 };
// ← B
public class Service
{
public Options Config { get; }
public Service()
{
Config = new Options { Url = "default" };
// ← C
}
}