创建记录类型

记录是使用基于值的相等性的类型。 可以将记录定义为引用类型或值类型。 如果记录类型定义相同,并且每个字段的值在两个记录中都相等,那么这两个记录类型的变量是相等的。 如果引用的对象是相同的类类型,并且变量引用同一对象,则类类型的两个变量相等。 基于值的相等性意味着可能需要的记录类型中的其他功能。 声明 record 而不是 class时,编译器会生成其中许多成员。 编译器为 record struct 类型生成相同的方法。

您将在本教程中学习如何:

  • 确定是否将 record 修饰符添加到 class 类型。
  • 声明记录类型和位置记录类型。
  • 在记录中将你的方法替换为编译器生成的方法。

先决条件

需要将计算机设置为运行 .NET 6 或更高版本。 Visual Studio 2022.NET SDK中提供了 C# 编译器。

记录的特征

要定义 记录,需要先使用 record 关键字来声明一种类型,然后修改 classstruct 声明。 (可选)可以省略 class 关键字来创建 record class。 记录遵循基于值的相等语义。 为了强制实施值语义,编译器会为记录类型生成多种方法(适用于 record class 类型和 record struct 类型):

记录还提供了 Object.ToString() 的重写。 编译器使用 Object.ToString()生成用于显示记录的方法。 在编写本教程的代码时,你将浏览这些成员。 记录支持 with 表达式,以启用记录的非破坏性修改。

你还可以使用更简洁的语法声明 位置记录。 声明位置记录时,编译器会为你合成更多方法:

  • 一个主构造函数,其参数与记录声明上的位置参数匹配。
  • 主构造函数的每个参数的公共属性。 对于 record classreadonly record struct 类型,这些属性为 init-only。 对于 record struct 类型,它们是可读写的
  • 用于从记录中提取属性的 Deconstruct 方法。

生成温度数据

数据和统计信息是需要使用记录的场景之一。 在本教程中,你将构建一个用于计算度日数的应用程序,以用于不同用途度日数是反映几天、几周或几个月内采暖(或采暖不足)的度量。 度日数可跟踪和预测能源使用情况。 更热的日子意味着更多的空调,更冷的日子意味着更多的炉子使用。 度日数有助于管理植物种群,并且随着季节的变化,与植物的生长密切相关。 度日数有助于跟踪动物为适应气候而进行的物种迁徙。

公式基于给定日的平均温度和基线温度。 若要计算一段时间内的度日数,需要这段时间的每日最高温度和最低温度。 首先创建一个新应用程序。 创建新的控制台应用程序。 在名为“DailyTemperature.cs”的新文件中创建新记录类型:

public readonly record struct DailyTemperature(double HighTemp, double LowTemp);

前面的代码定义一个 位置记录。 由于不打算从 DailyTemperature 记录继承并且该记录应该不可变,因此该记录为 readonly record structHighTempLowTemp 属性是 init-only 属性,这意味着可在构造函数中设置它们,或使用属性初始化表达式设置它们。 如果希望位置参数是可读可写的,则应该声明 record struct 而不是 readonly record structDailyTemperature 类型还具有一个 主构造函数,该构造函数具有两个与两个属性匹配的参数。 使用该主构造函数初始化 DailyTemperature 记录。 以下代码创建并初始化多个 DailyTemperature 记录。 第一个使用命名参数来阐明 HighTempLowTemp。 剩余的初始值设定项使用位置参数来初始化 HighTempLowTemp

private static DailyTemperature[] data = [
    new DailyTemperature(HighTemp: 57, LowTemp: 30), 
    new DailyTemperature(60, 35),
    new DailyTemperature(63, 33),
    new DailyTemperature(68, 29),
    new DailyTemperature(72, 47),
    new DailyTemperature(75, 55),
    new DailyTemperature(77, 55),
    new DailyTemperature(72, 58),
    new DailyTemperature(70, 47),
    new DailyTemperature(77, 59),
    new DailyTemperature(85, 65),
    new DailyTemperature(87, 65),
    new DailyTemperature(85, 72),
    new DailyTemperature(83, 68),
    new DailyTemperature(77, 65),
    new DailyTemperature(72, 58),
    new DailyTemperature(77, 55),
    new DailyTemperature(76, 53),
    new DailyTemperature(80, 60),
    new DailyTemperature(85, 66) 
];

可以将自己的属性或方法添加到记录,包括位置记录。 你需要计算每天的平均温度。 可以将该属性添加到 DailyTemperature 记录:

public readonly record struct DailyTemperature(double HighTemp, double LowTemp)
{
    public double Mean => (HighTemp + LowTemp) / 2.0;
}

让我们一起确保你可以使用这些数据。 将以下代码添加到 Main 方法:

foreach (var item in data)
    Console.WriteLine(item);

运行应用程序,你会看到类似于以下显示的输出(删除了几行空间):

DailyTemperature { HighTemp = 57, LowTemp = 30, Mean = 43.5 }
DailyTemperature { HighTemp = 60, LowTemp = 35, Mean = 47.5 }


DailyTemperature { HighTemp = 80, LowTemp = 60, Mean = 70 }
DailyTemperature { HighTemp = 85, LowTemp = 66, Mean = 75.5 }

上述代码显示了由编译器合成的 ToString 的替代输出。 如果你更喜欢不同的文本,可以编写自己的 ToString 版本,以防止编译器为你合成版本。

计算度日数

若要计算度日数,需要获得给定的某一天的基准温度和平均温度之间的差额。 若要测量随时间推移的热量,请丢弃平均温度低于基线的任何日期。 若要测量一段时间内的冷度,请放弃平均温度高于基线的任何日期。 例如,美国将 65 华氏度作为供暖和制冷度日计算的基准。 这是不需要加热或冷却的温度。 如果一天的平均温度为 70 F,那天是五度冷却日和零加热度日。 相反,如果平均温度为 55 华氏度,那么那一天是 10 个采暖度日和 0 个制冷度日。

可将这些公式表示为记录类型的小型层次结构:一种抽象度日数类型以及两种具体的采暖度日数和制冷度日数类型。 这些类型也可以是位置记录。 它们采用基线温度和每日温度记录序列作为主要构造函数的参数:

public abstract record DegreeDays(double BaseTemperature, IEnumerable<DailyTemperature> TempRecords);

public sealed record HeatingDegreeDays(double BaseTemperature, IEnumerable<DailyTemperature> TempRecords)
    : DegreeDays(BaseTemperature, TempRecords)
{
    public double DegreeDays => TempRecords.Where(s => s.Mean < BaseTemperature).Sum(s => BaseTemperature - s.Mean);
}

public sealed record CoolingDegreeDays(double BaseTemperature, IEnumerable<DailyTemperature> TempRecords)
    : DegreeDays(BaseTemperature, TempRecords)
{
    public double DegreeDays => TempRecords.Where(s => s.Mean > BaseTemperature).Sum(s => s.Mean - BaseTemperature);
}

抽象 DegreeDays 记录是 HeatingDegreeDaysCoolingDegreeDays 记录的共享基类。 派生记录上的主要构造函数声明显示如何管理基本记录初始化。 派生记录为基本记录主构造函数中的所有参数声明参数。 基记录声明并初始化这些属性。 派生记录不会隐藏它们,但只会为其基记录中未声明的参数创建和初始化属性。 在此示例中,派生记录不会添加新的主构造函数参数。 将以下代码添加到 Main 方法中以对代码进行测试:

var heatingDegreeDays = new HeatingDegreeDays(65, data);
Console.WriteLine(heatingDegreeDays);

var coolingDegreeDays = new CoolingDegreeDays(65, data);
Console.WriteLine(coolingDegreeDays);

您将看到如下所示的输出:

HeatingDegreeDays { BaseTemperature = 65, TempRecords = record_types.DailyTemperature[], DegreeDays = 85 }
CoolingDegreeDays { BaseTemperature = 65, TempRecords = record_types.DailyTemperature[], DegreeDays = 71.5 }

定义由编译器自动生成的方法

代码将计算该时间段内正确的取暖度和冷却度天数。 但此示例展示了为何需要替换记录的某些合成方法。 可以在记录类型中声明自己的任何编译器合成方法版本,但克隆方法除外。 克隆方法具有编译器生成的名称,不能提供不同的实现。 这些合成方法包括复制构造函数、System.IEquatable<T> 接口的成员、相等性和不相等测试以及 GetHashCode()。 为此,可以合成 PrintMembers。 你还可以声明自己的 ToString,但 PrintMembers 为继承方案提供了更好的选择。 若要提供自己的合成方法版本,签名必须与合成方法匹配。

控制台输出中的 TempRecords 元素不起作用。 它显示类型,但不显示其他任何东西。 可以通过提供您自己的合成 PrintMembers 方法的实现来更改此行为。 签名取决于应用于 record 声明的修饰符:

  • 如果记录类型为 sealedrecord struct,则签名 private bool PrintMembers(StringBuilder builder);
  • 如果记录类型不是 sealed 并且从 object 派生(即,它不声明基记录),那么签名是 protected virtual bool PrintMembers(StringBuilder builder);
  • 如果记录类型不是 sealed 且派生自另一条记录,则签名为 protected override bool PrintMembers(StringBuilder builder);

通过了解 PrintMembers的目的,可以更容易理解这些规则。 PrintMembers 向字符串添加记录类型中每个属性的相关信息。 该协定要求基本记录添加其要显示的成员,并假设派生成员将添加其成员。 每个记录类型都会合成一个 ToString 替代,与下面的 HeatingDegreeDays 示例类似:

public override string ToString()
{
    StringBuilder stringBuilder = new StringBuilder();
    stringBuilder.Append("HeatingDegreeDays");
    stringBuilder.Append(" { ");
    if (PrintMembers(stringBuilder))
    {
        stringBuilder.Append(" ");
    }
    stringBuilder.Append("}");
    return stringBuilder.ToString();
}

在不打印集合类型的 DegreeDays 记录中声明 PrintMembers 方法:

protected virtual bool PrintMembers(StringBuilder stringBuilder)
{
    stringBuilder.Append($"BaseTemperature = {BaseTemperature}");
    return true;
}

签名声明 virtual protected 方法以匹配编译器的版本。 如果访问器出错,请不要担心;语言会强制执行正确的签名。 如果忘记了任何合成方法的正确修饰符,编译器会发出警告或错误,帮助你获取正确的签名。

可以在记录类型中将 ToString 方法声明为 sealed。 这会阻止派生记录提供新的实现。 派生记录将仍包含 PrintMembers 替代。 如果你不希望 ToString 显示记录的运行时类型,你就会将其密封。 在前面的示例中,你会丢失有关记录测量取暖度日数或降温度日数的位置信息。

非破坏性突变

位置记录类中的合成成员不会修改记录的状态。 目标是更轻松地创建不可变记录。 请记住,声明 readonly record struct 以创建不可变记录结构。 请再次查看前面的关于 HeatingDegreeDaysCoolingDegreeDays 的声明。 添加的成员对记录的值执行计算,但不会改变状态。 通过位置记录,可以更轻松地创建不可变引用类型。

创建不可变引用类型意味着要使用非破坏性突变。 使用 with 表达式创建与现有记录实例类似的新记录实例。 这些表达式是通过复制构造来创建的,并通过额外的赋值操作来修改该副本。 结果是一个新的记录实例,其中每个属性都从现有记录复制并选择性地修改。 原始记录保持不变。

让我们向程序添加一些演示 with 表达式的功能。 首先,创建一条新记录,使用相同数据计算增长的度日数。 增长的度日数通常使用 41 F 作为基准,并测量超出基准的温度。 若要使用相同的数据,可以创建类似于 coolingDegreeDays的新记录,但具有不同的基本温度:

// Growing degree days measure warming to determine plant growing rates
var growingDegreeDays = coolingDegreeDays with { BaseTemperature = 41 };
Console.WriteLine(growingDegreeDays);

可以将计算的度数与使用较高基线温度生成的数字进行比较。 请记住,记录是引用类型,这些副本是浅表副本。 不会复制数据的数组,但两条记录都引用相同的数据。 这一事实在另一个情况下是一个优势。 对于温度增长的日数,记录前 5 天的总度数非常有用。 可以使用 with 表达式创建具有不同源数据的新记录。 以下代码生成这些累积的集合,然后显示值:

// showing moving accumulation of 5 days using range syntax
List<CoolingDegreeDays> movingAccumulation = new();
int rangeSize = (data.Length > 5) ? 5 : data.Length;
for (int start = 0; start < data.Length - rangeSize; start++)
{
    var fiveDayTotal = growingDegreeDays with { TempRecords = data[start..(start + rangeSize)] };
    movingAccumulation.Add(fiveDayTotal);
}
Console.WriteLine();
Console.WriteLine("Total degree days in the last five days");
foreach(var item in movingAccumulation)
{
    Console.WriteLine(item);
}

还可以使用 with 表达式来创建记录的副本。 不要在 with 表达式的大括号之间指定任何属性。 这意味着创建副本,并且不更改任何属性:

var growingDegreeDaysCopy = growingDegreeDays with { };

运行已完成的应用程序以查看结果。

总结

本教程介绍了记录的几个方面。 记录为存储数据的基本用途的类型提供简洁的语法。 对于面向对象的类,基本用途是定义责任。 本教程重点介绍 位置记录,你可以在其中使用简洁的语法来声明记录的属性。 编译器合成记录的多个成员,用于复制和比较记录。 你可针对记录类型添加所需的任何其他成员。 你可以创建不可变的记录类型,知道编译器生成的成员都不会改变状态。 with 表达式可以轻松支持非破坏性突变。

记录添加另一种方法来定义类型。 使用 class 定义来创建面向对象的层次结构,这些层次结构侧重于对象的责任和行为。 为存储数据且足够小的数据结构创建 struct 类型,以便高效复制。 当想要基于值的相等性和比较、不想复制值以及想要使用引用变量时,可以创建 record 类型。 当希望某个类型的记录功能足够小,可以高效复制时,可以创建 record struct 类型。

要详细了解记录,请访问 记录类型的 C# 语言参考文章建议的记录类型规范记录结构规范