Day 3 — Control flow
Day 3 — Control flow
Stage I · ~3h (theory-heavy)
Goal: Master Go’s three control constructs—if, switch, and for—well enough to write a non-toy line classifier with correct exit codes, short-circuit safety, and modern range semantics.
Go has no while or do-while. Everything that loops is a form of for. That is a feature: one keyword, four shapes.
Why this day exists
Control flow is where language differences bite hardest for people arriving from C, Java, Python, or JavaScript:
switchdoes not fall through by default
ifhas an optional short statement that scopes variables tightly
- There is only
for—while, infinite loop, C-style, andrange
- Since Go 1.22, loop variables no longer share a single per-loop address (the classic “goroutine captures loop var” bug was fixed)
rangeover astringyields runes, not bytes
If these are solid, parsers, CLIs, and later workers become mechanical rather than mysterious.
Theory 1 — if: condition, short statement, and scoping
Basic form
if condition {
// ...
} else if other {
// ...
} else {
// ...
}Conditions must be boolean. There is no “truthiness” of 0, "", or nil.
// if n { } // compile error: non-boolean condition
if n != 0 { } // OK
if s != "" { } // OK
if p != nil { } // OKShort statement
if err := doWork(); err != nil {
return err
}
// err is not in scope hereThe short statement runs once before the condition. Variables declared there are scoped to the entire if / else if / else chain—not to the surrounding block.
Why it matters
| Pattern | Scope of err |
|---|---|
err := f(); if err != nil |
Outer block (leaks) |
if err := f(); err != nil |
If-chain only (preferred when you only need it for the check) |
Nested short statements
if v, err := strconv.Atoi(s); err != nil {
return 0, err
} else if v < 0 {
return 0, fmt.Errorf("negative: %d", v)
} else {
return v, nil
}Readable for two branches; for more logic, declare outside and use plain if.
Short-circuit evaluation
Boolean operators short-circuit left-to-right:
if p != nil && p.Ready() {
// p.Ready() never called if p is nil
}
if ok || expensive() {
// expensive() skipped if ok is already true
}Law: put the cheap/safe check first when the second expression would panic or be costly.
Theory 2 — switch: tagged, tagless, and type (preview)
No automatic fallthrough
switch kind {
case "info":
info++
case "warn":
warn++
default:
// unknown
}Unlike C, execution stops at the end of a matching case. Use the fallthrough keyword explicitly and sparingly—it falls into the next case body, ignoring that case’s match condition.
switch n {
case 1:
fmt.Println("one")
fallthrough
case 2:
fmt.Println("two-or-fell-from-one")
}Multi-value cases
switch day {
case "sat", "sun":
fmt.Println("weekend")
case "mon", "tue", "wed", "thu", "fri":
fmt.Println("weekday")
}Expression switch with short statement
switch v := strings.ToLower(raw); v {
case "y", "yes":
return true
case "n", "no":
return false
default:
return false
}Tagless switch (if-else chain)
switch {
case score >= 90:
grade = "A"
case score >= 80:
grade = "B"
default:
grade = "C"
}Cases are evaluated in order; first true wins. Prefer this over nested if when each branch is a pure condition.
Type switch (preview only)
switch x := anyVal.(type) {
case int:
fmt.Println("int", x)
case string:
fmt.Println("string", x)
default:
fmt.Printf("other %T\n", x)
}Full treatment lands on Day 12. Today: know it exists and that x has a different type in each case.
Theory 3 — for: four shapes, one keyword
1. Three-clause (C-style)
for i := 0; i < n; i++ {
// ...
}Any clause may be empty:
for ; cond; {
// while-style with explicit empty init/post
}2. Condition-only (while)
for pending > 0 {
pending = drain()
}3. Infinite loop
for {
line, err := r.ReadString('\n')
if err == io.EOF {
break
}
if err != nil {
return err
}
// handle line
}4. range
for i, v := range slice { /* index, value */ }
for i := range slice { /* index only */ }
for _, v := range slice { /* value only */ }
for k, v := range m { /* map key, value */ }
for i, r := range s { /* string: byte index, rune */ }
for v := range ch { /* channel receive until closed */ }break and continue
for i, line := range lines {
if strings.HasPrefix(line, "#") {
continue // skip comments
}
if line == "STOP" {
break // leave the loop
}
_ = i
}Labeled break / continue (use rarely)
Outer:
for i := 0; i < n; i++ {
for j := 0; j < m; j++ {
if bad(i, j) {
break Outer
}
}
}Prefer refactoring nested loops into a function with early return when labels start multiplying.
Theory 4 — Range semantics that surprise people
Strings: byte index + rune value
s := "Go🚀"
for i, r := range s {
fmt.Printf("i=%d r=%c U+%04X\n", i, r, r)
}
// i advances by UTF-8 width; r is the decoded runeIterating with a C-style index over len(s) walks bytes, not characters.
Maps: random iteration order
m := map[string]int{"a": 1, "b": 2, "c": 3}
for k, v := range m {
fmt.Println(k, v) // order not stable across runs
}Never depend on map range order for tests or protocols. Sort keys if you need stability (Day 6 depth).
Go 1.22+ loop variable semantics
Before Go 1.22, each loop reused one variable; closures captured the final value. Since 1.22 (and thus your 1.26 baseline), each iteration gets a fresh variable:
var funcs []func()
for i := 0; i < 3; i++ {
funcs = append(funcs, func() { fmt.Println(i) })
}
for _, f := range funcs {
f() // prints 0 1 2 on modern Go — not 2 2 2
}Still write clear code: capture by value if you hand code to older toolchains or want explicit intent.
for i := 0; i < 3; i++ {
i := i // intentional shadow; documents capture
go func() { fmt.Println(i) }()
}Integer range (Go 1.22+)
for i := range 5 {
fmt.Println(i) // 0..4
}Useful for fixed-count loops without a dummy slice.
Theory 5 — Control flow at the process boundary
CLIs communicate status with exit codes, not only printed text:
| Code | Typical meaning |
|---|---|
0 |
Success |
1 |
Runtime / domain failure |
2 |
Usage / argument error |
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: classifier <file>")
os.Exit(2)
}Prefer returning error from helpers and deciding os.Exit once in main—you will formalize that on Day 4 with defer and multiple returns.
Worked examples bank
Example A — Classifier core with switch
package main
import (
"bufio"
"fmt"
"io"
"strings"
)
type counts struct {
info, warn, error, fatal, unknown int
}
func classifyLine(line string, c *counts) (fatal bool) {
line = strings.TrimSpace(line)
if line == "" {
return false
}
kind, _, _ := strings.Cut(line, " ")
switch strings.ToLower(kind) {
case "info":
c.info++
case "warn":
c.warn++
case "error":
c.error++
case "fatal":
c.fatal++
return true
default:
c.unknown++
}
return false
}
func classify(r io.Reader) (counts, bool, error) {
var c counts
sawFatal := false
sc := bufio.NewScanner(r)
for sc.Scan() {
if classifyLine(sc.Text(), &c) {
sawFatal = true
}
}
return c, sawFatal, sc.Err()
}
func main() {
// wired in lab
_ = fmt.Sprintf
}Example B — Tagless switch for HTTP-ish status buckets
func bucket(code int) string {
switch {
case code >= 200 && code < 300:
return "success"
case code >= 400 && code < 500:
return "client_error"
case code >= 500:
return "server_error"
default:
return "other"
}
}Example C — Nested loop with early continue
func firstNonComment(lines []string) (string, bool) {
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
return line, true
}
return "", false
}Example D — Safe nil check with short-circuit
type Conn struct {
closed bool
}
func (c *Conn) Ready() bool { return c != nil && !c.closed }
func use(c *Conn) {
if c != nil && c.Ready() {
// safe
}
}Note: calling a method with a nil receiver is allowed in Go if the method handles c == nil. Short-circuit still documents intent.
Example E — Parsing with if short statement
func parsePort(s string) (int, error) {
if s == "" {
return 0, fmt.Errorf("empty port")
}
if n, err := strconv.Atoi(s); err != nil {
return 0, fmt.Errorf("port %q: %w", s, err)
} else if n < 1 || n > 65535 {
return 0, fmt.Errorf("port out of range: %d", n)
} else {
return n, nil
}
}Labs
Suggested workspace: ~/lab/90daysofx/go/day03
Lab 1 — Line classifier CLI
Build a stdin (or file) line classifier.
Input format (one event per line):
KIND payload...
Kinds: info, warn, error, fatal (case-insensitive). Anything else is unknown.
Behavior
- Read lines until EOF.
- Count each kind.
- Print a summary to stdout:
info=3 warn=1 error=0 fatal=1 unknown=2
- If any
fatalline was seen, exit with code 1.
- On I/O / open errors, print to stderr and exit 1.
- On bad usage (if you take a path arg incorrectly), exit 2.
mkdir -p ~/lab/90daysofx/go/day03
cd ~/lab/90daysofx/go/day03
go mod init example.com/day03Lab 2 — Fixture-driven manual tests
Create testdata/sample.log:
info started
WARN disk low
error open failed
fatal cannot continue
not-a-kind garbage
info still running
go build -o classifier .
./classifier < testdata/sample.log; echo exit:$?
# expect non-zero because of fatalCreate testdata/clean.log with only info/warn and confirm exit 0.
Lab 3 — Deliberate control exercises
In a second file or _experiments.go (or a small explore program):
rangea string containing multi-byte runes; print index and rune.
- Write a tagless
switchthat grades scores.
- Demonstrate that
fallthroughis required for C-style multi-case body sharing.
Common gotchas
| Gotcha | Reality / fix |
|---|---|
Expecting C switch fallthrough |
Cases break automatically; use fallthrough only when deliberate |
if x { } with non-bool |
Illegal; compare explicitly |
for i, c := range s thinking i is character index |
i is byte offset |
Depending on range map order |
Order is randomized; sort keys if needed |
Using os.Exit deep in helpers |
Prefer error returns; exit in main |
| Silent unknown kinds | Count them or fail loudly—pick a policy and document it |
| Pre-1.22 loop-var myths | Fixed since 1.22; still avoid unclear captures |
Checkpoint
- Can write all four
forshapes from memory
ifshort statement used at least once with intentional scoping
switchwithdefaultand multi-value cases
- Explained string
range(byte index + rune)
- Classifier counts kinds correctly
- Exit code
1when anyfatalpresent;0otherwise on clean input
- Documented three personal gotchas
Commit
git add .
git commit -m "day03: control flow + line classifier"Tomorrow
Day 4 — Functions, multiple returns & defer: named results, defer timing and LIFO, function values, and structuring main so errors—not panics—drive exit codes.