基礎●●●○○

ジェネリクス

Generics

型をパラメータ化し、型安全なまま多様な型で使い回せる部品を書く仕組み。コレクションで特に威力を発揮する。

概要

ジェネリクス(総称型)は、型そのものをパラメータにする仕組みです。「文字列のリスト」「数値のリスト」を別々に書く代わりに、List<T> という「T のリスト」を1つだけ書き、使う側が T に具体的な型を当てはめる — 値を引数にする関数の考え方を、型のレベルに持ち上げたものと言えます。学術的にはパラメトリック多相と呼ばれる型システムの機能です。

日常のコードでは、意識せずとも常に使っています。Java や C# のコレクション、TypeScript の Array<T>Promise<T>、Rust の Option<T> はすべてジェネリクスです。「中に入れる型は何でもよいが、入れた型と取り出す型は必ず一致する」という約束をコンパイラに検査させられることが核心で、コレクションのように「構造は同じで中身の型だけ違う」部品を書くときに最も威力を発揮します。

なぜ生まれたか

ジェネリクスがなかった時代、あらゆる型で使えるコレクションを作るには2つの道しかありませんでした。1つは型ごとに IntListStringList……と同じコードを複製する道で、重複だらけになり修正も型の数だけ必要です。もう1つは要素をすべて Object(や anyvoid*)として扱い、型情報を捨てる道です。何でも入れられる代わりに、取り出すたびにキャスト(型の読み替え)が必要になり、間違った型を入れてもコンパイルは通ってしまう。誤りは実行時に ClassCastException などとして、しかも入れた場所から遠く離れた取り出し側で爆発します。ジェネリクス導入前の Java(1.4 以前)のコレクションは、まさにこの実行時エラーの温床でした。

ジェネリクスは「型をパラメータにする」ことでこの二択を打ち破りました。コードは1つだけ書き、型の検査は具体化された型でコンパイラが行う — 使い回しと型安全を同時に手に入れる第三の道です。1970年代の ML で理論が確立し、C++ のテンプレートを経て、2004年の Java 5、C# 2.0 での導入により主流の実務言語に定着しました。

詳細

三択の構図

まず、ジェネリクスが何と何の間の解なのかを図で押さえておきます。

型ごとに複製Object で型放棄型パラメータ化IntListStringListUserList …ほぼ同じコードが型の数だけ重複 — 修正も型の数だけObjectList何でも入る取り出すたびにキャスト間違いは実行時に爆発コードは1つだが型検査を放棄しているList<T>T は使う側が決めるList<String> などコンパイル時に型検査コードは1つのまま型安全も保たれる「使い回し」と「型安全」の二者択一を、型をパラメータにすることで解消する
型ごとに複製 / Object で型放棄 / 型パラメータ化 — ジェネリクスは重複と実行時エラーの両方を避ける第三の道

型パラメータの基本

ジェネリクスの構文的な主役は型パラメータです。List<T>T は「後で決まる型」を表すプレースホルダで、List<String> と書いた瞬間に T = String として全体が具体化されます。add の引数も get の戻り値も String になるため、数値を入れようとすればその場でコンパイルエラーになり、取り出すときのキャストも不要です。クラスだけでなく関数も型パラメータを持てます。たとえば「配列の先頭要素を返す first<T> 関数」を1つ書けば、文字列配列に使えば戻り値は文字列、数値配列なら数値と、入力と出力の型の対応がそのまま保たれます。多くの言語では実引数から型パラメータを自動で推論してくれるため、書き心地は普通の関数とほとんど変わりません。

実装方式 — 型消去と具現化

同じジェネリクスでも、内部の実現方法は言語ごとに大きく異なります。Java は型消去(type erasure)方式で、型検査が終わるとコンパイラが型パラメータを消し、実行時にはジェネリクス以前と同じ List が1つあるだけです(後方互換のための選択で、実行時に List<String> かどうかを判別できないという制約が残ります)。C# は具現化(reification)方式で、実行時にも型引数の情報が保持されます。C++ のテンプレートや Rust は単相化(monomorphization)といって、使われた型ごとに特殊化されたコードをコンパイル時に生成する方式で、実行が速い代わりにバイナリが膨らみます。TypeScript のジェネリクスはコンパイル時の検査だけに使われ、トランスパイラが JavaScript に変換した時点で完全に消えます。日々の利用でこの違いを意識する場面は少ないですが、「実行時に型パラメータを調べたい」ときに言語ごとの限界として顔を出します。

制約付きジェネリクス

「どんな型でもよい」だけでは、T の値に対して何もできません。比較もできず、メソッドも呼べないからです。そこで「T は比較可能な型に限る」(Java なら T extends Comparable<T>、C# なら where 句、Rust ならトレイト境界)のように型パラメータに条件を付けるのが制約付き(bounded)ジェネリクスです。制約を付けることで、その能力(比較する、シリアライズするなど)を型パラメータの値に対して安全に使えるようになります。「任意の型で動く」と「その型に何かを要求する」のさじ加減こそがジェネリック API 設計の勘所です。

変性 — List<Cat>List<Animal>

ジェネリクスで最も混乱しやすいのが変性(variance)です。Cat が Animal の一種なら、List<Cat>List<Animal> として扱ってよいでしょうか。直感に反して、書き換え可能なリストでは「否」です。もし許すと、List<Animal> として受け取った参照経由で Dog を追加でき、元の List<Cat> が壊れてしまいます。型引数の親子関係をそのまま引き継ぐことを共変、逆転させることを反変、引き継がないことを非変と呼び、安全な向きは「取り出すだけ(生産者)なら共変、入れるだけ(消費者)なら反変」と整理できます。これは「派生型は基底型として安全に使えねばならない」というリスコフの置換原則(SOLID の L)の型パラメータ版にほかなりません。Java のワイルドカード ? extends / ? super や C#・Kotlin の out / in は、この向きを宣言するための構文です。なお Java の配列は歴史的経緯で共変にされており、まさに上記の事故が実行時例外として起こり得る — ジェネリクスが非変をデフォルトにした理由を示す反面教師です。

実務での使いどころ

実務でジェネリクスが支えているのは、まずコレクションとデータ構造全般です。リスト・マップ・キューを型ごとに書き直している言語は、いまやほぼありません。次に、成功か失敗かを型で表す Result<T, E> や、値の不在を表す Option<T> のような「入れ物」型です。中身が何であれ「失敗し得る」「無いかもしれない」という構造だけを共通化できるため、例外処理に代わるエラー表現として広く使われています。また、REST API のレスポンスを ApiResponse<T>(ステータスやページ情報は共通で、データ本体だけ型が違う)として定義するのも定番で、通信層のコードを1つに保ったままエンドポイントごとの型安全を確保できます。共通するのは「構造は同じで、中身の型だけが違う」という形を見つけたらジェネリクスの出番、という嗅覚です。逆に、型パラメータが1か所でしか現れない・具体型が2つしかない、といった場面で無理にジェネリック化すると、読み手の負担だけが増えます。抽象は必要になってから導入するのが、ここでも定石です。