この記事は、Develop fun!を体現する Works Human Intelligence Advent Calendar 2022 12月24日の記事です。
昨日は @satomihoya さんの記事「日々息をするようにアウトプットしている人は何を意識しているのか?」でした。
よろしければ、ぜひ他の記事もご覧になっていってください。
はじめに
こんにちは。
みなさんは、ビルダーパターン、使っていますか?
Kotlinには名前付き引数があるためあまり使う機会はありませんが、DSLなどを定義しているときなど、必要になる場合もあります。
fun person(init: PersonBuilder.() -> Unit): Person {
val builder = PersonBuilder()
builder.init()
return builder.build()
}
そうなるとやはり気になるのは、必須フィールドの扱いです。典型的なビルダーパターンの実装では、必須フィールドの指定漏れがコンパイルエラーから実行時例外になってしまうのです。
それを避ける場合、ビルダーのコンストラクタで必須フィールドを全て受け取る必要があります。
必須フィールドが少ないうちはこれでいいですが、必須フィールドが増えるとビルダーパターンである意味がなくなってきそうです。
val person = PersonBuilder()
.setAge(128)
.build() // IllegalStateException: The name field is required
val person = PersonBuilder("John") // What about data classes with many required fields?
.setAge(128)
.build()
そこで一工夫して、必須フィールドを静的にチェックできるようにします。
このパターンは、Scalaでは型安全ビルダーとして知られています。
なお、Kotlin の公式ドキュメントで書かれている Type-safe builders とは全くの別物です。
Type-safe builder by phantom types
まずは、ビルダーによって生成する型を紹介します。
このPerson
データクラスは、name
フィールドが必須フィールドで、age
フィールドがオプショナルなフィールドです。
data class Person(val name: String, val age: Int?)
次に、ビルダーを作成します。
型安全ビルダーの特徴は、型引数に状態を表す Phantom type を保持するということです。
sealed class Status {
class Ready : Status()
class NotReady : Status()
}
data class PersonBuilder<HasName : Status>(
val name: String? = null,
val age: Int? = null,
)
保持する状態とは、すなわちbuild
を呼び出せる条件が整っているかどうかです。
次のように、build
関数を全ての条件が満たされた時にのみ呼び出せるようにします。
fun PersonBuilder<Status.Ready>.build(): Person = Person(name!!, age)
そして、ビルダーを作成した時点では準備が整っていないように設定し、特定のメソッドを呼び出すと特定の条件が満たされるようにします。この場合、setName
を呼び出すと、 HasName
型パラメータに Status.Ready
が入ります。
条件に関与しないメソッドは条件を変更しません。
fun mkPersonBuilder() = PersonBuilder<Status.NotReady>()
fun PersonBuilder<*>.setName(name: String): PersonBuilder<Status.Ready> {
@Suppress("unchecked_cast") // Casts only phantom types
return copy(name = name) as PersonBuilder<Status.Ready>
}
fun <S : Status> PersonBuilder<S>.setAge(age: Int): PersonBuilder<S> {
return copy(age = age)
}
これだけで一連の道具が揃いました。ビルダーとして利用してみましょう。
次のコードはコンパイルが通ります。
val john = mkPersonBuilder()
.setName("John")
.setAge(128)
.build()
println(john) // Person(name=John, age=128)
次のコードはコンパイルが通りません。
val john = mkPersonBuilder()
// .setName("John")
.setAge(128)
.build()
println(john)
Unresolved reference. None of the following candidates is applicable because of receiver type mismatch
おわりに
簡単にScalaの型安全ビルダーをほとんどそのまま移植することができました。
シンプルかつ柔軟で、とても良いイディオムですね。
強いて言えば、setter
がビルダーの型を変えるため、このビルダーをDSLに組み込んだとしても Type-safe builders として紹介されているような使い心地とは違ってしまうところでしょうか。
person { builder ->
builder
.setName("John")
.setAge(128)
}
型引数で状態を管理する代わりにインターフェースで状態を管理して、setterを実行するとスマートキャストが行われるようにする手もあります(参考: https://pl.kotl.in/2f7hlXKmS )が、こちらはこちらで色々と面倒くさいです。
コード全文は以下にあります。