共変とか反変とか

共変

Java の型パラメータは共変では無いので、List<Number> に List<Integer> を代入できないのは有名。これを許容していないのは、型の安全性を保つために他ならないのだけど、配列は許容していたりする*1

  Number[] array = new Integer[1];
  array[0] = 0.1d;

ただ、Java の配列は共変なのだけど、型の安全性をコンパイル時に保障してくれないというおまけつき。実際に Integer の配列を Object[] の変数に代入して、Double とかを格納しようとすると、実行時に例外がでる。


Exception in thread "main" java.lang.ArrayStoreException: java.lang.Double
at CoVariantArray.main(CoVariantArray.java:4)


一方 Scala では、型パラメータに共変・反変を指定することが出来る。X[+T]のように + をつけた型パラメータは共変となり、- をつけた方は反変となる。List ではその要素型は +A とされて共変となっているため、List[AnyVal] 型の変数に List[Int] 型のインスタンスを代入することが可能だったりする。


scala> val list_Int: List[Int] = List(0, 1, 2)
list_Int: List[Int] = List(0, 1, 2)

scala> val list_AnyVal: List[AnyVal] = list_Int
list_AnyVal: List[AnyVal] = List(0, 1, 2)

scala>
((Scala 対話環境での実行例))


前述のとおり Java の型パラメータは共変では無いので、より広い範囲を扱う変数に代入したい場合、その型を List<? extends Number> というように、上限境界を指定することになる。

List<Integer> list_Int = new ArrayList<Integer>(Arrays.asList(0, 1, 2));
List<? extends Number> list_Number = list_Int;

Number number = list_Number.get(0); // 戻り値の型は ? extends Number なので、Number 型の変数に代入可能
System.out.println(number);

この場合、List が扱う型は Number ではなく、あくまで Number か Number を継承したなんらかの型ということになる。そして、この型が実際に何であったのかを意識しないため、get で値を取り出して Number 型の変数に格納することはできても、add で新たな値を追加することはできない。

list_Number.add((Number)3);

コンパイルすると、エラーとなる。


UpperBoundsList.java:11: シンボルを見つけられません。
シンボル: メソッド add(java.lang.Number)
場所 : java.util.List<capture#126 of ? extends java.lang.Number> の インタフェース
list_Number.add((Number)3);
^
エラー 1 個


同じようなケースであっても、Scala の場合は List がイミュータブルなこともうまく作用して、要素を追加した新たなリストを返すことができたりする。


scala> -1.0::list_AnyVal
res8: List[AnyVal] = List(-1.0, 0, 1, 2)

scala> "foo"::list_AnyVal
res9: List[Any] = List(foo, 0, 1, 2)

scala>

-1.0 を先頭に追加すると結果として得られるリストは List[AnyVal] 型だが、"foo" という文字列を先頭に追加した場合は List[Any] 型のリストが得られる。これは、-1.0 は AnyVal として扱えるため List[AnyVal] 型のままとなる。
一方、"foo" は文字列なので AnyVal として扱えないため、そのままでは要素を追加することができない。そこで、以下のように下限境界を指定した型パラメータを指定することで、AnyVal の親である Any 型として扱い、List[Any] 型のリストを結果として返すようになっている。

sealed abstract class List[+A] extends Seq[A] with Product {
  def ::[B >: A](x : B) : List[B]
}

反変

反変の場合、例えば Foo[Int] が親で Foo[AnyVal] が子という継承関係が成り立つ。共変に比べて直感的ではないので分かりづらい。
型パラメータが反変の場合、メソッド引数で利用することができる。というか、反変にしないとメソッド引数として利用できない*2。その反面、メソッドの戻り値型として使用することはできない。実際に使用されている例としては、Function トレイトの引数型が反変として定義されている。

trait Function1[-T1, +R] extends AnyRef {...}

このことにより、例えば (Int)=>Any を必要としている箇所に、(AnyVal)=>Any や (Any)=>Any の関数を渡すことができる。これは、引数型が AnyVal や Any であれば Int の値を扱えるということを表している。


scala> val func: (Int)=>Any = (x: AnyVal)=>x
func: (Int) => Any = <function>

scala> func(1)
res1: Any = 1

scala> val func: (Int)=>Any = (x: Any)=>x
func: (Int) => Any = <function>

scala> func(2)
res2: Any = 2

scala>

共変と反変の覚え方

メソッド呼び出し時に、型変換しつつ継承ツリーをたどっていくと考えると分かりやすい。このときの型変換ではアップキャストしか許容しない。例として、Function1[Int, Any] 型の変数に Function1[Any, Int] の関数を代入した場合を考える。


scala> val func: (Int)=>Any = (x: Any)=>x.hashCode
func: (Int) => Any = <function>

scala> func(0)
res2: Any = 0

scala>

func は Function1[Int, Any] なので、その引数は Int 型。よって func(0) の 0 は Int として扱われる。ただ、func に格納されている実際の関数は Function1[Any, Int] なので、引数 0 は Int → Any に型変換されて、実際の処理が呼び出される。
その後、Int 型の戻り値として x.hashCode が返される。func 変数の戻り値型は Any なので、Int → Any に型変換されて、呼び出し元に値を返すことになる。

0─┐      ┌→0
   ↓      |
 [(Int) => Any]
   |  △  ↑
   ↓  |  |
 [(Any) => Int]
   |      ↑
   ↓      |
   x.hashCode


Java では利用側にワイルドカードの使用を強制していたようなケースであっても、Scala では共変/反変を使うことでより分かりやすく記述することができる。また、上限境界/下限境界と組み合わせることで、より色々なことを表現できるのが素晴らしい!*3

*1:Java の配列は共変、型パラメータ導入以前に Generic なコードを書くために、配列を共変としたらしい。1.5 以降は型パラメータがあるので、配列が共変であることを利用することは少ないと思う。

*2:もちろん、型の安全性を保つため

*3:Java でも <U extends T> や、<? super T>はできる。でも、< U super T>はできない