syghの新フラグメント置き場

プログラミングTipsやコード断片の保管場所です。お絵描きもときどき載せます。

C系言語のswitch文に関する私見

C系言語の伝統的なswitch文 (switch statement) は、暗黙的なフォールスルーをし、各分岐を終了するにはbreak文を使います。

switch (x) {
case 0:
    puts("case 0");
    break; // もしこの break 文がなかった場合はフォールスルーして、その次の case 1 ラベルが付与された文に到達する。
case 1:
    puts("case 1");
    break;
case 2: // これも広義のフォールスルー。同じ文に対して複数のラベルを付けることができる。
case 3:
    puts("case 2 or 3");
    break;
default:
    puts("default");
    break;
}

結論から述べますが、とある理由から、C/C++C#ではこのswitch文をループ内に直接記述するべきではないと個人的に考えています。理由は後述します。

switch文の特性とメリット

初心者(あるいはプログラミング言語の理論的な側面に興味のない雇われプログラマー)はあまり意識することはないでしょうが、case xxx:default:は「ラベル」であり、その直後に書かれている文をジャンプ先として指定できるようにするために付与される原始的なマーカーです。つまり、実行可能なステップ行ではありません。構文要素としてはgoto文のラベルと似た役割を持ちます。

C/C++ではcaseラベルに整数定数(コンパイル時定数:列挙定数も含む)しか使えないという制限はありますが、同じく分岐に使われるif文と比べてスケーラビリティに優れており、最適化によってはテーブルジャンプに置き換えられることによって処理時間がO(1)になりうるというメリットがあります。例えば入力された文字コードやコマンド番号に応じて何らかの個別処理を実行したり、列挙定数に対応する文字列を返却したりするような場合に真価を発揮します。上記のコード例のcase 2:case 3:のように、複数の選択肢で同じ処理を実行するといったコードを比較的書きやすい(後からパターンを追加する際にラベル行の追加だけで済む)のもメリットです。特に列挙定数を使って分岐を書くときにswitch文を優先的に使うことが多いのではないかと思います。どちらかというと、アセンブリ言語のテーブルジャンプを上位水準言語でも書けるようにしたのがswitch文であると考えるべきなのかもしれません。

とはいえ、caseラベルを無限に書けるわけではなく、例えばC99規格で保証されているcaseラベルの数は1023となっています*1。上限に引っかかることはまずないでしょうが、一応知っておいたほうがよいです。
膨大なパターン分岐の場合、自前で関数ポインタや関数オブジェクトの配列/連想配列をジャンプテーブルとして定義して利用したり、仮想関数として抽象化したりするほうがスマートに書け、スケーラビリティの面からも推奨されます。

C/C++は真のelse if文をサポートせず、内部的にはif-elseのネストによって実現されていますが、C99規格で保証されているブロックのネストレベルは127段までであるため*2、この制限を超える分岐パターンは単純なif-elseだけでは実現できません。MSVCは128段を超えるとC1061のコンパイルエラーを引き起こします。ただし、このような多数の分岐をif-elseで書こうとすると、分岐条件のいずれも満たさない最悪ケースでは、すべての条件をテストすることになります。つまり計算量はO(n)であり、分岐の数に応じてパフォーマンスが悪化する間抜けな素人コードです。いやいや、普通はこんな頭の悪いコードをプロダクトコードとして書くアホなどいるわけがないだろ、いい加減にしろって? 甘いですね。あなたは一般人を過大評価しすぎている。世の中にはその場しのぎのクソダサコードを量産して技術的負債を後世に押し付ける連中というのはいくらでもいます。

if (x == 1) {
}
else if (x == 2) {
}
...
else if (x == 100) {
}
else {
    // このケースに該当する場合、x == 1 から x == 100 までのすべての条件式をチェックすることになる。
}

ちなみに#if-#elseのようなプリプロセッサディレクティブによる分岐(conditional inclusion: 条件付きコンパイル)にも制限があり、C99規格で保証されているネストレベルは63です*3

Javaのswitch文に使えるプリミティブ型はbyte, short, char, intに限られますが、それらのプリミティブラッパークラス、文字列型(java.lang.String型)や列挙型も使えるようになっています(Javaの文字列型や列挙型は値型ではなく参照型)。ただしJavaでは選択肢にnullは使えず、nullを渡すとNullPointerExceptionを引き起こします。switch文を実行する前にnullチェックを書く必要があります。

C#のswitch文ではnullも使えます。C# 7.xではswitch文のcaseに関係式などのパターンも使えるように拡張されたものの、浮動小数点数そのものは使えませんでしたが、C# 8.0以降のswitch文では浮動小数点数も直接使えます。ちなみにDouble.NaN == Double.NaNはfalseとなってしまいますが、公式ドキュメントのコード例にも記載されているように、Double.NaNをcaseラベルに記述した場合、意図した比較結果になるようです(内部的にはDouble.IsNaN(Double.NaN)を比較に使用するコードが生成されるはず)。もっとも、NaN以外でも浮動小数点数の場合は完全一致で比較できるケースはまれであり、C# 8.0以降のswitch文をif文で書き直した場合に誤って==を使ってしまう愚か者が現れる可能性もあります。

switch文の問題点

for文やwhile文では、break文でループを終了することができますが、ループ内に記述されたswitch文の中でbreak文を記述しても、switch文を抜けるだけで、その外側にあるループを抜けることはありません。
この仕様だけに着目すればまあ当然であり、むしろそうでなければ困るのですが、switch文の特定の分岐でループを脱出したいときは、ループ脱出用のフラグを別途用意したり、goto文を利用したり、といった工夫が必要になります。特にswitch文の中でcontinue文を書いた場合に紛らわしさが倍増します。この言語設計の一貫性や対称性のなさが本当にイライラさせてくれます。

// C/C++

for (int i = 0; i < 10; ++i) {
    switch (i) {
    case 0:
        puts("case 0: break switch");
        break; // switch 文だけを終了。
    case 1:
        puts("case 1: continue loop");
        continue; // for ループを継続。
    case 5:
        puts("case 5: break loop");
        goto my_next_to_loop_label; // for ループを脱出。
    default:
        printf("default: i = %d\n", i);
        break; // switch 文だけを終了。
    }
    puts("Post-proc after switch.");
}
my_next_to_loop_label:
;

Javaの場合はラベル付きbreak/continueを使うことで対処できますが、C/C++C#のswitch文ではgotoを使わずにスマートに解決する方法はありません。

// Java

my_loop_label:
for (int i = 0; i < 10; ++i) {
    switch (i) {
    case 0:
        System.out.println("case 0: break switch");
        break; // switch 文だけを終了。
    case 1:
        System.out.println("case 1: continue loop");
        continue my_loop_label; // for ループを継続。
        // このコード例ではラベルなしの continue だけでも同じコードになるが、break の動作との対称性の観点からはラベルを使ったほうが分かりやすい。
    case 5:
        System.out.println("case 5: break loop");
        break my_loop_label; // for ループを脱出。
    default:
        System.out.println("default: i = " + i);
        break; // switch 文だけを終了。
    }
    System.out.println("Post-proc after switch.");
}

下記のように関数やメソッド、関数オブジェクトとしてswitch文を抽出し、if-elseで再チェックする方法もありますが、二重の分岐は冗長になるし、インライン化できない場合は関数呼び出しのオーバーヘッドがかかります。
switch文の分岐パターンが少ないのであれば、いっそのこと最初からif-elseだけで書いたほうがマシです。

// C/C++

typedef enum ResultCode {
    ResultCodeNone,
    ResultCodeContinueLoop,
    ResultCodeBreakLoop,
} ResultCode;

ResultCode SomeFunc(int i) {
    switch (i) {
    case 0:
        puts("case 0: break switch");
        return ResultCodeNone;
    case 1:
        puts("case 1: continue loop");
        return ResultCodeContinueLoop;
    case 5:
        puts("case 5: break loop");
        return ResultCodeBreakLoop;
    default:
        printf("default: i = %d\n", i);
        return ResultCodeNone;
    }
}

for (int i = 0; i < 10; ++i) {
    const ResultCode code = SomeFunc(i);
    if (code == ResultCodeContinueLoop) {
        continue;
    }
    else if (code == ResultCodeBreakLoop) {
        break;
    }
    puts("Post-proc after switch.");
}

switchとループとで、脱出に同じbreakというキーワードを使うようにしてしまったのは完全にCの設計ミスです。タイムマシンで1970年代に飛んでカーニハンとリッチーをボコボコにブチ殴りたい。まあCがここまで広く使われる言語になるとは全然思っていなかったんでしょうがね。

JavaC#C/C++からのコード移植性を重視していたせいか、後発言語でありながらC/C++のswitch文やループ構文のクソ仕様をご丁寧に踏襲してしまい、脱出に同じbreakキーワードを使うという設計ミスが残ってしまっています。せめてループ脱出とswitch文の終了でキーワードを変えるか、break forbreak switchのように複合コンテキストキーワードを導入するべきだったと思いますが、そうするとC/C++の既存コードをそのまま移植することができなくなってしまうし、C/C++ユーザーの学習コストが増えてしまうことを嫌ったのかもしれません*4C#Javaよりも若干厳しくなっており、ラベルが連続する場合を除いて暗黙的なフォールスルーはできなくなっていますが、分岐終了にはbreakが必要であり、ループ内で使うと紛らわしいことには変わりありません。

この言語構文上の欠点こそが、C/C++C#ではswitch文をループ内に直接記述するべきではないと考える理由です。Javaの場合は、ループ内でswitch文を使う際は、ループ制御にはラベル付きbreak/continueを使うという条件付きであれば認めてもよいと思います。

「俺様はswitch文の仕様を把握してすでに完璧に使いこなしている。貴様ごときに言われずとも、この程度の些細な問題など気にすることはない」とタカをくくっているあなた。自信過剰なあなたがコードを書いた後、一切テストをすることなく無条件にプロダクトコードとしてコミットしているその傲慢な姿勢が一番危ないということに気付いてください。そしてこれまでテストを書かれることがなかったメンテナンス性の低いコードを引き継いだ人も、機能追加の際にはやはりノーテストでコードを修正しようとするでしょう。
実際にbreakの意味を誤解してバグを作り込んでしまった有名な例があります。ちなみにC言語AT&Tベル研究所で開発された言語です。そのお膝元でCを使ってアホみたいなバグを作り込み、テストをせずにデプロイしたコードが大規模障害を引き起こしてしまったというのはまさに皮肉としかいいようがありません。Cが登場したのはソフトウェア工学や安全設計の考え方が発達していなかった古い黎明期の時代なので仕方がなかった側面もありますが、カーニハンもリッチーもCを設計したときに安全面については完全に何も考えておらず、switch文の構文仕様が持つ根本的な問題点に注意を払っていなかったことがありありとうかがえます。

users.csc.calpoly.edu

Beyond the C

C/C++Java/C#のような歴史の古い言語は良くも悪くも無秩序であり、何も考えていない無頓着なプログラマーに無制限に使わせると、本当に可読性の低いカオスなコードを書いてきます(かつての自分もそうでした)。

Swiftでは逆に暗黙的なフォールスルーをしなくなり、breakを書く必要がなくなった代わりに、フォールスルーをするには明示的にfallthroughキーワードを記述する必要があります。本来の後発言語のswitch文としてあるべき姿はこれでした。SwiftはC系言語の遠い子孫とも言える特徴を持っているのですが、バージョン3でインクリメント・デクリメント演算子を廃止する*5など、Cの悪い設計からの脱却を図ろうとしているフシがあります。

JavaC#、Swiftではバージョンアップによってswitch式をサポートするようになり、関数型言語のmatch式に近い機能を備えるようになりました。Kotlinはswitch文がない代わりに最初からwhen式を備えています。
ちなみにC#のswitch文では、switchキーワードに続く()で囲まれた式のことをもともとswitch式 (switch expression) と呼んでいましたが、C# 8.0で追加されたパターンマッチングのためのswitch式とは別物です。C#は最近言語仕様のアップデートが頻繁過ぎて、用語の整理や公式ドキュメントの整備が全然追いついていないのが問題です。

我々が今するべきことは、同じ失敗を繰り返して手痛い経験をすることではなく、過去の歴史に学ぶことです。

*1:ISO/IEC 9899:1999 §5.2.4.1 Translation limits

*2:ISO/IEC 9899:1999 §5.2.4.1 Translation limits

*3:ISO/IEC 9899:1999 §5.2.4.1 Translation limits

*4:最近の静的型付け言語のトレンドはKotlinやSwiftのように宣言時に型を後置するほうが主流ですが、JavaC#C/C++同様に型を前置するスタイルになっています。制御構造に限らず、intやcharなどの組み込み型のキーワードにさえC/C++を踏襲しているものが多いのも、登場当時、C/C++で書かれた既存のコード(アルゴリズム)を最小限の手間で移植できるようにすることや、C/C++ユーザーがとっつきやすい言語であることを重視していたことがうかがえます。

*5:Appleは定期的に互換性をバッサリ切り捨てることが多く、Swiftも例外ではないため、後方互換性に関して慎重な姿勢のJavaC#と比べると、Web上に氾濫している古いバージョンで書かれた昔のコードはコンパイルすら通らないことが多く、すぐに陳腐化してしまいます。ただ、今後も残し続けるとメリットよりもデメリットが大きくなってしまう機能を廃止するなどして互換性を切り捨てることは、新規に書かれるコードの可読性や安全性を向上し、言語仕様をシンプルに保つためにはある程度必要なことでもあります。欲張って次から次へとなんでもかんでも機能をつぎ込もうとして増改築を重ねすぎた結果、もはや手に負えないレベルで言語仕様が肥大化してしまっているC#とは対照的とも言えます。C++に至っては、本当に必要とされている機能を差し置いて、どうでもいいオナニー機能をロクに考えもせずに無節操に追加することすらあり、C++98時代の古い危険な機能を廃止するのであればともかく、C++11で新しく追加した機能すら10年も経たないうちに非推奨化・廃止したり、既存コードの意味すら変えてしまう(コンパイル自体は通るが意味は変わってしまう)言語仕様の破壊的変更を入れたりするなど、広く使われている言語のわりには互換性が軽視されており、C++標準化委員会が実務経験のない無能集団であることは明らかです。