SJ blog
beginner
S

信頼度ランク

S 公式ソース確認済み
A 成功実績多数・失敗例少数
B 賛否両論
C 動作未確認・セキュリティリスク高
Z 個人所感

Javaの細かい制約まとめ — catch順序・finally+return・switch型・ローカル変数初期化

試験で問われる細かい仕様:catchは子クラスを先に書く理由、finallyのreturnがtryのreturnを上書きする仕組み、switchで使える型の制約の理由、ローカル変数とフィールドの初期化の違いを解説。

一言結論

Javaの細かい制約には「コンパイラが静的に検出できる問題を実行時まで持ち越さない」という一貫した思想がある。catch順序・finally優先・switch型制限・ローカル変数の初期化強制はすべてその表れだ。

catchは子クラスを先に書かなければならない

try {
    // ...
} catch (Exception e) {        // ← 親クラスを先に書いた
    System.out.println("Exception");
} catch (IOException e) {      // ❌ コンパイルエラー: 到達不能なcatch節
    System.out.println("IOException");
}

IOExceptionExceptionの子クラスだ。Exceptionのcatchが先にあれば、IOExceptionはそこで必ず捕まえられてしまう。下のcatch節には絶対に到達しない。

コンパイラはこれを静的に検出して「到達不能なcatch(unreachable catch block)」としてエラーにする。バグになりうるコードを実行時まで持ち越さず、コンパイル時に排除するJavaの設計思想の表れだ。

正しい順序はより具体的な(子クラスの)例外を先に、より汎用的な例外を後に

try {
    // ...
} catch (FileNotFoundException e) {  // より具体的(IOException の子クラス)
    System.out.println("ファイルが見つからない");
} catch (IOException e) {            // より汎用的
    System.out.println("IO系のエラー");
} catch (Exception e) {              // 最も汎用的(最後に)
    System.out.println("その他のエラー");
}

multi-catchと継承関係の制約

Java 7から|で複数の例外を1つのcatch節に書ける:

try {
    // ...
} catch (IOException | SQLException e) {
    System.out.println("IOかDB系のエラー");
}

ただし、継承関係にある例外を|で並べるとコンパイルエラーになる:

try {
    // ...
} catch (IOException | FileNotFoundException e) {  // ❌ エラー
    // FileNotFoundException は IOException の子クラス
}

なぜか?FileNotFoundExceptionが起きてもIOExceptionで必ず捕まえられる。つまりFileNotFoundExceptionの記述は冗長(redundant)だ。コンパイラは冗長なコードをエラーにして「より具体的な例外だけ書けば十分だ」と教えてくれる。

この場合はIOExceptionだけ書けばいい(FileNotFoundExceptionはその子クラスなので自動的に含まれる)。

finallyのreturnはtryのreturnを上書きする

finallyは例外の有無にかかわらず実行される。ここに落とし穴がある:

int method() {
    try {
        return 1;   // ← returnしようとする
    } finally {
        return 2;   // ← こちらが「最終的な」return
    }
}

System.out.println(method());  // 2 (!)

tryreturn 1を実行しようとしても、finallyが必ず実行されてそこでreturn 2されてしまう。finallyのreturnがtryのreturnを上書きする

しくみとしては:

  1. tryreturn 1で「1を返す準備」
  2. finallyが実行される(returnの前に割り込む)
  3. finallyの中でreturn 2が実行される
  4. メソッドは2を返して終わる(1は捨てられる)

例外が起きた場合も同様だ:

int method() {
    try {
        throw new RuntimeException("エラー");
    } finally {
        return 999;  // ← 例外を飲み込んでしまう!
    }
}

System.out.println(method());  // 999(例外が発生したのに正常終了する)

finallyの中のreturnが例外を「飲み込んで」しまう。エラーが隠蔽されるので非常に危険なパターンだ。

finallyの中でreturnを書くのは(ほぼ)アンチパターンだ。意図しない例外の隠蔽や戻り値の上書きが起きる。finallyはリソースのクローズなど「副作用のない後片付け」に限定するべきだ。

switch文で使える型は限られている

switch (x) {  // xに使える型は?
    case 1: break;
}

switchの式に使える型:

  • byte, short, int, char
  • Byte, Short, Integer, Character(ラッパー型)
  • String(Java 7以降)
  • enum

使えない型:

  • long, float, double, boolean
  • Long, Float, Double, Boolean

なぜlongが使えないのか?

switchの内部実装は「ジャンプテーブル(jump table)」または「ルックアップテーブル」という仕組みで動く。ケースの値を使って直接ジャンプ先を決定する配列のインデックスとして使う。

int(32bit)のジャンプテーブルは実用的なサイズに収まる。でもlong(64bit)の取りうる値の範囲(約922京)でジャンプテーブルを作るのはメモリが破綻する。だからlongはサポートしていない。

なぜfloat・doubleが使えないのか?

浮動小数点数は「完全な等値比較」が難しい。0.1 + 0.20.3にならない(浮動小数点の誤差)ことは有名だ。case 0.3:0.1 + 0.2にマッチするかどうかが曖昧になる。switch文の「このcaseにマッチするか」という判定には整数型のような完全な等値が必要なので、浮動小数点はサポートしていない。

なぜbooleanが使えないのか?

booleantruefalseの2値だけなので、switchを使う意味がなく、if-elseで十分だ。

なぜStringはJava 7まで使えなかったのか?

Java 7より前のswitchは整数型の比較だけを想定していた。文字列の比較はString.equals()が必要で、switchの仕組みと相性が悪かった。Java 7で内部的にhashCode()を使ったジャンプとequals()による確認を組み合わせる実装が追加され、対応した。

ローカル変数は初期化必須、フィールドはデフォルト値あり

public class Example {
    int fieldValue;  // フィールド:初期化しなくてもOK(デフォルトは0)

    void method() {
        int localValue;              // ローカル変数:宣言だけ
        System.out.println(localValue);  // ❌ コンパイルエラー: 未初期化の変数を使った
    }
}

フィールドとローカル変数で初期化の扱いが違う理由はJVMのメモリ管理にある。

フィールドはヒープ(heap)に確保される。 JVMはヒープ上のメモリを必ずゼロ初期化する(int→0, boolean→false, 参照型→null)。これはセキュリティのためでもある(別のオブジェクトが使っていたメモリの内容が漏れないようにする)。

ローカル変数はスタック(stack)に確保される。 スタックはメソッドの呼び出し・返却が繰り返されるため、前のメソッドが使ったメモリ領域をそのまま使い回すことがある。JVMはスタックをゼロ初期化しない(コストが高くなるので)。代わりに「ゼロ初期化されていないかもしれない変数を使わせない」をコンパイラが保証する。

コンパイラは「すべての実行パスで変数が初期化されているか」を静的に解析する:

void method(boolean flag) {
    int x;
    if (flag) {
        x = 1;
    }
    // flagがfalseのとき、xは初期化されていない
    System.out.println(x);  // ❌ コンパイルエラー
}

void method2(boolean flag) {
    int x;
    if (flag) {
        x = 1;
    } else {
        x = 2;  // elseでも初期化すれば
    }
    System.out.println(x);  // ✅ すべてのパスで初期化されている
}

for文のスコープ

for文の初期化式で宣言した変数は、forブロックの外では使えない:

for (int i = 0; i < 10; i++) {
    System.out.println(i);
}
System.out.println(i);  // ❌ コンパイルエラー: iのスコープはforブロック内のみ

これは意図的な設計だ。カウンタ変数(iなど)はループの中だけで使うものなので、ループを抜けた後に使えるべきではない。スコープを限定することで「不用意に外側のコードがループ変数を参照するバグ」を防いでいる。

同様に、ブロック({})内で宣言した変数はそのブロック内でのみ有効だ:

{
    int x = 10;
}
System.out.println(x);  // ❌ コンパイルエラー: xのスコープはブロック内のみ

まとめ

制約ルール理由
catch節の順序子クラスを先、親クラスを後親を先にすると子クラスのcatchは到達不能(コンパイルエラー)
multi-catchの継承関係にある例外を並べると冗長(コンパイルエラー)
finallyのreturntryやcatchのreturnを上書きするfinallyは必ず実行されるため、最後のreturnが有効になる
switchの型byte/short/int/char/String/enumlong: テーブルが巨大すぎる。float/double: 等値比較が不安定。boolean: if-elseで十分
ローカル変数使う前に必ず初期化スタックはゼロ初期化されない。コンパイラが全パスの初期化を検証する
for変数のスコープfor内のみ有効ループ外での参照バグを防ぐ意図的なスコープ制限

全体を通じて「コンパイラが静的に検出できる問題は実行時まで持ち越さない」という思想が貫かれている。catchの順序も、ローカル変数の未初期化も、finallyのreturnだけは実行時にしか確定しないが、他のほとんどはコンパイル時にエラーにできる。Javaがコンパイラのチェックを重視する言語設計であることが、こういった細かい制約の多さにも表れている。