The original article was published on October 10, 2024.
Hello, I'm Munetoshi Ishikawa, a mobile client developer for the LINE messaging app.
This article is the latest installment of our weekly series "Improving code quality". For more information about the Weekly Report, please see the first article.
All's well that ends null?
When calling next
on a Iterator
in Java or Kotlin, you must ensure that the "next" element exists (usually checked with hasNext
). If you call next
when there is no "next" element, as in the code below, a NoSuchElementException
is thrown.
val iterator = listOf(0, 1, 2).iterator()
println(iterator.next()) // 0
println(iterator.next()) // 1
println(iterator.next()) // 2
println(iterator.next()) // throws `NoSuchElementException`
To safely call next
, I defined a class called SafeIterator
. This class returns null
when next
is called beyond the number of elements.
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
The following code is an example of using SafeIterator
in a loop.
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()
}
By doing this, you can replace the pre-check with hasNext
with a check for null
. In languages with smart casts, type guards, or safe call operators, you can write code that ensures the presence of elements without runtime errors.
However, there is a problem with this SafeIterator
. What is it?
Intermission or finale?
The problem with this SafeIterator
is that when using a nullable type for elements, you cannot distinguish between null
as an element and null
as the end. This can cause a bug where the scan stops prematurely. For example, in the code below, 0
is output, but 2
is not.
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()
}
In general, when creating collections or wrappers, you need to avoid duplication between types that indicate errors/edge cases and element types.
Option 1: Exclude types used as errors from elements
One of the simplest solutions is to limit the element type to "those that do not include error/edge case types". In this case, it is sufficient to ensure that the elements are non-null. In Kotlin, this can be achieved by specifying Any
as the upper bound of the type parameter.
class SafeIterator<T : Any>(private vararg val elements: T) {
private var index = 0
fun next(): T? = elements.getOrNull(index++)
}
If you need to represent empty elements, you can explicitly prepare a type that can represent "empty". The following NullableValue
and Optional
are examples of class definitions that can represent empty.
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>
}
However, if you use null
as an error/edge case, note that even if you define such a type, it may not be handled safely depending on the language.
Option 2: Define a dedicated return type
Contrary to the idea of Option 1, there is also a method of defining a separate return type on the collection/wrapper side. By defining NextResult
as shown below, you can distinguish whether there is a null
element (NextResult(null)
) or no element at all (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
}
}
To more explicitly represent empty elements, you might want to prepare a value like 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
}
}
By doing this, you can distinguish between null
as an element and the end.
val iterator = SafeIterator(0, null, 2)
var result = iterator.next()
while (result is NextResult.Exists) {
println(result.value)
result = iterator.next()
}
Option 3: Make it noticeable with function names
In Kotlin, there are standard naming conventions like OrNull
and OrThrow
. This means returning null
or throwing an exception when an element cannot be obtained. If it is difficult to change the type definition, you can also make the handling of errors/edge cases clear with names like this.
nextOrThrow
: ThrowsNoSuchElementException
if out of rangenextOrNull
: Returnsnull
if out of range
By naming them this way, it becomes easier to notice that nextOrThrow
requires a pre-check and nextOrNull
requires careful handling of null
.
Null goes on forever
Cases where careful handling of null
is required are not limited to return values like get
or next
. It is generally necessary to be careful when holding values internally. For example, in Kotlin's Lazy
, an object called UNINITIALIZED_VALUE
is used to represent an uninitialized state, clearly distinguishing it from null (cf., https://github.com/JetBrains/kotlin/blob/master/libraries/stdlib/src/kotlin/util/Lazy.kt#L93).
In a nutshell
When creating collections or wrappers, be careful of duplication between types that indicate errors/edge cases and value types.
Keywords: null
, empty
, collection