ジェネリクスの実装戦略(単相化と型消去)
同じ `Vec<T>` がC++/Rustでは速くJavaでは遅い理由がわかる。単相化はコード膨張と引き換えに速度を、型消去はバイナリ互換と引き換えに実行時制約を選ぶ。二大戦略のトレードオフを原理から解説します。
- 1.単相化(C++/Rust)は型引数ごとに専用コードを実体化し、インライン化と最適化が効く代わりにコード膨張とコンパイル時間増を招く。
- 2.型消去(Java)は型引数をコンパイル後に捨て1種類のバイトコードへ畳むため、バイナリ互換は高いが実行時に `T` を直接使えずプリミティブはボックス化される。
- 3.C#は実行時にジェネリクスを再具体化する第三の道を採り、参照型は共有・値型は単相化する折衷でボックス化を避ける。
ジェネリクスは「いつ型を埋めるか」で分かれる
ジェネリクス はソースコード上では Vec<T> のように型を引数化して書きますが、機械語やバイトコードに T という記号は存在しません。どこかで T を具体的な型へ埋め込むか、あるいは消し去る必要があります。この「型引数をいつ・どう処理するか」の選択が、ジェネリクスの実装戦略であり、言語の速度・互換性・表現力を大きく左右します。
二大戦略は 単相化(monomorphization) と 型消去(type erasure) です。前者は型引数の組み合わせごとに専用コードを生成し、後者は型引数を捨てて1種類のコードに畳みます。どちらが優れているという話ではなく、何を犠牲にして何を得るかが正反対のトレードオフになっています。
単相化:型ごとに専用コードを実体化する
C++のテンプレートとRustのジェネリクスが採る方式が単相化です。コンパイラはコード中に現れた型引数の具体的な組み合わせを集め、それぞれに対して別個の関数・型を機械語レベルで生成します。max<int> と max<double> は、ソースでは1つの定義でも、出力では完全に別の関数になります。
// Rust:1つの定義
fn max<T: PartialOrd>(a: T, b: T) -> T {
if a > b { a } else { b }
}
// 使用箇所から、コンパイラが実体を生成する
max(1i32, 2i32); // max_i32 を生成
max(1.0f64, 2.0f64); // max_f64 を生成(別関数)
この方式の最大の利点は実行時コストがゼロに近いことです。各実体は具体型に特化しているので、T が i32 なら整数比較命令が直接埋め込まれ、間接参照も型タグの検査もありません。さらに専用コードなのでインライン展開や定数畳み込みといった最適化がフルに効き、手書きの型専用コードと同等の速度が出ます。Rustが「ゼロコスト抽象化」を掲げられる根拠の一つがこれです。値型(プリミティブ)もそのままレイアウトされ、ボックス化(後述)は起きません。
代償はコード膨張(code bloat) です。Vec<T> を10種類の型で使えば、push や iter などのコードが原理上10セット生成され得ます。バイナリが肥大化すると命令キャッシュ の効きが悪くなり、かえって遅くなる場合もあります。加えて、実体化は使用箇所をすべて見てから行うため、コンパイル時間が増え、コンパイル単位をまたいだジェネリック定義は実装本体をヘッダ等で公開せざるを得ない(分離コンパイルしにくい)という制約も生みます。
型消去:型引数を捨てて1種類に畳む
Javaのジェネリクスが採るのが型消去です。コンパイラは型引数を使ってコンパイル時の型検査だけを行い、検査を通したら型引数の情報を捨て、バイトコードでは1種類の実装へ畳み込みます。List<String> も List<Integer> も、実行時には同じ「生の List」として動作します。T のような無制約の型引数は、概ね Object へ置き換えられます。
// ソース上は型安全
List<String> xs = new ArrayList<>();
xs.add("hello");
String s = xs.get(0); // キャストは書かなくてよい
// コンパイル後は概ねこう(型引数は消える)
List raw = new ArrayList();
raw.add("hello");
String s2 = (String) raw.get(0); // コンパイラが暗黙のキャストを挿入
利点はバイナリ互換性と単純さです。ジェネリクスはJava 5で導入されましたが、型消去のおかげで生成されるバイトコードは旧来の非ジェネリックな List と互換で、古いJVMやライブラリと混在できました。実装が1種類なのでコード膨張も起きません。
代償は実行時の制約です。T の情報が消えているため、new T() や T.class、obj instanceof T のように実行時に型引数を直接使う操作が書けません。さらに List<String> と List<Integer> は実行時には区別不能(同じ生型)で、これを reifiable でない型 と呼びます。決定的なのは、Javaの型引数は参照型しか取れず、int などのプリミティブは Integer へボックス化しなければならない点です。これはヒープ確保とポインタ間接参照を伴い、数値処理では無視できないオーバーヘッドになります(Project Valhallaがこの解消を目指しています)。
型消去のため、Javaでは同じメソッドの f(List<String>) と f(List<Integer>) はシグネチャが衝突してオーバーロードできません(消去後はどちらも f(List))。また配列は型を実行時に保持する(共変で reifiable)一方、ジェネリクスは消去されるため、new T[n] は直接書けず T[] 配列の生成には回避策が要ります。配列とジェネリクスで型の扱いが食い違うのは、この実装方針の差が原因です。
第三の道:C#のランタイム再具体化
単相化と型消去の中間に位置するのがC#(.NET)です。CLRは実行時にジェネリクスを再具体化(reification) します。ジェネリクスのメタ情報をバイトコード(IL)に保持したまま配り、JITが実際に使われた型引数を見て専用コードを生成します。
巧妙なのは参照型と値型で扱いを変える点です。参照型(string, クラス)はメモリ表現が一様なので、List<string> と List<object> で生成コードを共有します(型消去に近い効率)。一方、値型(int, struct)は型ごとにサイズが違うため、List<int> 用に専用コードを単相化します。これにより値型のボックス化を回避でき、Javaの弱点を実行時情報の保持で克服しています。代償としてランタイム(CLR/JIT)に型情報を運ぶ複雑さを抱え込みます。JITコンパイル を前提とする設計だからこそ取れる戦略です。
| 観点 | 単相化(C++/Rust) | 型消去(Java) | 再具体化(C#) |
|---|---|---|---|
| 型引数を埋める時点 | コンパイル時に実体化 | 捨てる(実行時は無) | 実行時にJITが具体化 |
| 実行時の T 情報 | なし(不要) | なし(消える) | あり(保持) |
| 値型のボックス化 | 起きない | 起きる(Integer 等) | 起きない(値型は単相化) |
| コード膨張 | 起きやすい | 起きない | 値型のみ起きる |
| new T() / typeof(T) | 型に特化し可能 | 書けない | 可能 |
| バイナリ互換・分離 | 弱い(本体公開) | 強い | 中(IL に保持) |
トレードオフの本質
三方式の違いは結局、型情報という「重さ」をどこへ移すかに集約されます。単相化はコンパイル時にすべて解決し、実行時を最速にする代わりにバイナリと翻訳時間を太らせます。型消去は実行時から型を追放してバイナリを軽く互換に保つ代わりに、実行時の表現力とプリミティブ性能を諦めます。再具体化はその情報を実行時まで運び、JITに肩代わりさせることで両者の利点を狙います。
実用上の指針としては、数値計算やシステムプログラミングのように性能が要件なら単相化系(Rust/C++)が、プラットフォーム互換と巨大なエコシステムが要件なら型消去系(Java)が向きます。なお同じ間接参照でも、動的ディスパッチとvtable は実行時に呼び先を選ぶための機構であり、コンパイル時に呼び先が確定する単相化(静的ディスパッチ)とは目的が異なります。Rustが両方を持つのは、ゼロコストな既定(単相化)と、コード膨張を避けたいときの動的化(dyn Trait)を使い分けられるようにするためです。
「Javaのジェネリクスでできないことは?」には new T()・T.class・instanceof T、プリミティブの型引数、消去後シグネチャ衝突するオーバーロード を挙げます。「なぜ型消去を選んだか」は 既存JVM・ライブラリとのバイナリ互換 が核心です。「C++/Rustの単相化の長所と短所」は 特化による最速・ゼロコスト抽象化 vs コード膨張とコンパイル時間 で対比できれば十分です。
まとめ
ジェネリクスの実装は「型引数をいつ埋めるか」で分岐します。単相化(C++/Rust)は型ごとに専用コードを実体化して実行時を最速にしますが、コード膨張とコンパイル時間・分離コンパイルの難しさを抱えます。型消去(Java)は型を捨てて1種類に畳み、バイナリ互換と単純さを得る代わりに、実行時に T を使えずプリミティブはボックス化されます。C#の再具体化は型情報をJITまで運び、参照型は共有・値型は単相化する折衷で両者の弱点を埋めます。どれも「型という抽象のコストを、コンパイル時・実行時のどこで支払うか」という一つの問いへの異なる答えにすぎません。
プログラミング Article
ジェネリクスの実装戦略(単相化と型消去)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
ジェネリクス
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 6
導入後に効く点
型消去(Java)は型引数をコンパイル後に捨て1種類のバイトコードへ畳むため、バイナリ互換は高いが実行時に `T` を直接使えずプリミティブはボックス化される。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 6
判断チェックリスト
- 自社の用途が「ジェネリクス / 単相化」に近いか確認する。
- 強みである「単相化(C++/Rust)は型引数ごとに専用コードを実体化し、インライン化と最適化が効く代わりにコード膨張とコンパイル時間増を招く。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。