🤖

[Unity] switch式によるパターンマッチの網羅性チェック

に公開

ローカル環境

Unity Editor : 6000.0.58f2

C# (.NET 9) のswitch文と課題

C#の列挙型 (enum) のswitch文では対応するcaseがあれば実行されますが、対応するcaseがない場合は何も実行されません。

そのため、enumの列挙値が増えた際、switch文でハンドリングされずに意図しない挙動になる恐れがあります。また、その挙動の検知は実行時にしか確認できないため、実行頻度が低いコードにおいては検知困難なケースがあります。

enum E { A, B, C }

// case E.B: にマッチするのでログが出力される
// B
Do(E.B);

// E.C に対応するcaseがないので何も出力されない
//
Do(E.C);

void Do(E e)
{
    switch (e)
    {
        case E.A:
            Console.WriteLine("A");
            break;
        case E.B:
            Console.WriteLine("B");
            break;
        // E.C に対応するcase節がない
    }
}

Swift (6.1.2) のswitch文

一方、iOSやmacOSなどに主に活用されるSwiftではコンパイラによる網羅性チェックが保証されているため、不足しているcaseがある場合はコンパイルエラーになります。

import Foundation

enum E {
  case a
  case b
  case c 
}

Do(e: .b);
Do(e: .c);

func Do(e: E) {
  // |- error: switch must be exhaustive
  // `- note: add missing case: '.c'
  switch (e)
  {
    case .a:
      print("a")
      break;
    case .b:
      print("b")
      break;
    // E.c に対応するcase節がない
  }
}

C# (.NET 9) のswitch式

swiftのswitch文のようにC#でも実行前のコンパイル段階で不足しているcaseを検知して、コンパイルエラーにすることで堅牢性を向上させたいです。

いくつかアプローチはありますが、ここではswitch文ではなくswitch式を使うと網羅性チェックに関する警告がコンパイル段階で検出されることを活用して、caseの網羅性不足の際にコンパイルエラーを発生させます。

var e = E.B;

// [CS8509] Warning
// The 'switch' expression does not handle all possible inputs (it is not exhaustive). For example, the pattern 'E' is not covered.
var value = e switch
{
    E.A => 1,
    E.B => 2
    // E.C に対応する処理がない
}

switch式の網羅性チェックによるコンパイルエラー

Unityにおけるコンパイルにオプションを渡せる*.rspファイルを使って対象の警告 (CS8509) をコンパイルエラーにします。

これによりコンパイル段階で、IDE・Unity Editorの両方で、単純な値を返すswitch式に関しては網羅性チェックを担保できます。

Rider Unity

Assets/csp.rsp

Unity における .rsp(Response)ファイルの拡張子
C# コンパイラ(Roslyn)の 追加引数・設定を渡すためのファイル
主に Unity のビルドプロセスで csc.exe にオプションを注入するために使われる

# switch式で既知の値が未処理 (定義済みの列挙型の値についてのみコンパイルエラーにする)
-warnaserror+:CS8509

# switch式でnullなどの網羅漏れ
-nowarn:CS8524 

処理分岐のswitch式

単純な値を返すswitch式は実現できましたが、シンプルな処理分岐はswitch式をそのまま使うと不便です。

Action型に格納してから実行することはできますが、実行漏れをした際にエラーにならないのでリスクがあります。

enum E { A, B, C }

Do(E.B); // B
Do(E.C); //

void Do(E e)
{
    Action handle = e switch
    {
        E.A => () => Console.WriteLine("A"),
        E.B => () => Console.WriteLine("B"),
        E.C => () => { }
    };
    handle(); // 実行漏れのリスク
}

へルパークラスを活用して実行漏れを防ぐ

実行漏れのリスクを減らすために、switch式だけで実行まで完了するヘルパークラス (InstantAction) を使います。

InstantActionのコンストラクタが呼び出された時点でActionが即時実行されるため、実行漏れのリスクがありません。同期、非同期の両方に対応しているため、処理内容に応じて使い分けることができます。

// 即時実行用のヘルパークラス
public sealed class InstantAction
{
    private InstantAction(Action action)
    {
        action?.Invoke(); // コンストラクタで即時実行することで実行漏れを防ぐ
    }

    public static Action Noop { get; } = () => { };
    public static Func<UniTask> AwaitableNoop { get; } = () => UniTask.CompletedTask;

    public static Func<UniTask> AwaitableAction(Action action) => () =>
    {
        action.Invoke();
        return UniTask.CompletedTask;
    };

    public static async UniTask Awaitable(Func<UniTask> asyncAction)
    {
        if (asyncAction != null)
            await asyncAction();
    }

    // Implicit conversion between Action and InstantAction
    public static implicit operator InstantAction(Action action) => new(action);
}
// 同期
InstantAction _ = e switch
{
    E.A => () => Console.WriteLine("A"),
    E.B => () => Console.WriteLine("B"),
    E.C => InstantAction.Noop
}

// 非同期
await InstantAction.Awaitable(e switch
{
    E.A => async () => {        
        await SomeAsyncOperation(); // 非同期処理の例
    },
    E.B => InstantAction.AwaitableAction(() => Console.WriteLine("B")),
    E.C => InstantAction.AwaitableNoop
});

まとめ

*.rspswitch式を活用することで、enumの網羅性チェックをコンパイル時に保証しつつパターンマッチを実現できました。

チームで開発する場合はenumのパターンマッチを扱う際にswitch式で記述するというコーディング規約が必要ですが、複数の開発者が特定のenumの列挙値を増やした際にコンパイルエラーになるので、不具合の早期発見に繋がります。

参考文献

GitHubで編集を提案

Discussion