追記: 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
)を型クラスのインスタンスにする方法について考えていく。