Scalaは静的型付け言語で強力な型システムを備えてますが、もっと型の力を引き出したい時に 便利なのがshapelessというライブラリです。 この記事ではjsonコンバータを例にshapelessの使い方をご説明します。
あいさつ
こんにちは! オプトテクノロジーズ エンジニアの田中です。 プロダクトでは主にサーバーサイドを担当しています。 プロダクトではDBから取ってきたデータをファイル用に加工する処理などにshapelessを使っています。
shapelessとは
shapelessはScalaでpolytypicなプログラミングを実現するライブラリです。
一部でScala界のラスボスなどと呼ばれていて、 macro、 再帰的なimplicit探索など普段あまり目にしないScalaの機能が使われています。
shapelessを使っているライブラリは有名どころではspray、specs2、 最近だとtwitterが出しているRPCフレームワークの関数型ラッパーであるfinchというライブラリで使われています。
他にもtypelevelで使用例があります。
実行環境
バージョン | |
---|---|
Scala | 2.11.7 |
shapeless | 2.3.0 |
OS X | 10.11.4 |
HList
shapelessにはCoproduct、 Lens、 HMap、 Natなどありますがその中でも有名なのがHListです。 HListはHeterogeneous Listの略で複数の型を持てるListで、要素の数が決まってないタプルと言ってもいいかも知れません。
Scala標準のListとどう異なるかというと、ListはList[Int], List[String]のように一つの型しかとれません。
scala> 1 ::"scala"::Nil res: List[Any] = List(1, scala)
このように違う型を持つ要素を一つのlistに入れると共通の親クラスの型になります。 この場合はIntとStringなのでAnyです。
HListの場合はどうなるのでしょう?
import shapeless._ scala> 1::"scala"::HNil res: shapeless.::[Int,shapeless.::[String,shapeless.HNil]] = 1 :: scala :: HNil
ちょっと分かり辛いですがListと違いHListは各要素の型を失わずに持っています。 何故複数の型を持てるのでしょうか?
ListとHListの定義を見比べてみましょう。
HListとListの比較
// 普通のList sealed List[+A] case class Cons[+A](head: A, tail: List[A]) extends List[A] case object Nil extends List[Nothing] //HList sealed HList case class HCons[+A, +B <: HList](head: A, tail: B) extends HList case object HNil extends HList
普通のListと異なりConsが二つの型引数を取るため要素毎に違う型を持てるようになっています。
HListは長さが静的に決まっていて何番目の要素が何の型か分かるようになっています。 長さが静的に決まっているため範囲外の要素にアクセスしようとするとコンパイルエラーになります。 IndexOutOfBoundsExceptionではなくコンパイルエラーです。
scala> val hlist = 1::"scala"::HNil hlist: shapeless.::[Int,shapeless.::[String,shapeless.HNil]] = 1 :: scala :: HNil scala> hlist(2) <console>:15: error: Implicit not found: shapeless.Ops.At[shapeless.::[Int,shapeless.::[String,shapeless.HNil]], nat_$macro$1.N]. You requested to access an element at the position nat_$macro$1.N, but the HList shapeless.::[Int,shapeless.::[String,shapeless.HNil]] is too short. hlist(2) ^
HListを利用してjsonコンバータを作る
HListを簡単に紹介しました。 ただ、これだけだと 何が嬉しいのか分かりません。HListがあることで何ができるでしょうか。 少しScalaの基本に立ち返ってみましょう。
Scalaにはデータ型を定義する時に便利な case class
があります。
case classはequals、hashCode、toString、copyなどの便利な関数の実装を自動的に導出してくれます。
case class は便利ですが、たまにlistに変換して扱いたい時があります。 例えばUser型のcase classをjsonに変換する場合を考えてみましょう。
//これを {"name": name, "age": age}に変換したい case class User(name: String, age: Int)
StringとIntをそれぞれJsonの値に変換してUser型のfield名をkeyとすれば、 {"name": name, "age": age}
のjsonに変換できそうです。
case classはProductというtraitを継承しているのでproductIteratorというメソッドを呼べばcase classのIteratorに変換できます。
scala> User("scalaちゃん", 18).productIterator.toList res: List[Any] = List("scalaちゃん", 18)
listの要素を変換するtoJValue(elem: Any):JsonValue という関数があれば、List[JsonValue]に変換できそうです。
def toJValue(elem: Any): JsonValue = elem match { case elem: Int => JNumber(elem) case elem: String => JString(elem) }
リフレクションを使いcase classからfield名を取得しjsonに。
val user = User("scalaちゃん", 18) val fields = user.getClass.getDeclaredFields.map(_.getName) val values = user.productIterator.toList.map(toJValue) val json = fields.zip(values).map { case (field, value) => s"${field}: ${value}" }.mkString("{", ", ", "}") // {name: scalaちゃん, age: 18}
しかしこの方法では型安全性が失われてしまいます。 toJValueに渡される引数はAny型で、何が渡されるのかは実行されるまでわからないので、 独自定義型などを渡した時にパターンマッチエラーという実行時エラーになってしまうからです。
ここで各要素の型を持てるHListの出番です。
shapelessにはcase classとHListの相互変換をしてくれる Generic
があります。
val genUser = Generic[User] val hlist = genUser.to(User("scalaちゃん", 18)) // HListに変換 genUser.from(hlist) == User("scalaちゃん", 18) //HListからcase class に変換。
map関数でHListの各要素をJsonValueに変換してから普通のListに変換すれば型安全にList[JsonValue]に変換できます。
ただしHListのmap関数は普通の関数は受け取りません。 Scalaの関数はFunction traitのインスタンスなので値が多相になれないScalaでは複数の型を持てません。 面倒ですがPolyというtraitを実装したobjectを渡してやる必要があります。
object toJValue extends Poly1 { implicit val atInt = at[Int](JNumber(_)) implicit val atString = at[String](JString(_)) }
at[A](a => ...)
で Aの型に対応した動作を書けます。
これをHListのmap関数に渡すことで要素の型に対応したimplicitがtoJValueにある場合だけコンパイルが通ります。
対応していない型を渡した場合にコンパイルエラーになってくれるので型安全です。
LabelledGenericを使うとcase classのフィールド名も取得できます。
import shapeless.ops.record._ val genUser = LabelledGeneric[User] val fields: List[String] = Keys[genUser.Repr].apply.toList[Symbol].map(_.name) val values: List[JsonValue] = genUser.to(User("scalaちゃん", 18)).map(toJvalue).toList val json = fields.zip(values).map { case (field, value) => s"${field}: ${value}" }.mkString("{", ", ", "}") // {name: scalaちゃん, age: 18}
productIteratorを使った場合と違い、 もしUser型にtoJValue関数が対応していない型がある場合は実行時ではなくコンパイルエラーになってくれます。
またcase classからfield名を取得する処理はコンパイル時に行われるのでリフレクションを使っている場合と比べ速いです。
まとめ
shapelessを使ったプログラミングでは普通はリフレクションを使わないと実現できないようなことでも型安全にプログラミングできます。
他にもsealed traitと相互変換するCoproduct(余積)などの便利な機能がたくさんあるので機会があれば使ってみてはいかがでしょうか。