狙った型を持つフィールドの値を網羅的に集めるScalaマクロ

公開:2020/11/29
更新:2020/12/14
7 min読了の目安(約6400字TECH技術記事

はじめに

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を定義する。

CaseClassFilter.scala
trait CaseClassFilter[A, B] {
  def filterFieldType(a: A): Seq[B]
}

型パラメーターABはそれぞれ検索元の型とそこから取り出すことができる型を表している。たとえばUser CaseClassFilter Email[1]というインスタンスが存在すれば、これは「型Userから型Emailとなるフィールドの値を全てSeqで取り出すメソッドfilterFieldTypeを提供できる」ということを意味している。

インスタンス定義

いくつかのパターンに分けてCaseClassFilterのインスタンスを定義する。

探索対象が存在するインスタンス

このケースはBoolean CaseClassFilter Booleanのように、探していた型Aと探し元の型Bがサブタイプ関係[2]B <: A)というケースはこのようになる。

CaseClassFilterInstances.scala
implicit def pureFilterInstance[A, B <: A]: B CaseClassFilter A =
  (a: A) => Seq(a)

また、これがあればOptionSeqEitherMapといったコレクションについても定義することができる。これらはだいたい同じ感じになるので、ここでは1つ代表してOptionのインスタンスを掲載する。

CaseClassFilterInstances.scala
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)のとき
    • pureFilterbody: Aから型Bを取り出すことができるので、それを実行してfilterFieldTypeの返り値とする

あとは似たようにSeqEitherMapなどのコレクション用を作っていけばよい。

ケースクラスのインスタンス定義とshapeless

ケースクラスのようにユーザー(プログラマー)が定義した型というのは無限に存在し、それらの型に1つ1つインスタンス(implicit def/val)を与えていくことは不可能である。そこでshapelessを利用して、マクロによってユーザー定義なケースクラスをHListCoproductへと分解することで、我々はプリミティブな型とHListCoproductについてインスタンスを与えればほぼ全ての型のインスタンスをカバーすることができる。HListCoproductを利用したインスタンス生成は以前の“ダミー値”を自動で作成する型クラス#HListとCoproductとマクロ(Qiita)で解説したものがあるので、ここでは解説しない。

型レベルContainsとプリミティブの除外処理

ケースクラスはshapelessで分解するとして、たとえば取り出したい値の型がStringだとしたとき、BooleanDoubleといったプリミティブ型は絶対にStringを持たない。ナイーブには次のようにプリミティブ用のインスタンスを定義していけばいい。

// `A <: Boolean`ではないというのはあらかじめチェックずみとする
implicit val booleanInstance[A]: Boolean CaseClassFilter A =
  (_: Boolean) => Nil

しかし、これをLongDoubleなどあらゆるプリミティブな型に対して定義していくコードはボイラープレートであると言わざるを得ない。そこで型クラスContainsを作る。

CaseClassFilterInstances.scala
trait Contains[L <: HList, A]

この型クラスはHListのサブタイプであるLAが含まれている場合にインスタンスが存在し、そうでない場合はインスタンスが存在しないようになる型クラスである。そのようなインスタンスは次のように定義すればよい。

CaseClassFilterInstances.scala
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 :: LAが含まれている場合である。先頭の型Aが含まれていてほしい型であるケースであるため、この場合はインスタンスが存在してよい。
次にインスタンスtailは適当な型BHListであるLについて、もしLAが含まれている(つまりL Contains AとなるインスタンスEVが存在する)ならば、B :: Lにおいても型Bが含まれていると主張するものである。
このような型レベルリストに対するContainsを利用することで、プリミティブの除外を次のように書くことができる。

CaseClassFilterInstances.scala
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と定義しておいて、それに含まれていることを検査する。つまり型AEmptyTypeListに含まれているならば、EmptyTypeList Contains AとなるインスタンスEVが存在するので、そういう型AfilterFieldTypeとして常にNilを返しておけばよい。
また、たとえばこの型レベルリストEmptyTypeListにはFloatが含まれていないが、ここに含まれていないものを含むケースクラスから次のようにfilterFieldTypeで何かを取り出そうとするとコンパイルエラーとなる。

case class Dummy(
  a: String,
  b: Float
)

// compile error!
Dummy("a", (3.14).toFloat).filterFieldType[String]

インスタンス配線とimplicitパラメーターの探索順序

ここまで次のような4つのインスタンスを作った。

  1. B <: AとしてB CaseClassFilter Aのように探索元と探索対象がサブタイプ関係のインスタンス
  2. 探索元がOptionなどのコレクションのインスタンス
  3. shapelessを使ったHListCoproductのインスタンス
  4. 型レベルContainsを利用したプリミティブのインスタンス

これらを1つのobjectに全て定義するとimplicitパラメーター探索が曖昧(
ambiguous)となってしまう。そこでScalaは継承関係によってimplicitパラメーターの探索に優先順位を与えることを利用して次のように整理する。

  • (1)〜(3)までのインスタンスはトレイトCaseClassFilterInstancesに定義
  • (4)のインスタンスはトレイトCaseClassFilterLowPriorityInstancesに定義

そのうえで次のような継承関係にする。

CaseClassFilterInstances.scala
trait CaseClassFilterInstances
  extends CaseClassFilterLowPriorityInstances

このようにすることで、まずは(1)〜(3)の「存在するかもしれない」ケースを試していき、そこにインスタンスが無いという場合のみCaseClassFilterLowPriorityInstancesからインスタンスを探すといった制御ができる。

まとめ

このようにすればケースクラスから狙った型を持つフィールドの値を取り出すことができる。たとえばバリデーション処理でURL型の値を全てチェックするといったユースケースがあると思う。依存しているshapelessは、Scala3対応に積極的らしいのでScala3でもこれは動いてくれるかもしれない。

謝辞

  • 継承を使ったインスタンスの優先順序付けは@xuwei-kさんに教えていただいた
  • 型レベルContains@halcat0x15aさんに教えていただいた
脚注
  1. ScalaはEitherのような型パラメーターを2つ取る型コンストラクターを中置記法でError Either Result(= Either[Error, Result])のように書くことができる。CaseClassFilterも同様に2つ型パラメーターを取るためA CaseClassFilter BCaseClassFilter[A, B]を意味する。 ↩︎

  2. 一般的に任意の型AについてAA自身のサブタイプであるため、A <: Aが成り立つので、B <: Aというサブタイプ関係な場合には型が同一なケースであるA = Bも内包している。ここでは「探索」というニュアンスに対してより直感的には「型が同一」という言葉になるが、コードに対応する言葉としてはこちらが適切となる。 ↩︎