狙った型を持つフィールドの値を網羅的に集めるScalaマクロ
はじめに
Scalaなどのプログラム内ではプログラマーにとって使いやすいデータ構造(型)を定義することが多い。Scalaではケースクラス(case class)でそのような値となるデータ型を定義していくが、ここから狙った型を持つフィールドの値を列挙したいときがある。たとえばケースクラス内にあるFile型の値を全て取得したいといったことがあるが、ケースクラスのフィールドの型がさらに別のケースクラスとなっていることもあり、そういったプログラムを手で書くと取得漏れといったミスの温床となるか、あるいはメンテナンス工数が膨大となってしまうと思われる。この記事ではこういったケースクラス内にある特定の型を持つフィールドの値を全て網羅的に集めてくるようなマクロをshapelessを使って定義する。
この記事で利用しているコードは下記のGitHubリポジトリーから入手できる。
この記事を読んで不明な点や改善箇所などがあれば気軽に連絡してほしい。
使い方
具体的な説明の前に、今回作ったものがどのように使えるのかについて解説する。今、次のようなケースクラスAが存在する。
case class A(
a: String,
b: Int,
c: Double,
d: Seq[String]
)
次のようにimport caseclassfilter.CaseClassFilter._することでfilterFieldTypeメソッドが使えるようになり、このメソッドの型パラメーターで与えた型を持つフィールドを次のように取得することができる。
val strings: Seq[String] = A(
a = "This will be filtered in.\n",
b = 1,
c = 3.14,
d = Seq("These", "would", "be", "collected", "too.", "\n")
).filterFieldType[String]
println(strings.mkString(" "))
This will be filtered in.
These would be collected too.
このfilterFieldTypeはケースクラスを入れ子にしても動作する。この記事ではこのようなメソッドfilterFieldTypeをどのようにして作ったのかを解説する。
型クラスCaseClassFilterの定義
まず次のような型クラス(トレイト)CaseClassFilterを定義する。
trait CaseClassFilter[A, B] {
def filterFieldType(a: A): Seq[B]
}
型パラメーターAとBはそれぞれ検索元の型とそこから取り出すことができる型を表している。たとえばUser CaseClassFilter Email[1]というインスタンスが存在すれば、これは「型Userから型Emailとなるフィールドの値を全てSeqで取り出すメソッドfilterFieldTypeを提供できる」ということを意味している。
インスタンス定義
いくつかのパターンに分けてCaseClassFilterのインスタンスを定義する。
探索対象が存在するインスタンス
このケースはBoolean CaseClassFilter Booleanのように、探していた型Aと探し元の型Bがサブタイプ関係[2](B <: A)というケースはこのようになる。
implicit def pureFilterInstance[A, B <: A]: B CaseClassFilter A =
(a: A) => Seq(a)
また、これがあればOptionやSeq、EitherやMapといったコレクションについても定義することができる。これらはだいたい同じ感じになるので、ここでは1つ代表してOptionのインスタンスを掲載する。
implicit def optionInstance[A, B](implicit
pureFilter: A CaseClassFilter B
): CaseClassFilter[Option[A], B] =
(a: Option[A]) => a.map(pureFilter.filterFieldType).getOrElse(Nil)
これは型Aから型Bが取り出せる(そういうインスタンスpureFilter: A CaseClassFilter Bが存在する)ことを前提に、Option[A]からBを取り出すインスタンスであるOption[A] CaseClassFilter Bを定義している。これは次のように場合わけすれば分かりやすい。
-
a: Option[A]がNoneのとき- 型
Aの値が存在しないためfilterFieldTypeの返り値はNilとする
- 型
-
a: Option[A]がSome(body: A)のとき-
pureFilterはbody: Aから型Bを取り出すことができるので、それを実行してfilterFieldTypeの返り値とする
-
あとは似たようにSeq、EitherやMapなどのコレクション用を作っていけばよい。
ケースクラスのインスタンス定義とshapeless
ケースクラスのようにユーザー(プログラマー)が定義した型というのは無限に存在し、それらの型に1つ1つインスタンス(implicit def/val)を与えていくことは不可能である。そこでshapelessを利用して、マクロによってユーザー定義なケースクラスをHListとCoproductへと分解することで、我々はプリミティブな型とHListとCoproductについてインスタンスを与えればほぼ全ての型のインスタンスをカバーすることができる。HListとCoproductを利用したインスタンス生成は以前の“ダミー値”を自動で作成する型クラス#HListとCoproductとマクロ(Qiita)で解説したものがあるので、ここでは解説しない。
型レベルContainsとプリミティブの除外処理
ケースクラスはshapelessで分解するとして、たとえば取り出したい値の型がStringだとしたとき、BooleanやDoubleといったプリミティブ型は絶対にStringを持たない。ナイーブには次のようにプリミティブ用のインスタンスを定義していけばいい。
// `A <: Boolean`ではないというのはあらかじめチェックずみとする
implicit val booleanInstance[A]: Boolean CaseClassFilter A =
(_: Boolean) => Nil
しかし、これをLongやDoubleなどあらゆるプリミティブな型に対して定義していくコードはボイラープレートであると言わざるを得ない。そこで型クラスContainsを作る。
trait Contains[L <: HList, A]
この型クラスはHListのサブタイプであるLにAが含まれている場合にインスタンスが存在し、そうでない場合はインスタンスが存在しないようになる型クラスである。そのようなインスタンスは次のように定義すればよい。
object Contains {
implicit def head[L <: HList, A]: ::[A, L] Contains A =
new Contains[::[A, L], A] {}
implicit def tail[L <: HList, A, B](implicit
@unused EV: L Contains A
): ::[B, L] Contains A = new Contains[::[B, L], A] {}
}
まず最初のインスタンスheadは任意のHListであるLをtailとしてA :: LにAが含まれている場合である。先頭の型Aが含まれていてほしい型であるケースであるため、この場合はインスタンスが存在してよい。
次にインスタンスtailは適当な型BとHListであるLについて、もしLにAが含まれている(つまりL Contains AとなるインスタンスEVが存在する)ならば、B :: Lにおいても型Bが含まれていると主張するものである。
このような型レベルリストに対するContainsを利用することで、プリミティブの除外を次のように書くことができる。
type EmptyTypeList =
Int :: Long :: Boolean :: Double :: String :: URI :: Null :: HNil
implicit def emptyInstance[A, B](implicit
@unused EV: EmptyTypeList Contains A
): A CaseClassFilter B =
(_: A) => Nil
プリミティブ型の型レベルリストをEmptyTypeListと定義しておいて、それに含まれていることを検査する。つまり型AがEmptyTypeListに含まれているならば、EmptyTypeList Contains AとなるインスタンスEVが存在するので、そういう型AはfilterFieldTypeとして常にNilを返しておけばよい。
また、たとえばこの型レベルリストEmptyTypeListにはFloatが含まれていないが、ここに含まれていないものを含むケースクラスから次のようにfilterFieldTypeで何かを取り出そうとするとコンパイルエラーとなる。
case class Dummy(
a: String,
b: Float
)
// compile error!
Dummy("a", (3.14).toFloat).filterFieldType[String]
インスタンス配線とimplicitパラメーターの探索順序
ここまで次のような4つのインスタンスを作った。
-
B <: AとしてB CaseClassFilter Aのように探索元と探索対象がサブタイプ関係のインスタンス - 探索元が
Optionなどのコレクションのインスタンス - shapelessを使った
HListとCoproductのインスタンス - 型レベル
Containsを利用したプリミティブのインスタンス
これらを1つのobjectに全て定義するとimplicitパラメーター探索が曖昧(
ambiguous)となってしまう。そこでScalaは継承関係によってimplicitパラメーターの探索に優先順位を与えることを利用して次のように整理する。
- (1)〜(3)までのインスタンスはトレイト
CaseClassFilterInstancesに定義 - (4)のインスタンスはトレイト
CaseClassFilterLowPriorityInstancesに定義
そのうえで次のような継承関係にする。
trait CaseClassFilterInstances
extends CaseClassFilterLowPriorityInstances
このようにすることで、まずは(1)〜(3)の「存在するかもしれない」ケースを試していき、そこにインスタンスが無いという場合のみCaseClassFilterLowPriorityInstancesからインスタンスを探すといった制御ができる。
まとめ
このようにすればケースクラスから狙った型を持つフィールドの値を取り出すことができる。たとえばバリデーション処理でURL型の値を全てチェックするといったユースケースがあると思う。依存しているshapelessは、Scala3対応に積極的らしいのでScala3でもこれは動いてくれるかもしれない。
謝辞
- 継承を使ったインスタンスの優先順序付けは@xuwei-kさんに教えていただいた
- 型レベル
Containsは@halcat0x15aさんに教えていただいた
-
Scalaは
Eitherのような型パラメーターを2つ取る型コンストラクターを中置記法でError Either Result(=Either[Error, Result])のように書くことができる。CaseClassFilterも同様に2つ型パラメーターを取るためA CaseClassFilter BはCaseClassFilter[A, B]を意味する。 ↩︎ -
一般的に任意の型
AについてAはA自身のサブタイプであるため、A <: Aが成り立つので、B <: Aというサブタイプ関係な場合には型が同一なケースであるA = Bも内包している。ここでは「探索」というニュアンスに対してより直感的には「型が同一」という言葉になるが、コードに対応する言葉としてはこちらが適切となる。 ↩︎
Discussion