教學:模式比對基礎

Tip

剛開始開發軟體嗎? 先從 入門 教學開始。 他們介紹類別、方法和控制流程。

有其他語言的經驗嗎? 本教學展示了當規則依賴於物件當前狀態時,C# 模式如何清楚表達物件行為。

在這個教學中,你會建立一個主控台應用程式,為運河閘門的規則建立模型。

運河船閘可在兩段不同高度的水域之間升降船隻。 它有兩個閘門和一個水位在低水位和高水位間變化的艙室。 只有當水位與閘門位置保持在有效組合中時,船閘才能安全運作。

在這個教學中,你會學到如何:

  • 透過根據狀態比對來表達物件行為。
  • 用 C# 模式匹配實作那些規則。
  • 使用編譯器診斷來驗證你的實作。

Prerequisites

安裝指示

在 Windows 上,這個 WinGet 設定檔 用來安裝所有前置條件。 如果您已安裝某些專案,WinGet 將會略過此步驟。

  1. 下載檔案,然後按兩下以執行它。
  2. 閱讀許可協議,輸入 y,然後在系統提示接受時選取 [輸入]。
  3. 如果您在任務欄中收到閃爍的用戶帳戶控制 (UAC) 提示,請允許安裝繼續。

在其他平臺上,您必須個別安裝這些元件。

  1. 從 .NET SDK 下載頁面下載 推薦安裝程式,雙擊即可執行。 下載頁面會偵測您的平臺,並建議您平臺的最新安裝程式。
  2. 從 Visual Studio Code 首頁下載最新安裝程式,雙擊即可執行。 該頁面還會偵測您的平臺,並且應該提供適合您系統的正確連結。
  3. 按兩下 C# DevKit 擴充功能頁面上的 [安裝] 按鈕。 這會打開 Visual Studio 程式碼,並詢問你是否要安裝或啟用該擴充功能。 選取 [安裝]。

建立運河船閘模擬

運河閘門讓船隻在不同水位的航道之間升降。 在這個教學中,模擬的船閘有下閘門、上閘門,且水位可低可高。

正常運作時,當船閘內的水位與入口側水位相符時,船隻才會進入船閘。 船一進去,兩個大門就會關閉。 水位會調整至與出口側相同,然後出口閘門打開。 為了確保模型安全,水位只有在兩扇閘門同時關閉時才會改變,而閘門只有在水位與該側相符時才能開啟。

你可以用 CanalLock 類別來建模這些規則。 它提供可開啟或關閉任一閘門,以及升高或降低水位的指令。 它也會提供報告鎖目前狀態的屬性。

定義類別

建立一個主控台專案,然後新增一個名為 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}");

現在新增第一個實作,改變每個狀態值而不強制執行安全規則:

// 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 陳述式。 程式碼變得更難瀏覽,因為每條規則彼此分開。

用模式實作指令

更明確的做法是直接用 模式 描述有效的組合。 下一步,每個 switch 運算式都使用一個元組作為 模式輸入。 C# 會評估該元組一次,每個 switch 分支會測試當前閘態、水位及要求的新設定。

對於上方的閘門,你可以這樣總結這些組合:

新設定 閘門狀態 水位 Result
已關閉 已關閉 高 已關閉
已關閉 已關閉 低 已關閉
已關閉 Open 高 已關閉
Closed 開啟 Low Closed
Open 已關閉 高 Open
Open 已關閉 低 已關閉(錯誤)
Open Open 高 Open
開啟 開啟 Low 已關閉(錯誤)

加刪除線的列代表無效的內部狀態。 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
};

試試這個版本。 你的測試通過了。 編譯器也警告 switch 運算式並非 完整無遺 ——它無法涵蓋所有可能的值——因為 WaterLevel 是枚舉,而 C# 允許將枚舉底層數值類型中的任何值投射到該枚舉,即使是沒有對應具名成員的值。 最後加上一個帶有丟棄模式(_)的分支,以處理這些不可能的內部狀態:

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

該分支必須放在最後,因為丟棄模式會匹配所有剩餘輸入。

你可以簡化之前的分支。 關閉閘門始終是允許的,因此一個分支可以取代四個獨立的關閉情況:

(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"),

再次執行程式。 測試還是通過了。 以下是最終 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"),
    };
}

實作剩餘規則

現在將同樣的概念應用於 SetLowGate 和 SetWaterLevel。 首先加入暴露無效操作的測試:

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}");

重新執行應用程式。 這些測試失敗,運河鎖會進入無效狀態。 剩餘的方法則透過完整狀態的匹配來實作。 類似於 。 SetWaterLevel 使用當前水位加上兩個閘門位置:

CanalLockWaterLevel = (newLevel, CanalLockWaterLevel, LowWaterGateOpen, HighWaterGateOpen) switch
{
    // arms go here
};

你有16種組合要考慮。 從完整表格開始,寫出 switch 分支,執行測試,然後簡化重複結果。

你最後有用到類似的方法嗎?

// 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 low gate when the water is high"),
        _ => 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"),
    };
}

你的測試現在應該會通過,而運河閘門鎖定機制也會執行其安全規則。

Summary

在這個教學中,你用模式匹配來表達物件如何從一個有效狀態轉換到另一個有效狀態。 模式會將允許的過渡串連在一起,讓你比起一連串分支語句更容易比較它們。 當行為依賴於多個狀態值的組合形狀時,這種方法效果良好。