创建 record 类型

记录是基于值进行比较的类型。您可以将记录定义为引用类型或值类型。如果 record 类型的定义完全相同,并且对于每个字段,两个记录中的值都相等,那么这两个 record 类型的变量就是相等的。如果 class 类型的两个变量相等,则意味着所引用的对象属于相同的 class 类型,并且这两个变量分别指向同一个对象。基于值的比较意味着 record 类型可能还需要具备您可能希望具备的其他功能。当您声明 record 而非 class 时,编译器会生成许多此类成员。对于 record struct 类型,编译器也会生成相同的方法。

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

  • 决定是否为类类型添加记录修饰符。
  • 声明记录类型和位置记录类型。
  • 在记录中替换编译器生成的方法,以使用您自己的方法。

record 的特征

您可以通过使用 “record” 关键字声明类型来定义一个记录,也可以在 class 或 struct 声明中对其进行修改。您还可以选择省略 “class” 关键字来创建一个 record class。record 遵循基于值的相等性语义。为了强制实现值语义,编译器会为您的 record 类型生成多个方法(包括 record class 类型和 record struct 类型):

  • 对 Object . Equals ( Object ) 的重写。
  • 一个虚拟的 Equals 方法,其参数为记录类型。
  • 对 Object . GetHashCode ( ) 的重写。
  • 用于操作符 == 和操作符 != 的方法。
  • 记录类型实现了 System . IEquatable < T >。

record 还提供了对 Object . ToString ( ) 的重写。编译器会使用 Object . ToString ( ) 为显示 record 生成方法。在完成本教程的代码编写时,您将探索这些成员。record 支持使用表达式实现对 record 的非破坏性修改。

您还可以使用更简洁的语法来声明位置 record。当您声明位置 record 时,编译器会为您生成更多的方法:

  • 一个与 record 声明中的位置参数相匹配的主构造函数。
  • 每个主构造函数参数的 public 属性。对于 record class 类型和 readonly record struct 类型,这些属性是只可初始化的;对于 record struct 类型,它们是可读写的。
  • 一个解构方法,用于从 record 中提取属性。

构建温度数据

数据和统计数据属于需要使用 record 的场景之一。在本教程中,您将构建一个应用程序,用于计算不同用途的日温度数。日温度数是对一段时间(如几天、几周或几个月)内的冷热的衡量。日温度数用于跟踪和预测能源使用情况。更炎热的日子意味着更多的空调使用,而更寒冷的日子则意味着更多的供暖使用。日温度数有助于管理植物种群,并随着季节的变化与植物生长相关联。日温度数有助于追踪迁徙物种的迁徙路径,这些物种会根据气候条件迁移。

该公式基于特定日期的平均温度和基准温度。要计算一段时间内的日温度数,您需要获取一段时间内每天的最高和最低温度。首先,让我们创建一个新的应用程序。创建一个新的控制台应用程序。在名为 “日温度数.cs” 的新文件中创建一个新的 record 类型:
public readonly record struct 日温度 ( double 高温 , double 低温 );
上述代码定义了一个位置 record。日温度 是一个 readonly record struct,因为您并不打算对其进行继承,并且它应该是不可变的。高温 和 低温 属性是仅可初始化的属性,这意味着它们可以在构造函数中设置,或者通过属性初始化器来设置。如果您希望位置参数是可读写的,那么就声明一个 record struct 而不是 readonly record struct。日温度 类型还有一个主构造函数,它有两个参数与这两个属性相匹配。您使用主构造函数来初始化 日温度 record。以下代码创建并初始化了某地冬季一周(该年第 48 周) 日温度 记录。第一个使用命名参数来明确 高温 和 低温。其余的初始化器使用位置参数来初始化 高温 和 低温:

private static 日温度 [ ] sj48 = [
    new 日温度 ( 高温: 12.7 , 低温: 0.6 ),
    new 日温度 ( 10.5 , -3.6 ),
    new 日温度 ( 4.1 , -17.8 ),
    new 日温度 ( 5.5 , -11.6 ),
    new 日温度 ( -1.6 , -7.4 ),
    new 日温度 ( 2.3 , -12.8 ),
    new 日温度 ( 9.6 , -13.9 ),
    ];

您可以向 record(包括位置 record)添加自己的属性或方法。您需要计算每天的平均温度。您可以将该属性添加到 日温度 记录中:

public readonly record struct 日温度 ( double 高温 , double 低温 )
    {
        public double 平均温度 => ( 高温 + 低温 ) / 2;
        public override string ToString ( )
            {
                return $"高温:{高温}°,低温:{低温}° - 平均温度:{平均温度:N2}°";
            }
    }

让我们确保您能够使用这些数据。在您的 Main 方法中添加以下代码:

foreach ( var xm in sj48 )
    {
        Console . WriteLine( xm );
    }

运行您的应用程序,您会看到类似以下显示的输出(为节省空间,省略了几行):
高温:12.7°,低温:0.6° - 平均温度:6.65°
高温:10.5°,低温:-3.6° - 平均温度:3.45°
高温:4.1°,低温:-17.8° - 平均温度:-6.85°
……
上述代码展示了 override 的 “ToString” 方法重写后的输出结果。如果您希望使用不同的文本,可以编写自己的 “ToString” 方法,以避免编译器为您生成相应的版本(默认版本会生成 double 17 位长度的字符串)。

计算日温度

要计算日温度,需将某一天的平均温度与基准温度相减。为了衡量一段时间内的热量变化,会剔除平均温度低于基准温度的那些日子。为了衡量一段时间内的寒冷程度,会剔除平均温度高于基准温度的那些日子。例如,某家将 24 ℃ 设定为是否供暖或制冷的基准温度。这是无需供暖或制冷的温度值。如果某天的平均温度为 30 ℃,那么这一天就是需要制冷 6 ℃ 无需制热。相反,如果平均温度为 10 ℃,那么这一天就是需要制热 14 ℃ 无需制冷。

您可以将这些公式表示为一个小型的记录类型层次结构:一个 abstract 的 “度日数” 类型,以及两个具体的类型,分别对应 “供暖度日数” 和 “制冷度日数”。这些类型也可以是位置记录。它们通过调用主构造函数时所接受的基准温度和一系列每日温度记录作为参数来实现:

public abstract record 日温 ( double 基温 , IEnumerable < 日温度 > 温度记录 );
public sealed record 热天 ( double 基温 , IEnumerable < 日温度 > 温度记录 ) : 日温 ( 基温 , 温度记录 )
    {
        public double 日温 => 温度记录 . Where ( rw => rw . 平均温度 < 基温 ) . Sum ( rw => 基温 - rw . 平均温度 );
    }
public sealed record 冷天 ( double 基温 , IEnumerable<日温度> 温度记录 ) : 日温 ( 基温 , 温度记录 )
    {
        public double 日温 => 温度记录 . Where ( rw => rw . 平均温度 > 基温 ) . Sum ( rw => rw . 平均温度 - 基温 );
    }

抽象的 日温 记录是 热天 和 冷天 记录的共享基类。派生记录上的主构造函数声明展示了如何管理基记录的初始化。您的派生记录为基记录主构造函数中的所有参数声明参数。基记录声明并初始化这些属性。派生记录不会隐藏它们,而只是为基记录中未声明的参数创建和初始化属性。在此示例中,派生记录未添加新的主构造函数参数。通过向 Main 方法添加以下代码来测试您的代码:

static void Main(string[] args)
    {
        var rs = new FF供暖 ( 24 , sj48 );
        Console . WriteLine ( rs );
        var ls = new FF制冷 ( 24 , sj48 );
        Console . WriteLine ( ls );
    }

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

您的代码能够正确计算该时间段内的供暖和制冷度日数。但此示例说明了为何您可能希望替换某些 record 类型的编译器自动生成的方法。除了 clone 方法之外,您可以为 record 类型中的任何编译器自动生成的方法声明自己的版本。clone 方法具有编译器生成的名称,您无法提供不同的实现。这些自动生成的方法包括复制构造函数、System . IEquatable < T > 接口的成员、相等性和不等性测试以及 GetHashCode ( )。为此,您自动生成了 PrintMembers。您也可以声明自己的 ToString,但 PrintMembers 为继承场景提供了更好的选择。要提供自定义版本的自动生成方法,其签名必须与自动生成的方法匹配。

控制台输出中的 sj48 元素没有用处。它只显示类型,其他信息都没有。您可以通过提供自己的合成 PrintMembers 方法的实现来更改此行为。签名取决于应用于记录声明的修饰符:

  • 如果记录类型被密封(sealed),或者是一个 record struct,签名就是 private bool PrintMembers ( StringBuilder sb );
  • 如果记录类型未被密封且派生自 object(即未声明基记录),签名就是 protected virtual bool PrintMembers ( StringBuilder sb );
  • 如果记录类型未被密封且派生自另一个记录,签名就是 protected override bool PrintMembers ( StringBuilder sb );

很容易通过理解 PrintMembers 的用途理解这些规则。PrintMembers 会将记录类型中的每个属性的信息添加到字符串中。契约要求基记录将其成员添加到显示中,并假定派生成员会添加其成员。每个记录类型都会合成一个类似于以下 供暖 示例的 ToString 重写:

public override string ToString ( )
    {
        StringBuilder sb = new ( "供暖" );
        sb . Append ( " { " );
        if ( PrintMembers ( sb ) )
            {
                sb . Append ( "  " );
            }
        sb .Append ( "}" );
        return sb . ToString ( );
    }

您在 日温 记录中声明了一个 PrintMembers 方法,该方法未打印集合的类型:

protected virtual bool PrintMembers ( StringBuilder sb )
    {
        sb . Append ( $"基温 = {基温}" );
        return true;
    }

该签名声明了一个 protected virtual 方法,以与编译器的版本相匹配。即便您将访问器设置错误也没关系;该语言会强制采用正确的签名。如果您忘记了任何自动生成方法的正确修饰符,编译器会发出警告或错误信息,帮助您获得正确的签名。

您可以在记录类型中将 ToString 方法声明为 sealed(密封)状态。这样可以防止派生记录提供新的实现方式。派生记录仍会包含 PrintMembers 的重写方法。如果您不想让 ToString 方法显示记录的运行时类型,那么就应该将其密封。在上述示例中,您将无法获取有关记录测量供暖或制冷度日的具体位置的信息。

非破坏性变异

在位置记录类中的合成成员不会改变记录的状态。其目的是让您能够更轻松地创建不可变的记录。请记住,要创建不可变的 record struct,您需要声明一个 readonly record struct。再次查看前面关于 “供暖” 和 “制冷” 的声明。添加的成员会对记录的值进行计算,但不会改变状态。位置记录使您更容易创建不可变的引用类型。

创建不可变引用类型意味着您需要采用非破坏性修改的方式。您可以通过 “with” 表达式创建与现有记录实例相似的新记录实例。这些表达式是一种复制构造,其中包含额外的赋值操作来修改复制后的记录。其结果是一个新的记录实例,其中每个属性都从现有记录中复制而来,并且可以选择进行修改。而原始记录则保持不变。

让我们为您的程序添加一些功能,通过表达式来展示相关内容。首先,让我们创建一个新的记录,使用相同的数据来计算温度日数。增温度日数通常以 30 ℃ 作为基准,并测量高于该基准的温度。为了使用相同的数据,您可以创建一个类似于 “制冷” 的新记录,但其基准温度不同:

var nls = ls with { 基温 = 30 };
Console . WriteLine ( nls );

您可以将计算得出的度数与在更高基准温度下生成的数值进行比较。请记住,这些记录是引用类型,而这些副本是浅拷贝。用于数据的数组并未被复制,但两个记录都指向相同的数据。这一事实在另一种情况下是一个优势。对于增长的度日数,记录前五天的总和很有用。您可以使用 with 表达式创建具有不同源数据的新记录。以下代码构建了这些累积值的集合,然后显示其值:

List < FF供暖 > gn = new ( );
int fw = ( sj48 . Length > 5 ) ? 5 : sj48 . Length;
for ( int q = 0 ; q < sj48 . Length - fw ; q++ )
    {
        var zh5 = rs with { 温度记录 = sj48 [ q .. ( q + fw ) ] , 基温 = 15 };
        gn . Add ( zh5 );
    }
Console . WriteLine ( );
Console . WriteLine ( "过去五天的总度日数" );
foreach ( var z in gn )
    {
        Console . WriteLine ( z . ToString ( ) );
    }

您还可以使用 “with” 表达式来创建记录的副本。在大括号中不要指定任何属性,这意味着创建一个副本,且不更改任何属性。
var growingDegreeDaysCopy = growingDegreeDays with { };

摘要

本教程展示了 record 的几个方面。record 为用于存储数据的基本类型提供了简洁的语法。对于面向对象的类,其基本用途是定义职责。本教程重点介绍了位置记录,您可以使用简洁的语法为记录声明属性。编译器会为记录合成几个成员,用于复制和比较记录。您可以为记录类型添加所需的任何其他成员。您可以创建不可变的记录类型,因为编译器生成的所有成员都不会更改状态。并且借助表达式,支持非破坏性修改变得轻而易举。

record 提供了另一种定义类型的方式。您使用 class 定义来创建面向对象的层次结构,重点关注对象的责任和行为。您创建 struct 类型来存储数据且其规模足够小以便高效复制。当您希望基于值进行相等性和比较操作、不想复制值并且希望使用引用变量时,您创建 record class 类型。当您希望为规模足够小以便高效复制的类型提供 record 的特性时,您创建 record struct 类型。

教程:通过使用顶层语句来探索各种想法,并在学习过程中构建代码

开始探索吧

顶级语句可让您避免因将程序的入口点置于某个类中的 static 方法中而产生的额外繁琐步骤。新创建的控制台应用程序的典型起始点如下所示的代码:

using System;

namespace Application
{
    class Program
    {
        static void Main ( string [ ] args )
        {
            Console . WriteLine ( "Hello World!" );
        }
    }
}

上述代码是通过运行 “dotnet new console” 命令并创建一个新的控制台应用程序而得到的结果。这 11 行代码中只包含了一行可执行代码。您可以利用新的顶级语句功能来简化这个程序。这使得您能够删除此程序中除两行之外的所有内容:

// See https://aka.ms/new-console-template for more information
Console . WriteLine ( "Hello, World!" );

重要事项:
.NET 6 的 C# 模板采用顶层语句。如果您已经将应用程序升级到 .NET 6,那么您的应用程序可能与本文中的代码不匹配。
.NET 6 SDK 还为使用以下 SDK 的项目添加了一组隐式全局使用指令:

  • Microsoft.NET.Sdk
  • Microsoft.NET.Sdk.Web
  • Microsoft.NET.Sdk.Worker
    这些隐式的全局使用指令包含了项目类型中最常用的命名空间。

此功能简化了您对新想法的探索过程。您可以将顶层语句用于编写或探索脚本场景。一旦您掌握了基本操作,就可以开始重构代码,并创建方法、类或其他组件以构建可重复使用的模块。顶层语句确实能够实现快速试验和入门教程。它们还为从试验阶段过渡到完整程序提供了顺畅的路径。

顶级语句会按照它们在文件中的出现顺序进行执行。顶级语句只能在您应用程序中的一个源文件中使用。如果您在多个文件中使用它们,编译器将会产生错误提示。

构建一个神奇的 .NET 语音应答器

在本教程中,我们将构建一个控制台应用程序,该程序能随机回答 “是” 或 “否” 的问题。我们将逐步实现这一功能。您可以专注于您的任务,而无需在意通常程序所需的结构形式。完成功能测试后,您可以根据需要对应用程序进行重构。

一个良好的开端是将问题输出到控制台。您可以先编写以下代码:
Console . WriteLine ( args );
您无需声明一个名为 “args” 的变量。对于包含您顶层语句的单个源文件,编译器会将 “args” 理解为命令行参数。args 的类型是一个字符串数组,与所有 C# 程序中的情况相同。

您可以通过运行以下 “dotnet run” 命令来测试您的代码:
dotnet run -- 我是否应该在所有程序中都使用顶级语句呢?
命令行中 “--” 之后的参数会被传递给程序。您可以看到变量 “args” 的类型被打印到了控制台:
System . String [ ]
若要在控制台中输出问题,您需要列出参数并用空格将其分隔开。将 “WriteLine” 调用替换为以下代码:

Console . WriteLine ( );
foreach ( var s in args )
    {
        Console . Write ( s );
        Console . Write ( ' ' );
    }
Console . WriteLine ( );

现在,当你运行这个程序时,它会正确地将问题以一系列参数的形式显示出来。

以随机答案回复

在重复问题之后,您可以添加代码来生成随机答案。首先添加一组可能的答案:

string [ ] DaAn =
    [
    "这是肯定的。" , "回答含糊,再试一次。" , "别指望了。" , "这是毫无疑问的。" , "过会儿再问。" , "我的回答是不行。" , "毫无疑问。" , "现在最好别告诉你。" , "我的消息说不行。" , "是的 — 肯定的。" , "现在无法预测。" , "前景不太好。" , "你可以相信的。" , "集中精力再问一次。" , "非常不确定。" , "在我看来,是的。" , "很有可能。" , "前景良好。" , "是的。" , "迹象表明是的。"
    ];

此数组包含 10 个肯定的答案、5 个不置可否的答案以及 5 个否定的答案。接下来,添加以下代码以从该数组中生成并显示一个随机答案:

int SY = new Random ( ) . Next ( DaAns . Length - 1 );
Console . WriteLine ( DaAns [ SY ] );

您可以再次运行该应用程序以查看结果。您应该会看到类似于以下的输出内容:

dotnet run -- 我长得帅吗?

我长得帅吗?
非常不确定。

生成答案的代码在顶层语句中包含了变量声明。编译器会在编译生成的主方法中包含该声明。由于这些变量声明是局部变量,所以不能添加 “static” 修饰符。

这段代码能够回答问题,不过让我们再添加一个功能吧。您希望您的问题应用程序能够模拟思考得出答案的过程。您可以通过添加一些 ASCII 动画,并在处理过程中暂停来实现这一点。在输出问题的那一行之后添加以下代码:

for ( int i = 0 ; i < 20 ; i++ )
    {
    Console . Write ( "| -" );
    await Task . Delay ( 50 );
    Console . Write ( "\b\b\b" );
    Console . Write ( "/ \\" );
    await Task . Delay ( 50 );
    Console . Write ( "\b\b\b" );
    Console . Write ( "- |" );
    await Task . Delay ( 50 );
    Console . Write ( "\b\b\b" );
    Console . Write ( "\\ /" );
    await Task . Delay ( 50 );
    Console . Write ( "\b\b\b" );
    }
Console . WriteLine ( );

您还需要在源文件的顶部添加一个 “using” 指令:
使用 System . Threading . Tasks;
using 指令必须位于文件中的任何其他语句之前。否则,这将是一个编译错误。您可以再次运行程序并查看动画效果。这样会带来更好的体验。根据您的喜好调整延迟的长度进行试验。

上述代码创建了一组由空格分隔的旋转线条。添加 “await” 关键字后,编译器会将程序入口点生成为带有 “async” 修饰符的方法,并返回一个“System . Threading . Tasks . Task” 对象。此程序不返回任何值,所以程序入口点返回一个 “Task”。如果您的程序返回一个整数值,您需要在顶层语句的末尾添加一个 “return” 语句。该 “return” 语句将指定要返回的整数值。如果您的顶层语句包含 “await” 表达式,返回类型则变为 “System . Threading . Tasks . Task < TResult >”。

为未来进行重构

上述代码是合理的。它奏效了。但它不可重复使用。既然你的应用程序已经能够正常运行了,那么现在就该提取出可重复使用的部分了。

其中一个候选方案是用于显示等待动画的代码。这段代码片段可以被封装成一个方法:

您可以先在您的文件中创建一个本地函数。将当前的动画替换为以下代码:

static async Task FF控制台动画 ( )
    {
    for ( int i = 0 ; i < 20 ; i++ )
        {
            Console . Write ( "| -" );
            await Task . Delay ( 50 );
            Console . Write ( "\b\b\b" );
            Console . Write ( "/ \\" );
            await Task . Delay ( 50 );
            Console . Write ( "\b\b\b" );
            Console . Write ( "- |" );
            await Task . Delay ( 50 );
            Console . Write ( "\b\b\b" );
            Console . Write ( "\\ /" );
            await Task . Delay ( 50 );
            Console . Write ( "\b\b\b" );
        }
        Console . WriteLine ( );
    }

当需要显示该动画时,使用下列语句:
await FF控制台动画 ( );
上述代码在你的主方法内部创建了一个局部函数。但这段代码仍不具备可复用性。因此,将该代码提取到一个类中。创建一个名为 “工具.cs” 的新文件(添加一个新类),并将上述动画代码剪切到该类中(添加 public 修饰符)。

包含顶级语句的文件还可以在文件末尾(在顶级语句之后)包含命名空间和类型。但对于本教程而言,将动画方法放在单独的文件中会使其更易于复用。

最后,您可以对动画代码进行清理,以去除一些重复内容,方法是使用 “foreach” 循环遍历在 “DHs” 数组中定义的一组动画元素。

经过重构后的完整 “FF控制台动画” 方法应类似于以下代码:

public static async Task FF控制台动画 ( )
    {
        string [ ] DHs = [ "| - " , "/ \\ " , "- |" , "\\ /" ];
        for ( int i = 0 ; i < 20 ; i++ )
            {
                foreach ( string s in DHs )
                    {
                        Console .Write (s);
                        await Task . Delay ( 50 );
                        Console . Write ( "\b\b\b" );
                    }
            }
        Console . WriteLine ( );
    }

现在您已经拥有了一个完整的应用程序,并且已经对可重复使用的部分进行了重构以便日后使用。您可以从首部添加 using 问答器; 语句并在顶层语句中调用这个新的实用方法,在控制台列出问题之后添加如下代码:
await 工具 . FF控制台动画 ( );
上述示例中添加了对 “工具 . FF控制台动画” 函数的调用,并且还添加了一个 “using” 指令。

总结

顶层语句使得创建用于探索新算法的简单程序变得更加容易。您可以通过尝试不同的代码片段来对算法进行试验。一旦您了解了哪些方法有效,就可以对代码进行重构,使其更易于维护。

顶级语句可简化基于控制台应用程序的程序。这类应用程序包括 Azure 功能、GitHub 任务以及其他小型实用工具。

索引和范围

对索引和范围的支持

索引和范围为访问序列中的单个元素或子序列提供了简洁的语法。

这种语言支持依赖于两种新类型和两种新运算符:

  • “System . Index” 表示序列中的一个索引。
  • “从结尾起始的索引” 运算符 ^ 表示该索引是相对于序列尾部的。
  • “System . Range” 表示序列的一个子范围。
  • “范围” 运算符 .. 用于指定范围的起始和结束作为其操作数。

让我们先来看一下索引的规则。考虑一个数组序列 SHZ。索引 0 与 SHZ [ 0 ] 相同。^0 索引与 SHZ [ sequence . Length ] 相同。表达式 SHZ [ ^0 ] 会抛出异常,就像 SHZ [ sequence . Length ] 一样。对于任何数字 n,索引 ^n 与 SHZ . Length - n 相同。

public class LEI中文数字
    {
    public string [ ] ZFC中文数字 =
        [
                      // 起始索引                                结尾索引
            "一" ,    // 0                                           ^10
            "二" ,    // 1                                           ^9
            "三" ,    // 2                                           ^8
            "四" ,    // 3                                           ^7
            "五" ,    // 4                                           ^6
            "六" ,    // 5                                           ^5
            "七" ,    // 6                                           ^4
            "八" ,    // 7                                           ^3
            "九" ,    // 8                                           ^2
            "十"      // 9                                           ^1
        ];            // 10                (或者 ( words . Length ) ^0 )
    }

您可以通过 ^1 标识符来获取最后一个单词。在初始化代码下方添加以下代码:
Console.WriteLine( $"最后一个数字是:{ ( new LEI中文数字 ( ) ) .ZFC中文数字 [ ^1 ] }");
范围指定了一个范围的起始和结束位置。该范围的起始位置是包含在内的,但结束位置是不包含在内的,也就是说起始位置包含在范围内,而结束位置不包含在范围内。范围 [ 0 .. ^0 ] 表示整个范围,正如 [ 0 .. SHZ . Length ] 表示整个范围一样。

以下代码创建了一个包含 “第二”、“第三” 和 “第四” 这几个词的子范围。它涵盖了 ZFC中文数字 [ 1 ] 到 ZFC中文数字 [ 3 ] 这部分内容。而 ZFC中文数字 [ 4 ] 这个元素不在该范围内。

string [ ] zfc234 = ( new LEI中文数字 ( ) ) . ZFC中文数字 [ 1 .. 4 ];
foreach ( string z in zfc234 )
    Console . Write ( z ); // 二三四
Console . WriteLine ( );

以下代码会返回包含 “九” 和 “十” 这两个词的范围。它包含了 “[ ^2 ]” 和 “[ ^1 ]” 这些词。而 “[ ^0 ]” 这个结束词并不包含在内。

string [ ] zfc910 = ( new LEI中文数字 ( ) ) . ZFC中文数字 [ ^2 .. ^0 ];
foreach ( string z in zfc910 )
    Console . Write ( z ); // 九十
Console . WriteLine ( );

以下示例所创建的范围在起始点、结束点或两者都处均未设定具体界限:

string [ ] quan = ( new LEI中文数字 ( ) ) .ZFC中文数字 [ .. ]; // 全部数字
string [ ] qian4 = ( new LEI中文数字 ( ) ) .ZFC中文数字 [ .. 4 ]; // 前四个
string [ ] hou4 = ( new LEI中文数字 ( ) ) .ZFC中文数字 [ 6 .. ]; // 后四个

foreach ( string z in quan )
    Console . Write ( z ); // 一二三四五六七八九十
Console . WriteLine ( );

foreach ( string z in qian4 )
    Console . Write ( z ); // 一二三四
Console . WriteLine ( );

foreach ( string z in hou4 )
    Console . Write ( z ); // 七八九十
Console . WriteLine ( );

您还可以将范围或索引声明为变量。然后,该变量就可以在方括号 [ 和 ] 内使用了:

Index h3 = ^3;
Console . WriteLine ( $"< {( new LEI中文数字 ( ) ) . ZFC中文数字 [ h3 ]} >" ); // < 八 >
Range fw = 1 .. 4;
string [ ] zfc范围 = ( new LEI中文数字 ( ) ) . ZFC中文数字 [ fw ];
foreach ( string z in zfc范围 )
    Console . Write ( $"< {z} >" ); // 二三四
Console . WriteLine ( );

以下示例展示了做出这些选择的诸多原因。修改变量 x、y 和 z 以尝试不同的组合方式。在进行实验时,请使用 x 小于 y 且 y 小于 z 的值来构成有效的组合。在新的方法中添加以下代码。尝试不同的组合方式:

int[] ZHSs = [ .. Enumerable . Range ( 0 , 100 ) ];
int a = 12;
int b = 25;
int c = 36;

Console . WriteLine ( $"{ZHSs [ ^a ]} 等同于 {ZHSs [ ZHSs . Length - a ]}" );
Console . WriteLine ( $"{ZHSs [ a .. b ] . Length} 等同于 {b - a}" );

Console . WriteLine ( "ZHSs [ a .. b ] 和 ZHSs [ b .. c ] 是连续且不重叠的:" );
Span < int > a_b = ZHSs [ a .. b ];
Span < int > b_c = ZHSs [ b .. c ];
Console . WriteLine ( $"\tZHSs [ a .. b ] 是 {a_b [ 0 ]} 到 {a_b [ ^1 ]},ZHSs [ b .. c ] 是 {b_c [ 0 ]} 到 {b_c [ ^1 ]}" );

Console . WriteLine ( "ZHSs [ a ..^a ] 从两端各移除 a 个元素:" );
Span < int > a_a = ZHSs [ a .. ^a ];
Console . WriteLine ( $"\tZHSs [ a .. ^a ] 起始于 {a_a [ 0 ]} 并结束于 {a_a [ ^1 ]}" );

Console . WriteLine ( "ZHSs [ .. a ] 意思是 ZHSs [ 0 .. a ],ZHSs [ a .. ] 意思是 ZHSs [ a .. 0 ]" );
Span < int > start_a = ZHSs [ .. a ];
Span < int > zero_a = ZHSs [ 0 .. a ];
Console . WriteLine ( $"\t{start_a [ 0 ]} .. {start_a [ ^1 ]} 等同于 {zero_a [ 0 ]} .. {zero_a [ ^1 ]}" );

Span < int > c_end = ZHSs [ c .. ];
Span < int > c_zero = ZHSs [ c .. ^0 ];
Console . WriteLine ( $"\t{c_end [ 0 ]} .. {c_end [ ^1 ]} 等同于 {c_zero [ 0 ]} .. {c_zero [ ^1 ]}" );

不仅数组支持索引和范围,您还可以将索引和范围与字符串、Span < T > 或 ReadOnlySpan < T > 结合使用。

隐式范围运算符表达式转换

在使用范围运算符表达式语法时,编译器会自动将起始值和结束值转换为 “index” 类型,并据此创建一个新的 “range” 实例。以下代码展示了从范围运算符表达式语法进行的隐式转换示例,以及其对应的显式转换方式:

Range ys = 3 .. ^5;
Range xs = new ( start: new Index ( value: 3 , fromEnd: false ) , end: new Index ( value: 5 , fromEnd: true ) );
if ( ys . Equals ( xs ) )
    Console . WriteLine ( $"隐式范围‘{ys}’等同于显式范围‘{xs}’" );

重要事项:
从 Int32 类型隐式转换为 Index 类型时,如果值为负数,则会抛出 ArgumentOutOfRangeException 异常。同样,Index 构造函数在接收到负值参数时也会抛出 ArgumentOutOfRangeException 异常。

对索引和范围的支持

索引和范围提供了清晰、简洁的语法,用于访问序列中的单个元素或一组元素。索引表达式通常会返回序列中元素的类型。范围表达式通常会返回与源序列相同的序列类型。

任何明确为索引器提供 “index” 或 “range” 参数的类型,都将分别支持索引或范围。接受单个 “range” 参数的索引器可能会返回不同的序列类型,例如 System . Span < T > 。

重要事项:
使用范围运算符编写的代码的执行效果取决于序列操作数的类型。
范围运算符的时间复杂度取决于序列的类型。例如,如果序列是字符串或数组,那么结果就是输入中指定部分的副本,因此时间复杂度为 O ( N )(其中 N 是范围的长度)。另一方面,如果它是 System . Span < T > 或 System . Memory < T >,结果则引用相同的存储区域,这意味着没有复制,该操作的时间复杂度为 O ( 1 )。
除了时间复杂度之外,这还会导致额外的分配和复制操作,从而影响性能。在对性能要求较高的代码中,可以考虑使用 Span < T > 或 Memory < T > 作为序列类型,因为范围运算符不会为它们进行分配操作。

如果一个类型具有名为 “长度” 或 “个数” 的属性,并且该属性有一个可访问的获取器,且其返回类型为 “int”,那么该类型就是可计数的。一个未明确支持 index 或 range 的可计数类型可能会隐式地提供对它们的支持。使用隐式范围支持的 range 会返回与源序列相同的序列类型。

例如,以下这些 .NET 类型既支持索引也支持范围:string、Span < T > 和 ReadOnlySpan < T >。而 List < T > 仅支持索引,而不支持范围。

数组具有更复杂的特性。单维数组既支持 index 也支持 range。多维数组不支持 index 或 range。多维数组的索引器有多个参数,而非单个参数。交错数组,也被称为数组的数组,既支持 range 也支持 index。以下示例展示了如何遍历交错数组的一个矩形子区域。它会遍历中间部分,不包括最前面和最后面的三行,以及每个选定行的最前面和最后面的两列:

int [ ] [ ] Z锯齿 =
[
   [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
   [10,11,12,13,14,15,16,17,18,19],
   [20,21,22,23,24,25,26,27,28,29],
   [30,31,32,33,34,35,36,37,38,39],
   [40,41,42,43,44,45,46,47,48,49],
   [50,51,52,53,54,55,56,57,58,59],
   [60,61,62,63,64,65,66,67,68,69],
   [70,71,72,73,74,75,76,77,78,79],
   [80,81,82,83,84,85,86,87,88,89],
   [90,91,92,93,94,95,96,97,98,99],
];

var H选中 = Z锯齿 [ 3 .. ^3 ];

foreach ( var h in H选中 )
    {
        var L选中 = h [ 2 .. ^2 ];
        foreach ( var dy in L选中 )
            {
                Console . Write ( $"{dy}, " );
            }
        Console . WriteLine ( );
    }

在所有情况下,数组的范围运算符都会分配一个数组来存储返回的元素。

索引和范围的示例

当您想要分析一个较大序列的一部分时,通常会使用范围和索引。新的语法在阅读时能更清晰地表明所涉及的序列部分。本地函数 “FF移动平均” 将其作为参数接受一个范围。然后,在计算最小值、最大值和平均值时,该方法仅遍历该范围。请在您的项目中尝试以下代码:

int [ ] Z顺序 ( int 个数 ) => [ .. Enumerable . Range ( 0 , 个数 ) . Select ( x => ( int ) ( Math . Sqrt ( x ) * 100 ) ) ];

(int 最小, int 最大, double 平均) FF移动平均 ( int [ ] 子顺序 , Range 范围 ) =>
    (
        子顺序 [ 范围 ] . Min ( ),
        子顺序 [ 范围 ] . Max ( ),
        子顺序 [ 范围 ] . Average ( )
    );

int [ ] Z顺序s = Z顺序 ( 1000 );
for ( int q = 0 ; q < Z顺序s . Length ; q += 100 )
    {
        Range fw1 = q .. ( q + 10 );
        var (zx, zd, pj) = FF移动平均 ( Z顺序s , fw1 );
        Console . WriteLine ( $"从 {fw1 . Start} 到 {fw1 . End}:    \t最小:{zx},\t最大:{zd},\t平均值:{pj}" );
    }

for ( int q = 0 ; q < Z顺序s . Length ; q += 100 )
    {
        Range fw2 = ^(q + 10 ) .. ^q;
        var (zx, zd, pj) = FF移动平均 ( Z顺序s , fw2 );
        Console . WriteLine ( $"从 {fw2 . Start} 到 {fw2 . End}:    \t最小:{zx},\t最大:{zd},\t平均值:{pj}" );
    }

关于范围索引和数组的说明

从数组中获取一个范围时,得到的结果是一个从原始数组复制而来的新数组,而非对其的引用。对所得数组中的值进行修改不会改变原始数组中的值。

例如:

var SHZ5 =  new [ ] { 1 , 2 , 3 , 4 , 5 };

var SHZ头3 = SHZ5 [ .. 3 ]; // 包括 1 , 2 , 3
SHZ头3 [ 0 ] =  11; // 现在包括 11 , 2 , 3

Console . WriteLine ( string . Join ( "," , SHZ头3 ) ); // 11,2,3
Console . WriteLine ( string . Join ( "," , SHZ5 ) ); // 1,2,3,4,5

// output:
// 11,2,3
// 1,2,3,4,5

教程:使用可 null 和不可 null 引用类型更清晰地表达设计意图

可 null 引用类型与引用类型一样,起到了补充作用;而可 null 值类型则与值类型一样,起到了补充作用。通过在类型后添加一个 “?” 符号,可以声明一个变量为可 null 引用类型。例如,string? 表示一个可 null 的字符串。您可以使用这些新类型更清晰地表达您的设计意图:有些变量必须始终有值,而其他变量则可能没有值。

将可 null 引用类型纳入您的设计中

在本教程中,您将构建一个用于模拟调查运行的库。该代码同时使用可 null 引用类型和不可 null 引用类型来表示现实世界中的概念。调查问题永远不会为 null。受访者可能不愿意回答某个问题。在这种情况下,回答可能会为 null。

您为这个示例编写的代码能够明确表达这一意图,并且编译器会强制执行这一意图。

创建应用程序并启用可 null 引用类型

在 Visual Studio 中或通过命令行使用 dotnet new console 创建一个新的控制台应用程序。将应用程序命名为“可为 null 示例”。创建应用程序后,您需要指定整个项目在启用的可 null 注解上下文中进行编译。打开 .csproj 文件,并在 PropertyGroup 元素中添加一个 Nullable 元素。将其值设置为 enable。您必须在 C# 11 之前的项目中选择启用可 null 引用类型的功能。这是因为一旦该功能开启,现有的引用变量声明就会变成不可 null 的引用类型。虽然这一决定有助于发现现有代码中可能没有适当 null 值检查的问题,但它可能无法准确反映您最初的设计意图:
<Nullable>enable</Nullable>
在.NET 6 之前,新项目中不包含 “Nullable” 元素。从 .NET 6 开始,新项目会在项目文件中包含 <Nullable>enable</Nullable> 元素。

为该应用程序设计类型

此调查应用程序需要创建若干类:

  • 一个用于表示问题列表的类。
  • 一个用于表示为调查所联系的人员列表的类。
  • 一个用于表示参与调查的人员的回答的类。

这些类型将同时使用可 null 引用类型和不可 null 引用类型来明确表示哪些成员是必填的,哪些成员是可选的。可 null 引用类型清晰地传达了这种设计意图:

  • 该调查中的问题绝不可能是无效的:提出一个 null 的问题毫无意义。
  • 受访者也不可能是无效的。您需要追踪您联系过的人员,包括那些拒绝参与调查的受访者。
  • 对任何问题的回答都可能无效。受访者可以拒绝回答部分或全部问题。

如果您使用的是 C# 编程语言,您可能已经习惯了引用类型允许存在 null 值这一点,以至于可能忽略了其他声明不可为 null 实例的机会:

  • 所收集的问题必须不能为空。
  • 所收集的受访者信息也必须不能为空。

在编写代码时,您会发现将非 null 引用类型作为引用的默认值可以避免可能导致 “NullReferenceException” 的常见错误。从本教程中得到的一个教训是,您已经决定哪些变量可以为 null,哪些不可以。该语言之前没有提供语法来表达这些决定。但现在它有了。

您将要开发的应用程序会执行以下步骤:

  1. 创建一个调查问卷,并向其中添加问题。
  2. 为该调查问卷生成一组伪随机的受访者。
  3. 持续联系受访者,直至完成的调查问卷数量达到目标数量。
  4. 将调查问卷的回答结果的重要统计数据写入文件。

使用可空和不可空的引用类型构建调查问卷

您将编写的第一个代码段将创建调查问卷。您将编写类来模拟调查问卷问题和调查运行。您的调查包含三种类型的问题,它们根据答案的格式而有所区别:是/否答案、数字答案和文本答案。创建一个名为 “LEI公共问题” 的公共类(编译器会将每个引用类型变量声明解释为在启用可 null 性注解上下文中代码所使用的非 null 引用类型。您可以通过为问题文本和问题类型添加属性来看到您的第一个警告,如以下代码所示):

public class LEI公共问题
    {
        public string 问题文本 {  get; }
        public MJ问题类型 类型 { get; }
    }

public enum MJ问题类型
    {
        是否 = 0,
        数值 = 1,
        文本 = 2,
    }

由于您尚未初始化 问题文本,编译器会发出警告,提示一个非 null 属性未被初始化。您的设计要求问题文本不能为 null,因此您添加了一个构造函数来对其进行初始化,并同时初始化 问题文本 的值:
public LEI公共问题 ( MJ问题类型 问题类型 , string 文本 ) => ( 类型 , 问题文本 ) = ( 问题类型 , 文本 );
添加构造函数可消除警告。构造函数参数也是一种非 null 引用类型,因此编译器不会发出任何警告。
接下来,创建一个名为 LEI调查运行 的 public class。该类包含一个 调查问题 对象列表以及用于向调查中添加问题的方法,如下所示的代码:

public class LEI调查运行
    {
        private List < LEI公共问题 > WTs =  new ( );
        public void FF添加问题 ( LEI公共问题 问题 ) => WTs . Add ( 问题 );
        public void FF添加问题 ( MJ问题类型 问题类型 , string 问题文本 ) => FF添加问题 ( new LEI公共问题 ( 问题类型 , 问题文本 ) );
    }

和之前一样,您必须将列表对象初始化为非 null 值,否则编译器会发出警告。在 FF添加问题 的第二个重载中没有 null 值检查,因为编译器有助于强制执行非 null 值约定:您已声明该变量为非 null 值。虽然编译器会警告潜在的 null 值赋值,但在运行时仍可能出现 null 值。对于公共 API,即使对于非 null 值引用类型,也应考虑添加参数验证,因为客户端代码可能未启用 null 值引用类型,或者可能有意传递 null 值。

在您的编辑器中切换到 Program.cs,然后将 Main 方法的内容替换为以下几行代码:

LEI调查运行 WT调查运行 = new ( );
WT调查运行 . FF添加问题 ( MJ问题类型 . 是否 , "你是学生吗?" );
WT调查运行 . FF添加问题 ( new LEI公共问题 ( MJ问题类型 . 数值 , "你在读几年级?" ) );
WT调查运行 . FF添加问题 ( MJ问题类型 . 文本 , "你最喜欢什么颜色?" );

由于整个项目处于启用的可 null 性注解环境中,因此当您向任何期望非不可 null 引用类型的参数传递 null 时,您将会收到警告。您可以尝试在 Main 中添加以下代码行:
WT调查运行 . FF添加问题 ( MJ问题类型 . 文本 , default );

创建调查对象并获取调查答案

接下来,编写生成调查答案的代码。这个过程包含几个小步骤:

  1. 构建一个方法来生成响应对象。这些对象代表被要求填写调查问卷的人员。
  2. 构建逻辑来模拟向响应者提问并收集答案或记录响应者未作回答的情况。
  3. 重复进行操作,直到有足够的响应者完成了调查问卷。

您需要一个类来表示调查响应,所以现在添加这个类。启用可 null 性支持。添加一个 “Id” 属性和一个用于初始化它的构造函数,如以下代码所示:

public class LEI调查答复
    {
        public int ID {  get; }
        public LEI调查答复 ( int id ) => id = ID;
    }

接下来,添加一个 static 方法,用于通过生成随机 ID 来创建新的参与者:

private static readonly Random SJS = new ( );
public static LEI调查答复 FF获取随机ID ( ) => new LEI调查答复 ( SJS . Next ( ) );

该类别的主要职责是为参与者生成针对调查中问题的回答。这一职责包含以下几个步骤:

  1. 请求参与此次调查。如果对方不同意参与,则返回缺失(或为 null)的响应。
  2. 逐个提出问题并记录答案。每个答案也可能缺失(或为 null)。

在您的 “LEI调查回复” 类中添加以下代码:

private bool BER同意接受调查 ( ) => SJS . Next ( 0 , 2 ) == 1;

private string? FF主回答 ( LEI公共问题 问题 )
    {
        switch ( 问题 . 类型 )
            {
                case MJ问题类型 . 是否:
                    int a = SJS . Next ( -1 , 2 );
                    return ( a == -1 ) ? default : ( a == 0 ) ? "否" : "是";
                case MJ问题类型 . 数值:
                    a = SJS . Next ( -30 , 101 );
                    return ( a < 0 ) ? default : a . ToString ( );
                case MJ问题类型.文本:
                default:
                    switch ( SJS . Next ( 0 , 5 ) )
                        {
                            case 0:
                                return default;
                            case 1:
                                return "红";
                            case 2:
                                return "绿";
                            case 3:
                                return "蓝";
                        }
                return "红?不是;绿?不是;等一下……蓝……AAARGGGGGHHH!";
        }
    }

private Dictionary < int , string >? 答复;
public bool BER调查答复 ( IEnumerable < LEI公共问题 > 问题 )
    {
        if ( BER同意接受调查 ( ) )
            {
                答复 = [ ];
                int sy = 0;
                foreach ( var q in 问题 )
                    {
                        var da = FF主回答 ( q );
                        if ( da != null )
                            {
                                答复 . Add ( sy, da );
                            }
                        sy++;
                    }
            }
        return 答复 != null;
    }

调查答案的存储是一个 Dictionary < int , string >? 类型,这表明它可能是 null 值。您正在使用新的语言特性来向编译器以及之后阅读您代码的任何人声明您的设计意图。如果您在检查 null 值之前就对 LEI调查回复 进行解引用操作,您将会收到编译器警告。在 答复 方法中您不会收到警告,因为编译器能够确定上面已将 LEI调查答复 变量设置为非 null 值。

在缺失答案处使用 null 强调了处理可 null 引用类型的一个关键要点:您的目标并非从程序中移除所有 null 值。相反,您的目标是确保所编写的代码能够表达设计意图。缺失值是代码中必须表达的一个概念。null 值是表达这些缺失值的一种清晰方式。试图移除所有 null 值只会导致定义出其他方式来表达这些缺失值,而不再使用 null。

接下来,您需要在 LEI调查运行 类中编写 FF执行调查 方法。在 LEI调查运行 类中添加以下代码:

private List < LEI调查答复 >? 答复;
public void FF执行调查 ( int 答案数 )
    {
        int DAs = 0;
        答复 = [ ];
        while ( DAs < 答案数 )
            {
                var da = LEI调查答复 . FF获取随机ID ( );
                if ( da .BER调查答复 ( WTs ) )
                    {
                        DAs++;
                        答复 . Add ( da );
                    }
            }
    }

这里再次说明,您选择的可为 null 的 List < LEI调查答复 >? 表示响应可能为 null。这表明调查问卷尚未分发给任何受访者。请注意,会一直添加受访者,直到有足够的人同意为止。

运行调查的最后一步是在 Main 方法的末尾添加一个执行调查的调用:
WT调查运行 . FF执行调查 ( 50 );

检查调查问卷的回复

最后一步是展示调查结果。您需要为所编写的许多类添加代码。这段代码展示了区分可 null 引用类型和不可 null 引用类型的必要性。首先,在 “LEI调查回复” 类中添加以下两个表达式体成员:

public bool BER回答 => 答复 != null;
public string 答案 ( int 索引 ) => 答复? . GetValueOrDefault ( 索引 ) ?? "没回答";

因为 LEI调查答复 是一个可为 null 的引用类型,所以在对其进行解引用之前必须进行 null 值检查。答案 方法返回的是一个不可为 null 的字符串,因此我们需要使用 null 值合并运算符来处理没有答案的情况。

接下来,将这三个带表达式的成员添加到 “LEI调查运行” 类中:

public IEnumerable<LEI调查答复> 参与者s =>  答复 ?? Enumerable . Empty < LEI调查答复 > ( );
public ICollection<LEI公共问题> 问题 => WTs;
public LEI公共问题 FF获取问题 ( int 索引 ) => WTs [ 索引 ];

“参与者s” 成员必须考虑到 “答复” 变量可能为 null,但返回值不能为 null。如果您通过删除 “??” 以及其后的 null 序列来修改该表达式,编译器会警告您该方法可能会返回 null,而其返回签名却返回的是不可为 null 的类型。

最后,在主方法的底部添加以下循环:

foreach ( var cyz in WT调查运行 . 参与者s )
    {
        Console . WriteLine ( $"参与者:{ cyz . ID}:" );
        if ( cyz . BER回答 )
            {
                for ( int i = 0 ; i < WT调查运行 . 问题 . Count ; i++ )
                    {
                        var daan = cyz . 答案 ( i );
                        Console . WriteLine ( $"\t{WT调查运行 . FF获取问题 ( i ) . 问题文本};答案: {daan}" );
                    }
            }
        else
                {
                    Console . WriteLine ( "\t没回答" );
                }
    }

在该代码中无需进行任何 null 值检查,因为您已经设计了底层接口,使得它们都返回不可 null 的引用类型。编译器的静态分析有助于确保这些设计约定得以遵循。

获取代码

您可以从我们的示例库中的 “csharp/NullableIntroduction” 文件夹中获取已完成教程的代码。

通过改变可 null 引用类型与非可 null 引用类型的类型声明来进行试验。观察会产生哪些不同的警告,以确保不会意外地对空值进行解引用操作。

下一步行动

了解在使用 Entity Framework 时如何使用可为空的引用类型。

使用模式匹配来构建您的类行为,以优化代码

C# 中的模式匹配功能提供了表达算法的语法。您可以使用这些技术在类中实现行为。您可以将面向对象的类设计与面向数据的实现相结合,以提供简洁的代码并模拟现实世界中的对象。
在本教程中,您将学习如何:

  • 使用数据模式来表达面向对象的类。
  • 利用 C# 的模式匹配功能来实现这些模式。
  • 借助编译器诊断功能来验证您的实现。

构建运河闸门的模拟模型

在本教程中,您将构建一个 C# 类,用于模拟运河闸门。简而言之,运河闸门是一种在船只在不同水位的两段水域之间航行时能够升降船只的装置。闸门有两个门和一些改变水位的机制。

在正常运行状态下,船只会驶入其中一个闸门,此时水闸内的水位会与船只驶入一侧的水位保持一致。船只进入水闸后,水位会调整至与船只离开水闸时的水位相匹配。当水位与船只离开水闸的一侧水位相同时,出口一侧的闸门就会开启。安全措施确保操作人员不会在运河中造成危险情况。只有当两个闸门都关闭时,水位才能被改变。最多只能有一个闸门开启。要开启一个闸门,水闸内的水位必须与要开启的闸门外的水位相匹配。

您可以创建一个 C# 类来模拟这种行为。一个 “LEI运河闸” 类将支持开启或关闭任一闸门的指令。它还将包含其他指令,用于抬高或降低水位。该类还应支持读取两个闸门当前状态以及水位的属性。您的方法将实现安全措施。

定义一个类

您要构建一个控制台应用程序来测试您的 LEI运河闸 类。使用 Visual Studio 或 .NET CLI 创建一个针对 .NET 5 的新控制台项目。然后,添加一个新的 class,并将其命名为 LEI运河闸。接下来,设计您的公共 API,在类中为每个方法添加第一个实现版本。以下代码实现了该类中的方法,但并未考虑安全规则。稍后您再添加安全测试:

public enum MJ水位
    {
        高 = 1,
        低 = 0,
    }

public class LEI运河闸
    {
        public MJ水位 开闸水位 { get ; private set; } = MJ水位 . 低;
        public bool 高闸开启 { get; private set; } = false;
        public bool 低闸开启 { get; private set; } = false;

        public void FF设置高闸 ( bool 开启 )
            {
                FF高闸开启 = 开启;
            }

        public void FF设置低闸 ( bool 开启 )
            {
                FF低闸开启 = 开启;
            }

        public void FF设置水位 ( MJ水位 水位 )
            {
                开闸水位 = 水位;
            }

        public override string ToString ( )
            {
                return $"低闸门是{( 低闸开启 ? " 开启" : " 关闭")}。高闸门是{( 高闸开启 ? " 开启" : " 关闭")}。水位是 {开闸水位}。";
            }
    }

上述代码将对象初始化为两个闸门都关闭且水位较低的状态。接下来,在您的 Main 方法中编写以下测试代码,以指导您创建该类的第一个实现:

var Zha = new LEI运河闸 ( );

Console.WriteLine ( Zha );

Zha . FF设置低闸 ( 开启: true );
Console . WriteLine ( $"开启低闸:{Zha}" );

Console . WriteLine ( "船从低闸进入船闸" );

Zha . FF设置低闸 ( 开启: false );
Console . WriteLine ( $"关闭低闸:{Zha}" );

Zha . FF设置水位 ( 水位: MJ水位.高 );
Console . WriteLine ( $"提高水位:{Zha}" );

Zha . FF设置高闸 ( 开启: true );
Console . WriteLine ( $"开启高闸:{Zha}" );

Console . WriteLine ( "船从高闸驶出船闸" );
Console . WriteLine ( "另一艘船从高闸进入船闸" );

Zha . FF设置高闸 ( 开启: false );
Console . WriteLine ( $"关闭高闸:{Zha}" );

Zha . FF设置水位 ( 水位: MJ水位 . 低 );
Console . WriteLine ( $"降低水位:{Zha}" );

Zha . FF设置低闸 ( 开启: true );
Console . WriteLine ( $"开启低闸:{Zha}" );

Console . WriteLine ( "船从低闸驶出船闸" );

Zha . FF设置低闸 ( 开启: false );
Console . WriteLine ( $"关闭低闸:{Zha}" );

到目前为止,您编写的测试都通过了。您已经实现了基本功能。现在,要为第一个故障情况编写一个测试。在之前的测试结束时,两个闸门都处于关闭状态,水位被设置为较低水平。添加一个测试来尝试打开上闸门。首先修改 LEI运河闸 的 FF设置高闸 方法,以便当水位为 MJ水位 . 低 的时候,开启高闸无效:

public void FF设置高闸 ( bool 开启 )
    {
        if ( 开启 && ( 开闸水位 == MJ水位 . 高 ) ) // 仅当指令为 true(开启)且水位为高时可以开启高闸
            高闸开启 = true;
        else if ( 开启 && 开闸水位 == MJ水位 . 低 ) // 当指令为 true(开启)且水位为低时操作无效
            {
                throw new InvalidOperationException ( "当低水位状态时不能开启高闸。" );
            }
        else
            {
                高闸开启 = false;
            }
    }

然后在 Main 方法中测试无效操作:

try
    {
        Zha . FF设置水位 ( MJ水位 . 低 ); // 设置水位低
        Zha . FF设置高闸 ( true ); // 开启高闸(失败)
    }
catch ( InvalidOperationException yc )
    {
        Console . WriteLine ( yc . Message );
    }

您的测试通过了。但随着您增加更多的测试用例,您也会增加更多的 “if” 语句,并测试不同的属性。很快,随着条件语句的增多,这些方法就会变得过于复杂。

以模式实现命令

更好的方法是利用模式来判断对象是否处于执行命令的有效状态。您可以将命令是否被允许表示为三个变量的函数:闸的状态、水位以及新的命令:

命令闸门水位结果
关闭关闭关闭
关闭关闭关闭
关闭开启关闭
关闭开启关闭
开启关闭开启
开启关闭关闭(异常)
开启开启开启
开启开启关闭(异常)

表格中的第四行和最后一行有斜体标注,因为它们是无效的。您现在添加的代码应当确保在水位较低时,高位闸门永远不会开启。这些状态可以使用一个单一的开关表达式来编码(请记住,false 表示 “关闭”):

public void FF设置高闸 ( bool 开启 )
    {
        高闸开启 = ( 开启 , 高闸开启 , 开闸水位 ) switch
            {
                ( false , false , MJ水位 . 高 ) => false, // 指令为关,状态为关,水位为高,就关着吧
                ( false , false , MJ水位 . 低 ) => false, // 指令为关,状态为关,水位为低,就关着吧
                ( false , true , MJ水位 . 高 ) => false, // 指令为关,状态为开,水位为高,就关死吧
                ( false , true , MJ水位 . 低 ) => false, // 指令为关,状态为开,水位为低(不可能发生,降低水位的指令应在关闭高闸后发出)
                ( true , false , MJ水位 . 高 ) => true, // 指令为开,状态为关,水位为高,就打开吧
                ( true , false , MJ水位 . 低 ) => throw new InvalidOperationException ( "水位低时无法打开高闸" ), // 无效操作异常
                ( true , true , MJ水位 . 高 ) => true, // 指令为开,状态为开,水位为高,就开着吧
                ( true , true , MJ水位 . 低 ) => false, // 指令为开,状态为开,水位为低(不可能发生,降低水位的指令应在关闭高闸后发出)
            };
     }

试试这个版本。您的测试通过了,验证了代码的正确性。完整的表格展示了输入和结果的所有可能组合。这意味着您和其他开发人员可以快速查看该表格,并确认已经涵盖了所有可能的输入。更方便的是,编译器也能提供帮助。在您添加之前提供的代码后,您会看到编译器生成了一个警告:CS8524 表示 switch 表达式没有涵盖所有可能的输入。发出这个警告的原因是其中一个输入是枚举类型。编译器将 “所有可能的输入” 解释为底层类型的所有输入,通常是整数。这个 switch 表达式只检查枚举中声明的值。要消除这个警告,您可以为表达式的最后一部分添加一个兜底的丢弃模式。这个条件会抛出异常,因为它表示输入无效:
_ => throw new InvalidOperationException ( "无效的内部状态" ),
前面的那个切换臂必须放在你的切换表达式的最后,因为它会匹配所有的输入项。你可以通过将它提前排列在顺序中来进行试验。这样做会导致出现编译错误 CS8510,即在模式中出现了无法执行的代码。切换表达式的自然结构使编译器能够针对可能出现的错误生成错误和警告。编译器的 “安全网” 使你能够以更少的迭代次数创建正确的代码,并且能够自由地将切换臂与通配符结合使用。如果你的组合导致出现你未曾预料到的无法执行的切换臂,编译器会发出错误警告;如果删除了一个必要的切换臂,也会发出警告。
首先要做的是将所有负责关闭大门的控制臂合并在一起;这种操作是始终被允许的。在您的 switch 表达式中,将以下代码作为第一个控制臂添加进去:
( false , _ , _ ) => false,
在添加了之前的开关臂之后,您会得到四个编译错误,每个错误出现在命令为假的那条开关臂上。这些开关臂已经被新添加的开关臂所涵盖。您可以放心地删除这四行代码。您原本是希望这个新的开关臂能够取代那些条件语句的。

接下来,你可以简化那四个控制臂,其中的指令是打开闸门。在两种水位较高的情况下,闸门都可以打开(其中一种情况已经处于打开状态)。有一种水位较低的情况会引发异常,而另一种情况则不应发生。如果水闸已经处于无效状态,那么抛出同样的异常应该是安全的。针对这些控制臂,你可以进行以下简化操作:

( true , _ , MJ水位 . 高 ) => true, // 指令为开,状态无所谓,水位为高,就打开吧
( true , false , MJ水位 . 低 ) => throw new InvalidOperationException ( "水位低时无法打开高闸" ), // 无效操作异常

再次运行你的测试,它们都通过了。

自行实现模式

既然您已经了解了该技术,那么请自行编写 FF设置低闸 和 FF设置水位 方法。首先,代码来测试这些方法的无效操作。例如高水位时不能打开低闸,任一闸门开启时不能调节水位。

再次运行您的应用程序。您会看到新的测试失败,并且水闸锁进入了无效状态。尝试自己实现剩余的方法。设置低闸的方法应该与设置高闸的方法类似。改变水位的方法有不同的检查,但应该遵循类似的结构。您可能会发现使用与设置水位的方法相同的流程来实现该方法会有所帮助。首先准备好所有三个输入:两个闸门的状态以及当前水位的状态。开关表达式应以:

public void FF设置水位 ( )
    {
        开闸水位 = ( 开闸水位, 低闸开启, 高闸开启 ) switch
            {
                ( MJ水位 . 高 , false , false ) => MJ水位 . 低,
                ( MJ水位 . 低 , false , false ) => MJ水位 . 高,
                ( _ , true , _ ) => throw new InvalidOperationException ( "闸门未关闭时不能调节水位" ),
                ( _ , _ , true ) => throw new InvalidOperationException ( "闸门未关闭时不能调节水位" ),
                _ => throw new InvalidOperationException ( "无效的内部状态" ),
        };
    }

您共有 16 个开关臂需要填入。然后进行测试并简化。

您的测试应该能够通过,而且运河船闸也应该能够安全运行。

总结

在本教程中,您学习了如何使用模式匹配来在对对象的状态进行任何更改之前检查该对象的内部状态。您可以检查属性的组合。一旦为任何这些转换构建了表格,您就可以测试您的代码,然后为了可读性和可维护性进行简化。这些初始的重构可能会提出进一步的重构,以验证内部状态或管理其他 API 变更。本教程将类和对象与一种更注重数据、基于模式的方法相结合,以实现这些类。

C# 中的字符串插值

本教程将向您展示如何使用字符串插值来格式化并将表达式结果包含在结果字符串中。示例假设您已熟悉基本的 C# 概念和.NET 类型格式化。

简介

要将字符串常量识别为插值字符串,请在其前面加上 $ 符号。您可以将任何具有有效返回值的 C# 表达式嵌入到插值字符串中。在以下示例中,一旦表达式被计算,其结果就会被转换为字符串,并包含在结果字符串中:

double a = 3 , b = 4;
Console . WriteLine ( $"直角边分别为 {a} 和 {b} 的直角三角形面积为 {a * b / 2}" );
Console . WriteLine ( $"直角边分别为 {a} 和 {b} 的直角三角形斜边为 {Math . Sqrt ( a * a + b * b )}" );

如示例所示,要在嵌入式字符串中包含一个表达式,需用花括号将其括起来:
{<插值表达式>}
插值字符串支持字符串复合格式化功能的所有功能。这使得它们成为使用 String . Format 方法的更易读的替代方案。每个插值字符串都必须具备以下条件:

  • 以 “$” 字符开头,并在其前引号字符之前的一个字符串常量。在 “$” 符号与引号字符之间不能有任何空格。
  • 一个或多个插值表达式。您用一对花括号({ 和 })来表示插值表达式。您可以将任何返回值(包括 null)的 C# 表达式放在花括号内。

C# 会按照以下规则对花括号内的表达式进行计算:

  • 如果插值表达式的计算结果为 null,则会使用一个空字符串(即 "" 或 String . Empty)。
  • 如果插值表达式的计算结果不为 null,则通常会调用结果类型的 ToString 方法。

如何为插值表达式指定格式字符串

若要指定与表达式结果类型所支持的格式字符串相匹配的格式字符串,请在插值表达式后加上冒号(:)以及格式字符串:
{<插值表达式>:<格式化字符串>}
以下示例展示了如何为生成日期、时间或数值结果的表达式指定标准和自定义格式字符串:

DateTime rq = new ( 1731 , 11 , 25 );
Console . WriteLine ( $"{rq:dddd,MMMM - dd,yyyy},莱昂哈德·欧拉引入了字母 “e” 来表示 {Math . E:F5} 这个数值。" );

如何控制格式化插值表达式的字段宽度和对齐方式

若要指定格式化表达式结果的最小字段宽度和对齐方式,请在插值表达式后加上一个逗号(,)和常量表达式:
{<插值表达式>,<宽度>}
以下代码示例使用最小字段宽度来生成表格形式的输出:

Dictionary < string , string > Shu = new ( )
    {
    ["Doyle, Arthur Conan"] = "Hound of the Baskervilles, The",
    ["London, Jack"] = "Call of the Wild, The",
    ["Shakespeare, William"] = "Tempest, The"
    };

Console . WriteLine ( "作者和标题列表:" );
Console . WriteLine ( );
Console . WriteLine ( $"×{"Author",-25}×{"Title",30}×" );
foreach ( var s in Shu )
    {
        Console . WriteLine ( $"×{s . Key,-25}×{s . Value,30}×" );
    }

如果宽度值为正数,则格式化表达式的结果会右对齐;如果为负数,则会左对齐。删除宽度说明符前的 “-” 符号,然后再次运行示例以查看结果。

如果您需要同时指定宽度和格式字符串,请先指定宽度部分:
{<插值表达式>,<宽度>:<格式化字符串>}
以下示例展示了如何指定宽度和对齐方式,并使用竖线字符 (|) 来分隔文本字段:

const int NameAlignment = -9;
const int ValueAlignment = 7;

Console . WriteLine ( $"|{"Arithmetic",NameAlignment}|{0.5 * ( a + b ),ValueAlignment:F3}|" );
Console . WriteLine ( $"|{"Geometric",NameAlignment}|{Math . Sqrt ( a * b ),ValueAlignment:F3}|" );
Console . WriteLine ( $"|{"Harmonic",NameAlignment}|{2 / ( 1 / a + 1 / b ),ValueAlignment:F3}|" );

如示例输出所示,如果格式化表达式的结果长度超过了指定的字段宽度,那么宽度值将被忽略。

如何在插值字符串中使用转义序列

插值字符串支持所有可在普通字符串字面值中使用的转义序列。

若要按字面意义解释转义序列,请使用原始字符串字面量。带有插值的原始字符串以 $ 和 @ 两个字符同时出现为起始。在任何顺序中都可以使用 $ 和 @:$@"..." 和 @$"..." 都是有效的带有插值的原始字符串。

若要在结果字符串中包含一对花括号 “{” 或 “}”,则应使用两个花括号,即 “{{” 或 “}}”。

以下示例展示了如何在结果字符串中加入花括号,并构建一个原样插入的字符串:

int [ ] x4 = [ 1 , 2 , 7 , 9 ];
int [ ] x3 = [ 7 , 9 , 12 ];
Console . WriteLine ( $"找出 {{{string . Join ( "," , x4 )}}} 和 {{{string . Join ( "," , x3 )}}} 这两个集合的交集。" );

string XM = "小猪";
string zfc转义 = $"C:\\User\\{XM}\\Documents";
Console . WriteLine ( zfc转义 );
string zfc原样 = $@"C:\User\{XM}\Documents";
Console . WriteLine ( zfc原样 );

从 C# 11 版本开始,您就可以使用嵌入式原始字符串字面值了。

如何在插值表达式中使用三元条件运算符?:

由于冒号 (:) 在包含插值表达式的项中具有特殊含义,因此若要在表达式中使用条件运算符,则需将其括在括号内,如下例所示:

Random SJS = new ( );
for ( int i = 0; i < 7 ; i++ )
    {
        Console . WriteLine ( $"硬币投掷:{( SJS . NextDouble ( ) < 0.5 ? "头像面" : "背面" )}" );
    }

如何使用字符串插值创建特定文化的结果字符串

默认情况下,插值字符串在所有格式化操作中都会使用由 CultureInfo . CurrentCulture 属性定义的当前文化。

从 .NET 6 开始,您可以使用 String . Create ( IFormatProvider , DefaultInterpolatedStringHandler ) 方法将带插值的字符串转换为具有特定文化背景的结果字符串,如下例所示:

CultureInfo [ ] QYs =
    {
        CultureInfo . GetCultureInfo ( "en-US" ),
        CultureInfo . GetCultureInfo ( "en-GB" ),
        CultureInfo . GetCultureInfo ( "nl-NL" ),
        CultureInfo . InvariantCulture
    };
var rq = DateTime.Now;
var Pi = 31_415_926.536;
foreach ( var qy in QYs )
    {
        var XX区域特定 = string . Create ( qy , $"{rq,23}{Pi,20:N3}");
        Console . WriteLine ( $"{qy . Name,-10}{XX区域特定}" );
    }

在早期版本的 .NET 中,可以将嵌入式字符串进行隐式转换为一个 System . FormattableString 实例,并调用其 ToString ( IFormatProvider ) 方法来创建具有特定文化属性的结果字符串。以下示例展示了如何实现这一操作(与上例输出相同):

……
FormattableString XX = $"{rq,23}{Pi,20:N3}";
foreach ( var qy in QYs )
    {
        var XX区域特定 = XX . ToString ( qy );
        Console . WriteLine ( $"{qy . Name,-10}{XX区域特定}" );
    }

如示例所示,您可以使用一个 FormattableString 实例为不同的文化生成多个结果字符串。

如何使用不变文化创建结果字符串

从 .NET 6 开始,使用 String . Create ( IFormatProvider , DefaultInterpolatedStringHandler ) 方法将插值字符串解析为 InvariantCulture 的结果字符串,如下例所示:

string XX = string . Create ( CultureInfo . InvariantCulture , $"日期和时间在不变区域中: {DateTime . Now}" );
Console . WriteLine ( XX );

在早期版本的 .NET 中,除了 FormattableString . ToString ( IFormatProvider ) 方法外,您还可以使用静态的 FormattableString . Invariant 方法,如下例所示:

string XX = FormattableString . Invariant ( $"日期和时间在不变区域中: {DateTime . Now}" );
Console . WriteLine ( XX );

结论

本教程描述了字符串插值的常见使用场景。

控制台应用

您将构建一个应用程序,该程序能够读取一个文本文件,并将该文本文件的内容输出到控制台。控制台的输出速度会与大声朗读该文件的速度相匹配。您可以通过按下 “<”(小于)或 “>”(大于)键来加快或减慢输出速度。您可以将此应用程序在 Windows、Linux、macOS 或 Docker 容器中运行。

这个教程中有许多功能。让我们一个一个地来实现它们。

创建应用程序

第一步是创建一个新的应用程序。打开命令提示符,为您的应用程序创建一个新的目录。将该目录设为当前目录。在命令提示符中输入命令 “dotnet new console”。例如:

E:\development\VSprojects>mkdir teleprompter
E:\development\VSprojects>cd teleprompter
E:\development\VSprojects\teleprompter>dotnet new console
The template "Console Application" was created successfully.

Processing post-creation actions...
Running 'dotnet restore' on E:\development\VSprojects\teleprompter\teleprompter.csproj...
  Determining projects to restore...
  Restored E:\development\VSprojects\teleprompter\teleprompter.csproj (in 78 ms).
Restore succeeded.

这为一个简单的 “Hello World” 应用程序创建了初始文件。

在您开始进行修改之前,让我们先运行一个简单的 “Hello World” 应用程序。完成应用程序的创建后,在命令提示符下输入 “dotnet run”。此命令会执行 NuGet 包恢复流程、生成应用程序可执行文件,并运行该可执行文件。

简单的 “Hello World” 应用程序代码都包含在 Program.cs 文件中。使用您喜欢的文本编辑器打开该文件。将 Program.cs 中的代码替换为以下代码:

namespace 提词器
{
    internal class Program
    {
        static void Main( string [ ] args )
        {
            Console . WriteLine ( "Hello, World!" );
        }
    }
}

在文件的顶部,可以看到一个命名空间声明。与您使用过的其他面向对象语言一样,C# 也使用命名空间来组织类型。这个 “Hello World” 程序也不例外。您可以看到该程序位于名为 “提词器” 的命名空间中。

读取并回显文件

要添加的第一个功能是能够读取文本文件,并将所有文本显示在控制台中。首先,我们需要添加一个文本文件。从此示例的 GitHub 仓库中复制 sampleQuotes.txt 文件到您的项目目录中。这将成为您应用程序的脚本。

其次,你需要在 “解决方案资源管理器” 中向解决方案添加一个文件夹 “文本”,向该文件夹添加示例文本文件,并在属性窗口中将 “复制到输出目录” 异响修改为 “始终复制” 或 “如果较新则复制”。

接下来,在您的 “Program” 类中添加以下方法(紧接在 “Main” 方法之后):

static IEnumerable<string> FF读取 ( string 文件 )
    {
        string? Hang;
        using ( var du = File . OpenText ( 文件 ) )
            {
                while ( ( Hang = du . ReadLine ( ) ) != null )
                    {
                        yield return Hang;
                    }
            }
    }

这种方法是一种特殊的 C# 方法,称为迭代器方法。迭代器方法会返回按需延迟计算的序列。这意味着序列中的每个项目都是在代码消费该序列时才生成的。迭代器方法是包含一个或多个 “yield return” 语句的方法。“FF读取” 方法返回的对象包含了生成序列中每个项目的代码。在本示例中,这涉及到从源文件读取下一行文本,并返回该字符串。每次调用代码从序列中请求下一个项目时,代码都会从文件中读取下一行文本并返回它。当文件完全读取完毕时,序列会表明没有更多项目了。

在这个方法中,有两个 C# 语法元素可能对您来说是新的。其中的 “using” 语句负责管理资源的清理工作。在 “using” 语句中初始化的变量(在此示例中为 “du”)必须实现 “IDisposable” 接口。该接口定义了一个名为 “Dispose” 的单一方法,当需要释放资源时应调用此方法。当执行到达 “using” 语句的闭合括号时,编译器会生成此调用。编译器生成的代码确保即使在由 “using” 语句定义的代码块中抛出异常时,资源也能被释放。

“du” 变量是通过 “var” 关键字来定义的。“var” 用于定义一个隐式类型的局部变量。这意味着变量的类型是由赋给该变量的对象在编译时的类型所决定的。在这里,该变量的类型是由 “OpenText ( String )” 方法的返回值所决定的,而该返回值是一个 “StreamReader” 对象。

现在,让我们在 “Main” 方法中编写代码来读取文件:

var Hs = FF读取 ( "文本\\sampleQuotes.txt" );
foreach ( var h in Hs )
    {
        Console . WriteLine ( h );
    }

运行该程序(使用 “dotnet run” 命令)后,您就能看到所有打印的内容都被输出到了控制台。

添加延迟并格式化输出

您所看到的内容显示得太快,难以大声朗读出来。现在您需要在输出中添加延迟。在开始时,您将构建一些能够实现异步处理的核心代码。然而,这些第一步会遵循一些反模式。在添加代码时,反模式会在注释中指出,并且代码将在后续步骤中进行更新。

这一部分包含两个步骤。首先,您需要更新迭代器方法,使其返回单个单词而非整行内容。这是通过以下修改来实现的。将 “yield return” 这一行的语句替换为以下代码:

var CIs = Hang . Split ( ' ' );
foreach ( var c in CIs )
    {
        yield return c + " ";
    }
yield return Environment . NewLine;

接下来,您需要调整读取文件行的方式,并在每次写入单词后添加延迟。将 Main 方法中的 Console . WriteLine ( h ) 语句替换为以下代码块:

Console . Write ( h );
if ( ! string . IsNullOrWhiteSpace  ( h ) )
    {
        var zt = Task . Delay ( 200 );
        // 同步等待任务是一种不良做法。这一问题将在后续步骤中得到解决
        zt . Wait ( );
    }

运行示例程序,并检查输出结果。现在,每个单词都会被单独打印出来,随后会有 200 毫秒的延迟。然而,显示的输出结果存在一些问题,因为源文本文件中有几行字符超过 80 个且没有换行符。这在滚动显示时会很难阅读。这个问题很容易解决。您只需跟踪每行的长度,并在行长度达到某个阈值时生成新的一行。在 FF读取 方法中,在 CIs 的声明之后声明一个局部变量来保存行长度:
var HCD = 0;
然后,在 “yield return c + " ";” 语句后加上以下代码(在大括号之前):

HCD += c . Length + 1;
if ( HCD > 70 )
    {
        yield return Environment . NewLine;
        HCD = 0;
    }

运行这个样本程序,您就能按照其预先设定的语速进行朗读了。

异步任务

在这一最后步骤中,您将添加代码以异步方式编写输出内容,同时运行另一个任务来读取用户输入(如果用户希望加快或减慢文本显示速度,或者完全停止文本显示的话)。这包含几个步骤,最终您将获得所需的所有更新。第一步是创建一个异步任务返回方法,该方法代表您目前所创建的用于读取和显示文件的代码。

将此方法添加到您的 “Program” 类中(该方法取自您的 “Main” 方法的主体部分):

private static async Task FF异步提词器 ( )
    {
        var CIs = FF读取 ( "文本\\sampleQuotes.txt" );
        foreach ( var c in CIs )
            {
                Console . Write ( c );
                if ( !string . IsNullOrWhiteSpace ( c ) )
                    {
                        await Task . Delay ( 200 );
                    }
            }
    }

您会注意到两个变化。首先,在方法体中,此版本不再调用 Wait ( ) 来同步等待任务完成,而是使用了 await 关键字。要实现这一点,您需要在方法签名中添加 async 修饰符。此方法返回一个 Task。请注意,这里没有返回带有 Task 对象的语句。相反,该 Task 对象是由您使用 await 运算符时编译器生成的代码创建的。您可以想象,此方法在到达 await 语句时停止执行。返回的 Task 表明工作尚未完成。当所等待的任务完成时,该方法会恢复执行。当它执行完毕后,返回的 Task 表明其已完成。调用代码可以监控返回的 Task 以确定何时完成。

在调用 “FF异步提词器” 函数之前添加一个 “await” 关键字:
await FF异步提词器 ( );
这要求您将主方法的签名修改为:
static async Task Main ( string [ ] args )
接下来,您需要编写第二个异步方法,用于从控制台读取数据,并监听 “<”(小于号)、“>”(大于号)以及 “X” 或 “x” 这些按键。以下是用于完成此任务的您需要添加的方法:

private static async Task FF获取输入 ( )
    {
        var ys = 200;
        Action gz = async () =>
            {
                do
                    {
                        var jian = Console . ReadKey ( true );
                        if ( jian . KeyChar == '>' )
                            { ys -= 10; }
                        else if ( jian . KeyChar == '<' )
                            { ys += 10; }
                        else if ( jian . KeyChar == 'X' || jian . KeyChar == 'x' )
                            { break; }
                    } while ( true );
            };
            await Task . Run ( gz );
    }

这会生成一个 lambda 表达式,用于表示一个 Action 委托函数,该函数从控制台读取一个键值,并修改一个代表用户按下 “<”(小于)或 “>”(大于)键时延迟时间的局部变量。该委托方法在用户按下 “X” 或 “x” 键时结束,这些键允许用户随时停止文本显示。此方法使用 ReadKey ( ) 来阻塞并等待用户按下一个键。

现在是时候创建一个能够处理这两项任务之间共享数据的类了。这个类包含两个公共属性:延迟时间和一个名为 “完成” 的标志,用于表示文件已完全读取完毕:

using static System . Math;

namespace 提词器
    {
    internal class LEI提词器配置
        {
        public int ys { get; private set; } = 200;
        public void FF更新延时 ( int 增量 ) // 正数加速
            {
            var ysXin = Min ( ys + 增量 , 1000 );
            ysXin = Max ( ysXin , 20 );
            ys = ysXin;
            }

        public bool 完成 { get; private set; }
        public void FF完成 ( )
            {
            完成 = true;
            }
        }
    }

创建一个新的文件;其名称可以是任意后缀为 “.cs” 的名称。例如 “LEI提词器配置.cs”。将 “LEI提词器配置” 类的代码粘贴进去,保存并关闭。将该类放在 “提词器” 命名空间中。请注意,“using static” 语句允许您在不使用外部类或命名空间名称的情况下引用 “Min” 和 “Max” 方法。“using static” 语句会导入一个类中的方法。这与没有 “static” 关键字的 “using” 语句不同,后者会从一个命名空间中导入所有类。

接下来,您需要将 FF异步提词器 和 FF获取输入 方法的实现更新为使用新的配置对象。要完成此功能,您需要创建一个新的异步任务返回方法,该方法会启动这两个任务(FF获取输入 和 FF异步提词器),同时还会管理这两个任务之间的共享数据。创建一个 FF运行提词器 任务来启动这两个任务,并在第一个任务完成时退出:

var peizhi = new LEI提词器配置 ( );
var tcq = FF异步提词器 ( peizhi );

var Task速度 = FF获取输入 ( peizhi );
await Task . WhenAny ( tcq , Task速度 );

这里新增的一种方法是 “WhenAny ( Task [ ] )” 调用。它会创建一个任务,该任务会在其参数列表中的任何一个任务完成时立即结束。

接下来,您需要更新 “FF异步提词器” 和 “FF获取输入” 这两个方法,使其使用 “配置” 对象来设置延迟时间。该 “配置” 对象作为参数传递给这两个方法。请使用复制/粘贴的方式将这些方法完全替换为下面的新代码。您可以看到代码正在使用属性并从 “配置” 对象调用方法:

private static async Task FF获取输入 ( LEI提词器配置 配置 )
    {
        Action gz = ( ) =>
            {
                do
                    {
                        var jian = Console . ReadKey ( true );
                        if ( jian . KeyChar == '>' )
                            { 配置 . FF更新延时 (-10); }
                        else if ( jian . KeyChar == '<' )
                            { 配置 . FF更新延时 (10); }
                        else if ( jian . KeyChar == 'X' || jian . KeyChar == 'x' )
                            { 配置 . FF完成 ( ); }
                    } while ( ! 配置 . 完成 );
        };
        await Task . Run ( gz );
    }

        private static async Task FF异步提词器 ( LEI提词器配置 配置 )
            {
            var CIs = FF读取 ( "文本\\sampleQuotes.txt" );
            foreach ( var c in CIs )
                {
                Console . Write ( c );
                if ( !string . IsNullOrWhiteSpace ( c ) )
                    {
                    await Task . Delay ( 配置 . ys );
                    }
                }
            配置 . FF完成 ( );
            }

现在,您需要修改 “Main” 程序,使其改为调用 “FF运行提词器” 函数,而非 “FF异步提词器” 函数:
await FF运行提词器 ( );

结论

本教程向您展示了 C# 语言及其与控制台应用程序相关联的 .NET Core 库的一些特性。您可以基于这些知识进一步探索该语言以及这里介绍的类。您已经了解了文件和控制台输入/输出的基本知识、基于任务的异步编程的阻塞和非阻塞使用方法、对 C# 语言的概览以及 C# 程序的组织方式,还有 .NET CLI。

教程:在 .NET 控制台应用程序中使用 C# 发送 HTTP 请求

本教程将构建一个应用程序,该程序会向 GitHub 上的 REST 服务发送 HTTP 请求。该应用程序会读取以 JSON 格式存储的信息,并将 JSON 转换为 C# 对象。将 JSON 转换为 C# 对象的过程被称为反序列化。

该教程展示了如何:

  • 发送 HTTP 请求。
  • 解码 JSON 响应。
  • 使用属性配置解码过程。

如果您想跟着本教程的最终示例一起操作,可以下载它

创建客户端应用程序

  1. 打开命令提示符窗口,为您的应用程序创建一个新的目录。将该目录设为当前目录。
  2. 在控制台窗口中输入以下命令:
    dotnet new Console --name WebAPI客户端
    此命令会为一个简单的 “Hello World” 应用程序创建初始文件。该项目的名称为 “WebAPI客户端”。
  3. 进入 “WebAPI客户端” 目录,然后运行该应用程序。

    cd WebAPI客户端
    dotnet run

    “dotnet run” 命令会自动执行 “dotnet restore” 来恢复应用程序所需的任何依赖项。如果需要,还会运行 “dotnet build”。您应该会看到应用程序输出 “Hello, World!”。在终端中,按 Ctrl + C 可停止该应用程序。

    发出 HTTP 请求

    此应用程序调用 GitHub API 以获取隶属于 .NET 基金会旗下的项目的相关信息。该端点为 https://api.github.com/orgs/dotnet/repos。为了获取信息,它会发出一个 HTTP GET 请求。浏览器也会发出 HTTP GET 请求,因此您可以将该 URL 粘贴到浏览器地址栏中,以查看您将收到和处理的信息内容。

使用 HttpClient 类来发送 HTTP 请求。HttpClient 仅支持其长时间运行的 API 的异步方法。因此,以下步骤创建了一个异步方法,并从 Main 方法中调用它。

  1. 在您的项目目录中打开 Program.cs 文件,并将其内容替换为以下内容:

    using System . Net . Http . Headers;
    ……
    static async Task Main ( string [ ] args )
     {
         using HttpClient khd = new ( );
         khd . DefaultRequestHeaders . Accept . Clear ( );
         khd . DefaultRequestHeaders . Accept . Add ( new MediaTypeWithQualityHeaderValue ( "application/vnd.github.v3+json" ) );
         khd . DefaultRequestHeaders . Add ( "User-Agent" , ".NET Foundation Repository Reporter" );
    
         static async Task FF进程存储库异步处理 ( HttpClient 客户端 )
             { }
         await FF进程存储库异步处理 ( khd );
     }

    这段代码:

    • 为所有请求设置 HTTP headers:
    • 一个 “Accept” header,用于接受 JSON 格式的响应
    • 一个 “User-Agent” header。这些 headers 会由 GitHub 服务器代码进行检查,并且是从 GitHub 获取信息所必需的。

      • 将 Console . WriteLine 语句替换为对 FF进程存储库异步处理 方法的调用,该方法使用了 await 关键字。
      • 定义了一个空的 FF进程存储库异步处理 方法。
  2. 在 “FF进程存储库异步处理” 方法中,调用该 GitHub 端点,该端点会返回 .NET 基金会组织下的所有存储库的列表:

    static async Task FF进程存储库异步处理 ( HttpClient 客户端 )
     {
         var json = await 客户端 . GetStringAsync ( "https://api.github.com/orgs/dotnet/repos" );
         Console . Write ( json );
     }

    这段代码:

    • 等待通过调用 HttpClient . GetStringAsync ( String ) 方法返回的任务。此方法会向指定的 URI 发送 HTTP GET 请求。响应的主体将以字符串形式返回,任务完成时即可获取该字符串。
    • 将响应字符串 json 打印到控制台。
  3. 构建应用程序并运行它。
    dotnet run
    由于 “FF进程存储库异步处理” 现在包含了一个 “await” 操作符,所以不存在构建警告。输出内容是一长串的 JSON 文本。

反序列化 JSON 结果

以下步骤简化了获取数据并对其进行处理的方法。您将使用来自 System.Net.Http.Json NuGet 包的 GetFromJsonAsync 扩展方法来获取并将 JSON 结果反序列化为对象。

  1. 创建一个名为 “LEI仓库” 的 record class,并添加以下代码:
    internal record class LEI仓库 ( string Name )
    即该类的主构造函数。
    上述代码定义了一个类,用于表示从 GitHub API 返回的 JSON 对象。您将使用此类来显示一系列存储库的名称。
    一个存储库对象的 JSON 格式包含数十个属性,但只有 “Name” 属性会被反序列化。序列化器会自动忽略那些在目标类中没有对应项的 JSON 属性。此功能使得创建仅使用大型 JSON 数据包中部分字段的类型变得更加容易。
    尽管在接下来的步骤中您将使用的 GetFromJsonAsync 方法在处理属性名称时具有不区分大小写的优点,但 C# 的惯例是将属性名称的首字母大写。
  2. 使用 HttpClientJsonExtensions.GetFromJsonAsync 方法来获取 JSON 数据并将其转换为 C# 对象。将 ProcessRepositoriesAsync 方法中的调用 GetStringAsync ( String ) 替换为以下几行代码:
    var CKs = await 客户端 . GetFromJsonAsync < List < LEI仓库 > > ( "https://api.github.com/orgs/dotnet/repos" );
    更新后的代码将 GetStringAsync ( String ) 替换为了 HttpClientJsonExtensions . GetFromJsonAsync。
    GetFromJsonAsync 方法的第一个参数是一个 await 表达式。await 表达式几乎可以在代码中的任何位置出现,尽管到目前为止,您只看到它们作为赋值语句的一部分出现。接下来的参数 requestUri 是可选的,如果在创建客户端对象时已经指定了该 URI,则无需再提供。您没有为客户端对象指定要发送请求的 URI,所以现在您指定了该 URI。最后一个可选参数 CancellationToken 在代码片段中被省略了。
    GetFromJsonAsync 方法是通用的,这意味着您需要为从获取的 JSON 文本中应创建何种对象提供类型参数。在本示例中,您将数据反序列化为 List < Repository >,这是一个另一个通用对象,即 System . Collections . Generic . List < T >。List < T > 类用于存储对象集合。类型参数声明了 List < T > 中存储的对象的类型。类型参数是您的 LEI仓库 记录,因为 JSON 文本代表了一个包含 LEI仓库 对象的集合。
  3. 添加代码以显示每个存储库的名称。替换那些内容为:
    Console . WriteLine ( json );
    使用以下代码:

    foreach ( var ck in CKs ?? Enumerable . Empty < LEI仓库 > ( ) )
    Console . WriteLine ( ck . 仓库名 );
  4. 以下的 using 指令应位于文件的顶部:

    using System . Net . Http . Headers;
    using System . Net . Http . Json;
  5. 运行应用程序。
    dotnet run
    输出结果是一份包含属于 .NET 基金会的各个存储库名称的列表。

重构代码

“FF进程存储库异步处理” 方法可以执行异步操作并返回一系列的存储库。将该方法修改为返回 “Task < List < LEI仓库 > >”,并将向控制台写入的代码移到其调用者附近。

  1. 将 “FF进程存储库异步处理” 方法的签名修改为:返回一个任务,其结果是一个包含 “LEI仓库” 对象的列表:
    static async Task < List < LEI仓库 > > FF进程存储库异步处理 ( HttpClient 客户端 )
  2. 处理完 JSON 响应后返回存储库信息:

    var CKs = await 客户端 . GetFromJsonAsync < List < LEI仓库 > > ( "https://api.github.com/orgs/dotnet/repos" );
    return CKs ?? new ( );

    由于您已将此方法标记为异步,编译器会为返回值生成一个 “ Task < T > ”对象。

  3. 修改 Program.cs 文件,将对 FF进程存储库异步处理 的调用替换为以下代码,以便捕获结果并将每个存储库的名称写入控制台。

    var CKs = await FF进程存储库异步处理 ( khd );
    foreach ( var ck in CKs )
     {
         Console . WriteLine ( ck . Name );
     }
  4. 运行应用程序。

反序列化更多属性

以下步骤将添加代码以处理接收到的 JSON 数据包中的更多属性。您可能并不想处理每个属性,但添加几个更多属性可以展示 C# 的其他特性。

  1. 将 LEI仓库 类的内容替换为以下记录定义:
    internal record class LEI仓库 ( string Name , string Description , Uri GitHubHomeUrl , Uri Homepage , int Watchers , DateTime LastPushUtc )
    Uri 和 int 类型具有内置的功能,可将数据转换为字符串表示形式以及从字符串形式转换回原始数据类型。无需额外代码即可将 JSON 字符串格式的数据反序列化为这些目标类型。如果 JSON 数据包包含无法转换为目标类型的数据,则序列化操作会抛出异常。
    JSON 通常会将对象的名称使用小写字母,但我们无需进行任何转换,可以保留字段名称的大写形式,因为正如在之前某一点中所提到的,GetFromJsonAsync 扩展方法在处理属性名称时是不区分大小写的。
  2. 更新 Program.cs 文件中的 foreach 循环,以显示属性值:

    foreach ( var ck in CKs )
     {
         Console . WriteLine ( $"名称:{ck . Name}" );
         Console . WriteLine ( $"主页:{ck . Homepage}" );
         Console . WriteLine ( $"GitHub:{ck . GitHubHomeUrl}" );
         Console . WriteLine ( $"说明:{ck . Description}" );
         Console . WriteLine ( $"访问:{ck . Watchers:#,0}" );
         Console . WriteLine ( );
     }
  3. 运行应用程序。

添加一个日期属性

在 JSON 响应中,最后一次推送操作的日期是以这种方式格式化的:

2016-02-08T21:27:00Z

此格式为协调世界时(UTC)格式,因此反序列化的结果是一个 DateTime 值,其 Kind 属性为 Utc。
要获取以您所在时区表示的日期和时间,您需要编写一个自定义的转换方法。

  1. 在 LEI仓库 中,添加一个用于表示日期和时间的 UTC 格式的属性,以及一个只读的 LastPush 属性,该属性会返回转换为本地时间的日期,文件应如下所示:
    public DateTime LastPush => LastPushUtc . ToLocalTime ( );
    “LastPush” 属性是通过表达式体成员来定义其 get 访问器的。没有 set 访问器。在 C# 中,省略 set 访问器就是定义 readonly 属性的一种方式(没错,在 C# 中可以创建 writeonly 属性,但其值是有限制的)。
  2. 在 Program.cs 文件中再添加一条输出语句:
    Console . WriteLine ( $"{ck . LastPush}" );
  3. 运行应用程序。

下一步

在本教程中,您创建了一个能够发起网络请求并解析结果的应用程序。您所编写的该应用程序版本现在应该与完成的示例版本保持一致了。

使用语言集成查询(Language - Integrated Query,LINQ)进行操作

简介

本教程将向您介绍 .NET Core 和 C# 语言的相关特性。您将学习如何:

  • 使用 LINQ 生成序列。
  • 编写可在 LINQ 查询中轻松使用的方法。
  • 区分“贪婪(eager)”求值和“延迟(lazy)”求值。

您将通过构建一个演示任何魔术师基本技能的应用程序来学习这些技术:法罗洗牌。简而言之,法罗洗牌是一种将牌组精确分成两半的技巧,然后通过交替排列每个半组中的每一张牌来重建原始牌组。

魔术师们采用这种技巧是因为每次打乱牌堆后,每张牌的位置都是固定的,并且牌的排列顺序是一个重复的模式。

对于您的需求而言,这是一次轻松地探讨数据序列处理的内容。您将要构建的应用程序会创建一副扑克牌,然后依次执行一系列的洗牌操作,并每次将洗牌后的顺序记录下来。您还将将更新后的顺序与原始顺序进行比较。

本教程包含多个步骤。完成每一步后,您都可以运行应用程序并查看进展情况。您还可以在 dotnet/samples 代码库中查看已完成的示例

创建应用程序

第一步是创建一个新的应用程序。打开命令提示符,为您的应用程序创建一个新的目录。将该目录设为当前目录。在命令提示符中输入命令 “dotnet new console”。这将为一个基本的 “Hello World” 应用程序创建初始文件。

如果您之前从未使用过 C# 语言,那么本教程将为您介绍 C# 程序的结构。您可以先阅读这部分内容,然后再回到这里进一步了解 LINQ。

创建数据集

在开始之前,请确保在由 dotnet new console 生成的 Program.cs 文件的顶部添加以下几行代码:

using System;
using System . Collections . Generic;
using System . Linq;

如果这三条指令(使用指令)不在文件的顶部,那么你的程序可能无法编译。

提示:在本教程中,您可以将代码组织到名为 “法罗洗牌” 的命名空间中,以与示例代码保持一致,或者您也可以使用默认的全局命名空间。如果您选择使用命名空间,请确保所有类和方法都始终处于同一个命名空间内,或者根据需要添加适当的使用声明。

既然您已经获取了所需的所有参考资料,那么请思考一下一副牌是由哪些元素构成的。通常情况下,一副扑克牌有四种花色,每种花色有 13 种数值。通常情况下,您可能会一开始就创建一个 “LEI纸牌” 类,并手动填充一个包含 LEI纸牌 对象的集合。使用 LINQ,您可以比通常处理创建一副牌的方式更加简洁。而不是创建一个 “LEI纸牌” 类,您可以分别创建两个序列来表示花色和数值。您将创建一对非常简单的迭代器方法,它们将生成作为字符串的数值和花色的 IEnumerable < T > 对象:

static IEnumerable < string > 花色 ( )
    {
        yield return "梅花";
        yield return "方片";
        yield return "红桃";
        yield return "黑桃";
    }

static IEnumerable < string > 数值 ( )
    {
        yield return "2";
        yield return "3";
        yield return "4";
        yield return "5";
        yield return "6";
        yield return "7";
        yield return "8";
        yield return "9";
        yield return "10";
        yield return "J";
        yield return "Q";
        yield return "K";
        yield return "A";
    }

将这些内容放置在您的 Program.cs 文件中的 Main 方法下方。这两个方法都使用 yield return 语法来生成一个序列,它们在运行时会实现这一功能。编译器会构建一个实现了 IEnumerable < T > 接口的对象,并在需要时生成字符串序列。

现在,使用这些迭代器方法来创建牌组。您将把 LINQ 查询放在我们的 Main 方法中。下面是它的示例:

var Pai起始 = from h in 花色 ( )
              from z in 数值 ( )
              select new { 花色 = h , 数值 = z };

foreach ( var p in Pai起始 )
    Console . WriteLine ( p );

多个 “from” 子句会生成一个 “SelectMany” 操作,它会将第一个序列中的每个元素与第二个序列中的每个元素进行组合,从而创建一个单一的序列。对于我们的目的而言,顺序很重要。第一个源序列(花色)中的第一个元素与第二个序列(数值)中的每个元素进行组合。这会生成第一个花色的所有十三张牌。然后,这个过程会针对第一个序列中的每个元素(花色)重复进行。最终的结果是一副按照花色排序、随后按数值排列的牌组。

需要记住的是,无论您是选择使用上述查询语法来编写 LINQ 还是采用方法语法,都始终可以将一种语法形式转换为另一种语法形式。上述以查询语法编写的内容,也可以转换为方法语法的形式,即:
var Pai起始 = 花色 ( ) . SelectMany ( 花色 => 数值 ( ) . Select ( 数值 => new { 花色 , 数值 } ) );
编译器会将使用查询语法编写的 LINQ 语句转换为等效的方法调用语法。因此,无论您选择何种语法,这两种查询版本都会产生相同的结果。请根据您的具体情况选择最合适的语法:例如,如果您所在的团队中有些成员难以理解方法语法,那么请尽量优先使用查询语法。

现在请运行您目前构建的示例程序。它将显示整个牌组中的所有 52 张牌。您可能会发现,在调试器下运行此示例以观察 花色 ( ) 和 数值 ( ) 方法的执行过程会非常有帮助。您会清楚地看到,每个序列中的每个字符串都是在实际需要时才生成的。

调整顺序

接下来,要关注的是如何对牌组进行重新排列。任何有效的洗牌过程的第一步都是将牌组分成两半。LINQ API 中的 “FF取牌” 和 “FF跳过” 方法就为您提供了这一功能。将它们放在 foreach 循环的下方:

var l顶 = Pai起始 . Take ( 26 );
var l底 = Pai起始 . Skip ( 26 );

然而,标准库中并没有可用的随机打乱方法,所以您必须自己编写一个。您将要创建的这个随机打乱方法将演示一些您在基于 LINQ 的程序中会用到的技术,因此这一过程的每个步骤都会进行详细说明。

为了增强您从 LINQ 查询中获取的 IEnumerable < T > 对象的交互方式,您需要编写一些特殊类型的方法,这些方法被称为扩展方法。简而言之,扩展方法是一种特殊的 static 方法,它能够为已存在的类型添加新的功能,而无需修改您想要为其添加功能的原始类型。

为您的扩展方法创建一个新的存放位置,方法是向您的程序中添加一个名为 “LEI静态扩展.cs” 的新 static class 文件,然后开始编写第一个扩展方法:

namespace 法罗洗牌
    {
        internal static class LEI静态扩展
            {
                public static IEnumerable < T > FF交错序列 < T > ( this IEnumerable < T > yi , IEnumerable < T > er )
                    {

                    }
            }
    }

注意:如果您使用的不是 Visual Studio 这款编辑器(比如 Visual Studio Code),则可能需要在您的 Program.cs 文件顶部添加 “using 法罗洗牌;” 语句,以便能够使用这些扩展方法。Visual Studio 会自动添加这个使用语句,但其他编辑器可能不会这样做。

请稍作观察方法的签名部分,特别是其中的参数。

您可以看到,在该方法的第一个参数上添加了 “this” 修饰符。这意味着您调用该方法时,就好像它是第一个参数类型的一个成员方法一样。此方法声明还遵循了一种标准模式,即输入和输出类型均为 “IEnumerable < T >”。这种做法使得 LINQ 方法能够串联起来以执行更复杂的查询。

当然,既然你将牌组分成了两半,那么你就需要将这两半合并起来。在代码中,这意味着你要同时遍历通过 “FF取牌” 和 “FF跳过” 操作获取的两个序列,将元素交错排列,并创建一个序列:这就是你现在已洗好的牌组。编写一个能处理两个序列的 LINQ 方法需要你了解 IEnumerable < T > 的工作原理。

IEnumerable < T > 接口仅有一个方法:GetEnumerator。GetEnumerator 返回的对象有一个用于移动到下一个元素的方法,还有一个用于获取序列中当前元素的属性。您将使用这两个成员来枚举集合并返回元素。这个 FF交错序列 方法将是一个迭代器方法,因此您不会构建一个集合并返回该集合,而是会使用上述所示的 yield return 语法。

以下是该方法的实现方式:

public static IEnumerable < T > FF交错序列 < T > ( this IEnumerable < T > yi , IEnumerable < T > er )
    {
        var Tyi = yi . GetEnumerator ( );
        var Ter = er . GetEnumerator ( );

        while ( Tyi . MoveNext ( ) && Ter . MoveNext ( ) )
            {
                yield return Tyi . Current;
                yield return Ter . Current;
            }
    }

既然你已经编写好了这个方法,那就回到 Main 方法中,对牌组进行一次重新洗牌:

// 洗牌
var Pai混 = l顶 . FF交错序列 ( l底 );
foreach ( var p in Pai混 )
    Console . WriteLine ( p );

比较

要将牌重新排列回初始顺序需要进行多少次洗牌操作呢?要找出答案,您需要编写一个方法来判断两个序列是否相等。在您有了这个方法之后,您需要将洗牌的代码放入一个循环中,并检查何时牌又回到了原来顺序。

编写一个用于判断两个序列是否相等的方法应该是很容易的。其结构与您编写的用于洗牌的那段代码类似。只是这一次,不再是 yield 返回每个元素,而是要比较两个序列中对应元素的相等性。当整个序列都被遍历完毕后,如果每个元素都相匹配,那么这两个序列就是相同的:

public static bool FF检查相等<T> ( this IEnumerable<T> yi , IEnumerable<T> er )
    {
        var Tyi = yi . GetEnumerator ( );
        var Ter = er . GetEnumerator ( );

        while ( ( Tyi . MoveNext ( ) == true ) && Ter . MoveNext ( ) )
            {
                if ( ( Tyi . Current is not null ) && !Tyi . Current . Equals ( Ter . Current ) )
                    {
                        return false;
                    }
                return true;
            }
    }

这展示了 LINQ 的第二种用法模式:终端(terminal)方法。这些方法接收一个序列作为输入(在本例中是两个序列),并返回一个单一的标量值。在使用终端方法时,它们总是 LINQ 查询中方法链中的最后一个方法,因此被称为 “terminal” 方法。

当您使用它来确定牌组是否恢复到初始顺序时,您就能看到其实际应用效果。将洗牌代码放入一个循环中,并通过调用 FF检查相等 ( ) 方法在序列恢复到初始顺序时停止循环。您可以看到,它在任何查询中总是最后的方法,因为它返回的是单个值而非一个序列:

public static bool FF检查相等 < T > ( this IEnumerable<T> yi , IEnumerable<T> er )
    {
        var Tyi = yi . GetEnumerator ( );
        var Ter = er . GetEnumerator ( );

        while ( ( Tyi? . MoveNext ( ) == true ) && Ter . MoveNext ( ) )
            {
                if ( ( Tyi . Current is not null ) && !Tyi . Current . Equals ( Ter . Current ) )
                    {
                        return false;
                    }
            }
        return true;
    }

// Main
var times = 0;
Pai混 = Pai起始;
do
    {
        Pai混 = Pai混 . Take ( 26 ) . FF交错序列 ( Pai混 . Skip ( 26 ) );

        foreach ( var p in Pai混 )
            {
                Console . WriteLine ( p );
            }
            Console . WriteLine ( );
            times++;
    } while ( !Pai起始 . FF检查相等 ( Pai混 ) );

Console . WriteLine ( times );

运行你目前所拥有的代码,并留意每次打乱时牌组是如何重新排列的。在进行 8 次打乱操作(即 do……while 循环的迭代次数)之后,牌组会恢复到你最初根据起始 LINQ 查询创建时的原始状态。

优化措施

您目前构建的示例执行的是 “外循环洗牌”,在这种洗牌方式下,顶部和底部的牌在每次运行时保持不变。现在让我们做一点改动:我们将采用 “内循环洗牌”,在这种洗牌方式下,所有的 52 张牌都会改变位置。对于内循环洗牌,您需要将牌组进行交错排列,使得底部半部分中的第一张牌成为牌组中的第一张牌。这意味着顶部半部分中的最后一张牌将成为底部的那张牌。这是一个对单行代码的简单改动。通过交换 “FF取牌” 和 “FF跳过” 这两部分的位置来更新当前的洗牌查询。这将改变牌组顶部和底部两部分的顺序:
Pai混 = Pai混 . Take ( 26 ) . FF交错序列 ( Pai混 . Skip ( 26 ) );
再次运行该程序,您会发现牌组重新排列需要进行 52 次迭代。同时,随着程序的持续运行,您还会开始注意到一些严重的性能下降现象。

造成这种情况的原因有很多。你可以解决导致性能下降的一个主要原因:对惰性求值的不当使用。

简而言之,惰性求值指的是在需要某个表达式的值之前,不会对其进行求值。LINQ 查询就是这种惰性求值的表达式。序列只有在元素被请求时才会生成。通常,这是 LINQ 的一个主要优点。然而,在像这个程序这样的使用场景中,这会导致执行时间呈指数级增长。

请记住,我们是使用 LINQ 查询生成原始牌组的。每次洗牌都是通过对上一个牌组执行三个 LINQ 查询来实现的。所有这些操作都是延迟执行的。这意味着每次请求牌序时都会重新执行这些操作。到第 52 次迭代时,您会多次重新生成原始牌组。让我们写一个日志来展示这种行为。然后,您会对其进行修正。

在您的 LEI静态扩展 中,输入或复制以下方法。此扩展方法会在您的项目目录内创建一个名为 debug.log 的新文件,并将当前正在执行的查询记录到该日志文件中。此扩展方法可以附加到任何查询中,以表明该查询已执行。

public static IEnumerable < T > FFLog < T > ( this IEnumerable < T > 序列 , string 标签 )
    {
        using ( var Log = File . AppendText ( "debug.log" ) )
            {
                Log . WriteLine ( $"执行查询 {标签}" );
            }
        return 序列;
    }

您会在 “File” 下面看到一条红色波浪线,这意味着它不存在。由于编译器不知道 “File” 是什么,所以无法编译。要解决此问题,请务必在 Extensions.cs 中的第一行下面添加以下代码行:
Using System . IO;
这应该能解决问题,红色错误提示也会消失。

接下来,在每个查询的定义中插入一条日志消息:

var Pai起始 = (from h in 花色 ( ) . FFLog ( "花色一代" )
                       from z in 数值 ( ) . FFLog ( "数值一代" )
                       select new { 花色 = h , 数值 = z }) . FFLog ( "起始牌序" );

// 洗牌
var Pai混 = Pai起始;
var cs = 0;

do
    {
        // 外混
        /*
        Pai混 = Pai混 . Take ( 26 )
                              . FFLog ( "上半" )
                              . FF交错序列 ( Pai混 . Skip ( 26 ) )
                              . FFLog ( "下半" )
                              . FFLog ( "混牌" );
        */

        // 内混
        Pai混 = Pai混 . Skip ( 26 ) . FFLog ( "下半" )
                              . FF交错序列 ( Pai混 . Take ( 26 ) . FFLog ( "下半" ) )
                              . FFLog ( "混牌" );

        foreach ( var p in Pai混 )
            {
                Console . WriteLine ( p );
            }

        Console . WriteLine ( );
        Console . WriteLine ( $"次数:{cs}" );
        cs++;
    } while ( !Pai起始 . FF检查相等 ( Pai混 ) );

    Console . WriteLine ( cs );

请注意,您并非每次访问查询时都会进行记录,仅在创建原始查询时进行记录。程序运行时间仍然很长,但现在您能明白原因了。如果您在开启记录的情况下运行内洗牌时失去耐心,可以切换回外洗牌。您仍能看到惰性求值的效果。在一次运行中,它执行了 2592 次查询,包括所有值和花色的生成。

您可以在此处优化代码性能,以减少执行次数。一个简单的修复方法是缓存构建牌组的原始 LINQ 查询的结果。目前,每次 do-while 循环迭代时,您都会再次执行这些查询,每次都重新构建牌组并重新洗牌。要缓存牌组,您可以利用 LINQ 方法 ToArray 和 ToList;将它们附加到查询中时,它们会执行您所指示的操作,但现在会将结果存储在数组或列表中,具体取决于您选择调用的方法。将 LINQ 方法 ToArray 附加到两个查询中,然后再次运行程序:

static void Main(string[] args)
    {
        IEnumerable < string >? HSs = 花色s ( );
        IEnumerable < string >? SZs = 数值s ( );

        if ( ( HSs is null ) || ( SZs is null ) )
            return;

        var Pai起始 = (from h in HSs . FFLog ( "花色一代" )
                               from z in SZs . FFLog ( "数值一代" )
                               select new { 花色 = h , 数值 = z })
                               . FFLog ( "起始牌序" )
                               . ToArray ( );

        // 洗牌
        var Pai混 = Pai起始;
        var cs = 0;

        do
            {
                // 外混
                /*
                Pai混 = Pai混 . Take ( 26 )
                              . FFLog ( "上半" )
                              . FF交错序列 ( Pai混 . Skip ( 26 ) )
                              . FFLog ( "下半" )
                              . FFLog ( "混牌" );
                */

                // 内混
                Pai混 = Pai混 . Skip ( 26 )
                              . FFLog ( "下半" )
                              . FF交错序列 ( Pai混 . Take ( 26 ) . FFLog ( "下半" ) )
                              . FFLog ( "混牌" )
                              . ToArray ( );

                foreach ( var p in Pai混 )
                    {
                        Console . WriteLine ( p );
                    }

                Console . WriteLine ( );
                Console . WriteLine ( $"次数:{cs}" );
                cs++;
        } while ( !Pai起始 . FF检查相等 ( Pai混 ) );

        Console . WriteLine ( cs );
    }

现在,外部洗牌已减少到 30 次查询。再次运行内部洗牌,您会看到类似的改进:现在它执行 162 次查询。

请注意,此示例旨在突出在哪些用例中延迟求值会导致性能问题。虽然了解延迟求值可能对代码性能产生影响的地方很重要,但同样重要的是要明白并非所有查询都应立即执行。如果不使用 ToArray ( ),您会遭受性能损失,这是因为每副新牌的排列都是基于前一副牌的排列构建的。使用延迟求值意味着每副新牌的配置都是基于原始牌组构建的,甚至会执行构建起始牌组的代码。这会导致大量的额外工作。

实际上,有些算法使用急切求值运行良好,而有些算法使用惰性求值运行良好。对于日常使用而言,当数据源是单独的进程(如数据库引擎)时,惰性求值通常是更好的选择。对于数据库而言,惰性求值允许更复杂的查询仅执行一次往返数据库进程和返回到您代码的其余部分的操作。无论您选择使用惰性求值还是急切求值,LINQ 都具有灵活性,因此请衡量您的流程并选择能为您提供最佳性能的求值方式。

结论

在这个项目中,您涵盖了:

  • 使用 LINQ 查询将数据聚合为有意义的序列
  • 编写扩展方法以向 LINQ 查询添加我们自己的自定义功能
  • 在代码中定位 LINQ 查询可能遇到性能问题(如速度下降)的地方
  • 关于 LINQ 查询的延迟和即时求值以及它们可能对查询性能产生的影响

除了 LINQ,您还了解了一些魔术师用于纸牌魔术的技巧。魔术师使用完美洗牌是因为他们可以控制每张牌在牌组中的移动位置。现在您知道了,可别把这秘密告诉其他人!


兔子码农
4 声望1 粉丝

一个酒晕子