🏗️

Intersection Types を利用した Type Safe Builder の改善

2021/11/16に公開

Builder パターン

Java では Builder[1] と呼ばれるパターンが使われる事があります。

これは多くのオプショナルなパラメータを持つオブジェクトの生成において、Python等にあるような名前付き引数呼び出しをエミュレートするための方法として生まれました。

// Builder パターンの利用例
Contract contract = Contract.builder("gakuzzzz")       // 必須パラメータ
                            .mail("test@example.com")  // オプショナル
                            .lineId("xxxxxxxxx")       // オプショナル
                            .build();
# Python 等であれば名前付き引数呼び出しで事足りる
contract = Contract(name = "gakuzzzz", mail = "test@example.com")

Type Safe Builder パターン

Scala においても、名前付き引数呼び出しは普通に使えるのでこのBuilderパターンは基本的には使われません。

ただし、これを拡張した Type Safe Builder[2] と呼ばれるパターンは時折使われることがあります。

Type Safe Builder とは、オブジェクトの生成に何らかの制約がある際、その制約が満たされた時だけ Builder が実行できるように型レベルで保証したパターンです。

具体例を見ていきましょう。

class Contract private (
  val name: String,    // 必須
  val mail: Option[String] = None,    
  val lineId: Option[String] = None,
) {
  // mail か lineId のどちらかは必ず必要
  require(mail.isDefined || lineId.isDefined)
}

人工的な例ですが、上記の ContractmaillineId というオプショナルなパラメータを持っていますが、必ずどちらかは設定されているという制約を持っています。両方設定される方が望ましいため Either を使うことも要件的にできません。

この Contract クラスを単純に new する場合、制約を満たさなければ実行時エラーになってしまいます。

そこで以下の様に制約を満たさない build メソッドの呼び出しをコンパイルエラーにする Builder を定義しようというのが Type Safe Builder パターンになります。

val contract = Contract.builder
                 .mail("test@example.com")
                 .lineId("xxxxx")
		 .build // name が未設定なのでコンパイルエラー
val contract = Contract.builder
                 .name("gakuzzzz")
                 .mail("test@example.com")
		 .build // コンパイル通る!

従来の Type Safe Builder の実装

この章では従来使われていた Type Safe Builder の実装方法を示します。本題は次の章なので、既に実装方法をご存知の方はこの章は読み飛ばして頂いて大丈夫です。

さて、ではこの Type Safe Builder はどの様に作ればいいのかというと、いわゆる Phantom Type(幽霊型) を使って実現されています。

例として今まで出てきた ContractBuilder を見てみましょう。

object Contract {
  trait Ok
  trait Ng

  class Builder[HasName, HasContactInfo] private (
    name: String, mail: Option[String], lineId: Option[String],
  ) {
    def name(name: String): Builder[Ok, HasContactInfo] =
      new Builder(name, mail, lineId)

    def mail(mail: String): Builder[HasName, Ok] =
      new Builder(name, Option(mail), lineId)

    def lineId(lineId: String): Builder[HasName, Ok] =
      new Builder(name, mail, Option(lineId))

    def build(using HasName =:= Ok, HasContactInfo =:= Ok): Contract =
      new Contract(name, mail, lineId)
  }
  def builder: Builder[Ng, Ng] = new Builder("", None, None)
}

分解して見ていきましょう。

object Contract {
  trait Ok
  trait Ng

まず事前準備として、制約を満たしているか否かを型レベルで表現できるように Ok と Ng を表すそれぞれの型を用意しておきます。

そして Builder クラスには満たすべき制約を全て型パラメータとして表現します。

  class Builder[HasName, HasContactInfo] private (
    ...

ここでは名前を持つ制約として HasName を、MailアドレスかLINE IDのどちらかを持つ制約として HasContactInfo を型パラメータとして表現している形ですね。

そして Builder インスタンスの生成メソッドでは、全ての型引数を Ng として宣言します。

  def builder: Builder[Ng, Ng] = new Builder("", None, None)

制約を満たす設定メソッドでは該当の制約を表す型引数を Ok にし、関係ない制約はそのまま引き継いで戻り値型を宣言します。

  class Builder[HasName, HasContactInfo] private (
    ...
    def name(name: String): Builder[Ok, HasContactInfo] = 
      new Builder(name, mail, lineId)

そうする事で、全ての制約を満たしていれば Builderインスタンスの型引数は全て Ok になります。

val b: Builder[Ok, Ok] = Contract.builder
                                 .name("gakuzzzz")
                                 .mail("test@example.com")

満たさない制約があれば、型引数のどこかに Ng が現れます。

val b: Builder[Ng, Ok] = Contract.builder
                                 .mail("test@example.com")
                                 .lineId("xxxxx")

そして build メソッドの呼び出しを全ての型引数が Ok の時だけ呼び出せるようにすれば制約を満たさない呼び出しをコンパイルエラーにする事ができます。

    def build(using HasName =:= Ok, HasContactInfo =:= Ok): Contract =
      new Contract(name, mail, lineId)

Intersection Types を使った改善

さて、実はここからが本題です。

これまで見てきた実装でも十分機能するのですが、必要な制約を全て型パラメータとして表現するため、制約の数が多い場合に Builder を定義するのは非常に大変でした。

そこで Intersection Types を使うことで、より簡単に Type Safe Builder を定義できるようになったので、その方法を紹介します。

では先の Contract.Builder を書き換えてみましょう。

object Contract {
  trait HasName
  trait HasContactInfo

  class Builder[Status] private (
    name: String, mail: Option[String], lineId: Option[String],
  ) {
    def name(name: String): Builder[Status & HasName] =
      new Builder(name, mail, lineId)

    def mail(mail: String): Builder[Status & HasContactInfo] =
      new Builder(name, Option(mail), lineId)

    def lineId(lineId: String): Builder[Status & HasContactInfo] =
      new Builder(name, mail, Option(lineId))

    def build(using Status =:= (HasName & HasContactInfo)): Contract =
      new Contract(name, mail, lineId)
  }
  def builder: Builder[Any] = new Builder("", None, None)
}

今度は制約の一つ一つを型パラメータにするのではなく、制約毎に型を定義します。

そして型パラメータには現在満たしている制約を表現するためのパラメータを一つ取ります。

object Contract {
  trait HasName
  trait HasContactInfo

  class Builder[Status] private (
    ...

Builder インスタンスの生成メソッドでは、型引数を Any として宣言します。

  def builder: Builder[Any] = new Builder("", None, None)

制約を満たす設定メソッドでは当該の制約と現在の状態の Intersection Types を返すようにします。

  class Builder[Status] private (
    ...
    def name(name: String): Builder[Status & HasName] =
      new Builder(name, mail, lineId)

そして build メソッドのでは、型引数が全ての制約の Intersection Types の時だけ呼び出せるようにすれば制約を満たさない呼び出しをコンパイルエラーにする事ができます。

    def build(using Status =:= (HasName & HasContactInfo)): Contract =
      new Contract(name, mail, lineId)

制約の数が多い時は別名をつけた方がわかりやすくなるかもしれません。

  type Mandatory = HasName & HasContactInfo & ...
  ...
    def build(using Status =:= Mandatory): Contract = 
      new Contract(name, mail, lineId)

こうして Intersection Types で型パラメータの数を減らし簡潔に Builder を定義することができました。

めでたしめでたし。

備考

この方式を自分が知ったのは kodai さんのツイートからでした。

実のところ Intersection Types の代わりに Compound types (with) を使うことで近しい事は可能でした。

object Contract {
  trait HasName
  trait HasContactInfo

  class Builder[Status] private (
    name: String, mail: Option[String], lineId: Option[String],
  ) {
    def name(name: String): Builder[Status with HasName] =
      new Builder(name, mail, lineId)

    def mail(mail: String): Builder[Status with HasContactInfo] =
      new Builder(name, Option(mail), lineId)

    def lineId(lineId: String): Builder[Status with HasContactInfo] =
      new Builder(name, mail, Option(lineId))

    def build(implicit ev: Status =:= (HasName with HasContactInfo)): Contract =
      new Contract(name, mail, lineId)
  }
  def builder: Builder[Any] = new Builder("", None, None)
}

ただし Intersection Types と異なり Compound types の場合は A with BB with A が Trait Linearization の関係で厳密には同じ型にならない場合があるので、素直に表現できる Intersection Types が使える場合はこちらを使う方が望ましいと思います。

脚注
  1. ここでは GoFの「オブジェクト指向における再利用のためのデザインパターン」で示されている Builder パターンではなく「Effective Java」で示されている Builder パターンの方を指しています。 ↩︎

  2. Kotlin でも Type Safe Builder と呼ばれるパターンがありますが、これはまた異なるパターンです。 ↩︎

Discussion