追記: Shapeless入門を記事にまとめました
Shapelessの勉強をしていて、これは2日目の備忘録。教科書は、The Type Astronaut’s Guide to Shapeless。
1日目はこれ
前回は、Chapter 2まで進み、GenericとHList、Coproductについて学んだ。
Chapter 3では、Genericに対応した型クラスのインスタンスの自動導出について勉強する。
復習
特定の型クラスに対応したインスタンスの自動導出について考える前に、まず型クラスについて復習する。
- 型クラスはパラメータ化されたTraitのようなものだといえる
- 型クラスのインスタンスを個々の型について実装することで、型クラスの機能が使えるようになる
- 個々のインスタンスは、型クラスのコンパニオンオブジェクトに実装したり、もしくはライブラリにしてimportしてもらったりすることでスコープに入れて使えるようにする
- 型クラスは便利だが、もちろん使いたい型について毎回インスタンスを用意してやらなければ使えない
- これでは面倒なので、コンパイラは特定の場合にうまくインスタンスを導出してくれる
- e.g.
Tuple2[A, B]では、AとBについてインスタンスを導出できる場合、Tuple[A, B]のインスタンスもうまく見付けてくれる
- e.g.
- ところが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を使っている。
これまで勉強してきたHList、Genericの知識から、直感的に次の事が言えそうだ:
HListについて、そのHeadとTailのそれぞれが型クラスFのインスタンスであるならば、HList全体もFのインスタンスにすることができるはず- case class
Aがあり、これに対応するGeneric[A]があり、さらにGeneric[A]#ReprがFのインスタンスであるならば、AはFのインスタンスにすることができるはず
HListは単にいくつかの型をProductにする以外は特に何もしていないので、この直感は正しそうだ。
今回は例として、case class IceCreamがCsvEncoderのインスタンスになることを確認していく。
CsvEncoderの定義は次の通り:
trait CsvEncoder[A] { def encode(value: A): List[String] }
HList の個々の要素をインスタンスにする
IceCreamの定義を思い出そう。
case class IceCream(name: String, numCherries: Int, inCone: Boolean)
String、Int、そして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、つまり型パラメータが無い場合にしかこの書き方はできないみたいだ。
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を結集して、String、Int、そしてBooleanからなるあらゆるHListのためのCsvEncoderを召喚できるようになった。
val reprEncoder: CsvEncoder[String :: Int :: Boolean :: HNil] = implicitly reprEncoder.encode("abc" :: 123 :: true :: HNil) // => List("abc", "123", "yes")
すごいぞ!!
IceCreamをインスタンスにする
ここまで定義してきたHListのための導出ルールと、Genericのインスタンスを組み合わせて、IceCreamをCsvEncoderのインスタンスにすることができる:
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]はAをHListと相互変換してくれるぞ!)
これにより、case class IceCream をCsvEncoder のインスタンスにすることができた。
Productをインスタンスにする
さて、これではIceCreamのためのインスタンスしか作れていない。ここから一般化していき、あらゆるケースクラスAが、Generic[A]と、それに対応する型クラスのインスタンスがあれば、うまく動くようにしたい。
ここで復習。
型クラスFがあるとする。case class AがFのインスタンスになるようにしたいとき……
- まず、
Generic[A]がある Generic[A]#ReprがFのインスタンスであるGeneric[A]さえ成り立っていれば、AはFのインスタンスにできるはず………
じゃあ、Aのインスタンスを実装しようか。
まずはGeneric[A]が無ければ話にならないので、implicitで要求しよう。
implicit def genericEncoder[A]
(implicit gen: Generic[A]): ??? = ???
次に、Generic[A]の中のReprがCsvEncoderのインスタンスである必要があるから、これも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のインスタンスならば、AをFのインスタンスにできるようになった。やったね!
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 classAを構成する型が全てFのインスタンスであり、HListがFのインスタンスになるように定義できていて、Generic[A]があるなら、AはFのインスタンスにできる - 隣の引数の内部の型を参照したいときは、型パラメータに切り出してから参照する
- 型パラメータに切り出すパターンのために
Auxパターンが存在する
次回は、Coproduct(とshapelessのCoproduct)を型クラスのインスタンスにする方法について考えていく。