🔖

【Unity】型によるswitchの「網羅漏れ」を検出するRoslynAnalyzer「ExhaustiveSwitch」を作った

に公開

本記事は Unityゲーム開発者ギルド Advent Calendar 2025 13日目の記事です。

はじめに

RoslynAnalyzer/CodeFixProviderを使って、
「型によるswitchの網羅性を強制する」Unity向けのライブラリを作ったので、
その紹介と、利用するメリットについてまとめます。

簡単に概要を説明すると、このライブラリは、
型によるswitch文/式で型が網羅されていないケースをエラーとして検出するライブラリです。
https://github.com/gameshalico/ExhaustiveSwitch

エラー表示とエラー修正

利用方法

まずは、利用方法を紹介します
以下のように、網羅性を強制したい抽象型に[Exhaustive]を付与し、
網羅したい具象クラスに[Case]を付与することで、型によるswitchの網羅を強制することができます。

// [Exhaustive]属性を付与
[Exhaustive] public interface IItem { /* ... */ }

public interface IConsumable { /* ... */ }
public interface IEquippable { /* ... */ }

// 各具象クラスに[Case]属性を付与
[Case] public class Potion : IItem, IConsumable { /* ... */ }
[Case] public class Bomb : IItem, IConsumable { /* ... */ }
[Case] public class Armor : IItem, IEquippable { /* ... */ }

以下のようなswitch文があったとき、それぞれ網羅されていない型が存在するとエラーになります。
(default句は網羅されたことになりません)

public void ProcessItem(IItem item)
{
    // 具象型で分岐 (新たにアイテムが実装されるとエラー)
    switch (item)
    {
        case Potion potion:
            // Potion専用の処理
            break;
        case Bomb bomb:
            // Bomb専用の処理
            break;
        case Armor armor:
            // Armor専用の処理
            break;
        default:
            throw new ArgumentOutOfRangeException(nameof(item));
    }

    // 上位の型で分岐 (消費可能でも、装備可能でもないアイテムが追加されるとエラー)
    switch (item)
    {
        case IConsumable consumable:
            // 消費可能Itemの処理 (Potion, Bomb)
            break;
        case IEquippable equippable:
            // 装備可能Itemの処理 (Armor)
            break;
        default:
            throw new ArgumentOutOfRangeException(nameof(item));
    }
}

何が嬉しいのか?

このライブラリを使うことでもたらされるメリットは、
「型によるswitchを安全に行うことができる」ということです。
裏を返せば、型によるswitchは危険 ということになります。

型によるswitchの危険性

なぜ型によるswitchが危険かというと、新たにクラスを追加したときに、考慮漏れが発生してしまうからです。たとえば、以下のように型による分岐を行っていたとします。

IItemView CreateView(IItem item)
{
    switch (item)
    {
        case Potion potion:
            // Potion用の処理
        case Armor armor:
            // Armor用の処理
        default:
            throw new ArgumentOutOfRangeException(nameof(item));
    }
}

ここで、新たにIItemの実装であるBombを追加したとしても、上記のコードは問題なくコンパイルされ、エラーになりません。itemBombとしてここに到達して初めて、エラーが出ることになります。利用箇所が多くなってくれば、修正すべき箇所を特定して欠かさずBomb用の処理を追加する、というのは難しくなります。

原理的な考え方をすれば、抽象の外から型によって処理を分岐させることそのものが 開放閉鎖の原則(OCP) 違反で間違いだという話になりますが、単一責任の原則(SRP) に乗っ取ることを意識し、抽象的に管理したり、レイヤーを分けたりしたいとなると、どうしてもこういった型による処理の出し分けが必要になります。

こういった際に起こる考慮漏れの問題を、
型を網羅しないswitch文/式をエラーとして検出することで解決するのが今回制作したライブラリの目的です。

既存の解決策 - Visitorパターン

この問題に対する既存の解決策として、Visitorパターンがあります。
そちらとの比較も行っていきましょう。
Visitorパターンは、ダブルディスパッチという手法で型による分岐を安全に行います。

実際のコードを見ていきましょう。
Visitorパターンは、Visitorインターフェースと、対象の型のAcceptメソッドによって成り立っています。
核となるのは、以下のようなパターンです。

public interface IItem
{
    // ...
    public T Accept(IItemVisitor<T> visitor);
}
public interface IItemVisitor<T>
{
    public T Visit(Potion potion);
    public T Visit(Bomb bomb);
}

具象クラスは、このようにに実装されます。

public class Potion
{
    // ...
    public T Accept<T>(IItemVisitor<T> visitor)
    {
        visitor.Visit(this);
    }
}
public class Bomb
{
    // ...
    public T Accept<T>(IItemVisitor<T> visitor)
    {
        visitor.Visit(this);
    }
}

Visitorパターンは、Visitorインターフェースの自分のクラスに対応するメソッドを、それぞれのAcceptメソッドから呼び出すようにすることで、型による処理の分岐を実現しています。

実際に利用する場合は、以下のように呼び出すことになります。

public class ViewCreateVisitor : IItemVisitor<IItemView>
{
    public IItemView Visit(Potion potion) { /* ... */ }
    public IItemView Visit(Bomb bomb) { /* ... */ }
}
// ...
private ViewCreateVisitor visitor = new();
IItemView CreateView(IItem item)
{
    return item.Accept(visitor)
}

これにより、型安全な、型による処理の分岐を実現できます。
型を追加したいときは、Visitorインターフェースに関数を追加することで、
全てのVisitorで実装することを強制することができます。

しかし、このパターンにはいくつかの問題点があります。
主な問題点は以下の3点です。

① 構造が複雑で理解しにくい

Visitorパターンはダブルディスパッチという仕組みにより双方からメソッドを呼び出すことによって、型による処理の分岐を実現しており、直感的ではありません。Visitorパターンについて知識がないと、何をやっているのか分かりづらいという問題があります。

② いちいちVisitorクラスを実装する必要がある

毎回Vistiorインターフェースを実装したり、インスタンスを持ったりしないといけないので、
ボイラープレートコードが多くなりがちです。関数を受け取って処理を出し分けるみたいなVisitor実装を使えば若干削減できますが、複雑さが増してしまいます。

③ 抽象レイヤーが具象レイヤーを知る必要がある

Visitorインターフェースは全ての具象型を処理するメソッドを持つ必要があるため、抽象レイヤーが具象レイヤーを参照することになってしまいます。そのため、レイヤーごとにAssembly Definition(asmdef)を分けるようなプロジェクト構成には適用できません。

ExhaustiveSwitchでは

上記の問題を解決するべく、今回、ExhaustiveSwitchを開発しました。
C#の機能である型によるswitchを安全に使えるようにしたことで、
Visitorパターンと比べて簡潔に型安全な分岐を実現します。

複数のAssembly Definitionに分割されていても、参照関係を汚さずに利用できる他、ジェネリック型、内部クラスにも対応しています。RoslynAnalyzerでAttributeを検知してエラーを出しているだけなので、Attributeはメタデータに過ぎず、ランタイム時のオーバーヘッドはゼロです。

PackageManagerから導入できますので、もし興味を持っていただけたら、是非試してみて下さい。
詳細はREADME.mdを確認して下さい。
https://github.com/gameshalico/ExhaustiveSwitch

おわりに

今回は、RoslynAnalyzer/CodeFixProviderを用いてエラーを検出するライブラリを作成しました。
CodingAgentの普及も相まって、コンパイル時にエラーを検出することの重要性は大きく高まっていると思います。
気軽に書くには面倒な部分も多かったですが、SourceGeneratorも合わせてできることの幅が大きく広がる技術でした。今後も選択肢の一つとして持っておきたいです。
ここまでお読みいただきありがとうございました。

参考記事

https://qiita.com/toRisouP/items/e5b312af53c40e1f4a80#visitorパターンの試用
https://qiita.com/toRisouP/items/12afeb98a0971095d409
https://qiita.com/toRisouP/items/d96a09fab827af17fb37
https://piffett.hateblo.jp/entry/2024/05/18/025011

Discussion