LINEヤフー Tech Blog

LINEヤフー株式会社のサービスを支える、技術・開発文化を発信しています。

コード品質向上のテクニック:第45回 終わり null ならすべてよし?

こんにちは。コミュニケーションアプリ「LINE」のモバイルクライアントを開発している石川です。

この記事は、毎週木曜の定期連載 "Weekly Report" 共有の第 45 回です。 LINEヤフー社内には、高い開発生産性を維持するための Review Committee という活動があります。ここで集まった知見を、Weekly Report と称して毎週社内に共有しており、その一部を本ブログ上でも公開しています。(Weekly Report の詳細については、過去の記事一覧を参照してください)

終わり null ならすべてよし?

Java や Kotlin の Iteratornext を呼ぶときは、「次」の要素が存在することを確認しなければなりません (通常は hasNext で確認します)。以下のコードのように、「次」の要素がない状態で next を呼び出すと、NoSuchElementException が投げられます。

val iterator = listOf(0, 1, 2).iterator()
println(iterator.next()) // 0
println(iterator.next()) // 1
println(iterator.next()) // 2
println(iterator.next()) // throws `NoSuchElementException`

そこで、next を安全に呼び出せるように、SafeIterator というクラスを定義しました。このクラスは、要素の数を超えて next を呼び出されたとき null を返します。

class SafeIterator<T>(private vararg val elements: T) {
    private var index = 0

    fun next(): T? = elements.getOrNull(index++)
}
val iterator = SafeIterator(0, 1, 2)
println(iterator.next()) // 0
println(iterator.next()) // 1
println(iterator.next()) // 2
println(iterator.next()) // null
println(iterator.next()) // null

以下のコードは、SafeIterator をループで使う例です。

val iterator = SafeIterator(0, 1, 2)

var element = iterator.next()
while (element != null) {
    println(element) // We can handle `element` as non-null 
    element = iterator.next()
}

このようにすることで、hasNext による事前チェックを null のチェックで代替できます。smart cast や type guard、 safe call operator がある言語ならば、要素があることを保証しつつ、ランタイムエラーが起きえないコードを書けます。

しかし、この SafeIterator には問題点があります。それは何でしょうか?

幕間か終演か

この SafeIterator には、要素に nullable な型を使ったときに、要素としての null と末尾の意味の null を区別できないという問題があります。そのため、途中で走査を止めてしまうというバグを発生させかねません。例えば以下のコードでは、0 は出力される一方で 2 は出力されません。

val iterator = SafeIterator(0, null, 2)

var element = iterator.next()
while (element != null) {
    println(element) // We can handle `element` as non-null 
    element = iterator.next()
}

一般化して言うと、コレクションやラッパーを作るときに エラー/エッジケースを意味する型と、要素の型で重複を避ける 必要があります。

Option 1: エラーとして使う型を要素から除外する

最も簡単な解決策の一つとして、要素の型を「エラー/エッジケースの型を含まないもの」だけに限定する方法が挙げられます。今回の場合は、要素が non-null であることを保証すれば十分です。Kotlin の場合は、タイプパラメータの上界として Any を指定することで実現できます。

class SafeIterator<T : Any>(private vararg val elements: T) {
    private var index = 0
    fun next(): T? = elements.getOrNull(index++)
}

空の要素を表現する必要がある場合は、明示的に「空」を表現可能な型を別途用意すればよいです。以下の NullableValueOptional は空を表現可能にしたクラス定義の例です。

class NullableValue<T : Any>(val value: T?)

sealed interface Optional<out T : Any> {
    @JvmInline
    value class Some<T : Any>(val value: T) : Optional<T>
    data object None : Optional<Nothing>
}

ただし、エラー/エッジケースとして null を使う場合、このような型を定義してもなお、言語によっては安全には取り扱えない点に注意してください。

Option 2: 戻り値専用の型を定義する

Option 1 の発想とは逆に、コレクション/ラッパー側で戻り値用の型を別途定義するという方法もあります。以下のような NextResult を定義することで、null の要素がある (NextResult(null)) のか、要素自体がない (null) のかを区別できます。

class NextResult<T>(val value: T)

class SafeIterator<T>(private vararg val elements: T) {
    private var index = 0

    fun next(): NextResult<T>? = if (index < elements.size) {
        NextResult(elements[index++])
    } else {
        null
    }
}

より空の要素を明示的に表現するためには、NoSuchElement のような値を用意しても良いかもしれません。

sealed interface NextResult<T> {
    @JvmInline
    value class Exists<T>(val value: T) : NextResult<T>
    data object NoSuchElement : NextResult<Nothing>
}

class SafeIterator<T>(private vararg val elements: T) {
    private var index = 0

    fun next(): NextResult<T> = if (index < elements.size) {
        NextResult.Exists(elements[index++])
    } else {
        NextResult.NoSuchElement
    }
}

このようにすることで、要素に null があっても、終端と区別できるようになります。

val iterator = SafeIterator(0, null, 2)

var result = iterator.next()
while (result is NextResult.Exists) {
    println(result.value)
    result = iterator.next()
}

Option 3: 関数名で注意できるようにする

Kotlin の標準的な命名として、OrNullOrThrow といった表現があります。これは、要素を取得できない場合などに null を返したり、例外を投げることを意味します。型の定義を変えるのが難しい場合は、このように名前でエラー/エッジケースの取り扱いを明確にするという方法もあります。

  • nextOrThrow: 範囲外の場合は NoSuchElementException を投げる
  • nextOrNull: 範囲外の場合は null を返す

このような名前をつけることで、nextOrThrow は事前チェックが必要で、nextOrNullnull の取り扱いに注意が必要ということに気が付きやすくなります。

null は続くよどこまでも

null の取り扱いに注意が必要なケースは、getnext といった戻り値だけではありません。内部的に値を保持する場合一般に注意が必要です。例えば Kotlin の Lazy では、未初期化の状態を表現するために UNINITIALIZED_VALUE というオブジェクトを使い、null とは明確に区別しています (cf., https://github.com/JetBrains/kotlin/blob/master/libraries/stdlib/src/kotlin/util/Lazy.kt#L93 )。

一言まとめ

コレクションやラッパーを作る場合は、エラー/エッジケースを示す型と値の型の重複に気をつける。

キーワード: null, empty, collection

コード品質向上のテクニックの他の記事を読む

コード品質向上のテクニックの記事一覧