Scalaで型安全Builderパターン

NantokaBuilder().setHoge("hoge").setFoo("bar")...build()
のようにプロパティを足していって作る必要がある Nantoka クラスがあったとします。
Java でもたまに見かけますね。チェーンできると使いやすくてうれしい。
ただ、困ったことにsetXXX はいつも自分の型を返しますので、
作ってる途中で我慢できずにbuildしてもコンパイルが通り、不完全なインスタンスが出来上がってしまい、
実行→突然の死!!ということが起こります。
コンパイルが通っているからって正しい使い方をしているとは限らない。
なんとかしたい。

普通の Builder パターン

例として、Moco'sキッチンでラーメンを作ってみます。
MocosRamenBuilder に材料を足していき、最後に #cook を呼ぶとラーメンが出来上がるのですが、
オリーブオイルは必須です。当然です。麺やスープはどうでも良いですが、オリーブオイルがないのはだめです。

case class Recipe(ingredients: Map[String, Int])

object MocosRamenBuilder {

  def apply(): MocosRamenBuilder = new MocosRamenBuilder()

}

class MocosRamenBuilder(
  recipe: Recipe = Recipe(Map.empty[String, Int].withDefaultValue(0))
) {

  def men(num: Int): MocosRamenBuilder =
    new MocosRamenBuilder(
      recipe.copy(ingredients = recipe.ingredients + ("men" -> (recipe.ingredients("men") + num)))
    )

  def soup(num: Int): MocosRamenBuilder =
    new MocosRamenBuilder(
      recipe.copy(ingredients = recipe.ingredients + ("soup" -> (recipe.ingredients("soup") + num)))
    )

  def olive(num: Int): MocosRamenBuilder =
    new MocosRamenBuilder(
      recipe.copy(ingredients = recipe.ingredients + ("olive oil" -> (recipe.ingredients("olive oil") + num)))
    )

  def cook(): Unit = {
    val oliveOil = recipe.ingredients("olive oil")
    if (oliveOil == 0) {
      throw new Exception(
        """|
           |_人人 人人_
           |> 突然の死 <
           | ̄Y^Y^Y^Y ̄
           |""".stripMargin
      )
    }
    println("オリーブオイル! ラーメン!!!: olive oil: %d".format(oliveOil))
  }

}


MocosRamenBuilder().men(1).soup(1).olive(1).cook()
MocosRamenBuilder().olive(1).olive(1).soup(1).cook()
MocosRamenBuilder().men(1).soup(1).cook()

/*

オリーブオイル! ラーメン!!!: olive oil: 1
オリーブオイル! ラーメン!!!: olive oil: 2
java.lang.Exception:
_人人 人人_
> 突然の死 <
 ̄Y^Y^Y^Y ̄

        at Main$$anon$1$MocosRamenBuilder.cook(Moco.scala:33)
        at Main$$anon$1.<init>(Moco.scala:49)
        at Main$.main(Moco.scala:1)
        at Main.main(Moco.scala)
        at sun.reflect.NativeMethodAccessor
*/


このように、利用者が使い方をちょっと間違っただけでエラーとなります。危うい。

型安全なビルダーパターン

上記問題の解決策としては
「より厳重なチェック体制を引き、再発防止につとめる」のが社会的には丸いです。
あとは、=:= というトンボみたいなやつで型制約をつけることで、オリーブオイルがないのをコンパイル時に検知することができます。

case class Recipe[HasOlive](ingredients: Map[String, Int])

sealed trait HasOlive
trait Oliveあり extends HasOlive
trait Oliveなし extends HasOlive

type NotMocoRecipe = Recipe[Oliveなし]
type MocosRecipe = Recipe[Oliveあり]

object MocosRamenBuilder {
  def apply(): MocosRamenBuilder[Oliveなし] = new MocosRamenBuilder()
}

class MocosRamenBuilder[T <: HasOlive] private(
  recipe: Recipe[HasOlive] = Recipe(Map.empty[String, Int].withDefaultValue(0))
) {

  type ThisRecipe = Recipe[T]
  type This = MocosRamenBuilder[T]

  def men(num: Int): This =
    new MocosRamenBuilder(
      recipe.copy(ingredients = recipe.ingredients + ("men" -> (recipe.ingredients("men") + num)))
    )

  def soup(num: Int): This =
    new MocosRamenBuilder(
      recipe.copy(ingredients = recipe.ingredients + ("soup" -> (recipe.ingredients("soup") + num)))
    )

  def olive(num: Int): MocosRamenBuilder[Oliveあり] =
    new MocosRamenBuilder(
      recipe.copy(ingredients = recipe.ingredients + ("olive oil" -> (recipe.ingredients("olive oil") + num)))
    )

  def cook()(implicit HAS_OLIVE_OIL: ThisRecipe =:= MocosRecipe): Unit = {
    println("オリーブオイルラーメン!!!: olive oil: %d".format(recipe.ingredients("olive oil")))
  }

}

MocosRamenBuilder().men(1).soup(1).cook()

#cook の implicit HAS_OLIVE_OIL: ThisRecipe =:= MocosRecipe がミソです。
これを実行しようとしても、まずコンパイルが通りません。

t-takahashi@/Users/t-takahashi/tmp% scala KataAnzenMoco.scala
/Users/t-takahashi/tmp/KataAnzenMoco.scala:43: error: Cannot prove that this.Recipe[this.Oliveなし] =:= this.Recipe[this.Oliveあり].
MocosRamenBuilder().men(1).soup(1).cook()
                                       ^
one error found


オリーブを入れればコンパイルが通ります。

MocosRamenBuilder().men(1).soup(1).olive(100).cook()
t-takahashi@/Users/t-takahashi/tmp% scala KataAnzenMoco.scala
オリーブオイルラーメン!!!: olive oil: 100

こうして、コンパイルが通った段階でAPIの使い方が間違っていないことを保証できる仕組み」を提供できる、というわけです。


Twitter/finale での例

TwitterのFinagleがこれを利用しています。

Finagle では

val client: Service[HttpRequest, HttpResponse] = ClientBuilder()
  .codec(Http)
  .hosts(address)
  .hostConnectionLimit(1)
  .build()

のように、ちょっと長いBuilderでclientやserverのインスタンスを作ります。
私はFinagleあまり触ったことなかったんですが、その理由はこのBuilderがだるだる感かもしていたからです。
いや、実際だるいんですが、嬉しいことに、例えば address を set し忘れる、などのミスをしたらちゃんとコンパイルエラーになります。
実行時に、うええ、なんで動かないんだぜ??ってならなくてすみます。
ってことに Finagleハッカソン #finagle_hack - PARTAKEで気づきました。*1


詳しくは Twitter/finagle の *Builder.scala とかを眺めると良いと思います。Yes って文字列とかで検索してみ。


*1:あ、これこの前の#akskscalaでしゃべるの忘れた