狙った型を持つフィールドの値を網羅的に集める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