読者です 読者をやめる 読者になる 読者になる

( ꒪⌓꒪) ゆるよろ日記

( ゚∀゚)o彡°オパーイ!オパーイ! ( ;゚皿゚)ノシΣ フィンギィィーーッ!!!

Scalaで<:<とか=:=を使ったgeneralized type constraintsがスゴすぎて感動した話

Scala2.8から、Predefに<:<とか=:=とかが定義されていて、これなんだろ?とずーっと疑問だった訳ですよ。で、ついったーで質問投げてたらやっと理解できました。


教えて頂いた @ScalaTohoku さん、@okomok さん、@tioa さん、有り難うございました!


"generalized type constraints"というヤツで、型パラメータに与えられた型が、特定の条件を満たす場合にのみ呼び出せるメソッドを定義できるというものです。しかもコンパイル時に静的にチェックされる!! これはスゴい!!

What do <:<, <%<, and =:= mean in Scala 2.8, and where are they documented? - Stack Overflow

=:=や<:<や<%<で特定の型のみ呼び出せるメソッドを定義する

具体的な例で説明します。以下のような型パラメータTを取るクラスCellがあります。

このCellに、TがIntの場合にのみ呼び出せるメソッドincrementを定義します。
incrementは、Cellが持つInt型の値に+1した値を持つ新しいCellを返すメソッドです。

case class Cell[T](v:T) {
  
  // TがInt型の場合にのみ呼び出せる
  def increment(implicit ev:T =:= Int ):Cell[Int] = Cell( v + 1 )
}


incrementの定義でimplicit parameterが定義されています。(implicit ev:T =:= Int )というのは、TがInt型である必要があるという制約を表します。このように、型パラメータに制約を定義したいときには、implicit parameterでPredef.=:=や<:<などを利用してTがどのような型であるべきかを定義するわけです。


実際に実行してみましょう。

scala> val c = Cell(99)
c: Cell[Int] = Cell(99)

scala> c.increment
res27: Cell[Int] = Cell(100)

scala> Cell("foo").increment
<console>:9: error: could not find implicit value for parameter ev: =:=[java.lang.String,Int]
       Cell("foo").increment

CellにInt型の値を与えた場合は、incrementが呼び出せますが、String型の値を与えた場合はincrementの呼び出しに対してコンパイルエラーが発生しています。このように、特定の条件を満たす場合のみ呼び出せるメソッドを定義できて、かつ型が条件を満たしているかをコンパイル時に静的にチェックできるのがスゴいところです。感動したっ!


他にも制約を指定することができます。"T <:< Date"だとTがDate型かそのサブタイプである必要があるという制約になります。"T <%< WrappedString"だと、Tはimplicit conversion等でTがWrappedStringと見なせる場合という意味です(ScalaではString型はscala.collection.immutable.WrappedStringにimplicit conversionで変換可能です)


さきほどのCellクラスに、TがDateまたはそのサブタイプのみ呼び出せるformatDateと、TがWrappedStringと見なせる場合に呼び出せるasIntを追加しました。

case class Cell[T](v:T) {
  
  // TがInt型の場合にのみ呼び出せる
  def increment(implicit ev:T =:= Int ):Cell[Int] = Cell( v + 1 )
  
  import java.text.SimpleDateFormat
  import java.util.Date
  // TがDateまたはそのサブタイプのときに呼び出せる
  def formatDate(implicit ev: T <:< java.util.Date ) = 
    (new SimpleDateFormat("yyyy/MM/dd HH:mm:ss")).format(v)
  
  import scala.collection.immutable.WrappedString
  // Tがimplicit conversion等でWrappedStringと見なせる場合に呼び出せる
  // toIntはWrappedStringが持つメソッドだが、コンパイルは通る
  def asInt(implicit ev: T <%< WrappedString) = v.toInt
}


実行してみましょう。

// TがDateなのでformatDateを呼べる
scala> Cell( new java.util.Date).formatDate  
res38: java.lang.String = 2010/09/14 18:34:49

// java.sql.TimestampはDateを継承するのでformatDateを呼べる
scala> Cell( new java.sql.Timestamp( System.currentTimeMillis)).formatDate
res40: java.lang.String = 2010/09/14 18:35:18

// TがDateの場合はasIntは呼べない
scala> Cell( new java.util.Date).asInt                                    
<console>:10: error: could not find implicit value for parameter ev: <%<[java.util.Date,scala.collection.immutable.WrappedString]
       Cell( new java.util.Date).asInt

// TがString型ならWrappedStringと見なせるのでasIntが呼べる
scala> Cell("123").asInt              
res43: Int = 123

// TがDateではないのでformatDateは呼べない
scala> Cell("123").formatDate
<console>:10: error: could not find implicit value for parameter ev: <:<[java.lang.String,java.util.Date]
       Cell("123").formatDate


さて、このような型に応じたメソッドの定義ですが、ScalaのCollectionでも使われています。


例えば、TraversableLike#toMapは、Mapに変換する関数です。この関数は、例えばList[(String,Int)]のようなTuple2を要素にもつListなどからMapに変換を行うことができますが、List[Int]からはMapに変換できません。

scala> Seq( ("a",1),("b",2) ).toMap
res18: scala.collection.immutable.Map[java.lang.String,Int] = Map((a,1), (b,2))

scala> Seq(1,2).toMap
<console>:12: error: could not find implicit value for parameter ev: <:<[Int,(T, U)]
       Seq(1,2).toMap


これは、TraversableLike#toMapの定義が以下のようになっているからです。

def toMap[T, U](implicit ev: A <:< (T, U)): Map[T, U]

つまり、toMapはTraversableLikeが持つ要素の型Aが、"(T, U)"つまりTuple2である場合のみ呼び出せるメソッドであるということです。Seq(1,2)は型AがInt型で、Tuple2ではないのでエラーとなっているのです。

なんでこんなことができてんの?そもそも<:<とかってなに?

(ここに書いてあることは推測や間違いが多々あるかもしれません。ツッコミ歓迎します!)


そもそも、<:<とか=:=とかってなんでしょうか?


scala.Predefに、以下のように定義されています。

  sealed abstract class <:<[-From, +To] extends (From => To)
  implicit def conforms[A]: A <:< A = new (A <:< A) {def apply(x: A) = x} 
 
  sealed abstract class =:=[From, To] extends (From => To)
  object =:= {
    implicit def tpEquals[A]: A =:= A = new (A =:= A) {def apply(x: A) = x}
  }
 
  sealed abstract class <%<[-From, +To] extends (From => To)
  object <%< {
    implicit def conformsOrViewsAs[A <% B, B]: A <%< B = new (A <%< B) {def apply(x: A) = x}
  }


<:<などは型パラメータFrom,toを取る抽象クラスで、(Form => To)を継承した関数のようです。


これだけだとよくわからないので、以下のようなCell.scalaを用意して、実際にはどのような呼び出しが行われるのか調べてみます。

case class Cell[T](v:T) {
  // TがInt型の場合にのみ呼び出せる
  def increment(implicit ev:T =:= Int ):Cell[Int] = Cell( v + 1 )

  import java.text.SimpleDateFormat
  import java.util.Date
  // TがDateまたはそのサブタイプのときに呼び出せる
  def formatDate(implicit ev: T <:< java.util.Date ) =
    (new SimpleDateFormat("yyyy/MM/dd HH:mm:ss")).format(v)

  import scala.collection.immutable.WrappedString
  // Tがimplicit conversion等でWrappedStringと見なせる場合に呼び出せる
  // toIntはWrappedStringが持つメソッドだが、コンパイルは通る
  def asInt(implicit ev: T <%< WrappedString) = v.toInt
}

object Main {
  def main(args:Array[String]) = {
    println( Cell(99).increment )
    println( Cell( new java.util.Date).formatDate )
    println( Cell( new java.sql.Timestamp( System.currentTimeMillis)).formatDate )
    println( Cell("123").asInt )
  }
}

このCell.scalaを、"scalac -Xprint:typer Cell.scala "でコンパイルしてみて、Scalaコンパイラがどのようなコードを生成しているか見てみます。重要な箇所のみ抜粋したのが以下です。

  @serializable case class Cell[T >: Nothing <: Any] extends java.lang.Object with ScalaObject with Product {
    
    def increment(implicit ev: =:=[T,Int]): Cell[Int] = Cell.apply[Int](ev.apply(Cell.this.v).+(1));

    def formatDate(implicit ev: <:<[T,java.util.Date]): java.lang.String = new java.text.SimpleDateFormat("yyyy/MM/dd HH:mm:ss").format(Cell.this.v);

    def asInt(implicit ev: <%<[T,scala.collection.immutable.WrappedString]): Int = ev.apply(Cell.this.v).toInt;
   
  };
  
  final object Main extends java.lang.Object with ScalaObject {
    def main(args: Array[String]): Unit = {
      scala.this.Predef.println(Cell.apply[Int](99).increment(scala.this.Predef.=:=.tpEquals[Int]));
      scala.this.Predef.println(Cell.apply[java.util.Date](new java.util.Date()).formatDate(scala.this.Predef.conforms[java.util.Date]));
      scala.this.Predef.println(Cell.apply[java.sql.Timestamp](new java.sql.Timestamp(java.this.lang.System.currentTimeMillis())).formatDate(scala.this.Predef.conforms[java.sql.Timestamp]));
      scala.this.Predef.println(Cell.apply[java.lang.String]("123").asInt(scala.this.Predef.<%<.conformsOrViewsAs[java.lang.String, scala.collection.immutable.WrappedString]({
        ((s: String) => scala.this.Predef.wrapString(s))
      })))
    }
  };
 
}

incrementを呼び出している箇所では、以下のようなコードになっています。

scala.this.Predef.println(Cell.apply[Int](99).increment(scala.this.Predef.=:=.tpEquals[Int]));

Predef.=:=.tpEquals[Int]をincrementに渡しています。tpEqualsは渡された値をそのまま返すだけの関数です。この場合はIntを受けとってIntを返す関数なわけです。


incrementの内部では、引数のimplicit parameterで渡されたev: =:=[T,Int]のapplyにCellがもつvを渡しています。


ここではTはInt型なのでPredef.=:=.tpEquals[Int]を引数にincrementを呼び出すことは妥当ですが、仮にTがStringだとしたら、Predef.=:=.tpEquals[String]を引数にicrementを呼び出すことになってしまい、incrementの定義と矛盾するわけですね。


<:<も同様ですが、この場合はTはDate型のサブタイプであればよいので、tpEqualsのような型の同一性をチェックする関数をはさまなくても普通のT => Date型の関数でよいわけです。


<%<に関しては、ちょっと事情がことなり、<%<[-From,+To].conformsOrViewsAsに、コンパイラが解決したimplicit conversionによる変換を行う関数を渡しています。

      scala.this.Predef.println(Cell.apply[java.lang.String]("123").asInt(scala.this.Predef.<%<.conformsOrViewsAs[java.lang.String, scala.collection.immutable.WrappedString]({
        ((s: String) => scala.this.Predef.wrapString(s))
      })))

これにより、呼び出し先のasIntでは、implicit parameterで渡された"ev: <%<[T,scala.collection.immutable.WrappedString]"という関数にvを渡すことで、StringからWrappedStringの変換が行われている、というわけです。