Lesson 07: C# 9 Records — 不可变引用类型与值相等语义

50 行样板代码 → 1 行。这不是语法糖——这是对"数据"这个概念的重新定义

前置 / Prerequisite:已完成 Lesson 06(C# 8 收尾),熟悉 class、struct、属性、继承的基本概念。
本课目标:理解记录类型Records解决的问题、掌握位置记录Positional Records语法、理解值相等Value Equality语义、熟练使用仅初始化属性Init-only Properties(含 class 场景)、with 表达式With Expressions进行非破坏性变异Non-destructive Mutation、深入理解解构函数Deconstruct机制。

一、钩子——一个 Person 类写 50 行,到底在写什么?

来,写一个最简单的"人"类。不需要任何业务逻辑——只要存姓名和年龄:

// 需求:存 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 说:让编译器来写样板代码——你只需要声明数据结构是什么。

二、位置记录 Positional Records — 一行搞定

// 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。编译器自动生成:

  1. 同名属性(NameAge)——带 init 访问器
  2. 匹配参数的构造函数
  3. Equals(object) + Equals(Person) + == / != ——基于属性值比较
  4. GetHashCode() ——基于所有属性值
  5. ToString() ——格式化输出所有属性和值
  6. Deconstruct() ——解构支持
  7. <Clone>$() ——为 with 表达式提供拷贝基础(后面讲)
关键理解:record引用类型Reference Type——和 class 一样在堆上分配。但它用值相等Value Equality而不是引用相等来判断两个对象是否"相同"。这是 record 和 class 最本质的区别。

三、仅初始化属性 Init-only Properties — 不可变的基石

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

init 不仅限 record:你可以给普通 classstruct 的属性加 init 访问器——它独立于 record 存在。但在实践中,init 和 record 是天然的搭档:record 提供值语义,init 保证不可变性。

3.1 init 在普通 class 中使用

当你不需要 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)

3.2 真实场景——什么时候在 class 中用 init?

场景一:配置对象——创建时一次性赋值,之后全局只读,防止被业务代码意外修改:

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; }
}

3.3 init 属性 vs readonly 字段

特性init 属性readonly 字段
本质属性(有访问器逻辑)字段(直接存储)
对象初始化器中赋值new X { Prop = val }
可定义在接口中❌(字段不能进接口)
适用场景暴露给外部的不可变数据内部实现细节
编译器生成了什么?init 属性在底层被编译为一个 readonly 的 backing field + 带 modreq 标记的 set 访问器。CLR 通过 modreq 验证调用方是否在合法的构造阶段——不在则拒绝执行。这就是为什么你绕不过它的限制:不是编译器的语法检查,而是 CLR 级别的强制。

四、With 表达式 With Expressions — 基于旧对象创建新对象

如果 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 逻辑——编译器从位置参数推导出所有细节。

4.1 With 表达式的嵌套场景

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 = 长安街 } }

五、值相等 Value Equality 深入

两个 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 —— 它们的引用确实不同

Class:引用相等

== 比较地址(除非重载)
两个 new 出来的 class 永远 !=
用于有"身份"的对象——Entity

Record:值相等

== 比较所有属性值
两个属性值相同的 record 就是 ==
用于有"值"的对象——DTO、消息

5.1 Equals 的派生规则

编译器的 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
陷阱:record 的值相等是递归的——它检查每个属性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();  // 浅拷贝
    }
}
为什么要关注编译器生成代码?① 面试:问"record 和 class 有什么区别"时,你能量化回答——"编译器自动生成 7 种方法"比"record 更方便"有力得多;② 调试:当你发现 GetHashCode 行为不符合预期时,你知道去哪看;③ 性能:with 做的是浅拷贝,不是深拷贝——明白这个就不会在引用类型属性上踩坑。

七、解构函数 Deconstruct 深入 — 把对象"拆开"的魔法

你在第二节见过这行代码:

var (name, age) = alice;  // name = "Alice", age = 30

这背后工作的就是解构函数——一个叫 Deconstruct 的方法。它不是 C# 9 的新特性(早在 C# 7 就引入了),但 record 让它从"手动写"变成了"自动生成"。

7.1 Deconstruct 不是什么关键字——是纯约定

解构函数不依赖任何接口不需要任何特性标注。编译器只认一个模式:

// 只要你的类型有一个签名匹配的方法……
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 参数版本

7.2 ⚠️ 解构 ≠ 析构

中文非常容易混淆,但它们毫无关系

术语英文方法签名含义引入版本
解构Deconstructvoid Deconstruct(out T1, ...)把对象拆成多个变量C# 7
析构Destructor / Finalizer~ClassName()GC 回收前的清理回调C# 1.0
面试高频陷阱题:"C# 中解构和析构有什么区别?"——很多候选人以为是一种东西。解构是拆开用(Deconstruct → out 参数),析构是销毁清理(Destructor → ~ClassName())。两者在语法、语义、运行时机制上没有任何联系。

7.3 哪些类型可以解构?

解构是纯命名约定,不依赖任何接口。以下全部支持:

// ✅ 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;  // 解构系统类型!
扩展方法解构是 C# 的一颗隐藏宝石:它意味着你可以给任何第三方类型、甚至 BCL 类型(如 DateTimeKeyValuePair)加上解构能力——而不需要修改源码。这是"命名约定优于接口继承"设计哲学的绝佳例子:灵活性极高,代价是"解构"没有编译期契约保障(你无法强制一个类型一定可解构)。

7.4 Record 的 Deconstruct:自动生成 vs 手动

记录类型编译器生成 Deconstruct?为什么
位置记录 record Person(string Name, int Age)✅ 自动位置参数一一对应属性,编译器知道解构出什么
命名记录(手动定义属性)❌ 不生成属性和构造函数参数不一定一一对应,编译器不做猜测

如果命名记录也需要解构——自己写一个 Deconstruct 方法即可,和 class 里面一模一样。

八、命名记录 Nominal Records — 手动定义

位置记录提供了一行声明法,但你也可以手动定义 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。但 EqualsGetHashCodeToString<Clone>$ 依然自动生成——因为它们基于所有属性,和构造函数参数无关。

九、继承 Inheritance — Record 可以继承 Record

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 继承 ClassRecord 只能继承 Record 或 object
Class 继承 RecordRecord 基类有编译器生成的方法,class 无法兼容
sealed record阻止进一步继承,推荐作为默认做法
继承 + 值相等的坑:如果 Person p = new Student("Bob", 20, "Tsinghua")p.Equals(anotherPerson)同时检查运行时类型和属性值。两个不同的子类——即使父类属性完全相同——也不会相等。因为编译器插入了一个 EqualityContract 属性来标识类型,确保不会把 Student 和 Teacher(碰巧同名同年龄)判为相等。

十、使用指南——何时用 Record,何时不用

✅ 用 Record❌ 不用 Record
DTO / API 响应模型——数据进来出去,不需要改EF Core 实体——需要变更追踪Change Tracking
消息 / 事件——值语义保证幂等比较需要独立身份Identity的对象——比如 User(即使所有属性相同也是不同的人)
配置快照——不可变保证线程安全频繁修改的大对象——每次 with 都创建新对象,GC 压力大
领域值对象Value Object——Money、Address、Color需要引用相等语义的场景——比如缓存键依赖引用
需要无损变异Non-destructive Mutation的场景所有属性都可变的简单数据容器——struct 可能更适合
EF Core 能用 Record 吗?从 EF Core 6 开始,Record 可以作为 Owned Entity(值对象)使用。但作为主实体(Aggregate Root)时,EF Core 需要能修改属性来做变更追踪——这时 Record 的不可变性反而碍事。C# 10 的 record struct 部分缓解了这个问题,但那是另一个话题。

十一、C# 9 还有哪些值得期待的特性?

特性一句话本课覆盖?
Records不可变引用类型,值相等语义✅ 本课
Init-only Propertiesset 只能在初始化时调用✅ 本课
With Expressions基于旧对象创建新对象✅ 本课
Top-level Statements没有 Main 方法的 Program.cs📅 下一课
Pattern Matching 增强is notand/or 模式、关系模式📅 下一课
Target-typed newPerson p = new(); 省略类型📅 下一课
Covariant Returns重写方法可以返回更具体的类型📅 下一课

📝 小测验

Q1. 以下代码输出什么?

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));
选择正确答案:

Q2. 关于 with 表达式,以下哪句是正确的?

选择正确的描述:

Q3. init 访问器允许在哪些场景下赋值?

选择最准确的描述:

Q4. 以下关于 Record 继承的说法,哪个是正确的?

选择正确的描述:

Q5. 为什么两个属性值相同的 Person record,ReferenceEquals 返回 False?

选择最准确的解释:

Q6.(场景题)你在写一个订单系统。以下哪个最适合用 Record?

选择最佳答案:

Q7. 关于解构函数 Deconstruct,以下哪个说法是错误的?

选择不正确的描述:

Q8. 以下代码中,哪个赋值操作会编译失败

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
    }
}
选择会编译失败的行: