使用模式比對來建置類別行為,以取得更好的程式碼

C# 中的模式比對功能提供語法來表示您的演算法。 您可以使用這些技術以在類別中實作行為。 您可以將物件導向類別設計與資料導向實作合併來提供簡潔的程式碼,同時建立真實世界物件的模型。

在本教學課程中,您將了解如何:

  • 使用資料模式來表達您的物件導向類別。
  • 使用 C# 的模式比對功能來實作這些模式。
  • 利用編譯器診斷來驗證您的實作。

必要條件

您將需要設定機器執行 .NET。 下載 Visual Studio 2022.NET SDK

建置水閘模擬

在本教學課程中,您將建置 C# 類別,以模擬水閘。 簡言之,水閘是一種裝置,可在船隻經過不同水位的兩段水域時將其抬高和降低。 水閘有兩個閘門和一些機制來變更水位。

在其正常作業中,船隻會進入其中一個閘門,而水閘中的水位會符合船隻進入那側的水位。 位於水閘中之後,會變更水位以符合船隻將離開水閘的水位。 水位符合該側之後,會開啟出口端的閘門。 安全措施確保操作員無法建立水道中的危險狀況。 只有在關閉兩個閘門時,才能變更水位。 最多可以開啟一個閘門。 若要開啟閘門,水閘中的水位必須符合所開啟閘門外部的水位。

您可以建置 C# 類別來建立此行為的模型。 CanalLock 類別將會支援可開啟或關閉任一閘門的命令。 其將會有其他命令來抬高或降低水位。 此類別也應該支援可讀取兩個閘門和水位目前狀態的屬性。 您的方法會實作安全措施。

定義類別

您將建置主控台應用程式來測試您的 CanalLock 類別。 使用 Visual Studio 或 .NET CLI,以建立適用於 .NET 5 的新主控台專案。 然後,新增類別,並將其命名為 CanalLock。 接下來,設計您的公用 API,但不要實作方法:

public enum WaterLevel
{
    Low,
    High
}
public class CanalLock
{
    // Query canal lock state:
    public WaterLevel CanalLockWaterLevel { get; private set; } = WaterLevel.Low;
    public bool HighWaterGateOpen { get; private set; } = false;
    public bool LowWaterGateOpen { get; private set; } = false;

    // Change the upper gate.
    public void SetHighGate(bool open)
    {
        throw new NotImplementedException();
    }

    // Change the lower gate.
    public void SetLowGate(bool open)
    {
        throw new NotImplementedException();
    }

    // Change water level.
    public void SetWaterLevel(WaterLevel newLevel)
    {
        throw new NotImplementedException();
    }

    public override string ToString() =>
        $"The lower gate is {(LowWaterGateOpen ? "Open" : "Closed")}. " +
        $"The upper gate is {(HighWaterGateOpen ? "Open" : "Closed")}. " +
        $"The water level is {CanalLockWaterLevel}.";
}

上述程式碼會初始化物件,以關閉兩個閘門,而且水位低。 接下來,在 Main 方法中撰寫下列測試程式碼,以引導您建立類別的第一個實作:

// Create a new canal lock:
var canalGate = new CanalLock();

// State should be doors closed, water level low:
Console.WriteLine(canalGate);

canalGate.SetLowGate(open: true);
Console.WriteLine($"Open the lower gate:  {canalGate}");

Console.WriteLine("Boat enters lock from lower gate");

canalGate.SetLowGate(open: false);
Console.WriteLine($"Close the lower gate:  {canalGate}");

canalGate.SetWaterLevel(WaterLevel.High);
Console.WriteLine($"Raise the water level: {canalGate}");

canalGate.SetHighGate(open: true);
Console.WriteLine($"Open the higher gate:  {canalGate}");

Console.WriteLine("Boat exits lock at upper gate");
Console.WriteLine("Boat enters lock from upper gate");

canalGate.SetHighGate(open: false);
Console.WriteLine($"Close the higher gate: {canalGate}");

canalGate.SetWaterLevel(WaterLevel.Low);
Console.WriteLine($"Lower the water level: {canalGate}");

canalGate.SetLowGate(open: true);
Console.WriteLine($"Open the lower gate:  {canalGate}");

Console.WriteLine("Boat exits lock at upper gate");

canalGate.SetLowGate(open: false);
Console.WriteLine($"Close the lower gate:  {canalGate}");

接下來,新增 CanalLock 類別中每個方法的第一個實作。 下列程式碼會實作類別的方法,而不需考慮安全規則。 您稍後將新增安全測試:

// Change the upper gate.
public void SetHighGate(bool open)
{
    HighWaterGateOpen = open;
}

// Change the lower gate.
public void SetLowGate(bool open)
{
    LowWaterGateOpen = open;
}

// Change water level.
public void SetWaterLevel(WaterLevel newLevel)
{
    CanalLockWaterLevel = newLevel;
}

您到目前為止所撰寫的測試已通過。 您已實作基本知識。 現在,撰寫第一個失敗狀況的測試。 在先前測試結束時,會關閉兩個閘門,而且水位會設定為低。 新增測試以嘗試開啟上方閘門:

Console.WriteLine("=============================================");
Console.WriteLine("     Test invalid commands");
// Open "wrong" gate (2 tests)
try
{
    canalGate = new CanalLock();
    canalGate.SetHighGate(open: true);
}
catch (InvalidOperationException)
{
    Console.WriteLine("Invalid operation: Can't open the high gate. Water is low.");
}
Console.WriteLine($"Try to open upper gate: {canalGate}");

此測試失敗,因為開啟閘門。 第一個實作是您可以使用下列程式碼進行修正:

// Change the upper gate.
public void SetHighGate(bool open)
{
    if (open && (CanalLockWaterLevel == WaterLevel.High))
        HighWaterGateOpen = true;
    else if (open && (CanalLockWaterLevel == WaterLevel.Low))
        throw new InvalidOperationException("Cannot open high gate when the water is low");
}

您的測試已通過。 但是,當您新增更多測試時,將會新增更多 if 子句,並測試不同的屬性。 很快,當您新增更多條件時,這些方法會變得太過複雜。

使用模式來實作命令

更好的方法是使用 patterns 來判斷物件是否處於可執行命令的有效狀態。 您可以表示是否允許命令作為三個變數的函數:閘門狀態、水位和新設定:

新設定 閘門狀態 水位 結果
已關閉 已關閉 已關閉
已關閉 已關閉 已關閉
已關閉 開盤 已關閉
結案 Open 結案
開盤 已關閉 開盤
開盤 已關閉 已關閉 (錯誤)
開盤 開盤 開盤
Open Open 已關閉 (錯誤)

資料表中的第四個和最後一個資料列會有刪除線文字,因為它們無效。 您現在要新增的程式碼應該確定在水量不足時永遠不會開啟高水位閘門。 這些狀態可以編碼為單一 switch 運算式 (請記住,false 指出 [已關閉]):

HighWaterGateOpen = (open, HighWaterGateOpen, CanalLockWaterLevel) switch
{
    (false, false, WaterLevel.High) => false,
    (false, false, WaterLevel.Low) => false,
    (false, true, WaterLevel.High) => false,
    (false, true, WaterLevel.Low) => false, // should never happen
    (true, false, WaterLevel.High) => true,
    (true, false, WaterLevel.Low) => throw new InvalidOperationException("Cannot open high gate when the water is low"),
    (true, true, WaterLevel.High) => true,
    (true, true, WaterLevel.Low) => false, // should never happen
};

請嘗試此版本。 您的測試已通過,正在驗證程式碼。 完整資料表會顯示可能的輸入與結果組合。 這表示您和其他開發人員可以快速地查看資料表,並瞭解您已涵蓋所有可能的輸入。 甚至更為輕鬆,編譯器也有所幫助。 在您新增先前的程式碼之後,可以看到編譯器產生警告:CS8524 指出 switch 運算式未涵蓋所有可能的輸入。 該警告的原因是其中一個輸入是 enum 類型。 編譯器會將「所有可能輸入」解譯為基礎類型的所有輸入,通常是 int。 此 switch 運算式只會檢查 enum 中所宣告的值。 若要移除警告,您可以為運算式的最後一個 arm 新增全部捨棄模式。 此條件會擲回例外狀況,因為這指出輸入不正確:

_  => throw new InvalidOperationException("Invalid internal state"),

上述 switch arm 必須是 switch 運算式中的最後一個項目,因為其符合所有輸入。 將其依順序往前移動來進行實驗。 這會導致模式中無法連線程式碼的編譯器錯誤 CS8510。 switch 運算式的自然結構可讓編譯器針對可能的錯誤產生錯誤和警告。 編譯器「安全網路」可讓您以較少的反覆運算更輕鬆地建立正確的程式碼,而且可以自由地合併 switch arm 與萬用字元。 如果您的組合導致未預期的無法連線 arm,則編譯器將會發出錯誤;如果您移除所需的 arm,則會發出警告。

第一個變更是合併命令關閉閘門的所有 arm;這一律予以允許。 將下列程式碼新增為 switch 運算式中的第一個 arm:

(false, _, _) => false,

在您新增上一個 switch arm 之後,會收到四個編譯器錯誤,命令是 false 的每個 arm 都各有一個。 新增的 arm 已涵蓋這些 arm。 您可以放心地移除這四行。 您打算使用這個新的 switch arm 來取代這些條件。

接下來,您可以簡化命令開啟閘門的四個 arm。 在這兩個高水位案例中,可以開啟閘門。 (在一個案例中,已予以開啟。)其中一個低水位案例會擲回例外狀況,另一個則不應該發生。 如果水鎖已處於無效狀態,則擲回相同的例外狀況應該很安全。 您可以針對這些 arm 進行下列簡化:

(true, _, WaterLevel.High) => true,
(true, false, WaterLevel.Low) => throw new InvalidOperationException("Cannot open high gate when the water is low"),
_ => throw new InvalidOperationException("Invalid internal state"),

再次執行測試,而且全部通過。 以下是 SetHighGate 方法的最終版本:

// Change the upper gate.
public void SetHighGate(bool open)
{
    HighWaterGateOpen = (open, HighWaterGateOpen, CanalLockWaterLevel) switch
    {
        (false, _,    _)               => false,
        (true, _,     WaterLevel.High) => true,
        (true, false, WaterLevel.Low)  => throw new InvalidOperationException("Cannot open high gate when the water is low"),
        _                              => throw new InvalidOperationException("Invalid internal state"),
    };
}

自行實作模式

既然您已瞭解這項技術,請自行填入 SetLowGateSetWaterLevel 方法。 請從新增下列程式碼以在這些方法上測試無效作業開始:

Console.WriteLine();
Console.WriteLine();
try
{
    canalGate = new CanalLock();
    canalGate.SetWaterLevel(WaterLevel.High);
    canalGate.SetLowGate(open: true);
}
catch (InvalidOperationException)
{
    Console.WriteLine("invalid operation: Can't open the lower gate. Water is high.");
}
Console.WriteLine($"Try to open lower gate: {canalGate}");
// change water level with gate open (2 tests)
Console.WriteLine();
Console.WriteLine();
try
{
    canalGate = new CanalLock();
    canalGate.SetLowGate(open: true);
    canalGate.SetWaterLevel(WaterLevel.High);
}
catch (InvalidOperationException)
{
    Console.WriteLine("invalid operation: Can't raise water when the lower gate is open.");
}
Console.WriteLine($"Try to raise water with lower gate open: {canalGate}");
Console.WriteLine();
Console.WriteLine();
try
{
    canalGate = new CanalLock();
    canalGate.SetWaterLevel(WaterLevel.High);
    canalGate.SetHighGate(open: true);
    canalGate.SetWaterLevel(WaterLevel.Low);
}
catch (InvalidOperationException)
{
    Console.WriteLine("invalid operation: Can't lower water when the high gate is open.");
}
Console.WriteLine($"Try to lower water with high gate open: {canalGate}");

再次執行應用程式。 您可以看到新測試失敗,而水閘會進入無效狀態。 請嘗試自行實作其餘的方法。 設定低閘門的方法應該與設定高閘門的方法類似。 變更水位的方法有不同的檢查,但應該遵循類似的結構。 您可能會發現,針對設定水位的方法使用相同的程序十分有用。 從這四個輸入開始:兩個閘門的狀態、水位的目前狀態,以及所要求的新水位。 switch 運算式的開頭應該為:

CanalLockWaterLevel = (newLevel, CanalLockWaterLevel, LowWaterGateOpen, HighWaterGateOpen) switch
{
    // elided
};

您一共要填入 16 個 switch arm。 然後,測試並簡化。

您是否讓方法看起來像這樣?

// Change the lower gate.
public void SetLowGate(bool open)
{
    LowWaterGateOpen = (open, LowWaterGateOpen, CanalLockWaterLevel) switch
    {
        (false, _, _) => false,
        (true, _, WaterLevel.Low) => true,
        (true, false, WaterLevel.High) => throw new InvalidOperationException("Cannot open high gate when the water is low"),
        _ => throw new InvalidOperationException("Invalid internal state"),
    };
}

// Change water level.
public void SetWaterLevel(WaterLevel newLevel)
{
    CanalLockWaterLevel = (newLevel, CanalLockWaterLevel, LowWaterGateOpen, HighWaterGateOpen) switch
    {
        (WaterLevel.Low, WaterLevel.Low, true, false) => WaterLevel.Low,
        (WaterLevel.High, WaterLevel.High, false, true) => WaterLevel.High,
        (WaterLevel.Low, _, false, false) => WaterLevel.Low,
        (WaterLevel.High, _, false, false) => WaterLevel.High,
        (WaterLevel.Low, WaterLevel.High, false, true) => throw new InvalidOperationException("Cannot lower water when the high gate is open"),
        (WaterLevel.High, WaterLevel.Low, true, false) => throw new InvalidOperationException("Cannot raise water when the low gate is open"),
        _ => throw new InvalidOperationException("Invalid internal state"),
    };
}

您的測試應該已通過,而且水閘應該已安全地操作。

摘要

在本教學課程中,您已了解如何先使用模式比對來檢查物件的內部狀態,再將任何變更套用至該狀態。 您可以檢查屬性的組合。 在您建置所有這些轉換的資料表之後,可以測試程式碼,然後簡化以提高可讀性和可維護性。 這些初始重構可能會建議進一步重構,以驗證內部狀態或管理其他 API 變更。 本教學課程已使用更具資料導向的模式型方式來合併類別和物件,以實作這些類別。