変性は選ぶものではなく、位置が決めるものである
TがUの部分型であるとき(T <: Uと書く)、Tを包んだ型F<T>とF<U>の間に部分型関係はあるか。この問いへの答え方が変性(variance)である。
答えは三通りしかない。
| 呼び名 | T <: Uのとき | 直感 |
|---|---|---|
| 共変(covariant) | F<T> <: F<U> | 中身の向きがそのまま外に出る |
| 反変(contravariant) | F<U> <: F<T> | 中身の向きが外で反転する |
| 不変(invariant) | どちらも成り立たない | 中身が違えば別型 |
用語に引きずられやすいが、「不変(invariant)」は「イミュータブル」という意味ではない。ここでの不変は「部分型関係を伝えない」であって、値が書き換わるかどうかの話ではない。むしろ後述するとおり、書き換えられる型ほど不変にせざるを得ない。
選択ではなく帰結である
変性は設計者が好みで決める旋回ダイヤルに見えるが、実際はTがFの中のどの位置に現れるかで決まってしまう。
理由は「その型の値に対して何ができるか」を考えれば出る。犬 <: 動物として、
読み出しだけの箱を考える。Box<犬>から取り出せるのは犬で、犬は動物なのだから、動物が欲しい人にBox<犬>を渡しても困らない。だからBox<犬> <: Box<動物>、共変である。
書き込みだけの受け口を考える。Sink<犬>(犬を入れる先)が必要な場面で、Sink<動物>を渡しても困らない — 動物なら何でも受け取るのだから、犬も当然受け取る。だからSink<動物> <: Sink<犬>である。
逆向きは壊れる。Sink<動物>が必要な場面 — つまり猫も入れるかもしれない場面 — にSink<犬>を渡すと、猫を入れた時点で破綻する。中身の向き(犬 <: 動物)に対して、外側の向きが反転している。これが反変である。
読み書き両方できる箱は、上の二つを同時に要求する。RW<犬> <: RW<動物>(読み出し側の要求)とRW<動物> <: RW<犬>(書き込み側の要求)が両立するのは両者が同じ型のときだけなので、結論は不変になる。
つまり可変なコンテナが不変になるのは、設計判断ではなく算術の結果である。
関数型がこの規則の純粋形である
一番きれいに現れるのは関数型A -> Bである。引数Aは入っていく位置、戻り値Bは出てくる位置なので、
A' <: A かつ B <: B'
------------------------------
(A -> B) <: (A' -> B')
引数については反変、戻り値については共変になる。日常的な言い方をすれば、「より広い引数を受け取り、より狭い値を返す関数」は、いつでも代わりに使える。より多くを受け付けて、より確かなものを返すのだから、置き換えて困る場面がない。
具体例で確かめる
規則だけ見ても腑に落ちないので、犬 <: 動物で実際に代入してみる。犬 -> 動物という関数がほしい場面を考える。呼ぶ側は犬を渡すつもりで、返ってきたものを動物として扱うつもりでいる。代わりに何を渡せるか。
| 渡す関数 | 通るか | 理由 |
|---|---|---|
動物 -> 動物 | 通る | 犬を渡す→動物なので受け取れる。返りは動物で期待どおり |
チワワ -> 動物 | 壊れる | 柴犬を渡した瞬間に困る。チワワしか扱えない |
犬 -> 犬 | 通る | 返りが犬。犬は動物なので、動物として扱って問題ない |
一行目と二行目が引数側の話で、引数の型は広げる分には通り、狭めると壊れる。中身の向き(チワワ <: 犬 <: 動物)と、関数全体の向きが逆になっている。
チワワ <: 犬 <: 動物 ← 引数の型の向き
動物->X <: 犬->X <: チワワ->X ← 関数全体の向き(反転)
三行目が戻り値側で、こちらは狭める分には通る。向きはそのままである。
「第一引数」は関数の引数のことではない
これは型理論の標準的な規則(S-Arrow)で、Pierceらの記述がそのまま「関数構成子は第一引数について反変、第二引数について共変である」と言っている(Software Foundations, Sub: Subtyping)。
この言い回しは引っかかりやすい。 ここでの「第一引数/第二引数」は型構成子->から見た引数であって、関数から見た引数ではない。A -> BをArrow<A, B>と書き直すと対応が見える。
| 原文の言い方 | 実体 | 関数として見ると |
|---|---|---|
->の第一引数 | A | 関数の引数の型 |
->の第二引数 | B | 関数の戻り値の型 |
同じ「引数」という語が二つの階層で使われていて、しかも片方がもう片方と一致してしまうので紛らわしい。わざわざこの言い方をするのは、List<T>の変性とA -> Bの変性を同じ土俵で語るためである。「型構成子は各引数について共変か反変か不変かを持つ」という一般則があり、Listは引数を一つ取って共変、->は引数を二つ取って第一が反変・第二が共変、と揃えて言える。->に限って読むなら「引数について反変、戻り値について共変」と読み替えてよい。
ScalaのFunction1はこの規則を型定義そのものに書いていて、trait Function1[-T1, +R]と、引数に反変(-)、戻り値に共変(+)の印が付く(scala/scala)。-T1が第一引数、+Rが第二引数で、上の表そのものである。
この規則は関数型言語に限った話ではない。メソッドのオーバーライドで戻り値を狭められる(共変戻り値)のに引数は狭められない、というよくある制約は、同じ規則の別の顔である。
だから読み出し専用は特別扱いできる
「読み出し専用なら共変で安全」という言い回しがよく出てくるのは、この位置の規則の系である。書き込み位置が無ければ反変の要求が発生しないので、共変の要求だけが残って矛盾しない。
主な問題は、共変な可変コレクションが型安全性を壊しうることである。これが、
Listが共変なコレクションであるのに対し、scala.collection.mutable.ListBufferが不変なコレクションである理由である。不変性(immutability)は、変性を使うという設計判断の重要な部分を構成している。
Tour of Scala — Variances
同じListという概念でも、読み出し専用の方は共変にでき、可変の方はできない。変性は型構成子の名前ではなく、その型が許す操作で決まるということである。
裏返せば、共変にしておいた型にあとから書き込み経路を足すと、その瞬間に健全性が壊れる。壊れるのは新しく足した操作ではなく、既にあった部分型関係の方である。これは変性は書かれていないだけで、既に決まっているでCarinaについて書いた懸念とまったく同じ形であり、そこでの「読み出し専用だから今は安全」という前提が、なぜ前提として明記されなければならないかの理由でもある。
言語ごとの現れ方
宣言側に注釈を書かせる方式が主流である。Scalaは型引数に+T(共変)/ -T(反変)を付け、既定は不変である — 「Scalaでは型引数は既定で不変であり、型引数どうしの部分型関係はパラメータ化された型に反映されない」(Tour of Scala — Variances)。C#は同じことをout(共変)/ in(反変)で書く(Microsoft Learn)。
注目すべきは、C#が変性の注釈を「位置の制約」として実装していることである。共変(out)と印を付けた型引数は戻り値の位置にしか書けない。
interface ICovariant<out R>
{
R GetSomething();
// 次の行はコンパイルエラーになる
// void SetSomething(R sampleArg);
}
上の「位置が変性を決める」という規則が、そのまま検査規則になっている。だからIEnumerable<T>やIReadOnlyList<T>は共変にできるのに、Add(T)を持つIList<T>は共変にできない。
一方使用側で指定する方式もある。Javaのジェネリクスは宣言時には変性を持たず、IntegerがNumberの部分型であっても「List<Integer>はList<Number>の部分型ではなく、実際この二つの型は無関係である」(The Java Tutorials)。代わりに使う側が? extends(共変として使う)/ ? super(反変として使う)と書く。
宣言側で決める方式(Scala、C#)は一度決めれば使用側が考えなくてよい代わりに、同じ型を両方の向きで使いたいときに困る。使用側で決める方式(Java)はその逆で、柔軟だが使うたびに書く必要がある。どちらも「位置が変性を決める」規則は同じで、その規則をいつ適用するかが違うだけである。
Carinaはどれでもない。注釈も無く、既定を不変に倒してもおらず、判定の再帰の向きが結果として共変を選んでいて、それが文書化されていないという状態である(変性は書かれていないだけで、既に決まっている)。
なぜ間違えると静かに壊れるか
変性の誤りが厄介なのは、型検査を通ってしまうところにある。共変であるべき場所を不変にすれば、通るべき代入が拒否されるだけで、書き手は不便を感じてすぐ気づく。逆に不変であるべき場所を共変にすると、通ってはいけない代入が通る — そして壊れるのは代入の瞬間ではなく、ずっと後の書き込みの瞬間である。
一番有名な実例がJavaの配列である。Javaは配列を共変にしていて、要素型に部分型関係があれば配列にも伝わる(JLS §4.10.3)。可変なのに共変なので、上の算術に反する。その結果、書き込みの検査を実行時まで遅らせるしかなくなっている — 「配列の要素への代入は実行時に検査され、代入される値が要素型に代入互換でなければArrayStoreExceptionが投げられる」(JLS §10.5)。仕様書自身が挙げている例がこれである。
ColoredPoint[] cpa = new ColoredPoint[10];
Point[] pa = cpa; // 共変なので通る
pa[0] = new Point(); // 実行時に ArrayStoreException
型システムが止められなかったものを、実行時検査で受け止めている。共変にした代償を、全ての配列書き込みが恒久的に払っているという形である。
同じ判断が意図的に行われている例もある。TypeScriptは既定で引数の検査が双変(bivariant)で、strictFunctionTypesを付けると関数型の引数が反変になる。ただしこの旗はメソッド構文には適用されない — 「この機能の開発中に、DOMを含む本質的に安全でないクラス階層が多数見つかった。そのため、この設定は関数構文で書かれた関数にのみ適用され、メソッド構文のものには適用されない」(TSConfig reference)。既存コードとの兼ね合いで、不健全と分かった上で残している箇所である。
だから変性は、規則を書いておくだけでは足りず、性質として機械的に検査されるところまで持っていかないと守られない。部分型関係が公理化されていないと性質の破れを検出できないが反射性・推移性・反対称性について言っているのと同じことが、変性についても言える。
根拠の確度
三種の定義と位置による決定則は型理論の標準的な内容で、特定の実装に依存しない。引用した一次情報はいずれも原文で確認済み — 関数型の規則はSoftware Foundations(Pierceら)、Scalaの変性注釈と可変コレクションの件はTour of Scala、C#のin/outと位置制約はMicrosoft Learn、Javaのジェネリクス不変性はThe Java Tutorials、配列共変とArrayStoreExceptionはJLS §4.10.3 / §10.5、TypeScriptのstrictFunctionTypesはTSConfig reference。
いくつか確認できなかったことがあるので、断定を避けた箇所がある。
- JLSに「ジェネリクスは不変である」と書いた一文は見つからなかった。 JLSは§4.5.1で型引数の包含として構造的に表現しており、引用したのは平易に書いてあるJava Tutorialsの方である
- TAPLの
S-Arrowのページ番号は未確認。 規則そのものと「反変・共変」という言い回しはSoftware Foundations(同じPierceらの記述)で確認したが、書籍のページは参照していない IList<T>が共変にできない理由を「可変だから」と書いたMicrosoftの文は無い。 C#の文書は「共変の型は戻り値の位置にしか使えない」という規則の形で述べていて、可変性との結び付きを言葉で言っているのはScalaの文書の方である。本文もその通りに書き分けた
「可変コンテナは不変になる」は読み書き両方の位置に同じ型引数が現れる場合の帰結である。実際の言語では読み出し専用インタフェースを別に切って共変にする(IReadOnlyList<T>、List vs ListBuffer)、使用側で変性を指定する(Javaの? extends)などの手段があるので、「可変だから常に不変」ではなく「読み書き両方の位置に同じ型引数が現れるなら不変」が正確な言い方である。