记录(C# 参考)

从 C# 9 开始,可以使用 record 关键字定义一个record,用来提供用于封装数据的内置功能。 C# 10 允许 record class 语法作为同义词来阐明引用类型,并允许 record struct 使用相同功能定义值类型。 通过使用位置参数或标准属性语法,可以创建具有不可变属性的记录类型。

以下两个示例演示 record(或 record class)引用类型:

public record Person(string FirstName, string LastName);
public record Person
{
    public string FirstName { get; init; } = default!;
    public string LastName { get; init; } = default!;
};

以下两个示例演示 record struct 值类型:

public readonly record struct Point(double X, double Y, double Z);
public record struct Point
{
    public double X {  get; init; }
    public double Y {  get; init; }
    public double Z {  get; init; }
}

此外,还可以创建具有可变属性和字段的记录:

public record Person
{
    public string FirstName { get; set; } = default!;
    public string LastName { get; set; } = default!;
};

记录结构也可以是可变的,包括位置记录结构和没有位置参数的记录结构:

public record struct DataMeasurement(DateTime TakenAt, double Measurement);
public record struct Point
{
    public double X { get; set; }
    public double Y { get; set; }
    public double Z { get; set; }
}

虽然记录可以是可变的,但它们主要用于支持不可变的数据模型。 记录类型提供以下功能:

前面的示例展示了引用类型记录和值类型记录之间的一些区别:

  • recordrecord class 声明引用类型。 class 关键字是可选项,但可以为读者提高清晰度。 record struct 声明值类型。
  • 位置属性在 readonly record struct 中不可变。 它们在 中可变。

本文的其余部分将讨论 record classrecord struct 类型。 每个部分都详细说明了这些差异。 你应该在 record classrecord struct 之间作出决定,该过程类似于在 classstruct 之间作出决定。 record 一词用于描述应用于所有记录类型的行为。 record structrecord class 用于描述仅适用于 struct 或 class 类型的行为。 record 类型是在 C# 9 中推出的;record struct 类型是在 C# 10 中推出的。

属性定义的位置语法

在创建实例时,可以使用位置参数来声明记录的属性,并初始化属性值:

public record Person(string FirstName, string LastName);

public static void Main()
{
    Person person = new("Nancy", "Davolio");
    Console.WriteLine(person);
    // output: Person { FirstName = Nancy, LastName = Davolio }
}

当你为属性定义使用位置语法时,编译器将创建以下内容:

  • 为记录声明中提供的每个位置参数提供一个公共的自动实现的属性。
    • 对于 recordreadonly record struct 类型,为 record 属性。
    • 对于 record struct 类型,为读写属性。
  • 主构造函数,它的参数与记录声明上的位置参数匹配。
  • 对于 record struct 类型,则是将每个字段设置为其默认值的无参数构造函数。
  • 一个 Deconstruct 方法,对记录声明中提供的每个位置参数都有一个 out 参数。 此方法解构了使用位置语法定义的属性;它忽略了使用标准属性语法定义的属性。

你可能希望向编译器根据记录定义创建的所有这些元素添加特性。 你可以向应用于位置记录属性的任何特性添加目标。 以下示例将向 Person 记录的每个属性应用 System.Text.Json.Serialization.JsonPropertyNameAttributeproperty: 目标指示该特性应用于编译器生成的属性。 其他值包括将该特性应用于字段的 field:,以及将该特性应用于参数的 param:

/// <summary>
/// Person record type
/// </summary>
/// <param name="FirstName">First Name</param>
/// <param name="LastName">Last Name</param>
/// <remarks>
/// The person type is a positional record containing the
/// properties for the first and last name. Those properties
/// map to the JSON elements "firstName" and "lastName" when
/// serialized or deserialized.
/// </remarks>
public record Person([property: JsonPropertyName("firstName")]string FirstName, 
    [property: JsonPropertyName("lastName")]string LastName);

上面的示例还演示了如何为记录创建 XML 文档注释。 你可以添加 <param> 标记,以添加主要构造函数参数的文档。

如果生成的自动实现的属性定义并不是你所需要的,你可以自行定义同名的属性。 例如,你可能想要更改可访问性或可变性,或者为 getset 访问器提供实现。 如果在源中声明属性,则必须从记录的位置参数初始化该属性。 如果属性是自动实现的属性,则必须初始化该属性。 如果在源中添加支持字段,则必须初始化支持字段。 生成的析构函数将使用属性定义。 例如,下面的示例声明了位置记录 publicFirstNameLastName 属性,但将 Id 位置参数限制为 internal。 可以对记录和 record struct 类型使用此语法。

public record Person(string FirstName, string LastName, string Id)
{
    internal string Id { get; init; } = Id;
}

public static void Main()
{
    Person person = new("Nancy", "Davolio", "12345");
    Console.WriteLine(person.FirstName); //output: Nancy

}

记录类型不需要声明任何位置属性。 你可以在没有任何位置属性的情况下声明一个记录,也可以声明其他字段和属性,如以下示例中所示:

public record Person(string FirstName, string LastName)
{
    public string[] PhoneNumbers { get; init; } = Array.Empty<string>();
};

如果使用标准属性语法来定义属性,但省略了访问修饰符,这些属性将隐式地 private

不可变性

positional record 和 positional readonly record struct 声明 init-only 属性 。 positional record struct 声明 read-write 属性。 可以替代这些默认值中的任何一个,如上个部分中所示。

如果你需要一个以数据为中心的类型是线程安全的,或者需要使哈希表中的哈希代码保持不变,那么不可变性很有用。 但不可变性并不是适用于所有的数据场景。 例如,Entity Framework Core 就不支持通过不可变实体类型进行更新。

init-only 属性无论是通过位置参数(record classreadonly record struct)创建的,还是通过指定 init 访问器创建的,都具有浅的不可变性。 初始化后,将不能更改值型属性的值或引用型属性的引用。 不过,引用型属性引用的数据是可以更改的。 下面的示例展示了引用型不可变属性的内容(本例中是数组)是可变的:

public record Person(string FirstName, string LastName, string[] PhoneNumbers);

public static void Main()
{
    Person person = new("Nancy", "Davolio", new string[1] { "555-1234" });
    Console.WriteLine(person.PhoneNumbers[0]); // output: 555-1234

    person.PhoneNumbers[0] = "555-6789";
    Console.WriteLine(person.PhoneNumbers[0]); // output: 555-6789
}

记录类型特有的功能是由编译器合成的方法实现的,这些方法都不会通过修改对象状态来影响不可变性。 除非另行指定,否则将为 recordrecord structreadonly record struct 声明生成综合方法。

值相等性

如果不替代或替换相等性方法,则声明的类型将控制如何定义相等性:

  • 对于 class 类型,如果两个对象引用内存中的同一对象,则这两个对象相等。
  • 对于 struct 类型,如果两个对象是相同的类型并且存储相同的值,则这两个对象相等。
  • 对于 record 类型(包括 record structreadonly record struct),如果两个对象是相同的类型并且存储相同的值,则这两个对象相等。

record struct 的相等性定义与 struct 的相等性定义相同。 不同之处在于,对于 struct,实现处于 ValueType.Equals(Object) 中并且依赖反射。 对于记录,实现由编译器合成,并且使用声明的数据成员。

一些数据模型需要引用相等性。 例如,Entity Framework Core 依赖于引用相等性来确保它对概念上是一个实体的实体类型只使用一个实例。 因此,记录和记录结构不适合用作 Entity Framework Core 中的实体类型。

下面的示例说明了记录类型的值相等性:

public record Person(string FirstName, string LastName, string[] PhoneNumbers);

public static void Main()
{
    var phoneNumbers = new string[2];
    Person person1 = new("Nancy", "Davolio", phoneNumbers);
    Person person2 = new("Nancy", "Davolio", phoneNumbers);
    Console.WriteLine(person1 == person2); // output: True

    person1.PhoneNumbers[0] = "555-1234";
    Console.WriteLine(person1 == person2); // output: True

    Console.WriteLine(ReferenceEquals(person1, person2)); // output: False
}

为了实现值相等性,编译器合成了几种方法,包括:

  • Object.Equals(Object) 的替代。 如果显式声明此替代,则会出现错误。

    当两个参数都为非 NULL 时,此方法被用作 Object.Equals(Object, Object) 静态方法的基础。

  • virtualsealedEquals(R? other),其中 R 是记录类型。 此方法实现 IEquatable<T>。 可以显式声明此方法。

  • 如果记录类型派生自基础记录类型 Base,则为 Equals(Base? other)。 如果显式声明此替代,则会出现错误。 如果提供自己的 Equals(R? other) 实现,则还应提供 GetHashCode 的实现。

  • Object.GetHashCode() 的替代。 可以显式声明此方法。

  • 运算符 ==!= 的替代。 如果显式声明这些运算符,则会出现错误。

  • 如果记录类型派生自基础记录类型,则为 protected override Type EqualityContract { get; };。 可以显式声明此属性。 有关详细信息,请参阅继承层次结构中的相等性

如果记录类型的方法与允许显式声明的合成方法的签名匹配,则编译器不会合成该方法。

非破坏性变化

如果需要复制包含一些修改的实例,可以使用 with 表达式来实现非破坏性变化。 with 表达式创建一个新的记录实例,该实例是现有记录实例的一个副本,修改了指定属性和字段。 使用对象初始值设定项语法来指定要更改的值,如以下示例中所示:

public record Person(string FirstName, string LastName)
{
    public string[] PhoneNumbers { get; init; }
}

public static void Main()
{
    Person person1 = new("Nancy", "Davolio") { PhoneNumbers = new string[1] };
    Console.WriteLine(person1);
    // output: Person { FirstName = Nancy, LastName = Davolio, PhoneNumbers = System.String[] }

    Person person2 = person1 with { FirstName = "John" };
    Console.WriteLine(person2);
    // output: Person { FirstName = John, LastName = Davolio, PhoneNumbers = System.String[] }
    Console.WriteLine(person1 == person2); // output: False

    person2 = person1 with { PhoneNumbers = new string[1] };
    Console.WriteLine(person2);
    // output: Person { FirstName = Nancy, LastName = Davolio, PhoneNumbers = System.String[] }
    Console.WriteLine(person1 == person2); // output: False

    person2 = person1 with { };
    Console.WriteLine(person1 == person2); // output: True
}

with 表达式可以设置位置属性或使用标准属性语法创建的属性。 非位置属性必须有一个 initset 访问器才能在 with 表达式中进行更改。

with 表达式的结果是一个浅的副本,这意味着对于引用属性,只复制对实例的引用。 原始记录和副本最终都具有对同一实例的引用。

为了对 record class 类型实现此功能,编译器合成了一个克隆方法和一个复制构造函数。 虚拟克隆方法返回由复制构造函数初始化的新记录。 当使用 with 表达式时,编译器将创建调用克隆方法的代码,然后设置 with 表达式中指定的属性。

如果你需要不同的复制行为,可以在 record class 中编写自己的复制构造函数。 如果你这样做,编译器将不会合成复制构造函数。 如果记录是 sealed,则使构造函数为 private,否则使其为 protected。 编译器不会针对 record struct 类型合成复制构造函数。 虽然你可以编写一个,但编译器不会为 with 表达式生成对它的调用。 record struct 的值在分配时进行复制。

你不能替代克隆方法,也不能在任意记录类型中创建名为 Clone 的成员。 克隆方法的实际名称是由编译器生成的。

用于显示的内置格式设置

记录类型具有编译器生成的 ToString 方法,可显示公共属性和字段的名称和值。 ToString 方法返回一个格式如下的字符串:

<record type name> { <property name> = <value>, <property name> = <value>, ...}

<value> 打印的字符串是 ToString() 针对属性类型返回的字符串。 在以下示例中,ChildNamesSystem.Array,其中 ToString 返回 System.String[]

Person { FirstName = Nancy, LastName = Davolio, ChildNames = System.String[] }

为了实现此功能,编译器在 record class 类型中合成了一个虚拟 PrintMembers 方法和一个 ToString 替代。 此成员在 record struct 类型中为 privateToString 替代创建了一个 StringBuilder 对象,它的类型名称后跟一个左括号。 它调用 PrintMembers 以添加属性名称和值,然后添加右括号。 下面的示例展示了类似于合成替代中包含的代码:

public override string ToString()
{
    StringBuilder stringBuilder = new StringBuilder();
    stringBuilder.Append("Teacher"); // type name
    stringBuilder.Append(" { ");
    if (PrintMembers(stringBuilder))
    {
        stringBuilder.Append(" ");
    }
    stringBuilder.Append("}");
    return stringBuilder.ToString();
}

你可以提供自己的 PrintMembers 实现或 ToString 替代。 本文后面的派生记录中的 格式设置一节中提供了示例。 在 C# 10 及更高版本中,ToString 的实现有可能包含 sealed 修饰符,这会阻止编译器为任何派生记录合成一个 ToString 实现。 为此,可以在 record 类型的层次结构中创建一致的字符串表示形式。 (派生记录仍会为所有派生属性生成一个 PrintMembers 方法。)

继承

本部分仅适用于 record class 类型。

一条记录可以从另一条记录继承。 但是,记录不能从类继承,类也不能从记录继承。

派生记录类型中的位置参数

派生记录为基本记录主构造函数中的所有参数声明位置参数。 基本记录声明并初始化这些属性。 派生记录不会隐藏它们,而只会创建和初始化未在其基本记录中声明的参数的属性。

下面的示例说明了具有位置属性语法的继承:

public abstract record Person(string FirstName, string LastName);
public record Teacher(string FirstName, string LastName, int Grade)
    : Person(FirstName, LastName);
public static void Main()
{
    Person teacher = new Teacher("Nancy", "Davolio", 3);
    Console.WriteLine(teacher);
    // output: Teacher { FirstName = Nancy, LastName = Davolio, Grade = 3 }
}

继承层次结构中的相等性

本部分适用于 record class 类型,但不适用于 record struct 类型。 要使两个记录变量相等,运行时类型必须相等。 包含变量的类型可能不同。 下面的代码示例演示了继承的相等性比较:

public abstract record Person(string FirstName, string LastName);
public record Teacher(string FirstName, string LastName, int Grade)
    : Person(FirstName, LastName);
public record Student(string FirstName, string LastName, int Grade)
    : Person(FirstName, LastName);
public static void Main()
{
    Person teacher = new Teacher("Nancy", "Davolio", 3);
    Person student = new Student("Nancy", "Davolio", 3);
    Console.WriteLine(teacher == student); // output: False

    Student student2 = new Student("Nancy", "Davolio", 3);
    Console.WriteLine(student2 == student); // output: True
}

在此示例中,所有变量都声明为 Person,即使该实例是 StudentTeacher 的派生类型也是如此。 这些实例都具有相同的属性和相同的属性值。 但 student == teacher 返回 False(尽管这两个都是 Person 型变量),student == student2 返回 True(尽管一个是 Person 变量,而另一个是 Student 变量)。 相等性测试依赖于实际对象的运行时类型,而不是声明的变量类型。

为实现此行为,编译器合成了一个 EqualityContract 属性,该属性返回一个与记录类型匹配的 Type 对象。 利用 EqualityContract相等性方法在检查相等性时,就可以比较对象的运行时类型。 如果记录的基类型为 object,则此属性为 virtual。 如果基类型是其他记录类型,则此属性是一个替代。 如果记录类型为 sealed,则此属性实际上是 sealed,因为类型是 sealed

在比较派生类型的两个实例时,合成的相等性方法会检查基类型和派生类型的所有属性是否相等。 合成的 GetHashCode 方法从基类型和派生的记录类型中声明的所有属性和字段中使用 GetHashCode 方法。

派生记录中的 with 表达式

with 表达式结果的运行时间类型与表达式操作数相同: 运行时类型的所有属性都会被复制,但你只能设置编译时类型的属性,如下面的示例所示:

public record Point(int X, int Y)
{
    public int Zbase { get; set; }
};
public record NamedPoint(string Name, int X, int Y) : Point(X, Y)
{
    public int Zderived { get; set; }
};

public static void Main()
{
    Point p1 = new NamedPoint("A", 1, 2) { Zbase = 3, Zderived = 4 };

    Point p2 = p1 with { X = 5, Y = 6, Zbase = 7 }; // Can't set Name or Zderived
    Console.WriteLine(p2 is NamedPoint);  // output: True
    Console.WriteLine(p2);
    // output: NamedPoint { X = 5, Y = 6, Zbase = 7, Name = A, Zderived = 4 }

    Point p3 = (NamedPoint)p1 with { Name = "B", X = 5, Y = 6, Zbase = 7, Zderived = 8 };
    Console.WriteLine(p3);
    // output: NamedPoint { X = 5, Y = 6, Zbase = 7, Name = B, Zderived = 8 }
}

派生记录中的 PrintMembers 格式设置

派生记录类型的合成 PrintMembers 方法调用基实现。 结果是派生类型和基类型的所有公共属性和字段都包含在 ToString 输出中,如以下示例中所示:

public abstract record Person(string FirstName, string LastName);
public record Teacher(string FirstName, string LastName, int Grade)
    : Person(FirstName, LastName);
public record Student(string FirstName, string LastName, int Grade)
    : Person(FirstName, LastName);

public static void Main()
{
    Person teacher = new Teacher("Nancy", "Davolio", 3);
    Console.WriteLine(teacher);
    // output: Teacher { FirstName = Nancy, LastName = Davolio, Grade = 3 }
}

你可以提供自己的 PrintMembers 方法的实现。 如果这样做,请使用以下签名:

  • 对于派生自 objectsealed 记录(不声明基本记录):使用 private bool PrintMembers(StringBuilder builder)
  • 对于从另一个记录派生的 sealed 记录(注意封闭类型是 sealed,因此该方法实际上是 sealed):protected override bool PrintMembers(StringBuilder builder)
  • 对于非 sealed 且派生自对象的记录:使用 protected virtual bool PrintMembers(StringBuilder builder);
  • 对于非 sealed 且派生自其他记录的记录:使用 protected override bool PrintMembers(StringBuilder builder);

下面的代码示例替换了合成的 PrintMembers 方法,一个是派生自对象的记录类型,另一个是派生自另一条记录的记录类型:

public abstract record Person(string FirstName, string LastName, string[] PhoneNumbers)
{
    protected virtual bool PrintMembers(StringBuilder stringBuilder)
    {
        stringBuilder.Append($"FirstName = {FirstName}, LastName = {LastName}, ");
        stringBuilder.Append($"PhoneNumber1 = {PhoneNumbers[0]}, PhoneNumber2 = {PhoneNumbers[1]}");
        return true;
    }
}

public record Teacher(string FirstName, string LastName, string[] PhoneNumbers, int Grade)
    : Person(FirstName, LastName, PhoneNumbers)
{
    protected override bool PrintMembers(StringBuilder stringBuilder)
    {
        if (base.PrintMembers(stringBuilder))
        {
            stringBuilder.Append(", ");
        };
        stringBuilder.Append($"Grade = {Grade}");
        return true;
    }
};

public static void Main()
{
    Person teacher = new Teacher("Nancy", "Davolio", new string[2] { "555-1234", "555-6789" }, 3);
    Console.WriteLine(teacher);
    // output: Teacher { FirstName = Nancy, LastName = Davolio, PhoneNumber1 = 555-1234, PhoneNumber2 = 555-6789, Grade = 3 }
}

注意

在 C# 10 及更高版本中,即便基记录密封了 ToString 方法,编译器也会在派生的记录中合成 PrintMembers。 还可以创建自己的 PrintMembers 实现。

派生记录中的解构函数行为

派生记录的 Deconstruct 方法返回编译时类型所有位置属性的值。 如果变量类型为基本记录,则只解构基本记录属性,除非该对象强制转换为派生类型。 下面的示例演示了如何对派生记录调用解构函数。

public abstract record Person(string FirstName, string LastName);
public record Teacher(string FirstName, string LastName, int Grade)
    : Person(FirstName, LastName);
public record Student(string FirstName, string LastName, int Grade)
    : Person(FirstName, LastName);

public static void Main()
{
    Person teacher = new Teacher("Nancy", "Davolio", 3);
    var (firstName, lastName) = teacher; // Doesn't deconstruct Grade
    Console.WriteLine($"{firstName}, {lastName}");// output: Nancy, Davolio

    var (fName, lName, grade) = (Teacher)teacher;
    Console.WriteLine($"{fName}, {lName}, {grade}");// output: Nancy, Davolio, 3
}

泛型约束

没有任何泛型约束要求类型是记录。 记录满足 classstruct 约束。 要对记录类型的特定层次结构进行约束,请像对基类一样对基本记录进行约束。 有关详细信息,请参阅类型参数的约束

C# 语言规范

有关详细信息,请参阅 C# 语言规范中的部分。

若要详细了解 C# 9 及更高版本中引入的功能,请参阅以下功能建议说明:

另请参阅