init / record / required:让 C# 对象一次成型

发布于:2025-09-16 ⋅ 阅读:(17) ⋅ 点赞:(0)

标签init record required with表达式 不可变性 数据模型 DTO

目录

1. init 访问器:让不可变对象的创建更灵活

官方文档:init 关键字 - C# reference | Microsoft Learn

C# 语言中,属性(Property)的 set 访问器可以被标记为 init,形成所谓的 “init-only setter”(仅初始化 set 访问器)。

1.1. 概念

init 访问器是 C# 9.0(.NET 5)中引入的一个关键特性,旨在以更灵活的方式支持不可变性。

  • 关键字: init
  • 核心目标: 在提供灵活的对象初始化方式的同时,保证对象状态在初始化后的不可变性。

1.1.1. 语法

init 访问器只能用于属性或索引器的 set 位置:

public class Person
{
    public string Name { get; init; }   // 自动生成 readonly 字段
    public int    Age  { get; init; }
}

1.1.2. 语义

init 访问器限制了属性只能在对象初始化阶段(即对象构造器或对象初始化器中)被赋值一次。初始化完成后,任何再次赋值的尝试都会导致编译错误(CS8852)。

1.2. 设计初衷:解决什么问题?

  1. 痛点:在 init 出现之前,实现不可变属性通常依赖 readonly 字段配合只有 get 访问器的属性。这种方式虽然保证了不可变性,但只能在构造函数中赋值,写法繁琐,且无法利用对象初始化器 {} 的简洁语法。
  2. 解决方案init 访问器将“不可变性”与“简洁初始化”完美结合。它既保证了属性在初始化后不可修改,又允许调用方使用对象初始化器方便地一次性设置多个属性值。

1.3. 使用方法

1.3.1. 在对象初始化器中赋值(主要场景)

var person = new Person
{
    FirstName = "John", // ✅ 正确:在初始化阶段
    LastName = "Doe",   // ✅ 正确:在初始化阶段
    Age = 30            // ✅ 正确:在初始化阶段
};

1.3.2. 在构造函数中赋值

public class Person
{
    public Person(string firstName)
    {
        FirstName = firstName; // ✅ 正确:构造函数也属于初始化阶段
    }
    
    public string FirstName { get; init; }
    public string LastName { get; init; }
}

1.3.3. 错误用法:初始化后赋值

var person = new Person { FirstName = "John", LastName = "Doe" };
person.Age = 30; // ❌ 编译错误:CS8852 

1.4. 内部实现原理

  1. 编译器会为每个 init 属性生成一个隐藏的 readonly 字段。
  2. init 访问器在编译后会生成一个普通的 set 方法,但会附加一个特殊的特性标记 [System.Runtime.CompilerServices.IsExternalInit]
  3. 编译器在 IL(中间语言)层面进行检查:只有在对象的构造阶段(如构造函数 .ctor 或对象初始化器生成的辅助方法)才允许调用这个被标记的 set 方法。
  4. 由于这只是一个编译器层面的标记,包含 init 属性的程序集在旧版运行时(如 .NET Framework)上也能加载和运行,只是旧版本的 C# 编译器无法识别和编译 init 语法。

1.5. 与相关特性的对比

特性 赋值时机 后续可改 适用场景
get-only 构造函数 纯不可变
init 构造函数 + 对象初始化器 需要对象初始化器语法糖
private set 任何成员方法 内部可变,外部只读
record 的 init 同 init record 自动生成 init
required 任何初始化阶段(C#11) 强制必须赋值

1.6. 高级主题与注意事项

1.6.1. 与 readonly 字段的配合

public class Person
{
    private readonly string _firstName; // 底层的只读字段
    public string FirstName
    {
        get => _firstName;
        init => _firstName = value; // init 可以给 readonly 字段赋值
    }
}

1.6.2. 继承中的使用

基类中定义的 init 属性可以在派生类的构造函数或对象初始化器中进行赋值。

public class Student : Person
{
    public string Major { get; init; }
}

var student = new Student
{
    FirstName = "Jane", // ✅ 可以初始化基类的 init 属性
    LastName = "Smith",
    Major = "Computer Science"
};

1.6.3. “浅”不可变性(Shallow Immutability)

init 只保证属性的引用本身不能被重新赋值。如果属性是一个引用类型(如 List<T>),其内部状态仍然是可变的。

public class Company
{
    public List<string> Employees { get; init; } = new List<string>();
}

var company = new Company { Employees = new List<string> { "John" } };
// 不能再给 Employees 属性分配一个新的列表
// company.Employees = new List<string>(); // ❌ 编译错误

// 但是可以修改列表里面的内容!
company.Employees.Add("Jane"); // ✅ 这不会报错!破坏了逻辑上的不可变性

Console.WriteLine(company.Employees.Count); // 输出:2
using System.Collections.Immutable;

public class Company
{
    public ImmutableList<string> Employees { get; init; } = ImmutableList<string>.Empty;
}

var company = new Company { Employees = ImmutableList.Create("John") };
// 任何修改操作都会返回一个新的集合,原集合不会被改变
var newCompany = company.Employees.Add("Jane"); 

1.6.4. 其他注意事项

  • 反射:可以通过反射绕过编译器的限制来修改 init 属性,但这会破坏其不可变性契约,应谨慎使用。
  • 序列化:现代序列化库(如 System.Text.JsonNewtonsoft.Json 13.0+)都支持反序列化到 init 属性。
using System;
using System.Reflection;
using System.Text.Json;
using Newtonsoft.Json;

// 定义包含init属性的模型
public class Person
{
    public string Name { get; init; }
    public int Age { get; init; }
}

class Program
{
    static void Main()
    {
        // 1. 反射示例
        Console.WriteLine("=== 反射示例 ===");
        var person = new Person { Name = "Alice", Age = 30 };
        Console.WriteLine($"原始值: {person.Name}, {person.Age}");

        // 通过反射修改init属性
        var property = typeof(Person).GetProperty("Name");
        property?.SetValue(person, "Bob");
        Console.WriteLine($"反射修改后: {person.Name}, {person.Age}");

        Console.WriteLine("\n=== 序列化示例 ===");

        // 2. System.Text.Json 序列化
        Console.WriteLine("1. System.Text.Json 反序列化:");
        var json = @"{""Name"":""Tom"", ""Age"":25}";
        var person1 = System.Text.Json.JsonSerializer.Deserialize<Person>(json);
        Console.WriteLine($"反序列化结果: {person1?.Name}, {person1?.Age}");

        // 3. Newtonsoft.Json 序列化(需要NuGet包 Newtonsoft.Json 13.0+)
        Console.WriteLine("2. Newtonsoft.Json 反序列化:");
        var person2 = JsonConvert.DeserializeObject<Person>(json);
        Console.WriteLine($"反序列化结果: {person2?.Name}, {person2?.Age}");
    }
}
=== 反射示例 ===
原始值: Alice, 30
反射修改后: Bob, 30

=== 序列化示例 ===
1. System.Text.Json 反序列化:
反序列化结果: Tom, 25
2. Newtonsoft.Json 反序列化:
反序列化结果: Tom, 25

2. record 类型:为数据而生的引用类型

官方文档:record 类型 - C# reference | Microsoft Learn

2.1. 概念

  1. 引入版本:C# 9.0 ( .NET 5)、 C# 10.0 ( **.NET **6)
  2. record 是 一种特殊的引用类型(或从 C# 10.0 开始的值类型),专为封装数据而设计。
  3. 它的特性是值语义(value semantics)和不可变性(immutability)。
  4. 当使用主构造函数声明 record 时,编译器会自动为每个参数生成公共的 init-only 属性,这些参数被称为位置参数

2.2. record class:引用类型记录

默认情况下,record 关键字创建的是一个引用类型记录 (record class)。

2.2.1. 基础语法与编译器生成的内容

简洁声明:

public record Person(string FirstName, string LastName);

反编译后查看代码

using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text;

// Token: 0x02000002 RID: 2
[NullableContext(1)]
[Nullable(0)]
public class Person : IEquatable<Person>
{
  // Token: 0x06000001 RID: 1 RVA: 0x00002050 File Offset: 0x00000250
  public Person(string FirstName, string LastName)
  {
    this.FirstName = FirstName;
    this.LastName = LastName;
    base..ctor();
  }

  // Token: 0x17000001 RID: 1
  // (get) Token: 0x06000002 RID: 2 RVA: 0x00002067 File Offset: 0x00000267
  [CompilerGenerated]
  protected virtual Type EqualityContract
  {
    [CompilerGenerated]
    get
    {
      return typeof(Person);
    }
  }

  // Token: 0x17000002 RID: 2
  // (get) Token: 0x06000003 RID: 3 RVA: 0x00002073 File Offset: 0x00000273
  // (set) Token: 0x06000004 RID: 4 RVA: 0x0000207B File Offset: 0x0000027B
  public string FirstName { get; set; }

  // Token: 0x17000003 RID: 3
  // (get) Token: 0x06000005 RID: 5 RVA: 0x00002084 File Offset: 0x00000284
  // (set) Token: 0x06000006 RID: 6 RVA: 0x0000208C File Offset: 0x0000028C
  public string LastName { get; set; }

  // Token: 0x06000007 RID: 7 RVA: 0x00002098 File Offset: 0x00000298
  [CompilerGenerated]
  public override string ToString()
  {
    StringBuilder stringBuilder = new StringBuilder();
    stringBuilder.Append("Person");
    stringBuilder.Append(" { ");
    if (this.PrintMembers(stringBuilder))
    {
      stringBuilder.Append(' ');
    }
    stringBuilder.Append('}');
    return stringBuilder.ToString();
  }

  // Token: 0x06000008 RID: 8 RVA: 0x000020E4 File Offset: 0x000002E4
  [CompilerGenerated]
  protected virtual bool PrintMembers(StringBuilder builder)
  {
    RuntimeHelpers.EnsureSufficientExecutionStack();
    builder.Append("FirstName = ");
    builder.Append(this.FirstName);
    builder.Append(", LastName = ");
    builder.Append(this.LastName);
    return true;
  }

  // Token: 0x06000009 RID: 9 RVA: 0x0000211E File Offset: 0x0000031E
  [NullableContext(2)]
  [CompilerGenerated]
  public static bool operator !=(Person left, Person right)
  {
    return !(left == right);
  }

  // Token: 0x0600000A RID: 10 RVA: 0x0000212A File Offset: 0x0000032A
  [NullableContext(2)]
  [CompilerGenerated]
  public static bool operator ==(Person left, Person right)
  {
    return left == right || (left != null && left.Equals(right));
  }

  // Token: 0x0600000B RID: 11 RVA: 0x00002140 File Offset: 0x00000340
  [CompilerGenerated]
  public override int GetHashCode()
  {
    return (EqualityComparer<Type>.Default.GetHashCode(this.EqualityContract) * -1521134295 + EqualityComparer<string>.Default.GetHashCode(this.<FirstName>k__BackingField)) * -1521134295 + EqualityComparer<string>.Default.GetHashCode(this.<LastName>k__BackingField);
  }

  // Token: 0x0600000C RID: 12 RVA: 0x00002180 File Offset: 0x00000380
  [NullableContext(2)]
  [CompilerGenerated]
  public override bool Equals(object obj)
  {
    return this.Equals(obj as Person);
  }

  // Token: 0x0600000D RID: 13 RVA: 0x00002190 File Offset: 0x00000390
  [NullableContext(2)]
  [CompilerGenerated]
  public virtual bool Equals(Person other)
  {
    return this == other || (other != null && this.EqualityContract == other.EqualityContract && EqualityComparer<string>.Default.Equals(this.<FirstName>k__BackingField, other.<FirstName>k__BackingField) && EqualityComparer<string>.Default.Equals(this.<LastName>k__BackingField, other.<LastName>k__BackingField));
  }

  // Token: 0x0600000F RID: 15 RVA: 0x000021F3 File Offset: 0x000003F3
  [CompilerGenerated]
  protected Person(Person original)
  {
    this.FirstName = original.<FirstName>k__BackingField;
    this.LastName = original.<LastName>k__BackingField;
  }

  // Token: 0x06000010 RID: 16 RVA: 0x00002215 File Offset: 0x00000415
  [CompilerGenerated]
  public void Deconstruct(out string FirstName, out string LastName)
  {
    FirstName = this.FirstName;
    LastName = this.LastName;
  }
}

等效于手写下面这个复杂的类:

public record Person
{
    // 编译器生成的构造函数
    public Person(string firstName, string lastName)
    {
        FirstName = firstName;
        LastName = lastName;
    }
    
    // 编译器生成的属性
    public string FirstName { get; init; }
    public string LastName { get; init; }
    
    // 编译器生成的解构方法
    public void Deconstruct(out string firstName, out string lastName)
    {
        firstName = FirstName;
        lastName = LastName;
    }
    
    // 编译器生成的Equals方法(基于值的相等性比较)
    public virtual bool Equals(Person? other)
    {
        return other is not null &&
               FirstName == other.FirstName &&
               LastName == other.LastName;
    }
    
    // 编译器生成的GetHashCode方法
    public override int GetHashCode()
    {
        return HashCode.Combine(FirstName, LastName);
    }
    
    // 编译器生成的ToString方法
    public override string ToString()
    {
        return $"Person {{ FirstName = {FirstName}, LastName = {LastName} }}";
    }
    
    // 编译器生成的复制方法(用于with表达式)
    protected virtual Person With(bool overwrite, ref Person result)
    {
        // 实际实现更复杂,这里简化表示
        if (overwrite)
        {
            result = this;
            return result;
        }
        return new Person(FirstName, LastName);
    }
    
    // 编译器生成的克隆操作符(用于with表达式)
    public Person Clone() => this with { };
}

编译器自动生成的关键成员:

  • 主构造函数:用于初始化所有位置属性。
  • init-only 属性:为每个位置参数生成一个公共的 init-only 属性。
  • 值相等性成员:
    • 重写 Equals(object)GetHashCode() 方法,实现基于所有公共属性值的比较。
    • 实现 IEquatable<T> 接口。
  • ToString() 方法:重写 ToString(),输出包含类型名称和所有公共属性值的格式化字符串。
  • Deconstruct() 方法:生成一个解构方法,方便将 record 的属性解构到单独的变量中。
  • with 表达式支持:生成一个隐藏的“拷贝构造函数”和一个 Clone() 方法,以支持非破坏性修改。

2.2.2. 继承

record class 支持继承。派生 record 会继承基 record 的所有成员,并且值相等性比较会包含所有基类和派生类的属性。

public record Employee(string Name, string LastName, string Company) : Person(Name, LastName);

2.2.3. 示例:结合继承与模式匹配

using System;
using System.Collections.Generic;

// 基类 record - 图形
public abstract record Shape(string Color, bool IsFilled);

// 派生 record - 圆形
public record Circle(string Color, bool IsFilled, double Radius) 
    : Shape(Color, IsFilled)
{
    public double Area => Math.PI * Radius * Radius;
}

// 派生 record - 矩形
public record Rectangle(string Color, bool IsFilled, double Width, double Height) 
    : Shape(Color, IsFilled)
{
    public double Area => Width * Height;
}

// 派生 record - 三角形
public record Triangle(string Color, bool IsFilled, double Base, double Height) 
    : Shape(Color, IsFilled)
{
    public double Area => 0.5 * Base * Height;
}

class Program
{
    static void Main()
    {
        // 创建图形列表
        var shapes = new List<Shape>
        {
            new Circle("Red", true, 5.0),
            new Rectangle("Blue", false, 4.0, 6.0),
            new Triangle("Green", true, 3.0, 4.0),
            new Circle("Yellow", false, 3.0),
            new Rectangle("Black", true, 5.0, 5.0)
        };

        // 使用模式匹配处理不同图形
        foreach (var shape in shapes)
        {
            Console.WriteLine(ProcessShape(shape));
        }

        Console.WriteLine();

        // 计算总面积
        double totalArea = CalculateTotalArea(shapes);
        Console.WriteLine($"Total area of all shapes: {totalArea:F2}");
    }

    // 使用模式匹配处理不同图形
    static string ProcessShape(Shape shape) => shape switch
    {
        Circle c when c.Radius > 4 => $"Large circle with area {c.Area:F2}",
        Circle c => $"Circle with radius {c.Radius} and area {c.Area:F2}",
        Rectangle r when r.Width == r.Height => $"Square with side {r.Width} and area {r.Area:F2}",
        Rectangle r => $"Rectangle with area {r.Area:F2}",
        Triangle t => $"Triangle with area {t.Area:F2}",
        _ => "Unknown shape"
    };

    // 计算所有图形的总面积
    static double CalculateTotalArea(List<Shape> shapes)
    {
        double total = 0;
        foreach (var shape in shapes)
        {
            total += shape switch
            {
                Circle c => c.Area,
                Rectangle r => r.Area,
                Triangle t => t.Area,
                _ => 0
            };
        }
        return total;
    }
}
Large circle with area 78.54
Rectangle with area 24.00
Triangle with area 6.00
Circle with radius 3 and area 28.27
Square with side 5 and area 25.00

Total area of all shapes: 141.81

2.2.4. 与普通 class 的内存与性能对比

虽然 record classclass 都是引用类型,在堆上分配,但它们的设计哲学导致了不同的性能特征。

内存布局

recordclass 实例在内存中的基本布局是相同的:变量存储一个指向堆上对象的引用。

示例内存表示:

// 无论是 class 还是 record,变量 'p' 都在栈上存储一个指向堆的地址
PersonRecord p1 = new PersonRecord("John", "Doe"); // record
PersonClass p2 = new PersonClass("John", "Doe");  // class

// 栈 (Stack)     堆 (Heap)
// [ p1: 0x1234 ] -> [ 0x1234: PersonRecord { FirstName="John", LastName="Doe" } ]
// [ p2: 0x5678 ] -> [ 0x5678: PersonClass { FirstName="John", LastName="Doe" } ]
关键差异及其影响
特性 record class 普通 class 对内存和性能的影响
不可变性 默认不可变。位置参数生成的属性是 init-only 默认可变。属性通常有 set 访问器。 record
• 安全:对象状态不会意外改变,适合在多线程间共享,无需额外锁,减少并发开销。
• 性能:由于不可变,编译器、运行时和 GC 可以进行更多优化(如更积极的栈上分配逃逸分析)。

class
• 灵活:可随时修改状态。
• 开销:在多线程环境中需要同步机制(如 lock),带来性能开销。
值语义相等性 基于值。比较所有属性的值。编译器生成 EqualsGetHashCode 基于引用。比较内存地址(除非重写 Equals/GetHashCode)。 record
• 计算开销:比较两个 record 需要遍历并比较所有属性的值。对于属性很多的 recordEqualsGetHashCode 的调用(如在 HashSet 或字典中)会比简单的引用比较更耗时。
• 哈希码:GetHashCode 需要基于所有属性计算,也可能更耗时,但保证了基于值的正确性。
with 表达式 核心特性。用于非破坏性修改。 不支持。 record
• 分配开销:original with { Prop = value } 总会创建一个全新的对象。频繁使用可能会增加 GC 压力,因为会产生更多短期存活的对象。
• 功能优势:这是实现不可变数据模型的必要代价,带来了状态清晰、线程安全等好处。
ToString 方法 编译器自动生成,输出所有属性及其值。 默认返回类型名称。 record
• 计算开销:生成格式化字符串需要计算和内存分配。如果频繁调用(如在日志中),可能会有性能影响。不过,这通常是开发阶段的行为。
总结与最佳实践
  1. 性能权衡
    • **record**的代价:Equals/GetHashCode 的计算成本、with 表达式带来的额外分配。
    • **record**的收益:线程安全、代码简洁、意图清晰、易于推理。
  2. 内存分配成本:单次 new 操作的成本基本相同。
  3. GC 压力record 的不可变性(特别是 with 表达式)可能会产生更多短期对象,从而增加 GC 压力。不过,现代 .NET GC 对处理这类对象的效率非常高。
  4. 最佳实践
    • 优先使用record:对于 DTO、值对象、API 模型等数据载体,优先使用 record
    • 性能敏感场景需测量:在高性能场景下,如果怀疑 record 是瓶颈,请使用 Benchmark.NET 等工具进行性能分析,不要凭空猜测。

2.3. record struct:值类型记录 (C# 10.0+)

使用 record struct 关键字可以定义值类型的记录,它结合了 struct 的性能优势和 record 的便利性。

示例声明:

public readonly record struct Point(double X, double Y, double Z);

等效的 struct 声明:

public record struct Point
{
    public double X { get; init; }
    public double Y { get; init; }
    public double Z { get; init; }
}

说明:

  • record struct 是值类型,分配在栈上(或作为其他对象的一部分),适用于轻量级数据结构。
  • 默认情况下,record struct 的位置属性是可变的get; set;),这与 record class 不同。可以通过添加 readonly 关键字 (public readonly record struct Point) 使其变为完全不可变。

2.4. record class vs record struct

特性 record class record struct
类型 引用类型 值类型
设计哲学 安全第一(类比法律文书) 性能与控制第一(类比实验草稿)
核心目标 代表一个完整的、身份由其值定义的实体。 安全共享和线程安全是首要目标。 代表一个轻量的数据快照。 提供灵活性,让开发者根据场景选择可变性或不可变性。
位置属性默认行为 不可变(init-only properties) 可变(除非显式设为 readonly
使用 readonly 不适用(已经是不可变的) 添加 `readonly` 关键字可使其变为完全不可变,与 `record class` 行为一致。
继承 支持 不支持
典型场景 领域模型、DTO、API 参数/返回、消息、哈希表键 高性能计算、图形、游戏开发中的数学对象(坐标、向量等)、 任何需要结构体性能且想获得记录便利性的场景

  • 区别record class引用类型record struct值类型。这是决定两者所有差异的根本原因,决定了它们在内存中的存储方式、赋值行为以及默认的相等性比较逻辑。
  • 可变性
    • record class 默认提供不可变性,这是其设计核心,旨在创建安全、可共享的数据模型。
    • record struct 默认是可变的,提供了灵活性。如果需要不可变性,必须显式使用 readonly record struct 声明。
  • 继承record class 支持继承,可以构建层次化的数据模型。record struct 是值类型,不支持继承。
  • 选择建议
    • 当需要表示一个逻辑上的“实体”(如一个人、一张订单),并且希望它是线程安全的、可作为字典键或用于基于值的比较时,应选择 record class
    • 当你需要定义一个轻量级的、数据量小的数据结构(如一个点、一个颜色RGB值),并且优先考虑性能(减少堆内存分配和垃圾回收)和灵活性时,应选择 record structreadonly record struct

2.5. with 表达式:非破坏性修改

2.5.1. 概述

with 表达式是为不可变数据模型设计的核心特性。

它允许你基于一个现有实例,创建一个新的、被修改过的副本,而原始实例保持不变。这种行为称为非破坏性突变(non-destructive mutation)

2.5.2. 语法

var newRecord = existingRecord with { Property1 = value1, Property2 = value2, ... };

2.5.3. 适用类型

  • record class
  • record struct
  • 任何提供了“拷贝构造函数”的 类型。

2.5.4. 实现原理

编译器会自动为 record 类型生成一个受保护的拷贝构造函数和一个用于支持 with 表达式的克隆方法。当你使用 with 表达式时,编译器会生成调用这些成员的代码来创建新对象。

public record Person
{
    // 编译器生成的主构造函数和属性
    public Person(string FirstName, string LastName, int Age)
    {
        this.FirstName = FirstName;
        this.LastName = LastName;
        this.Age = Age;
    }
    
    public string FirstName { get; init; }
    public string LastName { get; init; }
    public int Age { get; init; }

    // 编译器生成的受保护拷贝构造函数
    protected Person(Person existing)
    {
        // 复制所有字段
        this.FirstName = existing.FirstName;
        this.LastName = existing.LastName;
        this.Age = existing.Age;
    }

    // 编译器生成的用于支持 `with` 的合成方法(“Clone”方法)
    // 这个方法不是直接调用的,而是被 `with` 表达式使用的桥梁。
    public virtual Person With() => new Person(this); // 调用拷贝构造

    // ... 其他生成的成员(Equals, GetHashCode, ToString, Deconstruct)...
}

2.5.5. 示例

基本用法
public record Person(string FirstName, string LastName, int Age);

// 创建原始对象
var originalPerson = new Person("John", "Doe", 30);

// 使用 with 表达式创建新对象,仅修改 Age 属性
var youngPerson = originalPerson with { Age = 25 };

Console.WriteLine(originalPerson); // 输出: Person { FirstName = John, LastName = Doe, Age = 30 }
Console.WriteLine(youngPerson);    // 输出: Person { FirstName = John, LastName = Doe, Age = 25 }

// 同时修改 FirstName 和 Age
var modifiedPerson = originalPerson with { FirstName = "Jane", Age = 28 };
Console.WriteLine(modifiedPerson); // 输出: Person { FirstName = Jane, LastName = Doe, Age = 28 }

嵌套记录的使用
public record Address(string Street, string City);
public record Employee(string Name, Address Address, string Department);

var emp1 = new Employee("John", new Address("123 Main St", "Seattle"), "IT");

// 修改嵌套记录的属性
var emp2 = emp1 with { Address = emp1.Address with { City = "Redmond" } };

Console.WriteLine(emp1);
// 输出: Employee { Name = John, Address = Address { Street = 123 Main St, City = Seattle }, Department = IT }

Console.WriteLine(emp2);
// 输出: Employee { Name = John, Address = Address { Street = 123 Main St, City = Redmond }, Department = IT }
在普通 struct 中启用 with 表达式

通过提供一个自定义的拷贝构造函数,你也可以让普通的 struct 支持 with 表达式。这对于需要 struct 的性能但又想获得 with 表达式便利性的场景非常有用。

public struct Vertex
{
    public double X { get; init; }
    public double Y { get; init; }
    public double Z { get; init; }
    
    // 计算得到的属性
    public double Magnitude { get; } // 没有 setter 或 init-only

    // 主构造函数
    public Vertex(double x, double y, double z)
    {
        X = x;
        Y = y;
        Z = z;
        // Magnitude 需要在构造函数中计算
        Magnitude = Math.Sqrt(X * X + Y * Y + Z * Z);
    }

    // !!!自定义复制构造函数!!!
    // 参数必须与结构体类型相同,通常命名为 'original'
    public Vertex(Vertex original)
    {
        // 1. 复制所有字段/属性
        this = original; // 这是关键的一步:使用现有实例进行整体赋值
        
        // 2. 然后你可以在这里进行任何自定义逻辑
        // 例如:日志记录、通知、或者重新计算依赖于其他字段的只读属性(但此例中不需要,因为 this=original 已经复制了 Magnitude)
        // Console.WriteLine("A copy of Vertex was created.");
    }

    public override string ToString() => $"({X}, {Y}, {Z}) with magnitude {Magnitude:F2}";
}

class Program
{
    static void Main()
    {
        Vertex v1 = new Vertex(1, 2, 2);
        Console.WriteLine(v1); // 输出: (1, 2, 2) with magnitude 3.00

        // 由于定义了复制构造函数,Vertex 现在支持 with 表达式
        Vertex v2 = v1 with { X = 5 }; // 修改 X,Y 和 Z 保持不变
        Console.WriteLine(v2); // 输出: (5, 2, 2) with magnitude 5.74

        // 编译器会将上面的 with 表达式转换为类似下面的代码:
        // Vertex temp = new Vertex(v1); // 调用自定义的复制构造函数
        // temp.X = 5;                   // 在对象初始化器中设置属性
        // Vertex v2 = temp;
    }
}

2.5.6. 典型应用场景

  • 状态更新:在函数式或响应式编程中,用于创建新的状态,而非修改现有状态。
  • 数据转换:基于一个模板对象,生成多个只有细微差别的相似对象。
  • 测试数据构建:轻松创建测试数据的各种变体。

2.6. 特性总结

  1. 简洁的语法:通过位置参数极大减少了定义数据模型的样板代码。
  2. 值语义:自动实现基于所有公共属性的相等性比较。
  3. 不可变性record class 默认生成 init-only 属性,鼓励不可变的设计模式。
  4. 非破坏性修改:通过 with 表达式,可以安全、便捷地创建对象的修改副本。
  5. 内置格式化:自动重写的 ToString() 方法便于调试和日志记录。
  6. 继承支持record class 支持继承,并且派生的 record 也能正确地进行值比较。
  7. 正确性与可维护性:不可变性和值语义有助于避免因意外状态修改而导致的错误,使代码更容易推理和测试。
  8. 线程安全:不可变对象在多线程环境中可以安全共享,无需额外的同步锁。

2.8. 典型应用场景

  • 数据传输对象 (DTOs)

    在不同层或服务之间传递数据的理想选择。

  • 领域模型中的值对象 (Value Objects)

    MoneyAddress 等,这些对象的身份由其属性值决定,而非唯一的 ID。

  • API 请求/响应模型

    在 Web API 中定义输入和输出的数据结构。

  • CQRS 中的命令和查询

    在 MediatR 等库中,record 是定义命令和查询的完美选择。

  • 事件/消息

    在事件驱动或微服务架构中,用于定义不可变的事件或消息体。

3. required 修饰符:强制初始化的契约

3.1. 概念

  • 引入版本:C# 11 (.NET 7)
  • 核心目标:强制调用方在创建对象时,必须为标记为 required 的成员(属性或字段)显式赋值。
  • 作用:将“必须赋值”的契约从构造函数参数扩展到了对象初始化器,增强代码的健壮性,尤其是在与可空引用类型(NRT)结合使用时。

3.2. 基本用法

public class Person
{
    public required string FirstName { get; init; }
    public required string LastName  { get; init; }
    public int? Age { get; init; }     // 不标记则为可选
}
// 正确:使用对象初始化器为所有 required 成员赋值
var validPerson = new Person { FirstName = "John", LastName = "Doe" };

// 编译错误!CS9035: 缺少所需成员 "Person.FirstName" 的初始化
// var invalidPerson = new Person { LastName = "Doe" };

// 编译错误!CS9035: 缺少所需成员 "Person.LastName" 的初始化
// var invalidPerson2 = new Person { FirstName = "John" };

// 编译错误!不能使用传统的构造函数(除非构造函数自己也设置了这些值)
// var invalidPerson3 = new Person();

3.3. 与构造函数的协作

当类中同时存在 required 成员和自定义构造函数时,需要特别处理。

3.3.1. 使用 [SetsRequiredMembers] 特性

如果一个构造函数已经确保为所有 required 成员都赋了值,你可以使用 [SetsRequiredMembers] 特性来通知编译器。这样,调用该构造函数的代码就不再需要使用对象初始化器来为 required 成员赋值。

using System.Diagnostics.CodeAnalysis;

var person = new Person("John"); // Error: Name is required
public class Person
{
    public required string Name { get; init; }
    public required int Age { get; init; }

    [SetsRequiredMembers]
    public Person(string name)
    {
        Name = name;
    }
}

3.3.2. 多个构造函数场景

  • 未标记 [SetsRequiredMembers] 的构造函数

    如果调用了一个没有此特性的构造函数,编译器仍然会要求你在对象初始化器中为所有 required 成员赋值(即使构造函数内部已经为部分成员赋值)。

  • 构造函数链

    如果一个构造函数通过 : this(): base() 调用了另一个标记了 [SetsRequiredMembers] 的构造函数,那么它也隐式地满足了 required 成员的赋值要求。

public class Person
{
    public required string FirstName { get; init; }
    public required string LastName { get; init; }
    public int? Age { get; init; }
    public string? Title { get; set; }

    // 构造函数1:无参构造函数
    public Person() { }

    // 构造函数2:只设置FirstName
    public Person(string firstName)
    {
        FirstName = firstName;
    }

    // 构造函数3:设置所有required属性
    [SetsRequiredMembers]
    public Person(string firstName, string lastName)
    {
        FirstName = firstName;
        LastName = lastName;
    }

    // 构造函数4:设置所有required属性和可选属性
    [SetsRequiredMembers]
    public Person(string firstName, string lastName, int age)
    {
        FirstName = firstName;
        LastName = lastName;
        Age = age;
    }
}

// 编译错误:缺少对 required 成员的初始化
// var p1 = new Person();

// 编译错误:缺少对 LastName 的初始化
// var p2 = new Person("John");

// 编译错误:LastName 必须在初始化列表中初始化
// var p3 = new Person("John") { LastName = "Doe" };

// 正确:构造函数标记了 [SetsRequiredMembers]
var p4 = new Person("John", "Doe");

// 正确:构造函数标记了 [SetsRequiredMembers]
var p5 = new Person("John", "Doe", 30);

3.3.3. 建议

  1. 优先使用主构造函数:对于 record 类型,尽可能通过主构造函数来满足 required 成员的初始化。
  2. 慎用**[SetsRequiredMembers],该特性违背设计原则,易引发成员缺失问题。**​
  3. 考虑工厂模式:对于复杂的对象创建逻辑,可以考虑使用静态工厂方法来替代多个重载的构造函数。

3.4. 高级主题与注意事项

3.4.1. requiredinit

requiredinit组合表达了“这个属性必须在初始化时赋值,且赋值后不可更改”的清晰意图。

public class ImmutablePerson
{
    public required string FirstName { get; init; } // 只能在初始化时赋值
    public required string LastName { get; init; }
}

var person = new ImmutablePerson { FirstName = "John", LastName = "Doe" };
// person.FirstName = "Jane"; // 这行会编译错误,因为 init 不允许之后修改

3.4.2. required 与字段

required 也可以用于实例字段,但不能用于 staticconst 字段。

public struct Point
{
    public required double X;
    public required double Y;
}

3.4.3. required 与继承

  • 派生类必须满足基类的 required 成员要求。
  • 派生类可以添加自己的 required 成员。
  • 如果派生类的构造函数希望完全负责所有(包括基类)required 成员的初始化,它必须调用一个标记了 [SetsRequiredMembers] 的基类构造函数,或者自己标记 [SetsRequiredMembers] 并为所有成员赋值。
// dotnet run
// 编译器:.NET 7+ / C# 11

using System.Diagnostics.CodeAnalysis;

namespace RequiredInheritanceDemo;

// 1) 基类
public abstract class Animal
{
    // 基类要求:创建时必须给 Name 赋值
    public required string Name { get; init; }
}

// 2) 派生类
public class Cat : Animal
{
    // 规则1:不能移除基类 required——这里如果写“override string Name”会 CS9030。
    //public override string Name { get; init; }

    // 规则2:派生类可以新增自己的 required 成员
    public required int Lives { get; init; }

    // 规则3:如果派生构造函数想让调用端“跳过”基类 required 成员,
    //       必须同时给基类成员赋值,并标记 [SetsRequiredMembers]
    [SetsRequiredMembers]   // 告诉编译器“这个 ctor 会搞定所有 required” ,如果没有这个标记,编译器会报cs9035错。
    public Cat(string name = "DefaultCat", int lives = 9)
    {
        // 先满足基类 required
        Name = name; //如果注释掉,cs8618提示,在退出构造函数时,不可为 null 的 属性 "Name" 必须包含非 null 值。请考虑添加 "required" 修饰符或将该 属性 声明为可为 null。
        // 再满足派生类自己新增的 required
        Lives = lives;
    }
}

// 3) 测试
internal static class Program
{
    private static void Main()
    {
        // 3-a 正常对象初始化器写法——必须给所有 required 赋值
        var a1 = new Cat { Name = "Tom", Lives = 7 };
        Console.WriteLine($"a1: {a1.Name}, lives={a1.Lives}");

        // 3-b 使用“跳过”构造函数——不需要再写初始化器
        var a2 = new Cat();               // 编译通过
        var a3 = new Cat("Garfield");     // 编译通过
        Console.WriteLine($"a2: {a2.Name}, lives={a2.Lives}");
        Console.WriteLine($"a3: {a3.Name}, lives={a3.Lives}");
    }
}
a1: Tom, lives=7
a2: DefaultCat, lives=9
a3: Garfield, lives=9

3.4.4. 反射与序列化

  • 反射:可以通过 RequiredMemberAttribute 在运行时检查成员是否是必需的。
  • 序列化:现代序列化库(如 System.Text.Json)支持 required 成员,如果在反序列化时缺少必需成员,会抛出异常。
using System;
using System.Linq;
using System.Reflection;
using System.Text.Json;

// 定义包含 required 成员的类
public class Person
{
    public required string Name { get; set; }
    public required int Age { get; set; }
    public string? Address { get; set; } // 非必需成员
}

class Program
{
    static void Main()
    {
        // 1. 反射示例:检查必需成员
        Console.WriteLine("=== 反射检查 ===");
        foreach (var prop in typeof(Person).GetProperties())
        {
            // 检查是否包含 RequiredMemberAttribute
            var isRequired = prop.CustomAttributes
                .Any(a => a.AttributeType.Name.Contains("RequiredMember"));
            
            Console.WriteLine($"{prop.Name}: {(isRequired ? "必需" : "可选")}");
        }

        // 2. 序列化示例
        Console.WriteLine("\n=== 序列化测试 ===");
        
        // 有效JSON(包含所有必需成员)
        string validJson = @"{
            ""Name"": ""张三"",
            ""Age"": 25,
            ""Address"": ""北京""
        }";

        // 无效JSON(缺少Age成员)
        string invalidJson = @"{
            ""Name"": ""张三""
        }";

        try
        {
            // 成功反序列化
            var person = JsonSerializer.Deserialize<Person>(validJson);
            Console.WriteLine($"成功解析:{person?.Name}, {person?.Age}岁");

            // 会抛出异常
            var invalidPerson = JsonSerializer.Deserialize<Person>(invalidJson);
        }
        catch (JsonException ex)
        {
            Console.WriteLine($"解析失败:{ex.Message}");
        }
    }
}
=== 反射检查 ===
Name: 必需
Age: 必需
Address: 可选

=== 序列化测试 ===
成功解析:张三, 25岁
解析失败:JSON deserialization for type 'Person' was missing required properties, including the following: Age

3.5. C# 11 之前的替代方案

在没有 required 关键字时,开发者通常使用以下模式来强制属性初始化:

  1. 构造函数参数:最经典的方式,但在参数过多时会变得臃肿。
  2. Fluent Builder 模式:提供了良好的可读性,但需要编写额外的构建器类。
  3. 可空引用类型(NRT)警告:依赖编译器警告来发现未初始化的非空属性,但并非强制错误。

3.6. 典型应用场景

  1. DTOs 和 API 模型:确保客户端传递了所有必需的字段。
  2. 配置对象:保证应用程序启动时加载了所有必需的配置项(如连接字符串)。
  3. 领域模型:强制实体在创建时就必须包含其核心属性(如 Order 必须有 CustomerId)。
  4. 配合可空引用类型 (NRT)required 是对 NRT 的完美补充,它将“非空”的承诺从编译时警告升级为了强制的初始化要求。

3.7. 限制与常见问题

  • 版本要求:需要 C# 11 / .NET 7 或更高版本。
  • 接口:不能在接口中定义 required 成员。
  • 默认值required 成员不能有默认值,因为这与“必须由调用方显式赋值”的语义相悖。

4. 相关特性回顾

为了更全面地理解 C# 中的不可变性生态,以下几个相关特性值得回顾:

  • 只读属性 (Getter-only Properties)

    在 C# 6.0 中引入,只能在构造函数中赋值。init 可以看作是它的演进版本,增加了对对象初始化器的支持。

    public class OldSchoolImmutable
    {
        public string Name { get; } // 没有 setter
        public OldSchoolImmutable(string name)
        {
            Name = name; // 只能在构造函数中赋值
        }
    }
    
  • readonly** 结构体 (readonly struct)**

    C# 7.2 引入,保证结构体的所有实例成员都不会修改其状态,是实现高性能、不可变值类型的关键。

    public readonly struct Point
    {
        public Point(double x, double y) 
        {
            X = x;
            Y = y;
        }
        public double X { get; } // 属性也是只读的
        public double Y { get; }
    }
    
  • 不可变集合 (Immutable Collections)

    位于 System.Collections.Immutable 命名空间,提供了如 ImmutableList<T>ImmutableDictionary<TKey, TValue> 等线程安全的、真正不可变的集合类型。

    它们是实现深层不可变性的重要工具。

    public class DataContainer
    {
        public ImmutableArray<int> Scores { get; init; } = ImmutableArray<int>.Empty;
    }
    // 初始化后,无法修改 Scores 数组中的元素
    

5. 最佳实践与设计建议

  1. 优先选择不可变性:在设计 DTO、配置对象和值对象时,默认使用 recordinit 来创建不可变类型。这能从根本上减少 bug,提升线程安全性。
  2. required 明确契约:对于任何在对象生命周期内都必须存在的属性,使用 required 来强制初始化,让编译器成为你数据完整性的守护者。
  3. 警惕“浅”不可变initreadonly 只保证引用本身不变。如果属性是可变引用类型(如 List<T>),考虑使用不可变集合(ImmutableList<T>)或将其封装在只读接口(IReadOnlyList<T>)后暴露,以实现更深层次的不可变性。
  4. 性能考量:不可变对象在修改时会创建新实例(如 with 表达式)。在绝大多数业务场景中,这带来的健壮性收益远超其微小的性能开销。但在极端性能敏感的热路径代码中,仍需进行性能评估。

6. 总结

C# 的 initrecordrequired 特性协同工作,旨在简化数据模型的创建,并强制实现不可变性和必需初始化。

  • init 访问器是实现不可变性的基石,它允许在享受对象初始化器便利性的同时,锁定对象创建后的状态。
  • recordinit 的基础上更进一步,通过自动生成样板代码(如值相等性比较、ToString),简化了数据载体的定义,是 DTO 和领域值对象的最佳选择。
  • required修饰符则为初始化过程提供了编译期的安全保障,确保了对象在创建时就处于一个有效的、完整的状态,从而显著减少了运行时的 NullReferenceException
  • initrequiredrequired 强制必须初始化,init 确保初始化后不可更改。两者结合,提供了最强的初始化契约。