次の方法で共有


パターン マッチングを使用してクラスの動作を構築し、コードを改善する

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

このチュートリアルでは、次の方法について説明します。

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

前提 条件

.NET を実行するようにマシンを設定する必要があります。 Visual Studio 2022 または .NET SDKをダウンロードします。

運河ロックのシミュレーションを構築する

このチュートリアルでは、運河ロックをシミュレートする C# クラスを構築します。 簡単に言うと、運河ロックは、異なるレベルで水の2つのストレッチの間を移動する際にボートを上下させる装置です。 ロックには、水位を変更するための2つのゲートといくつかのメカニズムがあります。

通常の操作では、ボートはゲートの1つに入り、ロックの水位はボートが入る側の水位と一致する。 ロックに入ると、ボートがロックを離れる水位に合わせて水位が変更されます。 水位がその側と一致すると、出口側のゲートが開きます。 安全対策はオペレータが運河で危険な状況を作成できないことを確かめる。 水位は、両方のゲートが閉じている場合にのみ変更できます。 最大で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 番目と最後の行は無効であるため、テキストを消してあります。 ここで追加するコードでは、水が少ないときに高水門が開かないようにする必要があります。 これらの状態は、単一のスイッチ式としてコード化できます (false は "Closed" を示します)。

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 式の最後に指定する必要があります。 順序を早めに移動して実験してください。 これにより、パターン内の到達できないコードに対する CS8510 コンパイラ エラーが発生します。 スイッチ式の自然な構造により、コンパイラは潜在的なミスに対してエラーや警告を生成できます。 コンパイラの "セーフティ ネット" を使用すると、反復回数を減らし、スイッチ アームとワイルドカードを自由に組み合わせて、適切なコードを簡単に作成できます。 コンパイラは、組み合わせによって予期していなかった到達不能な腕が発生した場合はエラーを発行し、必要なアームを削除すると警告が発生します。

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

(false, _, _) => false,

前のスイッチ アームを追加すると、コマンドが falseされている各アームに 1 つずつ、4 つのコンパイラ エラーが発生します。 これらの腕は、新しく追加された腕によって既に覆われています。 これらの 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 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"),
    };
}

テストに合格し、運河ロックが安全に動作する必要があります。

概要

このチュートリアルでは、パターン マッチングを使用して、その状態に変更を適用する前に、オブジェクトの内部状態を確認する方法について説明しました。 プロパティの組み合わせを確認できます。 これらの遷移のテーブルを作成したら、コードをテストし、読みやすく保守容易にするために簡略化します。 これらの初期リファクタリングでは、内部状態を検証したり、他の API の変更を管理したりするリファクタリングがさらに推奨される場合があります。 このチュートリアルでは、これらのクラスを実装するために、よりデータ指向のパターンベースのアプローチとクラスとオブジェクトを組み合わせたものです。