LY Corporation Tech Blog

We are promoting the technology and development culture that supports the services of LY Corporation and LY Corporation Group (LINE Plus, LINE Taiwan and LINE Vietnam).

This post is also available in the following languages. Japanese

Improving code quality - Session 45: All's well that ends null?

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: Throws NoSuchElementException if out of range
  • nextOrNull: Returns null 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

List of articles on techniques for improving code quality