チュートリアル: パターン マッチングを使用してアルゴリズムを構築する

パターン マッチングを使用すると、他のライブラリで定義されている型に対して、それらの型を変更せずに機能を追加できます。 パターンの別の用途は、アプリケーションで必要な、拡張される型の基本機能ではない機能を作成することです。

このチュートリアルでは、以下の内容を学習します。

  • パターン マッチングを使用する必要がある状況を認識する。
  • パターン マッチング式を使用して、型とプロパティの値に基づく動作を実装する。
  • パターン マッチングと他の手法を組み合わせて、完全なアルゴリズムを作成する。

Prerequisites

インストール手順

Windows では、この WinGet 構成ファイル を使用して、すべての前提条件をインストールします。 既に何かがインストールされている場合、WinGet はその手順をスキップします。

  1. ファイルをダウンロードし、ダブルクリックして実行します。
  2. 使用許諾契約書を読み、y と入力し、同意を求められたら Enter キーを選択します。
  3. タスク バーで点滅するユーザー アカウント制御 (UAC) プロンプトが表示された場合は、インストールを続行します。

他のプラットフォームでは、これらの各コンポーネントを個別にインストールする必要があります。

  1. 推奨されるインストーラーを .NET SDK ダウンロード ページからダウンロードし、ダブルクリックして実行します。 ダウンロード ページでプラットフォームが検出され、プラットフォームの最新のインストーラーが推奨されます。
  2. Visual Studio Codeホーム ページから最新のインストーラーをダウンロードし、ダブルクリックして実行します。 このページではプラットフォームも検出され、リンクはシステムに適している必要があります。
  3. C# DevKit 拡張機能ページの [インストール] ボタンをクリックします。 これで Visual Studio Code が開き、拡張機能をインストールするか有効にするかを確認するメッセージが表示されます。 [インストール] を選択します。

パターン マッチングのシナリオ

多くの場合、最新の開発には、1 つのまとまりのあるアプリケーションの中で、複数のソースから受信するデータを統合し、それらのデータに基づいて情報と分析情報を提示することが含まれます。 あなたとあなたのチームは、受信データを表す型を制御できない場合があります。

従来のオブジェクト指向の設計アプローチでは、これらの複数のデータ ソースの各データ型を表す型をアプリケーションで作成する必要があります。 その後、アプリケーションで、それらの新しい型を操作し、継承階層を構築し、仮想メソッドを作成し、抽象化を実装します。 これらの手法は有効であり、ときには最善のツールです。 少ないコードを記述できる場合があります。 場合によっては、データを使用する操作からデータを分離すると、コードが読みやすくなります。

このチュートリアルでは、1 つのシナリオで複数の外部ソースから受信データを受け取るアプリケーションを作成し、調査します。 パターン マッチングによって、元のシステムに含まれていなかった方法でそのデータを効率的に取り込み、処理できることがわかります。

通行料とピーク時間帯料金を使用してトラフィックを管理する主要な都市圏を考えてみましょう。 車種に基づいて通行料金を計算するアプリケーションを記述します。 将来的な機能強化として、車両の乗員数に基づく料金制度を組み込みます。 さらなる機能強化として、時間帯と曜日に基づく料金を追加します。

その簡単な説明から、このシステムをモデル化するためにオブジェクト階層の概略をすばやく描けるでしょう。 ただし、データは、他の車両登録管理システムなど、複数のソースから取得されます。 これらのシステムは、そのデータをモデル化するために異なるクラスを提供し、それらのシステムは単一のオブジェクト モデルを共有しません。 このチュートリアルでは、次のコードに示すように、これらの簡略化されたクラスを使用して、これらの外部システムの車両データをモデル化します。

namespace ConsumerVehicleRegistration
{
    public class Car
    {
        public int Passengers { get; set; }
    }
}

namespace CommercialRegistration
{
    public class DeliveryTruck
    {
        public int GrossWeightClass { get; set; }
    }
}

namespace LiveryRegistration
{
    public class Taxi
    {
        public int Fares { get; set; }
    }

    public class Bus
    {
        public int Capacity { get; set; }
        public int Riders { get; set; }
    }
}

GitHub の dotnet/samples リポジトリから、スタート コードをダウンロードすることができます。 車両クラスが異なるシステムからのものであり、異なる名前空間にあることがわかります。 System.Object以外の共通基底クラスは使用できません。

パターンマッチングのデザイン

このチュートリアルで使用するシナリオでは、パターン マッチングを使用して解決するのに適した種類の問題に焦点を当てています。

  • 操作する必要があるオブジェクトは、目標と一致するオブジェクト階層内にはありません。 関連のないシステムの一部であるクラスを扱っている可能性があります。
  • 追加する機能は、これらのクラスのコア抽象化の一部ではありません。 車両が支払う通行料金は、車両の種類によって "変化" しますが、通行料金は車両の中心的な関数ではありません。

データの "形状" とデータに対する "操作" が一緒に記述されていない場合は、C# のパターン マッチング機能によって、作業が容易になります。

基本的な通行料金計算を実装する

最も基本的な通行料金計算は、車種にのみ依存します。

  • Car は $2.00。
  • Taxi は $3.50。
  • Bus は $5.00。
  • DeliveryTruckは $10.00 です。

新しい TollCalculator クラスを作成し、車種に対するパターン マッチングを実装して、通行料金の金額を取得します。 TollCalculator の最初の実装を次のコードに示します。

using System;
using CommercialRegistration;
using ConsumerVehicleRegistration;
using LiveryRegistration;

namespace Calculators;

public class TollCalculator
{
    public decimal CalculateToll(object vehicle) =>
        vehicle switch
    {
        Car c           => 2.00m,
        Taxi t          => 3.50m,
        Bus b           => 5.00m,
        DeliveryTruck t => 10.00m,
        { }             => throw new ArgumentException(message: "Not a known vehicle type", paramName: nameof(vehicle)),
        null            => throw new ArgumentNullException(nameof(vehicle))
    };
}

上記のコードでは、式を使用して宣言パターンをテストしています ( ステートメントとは異なります)。 上記のコードでは、switch 式は変数 vehicle で始まり、switch キーワードが続きます。 次に、すべての switch アームが中かっこ内に指定されます。 switch 式は、switch ステートメントを囲む構文に対して、その他の絞り込みを行います。 case キーワードは省略され、各アームの結果が式になります。 最後の 2 つのアームは、新しい言語機能を示しています。 { } case は、前のアームと一致しなかった null 以外のオブジェクトと一致します。 このアームは、このメソッドに渡された正しくない型をキャッチします。 { } case は、車種ごとの case に従う必要があります。 順序が逆になった場合は、{ } case が優先されます。 最後に、null定数パターンで、このメソッドに null がいつ渡されたかを検出します。 他のパターンが null 以外の正しい種類のオブジェクトのみと一致するため、null パターンを最後にすることができます。

このコードを Program.cs の次のコードを使用してテストできます。

using System;
using CommercialRegistration;
using ConsumerVehicleRegistration;
using LiveryRegistration;

using toll_calculator;

var tollCalc = new TollCalculator();

var car = new Car();
var taxi = new Taxi();
var bus = new Bus();
var truck = new DeliveryTruck();

Console.WriteLine($"The toll for a car is {tollCalc.CalculateToll(car)}");
Console.WriteLine($"The toll for a taxi is {tollCalc.CalculateToll(taxi)}");
Console.WriteLine($"The toll for a bus is {tollCalc.CalculateToll(bus)}");
Console.WriteLine($"The toll for a truck is {tollCalc.CalculateToll(truck)}");

try
{
    tollCalc.CalculateToll("this will fail");
}
catch (ArgumentException e)
{
    Console.WriteLine("Caught an argument exception when using the wrong type");
}
try
{
    tollCalc.CalculateToll(null!);
}
catch (ArgumentNullException e)
{
    Console.WriteLine("Caught an argument exception when using null");
}

そのコードはスターター プロジェクトに含まれていますが、コメント アウトされています。コメントを削除すると、作成した内容をテストできます。

コードとデータが分離されるアルゴリズムの作成にパターンがどのように役立つかを示す最初の例を見てきました。 switch 式によって型がテストされ、結果に基づいて別の値が生成されます。 これはほんの序の口に過ぎません。

乗員料金を追加する

料金徴収機関は、車両が乗車定員を乗せて走行することを推奨したいと思っています。 車両の乗員数が少ない場合は多く請求し、乗車定員の場合は料金を下げることによって、定員いっぱいでの走行を推奨することを決定しています。

  • 乗客がいない自動車とタクシーは、$0.50 余分に支払う。
  • 2 名の乗客がいる自動車とタクシーは、$0.50 割引される。
  • 3 名以上の乗客がいる自動車とタクシーは、$1.00 割引される。
  • 乗車率が 50% 未満のバスは、$2.00 余分に支払う。
  • 乗車率が 90% を超えるバスは、$1.00 割引される。

これらの規則は、同じ switch 式の プロパティ パターン を使用して実装できます。 プロパティ パターンでは、プロパティ値と定数値が比較されます。 プロパティ パターンは、型が決定されると、オブジェクトのプロパティを調べます。 Car {Passengers: 0}自体が再帰的なパターンです: Carにおける外部プロパティ パターンには、Passengers の値を検証する内部定数パターンが含まれています。 Car に対する 1 つの case が、4 つの異なる case に拡張されます。

vehicle switch
{
    Car {Passengers: 0} => 2.00m + 0.50m,
    Car {Passengers: 1} => 2.0m,
    Car {Passengers: 2} => 2.0m - 0.50m,
    Car                 => 2.00m - 1.0m,

    // ...
};

最初の 3 つの case では、型を Car としてテストした後、Passengers プロパティの値がチェックされます。 両方が一致する場合、式は評価され、値を返します。

タクシーに対する case も、同様の方法で拡張します。

vehicle switch
{
    // ...

    Taxi {Fares: 0}  => 3.50m + 1.00m,
    Taxi {Fares: 1}  => 3.50m,
    Taxi {Fares: 2}  => 3.50m - 0.50m,
    Taxi             => 3.50m - 1.00m,

    // ...
};

次に、次の例に示すように、バスに対する case を拡張して、乗車率ルールを実装します。

vehicle switch
{
    // ...

    Bus b when ((double)b.Riders / (double)b.Capacity) < 0.50 => 5.00m + 2.00m,
    Bus b when ((double)b.Riders / (double)b.Capacity) > 0.90 => 5.00m - 1.00m,
    Bus => 5.00m,

    // ...
};

料金徴収機関は、配送トラックの乗客数には関心がありません。 代わりに、次のようにトラックの重量クラスに基づいて通行料金の金額を調整します。

  • 5,000 lbs を超えるトラックは、$5.00 余分に請求される。
  • 3,000 lbs を下回る軽トラックは、$2.00 割引される。

この規則は、次のコードを使用して実装できます。

vehicle switch
{
    // ...

    DeliveryTruck t when (t.GrossWeightClass > 5000) => 10.00m + 5.00m,
    DeliveryTruck t when (t.GrossWeightClass < 3000) => 10.00m - 2.00m,
    DeliveryTruck => 10.00m,
};

前のコードは、switch アームの when 句を示しています。 プロパティの等値以外の条件をテストするには、 when 句を使用します。 完了すると、次のコードのようなメソッドができあがっています。

vehicle switch
{
    Car {Passengers: 0}        => 2.00m + 0.50m,
    Car {Passengers: 1}        => 2.0m,
    Car {Passengers: 2}        => 2.0m - 0.50m,
    Car                        => 2.00m - 1.0m,

    Taxi {Fares: 0}  => 3.50m + 1.00m,
    Taxi {Fares: 1}  => 3.50m,
    Taxi {Fares: 2}  => 3.50m - 0.50m,
    Taxi             => 3.50m - 1.00m,

    Bus b when ((double)b.Riders / (double)b.Capacity) < 0.50 => 5.00m + 2.00m,
    Bus b when ((double)b.Riders / (double)b.Capacity) > 0.90 => 5.00m - 1.00m,
    Bus => 5.00m,

    DeliveryTruck t when (t.GrossWeightClass > 5000) => 10.00m + 5.00m,
    DeliveryTruck t when (t.GrossWeightClass < 3000) => 10.00m - 2.00m,
    DeliveryTruck => 10.00m,

    { }     => throw new ArgumentException(message: "Not a known vehicle type", paramName: nameof(vehicle)),
    null    => throw new ArgumentNullException(nameof(vehicle))
};

前述のように、これらのスイッチ アームは再帰的なパターンであり、プロパティ パターン内に定数パターンを入れ子にします。

switch を入れ子にして使用することで、このコードの反復を少なくすることができます。 Car と Taxi は、どちらにも上記の例で示した 4 つの異なるアームがあります。 どちらの場合も、定数パターンに取り込まれる宣言パターンを作成できます。 この手法を次のコードに示します。

public decimal CalculateToll(object vehicle) =>
    vehicle switch
    {
        Car c => c.Passengers switch
        {
            0 => 2.00m + 0.5m,
            1 => 2.0m,
            2 => 2.0m - 0.5m,
            _ => 2.00m - 1.0m
        },

        Taxi t => t.Fares switch
        {
            0 => 3.50m + 1.00m,
            1 => 3.50m,
            2 => 3.50m - 0.50m,
            _ => 3.50m - 1.00m
        },

        Bus b when ((double)b.Riders / (double)b.Capacity) < 0.50 => 5.00m + 2.00m,
        Bus b when ((double)b.Riders / (double)b.Capacity) > 0.90 => 5.00m - 1.00m,
        Bus b => 5.00m,

        DeliveryTruck t when (t.GrossWeightClass > 5000) => 10.00m + 5.00m,
        DeliveryTruck t when (t.GrossWeightClass < 3000) => 10.00m - 2.00m,
        DeliveryTruck t => 10.00m,

        { }  => throw new ArgumentException(message: "Not a known vehicle type", paramName: nameof(vehicle)),
        null => throw new ArgumentNullException(nameof(vehicle))
    };

前のサンプルでは、スイッチ式を別のスイッチ アーム内に入れ子にすることで、プロパティ値をテストする子アームを含む Car アームと Taxi アームを繰り返さずに済みます。 この手法は、Bus および DeliveryTruck アームには使用されません。これらのアームは、個別の値ではなく、プロパティの範囲をテストするためです。

ピーク料金を追加する

最後の機能として、道路料金当局は時間帯に応じたピーク料金設定を追加したいと考えています。 朝と夕方のラッシュ アワーの間は、通行料金を倍にします。 このルールは、片方向の通行のみに影響し、朝のラッシュ時は市内に入る (インバウンド) 通行が、夕方のラッシュ時は市外に出てゆく (アウトバウンド) 通行が対象になります。 平日のその他の時間帯では、通行料金は 50% 増額される。 深夜と早朝の通行料金は、25% 減額される。 週末は、時間に関係なく、通常料金になる。 一連の if ステートメントと else ステートメントを使用して、次のコードを使用してこの規則を表現できます。

public decimal PeakTimePremiumIfElse(DateTime timeOfToll, bool inbound)
{
    if ((timeOfToll.DayOfWeek == DayOfWeek.Saturday) ||
        (timeOfToll.DayOfWeek == DayOfWeek.Sunday))
    {
        return 1.0m;
    }
    else
    {
        int hour = timeOfToll.Hour;
        if (hour < 6)
        {
            return 0.75m;
        }
        else if (hour < 10)
        {
            if (inbound)
            {
                return 2.0m;
            }
            else
            {
                return 1.0m;
            }
        }
        else if (hour < 16)
        {
            return 1.5m;
        }
        else if (hour < 20)
        {
            if (inbound)
            {
                return 1.0m;
            }
            else
            {
                return 2.0m;
            }
        }
        else // Overnight
        {
            return 0.75m;
        }
    }
}

上記のコードは正しく動作しますが、読みやすくありません。 コードを理解するには、すべての入力ケースと入れ子になった if ステートメントを連結する必要があります。 代わりに、この機能のためにパターン マッチングを使用しながら、これを他の手法と統合します。 方向、曜日、時刻のすべての組み合わせを考慮した 1 つのパターン 一致式を作成できます。 結果は、複雑な式になるでしょう。 読みにくく、理解しにくくなるでしょう。 それは、正確さを保証することを難しくします。 代わりに、メソッドを組み合わせて、すべての状態を簡潔に記述する値のタプルを作成します。 その後、パターン マッチングを使用して、通行料金の乗数を計算できます。 タプルには、3 つの個別の条件が含まれています。

  • その日が平日または週末のどちらであるか。
  • 通行料金が収集される時間帯。
  • 方向は市内に向かうか、市外に向かうかです。

次の表に、入力値の組み合わせとピーク時の乗数を示します。

日 時間 Direction Premium
平日 朝のラッシュ時 受信 x 2.00
平日 朝のラッシュ時 アウトバウンド x 1.00
平日 昼間 受信 x 1.50
平日 昼間 アウトバウンド x 1.50
平日 夕方のラッシュ時 受信 x 1.00
平日 夕方のラッシュ時 アウトバウンド x 2.00
平日 夜間 受信 x 0.75
平日 夜間 送信 x 0.75
週末 朝のラッシュ時 受信 x 1.00
週末 朝のラッシュ時 アウトバウンド x 1.00
週末 昼間 受信 x 1.00
週末 昼間 アウトバウンド x 1.00
週末 夕方のラッシュ時 受信 x 1.00
週末 夕方のラッシュ時 アウトバウンド x 1.00
週末 夜間 受信 x 1.00
週末 夜間 アウトバウンド x 1.00

3 つの変数の 16 組の異なる組み合わせがあります。 一部の条件を組み合わせることで、最終的なスイッチ式を簡略化できます。

通行料金を収集するシステムでは、通行料金が徴収される時刻に対して DateTime 構造体を使用しています。 上記の表から、変数を作成するメンバー メソッドを作成します。 次の関数では、パターン マッチングの switch 式を使用して、DateTime が週末または平日のどちらを表しているかを示しています。

private static bool IsWeekDay(DateTime timeOfToll) =>
    timeOfToll.DayOfWeek switch
    {
        DayOfWeek.Monday    => true,
        DayOfWeek.Tuesday   => true,
        DayOfWeek.Wednesday => true,
        DayOfWeek.Thursday  => true,
        DayOfWeek.Friday    => true,
        DayOfWeek.Saturday  => false,
        DayOfWeek.Sunday    => false
    };

このメソッドは正しいものですが、繰り返しが含まれています。 次のコードに示すように簡略化できます。

private static bool IsWeekDay(DateTime timeOfToll) =>
    timeOfToll.DayOfWeek switch
    {
        DayOfWeek.Saturday => false,
        DayOfWeek.Sunday => false,
        _ => true
    };

次に、時間をブロックに分類する同様の関数を追加します。

private enum TimeBand
{
    MorningRush,
    Daytime,
    EveningRush,
    Overnight
}

private static TimeBand GetTimeBand(DateTime timeOfToll) =>
    timeOfToll.Hour switch
    {
        < 6 or > 19 => TimeBand.Overnight,
        < 10 => TimeBand.MorningRush,
        < 16 => TimeBand.Daytime,
        _ => TimeBand.EveningRush,
    };

プライベート enum を追加して、時間の各範囲を個別の値に変換します。 次に、GetTimeBand メソッドは リレーショナル パターン と 論理パターンを使用します。 リレーショナル パターンを使用すると、<、>、<=、または >= の使用により数値をテストできます。 論理パターンは、他のパターンを組み合わせます。or パターンは式が 1 つ以上のパターンと一致するかどうかをテストし、and パターンは式が 2 つの異なるパターンと一致することをテストし、not パターンは式がパターンと一致しないことをテストします。

これらのメソッドを作成したら、switch (タプルの各要素に一致する位置パターン) を使用して別の 式を使用し、価格プレミアムを計算できます。 全部で 16 のアームがある switch 式を作成できます。

public decimal PeakTimePremiumFull(DateTime timeOfToll, bool inbound) =>
    (IsWeekDay(timeOfToll), GetTimeBand(timeOfToll), inbound) switch
    {
        (true, TimeBand.MorningRush, true) => 2.00m,
        (true, TimeBand.MorningRush, false) => 1.00m,
        (true, TimeBand.Daytime, true) => 1.50m,
        (true, TimeBand.Daytime, false) => 1.50m,
        (true, TimeBand.EveningRush, true) => 1.00m,
        (true, TimeBand.EveningRush, false) => 2.00m,
        (true, TimeBand.Overnight, true) => 0.75m,
        (true, TimeBand.Overnight, false) => 0.75m,
        (false, TimeBand.MorningRush, true) => 1.00m,
        (false, TimeBand.MorningRush, false) => 1.00m,
        (false, TimeBand.Daytime, true) => 1.00m,
        (false, TimeBand.Daytime, false) => 1.00m,
        (false, TimeBand.EveningRush, true) => 1.00m,
        (false, TimeBand.EveningRush, false) => 1.00m,
        (false, TimeBand.Overnight, true) => 1.00m,
        (false, TimeBand.Overnight, false) => 1.00m,
    };

上記のコードは機能しますが、簡略化できます。 週末の 8 つの組み合わせは、同じ通行料金になります。 8 つのすべてを、次の行に置き換えることができます。

(false, _, _) => 1.0m,

平日の日中と夜間のインバウンドとアウトバウンドの通行では、同じ乗数が使用されます。 これらの4つのスイッチアームを次の2行に置き換えることができます。

(true, TimeBand.Overnight, _) => 0.75m,
(true, TimeBand.Daytime, _)   => 1.5m,

これら 2 つの変更を行った後のコードは、次のようになります。

public decimal PeakTimePremium(DateTime timeOfToll, bool inbound) =>
    (IsWeekDay(timeOfToll), GetTimeBand(timeOfToll), inbound) switch
    {
        (true, TimeBand.MorningRush, true)  => 2.00m,
        (true, TimeBand.MorningRush, false) => 1.00m,
        (true, TimeBand.Daytime,     _)     => 1.50m,
        (true, TimeBand.EveningRush, true)  => 1.00m,
        (true, TimeBand.EveningRush, false) => 2.00m,
        (true, TimeBand.Overnight,   _)     => 0.75m,
        (false, _,                   _)     => 1.00m,
    };

最後に、通常料金が適用される2つのラッシュアワー時間帯を削除します。 これらのアームを削除した後、最後の switch アーム内の false を破棄 (_) に置き換えます。 次の完成したメソッドを取得します。

public decimal PeakTimePremium(DateTime timeOfToll, bool inbound) =>
    (IsWeekDay(timeOfToll), GetTimeBand(timeOfToll), inbound) switch
    {
        (true, TimeBand.Overnight, _) => 0.75m,
        (true, TimeBand.Daytime, _) => 1.5m,
        (true, TimeBand.MorningRush, true) => 2.0m,
        (true, TimeBand.EveningRush, false) => 2.0m,
        _ => 1.0m,
    };

この例では、パターン マッチングの利点の 1 つに注目しています。パターンの分岐は、順序正しく評価されます。 前のほうの分岐で後ろにあるいずれかの case が処理されるようにパターンを並べ替えると、コンパイラによって到達できないコードに関する警告が表示されます。 これらの言語ルールによって、コードが変化しないという自信を持って、前述した簡略化を簡単に実行できます。

パターン マッチングによって、ある種のコードが読みやすくなり、クラスにコードを追加できない場合はオブジェクト指向の手法の代替として機能します。 クラウドによって、データと機能は分離されています。 データの "形状" とデータに対する "操作" は、必ずしも一緒に記述されるわけではありません。 このチュートリアルでは、既存のデータを、元の関数とは完全に異なる方法で使用しています。 パターン マッチングでは、型を拡張できない場合でも、それらをオーバーライドする機能を記述できます。

次のステップ

GitHub リポジトリの dotnet/samples から、完成したコードをダウンロードできます。 自分のパターンを調査し、通常のコーディング アクティビティにこの手法を追加してください。 これらの手法を習得することで、別の方法で問題にアプローチし、新しい機能を作成できます。

参照