部分型付けと分散(共変・反変)
ジェネリクスの型引数を「いつ広げて・いつ狭めて」代入できるかが腑に落ちる。共変・反変・不変の規則を関数型から導き、配列共変が招く型安全性の穴まで原理で押さえます。
- 1.部分型はリスコフの置換原則に基づく「いつでも差し替え可能」の関係。S が T の部分型なら、T を期待する場所に S を渡せる。
- 2.関数型は引数で反変・戻り値で共変。複合型の分散はこの規則から決まり、書き込みと読み出しの両方を許す型は不変になる。
- 3.Java や C# の配列は共変だが、共変な書き込みは健全でないため実行時チェック(ArrayStoreException)で穴を塞いでいる。
部分型とは何を保証する関係か
**部分型(subtype)**は、型 S が型 T の部分型であるとき(S <: T と書く)、T が期待されるあらゆる文脈で S を代わりに使ってよいという置換可能性を表します。これはオブジェクト指向の継承だけの話ではなく、より一般的な型の包含関係です。
判定基準は**リスコフの置換原則(LSP)**です。Cat <: Animal が成り立つのは、Animal として扱えるすべての操作が Cat でも問題なく行えるから。逆向き(Animal を Cat の代わりに使う)は成り立ちません。すべての Animal が Cat とは限らないからです。
この「片方向だけ成り立つ」性質が、後で出てくる分散の議論すべての出発点になります。型システムの全体像は型システム(静的型付け vs 動的型付け)、その数理的な土台は型理論の基礎(ラムダ計算と型付け)も参照してください。
分散(variance):型構築子は順序をどう運ぶか
部分型関係が単純な型(Cat <: Animal)にあるとき、それを包む型構築子(List<_>、Func<_,_> など)に対して関係がどう伝わるかを**分散(variance)**と呼びます。3つの結果があります。
| 分散 | 規則 | 意味 |
|---|---|---|
| 共変 (covariant) | Cat <: Animal ⟹ F<Cat> <: F<Animal> | 部分型関係を同じ向きに保つ |
| 反変 (contravariant) | Cat <: Animal ⟹ F<Animal> <: F<Cat> | 部分型関係を逆向きにする |
| 不変 (invariant) | どちらの向きも成り立たない | F<Cat> と F<Animal> に関係なし |
重要なのは、分散は気分で決めるものではなく、その型構築子が要素型をどう使うか(読むか・書くか)から論理的に導かれるという点です。次節でその導出を行います。
関数型の分散:引数は反変・戻り値は共変
分散の核心は関数型にあります。関数 g: Animal -> Animal を期待する文脈で、関数 f を代わりに渡せる条件を考えます。f が安全な代替であるためには、
- 引数: 呼び出し側は Animal を渡してくる。
fはそれを受け取れねばならない。よってfの引数型は Animal 以上に広くてよい(Animalの上位型を受け入れる)。これが反変です。 - 戻り値: 呼び出し側は戻り値を Animal として扱う。
fの戻り値は Animal として通用すればよく、Catのような部分型でも構わない。これが共変です。
規則としてまとめると、(A1 -> R1) <: (A2 -> R2) が成り立つのは A2 <: A1 かつ R1 <: R2 のとき。引数は逆向き、戻り値は同じ向き。これが分散のすべての規則の親玉です。
// TypeScript(strictFunctionTypes 下)での関数の部分型
type AnimalFn = (a: Animal) => Animal;
// 引数を広げ(Animal の上位)、戻り値を狭める(Cat)のは安全 → 代入可
const f: AnimalFn = (a: unknown): Cat => new Cat();
// 引数を Cat に狭めるのは危険 → 反変なので代入不可(Animal が来たら困る)
const g: AnimalFn = (a: Cat): Animal => a; // ❌ 型エラー
反変は入れ子になると向きが反転し続けます。(A -> B) -> C の最も外側の引数位置では A -> B 全体が反変位置にあり、その内側の A は「反変の中の反変」で再び共変位置になります。位置の符号を掛け算(共変=+、反変=−)すると考えると一貫して追えます。
なぜ「書き込める入れ物」は不変なのか
ジェネリックコンテナの分散も、要素型がどの位置に現れるかで決まります。コンテナの操作を関数の集まりとみなすのが鍵です。
- 読み出し
get(): Tは、T が戻り値位置(共変位置)に現れる。 - 書き込み
set(x: T)は、T が引数位置(反変位置)に現れる。
読み出ししかできない(get だけの)コンテナは共変にできます。書き込みしかできないシンク(set だけ)は反変にできます。そして読み書き両方を持つ可変コンテナは、T が共変位置と反変位置の両方に現れるため不変になります。これが「ミュータブルなジェネリック型は基本的に不変」という実務上の鉄則の正体です。
// 読み出し専用なら共変が安全
interface ReadOnlyBox<out T> { get(): T; }
// 書き込み専用なら反変が安全
interface WriteOnlyBox<in T> { set(x: T): void; }
// 両方持つと不変にせざるを得ない
interface Box<T> { get(): T; set(x: T): void; }
C# の in/out キーワードや Kotlin の宣言箇所分散はまさにこの規則を構文化したもので、out T を付けた型引数は書き込み位置に置けないようコンパイラが強制します。型引数の基礎はジェネリクス(総称型)、推論の仕組みは型推論(Hindley-Milner)で扱っています。
配列の共変性という有名な穴
ここからが本題の「型安全性の穴」です。Java と C# は歴史的経緯から、配列を共変としています。つまり String[] <: Object[] が成り立ちます。一見便利ですが、配列は読み書き両方ができる可変コンテナなので、前節の規則に従えば本来は不変であるべきです。共変にした結果、健全性(soundness)が破れます。
// Java:配列は共変なのでこの代入はコンパイルを通る
Object[] arr = new String[3]; // String[] <: Object[]
// 型的には Object[] なので Integer を入れられるはず…
arr[0] = Integer.valueOf(42); // コンパイルは通る!
// しかし実体は String[]。実行時に ArrayStoreException が飛ぶ
コンパイラの型検査だけでは arr[0] = ... の代入を弾けません。arr の静的型は Object[] で、Object の代入は合法に見えるからです。穴を塞いでいるのは実行時チェックで、JVM は配列要素を書き込むたびに実際の要素型と照合し、不一致なら ArrayStoreException を投げます。
配列共変は「コンパイル時に保証されたはずの型安全が、実は実行時例外でしか守られていない」例です。静的型の約束が破られているため**不健全(unsound)**と呼ばれます。代償は2つ。型エラーが実行時まで遅延すること、そして配列書き込みのたびに型タグ照合の実行時コストがかかることです。
なぜ初期の Java はこの不健全さを受け入れたのか。ジェネリクスが言語に無かった時代、Object[] を受け取る汎用ソートのような関数を書くには配列の共変性が事実上必要だったためです。ジェネリクスの導入後はこの動機は薄れ、ジェネリックコレクション(List<T>)は正しく不変に設計されました。List<String> は List<Object> の部分型ではありません。
使用箇所分散:必要なときだけ向きを与える
ジェネリクスを常に不変にすると不便です。そこで Java は**使用箇所分散(use-site variance)**をワイルドカードで提供します。List<? extends Animal> は「Animal の何らかの部分型のリスト」で、読み出しは Animal として安全にできる一方、書き込みは禁止されます(要素の実型が不明なため)。逆に List<? super Cat> は書き込み向けで読み出しが制限されます。
これは PECS(Producer Extends, Consumer Super)として知られる指針そのもので、前々節の「読みは共変・書きは反変」を呼び出し側で選べるようにした仕組みです。
// 読み出すだけ(生産者)→ extends で共変に
double sum(List<? extends Number> nums) {
double s = 0;
for (Number n : nums) s += n.doubleValue(); // 読みは安全
// nums.add(...) はコンパイルエラー:実型が不明なので書けない
return s;
}
(1)S <: T は「T の場所に S を置ける」置換可能性。(2)関数は引数で反変・戻り値で共変。(3)可変コンテナは読み書き両用ゆえ不変が正解。(4)Java/C# の配列だけは例外的に共変で、その不健全さを ArrayStoreException(実行時)で補っている。(5)ジェネリクスは不変が既定で、分散は宣言箇所(out/in、Kotlin)か使用箇所(ワイルドカード、Java)で明示的に与える。
まとめ
分散は恣意的な仕様ではなく、要素型が読み出し位置(共変)に現れるか書き込み位置(反変)に現れるかから論理的に決まります。関数型の「引数は反変・戻り値は共変」がその大本で、複合型の分散はすべてここから導けます。両方の位置に現れる可変コンテナが不変になるのは必然で、それを破った配列共変は、コンパイル時の保証を実行時例外に肩代わりさせる「不健全さ」の代表例です。分散の符号を位置から逆算する習慣を付ければ、どの型を共変・反変・不変にすべきかを自分で判断できるようになります。
プログラミング Article
部分型付けと分散(共変・反変)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
型システム
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 5
導入後に効く点
関数型は引数で反変・戻り値で共変。複合型の分散はこの規則から決まり、書き込みと読み出しの両方を許す型は不変になる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 5
判断チェックリスト
- 自社の用途が「型システム / 部分型」に近いか確認する。
- 強みである「部分型はリスコフの置換原則に基づく「いつでも差し替え可能」の関係。S が T の部分型なら、T を期待する場所に S を渡せる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。