考える際の土台として、以下のようなクラスを考えます。
class Person(val name: String)
class Teacher(override val name: String, val kamoku: String) extends Person(name)
また 文字列からクラスAのインスタンスを生成する Read[A]
とクラスAのインスタンスから文字列を生成する Write[A]
というクラスについて考えてみます。(ReadはfromJson、WriteはtoJsonのようなイメージです。)
Read[Person]の代わりにRead[Teacher]を使った場合、情報を多く読み取ってしまいます。(Personには無いkamokuについてのデータも読み取ってしまいます。)
しかし、読み取った情報はPersonとしてちゃんと使うことが出来ます。(nameが無い!といったPersonに必要不可欠な情報が存在しないといったエラーは起こりません。)
trait Read[+A] {
def read(s: String): A
}
// 実装を楽にするために引数のsはここでは無視する。実際にはjsonみたいなものが渡されてきてその情報を元にクラスが生成されるイメージ
val readPerson = new Read[Person] {
def read(s: String): Person = new Person("p")
}
val readTeacher = new Read[Teacher] {
def read(s: String): Teacher = new Teacher("t", "kokugo")
}
def readP(r: Read[Person]): String = s"${(r.read("dummy")).name}"
readP(readPerson) // ok
readP(readTeacher) // ok
def readT(r: Read[Teacher]): String = s"${(r.read("dummy")).name} + ${(r.read("dummy")).kamoku}"
readT(readPerson) // ng (kamokuが無い)
readT(readTeacher) // ok
Write[Teacher]の代わりにWrite[Person]を使うと書き込む情報が少なくなってしまいます。(例えばkamokuは書き込まれない) しかし、書き込みの際にプロパティがなくてエラーになることはありません。(例えばkamokuを書き込もうと思ったのにPersonのインスタンスが渡ってきたからエラーになってしまうことはありません。)
逆にWrite[Person]の代わりにWrite[Teacher]を使おうとすると、Write[Teacher]はkamokuにアクセスするかもしれないので、Write[Person]だと思ってPersonを渡されるとkamokuが無くてエラーになってしまいます。
trait Write[-A] {
def write(a: A): Unit
}
val writePerson = new Write[Person] {
def write(a: Person): Unit = println(s"${a.name}")
}
val writeTeacher = new Write[Teacher] {
def write(a: Teacher): Unit = println(s"${a.name} + ${a.kamoku}")
}
def writeP(w: Write[Person]): Unit = w.write(new Person("p"))
writeP(writePerson) // ok
writeP(writeTeacher) // ng (kamokuがない)
def writeT(w: Write[Teacher]): Unit = w.write(new Teacher("t", "kokugo"))
writeT(writePerson) // ok
writeT(writeTeacher) // ok
-
readP, writeTのように具体的な型を指定せずに、 Read[A]があればなんでもReadできるメソッド
read
とWrite[A]があればなんでもwriteできるメソッドwrite
を作ってみましょう。(さらにimplicit parameterを使って型クラスとして使えるようにしてみましょう。) -
readの実装をさぼったので適当に復元できるようにしてみましょう。(カンマ区切り1行で
"name, kamoku"
のようなフォーマットで読み書きすると考えてみると楽かもしれません。)読み取ったデータ中に対応するフィールドが無かった場合、デフォルト値(Intなら0, Stringなら"")を使うようにしてみてください。(この実装の場合、どんな文字列を渡しても必ずパースに成功してしまうため、データがフォーマットに従っているかどうか程度はチェックしてエラーにしても良いかもしれません。その場合、readの返り値の型をエラーを表現できるように変えたりする必要がありますが、本題との関連がやや薄いため時間があまった人向けとします。) -
Javaの配列は共変です。つまり Teacher[] <: Person[]です。このとき以下のような操作を行うとどうなるか考えてみましょう。またこのような不思議な操作を防止するためにはどうすればよいか考えてみましょう。(ヒント:Javaの配列は共変です)
Object[] objects;
Integer[] integers = new Integer[]{1,2,3};
// Javaでは Integer <: Object です。
objects = integers;
// ポインタの先にある配列はInteger[]のはず・・?
objects[0] = "error";
// ここの値は・・?
System.out.println(integers[0]);
Q: 難しくない?
A: それな。ただし理解していると危険なメモリ操作をコンパイル時にはじけて便利です。(実践的にはライブラリで使われてると理解してないと何渡して良いのか分からなくなってしまうという話も・・)
Q: 使うべき?
A: ひとまず無しで書いてみて必要になったらつけるというスタンスが良いかもしれません。部分型も使えるようにするというのは部分型を受け入れてしまうということでもあるのでコンパイル時に弾けるミスが増える可能性もあります。(使い方次第なので議論が必要ですが、自信がなかったらつけないくらいのつもりでよいでしょう。)
Q: 最低限何覚えとけばいいですか・・・。
A: 引数は反変なのでマイナスを付ける。返り値は共変なのでプラスを付ける。だけ覚えておいて頂ければ・・!
Q: play-jsonのWritesは反変だけどReadsは共変になってない?
A: http://stackoverflow.com/questions/25567572/why-reads-is-not-declared-covariant を参照してください。 class A
のReadsなら本来は {}
を渡しても上手くうごいて欲しいですが、 class B(a: Int) extends A
のReadsを代わりに渡すと {}
だとエラーになり、 {"a": 1}
を渡さないと意図通りに動かないといった問題から共変にしていないようです。(Readsはシグネチャでは表現されていないが実際には部分関数になっている。定義域が異なるReadsを無理やり部分型として扱っていることが主な原因です。本文中では渡された文字列を無視することでこの問題を回避しています。また、練習2ではデフォルト値を入れる実装にすることで回避しています。
-
実質、関数オブジェクトで説明できることに型クラスを使っているので難しくなっているという説?(個人的には関数オブジェクトの部分型を考える理由がつかみにくそうなので型クラスの方がだ分かるかと思った。ただし、型クラスであることは言及していない。)
-
play-jsonのReadsで共変にしてないからいらぬ誤解を生みそう。そして実際Readsは共変にしないほうが良さそう。(なんか例を変えた方が適切そう・・。readがStringについての部分関数なっているのでリスコフの置換原則が満たされないといった問題があるけどこの辺を説明しだすといよいよ本題からそれる)TaPLどおりならセルのRefをSourceとSinkに分ける無しいなるけど参照セルの部分型付けを考えたいというモチベーションを伝えるのが難しそう・・。