Stories

Detail Return Return

C# 教程 - Stories Detail

創建 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,您還了解了一些魔術師用於紙牌魔術的技巧。魔術師使用完美洗牌是因為他們可以控制每張牌在牌組中的移動位置。現在您知道了,可別把這秘密告訴其他人!

Add a new Comments

Some HTML is okay.