コンパイル時型と実行時型とは
「コンパイル時型」はコード上で宣言された“見える型”で、コンパイラが文法チェックやメソッド呼び出しの可否を判断するために使います。「実行時型」は new で実際に生成されてメモリ上に存在する“実体の型”で、動的バインディングでどのメソッド実装が呼ばれるかを決めます。参照の“見え方”(コンパイル時型)と“中身”(実行時型)は別物で、この分離がポリモーフィズムの土台になります。
何がどう違うのか(役割の分担を理解する)
コンパイル時型は「どのメンバーにアクセスできるか」を制限します。見えていないメソッドやフィールドは呼べません。一方、実行時型は「どの実装が選ばれるか」を決めます。インスタンスメソッドは実行時型に基づいて最下段のオーバーライドが選ばれます(動的バインディング)。静的メソッドやフィールド参照はコンパイル時型で決まるため、動的な切り替えには関与しません。
abstract class Shape { abstract double area(); }
final class Rect extends Shape {
private final double w, h;
Rect(double w, double h){ this.w = w; this.h = h; }
@Override double area(){ return w * h; }
double perimeter(){ return 2 * (w + h); } // Rect にだけあるメソッド
}
Shape s = new Rect(3,4); // コンパイル時型: Shape / 実行時型: Rect
System.out.println(s.area()); // OK(動的に Rect の実装が選ばれる)
System.out.println(((Rect) s).perimeter()); // ダウンキャストすれば呼べる
// s.perimeter(); // コンパイルエラー(Shape からは見えない)
Java実行時型で選ばれるのは「インスタンスメソッド」だけ
動的に切り替わるのはインスタンスメソッドです。static メソッドやフィールド参照は見えている型(コンパイル時型)で解決されます。切り替えたい処理は必ずインスタンスメソッドへ寄せるのが鉄則です。
class Base { static String who(){ return "Base"; } }
class Sub extends Base { static String who(){ return "Sub"; } }
Base b = new Sub(); // コンパイル時型: Base / 実行時型: Sub
System.out.println(b.who()); // "Base"(static はコンパイル時型で決まる)
System.out.println(Sub.who()); // "Sub"(型名で呼ぶ)
Javaインターフェースでも同じ原理が働く
インターフェース型へアップキャストすると、見えるメソッドは契約の範囲に限定されますが、呼ばれる実装は実行時型(具体クラス)で決まります。これが分岐を減らし、差し替えを容易にします。
interface Formatter { String format(String raw); }
final class UpperFormatter implements Formatter {
@Override public String format(String raw){ return raw == null ? "" : raw.toUpperCase(); }
}
final class SnakeFormatter implements Formatter {
@Override public String format(String raw){ return raw == null ? "" : raw.trim().replaceAll("\\s+", "_"); }
}
Formatter f = new UpperFormatter(); // コンパイル時型: Formatter / 実行時型: UpperFormatter
System.out.println(f.format(" Hello ")); // Upper の実装
f = new SnakeFormatter(); // 実体差し替え
System.out.println(f.format("a b")); // Snake の実装
Javaジェネリクスでの“見える型”と安全性
ジェネリクスは「コンパイル時型の精度」を上げて安全性を確保します。List<Shape> は Shape のメソッドしか見えません。List<Rect> を List<Shape> へそのまま代入できないのは、コンパイル時型安全性を守るためです(実体が混在して壊れるのを防ぐ)。必要なら最初から共通型で受けるか、ストリームなどで詰め替えます。
java.util.List<Shape> shapes = java.util.List.of(new Rect(3,4));
double sum = shapes.stream().mapToDouble(Shape::area).sum(); // 見えるのは area だけ
Java設計で生きる「型の二層構造」(重要な深掘り)
呼び出し側はコンパイル時型を「親クラスやインターフェース」に揃えることで、アクセスを契約の範囲に限定し、差し替えを容易にします。一方、拡張側は実行時型(具体クラス)でオーバーライドを提供し、動的バインディングで振る舞いを切り替えます。必要な操作が親に無いなら、ダウンキャストに頼らず契約へ“昇格”して、呼び出し側のコンパイル時型で安全に呼べるようにします。これが分岐を消し、保守性を上げる王道です。
abstract class Importer {
public final void run(String path) {
var raw = load(path); // 見えるのは契約の範囲
var normalized = normalize(raw);
save(normalized); // 実行時型で実装が選ばれる
}
protected abstract String load(String path);
protected String normalize(String s){ return s == null ? "" : s.trim(); }
protected abstract void save(String data);
}
Javaよくある誤解と回避
「コンパイル時型に揃えると機能が減って損をする」わけではありません。必要な機能は契約へ昇格すべき、という設計のサインです。フィールドや static は“見える型で固定”されるので、動的に切り替えたいならインスタンスメソッドへ寄せる。コンストラクタでオーバーライド可能メソッドを呼ぶ“仮想呼び出し”は未初期化参照の事故につながるため厳禁です。初期化は「親の完成 → 子の完成」を守り、骨格はコンストラクタ外の final メソッドで固定します。
まとめと実践の指針
コンパイル時型は「アクセスの範囲と安全性」を、実行時型は「呼ばれる実装」を決めます。この二層構造を活かし、呼び出し側は契約に依存、拡張側はオーバーライドで差分を提供。static/フィールドは静的解決、動的切替はインスタンスメソッド——この線引きを徹底すると、分岐が消え、拡張に強く壊れにくい設計が自然に育ちます。
