Lesson 06 / 18
Control Flow
Five forms are measured with the same question — unconditional for, single-condition for, expressionless switch, an early return without an else, a switch branch that does not fall through on its own — and all five fill the unwritten part with a defined counterpart. Limiting measurement: an unwritten break is not needed, but code that wants to fall through has to write an extra word (fallthrough).
Published
In this lesson
- Runs proof of which parts are optional in Go's for and switch forms
- Measures that all five forms fill an unwritten part with a defined behavior
- Shows that an unwritten break does not come free by proving that code wanting to fall through has to write an extra word
Contents
The previous lesson measured fifteen unwritten cases, but none of them sat inside a control
structure — all of them were about the absence of a variable, a return, or an argument. This
lesson carries the same question into control flow: why can for sometimes be written without
a condition, why can switch sometimes be written without an expression, and what stands in
for a break left unwritten at the end of a branch?
The Programming Foundations course (M01/K02) built conditional branching and loop control using
Python; there, loops and branching had separate keywords (while, for, if/elif/else)
and each had its own fixed syntax. These concepts are not rebuilt here — one sentence is
enough to state the difference: Go uses a single keyword for loops (for), and which
classic loop form that word corresponds to depends on which parts of the source are written and
which are left unwritten. This lesson measures that dependency.
Five Forms, One Question
Go’s control flow rests on two keywords: for and switch (if is a third, but the only
thing left unwritten there is else). The distinction other languages carry with separate
words — “infinite loop,” “conditional loop,” “counted loop” — is reduced in Go to which parts
of a single for are written. We run five separate forms through the same frame: each leaves
something unwritten, and the language fills that gap on its own.
// flow.go — five forms, each filling an unwritten part with a defined counterpart
package main
import "fmt"
func classify(x int) string {
if x < 0 {
return "negative"
}
return "not negative"
}
func main() {
counter := 0
for {
counter++
if counter == 5 {
break
}
}
fmt.Printf("%-33s %s\n", "unconditional for (until stop)", fmt.Sprint("counter=", counter))
i, total := 0, 0
for i < 5 {
total += i
i++
}
fmt.Printf("%-33s %s\n", "single-condition for", fmt.Sprint("total=", total, " final_i=", i))
score := 72
var grade string
switch {
case score >= 90:
grade = "AA"
case score >= 70:
grade = "BA"
default:
grade = "FF"
}
fmt.Printf("%-33s %s\n", "expressionless switch", fmt.Sprint("grade=", grade))
fmt.Printf("%-33s %s\n", "early return without else", fmt.Sprint(classify(-3), " / ", classify(3)))
result := ""
switch 2 {
case 1:
result += "one"
case 2:
result += "two"
case 3:
result += "three"
}
fmt.Printf("%-33s %s\n", "switch does not fall through", fmt.Sprint("result=", result))
}
unconditional for (until stop) counter=5 single-condition for total=10 final_i=5 expressionless switch grade=BA early return without else negative / not negative switch does not fall through result=two
Each of the five lines rests on a number that can be read independently of its own body.
total=10 is the sum of 0+1+2+3+4, confirming the loop ran five times (i from zero to
four) and kept going as long as the i < 5 condition held; final_i=5 shows the loop stopped
the instant that condition stopped holding. grade=BA proves the score of 72 fell into the
score >= 70 branch and the score >= 90 branch was never tried at all. All five are
independent proofs of one another, but every one answers the same question: what fills in the
part that was not written?
LB17. All three parts of for (initialization, condition, post-statement) are optional. In
the first line none of them is written; the compiler puts an implicit true in place of
the unwritten condition, which is why the loop does not stop on its own — counter shows it
would keep growing without bound until break, and we stop it at five ourselves. In the second
line, only the condition is written; when initialization and the post-statement are left
unwritten, the language skips them outright, and it is enough that i was defined before the
loop and incremented by hand inside the body.
Both for forms show the same claim from opposite ends: an unwritten part is met by the
language saying “consider this part absent” — with no condition it is always taken as true,
with no initialization or post-statement they are never run at all. Both belong to the same
for keyword; which parts are written decides which classic form (infinite, conditional,
three-step) the loop corresponds to.
This means for carries a single syntax tree, but all three branches of that tree are
optional. The three-step form (for i := 0; i < 5; i++ { }) skips no part and stands in its
most explicit shape in the source; the single-condition form in the second line moves
initialization and the post-statement outside, before and inside the body —
i, total := 0, 0 before the loop, i++ on the body’s last line. For the compiler these two
forms produce the same machine code; the only difference is entirely what the reader sees at a
glance. In the three-step form, the loop’s bounds gather into a single line; in the
single-condition form, seeing the bounds requires reading the whole body.
This distinction has a cost: in the first line, counter is reset before the loop and
increases inside it, but where the loop stops is written neither in a header nor on a single
line — it is hidden in an if block in the middle of the body. The unconditional for is the
most flexible form because it pins the stopping condition nowhere; that flexibility
requires reading the entire body to find the stopping condition.
This flexibility has a reason as much as a cost. Some loops have a stopping condition too
complex to express with a single comparison — it depends on more than one condition holding
together, or on a signal arriving from an outside source (a channel, a reader). For a loop like
this, writing a single condition in the header would mean trying to squeeze the body’s real
stopping logic into the header; the unconditional for is the way to leave that stopping logic
in the body as is. The three-step and single-condition forms are preferred when the stopping
condition can be reduced to a single comparison; the unconditional form, when it cannot.
LB18. In the third line, switch is written without taking an expression. When a switch
expression is not written, the compiler compares it against an implicit true; each case
in turn becomes a logical expression of its own. Since score >= 70 is true, grade becomes
"BA" — exactly what an if/else if chain would do, only with switch syntax.
This shows that switch in Go is less an “equality” construct and more a “branching” one. In
some languages, switch can only compare a single value against constant values; in Go, when
no expression is written, cases can carry free-form logical conditions, and those conditions
are tried in order, with the first true one running — exactly as in an if/else if chain.
There is no difference at all between writing switch { } and writing switch true { }; the
second only makes explicit the part the first leaves unwritten.
This shows that switch merges two separate uses into a single syntax. When an expression is
written (switch score { case 90: ... }), each case can carry a list of constants
compared for equality against that expression — multiple-value matching is one example of
this, and it will reappear later in the course. When no expression is written, each case
instead carries a complete logical expression of its own, and what is tested is not equality
but truth. The same keyword turns into two different branching forms depending on the single
part that is written — the expression itself.
In the fifth line, a different unwritten case is measured: no case ends with break, yet
result carries only the value of the matching branch (case 2) — case 3’s body never runs
at all. In Go, the end of a case means the branch closes on its own; what stands in for
the unwritten break is that falling through never happens at all.
The fourth line comes from a different family: with no else ever written, the if block ends
with a return, and the remaining code runs as though it were an else block.
classify(-3) enters the if branch and returns "negative"; classify(3) never enters the
if branch and falls through to the second line, returning "not negative". What stands in
for the unwritten else is that every path leaving the if block — whether by a return or
by never entering at all — arrives at the same place, the rest of the function.
This form is a pattern known in Go sources as a “guard clause” — a way to filter out
exceptional cases early with a return at the top of a function, so that the rest of the body
can be written without one more level of nesting. Had an else followed the line
if x < 0 { return "negative" }, the rest of the function would have shifted one level deeper;
because no else is written, the second return stays at the same indentation level as the
function’s main body. What stands in for the unwritten else is, for this reason, not only a
behavior but also a gain in form: the body reads as a flat sequence instead of nested
blocks.
This pattern becomes even more visible when a function filters out more than one exceptional
case. When several guard clauses are written one after another (if a { return },
if b { return }, if c { return }), each writing its own else would push the body one
level deeper on every line; with no else ever written, all the guard clauses sit side by side
at the same indentation, and the function’s “real work” stays at the end, at the least
indented line. The unwritten else, here, affects the readability not of a single line but of
the whole function.
Limiting Measurement: An Unwritten Break, a Fallthrough That Must Be Written
The “no fall-through” behavior in the fifth line looks like a convenience — we were spared
writing one word (break). But when we want the reverse — a branch deliberately falling
through into the next one — the language does not give that for free.
The importance of this measurement lies in the answer it gives to the question “is an unwritten
thing always the shortest path.” As we saw in the fifth line, no case wrote break, and yet
only the matching branch ran — that looked like a gain. But this gain does not mean “falling
through” never exists in the language; it only means it is not the default. The following
two switch statements carry the same three branches and test the same value (2); the only
difference between them is that the second adds one word to the case 2 body.
// limiter.go — an unwritten break costs nothing, but wanting to fall through costs a word (fallthrough)
package main
import "fmt"
func main() {
notWritten := ""
switch 2 {
case 1:
notWritten += "one"
case 2:
notWritten += "two"
case 3:
notWritten += "three"
}
written := ""
switch 2 {
case 1:
written += "one"
case 2:
written += "two"
fallthrough
case 3:
written += "three"
}
fmt.Printf("%-28s %s\n", "break not written", fmt.Sprint("result=", notWritten))
fmt.Printf("%-28s %s\n", "fallthrough written", fmt.Sprint("result=", written))
}
break not written result=two fallthrough written result=twothree
LB19. Both switch statements test the same value (2) and carry the same three
branches; the only difference is that the second switch adds the fallthrough word to its
case 2 body. In the first line the result is only "two" — case 2 runs, case 3 is never
reached at all. In the second line the result is "twothree" — a deliberately requested
fall-through runs case 3 right after case 2.
This adds an asymmetry to the question of “what stands in for the unwritten.” The default
behavior (not falling through) needs no word at all; the opposite of the default (falling
through) needs a named, single-purpose keyword. This asymmetry is deliberate: someone reading
the body of a case, as long as they do not see fallthrough there, can be sure that branch
ends on its own. What is unwritten here is not a gap, it is a guarantee — only one reading
remains valid: the branch closes on its own unless stated otherwise.
fallthrough carries one more restriction: it can only fall into the next case, without
testing that case’s own condition. In the second switch, case 3‘s own condition (being
equal to 3) is never tested at all — the moment fallthrough is seen, the next branch runs
unconditionally. This shows that fallthrough should not be confused with **retesting a
case’s condition**: fallthrough does not mean “test the next branch’s condition too,” it
means “run the next branch’s body too.” This is a detail that needs to be told apart at
writing time — someone who does not know that fallthrough runs the next branch without
testing it might assume the falling-through branch’s condition gets checked too.
fallthrough comes with a restriction of its own: it can only be the last statement in a
case body. Writing it in the middle of a branch would be rejected by the compiler — falling
through is a single transition that comes after the rest of the branch has run, not a branching
tool that can be scattered inside the branch. This restriction sets fallthrough apart from an
ordinary goto: it can only move to an adjacent next branch, and it can only appear at the
very end of a branch.
This asymmetry resembles, but sits in a different place from, the “rejecting does not mean writing less” observation measured in the course’s first lesson. There, the counterpart of a rejected pattern was a longer piece of writing; here nothing is rejected, it is only that the non-default path is written longer. The default behavior always takes the shortest writing; the non-default behavior always asks for one more word.
Looking at all five forms together, a shared pattern emerges: in Go’s control flow, every
unwritten part stands in for the most commonly wanted behavior. A loop running forever is
rarely wanted, so the unconditional for needs no special syntax — the mere absence of a
condition is enough. A switch branch falling into the next one is rarely wanted, so not
falling through is the default; wanting to fall through means this exceptional request must be
flagged on its own. Code continuing after an if block is commonly wanted, so it continues
even with no else written. What is left unwritten is always the majority’s path; what
must be written is always the minority’s exception.
It is also worth noting that all five forms can be run side by side in a single measurement program: none affects another, none needs any special setup. This shows that control flow in Go was designed as a set of small, independent rules — understanding one form does not require knowing the others; each answers, on its own, a single unwritten case with a single answer.
Summary
- All three parts of
forare optional; an unwritten condition becomes an implicittrue, an unwritten initialization and post-statement are skipped outright. - An expressionless
switchis compared against an implicittrue, and eachcasebecomes a logical expression of its own. - When an
ifblock ends with areturn, the rest of the code runs like anelseblock even with noelsewritten. - A
switchbranch closes on its own with no need for an unwrittenbreak; all five forms fill their gap with a defined counterpart. - Wanting the opposite of the default (falling through) does not come free: the
fallthroughword has to be written explicitly.
Next Step
The “early return without else” form measured in this lesson showed cases where a function
ends early, but the function’s return value itself was never questioned — classify
always returned a single string, and no one ever left that string unwritten. The next lesson
asks that: when a function returns more than one value, what happens if one of them is left
unwritten, and what value does a named return carry when it is left completely untouched?
To keep your progress and take notes, Log in
My notes
Log in to take notes.