case classをtupleに変換したり、tupleをcase classに変換できるIso traitをマクロで作る

Scala初心者がShapelessのHListでderivingをどうやるのかを学んだ話 - だいたいよくわからないブログでは、HListに対しての演算を定義すればcase classに対しての演算をいちいち定義する必要がない。(case classをHListに変換→HListで演算→演算後のHListをcase classに戻せばよい。)ということを紹介しました。

しかし、紹介したコードではcase classをHListにするためのfromtoメソッドをいちいち手書きする必要がありました。これでは本末転倒ですので、今回はfromtoの自動生成をマクロで行いたいと思います。

とりあえずbuild.sbtです。

name := "Iso"

version := "1.0"

scalaVersion := "2.11.6"

libraryDependencies += "org.scala-lang" % "scala-reflect" % scalaVersion.value

同型射 Iso

case classとHListのように、どっちも相互に変換可能だし、どっちで計算しても答えは変わらないよーという性質をもったものを同型写像といいます。これにちなんでIso(Isomorphismの略?)という名前が付くのが一般的なようですので、本記事でもfromtoメソッドを持ったtraitをIsoという名前で呼びたいと思います。

Isoの定義はこんな感じです。

trait Iso[T, U] {
  def to(t: T): U
 
  def from(u: U):T
}

T => UU => T がありますね。 ここまでは前回と同じです。

問題は、どのようにIso[T, U]を自動生成するかです。

macro

Isoの生成にはいくつかの方法があるらしいのですが、今回はマクロを利用したいと思います。 あと、HListでやる前にtupleで練習しよ(ง ˘ω˘ )วと思ったら動かなさすぎてげんなりしたのでtuple版の実装です(◞‸◟)

traitの定義も含めた実装はこんなかんじです。

package Deriving
 
import scala.language.experimental.macros
import scala.reflect.macros.whitebox
 
trait Iso[T, U] {
  def to(t: T): U
 
  def from(u: U):T
}
 
object Iso {
  implicit def materializeIso[T, U]: Iso[T, U] = macro impl[T, U]
 
  def impl[T: c.WeakTypeTag, U: c.WeakTypeTag](c: whitebox.Context): c.Expr[Iso[T, U]] = {
    import c.universe._
 
    // case classのクラス名
    val caseClassSym: c.universe.Symbol = c.weakTypeOf[T].typeSymbol
    if (!caseClassSym.isClass || !caseClassSym.asClass.isCaseClass) c.abort(c.enclosingPosition, s"$caseClassSym is not a case class")
 
    // case classのフィールドに関するobject
    object Fields {
      // 各フィールドのシンボル
      val syms: List[TermSymbol] = caseClassSym.typeSignature.decls.toList.collect { case x: TermSymbol if x.isVal && x.isCaseAccessor => x }
 
      // List(Int, String, Int) のようなフィールドの型名を並べたリスト
      val types: List[Tree] = syms map (f => tq"${f.typeSignature}")
 
      // Lost(t.foo, t.bar, t.baz) のように 「t.フィールド名」 と並べたリスト (tはtoで使えるように、toの引数名に合わせている)
      // 本当はt.nameとしたいが、t.`foo` となってしまうので、 t.fooにするためにstring化→trimして回避している
      val names: List[Tree] = syms map (f => q"t.${TermName(f.name.toString.trim)}")
    }
 
    import Fields._
 
    // case classと同型なタプルの型
    // Tuple3[Int, String, Int]など
    def tupleType: Tree = tq"(..$types)"
 
    // case classと同型なタプルのインスタンス
    // (1, "aaa", 2)など
    def toImpl: Tree = q"(..$names)"
 
    // タプルと同型なcase classのインスタンス
    // case classのフィールドが0個のときはapply.tupledが無いので直接インスタンス化する
    // Foo(1, "aaa" 2)など
    def fromImpl: Tree = q"${if(syms.isEmpty) q"${caseClassSym.companion}.apply" else q"${caseClassSym.companion}.tupled(u)"}"
 
    // Iso[T, U]の無名サブクラスを定義してnew
    val evidence: Tree = q"""
      final class $$anon extends Iso[$caseClassSym, $tupleType] {
        def to(t: $caseClassSym): $tupleType = $toImpl
        def from(u: $tupleType): $caseClassSym = $fromImpl
      }
      new $$anon
    """
 
    c.Expr[Iso[T, U]](evidence)
  }
}

https://gist.github.com/matsu-chara/dde005b46c5a7218ace9#file-iso-scala

なかなか動かなくてコメントを書きまくったので、何をしているかなんとなく想像できるのではないでしょうか。

この実装を使うと、下記のようにネストしたcase classもtupleに変換することができます。

package Deriving

// (だいたい)任意のcase classをマクロでtupleに変換
object DerivingTest extends App {

  // テスト用ケースクラス
  case class Bar()
  case class Foo(i: Int, s: String, d: Double)
  case class Baz(i: Int, s: String, d: Double, f: Foo)

  // isoインスタンス
  val barIso = implicitly[Iso[Bar, Unit]]
  val fooIso = implicitly[Iso[Foo, (Int, String, Double)]]
  val bazIso = implicitly[Iso[Baz, (Int, String, Double, Foo)]]

  // フィールド数0のクラス
  val bar         : Bar  = Bar()
  val tupleFromBar: Unit = barIso.to(bar)
  val barFromTuple: Bar  = barIso.from(tupleFromBar)
  println(s"$tupleFromBar, $barFromTuple")

  // 普通のクラス
  val foo          = Foo(23, "foo", 4.3)
  val tupleFromFoo = fooIso.to(foo)
  val fooFromTuple = fooIso.from(tupleFromFoo)
  println(s"$foo, $tupleFromFoo, $fooFromTuple")

  // ネストした型
  val baz          = Baz(1, "b", 2.3, fooFromTuple)
  val tupleFromBaz = bazIso.to(baz)
  val bazFromTuple = bazIso.from(tupleFromBaz)
  println(s"$baz, $tupleFromBaz, $bazFromTuple")
}
}

https://gist.github.com/matsu-chara/dde005b46c5a7218ace9#file-derivingtest-scala

あとはtupleNに対して演算を定義してやればcase class(フィールド数N)に対しての型クラスが自動導出できるはずです。 一つの型クラスに対して高々22個なのでやろうと思えば(フィールド数22個以内なら)できるところまで来たと思います。

まとめ

実を言うと、この実装はSI-7470 implements fundep materialization by xeno-by · Pull Request #2499 · scala/scala · GitHubで挙げられている例のパクリなんですが、scala/Macros_1.scala at 7b890f71ecd0d28c1a1b81b7abfe8e0c11bfeb71 · scala/scala · GitHubでは from が実装されていないので、完全版という意味合いと、準クォートを使っているので少し読みやすいというメリットがあります。

しかし、変換後の型を自分で書かないといけないのは面倒ですね(´・_・`)

toだけなら推論してくれるんですがfromは返り値の型(どのcase classか)を指定しなければならないのでちょっと厳しそうです。もしかしたら何か方法があるかもしれません・・・。

次はもうちょい頑張って、HListへのIso traitを書いてshpelessのような自動導出のコード例が書けたらなと思います。