Day 3 — Control flow

Updated

July 30, 2026

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.

Note

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:

  • switch does not fall through by default
  • if has an optional short statement that scopes variables tightly
  • There is only for—while, infinite loop, C-style, and range
  • Since Go 1.22, loop variables no longer share a single per-loop address (the classic “goroutine captures loop var” bug was fixed)
  • range over a string yields 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 { }    // OK

Short statement

if err := doWork(); err != nil {
    return err
}
// err is not in scope here

The 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 rune

Iterating 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

  1. Read lines until EOF.
  2. Count each kind.
  3. Print a summary to stdout:
info=3 warn=1 error=0 fatal=1 unknown=2
  1. If any fatal line was seen, exit with code 1.
  2. On I/O / open errors, print to stderr and exit 1.
  3. 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/day03

Lab 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 fatal

Create 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):

  1. range a string containing multi-byte runes; print index and rune.
  2. Write a tagless switch that grades scores.
  3. Demonstrate that fallthrough is 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 for shapes from memory
  • if short statement used at least once with intentional scoping
  • switch with default and multi-value cases
  • Explained string range (byte index + rune)
  • Classifier counts kinds correctly
  • Exit code 1 when any fatal present; 0 otherwise 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.