Skip to content
academia.sh

Lesson 10 / 23

Safe Calls and the Elvis Operator

A measurement of where a safe call chain stops when it meets null, when the Elvis operator evaluates its right side, and which exception a not-null assertion throws.

Published Updated

In this lesson

  • Measures that a safe call chain stops at the first null value and does not evaluate the argument
  • Shows that the right side of the Elvis operator is evaluated only when the left side is null
  • Shows by catching it that a not-null assertion throws a NullPointerException on a null value
  • Verifies with the bounding measurement that the right side of the Elvis operator determines the criterion
Contents

The previous lesson showed that a null check is written with an if and that the type narrows after the check. Doing the same job where several values are stacked on top of one another requires a separate if for every step. Kotlin shortens this with two operators: the safe call ?. and the Elvis operator ?:. There is a third route as well: taking the check away from the compiler and placing it on the programmer. This lesson measures all three and looks at where each one stops when it meets null.

The measurement axis is the same as in the previous lesson: the relationship between a value and another value that counts as equal to it, the criterion. The Criterion class is given again below, as it was in the opening lesson. The rule works in order: the same object gives singleton; otherwise an unequal twin gives identity; an equal twin with an unequal third value gives content; an equal third value too gives partial content. The previous lesson measured the singleton slice of the 2/2/3/1 distribution; this lesson looks at the content slice.

// Criterion.kt — classifies the criterion that makes two values equal
class Criterion(val label: String, first: Any?, twin: Any?, other: Any?) {
    val sameObject = first === twin
    val equalTwin = first == twin
    val equalOther = first == other

    val name: String = when {
        sameObject -> "singleton"
        !equalTwin -> "identity"
        !equalOther -> "content"
        else -> "partial-content"
    }

    fun report() {
        println("$label: same-object=$sameObject, twin=$equalTwin, other=$equalOther -> $name")
    }
}

fun summarize(checks: List<Criterion>) {
    for (name in listOf("singleton", "identity", "content", "partial-content")) {
        println("$name: ${checks.count { it.name == name }}")
    }
}

The Safe Call

A safe call checks whether the receiver is null before accessing a member. If the receiver is filled in, the call is made and the result returns; if the receiver is null, the call is never made and the value of the expression is null. The result type is therefore always a nullable type: ?.length on a String? gives an Int?.

In chained safe calls, the first null value stops the chain. In an expression of the form user.address?.city?.uppercase(), if address is null, city is never looked at; if city is null, uppercase is not called. The block below measures this with three users: one fully filled in, one with no address at all, and one with an address but no city. It then asks a second question: is the argument of a method that is not called evaluated? The trace function prints a line every time it runs, so the output shows whether it ran. The last two lines ask the same question with let, whose block never runs on a null receiver.

// Main.kt — safe call chain
class Address(val city: String?)
class User(val name: String?, val address: Address?)

fun trace(label: String): String {
    println("evaluated: $label")
    return label
}

fun main() {
    val users = listOf(User("ada", Address("izmir")), User("can", null), User("eda", Address(null)))
    for (user in users) {
        println(user.address?.city?.uppercase())
    }
    val missing: String? = null
    println(missing?.plus(trace("argument")))
    val present: String? = "ab"
    println(present?.plus(trace("argument")))
    missing?.let { println("let ran") }
    present?.let { println("let ran: $it") }
}
IZMIR
null
null
null
evaluated: argument
abargument
let ran: ab

The first three lines give the behavior of the chain. For the fully filled-in user the result is IZMIR. For the user with no address, null is printed; the second link, the city, is never reached. For the user with an address but no city, null is printed as well; the third link, uppercase, is never called. All three results come from the same expression; only the place where the chain breaks changes.

The fourth line shows that when the receiver is null, the argument of the plus call is not evaluated: the evaluated: argument line does not appear after this call, only before the call whose receiver is filled in. An argument is not computed if the call will not be made. The same holds for let: on a null receiver the block does not run, and on a filled-in receiver it does, with it being the receiver itself.

TN6. This measurement covers only uppercase, plus, and let; the behavior of other methods and of a safe call in assignment form (writing a value to a property) was not tried. The argument not being evaluated was measured only with plus.

The Elvis Operator

?: produces a value: the left side itself if it is not null, and the right side if it is. The right side is evaluated only when the left side is null, just like the argument in a safe call. If the type of the left side is String?, the result of the operator is most often a non-nullable type; because if the right side gives a String, no path of the expression returns null.

The right side does not have to be a value. return and throw are expressions too, and their type counts as a subtype of all types; so name ?: return "no-name" and name ?: throw ... are valid. The first leaves the function early, the second throws an exception. If the left side is filled in, neither runs, and the following lines continue with a value of type String.

The block below measures four things. In the first two lines, does the right side’s trace call run when the left side is filled in and when it is empty? In the next two lines, the describe function leaves early with ?: return. In the last line, needName throws an IllegalArgumentException and the caller catches it; what is printed is only the exception’s class name.

// Main.kt — Elvis
fun trace(label: String): String {
    println("evaluated: $label")
    return label
}

fun describe(name: String?): String {
    val clean = name ?: return "no-name"
    return "name=" + clean
}

fun needName(name: String?): String = name ?: throw IllegalArgumentException("empty")

fun main() {
    val present: String? = "ada"
    val missing: String? = null
    println(present ?: trace("right-side"))
    println(missing ?: trace("right-side"))
    println(describe(present))
    println(describe(missing))
    try {
        needName(missing)
    } catch (e: IllegalArgumentException) {
        println(e::class.simpleName)
    }
}
ada
evaluated: right-side
right-side
name=ada
no-name
IllegalArgumentException

When the left side is filled in, trace never runs: the first line prints only ada. When the left side is empty, trace runs, the evaluated: right-side line is printed, and right-side, the value of the expression, is written. describe(present) gives name=ada and describe(missing) gives no-name; in the second, the function exits with return, so the "name=" + clean line is never reached. The last line gives the class name of the thrown exception.

TN7. return and throw were measured only in a function body and a single-expression body; the use of ?: return inside a lambda and the labeled return were not tried. The exception’s message text was not printed; only the class name was measured.

The Not-Null Assertion

The third route is to take the check away from the compiler and give the responsibility to the programmer. The not-null assertion converts a String? value to String by claiming that it is not null. If the value really is filled in, there is no problem. If the value is null, the compiler does not report an error ahead of time; the error is thrown at run time as a NullPointerException. The lesson text refers to the operator by its name, while in the code block the mark is visible.

The block below makes two calls. On the filled-in value, the assertion passes and the length is printed. On the empty value, the assertion does not hold; the try block catches the exception and prints only the class name. The exception’s message text and stack trace depend on the runtime, so they are not printed.

// Main.kt — not-null assertion
fun main() {
    val present: String? = "ada"
    val missing: String? = null
    println(present!!.length)
    try {
        println(missing!!.length)
    } catch (e: NullPointerException) {
        println(e::class.simpleName)
    }
}
3
NullPointerException

3 is printed for the filled-in value and NullPointerException for the empty one. The difference from the safe call can be read from the output: a safe call returns null for null and lets the program continue, while the assertion stops the program with an exception in the same situation. The assertion moves the error to a point the programmer has declared; the protection the compiler offers is lifted on that line.

TN8. The exception was caught only on a local variable and on a direct length call; the behavior of the assertion on a property or a function return value was not tried. The appearance of the name NullPointerException in this output is a measurement that depends on Kotlin 2.4.21’s JVM target.

The Same Expression Twice

For the course’s measurement axis, the real question is this: if the same safe call expression is run twice, what is the relationship between the results? If the receiver is filled in, uppercase produces a separate string on each call; the two results are equal but not the same object. If the receiver is null, both expressions give null, and null with null is the same object. The block below measures these two cases. The third value differs from the twin by a single letter: ADI is not ADA.

// Main.kt — same expression twice
fun main() {
    val name: String? = buildString { append("a"); append("da") }
    val missing: String? = null
    val checks = listOf(
        Criterion("full", name?.uppercase(), name?.uppercase(), "ADI"),
        Criterion("empty", missing?.uppercase(), missing?.uppercase(), "ADI"),
    )
    for (check in checks) check.report()
    summarize(checks)
}
full: same-object=false, twin=true, other=false -> content
empty: same-object=true, twin=true, other=false -> singleton
singleton: 1
identity: 0
content: 1
partial-content: 0

The full line has same-object=false and twin=true; because the third value differs, other=false comes out. The criterion is content: the two ADA values are separate objects, but == finds them equal. In the empty line, the same expression gives null and the criterion is singleton. The safe call itself does not determine a criterion; whether the receiver is filled in or empty does.

TN9. This measurement is for uppercase; whether two calls on the same receiver always produce separate objects was not tried for other string methods. The separate-object result in the full line was read from the === comparison; whether the runtime shares similar strings was not measured separately.

Operators on Lists Containing null

Operators are not limited to a single value. In a list that carries null elements, ?. and ?: are applied to each element separately. The block tries transformation, filtering, and summing functions on a List<String?>: map produces one result for each element, while mapNotNull and filterNotNull discard null results. The last two lines search for the null value itself.

// Main.kt — operators on a list containing null
fun main() {
    val names: List<String?> = listOf("ab", null, "cde")
    println(names.map { it?.length })
    println(names.mapNotNull { it?.length })
    println(names.filterNotNull().size)
    println(names.sumOf { it?.length ?: 0 })
    println(names.firstOrNull { it == null })
    println(names.indexOf(null))
}
[2, null, 3]
[2, 3]
2
5
null
1

map produces one result for each element, so the null element turns into a null result through ?.length and the list stays as [2, null, 3]. mapNotNull makes the same transformation and discards the null results, so the output is [2, 3]. filterNotNull only filters, makes the type List<String>, and two elements remain. The ?: 0 inside the sumOf block replaces the missing length with zero, and the total comes out as 5.

The last two lines search for the null value itself. firstOrNull gives the first element that satisfies the condition; even if the null element is found, the result is null, so this output cannot be told apart from the search failing. indexOf(null) reports the position and returns 1. A common mistake is to read a null result from firstOrNull as “there is no such element”. If the list can carry null elements, a position or a function such as any is chosen to ask about presence.

Operator Precedence

Because the Elvis operator has lower precedence than the addition operator, an expression written without parentheses can behave differently from how it looks. The block writes the same expression in two forms: with an addition on the right side, and with the left side wrapped in parentheses. It tries this with a filled-in and an empty value. Then ?. and ?: are used together, and both ?: and ?.plus are run on an Int?.

// Main.kt — operator precedence
fun main() {
    val missing: String? = null
    val present: String? = "x"
    println(present ?: "a" + "b")
    println((present ?: "a") + "b")
    println(missing ?: "a" + "b")
    println((missing ?: "a") + "b")
    println(missing?.length ?: -1)
    println(missing?.length.toString())
    val count: Int? = null
    println((count ?: 0) + 1)
    println(count?.plus(1))
}
x
xb
ab
ab
-1
null
1
null

The first line shows operator precedence. Because the Elvis operator has lower precedence than addition, present ?: "a" + "b" is read as present ?: ("a" + "b"). Since the left side is filled in, the right side is never computed and the result is x. When it is written with parentheses as (present ?: "a") + "b", xb is obtained. When the left side is null, the two forms give the same result, ab, so the mistake shows up only with a filled-in value and can slip past tests.

In the following lines, missing?.length ?: -1 gives -1. The line below is trickier: missing?.length.toString() prints null, but this null is a string. The toString call at the end of the chain runs on an Int? receiver and produces the string "null"; it does not short-circuit. The last two lines show the same distinction on an Int?: (count ?: 0) + 1 makes the number 1, while count?.plus(1) leaves it null. To produce a number out of a missing value, choose Elvis; to preserve the absence, choose ?..

The Bounding Measurement

This lesson’s rule is that a safe call does not give a single criterion. The same holds for the Elvis operator, but this time the criterion is determined not by the operator itself but by its right side. The left side is null in all three observations, and the expression evaluates the right side every time. The right side is written in three different forms: an object declaration (its details are in a later lesson of this course), a freshly built instance of a class whose equals has not been written, and a string built with buildString every time.

// Main.kt — by right side
object Fallback
class Wrapper(val value: Int)

fun main() {
    val nothing: String? = null
    val checks = listOf(
        Criterion("object", nothing ?: Fallback, nothing ?: Fallback, Wrapper(1)),
        Criterion("class", nothing ?: Wrapper(0), nothing ?: Wrapper(0), Wrapper(1)),
        Criterion("string", nothing ?: buildString { append("em"); append("pty") },
            nothing ?: buildString { append("em"); append("pty") }, "full"),
    )
    for (check in checks) check.report()
    summarize(checks)
}
object: same-object=true, twin=true, other=false -> singleton
class: same-object=false, twin=false, other=false -> identity
string: same-object=false, twin=true, other=false -> content
singleton: 1
identity: 1
content: 1
partial-content: 0

The same operator, the same null left side, three criteria. An object declaration gives the same instance on every evaluation, so the criterion is singleton. An ordinary class builds a separate instance on every evaluation, and because equals has not been written, the two instances do not count as equal: the criterion is identity. A string is a separate object every time, but because String is compared by content, the criterion is content. The sum of the three lines gives a 1/1/1 distribution; the operator did not change, the right side did.

This can lead to an unexpected result when using the Elvis operator. When nothing ?: Wrapper(0) is written, the expression produces a separate instance on every run; code that compares the results of two runs with == will never find them equal, unless Wrapper’s equals is written. Equality comes from the type of the right side.

TN10. The bounding measurement was made with only three observations whose left side is null; that the right side is not evaluated when the left side is filled in was taken from the measurement in the Elvis section and is not repeated here. The result of writing the class’s equals is the subject of the next lesson and was not measured here.

Summary

  • A safe call does not make the call if the receiver is null, does not evaluate the argument, and gives null as the result; the chain breaks at the first null value.
  • The right side of the Elvis operator is evaluated only when the left side is null; the right side can be return or throw.
  • The not-null assertion takes the check away from the compiler; on an empty value a NullPointerException is thrown at run time.
  • The same safe call expression gives two separate but equal strings on a filled-in receiver and two null values on an empty receiver: the first is content, the second singleton.
  • The right side determines the criterion of the Elvis operator: an object declaration is singleton, a class without equals is identity, a string is content.
  • This lesson’s criterion: content. When the same safe call is run twice, a filled-in receiver gives two separate but equal strings.

Next Step

On the right side of the Elvis operator, a class whose equals has not been written turned the criterion into identity. The next lesson measures how such classes are written and where equals comes from: the primary constructor, the init block, the secondary constructor, and properties. Why two instances with the same values, built by two different routes, do not count as equal is explained there by looking at the criterion.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close