はじめに
とあるTypeScriptのブログ記事を読んでいて「共変性」と「反変性」というキーワードを目の当たりにし、わからなかったので自分なりに調査し、メモとして残す。
目次
共変性、反変性とは
「共変性」と「反変性」とは、型システム全般における「型の代入可能性(subtyping)」に関する性質を指す用語。ある親子関係にある型(T
, U
)に対して、それらを利用する別の型(F<U>
, F<T>
)の関係性がどのようになっているか、を表している。
-
共変性(Covariance)
親子関係がそのまま保たれる性質。
例:T
がU
のサブタイプである場合、F<T>
もF<U>
のサブタイプになる。 -
反変性(Contravariance)
親子関係が逆転する性質。
例:T
がU
のサブタイプである場合、F<T>
がF<U>
のスーパータイプになる。
関数型における共変性と反変性
基本的にこの共変性と反変性という概念は関数に関する文脈でよく議論される。関数型では戻り値(出力)や引数(入力)に対して共変性と反変性が適用される。
以下の親子関係を持つ2つの型について考えることとする。
type Animal = { name: string };
type Lion = Animal & { roar: () => void };
この場合、Lion
は Animal
のサブタイプであり、その関係性を Lion <: Animal
と表現できる。
戻り値(出力): 共変性
関数の戻り値は、呼び出し元が期待する型に適合する必要があるため、共変性が適用される。
type Animal = { name: string };
type Lion = Animal & { roar: () => void };
function getLion(): Lion {
const lion: Lion = {
name: "Simba",
roar: () => {
console.log("Roar!");
}
};
return lion;
}
const getAnimal: () => Animal = getLion; // OK: 共変性
Lion
は Animal
のサブタイプなので、戻り値として Animal
を期待してその実体として Lion
が返ってきても問題ないため、() => Lion
は () => Animal
として扱うことができる。
Lion <: Animal
に対して () => Lion <: () => Animal
と、親子関係がそのままである性質を「共変性」と呼ぶ。
引数(入力): 反変性
関数の引数は、呼び出し元が渡す型に適合する必要があるため、反変性が適用される。
function feedAnimal(animal: Animal): void {
console.log(`Feeding ${animal.name}`);
}
const feedLion: (lion: Lion) => void = feedAnimal; // OK: 反変性
feedAnimal
は Animal
型を受け取る関数だが、その引数としてLion
型の実体が渡されても問題ないため、 Lion
型を受け取る関数として扱うことができる。
Lion <: Animal
に対して (Animal) => void <: (Lion) => void
と、親子関係が逆転する性質を「反変性」と呼ぶ。
この性質は、型システムが「サブタイプ多相(subtyping polymorphism)」をサポートしている言語に限り一般的に成り立つ。言語仕様や型システムの設計によっては、明示的な指定が必要な場合や適用範囲に制限がある場合も。
配列型における共変性と反変性
配列は「出力」と「入力」の両方の性質をあわせ持つため、その共変性・反変性に関する議論は、プログラミング言語の型システムにおいて重要なトピックであり、配列特有の問題をはらむ。
配列が共変性を持つ場合、次のような型の関係が成り立つ。
もし
T
がU
のサブタイプ(部分型)であるならば、T[]
はU[]
のサブタイプである。
つまり、ある型 T
が別の型 U
の一種である場合、T
型の配列は U
型の配列として扱うことができる、という性質である。
このように配列に対して共変性を持たせるかどうかはプログラミング言語によって扱いが異なる。健全性・厳密性をある程度犠牲にして利便性とのバランスを取る設計思想の元、TypeScriptではこれを認めている。
配列の共変性が引き起こす問題
配列の共変性を認めることで柔軟性が上がる一方で、型安全性が損なわれうる。
例えば、以下のコードにおいてコンパイルは通るものの実行時エラーが発生する。
type Animal = {
name: string;
};
type Lion = Animal & {
roar: () => void;
};
type Rabbit = Animal & {
hop: () => void;
};
// 共変性により、Lion[] 型は Animal[] 型として扱える
const lions: Lion[] = [{ name: "Simba", roar: () => console.log("Roar!")}];
const animals: Animal[] = lions; // 共変性により、これはOK
// RabbitオブジェクトをAnimal[]型の配列に追加する (コンパイルエラーにならない)
const rabbit: Rabbit = { name: "Bunny", hop: () => console.log("Hop!") };
animals.push(rabbit);
// Lion配列として扱われているため、すべての要素にroarメソッドがあると仮定される
lions.forEach(lion => lion.roar()); // 実行時エラー: Cannot read property 'roar' of undefined
このコードでは、animals
の実体は lions
だが、Animal[]
型の配列として扱われている。そのため、 Animal
型のサブタイプである Rabbit
型の実体を格納することができる。ここで、元の Lions
に対して Lion
型しか持たないメソッドを呼ぶことで、Rabbit
型の実体はそのメソッドを持たず実行時エラーとなる。
配列の共変性の問題を解決する方法
配列の共変性による型安全性の問題を解決するために、いくつかのアプローチがある。
- 配列を不変(イミュータブル)にする
配列を変更不可能にすることで、入力(書き込み)操作を禁止し、共変性による問題を回避。 - 配列を共変性ではなく不変性にする
不変性とは、T
がU
のサブタイプであっても、T[]
とU[]
の間にサブタイプ関係がないようなこと。
配列の共変性の実現のための実装に関して
Animal[]
型の変数に Lion[]
型の値を代入するためには Lion[] <: Animal[]
である必要がある。
TypeScriptの型システムは構造的なため、2つの型が親子関係にあるにはそれらに共通するプロパティについてもまた、親子関係である必要がある。
よって、Lion[]
と Animal[]
の対応するすべてのプロパティに関して親子関係にある、ということとなる。ここで、TypeScriptの配列型の indexOf
メソッドに関して
indexOf(searchElement: T, fromIndex?: number): number;
という実装になっているが、
(searchElement: Lion, fromIndex?: number): number <: (searchElement: Animal, fromIndex?: number): number
は先に言及した関数型の反変性から成り立たないはずである。そのため、素直な実装では配列の共変性は成り立たない。そこで、配列(とタプル)の場合には例外的に別の処理をすることで、TypeScriptでは無理やり共変性を実現している。