[Kotlin] 最近 value class を重宝してる
本記事は "mhidakaが建立した Advent Calendar 2024 (Vol.2)" の記事です。この記事では技術的なことを書きます。
最近書く Kotlin コードで value class
を重宝しているので、「 value class
いいよ!」というのを雑にアピールしておきたい。日本語の記事だと value class
を啓蒙する記事が少ないのでちょっとでも啓蒙しておくのが狙い。
Kotlin における value class
は Kotlin 1.5 で導入された仕組みで、単一の値を保持するがランタイムでは内部の型の値として表現されるクラス。
なにが嬉しくて重宝しているか
ドメイン固有の型を示す際に多くの場合通常の class
を実装することになる。その際、xxxId
や yyyCode
, 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
を与える必要がある。
プリミティブ型で実装するケース
これをプリミティブ型で実装するならば大体このようになるかもしれない。ただこのメソッドの利用者が意図通りに引数を渡すとは限らないし、渡すべき引数を間違えた場合はランタイムで(つまり 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 を啓蒙してる記事
- https://www.wantedly.com/companies/loglass/post_articles/462977
- https://zenn.dev/loglass/articles/1e75f6d390f694
12/5 の更新
Discussion