パターン マッチングを使用して、より良いコードのためのクラスの動作を構築する

C# のパターン マッチング機能には、アルゴリズムを表すための構文が用意されています。 これらの手法を使用して、クラスの動作を実装できます。 オブジェクト指向のクラス設計と、データ指向の実装を組み合わせることで、現実のオブジェクトをモデル化しながら、簡潔なコードを提供できます。

このチュートリアルで学習する内容は次のとおりです。

  • データ パターンを使用して、オブジェクト指向のクラスを表現します。
  • C# のパターン マッチング機能を使用して、それらのパターンを実装します。
  • コンパイラの診断機能を利用して、実装を検証します。

前提条件

お使いのコンピューターを、.NET が実行されるように設定する必要があります。 Visual Studio 2022 または .NET SDK をダウンロードします。

運河の水門のシミュレーションを構築する

このチュートリアルでは、運河の水門をシミュレートする C# クラスを構築します。 簡単に言うと、運河の水門は、水面の高さが異なる 2 つの水路の間を通過するときに船を上げ下げする仕組みです。 水門には、2 つのゲートと、水位を変更するためのメカニズムがあります。

通常の操作では、船が一方のゲートから進入するとき、水門の水位は船が進入する側の水位と一致しています。 水門内に入ると、船が水門から出て行く側の水位と一致するように、水位が変更されます。 水位がその側と一致すると、出口側のゲートが開きます。 安全対策により、オペレーターは運河を危険な状態にできないようになっています。 水位は、両方のゲートが閉じられている場合にのみ変更できます。 開くことができるのは最大で 1 つのゲートです。 ゲートを開くには、水門内の水位が、開かれるゲートの外側の水位と一致している必要があります。

この動作をモデル化する 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 句がどんどん増え、異なるプロパティをテストするようになります。 やがて、メソッドが複雑すぎて、条件を追加できなくなります。

パターンを使用してコマンドを実装する

もっとよい方法は、"パターン" を使用して、オブジェクトがコマンドを実行するための有効な状態であるかどうかを判断することです。 次の 3 つの変数の関数として、コマンドが許可されるかどうかを表すことができます: ゲートの状態、水位、新しい設定。

新しい設定 ゲートの状態 水位 結果
[不可] [不可] [不可]
[不可] [不可] [不可]
[不可] 開く [不可]
解決済み [ファイル] 解決済み
開く 解決済み 開く
開く 解決済み 閉 (エラー)
開く 未完了 開く
[ファイル] [ファイル] 閉 (エラー)

表の 4 番目と最後の行は無効であるため、テキストを消してあります。 ここで追加しようとしているコードを使用して、水位が低いときは、高い水位のゲートが開かれないようにする必要があります。 それらの状態は、1 つの 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 式ですべての可能な入力がカバーされていないことを示します。 その警告が表示される原因は、入力の 1 つが enum 型であることです。 コンパイラでは、"可能なすべての入力" は、基になる型からのすべての入力 (通常は int) と解釈されます。 この switch 式の場合、enum で宣言されている値のみがチェックされています。 警告を除去するには、式の最後のアームに、キャッチオール破棄パターンを追加します。 この状態は無効な入力を示すため、例外をスローします。

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

前記の switch アームは、すべての入力に一致するため、switch 式の最後に置く必要があります。 前の方の順序に移動して実験します。 そのようにすると、パターン内に到達できないコードがあることを示すコンパイラ エラー CS8510 が発生します。 switch 式の自然な構造により、コンパイラはエラーや警告を生成して間違いを防ぐことができます。 コンパイラの "セーフティ ネット" により、少ない繰り返しで正しいコードをより簡単に作成でき、switch アームとワイルドカードを自由に組み合わせることができます。 組み合わせによって意図せず到達できないアームが作成されると、コンパイラによってエラーが表示され、必要なアームを削除すると警告が表示されます。

最初の変更は、コマンドによってゲートが閉じられるすべてのアームを結合することです。これは常に許可されます。 次のコードを switch 式の最初のアームとして追加します。

(false, _, _) => false,

前の switch アームを追加すると、4 つのコンパイラ エラーが発生します。コマンドが false であるアームごとに 1 つあります。 それらのアームは、新しく追加したアームによって既にカバーされています。 それら 4 つの行は安全に削除できます。 この新しい switch アームを使用して、それらの条件を置き換えます。

次に、ゲートを開くコマンドの 4 つのアームを簡略化できます。 水位が高であるどちらの場合も、ゲートを開くことができます。 (1 つでは既に開いています)。水位が低である 1 つのケースでは例外がスローされ、もう 1 つのケースでは発生しません。 水門が既に無効な状態である場合は、同じ例外がスローされても安全である必要があります。 それらのアームは、次のように簡略化できます。

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

アプリケーションをもう一度実行します。 新しいテストは失敗し、運河の水門が無効な状態になることを確認できます。 残りのメソッドを自分で実装してみてください。 低い方のゲートを設定するメソッドは、高い方のゲートを設定するメソッドに似ているはずです。 水位を変更するメソッドのチェックは異なりますが、同様の構造にする必要があります。 水位を設定するメソッドにも同じプロセスを使用すると便利な場合があります。 次の 4 つのすべての入力から始めます: 両方のゲートの状態、水位の現在の状態、要求された新しい水位。 switch 式の先頭は次のようになります。

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

全部で 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 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 変更を管理したりするリファクタリングがさらに提案される場合があります。 このチュートリアルでは、クラスとオブジェクトを、よりデータ指向でパターンベースのアプローチと組み合わせて、それらのクラスを実装しました。