Lambdaカクテル

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

Shapelessの勉強(その1)

ScalaにはShapelessというジェネリックプログラミングを推進するためのライブラリがあって、同じ型の構造を持つCase Class間を変換するといった操作ができるのだけれど、あまり詳しいことは知らなかったので、公式のガイドブックを読んで勉強することにした。

https://books.underscore.io/shapeless-guide/shapeless-guide.pdf

無料でこういう本が読めるのはありがたすぎる。

早速2章の終わりまで読んだので、備忘録として記録を残しておきたい。コードサンプルはだいたい↑からの引用。

Chapter 1

Chapter 1はこれからどういう構成で行くよ、という紹介パートなので、割愛。

Chapter 2

Chapter 2は、ADTsの復習と、HListGenericCoproductの紹介に充てられている。

ADTs

まず、ADTsについての復習から。他の関数型言語と同様、ScalaにはADTsAlgebraic Data Typesを表現するための仕組みが備わっている。

おおまかには、ADTsは、AndとOrの構造を持ったデータ構造だといえる。例えば、次のようなコードを考える。

sealed trait Shape
final case class Rectangle(width: Double, height: Double) extends Shape
final case class Circle(radius: Double) extends Shape

この場合、Shapeは、Rectangle またはOR Circle であり、RectangleDouble AND Double から成り、 CircleDouble から成っている。

専門用語を使って言い換えると、Andを使ってデータ構造を組み立てることをProductと呼び、Orを使ってデータ構造を組み立てることをCoproductと呼ぶ。 今後はこの用語を使っていく。

ADTsのencoding

さて、ADTsをsealed traitcase classとを使って表現したけれど、ADTsをコードに落とし込む(encode)方法はそれだけではない。

例えば、Tupleを使ってProductし、Eitherを使ってCoproductしてもよい。

type Rectangle2 = (Double, Double)
type Circle2 = Double
type Shape2 = Either[Rectangle2, Circle2]

表現方法が異なるだけで、全く同じことができる。プロパティの名称などが剥がされているので、Tuple/Eitherを使った方が、より一般化されている。

Shapelessは独自の方法でADTsをencodeする

Shapelessは、前掲した2つの方法のいずれも取らず、独自の方法でADTsをencodeする。

理由はいくつかある。

  • Tupleは個数制限がある
  • Tuple1, Tuple2, ... といったそれぞれの型同士には関連性がなく、深さが変化するような場合に使いにくい
  • 0-sizedなTupleがない
    • Unitがしばしばこの目的に使われるが、UnitとTupleNとのupper boundをとるとAnyになってしまうので、全然使えない

ADTsをencodeするためには、ProductとCoproductそれぞれについてencode方法を考えることになる。まずはProductから見ていく。

HList for Product

まずProductをencodeするために、shapelessはshapeless.HListを導入する。

HListはHeterogeneous Listの略。

HListはListと異なり、それぞれの要素が独自の型を持つことができ、型レベルで長さがわかっている。

型コンストラクタはshapeless.::であり、shapeless.HNilが通常のListのNilに相当する。

import shapeless.{HList, ::, HNil}
val product: String :: Int :: Boolean :: HNil =
"Sunday" :: 1 :: false :: HNil

これだけだとTupleを使うのとあまり変わり映えしないが、そのうち強さが分かってくる、ということらしい。

型クラス登場: Generic

ここで、case classとHListとの相互変換を行ってくれる便利型クラスのshapeless.Genericが登場する。

型クラスなので別の型を取ってなんらかの働きをしてくれるのだが、同名のマクロによってこれは自動導出される。

例えば、val fooGen = Generic[FooCaseClass] のように書くと、 マクロによってGeneric[FooCaseClass]のインスタンスが自動導出される。

Generic[A]のインスタンスは、内部にReplという型を持っていて、AHListにencodeした型が格納される。

import shapeless.Generic
case class IceCream(name: String, numCherries: Int, inCone: Boolean)
val iceCreamGen = Generic[IceCream]
// iceCreamGen: shapeless.Generic[IceCream]{type Repr = String :: Int :: Boolean :: shapeless.HNil} = anon$macro$4$1@6b9323fe

そして、to / from メソッドを呼び出すことで、case classとHListとを相互に変換できる。

val iceCream = IceCream("Sundae", 1, false)
// iceCream: IceCream = IceCream(Sundae,1,false)
val repr = iceCreamGen.to(iceCream)
// repr: iceCreamGen.Repr = Sundae :: 1 :: false :: HNil
val iceCream2 = iceCreamGen.from(repr)
// iceCream2: IceCream = IceCream(Sundae,1,false)

ところで、genericな方に向けるために使うのがtoで、concreteな方に向けるために使うのがfromなのは若干分かりづらい。genericな方向に向かっていくからto、genericな方から戻ってくるからfrom、と考えると良さそう。

Reprが同じなら構造が一緒なので、toしてfromすることで、型安全にcase class間を変換できる。

case class Employee(name: String, number: Int, manager: Boolean)
// Create an employee from an ice cream:
val employee = Generic[Employee].from(Generic[IceCream].to(iceCream))
// employee: Employee = Employee(Sundae,1,false)

今のところそんなに便利じゃない。ちなみにTupleに対してもGenericは導出できるようになっている。

Coproduct for Coproduct

さて、HListがProductを実装した。ではCoproductを実装するのは誰?かというと、そのままshapeless.Coproductである。

型コンストラクタは:+:であり、CNilがNilに相当する。CiNiiっぽいね。たぶんCoproductのNilだからCNil。

type FooBarBuzz = Foo :+: Bar :+: Buzz :+: CNilという具合で型を定義できる。最後のCNilを忘れないように。これも、そんなに特別な事はしていなくて、:+:Eitherであり、CNilNothingにだいたい対応する。

さて、実際にFooBarBuzz型の値を作るにはどうしたら良いかというと、まずは:+:の2つのサブタイプについて知らなければならない。

  • :+: -- Eitherにだいたい対応する
    • Inl -- Left にだいたい対応する
    • Inr -- Right にだいたい対応する

どうしてこんなサブタイプがあるかというと、型合わせのために必要なのである。

type FooBarBuzz = Foo :+: Bar :+: Buzz :+: CNil
val x: FooBarBuzz = Bar(...) // 型が合わない!!!
val x: FooBarBuzz = Inr(Inl(Bar(...))) // ヨシ!

不思議に感じるかもしれないが、ふだんEitherを扱うときにLeft()とかRight()と書いていることを思い出せば、それほど奇怪ではない。Eitherが多段になり、いくつかある型スロットのうちどのスロットを選択するかを、明示的に指定しているのである。ふだんsealed traitを使っているときは、型ごとに名前がついているから、これが自動的に隠蔽されているにすぎない。

型の合わせ方は、左から順にずらしたい数だけInrを置き、一番内側にInlを置けばよい。

val x: FooBarBuzz = Inl(Foo) // 一番左
val x: FooBarBuzz = Inr(Inl(Bar)) // 左から1つめ
val x: FooBarBuzz = Inr(Inr(Inl(Buzz)))  // 左から2つめ

Listのメタファで考えるとわかりやすいだろう。

InlInrを省略できないのかと思ったが、この場合順序が大事なので、構造的に同じ型が登場する場合は適当に導出ができないことはすぐわかるはず。

  • Int :: Int :+: Int :: Int :+: CNilInt :: Intを入れたいとき、どうする?

Generic ふたたび

HListに対してやってくれたように、Coproductに対してもGenericは変換をサポートしてくれる。Genericマクロは、sealedなcase classを認識してGenericのインスタンスを導出し、Coproductの形に変換する。

import shapeless.Generic
sealed trait Shape
final case class Rectangle(width: Double, height: Double) extends Shape
final case class Circle(radius: Double) extends Shape
val gen = Generic[Shape]
// gen: shapeless.Generic[Shape]{type Repr = Rectangle :+: Circle :+: shapeless.CNil} = anon$macro$1$1@1a28fc61

Reprが:+:でつながった形式になっているのが分かるはず。

そして、これもto / from による変換をサポートしている。ただし、何番目の型なのかを標識するために、自動的にInlInrがひっついてくる。

gen.to(Rectangle(3.0, 4.0))
// res3: gen.Repr = Inl(Rectangle(3.0,4.0))
gen.to(Circle(1.0))
// res4: gen.Repr = Inr(Inl(Circle(1.0)))

今のところたいして魅力的ではないけれど、本によれば再帰的構造が登場したときに力を発揮するらしい。

今日はここまで。

まとめ

  • ADTsを復習した
  • HListがProductに、CoproductがCoproductに対応する
  • Generic型クラスが具体的なADTsとgenericな形式(HList + Coproduct)とを相互変換する
  • 同名のGenericマクロがcase class / sealed traitからGeneric型クラスのインスタンスを自動導出する
  • 名前で識別できないかわりに、CoproductInl / Inrで型の特殊化を表現する

Chapter3では、型クラスの自動導出についてやっていくらしい。

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