ジェネリクス
Generics ・ ジェネリクス
型をパラメータにして、型安全なまま色々な型で使い回せる部品を書く仕組み。コレクションで特に威力を発揮する。
✦ 30秒で試す — 航海コンパイルは通ったのに、配列に犬を入れた瞬間に落ちるaddDog に猫のリストを渡すと、どうなる?予想してみる概要
ジェネリクス(総称型)は、型そのものをパラメータにする仕組みです。「文字列のリスト」「数値のリスト」を別々に書く代わりに、List<T> という「T のリスト」を1つだけ書き、使う側が T に具体的な型を当てはめる — 値を引数にする関数の考え方を、型のレベルに持ち上げたものと言えます。学術的にはパラメトリック多相と呼ばれる型システムの機能です。
日常のコードでは、意識せずとも常に使っています。Java や C# のコレクション、TypeScript の Array<T> や Promise<T>、Rust の Option<T> はすべてジェネリクスです。「中に入れる型は何でもよいが、入れた型と取り出す型は必ず一致する」という約束をコンパイラに検査させられることが核心で、コレクションのように「構造は同じで中身の型だけ違う」部品を書くときに最も威力を発揮します。
なぜ生まれたか
ジェネリクスがなかった時代、あらゆる型で使えるコレクションを作るには2つの道しかありませんでした。1つは型ごとに IntList、StringList……と同じコードを複製する道で、重複だらけになり修正も型の数だけ必要です。もう1つは要素をすべて Object(や any、void*)として扱い、型情報を捨てる道です。何でも入れられる代わりに、取り出すたびにキャスト(型の読み替え)が必要になり、間違った型を入れても型システムの検査は素通りしてしまいます。誤りは実行時に ClassCastException などとして、しかも入れた場所から遠く離れた取り出し側で爆発します。ジェネリクス導入前の Java(1.4 以前)のコレクションは、まさにこの実行時エラーの温床でした。
ジェネリクスは「型をパラメータにする」ことでこの二択を打ち破りました。コードは1つだけ書き、型の検査は具体化された型でコンパイラが行う — 使い回しと型安全を同時に手に入れる第三の道です。
源流は1970年代にあります。Barbara Liskov らの CLU(1975年ごろ)は型を引数に取る「パラメータ化されたクラスタ」を持ち、1973年に登場した ML は、型パラメータを型推論と組み合わせたパラメトリック多相を理論として確立しました。1983年の Ada は generic を言語標準に入れ、1990年代に C++ のテンプレートが実務へ広めます。そして2004年の Java 5(J2SE 5.0)と2005年の C# 2.0 での導入によって、主流の業務言語に定着しました。
詳細
三択の構図
まず、ジェネリクスが何と何の間の解なのかを図で押さえておきます。
型パラメータの基本
ジェネリクスの構文的な主役は型パラメータです。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(「生産者は extends、消費者は super」と覚える PECS)や、C#・Kotlin の out / in は、この向きを宣言するための構文です。
次の部品で確かめてください。下の箱(List<Cat> など)を、上に並んだ関数の引数の口へドラッグして差し込みます。読むだけの口と書くだけの口は通り、読み書きする口はコンパイラが弾きます。検査を外して無理に通すと、猫のリストに犬が紛れ込んで壊れます。
- fun speakAll(xs: List<out Animal>) { xs.forEach { it.speak() } }
- fun addDog(xs: MutableList<Animal>) { xs.add(Dog()) }
- fun addCat(xs: MutableList<in Cat>) { xs.add(Cat()) }
- static void addDog(Animal[] xs) { xs[0] = new Dog(); } // Java
下の箱を、上の関数の引数の口へドラッグして差し込んでください。まず List<Cat> を、いちばん左の「読むだけ」の口へ。
最後の口は Java の配列です。Java の配列は、ジェネリクスがなかった時代に「動物の配列を受け取る汎用の関数」を書けるよう、歴史的な経緯で共変にされました。そのため Cat[] を Animal[] として渡すことはコンパイラが許し、犬を入れた瞬間に実行時の検査が ArrayStoreException で止めます。誤りをコンパイル時ではなく、実行時の例外(例外処理)でしか捕まえられない — ジェネリクスが既定で非変を選び、向きを宣言したときだけ共変・反変を許す設計にしたのは、この反面教師があったからです。
実務での使いどころ
実務でジェネリクスが支えているのは、まずコレクションとデータ構造全般です。リスト・マップ・キューを型ごとに書き直している言語は、いまやほぼありません。次に、成功か失敗かを型で表す Result<T, E> や、値の不在を表す Option<T> のような「入れ物」型です。中身が何であれ「失敗し得る」「無いかもしれない」という構造だけを共通化できるため、例外処理に代わるエラー表現として広く使われています。また、REST API のレスポンスを ApiResponse<T>(ステータスやページ情報は共通で、データ本体だけ型が違う)として定義するのも定番で、通信層のコードを1つに保ったままエンドポイントごとの型安全を確保できます。共通するのは「構造は同じで、中身の型だけが違う」という形を見つけたらジェネリクスの出番、という嗅覚です。逆に、型パラメータが1か所でしか現れない・具体型が2つしかない、といった場面で無理にジェネリック化すると、読み手の負担だけが増えます。抽象は必要になってから導入するのが、ここでも定石です。
試問
下に3つの箱(猫のリスト List<Cat>、動物のリスト List<Animal>、Java の猫の配列 Cat[])、上に4つの関数の引数の口(読むだけ・読み書き・書くだけ・Java の配列)があります。箱を口へドラッグすると、通るか、コンパイラに弾かれるか、実行時に壊れるかが分かります。コンパイラの型検査は、下の切り替えで外せます(もしもの実験)。
- fun speakAll(xs: List<out Animal>) { xs.forEach { it.speak() } }
- fun addDog(xs: MutableList<Animal>) { xs.add(Dog()) }
- fun addCat(xs: MutableList<in Cat>) { xs.add(Cat()) }
- static void addDog(Animal[] xs) { xs[0] = new Dog(); } // Java
下の箱を、上の関数の引数の口へドラッグして差し込んでください。まず List<Cat> を、いちばん左の「読むだけ」の口へ。
