Java の switch 構文落とし穴
概要
switch 構文は、ある変数がある値のときに処理を分岐させる命令であり、
数多くの言語でサポートされているが、言語により微妙に仕様が異なっている。
言語共通の話題も数多く含まれているが、
今回は、Java を前提に switch 構文について記述する。
詳細説明
switch 構文の記述方法
int value = 0; // valueの値により処理を分岐する switch (value) { case 0: // 処理1 break; case 10: // 処理2 break; default: // 処理3 }
上記 switch 構文は、次の if-else 構文と等価。
int value = 0; // valueの値により処理を分岐する if (value == 0) { // 処理1 }else if(value == 10) { // 処理2 }else { // 処理3 }
使い分け基準
一般的に、処理の分岐条件がある変数の値に依存する場合は switch 構文を使う。
理由
if-else 構文とは違い、分岐条件が値の一致しかできないため、
ソースコードを見る人にその意図が明確に伝わるから。
フォールスルーを極力使わない
フォールスルーとは
switch 構文における case 文の break を省略した記述。
switch 構文にある break を記述することは強制ではない。これらは次の case 文の処理を実行させないためにある。
もし、次の case 文の処理を続けて実行したいときは、break を省略できる。
しかし、フォールスルーは使い方により、バグの温床になる可能性がありオススメしない。
フォールスルーが利用される場合
基本的には、複数の値に対して同じ処理がある場合。
int value = 0; switch (value) { case 0: case 10: // 処理1 break; default: // 処理2 }
上記 switch 構文は、次の if-else 構文と等価であり、直感的で読みやすい。
int value = 0; if (value == 0 || value == 10) { // 処理1 }else { // 処理2 }
危険なフォールスルー
しかし、次のように処理を記述するとフォールスルーは途端に複雑になる。
int value = 0; switch (value) { case 0: // 処理1 case 10: // 処理2 break; default: // 処理3 }
これは、次の if-else 構文と同等。
int value = 0; if (value == 0 || value == 10) { if(value == 0) { // 処理1 } // 処理2 }else { // 処理3 }
これは、value が0でも10でも処理2が実行されることを意味する。
例えば、処理1と処理2がある同じ変数の初期化を行っているとすれば、
value が 0 でも10でも、最終的には同じ値が変数に格納される。
さらに、もう一個増やしてみよう。
int value = 0; switch (value) { case 0: // 処理1 case 10: // 処理2 case 20: // 処理3 break; default: // 処理4 }
これは、次の if-else 構文と同等。
int value = 0; if (value == 0 || value == 10 || value == 20) { if(value == 0 || value == 10) { if(value == 0) { // 処理1 } // 処理2 } // 処理3 }else { // 処理4 }
まとめ:フォールスルーが危険な理由
- 処理が複雑になりやすい
- 記述ミスで break を忘れた場合、バグの温床となりうる
なお、C#.NET では、上記のようなフォールスローは言語仕様で記述できないようになっている。
分岐用変数に極力オブジェクト変数を使わない
さきほどの例では、switch 変数の分岐用変数は int 型であったが、他にも次の変数が指定できる。
- int以下の整数基本データ(int/char/short/byte)
- 列挙型 (Java SE 1,5 から)
- String型 (JavaSE 1.7 から)
- int以下の整数基本データのラッパークラス(Integer/Char/Short/Byte)
もし、分岐条件の変数にint以下の整数基本データ「以外」を指定するなら、注意すべき事項がある。
1. null は分岐条件の値には指定できない
String value = getValue(); // 値をメソッドから取得 switch (value) { case "A": // 処理1 break; case null: //コンパイルエラー( null は指定できない) // 処理2 break; default: // 処理3 }
null の場合の処理分岐は、switch 文ではできない。
2. Java 内部実装による例外発生の可能性
さきほどのコードで、getValueメソッドの戻り値が null だったら NullPointerException が発生する。
極端な話、次のコードでも例外が発生する。
String value = null; switch(value){ default: }
原因
Java が case 文の処理を選択する際に内部的に String 型の equlas メソッドを呼び出しているため。
同様にラッパーオブジェクト、列挙型も内部的なメソッド呼び出しのために同様の例外発生が起こる可能性がある。
対策
オブジェクト型を条件分岐の値として使いたい場合は、以下を行う事。
- 条件分岐の値がメソッド処理結果だった場合、 メソッド仕様として null を返さない事を保証
- null チェックを switch 構文前に行う
switch 構文を用いるより、列挙型を用いた State パターンを実装する
State パターンとは
GoF(Gang of Four)デザインパターンの一つ。状態をクラスとして表すパターン。
例えば、あるサイトでの会員登録の画面を例にすると、
会員情報入力/入力項目確認/会員登録という段階があったとき、「次へ」のボタンを押すとき各段階において処理が異なる。
各段階を表現した状態クラスを導入することで、状態と処理が対応した分かりやすい構造ができる。
さきほどの例でいえば、valueが0のとき、valueが10のときは状態になり、
各処理がそのとき実行される処理となる。
State パターンへの書き換え
例えば次の switch 構文は、
int value = getValue(); // 状態を表す int 型を返す // valueの値により処理を分岐する switch (value) { case 0 : // 処理1 break; case 1 : // 処理2 break; default: // 処理3 }
クラス定義とロジック部分の2か所に分けて以下のように記述される。
ロジック部
State value = getValue(); // 処理に応じた State型を返すようにする。 value.doSomething(); // case 文にあった、どれかの処理が実行される
Stateパターンクラス定義
public static enum State { A { public void doSomething() { //処理1 } }, B { public void doSomething() { //処理2 } }, UNKNOWN { public void doSomething() { //処理3 } }; public abstract void doSomething(); }
Stateパターン利点1. 保守しやすい
状態が今後増えたとしても、Stateパターンクラスのインスタンスを追加するだけで対応可能。
ロジック部の変更は一切不要。
public static enum State { A { public void doSomething() { //処理1 } }, B { public void doSomething() { //処理2 } }, C { //追加した分岐条件 public void doSomething() { //処理 } }, UNKNOWN { public void doSomething() { //処理3 } }; public abstract void doSomething(); }
Stateパターン利点2. 各状態における処理の整合性が取れる
もし、ある状態における2つの処理が対応しなければならない場合があっても整合性がとりやすい。
switch 構文は、ある状態における処理(case 文)が欠落してもコンパイルエラーにならず動作する。
これらを起こさないために実装をクラス構造で強制することができる。
State value = getValue(); // State型を返すようにする。 value.doSomething(); //ここに何か処理があっても良い value.tearDown(); // 後片付け処理
public static enum State { A { public void doSomething() { //処理1 } public void tearDown() { // 状態Aのときの後片付け } }, B { public void doSomething() { //処理2 } public void tearDown() { // 状態Bのときの後片付け } }, UNKNOWN { public void doSomething() { //処理3 } public void tearDown() { // 状態UNKNOWNの後片付け } }; public abstract void doSomething(); public abstract void tearDown(); }
例えば、もし以下の対応関係が取れていなかったとしよう。
- ファイルからデータ取得 → ファイルストリームを閉じる
- データベースからデータ取得 → コネクションのクローズ
これらが対応していなくとも、おそらくそのプログラムは動作するが、いつか問題が発生するだろう。
それは非常に再現が難しく、原因も特定が難しい。さらに、このコード外で発生するかもしれないし、
プログラムの規模が大きくなるにつれて、その原因を解決することは難しくなるだろう。
Stateパターン利点3. コード管理がしやすい
- 状態における処理が一か所にまとまっているため、記述ミスが起こりにくい。
- 複数人の開発においては、ロジック部分とその詳細(処理)を分けて作成できる。
- abstract 宣言により各動作実装が保証されているため、実装の欠落があり得ない。
Stateパターンと比較した switch 構文の欠点
- 状態による分岐処理を複数箇所に記述すると、その都度全状態の case 文を必要とする
- 対応する処理が実装されなかった場合、整合性の取れない動作をする可能性がある。
さらに、これらはコンパイルエラーではなく、実行時例外として別の処理で現れる可能性がある。 - コードが読み辛くなりやすい
結論
- switch 構文は、複雑な処理は記述するにはあまり向いていない。
- 危険なフォールスルーを記述した場合、可読性を大幅に低下させ、バグの温床となりえる。
- オブジェクト型を switch 構文の分岐条件に使う場合は、その変数が null でないことを保証すること。
- もし、現在 switch 構文を使用している箇所が、今後以下のようになるならStateパターン適用を考慮する。
- 状態が増える
- 状態による分岐を複数箇所に記述する
- 各状態に対する整合性のあった処理が必要
参考図書
リファクタリング―プログラムの体質改善テクニック (Object Technology Series)
- 作者: マーチンファウラー,Martin Fowler,児玉公信,平澤章,友野晶夫,梅沢真史
- 出版社/メーカー: ピアソンエデュケーション
- 発売日: 2000/05
- メディア: 単行本
- 購入: 94人 クリック: 3,091回
- この商品を含むブログ (312件) を見る
EFFECTIVE JAVA 第2版 (The Java Series)
- 作者: Joshua Bloch,柴田芳樹
- 出版社/メーカー: 丸善出版
- 発売日: 2014/03/11
- メディア: 単行本(ソフトカバー)
- この商品を含むブログ (10件) を見る