Stringを要素に持つ任意のコレクションにgrepを適用できるようにする - あるいはScalaの暗黒面について
2011/07/03:末尾に補足を追記しました。
最近ちょっとRubyやってたりします。で、RubyのEnumerableにgrepっていうメソッドがあるらしいけど、Scalaには直接対応するものがないので、かなり雑につくってみた
誰得?(・ω・`)っていうか、ジェネリックにしようとしたら意外と難しくてできなかった*1んだけどどうやるの・・・Scala1年半以上やっててそんなこともできなくて死にたい orz
まず、 id:xuwei さんが、本来満たしたい仕様を実現するのはさほど簡単なことでもないので*1、特に気される必要は無いかと思います。
説明が長くなるので、先に結論から言うと次のコードで id:xuwei さんの要求を満たすことができます。
このコードは次のようにして使うことができます。
REPLの例を見ていただければわかるように、Stringを要素に持つ、List, Set, Vectorに対してgrepを適用することができて、各コレクションに対するgrepは同じ静的なコレクション型を返していることがわかります。
id:xuwei さんがどのようなアプローチを試みられたのかはわからないのですが、おそらく次のような感じだったのではないでしょうか。
import scala.collection.Traversable import scala.collection.generic.CanBuildFrom object PimpGrepToCollection { class GrepCallable[C[X] <: Traversable[X]](tr: C[String]){ def grep[A](regex:String)(func:String => A = identity _)(implicit bf: CanBuildFrom[C[String], A, C[A]]):C[A] = tr.collect[A, C[A]]{ case s if regex.r.findFirstIn(s).isDefined => func(s) }(bf) } implicit def toGrepCallable[C[X] <: Traversable[X]](tr: C[String]): GrepCallable[C] = new GrepCallable[C](tr) }
しかし、このコードをコンパイルしてみようとすると、次のようなコンパイルエラーが出てしまいます。
error: type mismatch; found : scala.collection.generic.CanBuildFrom[C[String],A,C[A]] required: scala.collection.generic.CanBuildFrom[Traversable[String],A,C[A]] }(bf)
この型エラーは、
CanBuildFrom[C[String],A,C[A]]
が
CanBuildFrom[Traversable[String],A,C[A]]
のサブタイプでないので、コンパイルできないよー、という事を言っています。
ここで問題なのは、何故、コンパイルできないか、ということです。それを理解するためには、
を知る必要があります。
まず最初の点について。Scala 2.9.0.1のAPIリファレンスから引用すると、collectのシグニチャは
def collect [B, That] (pf: PartialFunction[A, B])(implicit bf: CanBuildFrom[Traversable[A], B, That]): That
のようになっていることがわかります。
ここでのポイントは、Traversable型の定義時点で、既にCanBuildFrom
の最初の型パラメータがTraversable[A]
になってしまっていることです。
次に2点目について。Scala/Java 5以降のジェネリクスでは、イレイジャという手法によって、コンパイル時に型パラメータ情報を消去します。そのため、型パラメータに対して何も制約を指定しない場合、Anyに定義されているメソッドしか呼び出すことができません。型パラメータAを型に持つ式xに対して必要なメソッドを適用できるようにするには、Aに対して適切な上限境界/下限境界を指定する必要があります。
上記の誤ったコード例では、
class GrepCallable[C[X] <: Traversable[X]](tr: C[String]){ def grep[A](regex:String)(func:String => A = identity _)(implicit bf: CanBuildFrom[C[String], A, C[A]]):C[A] = tr.collect[A, C[A]]{ case s if regex.r.findFirstIn(s).isDefined => func(s) }(bf) }
のC[X] <: Traversable[X]
の部分が該当します*2。
ここで、この宣言は、あるジェネリックな型コンストラクタC
はTraversable
を継承していなければいけない、ということを表現しています。
問題は、tr.collect()
の呼び出しの型が一体何になるかということです。C
の上限境界の指定では、Traversable
を継承しているということしか言っていませんから、tr.collect()
を呼び出したときは、Traversable[String]#collect
を呼び出したときと同じように型チェックされます。
再びTraversable[A]#collect
の宣言を見てみましょう。
def collect [B, That] (pf: PartialFunction[A, B])(implicit bf: CanBuildFrom[Traversable[A], B, That]): That
collectの呼び出し時に明示的に型パラメータB, Thatを指定しているので、実際には、
def collect(pf: PartialFunction[String, A])(implicit bf: CanBuildFrom[Traversable[String], A, C[A]]): C[A]
というシグニチャを持ったcollectの呼び出しに対して型チェックが行われます。ここで問題になるのが、CanBuildFrom同士の型の互換性です。
端的に言うと、
CanBuildFrom[C[String], A, C[A]] // (呼び出し側)
と
CanBuildFrom[Traversable[String], A, C[A]] //(呼び出され側)
の二つの型の間には互換性が無いため、コンパイルエラーになってしまいます。なぜ互換性が無いかというと、
C[String] <: Travrersable[String] // A <: B は A は B のサブタイプである、ということを意味する
なのに対して、CanBuildFrom
の定義は
trait CanBuildFrom [-From, -Elem, +To] extends AnyRef
であり、第一型パラメータFrom
が反変として定義されているからです*3。
さて、ここまでは、何故、最初の誤ったコードがコンパイルエラーになるかを説明しましたが、それは原因の根本ではありません。
根本的な問題は、grepの定義を含むコードをコンパイルする時点では、ScalaコンパイラはC[String]
の「実際の型」を知る手段が無いため、C[String]
をTraversable[String]
のようにみなして型チェックを行うという点です。
コードを書いた人間としては、C[String]#collect
の呼び出しにおいて、C[String]
の「実際の型」(List[String]
, Set[String]
, ...)に基づいて型チェックをして欲しいのですが、Scalaの型システムの制限上、それは不可能です。
Scala 2.8のコレクションライブラリでは、ややトリッキーな技巧を用いてこの問題点を回避しています。まず、Traversable[A]#collect
を実際に定義しているトレイトであるTraversableLike
の宣言を見てみます。
trait TraversableLike [+A, +Repr] extends /* 本題に関係ないので省く ... */ { //... def collect [B, That] (pf: PartialFunction[A, B])(implicit bf: CanBuildFrom[Repr, B, That]): That //... }
Traversable
と比較すると、
- トレイトの宣言で、謎の型パラメータ
Repr
が追加されている
- collectの宣言では、
CanBuildFrom
に渡す第一型パラメータがRepr
になっている
このRepr
が重要なところで、型パラメータRepr
には、コレクションの実装クラス・トレイト(Traversable
, List
...)を定義する時に、そのコレクションの「実際の型」を渡すことになっています。このようなテクニックによって、コレクションの「実際の型」をうまく回復しよう、というわけです。
これを踏まえた上で、「正しい」定義を見てみましょう。
class GrepCallable[C[X] <: TraversableLike[X, C[X]]](tr: C[String]){
C[X]
の上限境界には、Traversable[X]
の代わりにTraversableLike[X, C[X]]
が指定されていることがわかります。このようにすると、tr.collect
の呼び出し時には、collectの型が
def collect(pf: PartialFunction[String, A])(implicit bf: CanBuildFrom[C[String], A, C[A]]): C[A]
となります。この
CanBuildFrom[C[String], A, C[A]]
という型は、collectの呼び出し側にあるbfの型
CanBuildFrom[C[String], A, C[A]]
と一致するため、無事にコンパイルを通るようになります。
長々と説明してきたことからわかるように、この問題はなかなかやっかいなもので、Scala初心者だけではなく、ある程度Scalaの経験がある人にとっても難しいかと思います。
一般論としては、コレクションに関する、汎用性が非常に高いメソッドをPimp my library*4によって追加しようとすると、Scalaの型システムに対するある程度高度な理解が必要になります。つまり、Scalaの暗黒面*5にある程度触れなければなりません。
Scalaの暗黒面を学ぶためにどの程度の期間が必要かによるのですが、今回のようなケースで List 限定で grep メソッドを追加するようにしたのは、それなりに妥当かと思います。
ちなみに、C++のテンプレートでは、メソッド定義時点では型パラメータに関する操作の正当性をチェックしないため*6、このような問題は発生しませんが、一方で、分割コンパイルの実装は容易ではありません。Scalaのようなシステムでは、上記のような問題が起こる代わりに、分割コンパイルを容易に実装することができます。
補足(重要):このエントリでの趣旨は、Stringを要素に持つ任意のコレクションにgrepを適用できるようにしつつ、かつ、Scalaコレクションライブラリの基本思想である「戻り値同型」の原則を遵守しようとすると複雑になるというものです。
強調した部分を明示していなかったので、コレクションにメソッドを追加するだけでこれだけ複雑な事を知らなければいけない、と誤解されたかもしれません。実際には、「戻り値同型」の原則を守らなくていいのなら、
import scala.collection.Traversable import scala.collection.generic.CanBuildFrom object PimpGrepToCollection { class GrepCallable[X](tr: Traversable[String]){ def grep[A](regex:String)(func:String => A = identity _):Traversable[A] = tr.collect{ case s if regex.r.findFirstIn(s).isDefined => func(s) } } implicit def toGrepCallable[A](tr: Traversable[String]): GrepCallable[A] = new GrepCallable[A](tr) }
というコードを書くだけでOKです。これで、ほとんど全てのコレクションにgrepメソッドが追加できます。ただし、grepの戻り値は必ずTraversableになってしまいます。