今度こそ完全に理解する共変・反変
なんど聞いても覚えられない共変・反変
TypeScriptや型関連の話題で、まれによく出てくるのが変性の話です。
自分も何度も見聞きしてはいるものの、毎回のように理解した気になってはいたものの「人に聞かれたときに何も見ずに説明できる状態」にはなっていませんでした。
つまり全く理解できていなかったということなんですが、先日ようやく腹落ちしたので書き記しておきます。
一応、最後のほうでVariance Annotationsについても軽く触れます。
関数の例がよく出てくるが
よく目にするのが「関数の引数は反変で、戻り値は共変だが、TypeScriptだと引数は双変」みたいな説明です。
まあ、たしかに関数の引数には元の定義より広い型を与えれば当てはめられるし、戻り値はより狹い型にしておけば当てはめられるというのはわかります。
type F = (x: 'foo') => number;
type A = (x: string) => 100;
const a: A = x => 100;
const f: F = a;
// 以降、aが型Fとして扱われるが
// 引数stringには'foo'がくるので問題なし
// 戻り値numberには100がくるので問題なし
いや、「それはそう」なんですけども。
説明されればそのパターンで代入可能だと理解はできます。が、本質を掴んだ感覚は一切しません。
共変や反変、ひいては変性とはそもそも何かという理解に繋がらず、少し時間が経てば「どっちがどっちだっけ?」となるのが関の山です。
結局、シンプルに「共変ってなんですか?」と聞かれたときにスラスラ答えられないようでは、理解していないも同然でしょう。
ということで、今日は私が理解した内容を私なりに書き下してみます。
関数ではなく型コンストラクタとして見よ
まず、共変と反変という言葉を見たとき、なんとなく方向性が逆なのは字面からわかります。
すると、それらの概念が「何を何と比較したときに、どういう方向で見てどういう逆なのか」という疑問がわきます。
ここで問題なのは、関数には引数と戻り値、そして関数全体であわせて3種類も型が登場するので、着目対象がどれか分からなくなることです。
「困難は分割せよ」というデカルトの言葉に従いましょう。
なにはともあれ登場する型の数を減らすしかありません。
ここが核心です。
そもそも「関数」ではなく「型コンストラクタ」という括りで見ることで、よりシンプルな例に落とし込むことができます。
型コンストラクタとは
型コンストラクタとは、「型引数を受け取って新しい型を定義するもの」だと思えばよいです。
(ジェネリクスという言葉を知っている人は、ここではそう言い換えてもかまいません)
type MyArray<T> = Array<T>;
上記のMyArray
は、型引数T
を受け取って新しい型Array<T>
を定義しているので型コンストラクタです。
同様に、関数の型定義も型コンストラクタの一種です。
type MyFunc<S, T> = (x: S) => T;
上記のMyFunc
は、型引数S
とT
を受け取って新しい型(x: S) => T
を定義しています。
でも型コンストラクタとしては2引数なので、ちょっと発展形なんですよね。
だから変性という概念のコアを考えるには少し複雑すぎます。
1引数・共変
もっともシンプルな例から考えましょう。
type ReadonlyArray<T> = readonly T[];
type A = ReadonlyArray<string>;
type B = ReadonlyArray<'foo'>;
上記のAとBは、型コンストラクタReadonlyArray
の型引数にそれぞれstring
と"foo"
を当てはめたものです。
このとき、型の関係性は以下のようになります。
- 型引数の関係:
string
\supset "foo"
- 型全体の関係:
ReadonlyArray<string>
\supset ReadonlyArray<"foo">
この「型引数の関係性」と「型全体の関係性」のパターンに名前を付けたのが変性です。
すなわち、型コンストラクタの型引数に当てはめる型を変化させたときに、それぞれの型引数について、型全体で見た関係性がどう変化するかを短い言葉で表現できるようにするものです。
したがって、1引数なら変性は1つですが、2引数なら1つの型コンストラクタに対して変性は2つあるということになります。
なるほど関数の例がいきなり出てくると理解しにくいわけですね。
では、実際に型の関係性を見てみましょう。
型引数Tにstring
や"foo"
を当てはめたとき、全体の型ReadonlyArray<string>
とReadonlyArray<"foo">
の関係性に着目します。
const b: B = ['foo', 'foo'] as const;
const a: A = b; // Bの型をAに代入できる
つまり、「型引数の関係性」と「型全体の関係性」が同じ方向になるので「共変」と呼びます。
2引数・共変
次に、2引数の型コンストラクタに発展させてみましょう。まずは共変のまま2引数にする例を考えます。
type ReadonlyPair<S, T> = {
readonly fst: S;
readonly snd: T;
};
type A = ReadonlyPair<string, number>;
type B = ReadonlyPair<'foo', 100>;
const b: B = { fst: 'foo', snd: 100 };
const a: A = b; // Bの型をAに代入できる
さきほどと全く同じ理屈で共変であることがわかります。
2引数・非変
さて、ここまでわざわざreadonly
をつけてきたのは、外した場合は性質が変わるからです。
type MutablePair<S, T> = {
fst: S;
snd: T;
};
type A = MutablePair<string, number>;
type B = MutablePair<'foo', 100>;
const b: B = { fst: 'foo', snd: 100 };
const a: A = b; // Bの型をAに代入できる
// a.fstはstring型のため'bar'を代入できてしまう!
// しかし、b.fstは'foo'型のため整合性が崩れる
a.fst = 'bar';
つまり、mutableの場合は同じ共変に見えますが、実は書き換え内容によっては整合性が破綻する危険性があります。
このように型引数の関係性によらず代入すると破綻してしまうケースを、非変と呼びます。
実際、Scalaなどではそもそも非変だと代入した時点でコンパイルエラーとなります。
class Foo;
class Bar extends Foo;
// Arrayはmutableなコレクションのため、非変であり代入できない
val bars: Array[Bar] = Array(new Bar);
val foos: Array[Foo] = bars; // コンパイルエラー
// Listはimmutableなコレクションのため、共変であり代入できる
val barList: List[Bar] = List(new Bar);
val fooList: List[Foo] = barList; // 代入可能
非変の妥協
しかし、先ほど示したTypeScriptコードではmutableな配列であっても代入が許容されていました。
代入不可とするとJavaScript資産の移植が困難になるためです。
つまり型システムの健全性を妥協して、共変として扱われているということに注意が必要です。
const a: Array<'foo'> = ['foo', 'foo'];
const b: Array<string> = a; // 代入が許される
// この操作は許されるが、aが['bar', 'foo']となってしまう
b[0] = 'bar';
本来、mutableな配列は共変ではなく、非変です。
しかし、TypeScriptではこれを共変として扱うため型システムの「健全性」が損なわれています。
TypeScriptでは、このように「健全性」を犠牲にして実用性を優先にしている部分もあります。
1引数・反変
さて、次にお待ちかねの反変も見てみましょう。
type VoidFunc<T> = (x: T) => void;
type A = VoidFunc<string>;
type B = VoidFunc<'foo'>;
const b: B = (x: 'foo') => console.log(x);
const a: A = b; // 先ほどまでと異なりこれは危険
// この呼び出しはエラーになる
a('bar');
const aa: A = (x: string) => console.log(x);
const bb: B = aa; // Aの型をBに代入できる
// この呼び出しはエラーにならない
bb('foo');
さて、上記のように反変の場合は、型引数の関係性が逆方向になります。
- 型引数の関係性:
string
\supset "foo"
- 型全体の関係性:
VoidFunc<string>
\subset VoidFunc<"foo">
関係性が逆になるので「反変」と呼びます。
文字で見ると直感的には分かりにくいですが、
-
(x: string) => void
は任意のstring
を受け取れる必要がある -
(x: "foo") => void
は"foo"
さえ受け取れればよい
つまり、VoidFunc<"foo">
のほうが作るのが簡単で型として広く、より作るのが難しいVoidFunc<string>
のほうが型として狭いです。
どうしても「引数の型」の広さに意識が引っ張られがちですが、より狭い引数のほうが関数としては一部のケースのみを実装すればよいので、他のより汎用的な関数と入れ替えやすい広い型ということになります。
あくまで代入が許されるかどうかに着目しましょう。
const a: 'foo' = 'foo';
const b: string = a;
// 引数の関係性と逆になっている
const bFn: (x: string) => void = (x: string) => console.log(x);
const aFn: (x: 'foo') => void = bFn;
2引数・共変&反変
さて、ようやく最初の例にたどり着きました。
type MyFunc<S, T> = (x: S) => T;
type A = MyFunc<string, 100>;
type B = MyFunc<'foo', number>;
const a: A = x => 100;
const b: B = a; // Aの型をBに代入できる
// 問題なく呼び出せる
const result = a('foo'); // 100
これまでの組み合わせですね。引数については反変で、戻り値については共変です。
双変の妥協
型の整合性だけを考えると、関数の引数は反変であってしかるべきですが、TypeScriptでは配列の共変性と同様に、型の健全性を犠牲にして関数やメソッドの引数を双変(共変かつ反変)としています。
(ただし、strictFunctionTypesが有効な場合は、関数は反変として扱われます。また、その場合もメソッドは双変のままです。)
先ほど、TypeScriptでは配列を共変とする妥協をしていると述べました。
まず、"foo"
はstring
に代入可能です。
- 反変性から、
(x: string) => void
は(x: "foo") => void
に代入可能です。 - 逆に、
(x: "foo") => void
は(x: string) => void
に代入できません(できるなら反変ではなく双変です)。
次に、配列の共変性から"foo"[]
はstring[]
に代入可能です。ここで問題が生じます。
代入可能ということは、2種類の配列が持つ各メソッドにも互換性があるということです(代入したあとに特定のメソッドが使えないなら、代入したことになりません)。
配列T[]
のpushメソッドは(x: T) => number
ですから、
-
"foo"[]
がstring[]
に代入可能であるならば、 -
(x: "foo") => number
も(x: string) => number
に代入可能でなければなりません。
しかし、反変では(x: "foo") => number
を(x: string) => number
に代入できないため矛盾が生じます。
したがって、配列を共変にしようとすると、メソッドの引数には双変性が必要ということがわかります。
配列の共変性を保つために犠牲にした型の健全性ですが、その綻びは引数の双変性として表れているといえるでしょう。
参考:変性指定
たとえばScalaでは型引数について変性指定が可能です。
// 型引数に+や-を付けることで、共変(+)や反変(-)を明示できる
trait Foo[-S, +T] {
def apply(x: S): T
}
Scalaは公称型の言語であり、型引数の変性を指定しない場合はデフォルトで非変となります。
構造的部分型を採用するTypeScriptとは異なり、Scalaでは型構造が一致していても、変性が明示されていなければ自動的なアップキャストやダウンキャストは行われません。
そのため、上記のようにFoo
と名付けられたジェネリック型の中で、引数や戻り値の型に関するサブタイプ関係を成立させるためには、変性を明示的に指定する必要があります。
実はTypeScriptにも変性に関する機能があります。Variance Annotationsと呼ばれています。
こんな機能があることを知っている人のほうが少なそうです。
type MethodCase<out T> {
apply(x: T): void;
}
type FnCase<out T> {
apply: (x: T) => void; // これはエラー
}
このとき、in
はinput、つまりメソッドの引数に相当する「反変」を表します。
反対にout
はoutput、つまりメソッドの戻り値に相当する「共変」を表します。
既述の通り、関数の引数は反変ですが、上記のFnCase
では反変の引数に対してout
で共変を指定しているのでエラーになります。
一方で、双変のMethodCase
では引数は共変でもあるため、out
を指定してもエラーになりません。
上記のように型引数の前にin
やout
を付けることで、変性のアノテーションができます。
ただ、この機能はあくまでアノテーションであり、型の挙動を変更するのものではありません。
Variance annotations don’t change structural behavior and are only consulted in specific situations
TypeScriptが正確に変性を推論できないような、限られた循環型のようなケースでのみ使うことが推奨されています。
Don’t use variance annotations to try to “force” a particular variance
まとめ
- 共変・反変の話は、型コンストラクタという文脈で考えるとシンプルになる
- 変性とは「型引数の関係性」に着目したとき「型全体の関係性」がどうなっているかを表す
- TypeScriptは、型の健全性より利便性を重視しているため以下の点が特徴的である
- 配列がmutableであっても共変として扱われる
- 関数の引数が反変ではなく双変である(しかも関数とメソッドで挙動が微妙に異なる)
Discussion