C#のif-elseとswitchのパフォーマンス比較と最適な使い分け完全ガイド

C#で大量の条件分岐を書くとき、「if-else を並べるべきか、それとも switch を使うべきか」で迷うことは多いです。どちらが速いかだけでなく、enum・int・string・範囲条件といった違いで最適解が変わります。本記事では、コンパイラ最適化・データ構造・ベンチマークの観点から、実務で迷わないための判断軸を整理します。

目次

C#のif-elseとswitch/caseはどちらが速いのか?結論から

最初に結論です。

  • 「どちらが速いか」はコード内容・ケース数・データ型・実行環境に依存するため、一概に決めつけることはできません。
  • 実務では、まずは読みやすさ・保守性を優先し、気になる箇所だけベンチマークで実測するのが現実解です。
  • 多数の整数・enum・stringの分岐なら、多くの場合 switch が有利になりやすい傾向があります。
  • 範囲条件や複合条件(x < 0 や a && b など)は、switchよりif-elseの方が自然です。
  • 「この集合に含まれているか」「値→処理のマッピング」は、HashSet / Dictionaryなどのデータ構造で置き換えられることが多く、こちらの方が高速かつ拡張しやすいです。

つまり、「if か switch か」という二択だけで悩まず、「分岐をそもそもデータ構造で置き換えられないか?」もセットで考えるのがポイントです。

コンパイラ最適化から見るif-elseとswitchの違い

int・enumの場合の挙動

int や enum を対象に switch を書くと、C#コンパイラやJITコンパイラは、ケース数や値のばらつきに応じて次のような最適化を行います。

  • 値がほぼ連番(密)でケースが多い → ジャンプテーブルに近いコードに最適化
  • 値がスカスカ(疎)・ケースが少ない → 比較と分岐(ifの束に近い)として展開

一方で if-else を素直に並べると、基本的には「上から順番に比較していく」形のコードになります。JITが多少並べ替える余地はありますが、switch のようにジャンプテーブル前提にはなりにくいです。

以下のようなコードを考えてみます。

int code = GetCode();

if (code == 0) DoA();
else if (code == 1) DoB();
else if (code == 2) DoC();
else if (code == 3) DoD();
else HandleDefault();

これを switch で書くとこうなります。

int code = GetCode();

switch (code)
{
    case 0: DoA(); break;
    case 1: DoB(); break;
    case 2: DoC(); break;
    case 3: DoD(); break;
    default: HandleDefault(); break;
}

ケース数が増えるほど、switch の方がジャンプテーブル化されやすくなり、平均的には if-else よりも有利になることが多いです。ただし、ケースが2〜3個程度であれば体感できる差はまず出ません。

対象ケース数コンパイル後の典型挙動パフォーマンス傾向(目安)
int / enum〜3件程度どちらも比較+分岐差はほぼゼロ。読みやすさ優先。
int / enum4〜10件程度switchが比較列または簡易ジャンプテーブルややswitch有利になることが多い。
int / enum10件以上switchがジャンプテーブル化されやすい規模が大きくなるほどswitch有利になりやすい。

ここで重要なのは「どう最適化されるかはコンパイラとJITの実装に依存する」という点です。最終的な答えは、実際にビルドして JIT されたコードを実行してみないと分かりません。

stringの場合のswitchの挙動

string に対する switch は、C#コンパイラが内部的に次のようなコードへ展開することが多いです。

  • 対象文字列のハッシュ値を求める
  • ハッシュ値ごとにグループ分けした条件分岐を行う
  • 最後に string.Equals で実際に等価比較して確定する

これはざっくり言えば「ハッシュテーブル+等価比較」のようなイメージで、ケースの数が増えるほど if (s == "foo") ... else if (s == "bar") ... のようなコードより効率が良くなる場合が多くなります。

しかし、ここでもやはり最終的な性能は以下の要因に左右されます。

  • ケース数(数件〜数十件〜数百件)
  • キー同士の類似性(ハッシュ値の衝突状況)
  • 実際の入力データでよくヒットするケース(分岐の偏り)

そのため、「string なら必ず switch が速い」とは言えず、「ケースが増えるほどswitchの方が有利になりやすい傾向がある」程度に捉えるのが安全です。

範囲条件・複合条件はif-elseが適任

switch が得意なのは「ある1つの値がどの候補と等しいか」を選ぶ場面です。対して、次のような処理は switch では表現しづらく、if-else の方が直感的です。

  • x < 0, 0 <= x && x < 100 のような範囲条件
  • a && b, a || (b && c) のような複合条件
  • 複数の値の組み合わせ(例:x > 0 && y < 0)

これらの条件を無理に switch に落とし込もうとすると、かえって可読性が落ち、バグの温床になります。パフォーマンス以前に、意図が明快な方を選ぶのが結果的に全体の品質を上げます。

ケース数と可読性から見た使い分けの目安

パフォーマンスと同じくらい重要なのが可読性です。分岐の規模に応じたおおまかな目安をまとめると、次のようになります。

ケース数推奨構造理由
1〜3件if / 三項演算子読みやすく、パフォーマンス差はほぼなし。
4〜10件switch またはif-elseどちらでもよいが、値ごとの分岐ならswitchが見通し良好。
10件以上switch か Dictionary 等にリファクタif-elseの連鎖は追いづらい。構造化されたswitchかデータ駆動を検討。

特に将来ケースが増えそうな箇所(プロトコルコード、ステータスコード、トークン種別など)は、最初から switch で書いておくと変更に強い構造になります。

分岐をデータ構造に置き換える:HashSetとDictionary

集合への所属判定はHashSetが最有力

「この値が禁止リストに含まれているか?」「このトークンは予約語か?」といった判定に、延々と if-else や switch を使っていないでしょうか。

こういった「集合への所属判定」は、ほとんどの場合 HashSet<T> に置き換えた方がシンプルで速くなります。

var blockedTokens = new HashSet&lt;string&gt;(StringComparer.Ordinal)
{
    "if",
    "else",
    "switch",
    "case"
};

if (blockedTokens.Contains(token))
{
    // 予約語
    HandleReserved(token);
}
else
{
    // 通常識別子
    HandleIdentifier(token);
}

ポイントは次の通りです。

  • Contains の平均計算量は O(1)(ハッシュベースのため)
  • 要素の追加・削除が簡単で、設定ファイルやDBからの読み込みにも向いている
  • 分岐ロジックから「リストの中身」を切り離せるため、責務が明確になる

Dictionaryで「値→処理」をマッピングする

「コード値に応じて実行する処理が変わる」ような状況では、switch の代わりに Dictionary<TKey, Action> などを使って処理をデータとして管理する手もあります。

var handlers = new Dictionary&lt;int, Action&gt;
{
    [0] = DoA,
    [1] = DoB,
    [2] = DoC
};

if (handlers.TryGetValue(code, out var handler))
{
    handler();
}
else
{
    HandleDefault();
}

戻り値が必要な場合は Func<TResult> や Func<T, TResult> を使います。

var calculators = new Dictionary&lt;int, Func&lt;int, int&gt;&gt;
{
    [0] = x =&gt; x + 1,
    [1] = x =&gt; x * 2,
    [2] = x =&gt; x * x
};

int result = calculators.TryGetValue(code, out var calc)
    ? calc(value)
    : value;

switch と Dictionary を比較したときの特徴は次のようになります。

項目switchDictionary
可読性小規模なうちは非常に読みやすい「データとしての表」にまとまるので見やすいことも多い
拡張性コード修正が必要(ビルド必須)設定から読み込むなど、動的拡張も可能
パフォーマンスジャンプテーブルなどで最適化されうるハッシュベース。ほぼ O(1) だがオーバーヘッドはやや大きめ
用途コンパイル時に確定した分岐外部設定やプラグインを含む柔軟な分岐

性能面での絶対優位はケースによりますが、「ビジネスルールが頻繁に変わる」ような場面では、Dictionary によるデータ駆動設計の方が結果的に安定した設計になることが多いです。

実際に測る:BenchmarkDotNetでifとswitchを比較する

最終的な答えは「自分の環境で実際に測る」です。そのための定番ライブラリが BenchmarkDotNet です。

BenchmarkDotNetの基本的な使い方(サンプル)

まず、プロジェクトに NuGet から BenchmarkDotNet を追加し、次のようなベンチマーククラスを作成します。

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

public class BranchBenchmarks
{
    private readonly int[] _data;

    public BranchBenchmarks()
    {
        var random = new Random(0);
        _data = Enumerable.Range(0, 1000)
            .Select(_ =&gt; random.Next(0, 10))
            .ToArray();
    }

    [Benchmark]
    public int IfElse()
    {
        int sum = 0;
        foreach (var x in _data)
        {
            if (x == 0) sum += 1;
            else if (x == 1) sum += 2;
            else if (x == 2) sum += 3;
            else if (x == 3) sum += 4;
            else if (x == 4) sum += 5;
            else if (x == 5) sum += 6;
            else if (x == 6) sum += 7;
            else if (x == 7) sum += 8;
            else if (x == 8) sum += 9;
            else sum += 10;
        }
        return sum;
    }

    [Benchmark]
    public int Switch()
    {
        int sum = 0;
        foreach (var x in _data)
        {
            switch (x)
            {
                case 0: sum += 1; break;
                case 1: sum += 2; break;
                case 2: sum += 3; break;
                case 3: sum += 4; break;
                case 4: sum += 5; break;
                case 5: sum += 6; break;
                case 6: sum += 7; break;
                case 7: sum += 8; break;
                case 8: sum += 9; break;
                default: sum += 10; break;
            }
        }
        return sum;
    }
}

public class Program
{
    public static void Main(string[] args)
    {
        BenchmarkRunner.Run&lt;BranchBenchmarks&gt;();
    }
}

このようにして、IfElse と Switch を同じ条件で比較します。実務ではこれに加えて、

  • HashSet を使うバージョン
  • Dictionary を使うバージョン
  • 文字列版(string)

なども並べて計測すると、「自分のコードでは何が最適か」が見えてきます。

ベンチマークでのよくある落とし穴

ベンチマークを書くときに注意したいポイントも押さえておきましょう。

  • Debug ビルドで測ってしまう → 必ず Release ビルド+最適化有効で測る。
  • 対象コードが短すぎる・ループ回数が少なすぎる → ノイズが大きく、測定誤差に埋もれる。BenchmarkDotNetは自動で繰り返してくれるが、ループ回数も意識する。
  • GCの影響を無視する → 途中でたまたまGCが走ってしまうと結果がぶれる。BenchmarkDotNetの [MemoryDiagnoser] 属性でGCの発生状況も確認するとよい。
  • 分岐の偏りを考慮していない → 実際の本番データでよくヒットするケースを再現しないと、現実のパフォーマンスとは異なる結果になる。

つまり、ベンチマークは「本番に近い条件をどれだけ正確に再現できるか」が重要です。synthetic な小さなサンプルだけを見て「if より switch が常に速い!」といった一般論を導くのは危険です。

modern C#の観点:switch式とパターンマッチング

C# 7以降では、switch にパターンマッチング機能が追加され、さらに C# 8 以降では switch式 も導入されました。これらは主にコードの表現力と可読性を高めるための機能です。

例えば、従来の switch を次のような switch 式に書き換えられます。

string GetLabel(int code) =&gt; code switch
{
    0 =&gt; "Zero",
    1 =&gt; "One",
    2 =&gt; "Two",
    _ =&gt; "Other"
};

また、型や条件によるパターンマッチングも行えます。

string Describe(object value) =&gt; value switch
{
    null =&gt; "null",
    int i when i &lt; 0 =&gt; "negative int",
    int i =&gt; "non-negative int",
    string s =&gt; $"string({s.Length})",
    _ =&gt; "other"
};

これらはコンパイル後には結局 switch + 条件分岐などに展開されます。パフォーマンス的にはif-elseと大差がないことも多く、むしろ「複雑な条件を安全に・読みやすく書ける」ことに価値があります。

特に enum に対する switch 式では、全ての列挙値を列挙しないとコンパイラ警告が出るため、「抜け漏れ」に気付きやすいという利点もあります。

パフォーマンス以外で考えるべきポイント

保守性と変更容易性

多くの場合、長期的な開発コストを決めるのは「数%の速度差」ではなく、

  • 新しいケースを追加するときにバグを埋め込みにくいか
  • 仕様書やドキュメントと行単位で対応づけやすいか
  • チームメンバーがすぐ意図を読み取れるか

といった観点です。

  • 固定的なコード値→処理 → switch で列挙すると、仕様書と見比べやすい。
  • 外部の設定やプラグインで動的に変わる処理 → Dictionary や DI(依存性注入)を使ってデータ駆動にした方が自然。

エラー検知のしやすさ

switch を使うと、default を書き忘れたときや、enum の新しい値を追加したときに警告が出るケースがあります。これにより、「新しいケースを追加したのに処理を実装し忘れた」という事故を防ぎやすくなります。

if-else は柔軟な代わりに、どこに分岐があるのかをコンパイラが完全には追いきれないため、変更漏れをコンパイル時に検出しづらいという側面があります。

よくある誤解・アンチパターン

誤解1:switchは常にifより高速である

よくある誤解が「switchはジャンプテーブルになるから常に高速」というものです。実際には、

  • ケースが少ないとき
  • 値がスカスカで連番ではないとき
  • コンパイラやJITのバージョンによる違い

などにより、switch が if より遅くなる可能性もあります。あくまで傾向として、「多数のケースではswitchが有利になりやすい」程度に理解しておくとよいでしょう。

誤解2:文字列のswitchは必ず遅い

「文字列比較は高コストだから string の switch は避けるべき」と言われることもありますが、内部的にはハッシュベースの分岐になっている実装が多く、単純な if-else の列より効率的に動くことも少なくありません。

もちろん、文字列生成や ToLower() / ToUpper() 呼び出しなどを毎回行っていると、それだけでコストが積み上がります。例えば次のような書き方は避けるべきです。

// 悪い例:毎回ToLowerを呼び出している
switch (command.ToLower())
{
    case "start":
        Start();
        break;
    case "stop":
        Stop();
        break;
}

StringComparer.OrdinalIgnoreCase などのケースインセンシティブな比較を提供するコンパレータを利用する、あるいは一度だけ正規化してから使う方が良い場合が多いです。

誤解3:分岐を書き直すだけで劇的に速くなる

パフォーマンス問題の多くは、

  • 不要なメモリアロケーション(newの多用、string連結など)
  • I/O 待ち(DBアクセス、ファイル読み込み、ネットワーク)
  • 不必要に複雑なアルゴリズム(O(n^2) のループなど)

に起因しており、「if を switch に変えた」程度では体感できる改善が得られないことがほとんどです。条件分岐の微最適化はあくまで最後の一押しであり、まずは大局的なボトルネックを特定することが重要です。

実務での判断フロー:どう選べばよいか

ここまでの内容を踏まえて、実務での判断フローを整理してみます。

状況推奨アプローチ理由
分岐が数件だけif / 三項演算子シンプルで読みやすく、性能差はほぼなし。
int / enum で10件以上の分岐switchジャンプテーブル化が期待でき、構造も明快。
文字列キーが多数switch または Dictionaryswitchはハッシュベースの分岐になりやすく、Dictionaryはデータ駆動設計に向く。
「この集合に含まれているか?」HashSet<T>.ContainsO(1) で高速、要素の追加削除も簡単。
範囲条件・複合条件if-else または switch式+パターンマッチングswitch の本領は等価比較。条件式はifの方が自然。
外部仕様に合わせて頻繁に追加・変更される分岐Dictionary+設定ファイルなどビルドなしで差し替えやすく、ルールをデータとして管理しやすい。
パフォーマンスがシビアなホットパス候補を絞ってBenchmarkDotNetで実測コンパイラ・JIT・CPUの違いを含めて、本当に速いものを選べる。

サンプル:配列・リストに対する分岐の書き方

最後に、質問のモチーフである「配列・リストの値に対して複数の分岐を行う」ケースをいくつかのパターンで比較してみます。

パターン1:単純なコード値に応じて処理を分ける

典型的には switch が分かりやすく、パフォーマンス面でも有利になりやすいパターンです。

foreach (var item in items)
{
    switch (item.Code)
    {
        case 0: HandleA(item); break;
        case 1: HandleB(item); break;
        case 2: HandleC(item); break;
        default: HandleDefault(item); break;
    }
}

パターン2:禁止コードのチェックだけをしたい

この場合は HashSet<int> にするとスッキリします。

var bannedCodes = new HashSet&lt;int&gt; { 3, 5, 7, 9 };

foreach (var item in items)
{
    if (bannedCodes.Contains(item.Code))
    {
        Reject(item);
    }
    else
    {
        Accept(item);
    }
}

パターン3:コードに応じて異なる処理オブジェクトを呼び出す

この場合は Dictionary<int, IHandler> のような構成も有力です。

public interface IItemHandler
{
    void Handle(Item item);
}

var handlers = new Dictionary&lt;int, IItemHandler&gt;
{
    [0] = new HandleA(),
    [1] = new HandleB(),
    [2] = new HandleC()
};

foreach (var item in items)
{
    if (handlers.TryGetValue(item.Code, out var handler))
    {
        handler.Handle(item);
    }
    else
    {
        DefaultHandler.Instance.Handle(item);
    }
}

こうしておくと、新しいコード値と処理を追加したいときにDictionaryへのエントリ追加だけで済むため、巨大な switch 文をいじるよりも安全なことが多いです。

まとめ:if/elseとswitchの「速度」より大事なこと

ここまでの内容を整理すると、次のようにまとめられます。

  • 最終的な速度はコードと実行環境に依存するため、「if より switch の方が常に速い」とは言えない。
  • 整数・enum の多数ケースでは、switch がジャンプテーブル化されて有利になることが多い。
  • string の多数ケースでは、switch が内部的にハッシュベースの分岐になるため、有利になりやすい。
  • 範囲条件・複合条件は if-else の方が自然で、switch で無理に書く必要はない。
  • 集合への所属判定は HashSet<T>、コード値→処理のマッピングは Dictionary<TKey, TDelegate> にすると設計がシンプルになる。
  • modern C# の switch 式とパターンマッチングは、パフォーマンスよりも表現力と保守性向上が主目的。
  • 本当にシビアな箇所は、BenchmarkDotNet などで実測した結果をもとに判断する。

日常の開発では、まずは「読みやすくバグを生みにくい書き方」を選び、必要になった時点で「if / switch / HashSet / Dictionary」など複数案をベンチマークしてみる、というスタンスが現実的です。

「if と switch、どちらが速いか?」という問いをきっかけに、分岐そのものの設計や、データ駆動へのリファクタリングまで含めて見直してみると、アプリケーション全体の品質向上にもつながるはずです。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次