まくまくKotlinノート
インラインクラスでプリミティブ型の型安全性を確保する (inline class)
2020-11-04

インラインクラスの基本

Kotlin のインラインクラスを使用すると、パフォーマンスに悪影響のないプリミティブ型ラッパークラスを作成することができます。 プライマリコンストラクタで 1 つの値(プロパティ)のみを受け取るクラスは、次のように inline キーワードを付けてインラインクラスにすることができます。

inline class Name(private val name: String)

このインラインクラスは、通常のクラスと同様に次のように使用します。

val name: Name = Name("Maku")

違いはコンパイル後のバイトコードに現れます。 上記のコードはコンパイル時にインライン展開され、次のようなコードを記述したのと同様に扱われます。

// コンパイル後のコード
val name: String = "Maku"

つまり、コンパイル時は具体的な型(Name 型)で型チェックを行いつつも、実行時にはプリミティブ型になっているのでパフォーマンスが悪化しない、といったことが実現できます。

現状、インラインクラスは、init ブロックを持てない、バッキングフィールドを持てない、といった制約がありますが、単純な getter プロパティや関数を持つことはできます。

inline class Name(private val name: String) {
    val length: Int
        get() = name.length

    fun greet() {
        println("Hello, $name")
    }
}

val name: Name = Name("Maku")  //=> val name: String = "Maku"
name.greet()                   //=> name.greet() ???

上記の name 変数はコンパイル時に String オブジェクトに置き換えられるので、name.greet() の部分でエラーになりそうですが、コンパイラがうまいこと静的関数を生成して問題なく動作するようにしてくれます。 上記のコードは次のようなコードにコンパイルされます。

val name: String = "Maku"
Name.`greet-impl`(name)

インラインクラスの用途

同一のプリミティブ型を区別したいとき

同じ型のパラメータを複数取る関数を作ると、引数の指定方法を間違える不具合が入りやすくなります。 例えば、次の searchBooks 関数は 2 つの Int 値を受け取ります。

fun searchBooks(authorId: Int, genreId: Int): Array<Book> {
    // ...
}

2 つのパラメーターは両方とも Int 型なので、次のように順序を間違えて呼び出してしまってもコンパイルエラーになってくれません。 実行時にもすぐには分からず、潜在的な不具合を埋め込んでしまう可能性があります。

val authorId: Int = getAuthorId()
val genreId: Int = getGenreId()
val books = searchBooks(genreId, authorId)  // 間違い!

次のように名前付き引数の構文を使えば、コード上は分かりやすくなりますが、相変わらず型情報は同じ Int なので、型安全性が高いとは言えません。

val books = searchBooks(authorId=authorId, genreId=genreId)

そこで、次のようにインラインクラスとして AuthorId 型と GenreId 型を定義します。

inline class AuthorId(private val authorId: Int)
inline class GenreId(private val genreId: Int)

fun searchBooks(authorId: AuthorId, genreId: GenreId): Array<Book> {
    // ...
}

こうすれば、それぞれの ID が異なる型として定義されるので、引数の指定ミスはコンパイル時に確実に発見できるようになります。

val authorId: AuthorId = ...
val genreId: GenreId = ...

val books1 = searchBooks(authorId, genreId)  // OK
val books2 = searchBooks(genreId, authorId)  // Error!

しかも、上記のインラインクラスは実際には Int 型として扱われるため、単純なラッパークラスとは違って Unboxing(AuthorId から Int への変換)のオーバーヘッドがかかりません。 つまり、速度を犠牲にせずに、型安全性を確保できます。

単位の違いを表現したいとき

長さ(m/cm/mm)、時間(時/分/秒)、ファイルサイズ(mb/kb/byte)などの単位をともなう情報は、多くの場合は IntLong などのプリミティブ型で表現されます。 このような場合、単位を表現するラッパークラスを作成すると、単位の間違いによる不具合を防ぐことができます。

まずは、すべてプリミティブの Long 型だけで済ませた例から見てみます。 次のコードは、時間を表す time が、「秒」と「ミリ秒」の 2 つの意味で使われてしまっています(不具合です)。

import java.util.concurrent.TimeUnit

// ラピュタ崩壊までの残り時間を取得
fun getRemainingTime(): Long {
    return 100  // 100秒のつもり
}

// ラピュタ崩壊までのカウントダウンを開始する
fun startTimer(time: Long) {
    TimeUnit.MILLISECONDS.sleep(time)  // time を「ミリ秒」として扱う
    println("バルス!")
}

fun main() {
    val time: Long = getRemainingTime()
    startTimer(time)
}

getRemainingTime() が返す値は「100秒」を表現しているつもりですが、この値をそのまま startTimer() に渡すと「100ミリ秒」として扱われてしまいます。 ラピュタ崩壊までいくらかの猶予(100秒)があると思っていたら、一瞬(100ミリ秒)でバルスされてしまうことになります。

このようなミスは、関数名やパラメーター名の工夫である程度防ぐことができます。

fun getRemainingTimeSeconds(): Long {
    // ...
}

fun startTimer(timeMillis: Long) {
    // ...
}

しかし、すべての関数やパラメーターにこのような単位名を付けようとすると、コードが全体的に冗長になり、可読性が下がってしまいます。 それに、ID の例でも示したように、名前で単位を示すのは型安全性という面では不安が残ります(万が一、誤用してもコンパイルエラーになりません)。

そこで、インラインクラスを使ったラッパークラスの出番です。 次の例では、秒を表現する型 Seconds と、ミリ秒を表現する型 Millis を定義しています。

// 秒を表現する型
inline class Seconds(val seconds: Long) {
    fun toMillis() = Millis(seconds * 1000)
}

// ミリ秒を表現する型
inline class Millis(val millis: Long) {
    // ...
}

fun getRemainingTime(): Seconds {
    return Seconds(100)
}

fun startTimer(time: Millis) {
    TimeUnit.MILLISECONDS.sleep(time.millis)
    println("バルス!")
}

fun main() {
    val time: Seconds = getRemainingTime()
    startTimer(time.toMillis())
    // startTimer(time)  // これは型のミスマッチによるコンパイルエラー
}

これで、時間情報を受け渡す各ポイントで型情報のチェックが入り、異なる単位の時刻情報が受け渡しされてしまうのを防ぐことができます。

もし、Seconds(100) のような記述が煩わしいと思うのであれば、次のように Long の拡張プロパティを定義してしまう方法もあります。 こうすれば、独自の数値型がもともと言語に備わっているかのようにコーディングすることができます。

// Long に拡張プロパティを追加
val Long.sec get() = Seconds(this)
val Long.ms get() = Millis(this)

// 使用例
val timeMillis: Millis = 50.ms

50 と記述する代わりに、50.ms と記述するだけで Millis 型のインスタンスを扱えます。

インラインクラスと typealias の違い

Kotlin の typealias を使うと、既存の型のエイリアスを作成することができます。

typealias Seconds = Long
typealias Millis = Long

この仕組みを使うと、インラインクラスを使った場合と同じようなコードを記述できますが、意味はまったく異なります。 インラインクラスが新しい型を定義しているのに対し、typealias はあくまで別名を付けているだけです。 つまり、上記のように定義した別名 SecondsMillis は、Long と等価(同じ型)の扱いになります。

typealias Seconds = Long
typealias Millis = Long

fun getRemainingTime(): Seconds = 100
fun startTimer(time: Millis) {}

fun main() {
    // Seconds を Long に入れてもエラーにならない!
    val seconds: Long = getRemainingTime()

    // Long を Millis として扱ってもエラーにならない!
    startTimer(seconds)
}

このような場面で型安全性を確保するには、typealias で別名を付けるのではなく、インラインクラス(によるラッパークラス)で新しい型を定義する必要があります。

toString はオーバーライドした方がいいかもしれない

既存の Int 変数などをインラインクラスに置き換える場合は、toString() の戻り値が変化することに注意してください。 例えば、下記のように Int 変数の toString() を呼び出している部分があるとします。

val n = 100
val s: String = n.toString()  //=> "100"

これを、インラインクラスに置き換えると、toString() の値が次のように変わります。

inline class GenreId(val value: Int)

val n = GenreId(100)
val s: String = n.toString()  //=> "GenreId(value=100)"

このような振舞いの変化を防ぎたい場合は、次のように toString() をオーバーライドしておくと安心です。

inline class GenreId(val value: Int) {
    override fun toString(): String = value.toString()
}

val n = GenreId(100)
val s: String = n.toString()  //=> "100"

コンパイル時の警告について

Experimental feature 警告

インラインクラスは現状 Experimental feature として提供されているので、使用しようとすると、

The feature "inline classes" is experimental

といった警告がでることがあります。 インラインクラスが正式リリースされれば、この警告は消えるのでそのままにしておいてもよいのですが、気になる場合は次のように警告を抑制することができます。

方法1: ファイルの先頭にアノテーションを記述する方法

@file:Suppress("EXPERIMENTAL_FEATURE_WARNING")

方法2: 対象コードの直前にアノテーションを記述する方法

@Suppress("EXPERIMENTAL_FEATURE_WARNING")
inline class CategoryId(val id: Int)

方法3: build.gradle (.kts) にコンパイラオプションを指定する方法

# build.gradle
compileKotlin {
    kotlinOptions.freeCompilerArgs += ["-Xinline-classes"]
}

# build.gradle.kts
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
    kotlinOptions.freeCompilerArgs += "-Xinline-classes"
}

いずれも、Android Studio を使用している場合は、inline にカーソルを合わせて Alt + Enter で自動入力できます。

1 ファイルでたくさん inline class 定義するとき

次のように 1 ファイル内でたくさんインラインクラスを定義している場合、detekt などの静的解析で警告が発生することがあります。

types.kt

package com.example.myapp

inline class GenreId(val id: Int)
inline class EventId(val id: Int)
class GenreId should be declared in a file named GenreId.kt

例えば、detekt であれば、@Suppress アノテーションを使ってこの警告を抑制 することができます。

@file:Suppress(
    "MatchingDeclarationName",  // 「ファイル名=公開クラス名」のチェックを抑制
    "EXPERIMENTAL_FEATURE_WARNING"  // inline class の experimental 警告を抑制
)
package com.example.myapp

// ...
2020-11-04