Lesson 09 / 23
Null Safety
A measurement of how nullable and non-nullable types are separate types, how a type narrows after a null check, and which criterion null equal to null produces.
Published Updated
In this lesson
- Shows the distinction between String and String? through the compiler's rejection
- Measures that a smart cast narrows the type after a null check
- Shows that two null values count as the same object and the criterion comes out singleton
- Verifies with the bounding measurement that the criterion stops at the first question and the later questions are never consulted
Contents
The previous lesson measured which value a scope function returns. Sometimes what comes back is nothing at all: a search finds no result, a field has not been filled in, a conversion cannot finish. That situation can be expressed with a value such as null; the value then has to be checked before every use, and when the check is forgotten the error surfaces at run time.
Kotlin writes absence into the type itself. A String is always a string; a String? is either a string or null. These two types are not the same type, and the compiler does not let you use one in place of the other. This is called null safety: a nullable type and a non-nullable type are separate types, so every place where null is possible is visible in the source code. This lesson measures that distinction through the rejection the compiler issues, then looks at what the same distinction says about the equality question.
The course’s measurement axis is the relationship between a value and another value that counts as equal to it. A criterion takes one of four forms: singleton, identity, content, and partial content. The rule works in order. If the first value and its twin are the same object, the criterion is singleton. If not, and the twin is not equal, it is identity: only the same object counts as equal. If the twin is equal but a third value that differs from the first in one respect is not equal, it is content: the objects are separate and the equality comes from the content. If the third value is also equal, it is partial content: the respect in which it differs does not affect equality. In the binding measurement of the opening lesson the distribution over eight values was 2/2/3/1; this lesson measures only its own slice.
The Criterion class that does the classification is given below as it is. Code blocks are compiled and run only inside the sandbox; every line in the output blocks is what the sandbox printed.
// 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 parameters have type Any?, which means null can be an argument too. This lesson’s first question is exactly that: when null is treated as a value, which criterion does it produce?
Two Separate Types
In the block below, a String and a String? with the same content stand side by side. The comparison compiles, because == does not require the two sides to have matching types; it looks only at the content. But an operation that depends on the type, such as taking the length, is trouble-free only on the non-nullable type.
// Main.kt — two types
fun main() {
val a: String = "cube"
val b: String? = "cube"
println(a == b)
println(a.length)
val c: String? = null
println(c == null)
println(c)
}
true 4 true null
a == b is true: both strings carry the same content, and the filled-in state of the String? type is compared as a string. c carries null, and null is written to the screen; printing a nullable variable is not an error. The point to notice is that in the a.length line a is a non-nullable type. Writing the same line with b would lead to a compiler rejection.
There are two rejections, and both were measured in the sandbox. The first is assigning null to a variable of type String. In Kotlin 2.4.21 the compiler rejects this: null cannot be a value of a non-nullable type. The error appears at compile time, and the program never runs.
fun main() {
val text: String = null
println(text)
}
The second is asking for length directly on a variable of type String?. The compiler rejects this as well: when the receiver is a nullable type, only a safe call or a not-null assertion is allowed. The safe call is the subject of the next lesson; the not-null assertion is also measured there, together with its failure.
fun main() {
val text: String? = "cube"
println(text.length)
}
TN1. These two rejections were measured only for a local variable; they were not tried separately for a class property, a function parameter, or a return type. The error text depends on the compiler version and is not reproduced here; what was measured is that the program never runs.
The Type Narrows After a Null Check
To get the length of a String? value, you first have to make sure it is not null. Kotlin does not make you write a separate conversion for this. When a null check is made inside an if, the compiler narrows the variable’s type to String within that branch. This behavior is called a smart cast: the programmer writes no conversion, and the check itself changes the type.
In the block below, the lengthOf function takes a String?; inside the if (text != null) branch, text.length is called directly. The block also filters a list containing two null values with filterNotNull; the result is a List<String> rather than a List<String?>, and its elements give length without any conversion.
// Main.kt — smart cast
fun lengthOf(text: String?): Int {
if (text != null) {
return text.length
}
return -1
}
fun main() {
println(lengthOf("cube"))
println(lengthOf(null))
val values = listOf("a", null, "bb", null)
println(values.size)
val present = values.filterNotNull()
println(present.size)
println(present.sumOf { it.length })
}
4 -1 4 2 3
lengthOf("cube") gives four, and lengthOf(null) gives -1. The list has four elements, but filterNotNull discards the two null values and leaves two strings; the sum of their lengths is three. What is measured here is that a null check is not merely a condition: after the check, the same variable is a different type.
TN2. The smart cast was measured only through if and filterNotNull; it was not tried for a check inside when, for checks chained with &&, or for when the narrowing breaks with mutable variables. The narrowing holds as long as the variable is known not to change between the check and the use.
Where the Question Mark Goes
The ? mark can be written in only one place of a type, and where it goes changes the meaning. List<String?> is a list whose elements can be empty: the list exists, and some places inside it are null. List<String>? is a type whose list itself can be empty: the list either exists or it does not, but if it exists, none of its elements is null. The two are independent, and both can be written at the same time.
The block below puts the two cases side by side. The size of the list with nullable elements is read directly, because the list itself cannot be null. With the type whose list itself is nullable, a null check is needed first; after the check, the same variable behaves as a List<String> and size is called directly.
// Main.kt — where the question mark goes
fun main() {
val items: List<String?> = listOf("a", null)
val maybeList: List<String>? = null
println(items.size)
println(maybeList == null)
if (maybeList != null) {
println(maybeList.size)
} else {
println("no-list")
}
}
2 true no-list
TN3. This measurement deals not with an empty list but with a list that is absent altogether (null); the behavior of an empty but existing list was not tried. The List<String?>? form, in which both ? marks are written together, was not measured either.
null with null
On the course’s measurement axis, the real question is this: are two null values the same object? In Kotlin null is not an instance of a class but a single value. Every null is the same as every other; there is no identity that could tell one from another. As a result, referential equality (===) and structural equality (==) give the same answer for two null values. The rule stops at the first question: if the first value and its twin are the same object, the criterion is singleton and the remaining questions are not consulted.
The block below has four observations. In the first two, the first value and its twin are null, and the third value is filled in on one and null again on the other. The third and fourth observations use filled-in strings: the two strings are built separately with buildString, so two separate objects carrying the same content are obtained.
// Main.kt — null with null
fun main() {
val nullFirst: String? = null
val nullTwin: String? = null
val full1 = buildString { append("cu"); append("be") }
val full2 = buildString { append("cu"); append("be") }
val checks = listOf(
Criterion("null-null", nullFirst, nullTwin, "cube"),
Criterion("null-null-null", nullFirst, nullTwin, null),
Criterion("full-full", full1, full2, "another"),
Criterion("full-null", full1, nullTwin, "another"),
)
for (check in checks) check.report()
summarize(checks)
}
null-null: same-object=true, twin=true, other=false -> singleton null-null-null: same-object=true, twin=true, other=true -> singleton full-full: same-object=false, twin=true, other=false -> content full-null: same-object=false, twin=false, other=false -> identity singleton: 2 identity: 1 content: 1 partial-content: 0
The result reads off four lines. null with null is singleton. Two filled-in strings, with the same content but as separate objects, give the content criterion: same-object=false, twin=true, other=false. A filled-in string against null is identity: the twin is not equal. The criterion for the filled-in strings is the result of String’s own equals, not of any type redefined in this lesson.
There is a trap here: because the criterion for two null values came out singleton, you cannot conclude that everything compared with null is singleton. Only null with null is singleton. A filled-in value against null is identity, because the twin is not equal.
TN4. This measurement covers only null values of type String?; whether two null values of another type count as the same object was not tried. That the filled-in strings are separate objects was read from the === result when they were built with buildString; whether constant strings are shared as the same object was not tried.
Ready-Made Functions on a Nullable Value
A null check is not always written with an if. The standard library offers a few ready-made functions whose receiver is String?: toString, isNullOrEmpty, and orEmpty. These functions do not throw an exception on a null receiver, because their definitions account from the start for the receiver possibly being null. The block below tries these functions with three values (null, an empty string, and a filled-in string). It also looks at what comes out when a string is concatenated with null.
// Main.kt — ready-made functions on a nullable receiver
fun main() {
val missing: String? = null
val empty: String? = ""
val present: String? = "ab"
println(missing.toString())
println("[" + missing + "]")
println(missing.isNullOrEmpty())
println(empty.isNullOrEmpty())
println(present.isNullOrEmpty())
println(missing == null)
println(missing.orEmpty().length)
}
null [null] true true false true 0
Three things can be read from the output. First, missing.toString() throws no exception; on a null receiver it gives the string "null". The null in the first line is therefore a string, not a null value. Second, concatenating a string with null works too, and the result is [null]: String.plus takes an Any? as its argument, so the compiler raises no objection; the side that can be null is the argument on the right. Third, isNullOrEmpty returns true for both null and the empty string, and false for the filled-in string; orEmpty converts null into the empty string, so the length comes out as 0.
What these functions have in common is that the receiver type is String?. An ordinary String method cannot be called directly on this type, because the receiver may be absent. A common mistake is confusing the result of toString with a null value. The type of the result is always String; measuring the absence of a value by searching for the string "null" is wrong. Absence is asked about with == null. When code writes a string to a log, the two cases looking the same is the main reason the error is noticed late.
Type Conversion and Null
Type conversion meets null as well. The as operator forces the conversion, as? gives null when it fails, and is only asks a question. The block tries all three on an Any? whose value is null; if the conversion fails, only the class name of the thrown exception is printed. In the last line, a filled-in value of the wrong type is converted with as?.
// Main.kt — as and as? on null
fun main() {
val value: Any? = null
println(value as? String)
println(value is String)
println(value is String?)
try {
val text = value as String
println(text.length)
} catch (e: Exception) {
println(e::class.simpleName)
}
val other: Any? = 5
println(other as? String)
}
null false true NullPointerException null
The three expressions show three different attitudes. When as? fails, it throws no exception and gives null; the result is null both for a null value and for a value of the wrong type. is String returns false for null, but is String? returns true, because the null value is a member of the String? type. The forcing as String throws a NullPointerException on a null value; the block catches it and prints only the class name.
The lesson here is that the two null outputs do not mean the same thing. The null in the first line comes from the value to be converted being absent in the first place. The null in the last line comes from the value being present but its type not fitting, and it is not possible to tell these two apart by looking at the output alone. When the distinction is needed, value == null is asked separately before the conversion. The forcing form of the conversion is written only where the type is known for certain; a wrong assumption comes back as an exception at run time, not at compile time.
The Bounding Measurement
This lesson’s rule is: the criterion is the answer to the first question. The rule asks four questions in order and stops at the first “yes”. The first two lines of the earlier section already showed this, but the difference has to be seen explicitly: in the null-null line the third value is filled in ("cube"), and in the null-null-null line the third value is null. The equality of the third value (other) came out opposite in the two lines, while the criterion is singleton in both. Nothing between other=false and other=true changes the criterion; this is because Criterion stops at the first question, and the third value adds no information to the criterion.
This shows where the limit lies. The other column is printed, because Criterion computes the third comparison in every case; but in a singleton criterion that column is not read. Saying that a value is singleton does not tell you how many values are equal to it. It says only that the twin is the same object.
The same limit can be read from the opposite direction. If the first value were null and its twin a filled-in string, same-object=false and twin=false would come out: the criterion would be identity. So starting with null does not make the criterion singleton; for it to be singleton, the twin must be null as well. What determines the criterion is not the type of the first value but whether the first value and its twin are the same object.
TN5. The bounding measurement was made with two null values and a filled-in or null third value; the ordering in which the first value is filled in and the twin is null was not given a separate output, and was only inferred from the identity result on the full-null line. Criterion’s rule was not tried with other orderings.
Summary
StringandString?are separate types: assigningnullto aStringand asking forlengthdirectly on aString?do not compile.- A null check inside an
ifnarrows the variable’s type toStringin that branch; this is a smart cast, and no conversion is written. filterNotNullturns aList<String?>into aList<String>and discards thenullelements.- Two
nullvalues are the same object and the criterion is singleton; two separate filled-in strings give content, and a filled-in string againstnullgives identity. - The rule stops at the first question: in the first two lines the equality of the third value came out opposite while the criterion stayed the same.
- This lesson’s criterion: singleton.
nullwithnullis the same object.
Next Step
In this lesson we saw null in the type and checked it with if. Every null check asks for a separate if, and the code grows longer as the checks line up one after another. The next lesson measures the operators that do the same job by a shorter route: where a ?. safe call chain stops when it sees null, when the ?: Elvis operator evaluates its right side, and which exception is thrown when a not-null assertion turns out wrong.
To keep your progress and take notes, Log in
My notes
Log in to take notes.