Lambdaカクテル

京都在住Webエンジニアの日記です

Shapelessの勉強(その2)

Shapelessの勉強をしていて、これは2日目の備忘録。教科書は、The Type Astronaut’s Guide to Shapeless。

1日目はこれ

blog.3qe.us

前回は、Chapter 2まで進み、GenericHListCoproductについて学んだ。

Chapter 3では、Genericに対応した型クラスのインスタンスの自動導出について勉強する。

復習

特定の型クラスに対応したインスタンスの自動導出について考える前に、まず型クラスについて復習する。

  • 型クラスはパラメータ化されたTraitのようなものだといえる
  • 型クラスのインスタンスを個々の型について実装することで、型クラスの機能が使えるようになる
  • 個々のインスタンスは、型クラスのコンパニオンオブジェクトに実装したり、もしくはライブラリにしてimportしてもらったりすることでスコープに入れて使えるようにする
  • 型クラスは便利だが、もちろん使いたい型について毎回インスタンスを用意してやらなければ使えない
  • これでは面倒なので、コンパイラは特定の場合にうまくインスタンスを導出してくれる
    • e.g. Tuple2[A, B]では、ABについてインスタンスを導出できる場合、Tuple[A, B]のインスタンスもうまく見付けてくれる
  • ところがcase class(Product)やsealed trait(Coproduct)についてはうまく解決できない
  • shapelessはいろいろ道具を用意して、あらゆるADTsに対応するインスタンスを導出できるようにしている

Shapelessが用意している型クラスのイディオム(the)について

これはコラム的な話題。

Scalaは、Implicit resolutionを行ってインスタンスを用意してもらうための関数 implicitly を用意している。ただしShapelessが扱うような複雑な型において、時々うまく型を推論できない場合がある。そこでShapelessはimplicitlyを代替できるtheを用意していて、より賢く推論してくれる。使い方はimplicitlyと同様である。

自動導出してProductを型クラスのインスタンスにする

ここからは、任意のADTsを適当な型クラスのインスタンスにして、構造さえ同じであればどんなcase classでも同じ操作ができるぜ、カッケェ!!という状態を目指していく。ADTsはProductとCoproductから成るから、それぞれについて考えていくことになる。

まずはcase class、つまりProductを型クラスのインスタンスにする方法を考えよう。型クラスは何でも良いのだが、教科書ではCsvEncoderを使っている。

これまで勉強してきたHListGenericの知識から、直感的に次の事が言えそうだ:

  1. HListについて、そのHeadとTailのそれぞれが型クラスFのインスタンスであるならば、HList全体もFのインスタンスにすることができるはず
  2. case class A があり、これに対応する Generic[A] があり、さらに Generic[A]#ReprF のインスタンスであるならば、 AF のインスタンスにすることができるはず

HListは単にいくつかの型をProductにする以外は特に何もしていないので、この直感は正しそうだ。

今回は例として、case class IceCreamCsvEncoderのインスタンスになることを確認していく。

CsvEncoderの定義は次の通り:

trait CsvEncoder[A] {
  def encode(value: A): List[String]
}

HList の個々の要素をインスタンスにする

IceCreamの定義を思い出そう。

case class IceCream(name: String, numCherries: Int, inCone: Boolean)

StringInt、そしてBooleanを含んでいる。まず最初にこれらをCsvEncoderのインスタンスにしなければならない。

def createEncoder[A](func: A => List[String]): CsvEncoder[A] =
  new CsvEncoder[A] {
    def encode(value: A): List[String] = func(value)
  }

implicit val stringEncoder: CsvEncoder[String] = createEncoder(str => List(str))
implicit val intEncoder: CsvEncoder[Int] = createEncoder(num => List(num.toString))
implicit val booleanEncoder: CsvEncoder[Boolean] = createEncoder(bool => if(bool) "yes" else "no")

あまり難しくない。

余談: SAMパターンは使えない

createEncoderは多分、Single Abstract Methodパターン(SAMパターン)で次のように書けると思う:

def createEncoder[A](func: A => List[String]): CsvEncoder[A] =
  new CsvEncoder(func(_))

と思ったけれど、Monomorphic、つまり型パラメータが無い場合にしかこの書き方はできないみたいだ。

stackoverflow.com

HList そのものをインスタンスにする

IceCreamが持つ個々の型がCsvEncoderのインスタンスになったところで、HList自体をCsvEncoderのインスタンスにしていこう。

といっても、あまり難しいことではない。HNil::について実装すれば終わりだ。

import shapeless.{HList, ::, HNil}

implicit val hnilEncoder: CsvEncoder = createEncoder(hnil => Nil)

implicit def hlistEncoder: CsvEncoder[H, T <: HList]
  (implicit hEncoder: CsvEncoder[H], tEncoder: CsvEncoder[T]): CsvEncoder[H :: T] =
  createEncoder {
    case h :: t => hEncoder.encode(h) ++ tEncoder.encode(t)
  }

HNilはそのままNilへとマッピングされた。HListはheadとtailをそれぞれCsvEncoderでencodeした結果をListとして結合している。

さて、5つのimplicitを結集して、StringInt、そしてBooleanからなるあらゆるHListのためのCsvEncoderを召喚できるようになった。

val reprEncoder: CsvEncoder[String :: Int :: Boolean :: HNil] = implicitly

reprEncoder.encode("abc" :: 123 :: true :: HNil) // => List("abc", "123", "yes")

すごいぞ!!

IceCreamをインスタンスにする

ここまで定義してきたHListのための導出ルールと、Genericのインスタンスを組み合わせて、IceCreamCsvEncoderのインスタンスにすることができる:

import shapeless.Generic

implicit val iceCreamEncoder: CsvEncoder[IceCream] = {
  val gen = Generic[IceCream]
  val enc = CsvEncoder[gen.Repr]
  createEncoder(iceCream => enc.encode(gen.to(iceCream)))
}

(復習: Generic[A]AHListと相互変換してくれるぞ!)

これにより、case class IceCreamCsvEncoder のインスタンスにすることができた。

Productをインスタンスにする

さて、これではIceCreamのためのインスタンスしか作れていない。ここから一般化していき、あらゆるケースクラスAが、Generic[A]と、それに対応する型クラスのインスタンスがあれば、うまく動くようにしたい。

ここで復習。

型クラスFがあるとする。case class AFのインスタンスになるようにしたいとき……

  • まず、Generic[A] がある
  • Generic[A]#ReprF のインスタンスである
  • Generic[A]さえ成り立っていれば、AFのインスタンスにできるはず………

じゃあ、Aのインスタンスを実装しようか。

まずはGeneric[A]が無ければ話にならないので、implicitで要求しよう。

implicit def genericEncoder[A]
  (implicit gen: Generic[A]): ??? = ???

次に、Generic[A]の中のReprCsvEncoderのインスタンスである必要があるから、これもimplicitで要求しよう。

implicit def genericEncoder[A]
  (implicit gen: Generic[A], enc: CsvEncoder[gen.Repr]): CsvEncoder[A] =
    createEncoder(a => enc.encode(gen.to(a)))

ドカン!!なんとこれはコンパイルできない。なぜかというと、隣の引数の内部の型を取ってくることはできないので、encからgenの内部の型を参照できないからだ。

Genericの中身のReprを取り出して、型パラメータRとして持ち上げてやり、引数からは型パラメータを参照させることで、うまくコンパイルできるようになる。

implicit def genericEncoder[A, R] (
  implicit gen: Generic[A] { type Repr = R },
  enc: CsvEncoder[R]
  ): CsvEncoder[A] =
    createEncoder(a => enc.encode(gen.to(a)))

今後も、この「型の中身の型を型パラメータに持ち上げて隣の引数から参照できるようにする」手法が登場するので覚えておこう。

ともかく最終的に、あらゆるcase class Aについて、その中身が全てFのインスタンスならば、AFのインスタンスにできるようになった。やったね!

case class Vec(x: Int, y: Int)

val vecEncoder: CsvEncoder[Vec] = implicitly

vecEncoder.encode(Vec(10, 20)) // => List("10", "20")

かっけえ!!

Aux 型エイリアスで型パラメータへの持ち上げを楽にする

ちなみに、ShapelessはGenericのために type Aux[A, R] = Generic[A] { type Repr = R }を定義しているので、やや分かりやすくなる。

implicit def genericEncoder[A, R] (
  implicit gen: Generic.Aux[A, R]
  enc: CsvEncoder[R]
  ): CsvEncoder[A] =
    createEncoder(a => enc.encode(gen.to(a)))

Auxも今後使われていくので、覚えておきたいイディオムの一つだ。

自動導出の欠点

欠点として、エラーメッセージが読みにくくなりがちという話題がある。教科書には、実行中に落ちる確率は下がるからね、という言い訳が書いてある……。

まとめ

いったんここでまとめよう。

  • ある型クラスFについて、case class A を構成する型が全てFのインスタンスであり、HListFのインスタンスになるように定義できていて、Generic[A]があるなら、AF のインスタンスにできる
  • 隣の引数の内部の型を参照したいときは、型パラメータに切り出してから参照する
  • 型パラメータに切り出すパターンのためにAuxパターンが存在する

次回は、Coproduct(とshapelessのCoproduct)を型クラスのインスタンスにする方法について考えていく。

Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?