😊

[Kotlin] 最近 value class を重宝してる

2024/12/04に公開

本記事は "mhidakaが建立した Advent Calendar 2024 (Vol.2)" の記事です。この記事では技術的なことを書きます。

最近書く Kotlin コードで value class を重宝しているので、「 value class いいよ!」というのを雑にアピールしておきたい。日本語の記事だと value class を啓蒙する記事が少ないのでちょっとでも啓蒙しておくのが狙い。

Kotlin における value class は Kotlin 1.5 で導入された仕組みで、単一の値を保持するがランタイムでは内部の型の値として表現されるクラス。

https://kotlinlang.org/docs/inline-classes.html

なにが嬉しくて重宝しているか

ドメイン固有の型を示す際に多くの場合通常の class を実装することになる。その際、xxxIdyyyCode, zzzNumber といった識別子などのデータはプリミティブ型で実装することが多かった。これらのプリミティブ型をわざわざ通常の class でラップしてしまうと、実行時にクラス生成のコストがかかってしまう。

value class はこれを解決する仕組みで、別の型として定義しておきながらコンパイル時には Kotlin Compiler によって内部で持つ型にコンパイルされる。このためランタイムではクラス生成のコストは発生せず、内部でプリミティブ型を使っているなら大抵の場合ランタイムの最適化の恩恵を受けることができる。

通常プリミティブ型で実装してしまいがちな簡単な値に、別の型で制約をつけてあげることで、Kotlin という静的型付け言語から受ける恩恵をより多くすることができる。

typealias ではダメか

似たような仕組みに typealias があって、こちらもある型に別の名前をつけることができるのだけど、これは value class に比べて型の制約が弱い。以下のコードはコンパイルが通る。

より強固な型の制約が欲しいならば value class の方が制約が強い。

fun main() {
    doSomething("string value")
}

typealias NewString = String

fun doSomething(value: NewString) {

}

GitHub API での例

例として GitHub API で Issue にコメントするケースを考える。
この API では、Path のパラメータに owner repo issue_number を、Body に body を与える必要がある。

https://docs.github.com/ja/rest/issues/comments?apiVersion=2022-11-28#create-an-issue-comment

プリミティブ型で実装するケース

これをプリミティブ型で実装するならば大体このようになるかもしれない。ただこのメソッドの利用者が意図通りに引数を渡すとは限らないし、渡すべき引数を間違えた場合はランタイムで(つまり GitHub API を呼び出してみたときに)それが判明することになる。

/*
 * プリミティブ型での定義
 */
interface IssueApi {

    fun createIssueComment(
        owner: String,
        repo: String,
        issueNumber: Int,
        body: String,
    )
}

data class Issue(
    val owner: String,
    val repo: String,
    val issueNumber: Int,
)

/*
 * 呼び出し側
 */
class CarelessCaller {

    fun call() {
        val issue = getIssue()

        // 間違っているがコンパイルエラーにならない。
        // ランタイムで間違いに気がつくかもしれないし、
        // 最悪ケースでは気が付かないかもしれない。
        issueApi.createIssueComment(
            owner = issue.repo,
            repo = "issue_comment_body",
            issueNumber = -1323,
            body = issue.owner,
        )
    }
}

value class で実装するケース

value class を用いてより型の制約を強めて上げるなら、こんな感じに実装することもできる。Kotlin は静的型付け言語でコンパイル時に多くの情報を得ることができる言語であるので、コンパイル時に利用者のミスを教えてあげることが出来れば、ランタイムでのミスを防ぐ余地が大きくなる。

value class UserLogin(val value: String)
value class RepoName(val value: String)
value class IssueNumber(val value: Int)

data class Issue(
    val owner: UserLogin,
    val repo: RepoName,
    val issueNumber: IssueNumber,
)

/*
 * value class を用いた定義
 */
interface IssueApi {

    fun createIssueComment(
        owner: UserLogin,
        repo: RepoName,
        issueNumber: IssueNumber,
        body: String,
    )
}

/*
 * 呼び出し側
 */
class CarelessCaller {

    fun call() {
        val issue = getIssue()

        // コンパイルエラーで気がつくか IDE が教えてくれる
        issueApi.createIssueComment(
            owner = issue.repo,
            repo = issue.owner,
            issueNumber = 124,
            body = "issue_comment_body",
        )
    }
}

もちろん value class は通常のクラスと同様に init { } を書くことができるので、内部で持つことができる値にさらなる制約をつけてあげることもできる。

value class IssueNumber(val value: Int) {
    init {
        require(value > 0) { "IssueNumber must be positive" }
    }
}

銀の弾丸ではない

もちろん銀の弾丸ではなく、利用者側が勝手にラップしてしまうと意味をなさない。

data class Issue(
    val owner: UserLogin,
    val repo: RepoName,
    val issueNumber: IssueNumber,
)

class CarelessCaller {

    fun call() {
        val issue = getIssue()

        // 勝手にラップされてしまうと意味をなさない。
        // コンパイルエラーにならず、ランタイムで気がつくしかない
        issueApi.createIssueComment(
            owner = UserLogin(issue.repo.value),
            repo = RepoName(issue.owner.value),
            issueNumber = 124,
            body = "issue_comment_body",
        )
    }
}

まとめ

Kotlin の value class を使うと、プリミティブ型で実装してしまいがちな簡単な値に別の型で制約をつけてあげることができるので、Kotlin という静的型付け言語からより多くの恩恵を受けることができる。最近重宝しています。

value class を啓蒙してる記事

12/5 の更新

https://adventar.org/calendars/10958

https://adventar.org/calendars/10964

Discussion