Scalaの総称クラスと総称メソッド-共変-

いわゆるGenericsのメカニズムについては、ごく基本的な部分についてはJavaと同じように見える。型パラメータの境界(親の型としてXXXを持ってないといけないとか、子の型としてXXXを持ってないといけないとか)についても同じ。大きく違うのは、型パラメータの継承関係と、パラメータバウンドされた総称クラス(以下、面倒なので"総称クラス"と書く)自体の継承関係との関係、すなわち共変(Co-Variant)に関する部分。

といっても、共変自体にあまり詳しくないので、しばらく共変について勉強してみる。

共変
調べても定義らしい定義が出てこなくて困った。とりあえずは関連ありそうな記事として以下のようなものがあった。
このあたりの記述を参考にすると、共変というのは「型パラメータの継承関係(代替性)が、総称クラスにまで伝搬するかどうかを表す性質」と考えてよさそうだ。関数型言語のページとか見てるともう少し広い考えのような気もするけど、とりあえず今のところはこういう理解で進んでみる。
それにしても、JavaのGenricsは既にものすごくややこしい…ワイルドカード、キャプチャ、境界、このあたりを正確に理解している人間はいったいどれくらいいるのだろうか。

Variance-Annotation
訳し方がわからなかったのでそのままのタイトルにしてしまった。これは要するに、共変に関する性質を変更できる注釈(Annotation)のこと。どのように変更できるかというと…
  • Non-Variant(共変しない(Javaと同じ))
  • Co-Variant(共変する)
  • Contra-Variant(マイナス方向に共変する)
では、順番に。

Non-Variant
これは、Stack[AnyRef]型の変数にStack[String]型のオブジェクトを代入しようとするとエラーになるということ。Scalaの総称クラスはデフォルトでは共変しない。JavaのGenericsも同様。実際にこんな風に書いてみると…
package generics

abstract class Stack[A] {
  def push(x: A): Stack[A] = new NonEmptyStack[A](x, this)
  def isEmpty: Boolean
  def top: A
  def pop: Stack[A]
}

class EmptyStack[A] extends Stack[A] {
  def isEmpty = true
  def top = error("EmptyStack.top")
  def pop = error("EmptyStack.pop")
}

class NonEmptyStack[A](elem: A, rest: Stack[A]) extends Stack[A] {
  def isEmpty = false
  def top = elem
  def pop = rest
}
で、まずは以下のように書いてみる。
  val stack : Stack[String] = new EmptyStack[String]
  println(stack.push("aiueo").top)
当然コンパイルは通る。型パラメータは同じなんだから当然。で、実行すると…
  aiueo
こうなる。今度はこんな風に書いてみると…
  val stack : Stack[AnyRef] = new EmptyStack[String]
今度はコンパイルエラーになり、以下のようなエラーメッセージが出る。
  type mismatch;
  found : generics.EmptyStack[String]
  required: generics.Stack[AnyRef]
型パラメータどうしは継承関係にあるけど、総称クラスどうしは継承関係にはならないことがわかる。ちなみに、このように書くと…。
  val stack = new EmptyStack[AnyRef]
  println(stack.push("aiueo").top)
当たり前だがコンパイルは通り、以下のように結果が出力される。
  aiueo
この場合は型推論が働いて、stackの型はEmptyStack[AnyRef]になるので型の不一致は起きない。"aiueo"はAnyRef型のサブタイプなので、メソッド呼び出しに関しても問題なし。これが、共変なしの状態。次。

Co-Variant
繰り返しになるがこれは、「型パラメータが継承関係にあれば、総称クラスも継承関係を持つ」というもの。具体的には、Stack[AnyRef]がStack[String]のスーパータイプになるということ。型パラメータにAnnotation「+」を付加することで、共変になる。Javaで言うと、配列が共変といえる。Object[] array = new String[5]はOKでしょ?じゃ、Scalaのサンプルいきます。

まずは、Stackの定義をこんな風に書き換えてみる。といっても、クラス定義の型パラメータに「+」をつけただけ。
abstract class Stack[+A] {
  def push(x: A): Stack[A] = new NonEmptyStack[A](x, this)
  def isEmpty: Boolean
  def top: A
  def pop: Stack[A]
}
すると、
  covariant type A occurs in contravariant position in type A of value x
こんなコンパイルエラーが出る。なんのこっちゃ?という感じだが、少し説明を。Javaの配列が共変だということは既に触れたわけだけど、Javaの配列でこーんなことをすると、コンパイルエラーは起きず、実行時例外が発生する。
  Object[] array = new String[5];
  array[0] = 0;

  Exception in thread "main" java.lang.ArrayStoreException: java.lang.Integer
at generics.ArraySample.main(ArraySample.java:6)
仮にこのコードが実行可能だとすると、arrayの実体であるString配列にintを格納することになり、型システムが崩れてしまう。Javaではこれを、コンパイル時のチェックではなく実行時チェックによって防いでいるためRuntimeExceptionが発生するわけだが、Scalaはコンパイル時にチェックするようになっている。言語仕様的には、共変の型を配置できる場所が決まっており、これはCo-Variantポジションと言うらしい。Co-Variantポジションは…
  • 変数の型
  • メソッドの戻り値の型
  • 他のcovariant型への引数
のいずれかであると決められており、これ以外の場所にCo-Variantな変数を書くとコンパイルエラーになる。さきほどの例では、def push(x: A): Stack[A] で引数の型としてCo-Variantの変数が書かれていたためにコンパイルエラーになったというわけ。引数でわたってくるデータは内部状態を変更する可能性があるので、禁止しているわけだ。うーん、保守的。でもこれが、Co-Variantのデフォルトの動作。
実際にはpushは内部を変更するわけではなく、新しいStackを作って返すだけなので、エラーになるのは納得いかない。これは、Lower Boundsで解決方法が書かれるらしいので、とりあえずは先まわし。

さて、気をとりなおして…Stackからpushメソッドを削除し、以下のようにする。もうStackでもなんでもないが、型システムを理解するのが目的なのでこれはこれでよしとする。
abstract class Stack[+A] {
  def isEmpty: Boolean
  def top: A
  def pop: Stack[A]
}

class EmptyStack[A] extends Stack[A] {
  def isEmpty = true
  def top = error("EmptyStack.top")
  def pop = error("EmptyStack.pop")
}

class NonEmptyStack[A](elem: A, rest: Stack[A]) extends Stack[A] {
  def isEmpty = false
  def top = elem
  def pop = rest
}
使う側のコード。
  val stack : Stack[AnyRef] = new NonEmptyStack[String]("aiueo", new EmptyStack[String])
  println(stack.top)
これは、コンパイルを通る。Stack[AnyRef]がNotEmptyStack[String]のスーパータイプになっていることがわかる。実行すると…
  aiueo
正常に出力される。最後、Contra-Variant。

Contra-Variant
これは今までの常識を超えた感じのもので、共変の反対になる。何が反対かというと継承階層の上下で、型パラメータがスーパータイプのものは総称クラスでサブタイプになり、型パラメータがサブタイプのものは総称クラスでスーパータイプになる。思わずポルナレフのAAが出てきそうになるが、例を出すとわかりやすい。Contra-Variantとは…
  val stack : Stack[String] = new Stack[AnyRef]
このコンパイルが通るようなもの。逆に、
  val stack : Stack[AnyRef] = new Stack[String]
これはコンパイルエラーになる。
  type mismatch;
  found : generics.contra_variant.EmptyStack[String]
  required: generics.contra_variant.Stack[AnyRef] StackMain.scala
どこで使うんでしょ、これw Contra-Variantな型にするためには、型パラメータに「-」をつける。Co-Variantと同様、Non-VariantなStackを改良してみる。同じく、クラス定義の型パラメータに「-」をつけただけ。
abstract class Stack[-A] {
  def push(x: A): Stack[A] = new NonEmptyStack[A](x, this)
  def isEmpty: Boolean
  def top: A
  def pop: Stack[A]
}

class EmptyStack[A] extends Stack[A] {
  def isEmpty = true
  def top = error("EmptyStack.top")
  def pop = error("EmptyStack.pop")
}

class NonEmptyStack[A](elem: A, rest: Stack[A]) extends Stack[A] {
  def isEmpty = false
  def top = elem
  def pop = rest
}
すると、予想通り(?)コンパイルエラーになる。
  contravariant type A occurs in covariant position in type => A of method top
もうわかったと思うが、Contra-Variantな型もCo-Variantな型と同様に記述できる場所が決まっており、これはContra-Variantポジションと言うようだ。サンプルのメッセージとこれまでの説明からすると、恐らくこれはCo-Variantと反対で、引数パラメータの中にしか許されない…のかな。ScalaByExampleでは深く触れられていないのでここでは深入りしないことにする。topを削除して…
abstract class Stack[-A] {
  def push(x: A): Stack[A] = new NonEmptyStack[A](x, this)
  def isEmpty: Boolean
  def pop: Stack[A]
}

class EmptyStack[A] extends Stack[A] {
  def isEmpty = true
  def pop = error("EmptyStack.pop")
}

class NonEmptyStack[A](elem: A, rest: Stack[A]) extends Stack[A] {
  def isEmpty = false
  def pop = rest
}
これでコンパイルは通った。使う側のコードは…
  val stack : Stack[AnyRef] = new EmptyStack[String]
まずはこう書くと、コンパイルエラーになる。まぁ予想どおり。で、こうすると…
  val stack : Stack[String] = new EmptyStack[AnyRef]
コンパイルを通る。やっぱり妙な感じ。

コメント