こんにちは。コミュニケーションアプリ「LINE」のモバイルクライアントを開発している石川です。
この記事は、毎週木曜の定期連載 "Weekly Report" 共有の第 45 回です。 LINEヤフー社内には、高い開発生産性を維持するための Review Committee という活動があります。ここで集まった知見を、Weekly Report と称して毎週社内に共有しており、その一部を本ブログ上でも公開しています。(Weekly Report の詳細については、過去の記事一覧を参照してください)
終わり null ならすべてよし?
Java や Kotlin の Iterator
で next
を呼ぶときは、「次」の要素が存在することを確認しなければなりません (通常は 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++)
}
空の要素を表現する必要がある場合は、明示的に「空」を表現可能な型を別途用意すればよいです。以下の NullableValue
や Optional
は空を表現可能にしたクラス定義の例です。
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 の標準的な命名として、OrNull
や OrThrow
といった表現があります。これは、要素を取得できない場合などに null
を返したり、例外を投げることを意味します。型の定義を変えるのが難しい場合は、このように名前でエラー/エッジケースの取り扱いを明確にするという方法もあります。
nextOrThrow
: 範囲外の場合はNoSuchElementException
を投げるnextOrNull
: 範囲外の場合はnull
を返す
このような名前をつけることで、nextOrThrow
は事前チェックが必要で、nextOrNull
は null
の取り扱いに注意が必要ということに気が付きやすくなります。
null は続くよどこまでも
null
の取り扱いに注意が必要なケースは、get
や next
といった戻り値だけではありません。内部的に値を保持する場合一般に注意が必要です。例えば Kotlin の Lazy
では、未初期化の状態を表現するために UNINITIALIZED_VALUE
というオブジェクトを使い、null とは明確に区別しています (cf., https://github.com/JetBrains/kotlin/blob/master/libraries/stdlib/src/kotlin/util/Lazy.kt#L93 )。
一言まとめ
コレクションやラッパーを作る場合は、エラー/エッジケースを示す型と 値の型の重複に気をつける。
キーワード: null
, empty
, collection