13
2

More than 1 year has passed since last update.

Kotlinで (Scalaの) 型安全ビルダー

Last updated at Posted at 2022-12-23

この記事は、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 )が、こちらはこちらで色々と面倒くさいです。

コード全文は以下にあります。

13
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
2