Lesson 05 / 23
Control Flow
Introduces the `if` and `when` expressions, the `is` check, and the `for` loop; measures that a `when` branch matches with `==` and does not hit for an ordinary class instance.
Published Updated
In this lesson
- Shows `if` and `when` used as expressions that produce a value
- Writes type checks with `is` and ranges with `..`, `until`, `downTo`, and `step`
- Explains that a `when` branch matches the value with `==` and what result this gives for an ordinary class
Contents
In string templates we saw that the same text can be built in two ways. In this lesson we move on to flow: choosing a value according to a condition, distributing a value to branches, walking over a range. At the end we find the identity criterion again: when uses == while matching a value against a branch, and an instance with no equality cannot hit the branch.
if, when, and for
In Kotlin, if is an expression: it produces a value, and that value can be assigned to a variable. A separate ternary operator is not needed; you write val label = if (score >= 50) "passed" else "failed". when is also an expression. In the form without a subject, every branch carries a logical expression and the first true branch runs. In the form tied to a value, you write when (value); each branch begins either with a value or with a type check using is. When when is used as an expression, the compiler wants to be sure that all cases are covered: in the form without a subject an else branch is needed; in the form with a subject, if Boolean, an enumeration, or a sealed class is listed with all of its cases, else is not required.
The for loop walks over a range or a collection. The range 1..3 is a closed range: the upper bound is included. 1 until 7 leaves the upper bound out. step sets the step, and 3 downTo 1 counts backward.
In this lesson too, the measurements are made with the helper file introduced in the first lesson. The Criterion class takes a value, its twin, and a different value, and asks three questions; the file is given below exactly as it was.
// 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 program below runs these together. grade turns a score into a word with a when that has no subject. describe separates an Any value by its type with is: for an Int it states the double, and for a String it states the length.
// Main.kt — if, when, is, and for
fun grade(score: Int): String = when {
score >= 90 -> "excellent"
score >= 70 -> "good"
else -> "pass"
}
fun describe(value: Any): String = when (value) {
is Int -> "integer, double is ${value * 2}"
is String -> "string, length ${value.length}"
else -> "unknown"
}
fun main() {
val score = 82
val label = if (score >= 50) "passed" else "failed"
println("$label, ${grade(score)}")
println(describe(21))
println(describe("kotlin"))
println((1..3).joinToString(" "))
println((1 until 7 step 2).joinToString(" "))
println((3 downTo 1).joinToString(" "))
}
passed, good integer, double is 42 string, length 6 1 2 3 1 3 5 3 2 1
In the first line, the label variable took the value passed through the if expression, and the call grade(82) returned good, because 82 is below ninety and above seventy. The second and third lines show the two branches of the describe function: because 21 is an Int, its double, 42, was printed; because "kotlin" is a String, its length, 6, was printed. Inside these branches, the value variable was reached with the new form of its type; this feature is called a smart cast. The last three lines are ranges: 1..3 gives all three, 1 until 7 step 2 gives the values one, three, five, and 3 downTo 1 counts backward.
LB31. The when expression was measured with integer and string branches; the exhaustive when over an enumeration and over Boolean is measured below, and the check over a sealed class is outside this lesson.
LB32. Ranges were printed only with joinToString; what an empty range does in a loop is measured separately below.
A when Branch Matches with ==
The measurement of the lesson looks at the matching rule of the when branch. When a branch expression is written inside when (value), Kotlin makes the comparison value == branch. For this reason, whether a branch hits depends on the equality criterion. The branchMatches function asks this directly: it matches the value it receives against branch through when and returns true or false.
Three cases are tried. The first is Point(1, 2) against Point(1, 2): a data class, that is, the content criterion. The second is Box(1) against Box(1): an ordinary class, that is, identity. The third is the same box instance matched against itself. Then the is Box branch is given to the same Box(1) value. Finally, Criterion classifies three observations.
// Main.kt — a when branch matches with ==
class Box(val size: Int)
data class Point(val x: Int, val y: Int)
fun branchMatches(value: Any, branch: Any): Boolean = when (value) {
branch -> true
else -> false
}
fun main() {
val box = Box(1)
println(branchMatches(Point(1, 2), Point(1, 2)))
println(branchMatches(Box(1), Box(1)))
println(branchMatches(box, box))
println(when (Box(1)) {
is Box -> "is Box branch hits"
else -> "does not hit"
})
val checks = listOf(
Criterion("when-branch-box", Box(1), Box(1), Box(2)),
Criterion("when-branch-point", Point(1, 2), Point(1, 2), Point(1, 3)),
Criterion("range", 1..3, 1..3, 1..4),
)
for (check in checks) {
check.report()
}
summarize(checks)
}
true false true is Box branch hits when-branch-box: same-object=false, twin=false, other=false -> identity when-branch-point: same-object=false, twin=true, other=false -> content range: same-object=false, twin=true, other=false -> content singleton: 0 identity: 1 content: 2 partial-content: 0
branchMatches(Point(1, 2), Point(1, 2)) gave true: in a data class == looks at the content, and the branch hit. branchMatches(Box(1), Box(1)) gave false: even though the two Box(1) instances carry the same constructor argument, they are separate objects, and in an ordinary class == looks at identity. The same box instance matched itself, because an object is equal to itself. The is Box branch looks at the type, not the value: even a freshly built Box(1) hits the is Box branch. For this reason the line is Box branch hits was printed.
The three classification lines summarize this. when-branch-box is identity: == with the twin gives false. when-branch-point is content: == with the twin gives true, and with the different value gives false. range is also content: the range 1..3 is equal to another separately built 1..3 range and is not equal to the 1..4 range. The summary lines show zero singleton, one identity, two content, and zero partial content.
LB33. The branchMatches function takes the branch as a variable; that the same == rule holds when a constant branch is written inside when was not measured separately in this lesson.
LB34. Range equality was measured with IntRange; the same behavior is not claimed for LongRange and CharRange.
when with a Range, a Multi-Value Branch, and an Enumeration
when branches are not limited to a single value. The block below asks three things: in which order a comma-separated multi-value branch, a range branch with in, and a branch that catches the outside of the range are evaluated when used together; whether a when over an enumeration (enum) compiles without else; and whether a when over Boolean closes with two values.
// Main.kt — when: multi-value branch, range, and enumeration
enum class Level { LOW, MEDIUM, HIGH }
fun classify(n: Int): String = when (n) {
0 -> "zero"
1, 2, 3 -> "few"
in 4..9 -> "medium"
!in 0..99 -> "out of range"
else -> "many"
}
fun weight(level: Level): Int = when (level) {
Level.LOW -> 1
Level.MEDIUM -> 5
Level.HIGH -> 10
}
fun main() {
println(listOf(0, 2, 7, 50, 120).map { classify(it) })
println(Level.entries.map { weight(it) })
val flag = true
println(when (flag) {
true -> "yes"
false -> "no"
})
}
[zero, few, medium, many, out of range] [1, 5, 10] yes
For the inputs 0, 2, 7, 50, and 120 the result was zero, few, medium, many, out of range. The value 2 fell on the branch 1, 2, 3, and 7 on the branch in 4..9. The value 50 did not hit the out-of-range branch and went to the else branch, while 120 hit the out-of-range branch. The rule is this: branches are tested from top to bottom and the first one that hits runs. Because the out-of-range branch was written before the else branch, the value 120 reached it; had the else branch been written earlier, the out of range branch could never have been reached; this reversed order could not be measured because it does not compile.
The second line gave [1, 5, 10]. The weight function carries no else branch and still compiles: the compiler sees that the branches cover all three values of the Level enumeration. The frequent mistake is to add a new value to the enumeration and forget to update the when expressions; an expression without an else branch gives a compile error in this case and finds the missing branch. If else is written, this guarantee is lost. This holds because when is used as an expression.
The third line gave yes: because Boolean is a type with two values, the true and false branches are exhaustive without else.
LB35. Exhaustiveness was measured with enum class and Boolean; the check of a when over a sealed class was not measured in this lesson.
Empty Range, Step, and Labeled break
Ranges look easy and produce mistakes because of their edge cases. The block below counts an empty range, builds a downTo and a .. that use step, tries a zero step, and leaves a nested loop with a labeled break.
// Main.kt — empty range, step, and labeled break
fun main() {
println((5..1).count())
println((1..0).toList())
println((10 downTo 1 step 3).toList())
println((1..10 step 4).last)
try {
println((1..3 step 0).toList())
} catch (e: IllegalArgumentException) {
println(e.message)
}
var visited = 0
outer@ for (row in 1..3) {
for (col in 1..3) {
if (row * col == 4) break@outer
visited++
}
}
println(visited)
}
0 [] [10, 7, 4, 1] 9 Step must be positive, was: 0. 4
(5..1).count() gave 0 and (1..0).toList() gave an empty list: the .. operator works from smaller to larger, and if the start is larger than the end the range is empty and the loop never runs. If you need to count backward, downTo must be written. This is the frequent mistake: a person who writes for (i in 5..1) expects a countdown, and the program silently does nothing.
The list 10 downTo 1 step 3 became [10, 7, 4, 1]. The last value of the range 1..10 step 4 came out as 9, that is, not 10: if the step does not fit the upper bound exactly, the last value stays below the bound. A zero step threw an IllegalArgumentException with the message Step must be positive, was: 0.; the step has to be positive, and counting backward is done with downTo, not with a negative step.
The last line gave 4. In the inner loop, when row * col == 4, break@outer ended both the inner and the outer loop. A break without a label would have ended only the innermost loop. The visit count is four in total: three in row 1, one in row 2, since the break happens at the second column of row 2. In Java, a labeled break works the same way; the syntax is outer: with break outer;.
LB36. The range measurements were made with Int; step and overflow behavior in a Long range was not measured in this lesson.
try Is an Expression
In Kotlin, try also produces a value. The block below makes the value of a try expression the return of a function, looks at when the finally block runs, and uses a function call inside an if expression. Finally, firstOrNull and the elvis operator are tried together.
// Main.kt — try is an expression
fun parse(text: String): Int = try {
text.toInt()
} catch (e: NumberFormatException) {
-1
} finally {
println("tried: $text")
}
fun main() {
println(parse("42"))
println(parse("kotlin"))
val label = if (parse("7") > 5) "big" else "small"
println(label)
val firstNegative = listOf(3, -2, 5).firstOrNull { it < 0 } ?: 0
println(firstNegative)
}
tried: 42 42 tried: kotlin -1 tried: 7 big -2
The first call, parse("42"), printed first the line tried: 42, which is the output of the finally block, and then the return value 42: finally runs before the value reaches the caller. In the second call, because "kotlin" could not be converted to a number, the catch branch ran and returned -1; again tried: kotlin was printed first. In the third line, the call parse("7") was used inside if: tried: 7 was printed, and because 7 is greater than 5, big was written.
The frequent mistake is to put an expression that returns a value in the finally block and to assume that it will change the value of the try expression. Here, even though println is the last expression, the value of the finally block does not join the value of the expression; the returned value is the last expression of the try or catch block. The last line gave -2: the first element in the list that satisfied the condition it < 0 was -2; if there had been none, the elvis operator would have returned 0, but this branch was not measured. In Java, try is a statement and does not produce a value.
LB37. The try measurements were made with NumberFormatException; how the value is overridden when a return is written in the finally block was not measured in this lesson.
Habits That Come from the Measurements
The common lesson of the measurements in flow control is that edge cases are silent. An empty range does not give an error; it only does nothing. If the step value does not fit the bound exactly, as in 1..10 step 4, the last value stays below the bound. A zero step, on the other hand, raises an exception with a single message. Of these three cases, only the last appears as an error; the others produce wrong output.
The absence of an else branch in a when expression is a design tool. If you do not write else on an enumeration, the compiler directs you to all the when expressions when a new value is added. If you write else, you have turned off this warning yourself. For this reason the else branch should be used where it really means “everything else”, for example in integer ranges; it should not be used on a closed set of values. Order also needs attention: because branches are tested from top to bottom, the specific branch is written before the general one, and in the measurement the value 120 reached the out-of-range branch because it was before else.
In a try expression, the value of the finally block does not join the result, but its side effect always runs. Work such as closing a resource and writing a log is done in finally for this reason, while the calculation of the value is done in try and catch. In Java, try is a statement; to assign the value to a variable, one has to define a variable outside. In Kotlin, writing val result = try { ... } catch ... makes this variable a read-only val and gives shorter code.
A labeled break is the most readable way to leave nested loops, but its use should be kept limited. In most cases, moving the search into a separate function and leaving with return requires no label and is also easy to test.
The Bounding Measurement
The identity criterion holds only for branches matched with ==. The is Box branch hits, because a type check is not a question of equality. That is, two branches can stand side by side in the same when: one does not hit because of identity, and the other hits because of the type. A program that builds a second Box(1) instance and tries to match it inside when can never hit the branch unless Box is a data class; but when is Box is written, the branch hits.
The second limit is in ranges: 1..3 does not fall under the identity criterion. Two range objects are equal even if they were built separately. The equality of a range and the hitting of an in branch are different questions: the first was measured in this section, and the second in the when section.
Summary
ifandwhenare expressions that produce a value; whenwhenis used as an expression, all cases must be covered, which anelsebranch or the listing of all cases of an enumeration,Boolean, or sealed class subject provides.isis a type check and allows the value to be used inside the branch as a variable of that type...,until,downTo, andstepbuild ranges;forwalks over these ranges.- A branch inside
when (value)matches withvalue == branch; because an ordinary class instance rests on identity, it cannot hit the branch. - This lesson’s criterion: identity; bounding measurement: the
is Boxbranch hits and two1..3ranges fall under the content criterion.
Next Step
We have seen the flow; now we move to the tool for packaging the flow: functions. We begin with parameters with default values, named arguments, and vararg. We measure whether taking the same function twice as a reference gives the same object or only equal content.
To keep your progress and take notes, Log in
My notes
Log in to take notes.