Lesson 06 / 24
Control Flow
Introduces conditions, loops and the pattern-matching `switch` statement; measures that `where` conditions after the first match are never evaluated.
Published Updated
In this lesson
- Use the `if` / `else if` chain and pattern matching inside `switch`
- Show that the conditions after the first match are not evaluated
- Read how many times `for ... where` and `while` conditions are evaluated
- Explain the exhaustiveness requirement of the `switch` statement
Contents
Operators joined two expressions, and the right-hand side was never evaluated when it was not needed. Control flow carries the same idea one step up: conditions decide which branch runs, and a construct that has found the first suitable branch does not look at the rest. The question of this lesson is this: in a chain, when are the conditions that come after a match evaluated?
Conditions, Loops and switch
if asks a Bool condition; an else if chain tries the conditions from top to bottom. for n in 1...3 runs the body for every value in a range, and while goes on as long as the condition is true. switch compares a value with patterns. A case can be a value, a range, a tuple or a condition decorated with where. In Swift the case bodies do not fall through to the next one; the first match runs and the switch ends.
A switch has to be exhaustive: every possible value must be covered by a case, or there must be a default. The snippet below handles only 1 and 2 for an Int. This snippet does not compile in Swift 6.4; the compiler rejects it, which is why it has no output.
let n = 3
switch n {
case 1: print("one")
case 2: print("two")
}
The error is a real guarantee: a switch that does not cover a newly added value does not compile. The fix is to add a default or to catch the remaining values with a pattern.
// Probe.swift — records when an expression is evaluated
final class Probe {
let label: String
private(set) var evaluations = 0
private(set) var uses = 0
private var evaluatedBeforeUse = false
init(_ label: String) {
self.label = label
}
func eval<T>(_ value: T) -> T {
evaluations += 1
if uses == 0 { evaluatedBeforeUse = true }
return value
}
func use<T>(_ access: () -> T) -> T {
uses += 1
return access()
}
var moment: String {
if evaluations == 0 { return "never" }
if evaluations > 1 { return "on-every-use" }
return evaluatedBeforeUse ? "immediately" : "on-first-use"
}
func report() {
print("\(label): \(evaluations) evaluations, \(uses) uses -> \(moment)")
}
}
func tally(_ probes: [Probe]) {
for moment in ["immediately", "on-first-use", "on-every-use", "never"] {
let count = probes.filter { $0.moment == moment }.count
print("\(moment): \(count)")
}
}
The measurement block builds two chains. In the first, the grade function evaluates a score of 75: the first condition is false, the second is true and the third is never reached. In the second, the describe function tests the value -5 with a switch: the where condition of the first case is true, and the where condition of the next case is never asked.
// main.swift — what follows the first match in a chain
func grade(_ score: Int, _ second: Probe, _ third: Probe) -> String {
if score >= 90 {
return "A"
} else if second.eval(score >= 70) {
return "B"
} else if third.eval(score >= 50) {
return "C"
}
return "F"
}
func describe(_ n: Int, _ first: Probe, _ later: Probe) -> String {
switch n {
case let x where first.eval(x < 0):
return "negative"
case let x where later.eval(x > 100):
return "large"
default:
return "other"
}
}
for n in 1...3 { print("step", n) }
let secondProbe = Probe("else-if-second-condition")
let thirdProbe = Probe("else-if-third-condition")
let firstWhereProbe = Probe("switch-first-where")
let laterWhereProbe = Probe("switch-later-where")
for _ in 1...2 {
let letter = secondProbe.use { thirdProbe.use { grade(75, secondProbe, thirdProbe) } }
print(letter)
let kind = firstWhereProbe.use { laterWhereProbe.use { describe(-5, firstWhereProbe, laterWhereProbe) } }
print(kind)
}
let allProbes = [secondProbe, thirdProbe, firstWhereProbe, laterWhereProbe]
for probe in allProbes { probe.report() }
tally(allProbes)
step 1 step 2 step 3 B negative B negative else-if-second-condition: 2 evaluations, 2 uses -> on-every-use else-if-third-condition: 0 evaluations, 2 uses -> never switch-first-where: 2 evaluations, 2 uses -> on-every-use switch-later-where: 0 evaluations, 2 uses -> never immediately: 0 on-first-use: 0 on-every-use: 2 never: 2
else-if-second-condition was evaluated on every call, because the first condition was false and so the chain came to it. else-if-third-condition, on the other hand, came out never in both calls: the second condition was already true and the chain ended there. The same holds for switch: the first where was tested every time, the later where was never tested. So a side effect inside a where condition never happens if an earlier case matched.
LB39. The measurement was done with a single input value (75 and -5); for other inputs, which condition stays never changes.
LB40. That switch is tried from top to bottom in source order was concluded from the measurement; whether the compiler changes the order was not measured separately.
Early Exit with guard
guard verifies a condition and forces an exit from the scope if the condition does not hold. The syntax is guard condition else { exit }; the else block has to end with return, break, continue or a call that stops the program. If the condition holds, control continues after the guard line, and the names bound in the condition are visible in the rest of that scope. It does the same job as if, but turns the intent around: the unwanted case is ruled out first, then the real work is written on a flat path.
The block below writes a function that accepts a positive number. The valid path writes to one Probe, the invalid path to another. The function is called with three numbers, and before each call the two Probe objects each record a use.
// Guard.swift — early exit with guard
func check(_ number: Int, valid: Probe, invalid: Probe) -> String {
guard number > 0 else { return invalid.eval("invalid") }
return valid.eval("valid")
}
func observe<T>(_ watched: [Probe], _ work: () -> T) -> T {
for probe in watched { _ = probe.use { 0 } }
return work()
}
let validProbe = Probe("valid-path")
let invalidProbe = Probe("invalid-path")
for number in [5, -2, 7] {
print(observe([validProbe, invalidProbe]) {
check(number, valid: validProbe, invalid: invalidProbe)
})
}
validProbe.report()
invalidProbe.report()
valid invalid valid valid-path: 2 evaluations, 3 uses -> on-every-use invalid-path: 1 evaluations, 3 uses -> on-first-use
The three numbers gave the results valid, invalid, valid. The valid path ran twice and the invalid path once: valid-path came out on-every-use with two evaluations and three uses, and invalid-path came out on-first-use with one evaluation and three uses. The use count came out three, because observe recorded a use before each call. The expression a path produces is evaluated only when that path is chosen; the expression of the path that is not chosen never runs.
guard makes it readable which path runs at which moment: the exceptional path stands at the very start, and the remaining lines run only when the condition holds. The measurement confirms this reading.
LB41. guard was measured only with an integer condition; using guard let with optional values is the job of a later topic.
switch Patterns
The switch statement compares a value with patterns in order. Patterns do not have to be only constants: tuple patterns compare several values at once, let binds a value, and where adds a condition. For example, case (let x, 0) catches every tuple whose second element is zero and binds the first element to the name x. The first matching case runs and the others are never evaluated.
The block below writes a function that finds the class of a point. Each of the three cases writes to its own Probe. The function is called with three points: the origin twice, a point on the horizontal axis once; the third case never matches.
// Pattern.swift — switch patterns
func classify(_ x: Int, _ y: Int, watched: [Probe]) -> String {
switch (x, y) {
case (0, 0):
return watched[0].eval("origin")
case (let along, 0):
return watched[1].eval("horizontal \(along)")
case (_, let height) where height > 5:
return watched[2].eval("high \(height)")
default:
return "other"
}
}
func observe<T>(_ watched: [Probe], _ work: () -> T) -> T {
for probe in watched { _ = probe.use { 0 } }
return work()
}
let originProbe = Probe("origin-case")
let axisProbe = Probe("axis-case")
let highProbe = Probe("high-case")
let cases = [originProbe, axisProbe, highProbe]
for point in [(0, 0), (4, 0), (0, 0)] {
print(observe(cases) { classify(point.0, point.1, watched: cases) })
}
for probe in cases { probe.report() }
origin horizontal 4 origin origin-case: 2 evaluations, 3 uses -> on-every-use axis-case: 1 evaluations, 3 uses -> on-first-use high-case: 0 evaluations, 3 uses -> never
The three calls gave the results origin, horizontal 4, origin. origin-case came out on-every-use with two evaluations, axis-case on-first-use with a single evaluation, and high-case never with zero evaluations. The third case was never tried despite its where condition, because the first two cases had already caught the points. So switch looks in order, takes the first match and does not touch the rest.
This rule matters when you order patterns: if you write a general case before a specific case, the specific case never runs. The compiler rejects missing coverage as an error, but in some cases it cannot find a wrong order.
LB42. Patterns were measured with tuples and where; enumeration cases and range patterns are outside this lesson.
Labeled Statements
In nested loops break ends only the innermost loop. To end the outer loop you give the loop a label: outer: for row in 1...3 { ... }. A break outer inside jumps to this label and the outer loop ends. continue with the same label moves to the next iteration of the outer loop. The label is the way to write explicitly which loop you mean.
The block below writes a function that looks for a particular product in a multiplication table. Each product is computed through a Probe; so the number of iterations run is measured. The function is called with two targets.
// Label.swift — search with a labeled break
func search(target: Int, probe: Probe) -> String {
var found = "none"
outer: for row in 1...3 {
for col in 1...3 {
if probe.eval(row * col) == target {
found = "\(row)x\(col)"
break outer
}
}
}
return found
}
let searchProbe = Probe("product-search")
print(searchProbe.use { search(target: 4, probe: searchProbe) })
print(searchProbe.use { search(target: 6, probe: searchProbe) })
searchProbe.report()
2x2 2x3 product-search: 11 evaluations, 2 uses -> on-every-use
The first search found 2x2, the second 2x3. Eleven evaluations were needed in total: five products were computed in the first search (1, 2, 3, 2, 4), and six in the second (1, 2, 3, 2, 4, 6). break outer ended both loops at once as soon as the product was found, so the remaining products were never computed. An unlabeled break would have ended only the inner loop, and the outer loop would have continued with the next row. The moment reads as on-every-use.
The labeled exit protects the search from unnecessary products. The label also shows how many loops the code skips; the reader knows from its name which loop ends.
LB43. The labeled break was measured with two nested loops; using a label with continue is outside this lesson.
At Least One Round with repeat-while
A while loop checks the condition first; if the condition is false from the start, the body never runs. repeat-while turns this around: the body runs first and the condition is checked afterwards. So the body runs at least once, whatever the condition is. When you need to read a value at least once, or to do an action once and then decide whether to go on, this form is the right tool.
The block below runs two loops with the same condition that is false from the start. Each body increases a counter by a one that comes from a Probe. One is written with while, the other with repeat-while.
// Repeat.swift — repeat-while runs at least one round
let whileProbe = Probe("while-body")
let repeatProbe = Probe("repeat-body")
var whileCount = 0
var repeatCount = 0
_ = whileProbe.use {
while whileCount < 0 { whileCount += whileProbe.eval(1) }
}
_ = repeatProbe.use {
repeat { repeatCount += repeatProbe.eval(1) } while repeatCount < 0
}
print(whileCount, repeatCount)
whileProbe.report()
repeatProbe.report()
0 1 while-body: 0 evaluations, 1 uses -> never repeat-body: 1 evaluations, 1 uses -> on-first-use
The two loops with the same false-from-the-start condition gave 0 1: while never increased the counter, repeat-while increased it once. while-body came out never with zero evaluations, and repeat-body on-first-use with one evaluation. In a repeat-while loop the condition is checked after the body, so the expression of the body ran once even though the condition was false.
The question of choice is this: does the body have to run at least once before the condition? If you read a value and decide afterwards, repeat-while is the right tool; if you want to do nothing when the condition does not hold from the start, while is.
LB44. repeat-while was measured with a condition that is false from the start; that the two forms give the same result in loops where the condition stays true is outside this lesson.
The Limiting Measurement
The conditions in a chain were asked once and dropped. In loops the condition is asked again on every round. for ... where evaluates the condition on every iteration; so the expression inside where is on-every-use in this line. while carries a subtle detail: the condition is evaluated one more time when leaving the loop, because the only way out of the loop is the condition returning false. A while that lasts three rounds therefore evaluates the condition four times.
// main.swift — loop conditions
let whereProbe = Probe("for-where-condition")
let whileProbe = Probe("while-condition")
for _ in 1...2 {
var evens: [Int] = []
whereProbe.use {
for n in 1...4 where whereProbe.eval(n % 2 == 0) { evens.append(n) }
}
print(evens)
whileProbe.use {
var i = 0
while whileProbe.eval(i < 3) { i += 1 }
}
}
let allProbes = [whereProbe, whileProbe]
for probe in allProbes { probe.report() }
tally(allProbes)
[2, 4] [2, 4] for-where-condition: 8 evaluations, 2 uses -> on-every-use while-condition: 8 evaluations, 2 uses -> on-every-use immediately: 0 on-first-use: 0 on-every-use: 2 never: 0
Over the two rounds, the for ... where condition was evaluated eight times, once for each of four iterations per round. The while condition got eight evaluations although it made three iterations in each of the two rounds: the number of iterations plus one. The extra evaluation is the false query that ends the loop.
LB45. The “iterations plus one” result for while was measured in this three-iteration example; a loop that exits with break or return has no extra query, but this was not measured in this lesson.
Summary
- The
if/else ifchain tries the conditions from top to bottom and stops at the first true one. - A
switchhas to be exhaustive; a missingcaseis rejected by the compiler. - The
whereconditions andelse ifconditions after the first match are never evaluated. - The
for ... wherecondition is evaluated on every iteration, and thewhilecondition one more time than the number of iterations. - The moment of this lesson: never (the
whereafter a match).
Next Step
A condition only tells whether a branch runs. There are also expressions whose evaluation moment during a call is the question: the default argument of a function. The next lesson measures argument labels and default values.
To keep your progress and take notes, Log in
My notes
Log in to take notes.