Scalaの型システム
単純な型パラメータ
Javaでは以下のように書ける。
class Animal { } class Fish extends Animal { } class Zoo<T> { public void add(T animal) { } } ... Zoo<Animal>zoo = new Zoo<Animal>(); zoo.add(new Fish()); ...
これと等価な(たぶん)Scalaコードは以下。
class Animal class Fish extends Animal; class Zoo[T] { def add(a:T) { } } val zoo = new Zoo[Animal]; zoo add new Fish
上限型境界
上のコードのままだと、Animal以外のZooの作成が可能になってしまう。
Zoo<Integer>zooInt = new Zoo<Integer>(); // 可能だが意味不明
また、そもそもこのままではZooクラスの中でAnimalのメソッドを呼び出せない。Tはどんな型でも可能だからである。 Zooの中でAnimalのメソッドを(キャスト無しに)呼び出すためには、TがAnimalクラスあるいはそのサブクラスであることを明示する必要がある。
そこで上限型境界指定を行う。Javaでは以下のようなものだった
class Zoo<T extends Animal> { ... } Zoo<Fish>fishZoo = new Zoo<Fish>(); // OK Zoo<Integer>intZoo = new Zoo<Integer>(); // エラー
Scalaでは以下になる
class Zoo[T <: Animal] { ... } .... val fishZoo = new Zoo[Fish]; // OK val intZoo = new Zoo[Int]; // エラー
変位指定の書き方
「変位」とは何か、JavaとScalaのその違いは何かはおいておき、ここではその書き方のみに注目してみる。
Javaの変位指定
Javaでは「その型を使う時に」「? extends Animal」などと記述して変位を指定する。 たとえば、
class Animal { } class Fish extends Animal { } class Zoo<T extends Animal> { ... } ... Zoo<Fish>fishZoo = new Zoo<Fish>(); Zoo<Animal>animalZoo1 = fishZoo; // エラー Zoo<? extends Animal>animalZoo2 = fishZoo; // ※OK ...
上の例でfishZooはanimalZoo1に代入することはできない(コンパイルエラー)。animalZoo2では、<? extends Animal>として変位を指定している。
Scalaの変位指定
ScalaではJavaのやり方とはまったく異なる。Javaでは上のように、「呼び出しサイト」で定義するが、Scalaでは「宣言サイト」で定義する(これらの言葉は、書籍「プログラミングScala」から拝借)。
以下はだめだが、
class Animal class Fish extends Animal; class Zoo[T <: Animal] { } val fishZoo = new Zoo[Fish]; val animalZoo: Zoo[Animal] = fishZoo;
以下はOK
class Animal class Fish extends Animal; class Zoo[+T <: Animal] { // Tの前に+をつけただけ } val fishZoo = new Zoo[Fish]; val animalZoo: Zoo[Animal] = fishZoo;
上限型境界と変位指定の記法
Javaでは、上限型境界も変位指定も、同じような「何々 extends 何々」という記法を用いるのだが、これらの機能はまったく異なるため、混乱の元となっている(と思われる、私も混乱した)。Scalaではこれを別の記法にして、明確に区別している。
さらに、JavaとScalaでの変位指定を行う場所に注目しなければならない。Javaでは、そのクラスを「使う側」が「使うたび」に指定しなければならないのだが、Scalaでは「定義する側」が一度だけ指定すればよい。この方が使う側にとっては楽である。
変位とは何か
それでは「変位」とはいったい何だろうか。よく出される例は、Javaの配列である(書籍「Scalaスケーラブルプログラミング」でもこの例が使われている)。
Javaの配列は通常のオブジェクトとは異なるのだが、ここでは仮に、型パラメータ指定の可能な通常のオブジェクトと考えてみる。 型パラメータを指定して配列を作成し、その要素を設定するにはこうする。
String[]a = new String[100]; a[0] = "abc"; a[1] = "xyz";
配列には、そもそもObject以下のすべてのクラスのオブジェクトが格納できるのだが、ここでは型パラメータとしてStringを指定したため、この配列にはStringしか格納できない。 ところが、配列を操作するユーティリティはObjectの配列を対象としている。たとえば、 System.arraycopyの定義は、
arraycopy(Object[]src, int srcPos, Object[]dest, int destPos, int length)
となっており、当然型が異なる。
もし型を厳格に守るならString配列をObject配列として扱うことはできない。その一方で、あらゆる型の配列について、それぞれのarraycopyを記述するのは無駄である。このため、Javaの設計では、配列に対して何の指定もなしに「変位」できるようにしてしまった(この場合は「共変」というらしい)。
ところが、その一方で、この便利な機能によっておかしなことも起こりかねない。 例えば、次のようなメソッドをString配列に対して適用すると、
public static void badMethod(Object[]array) { array[0] = new Integer(0); }
RuntimeExceptionが発生する。配列の要素はStringであるはずなのに、Integerを代入しようとしたからだ。
変位の必要性と難しさ
変位(この場合は「共変」)が必要なのは明らかである。変位がなければ、様々なオブジェクトについて一つのコードを適用することができなくなる。つまり、パラメータとした型について、専用のメソッドを作成しなければならない。
ところがその一方で、安易に変位を行うと、型システムを破ってしまい、やすやすとランタイム例外が発生することになる。
Scalaの解決策と制限
本来は、メソッドの処理内容によって、危険であるか安全であるかが決定するはずである。先のbadMethodは危険であるが、arraycopyは安全である。
ところが、Scalaではコンパイラが一律に安全か危険かを判断し、危険な場合はエラーとしてしまう。例えば、以下ようなクラスを書くとエラーになる。
class Animal class Fish extends Animal class Bird extends Animal class Zoo[+T <: Animal] { // Tは共変的パラメータ def add(a:T) {} // ここでエラー! }
たしかに、コンパイルが通ってしまうと以下のような操作ができる可能性がある。
val fishZoo = new Zoo[Fish] val animalZoo:Zoo[Animal] = fishZoo animalZoo add new Bird
実際には、addメソッドの中で危険な操作をしなければ大丈夫なはずだが、Scalaコンパイラは単に「共変的パラメータが反変的位置(メソッド引数)」に現れただけでエラーとする。
※コンパイラがどのような判断をするかは、日本語訳「Scalaスケーラブルプログラミング」の360ページあたりに解説があるのだが、この説明はちんぷんかんぷんである。現時点では「共変的パラメータはメソッド引数には使えない」とだけ覚えておく。
Zooクラスには、どうしてもT型のパラメータを受け入れるメソッドを書きたい(つまり、水族館には魚を入れるメソッド、野鳥園には鳥を入れるメソッドが必要)ので、ZooのTパラメータは共変にはできないということになる。
つまり、なんでもかんでも一律に共変にできるわけではなく、クラスによっては共変にできないケースが出てくるので、これはクラス設計上の制限となることに注意しなければならない。
非変、共変、反変の説明
変位(variance)には、非変(invariant)、共変(covariant)、反変(contravariant)という種類がある。 単純に言えば、「中身オブジェクトの型によって入れ物オブジェクトの型がどのように変化しうるか」を示す。
非変
中身オブジェクトの型によって、入れ物オブジェクトの型は変化し得ない。 例えばここに、冷蔵庫保存用のプラスチック製容器があるとする。これに、「野菜用」というラベルを貼る。これでこの容器は野菜専用の容器となり、野菜の出し入れは自由にできる。
この容器は、野菜以外のものを出し入れする可能性のあるところでは使うことはできない。例えば、「食べ物」を期待しているところでは使用できない。肉を入れられてしまう可能性もあるからだ。
ただし、出し入れするものは、野菜なら何でもよい。トマトでもニンジンでも。
Scalaではこんな感じ。
class Container[T] { // 変位しないので+も-も無し def add(v: T): Unit = {} def removeOne: T = {} } .... val vegetableContainer = new Container[Vegetable]; user.use(vegetableContainer) // useはuse(c: Container[Vegetable])以外にはできない
共変
自販機という入れ物を考えてみる。この容器は(普通の人は)入れることはできず、出すことしかできないものとする。 この容器に「野菜」というラベルを貼り、野菜専用にする。
この野菜自販機は「食べ物自販機」としても使うことができる。食べ物自販機を期待しているところに、野菜自販機を持っていってもよい。この「食べ物自販機」からはジュースや卵は出てこず、野菜しか出てこないが、それで問題ない。
つまり、「食べ物<--野菜」という継承関係があるなら、「自販機(食べ物)<--自販機(野菜)」という継承関係になる (ほんとは、後者は継承とはいわないのだが、こう考えた方が分かりやすい)。これを共変という。
Scalaではこんな感じ
class VendingMachine[+T] { // +で共変を示す。 def removeOne: T = {} } ... val machine = new VendingMachine[Vegetable]; user.use(machine) // useはuse(m: VendingMachine[Food])でよい。
しかし、もし自販機に「入れる機能」をつけると、おかしなことになる。野菜自販機を持っていった先が、食べ物自販機を期待しているところならば、そこでジュースや卵を入れられてしまう。
反変
ゴミ箱という入れ物を考えてみる。この容器には「入れることしかできない」という制限をつける。
この容器に「野菜」というラベルを貼り、野菜くずを入れるものとする。この容器は「トマト」用のゴミ箱を期待しているところで使用可能である。トマトは野菜なので、野菜用のゴミ箱が使えるのだ。
つまり、「野菜<--トマト」という継承関係があるなら、「ゴミ箱(トマト)<--ゴミ箱(野菜)」という反転した継承関係になる (再度、ほんとは継承ではないが)。継承関係が反対になっているので「反変」という。
Scalaではこう
class TrashBin[-T] { // -で反変を示す。 def add(a: T):Unit = {} } ... val vegetableTrashBin = new TrashBin[Vegetable]; user.use(vegetableTrashBin) // useはuse(b: TrashBin[Tomato])でよい
もしゴミ箱が「出す機能」を持っていると、これもおかしなことになる。野菜ゴミ箱を持っていった先が、トマトゴミ箱を期待していたとする。ゴミ箱から取り出すことのできるものは、トマトだけではなくニンジン等も混ざっている。
共変の扱い
上記では、例えば共変について「もし自販機に入れる機能がついていたら、野菜自販機にジュースや卵が入れられてしまう」は「意味」としておかしいことを述べた。
Javaでの扱い
Javaでこのようなコードを書くとどうなるだろうか。
class Food { } class Vegetable extends Food { } class Egg extends Food { } class VendingMachine<T extends Food> { // Javaではクラス宣言時に共変を指定できない。 public void add(T a) { ... } public T removeOne() { return ... } } public class SampleOne { public static void main(String[]args) { VendingMachine<Vegetable>m = new VendingMachine<Vegetable>(); m.add(new Vegetable()); use(m); } static void use(VendingMachine<? extends Food>m) { // 使う時に共変を指定する m.add(new Egg()); // しかし、ここでコンパイルエラー Food f = m.removeOne(); } }
Javaではクラス宣言時に共変を宣言できないため、使用時に共変であることを指定するしかない。 また、クラス宣言時に共変を宣言できないということは、コンパイラはそのクラスが共変として使用されるのかを知ることができない。
Scalaでの扱い
class Food {} class Vegetable extends Food {} class Egg extends Food {} class VendingMachine[+T <: Food] { // Scalaではクラス宣言時に共変を指定できる。 def add(a: T) { ... } // ここでエラー※ def removeOne: T = { return ... } }
Scalaではクラスの定義時にエラーが発生する。エラーの内容は以下の通り
error: covariant type T occurs in contravariant position in type T of value a def add(a: T) {}
共変的位置と反変的位置
ScalaはJavaとは異なり、クラスの宣言時にそれが共変であることを示すことができる(この言い方は変なのだがとりあえず)。つまり、Scalaのコンパイラはそのクラスが共変であることがわかっている(これも変だがとりあえず)。
そして、共変クラスとしておかしなクラス宣言をエラーにしてしまう。これが前項のエラーである。つまり、「反変的位置(contravariant position)に共変タイプT(covariant type T)が現れた」というエラーになっている。
共変的位置(covariant position)と反変的位置(contravariant position)とは、平たく言えば(というより、平たくしか理解してないが)、以下のようなものである。
- 共変的位置:メソッドのリターン値
- 反変的位置:メソッドのパラメータ
先のエラーは、共変とされたパラメータTが、メソッド引数という反変的位置に現れたためにエラーになったものである。
先の自販機とゴミ箱の例を思い起こすとわかりやすい。自販機は共変だが、そのメソッドとしてはリターン値として「自販機から取り出す」メソッドしか許されない。逆に反変であるゴミ箱のメソッドとしてはパラメータ値として「ゴミ箱に入れる」メソッドしか許されない。
Scalaコンパイラはこのように、共変あるいは反変として型Tが宣言されると、クラス内でTが使用されている位置を調べ、それが宣言に一致していない場所は一律にエラーとして報告する。
メソッド内で実際に危険な操作をしているかどうかは関係なく一律であるので、例えば次のような宣言でもエラーになる。
class VendingMachine[+T <: Food] { def fakeAdd(a: T) { /* 中身は空で、危険な操作はしていない */ } // でもエラーになる。 }
共変に下限型境界をつける
共変クラス(しつこいが、この言い方はおかしい。とりあえずわかりやすさのため)は、そのパラメータTをメソッド引数としては使用できないことは既に述べたが、下限型境界を使用することにより、この条件を緩和することができる。もちろん完全に自由になるわけではない。ある特定の場合にのみ、「Tに関連する自由な値」をメソッド引数として渡すことができるようになる。
この「下限型境界」(lower type bounds)の意味は非常にわかりにくい。既に述べた上限型境界(upper type bounds)とは全く異なる機能を持つようだ。とりあえず、コードを書いてみる。
class VendingMachine[+T] { def add[U >: T](a: U) {} def removeOne: T = {} }
これはコンパイルが可能である。「>:」は下限型境界を示す。つまり、UはTあるいはTのスーパークラスでなければならないことを意味している。結論から言うと、どうやっても共変であるVendingMachineに中身を追加する(addする)メソッドは記述できないのであるが、様々な書籍や例にあるこの「下限型境界」とは何を意味するのだろうか?