🧪

筒で理解する反変・共変

2024/10/03に公開

この記事では、Java、Scala、TypeScriptなど、サブタイピング(subtyping)をサポートする言語であれば間違いなくサポートしているであろう「反変(contravariant)」・「共変(covariant)」について、視覚的なアナロジーを用いつつ解説したいと思います。コード例を含め全てTypeScriptを前提とした説明ですが、同様の機能を持った言語であれば概ね同じことが言えるはずです。

そもそもサブタイピングとは

サブタイピングとは、型と型との間にサブタイプ(subtype)・スーパータイプ(supertype)という関係を定めて、スーパータイプである型の代わりとして、サブタイプである型を利用できるようにする仕組みです。

例えば、TypeScriptではstring型はObject型のサブタイプであるので、次のようにObject型の変数にstring型の値を代入することができます:

const o1: Object = "string";

一方、次のようにObject型の値をstring型の変数に代入することはできません。

const o2: string = "string" as Object;
// 型エラー: Type 'Object' is not assignable to type 'string'.

実際の値としては見るからに文字列型の値であったとしても、as演算子で強引にObject型としてマークされた値は、string型の値として扱われないのです。

ここまでのコードをTypeScript Playgroundで試す

反変・共変とは

反変と共変は、関数やリストのような、型引数を持った複雑な型におけるサブタイプ・スーパータイプの関係を説明するために使う用語です。型引数を受け取る型が具体的な型を受け取ったとき、他の型とどのようにサブタイプ・スーパータイプの関係を成すかを表します。

共変1: 単純なプロパティーとして備えている場合

ひとまず比較的単純な、共変の例を示しましょう。

interface Covariant1<T> {
  value: T;
}

上記のCovariant1という型は、型引数Tに対して共変です。共変であるということは、Covariant1に適当な型を渡して作った型同士 --- 例えばCovariant1<string>Covariant1<Object> --- のサブタイプ・スーパータイプの関係を、渡した型同士の関係から変えないことを表します。そのため、例に挙げたCovariant1<string>Covariant1<Object>では、stringObjectのサブタイプなのでCovariant1<string>Covariant1<Object>のサブタイプ、ということになります。

したがって、次のようにCovariant1<Object>型の変数にCovariant1<string>型の値を代入することができます:

const cov1: Covariant1<Object> = { value: "string" };

そしてその逆、Covariant1<string>型の変数にCovariant1<Object>型の値を代入することはできません:

const cov1Bad: Covariant1<string> = { value: "string" } as Covariant1<Object>;
// 型エラー: Type 'Covariant1<Object>' is not assignable to type 'Covariant1<string>'.
//             Type 'Object' is not assignable to type 'string'.

ここまでのコードをTypeScript Playgroundで試す

このことを、サブタイプ・スーパータイプの関係より単純な、集合の大小(取り得る値の数)の関係で例えてみます。例えばObjectstringのスーパータイプなので、Objectstringより大きい、と考えます。Objectには当然string以外の型の値が含まれますから、取り得る値の数は大きいと言えますね:

Object > string

サブタイプ・スーパータイプの関係は、実際のところ全順序な関係ではない、つまりサブタイプでもスーパータイプでもどちらとも言えない場合があるので、単純な「大小」による例えは正確ではありませんが、この記事ではどちらかに該当するケースのみを取り上げるので問題ありません。

この例えにしたがってCovariant1<Object>Covariant1<string>型の関係を示すと、次のようなイメージになります:

Covariant1<Object> > Covariant1<string>

型引数として渡したObjectstringCovariant1の「付属品」として付いているイメージで、その「付属品」の大きさによってサブタイプ・スーパータイプの関係を定めています。

変数は、自身に割り当てられた型[1]の「大きさ」にフィットする、器のような役割を果たします。代入しようとしている値の型が、変数に割り当てられた型より「小さい(あるいは同じ大きさの)」場合のみ代入できるわけです。ただし、Covariant1<Object>のような「付属品」が付いた型の場合は、「付属品」と「付属品」を付けている型両方の型が「小さく(あるいは同じ大きさで)」なければなりません。

共変2: 関数の戻り値として返す場合

Covariant1のように、型引数の型を単純にプロパティーとして備えている場合の他、型引数の型を関数の戻り値として備えている場合も、共変となります。例えば、下記のCovariant2は、型引数TをプロパティーreturnValue関数の戻り値としているので、Covariant2は型引数Tに対して共変です:

interface Covariant2<T> {
  returnValue: (value: any) => T;
}

Covariant1と同様、Covariant2<Object>型の変数にCovariant2<string>型の値を代入することができますし、その逆はできません:

const cov2: Covariant2<Object> =
  { returnValue: (_value: any) => "string" };
const cov2Bad: Covariant2<string> =
  { returnValue: (_value: any) => "string" } as Covariant2<Object>;
// 型エラー: Type 'Covariant2<Object>' is not assignable to type 'Covariant2<string>'.
//             Type 'Object' is not assignable to type 'string'.

ここまでのコードをTypeScript Playgroundで試す

これも大小関係で例えてみましょう。関数は値を受け取って値を返すものですから、値を入れると値が出てくる、筒のようなもので例えましょう。引数や戻り値における型の違いは、筒の入り口と出口の大きさの違いに相当するわけです。するとCovariant2<Object>Covariant2<string>は次のようにイメージされます:

Covariant2<Object> > Covariant2<string>

※ここでは引数の型は関係がないので、引数に当たる、筒の左側の部分は省略しています。

なぜこのような大小関係になるのでしょうか?実際にCovariant2<string>のような、関数を備えた型のオブジェクトを使う側になって考えてみてください。Covariant2<string>、つまりstring型の値を返す関数を持ったオブジェクトのつもりで、Covariant2<Object>、つまりstring型よりも「大きい」値を返す関数を持ったオブジェクトを使うと、次のような問題が起こり得るからです:

Covariant2<string>のつもりでCovariant2<Object>の関数を呼び出すと...

Objectのうち、string以外の値が返されてしまうことが!

図のとおり、Covariant2<string>を使うつもりでCovariant2<Object>に含まれる関数を呼び出すと、stringには含まれないけどObjectには含まれる型 --- 例えばnumber --- の値が出てきてしまう恐れがあります。Objectstringよりも集合として大きい、すなわち取り得る値の数が多いので、stringを返す関数のつもりでObjectを返す関数を扱ってしまうと、想定外の結果が返ってしまうことがあるのです。

以上の理由により、Covariant2<Object>Covariant2<string>のスーパータイプである、言い換えると、Covariant2<Object>の代わりとしてCovariant2<string>は利用できるが、その逆、Covariant2<string>の代わりとしてCovariant2<Object>を利用することはできない、と言えます。

反変: 関数の引数として受け取る場合

「反変」は、「反」という字が含まれていることから察せられる通り、「共変」とは反対の方向に振る舞います。例を見てみましょう:

interface Contravariant<T> {
  receiveValue: (value: T) => void;
}

Contravariant型は、型引数Tに対して反変です。receiveValueプロパティーのように、型引数の型を関数の引数とする関数を含んでいる場合、対象の型引数に対して反変になります。反変であるということは、Contravariantに適当な型を渡して作った型同士 --- 例えばContravariant<string>Contravariant<Object> --- のサブタイプ・スーパータイプの関係を、渡した型同士の関係から反対にすることを表します。

例えばObjectstringのスーパータイプなので、それぞれをContravariantに渡したContravariant<string>Contravariant<Object>におけるサブタイプ・スーパータイプの関係はその逆、すなわちContravariant<string>Contravariant<Object>のスーパータイプ、ということになります。

コードを書いてtscに聞いてみても、確かにそうなるようです:

const contra: Contravariant<string> =
  { receiveValue: (_value: Object) => {} };
const contraBad: Contravariant<Object> =
  { receiveValue: (_value: string) => {} };
// 型エラー:  Type '(_value: string) => void' is not assignable to type '(value: Object) => void'.
//              Types of parameters '_value' and 'value' are incompatible.
//                Type 'Object' is not assignable to type 'string'. [2322]

ここまでのコードをTypeScript Playgroundで試す

なぜ関係が逆になるという、一見直感に反する結果となるのでしょうか?ここでも、関数を筒のようなもので例えて説明します。

Contravariant<string> > Contravariant<Object>

今度は関数の引数が型引数の型で置き換えられるので、筒の左側の部分に型を置きました。

こちらも実際にContravariant<Object>のような、Object型の値を受け取る関数を持ったオブジェクトを使う立場に立って考えます。Contravariant<Object>、つまりObject型の値を受け取る関数を含んだオブジェクトのつもりで、Contravariant<string>、つまりObject型よりも「小さい」値のみを受け取る関数を持ったオブジェクトを扱うと、次のような問題が起こり得るからです:

Contravariant<Object>のつもりでContravariant<string>の関数を呼び出すと...

Objectのうち、string以外の値を渡してしまうことが!

図のように、Contravariant<Object>のつもりでContravariant<string>に含まれている関数を使用すると、stringには含まれないけどObjectには含まれる型 --- 例えばnumber --- の値をうっかり渡してしまう恐れがあります。Contravariant<string>の関数はstringのことしか想定していないので、それ以外の値を渡すと当然問題があります。したがって、Contravariant<string>Contravariant<Object>の代わりに用いるのは型エラーとなります。

まとめ

反変・共変とは:

  • ある型が型引数の型に対して共変である場合は、渡した型引数間のサブタイプ・スーパータイプの関係を変えない
  • ある型が型引数の型に対して反変である場合は、渡した型引数間のサブタイプ・スーパータイプの関係を反対にする

どういう型が、ある型引数に対して反変・共変になるか:

  • 型引数の型が、関数の戻り値(あるいは関数でない、単純なプロパティー)として使われていれば共変となり、
  • 型引数の型が、関数の引数として使われていれば反変となる

この違いは、関数を使う側と関数自身、どちらが変数(引数)に代入する値を決める立場にあるか、という点にあります。関数の戻り値は、関数自身が変数に代入する値を決める一方、関数の引数は、関数を使う側が引数に代入する値を決めるからです。変数に代入する値の型は、変数の型のサブタイプでなければならないので、代入する値の型(引数として渡す型)がサブタイプとなるように定められているのです。

参考文献(本文中で言及しなかったもののみ)

脚注
  1. TypeScriptを含め、昨今のプログラミング言語では推論された式の型に合わせて変数の型が決まる場合の方が多いですが、この記事では説明しやすさのために、全ての変数に予め型を設定しておきます。 ↩︎

GitHubで編集を提案

Discussion