Learn Go Series (#4) - Control Flow: if, switch, and the Only Loop

avatar

Learn Go Series (#4) - Control Flow: if, switch, and the Only Loop

go-banner.png

What will I learn

  • The if statement and its handy init clause that scopes a variable to the branch;
  • Go's single loop keyword for in all of its shapes, including for range and the newer range-over-an-integer form;
  • The switch -- no fallthrough by default, multiple values per case, an optional init statement, and the "tagless" switch true form;
  • A first look at the type switch, and break/continue with labels for nested loops;
  • Why Go has exactly one loop and no ternary operator, and why that reads better in the large;
  • When goto is (rarely) the right call.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Go distribution (1.27 or newer, from go.dev/dl) -- tested against Go 1.27;
  • Episodes 1-3, or comfort with functions and multiple returns;
  • The ambition to learn Go programming.

Difficulty

  • Beginner

Curriculum (of the Learn Go Series):

Learn Go Series (#4) - Control Flow: if, switch, and the Only Loop

Control flow is where Go's "fewer things, used more ways" instinct is at its clearest. There is one loop keyword, not three. There is no ternary operator. The switch does the job of long if/else chains far more cleanly than you might expect, and it does a couple of things C's switch cannot. None of this is a limitation once you see the shapes -- it is a smaller set of tools that happen to cover more ground per tool. That is a recurring theme in Go, and today is the episode where it stops being a slogan and starts being something you can feel while typing. Let us clear last episode's exercises and then walk through each construct in turn.

Solutions to Episode 3 Exercises

Exercise 1 -- safe division. The (result, error) shape, this time with floats:

package main

import (
    "errors"
    "fmt"
)

func safeDiv(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("cannot divide by zero")
    }
    return a / b, nil
}

func main() {
    if q, err := safeDiv(9, 2); err == nil {
        fmt.Printf("9 / 2 = %.2f\n", q)
    }
    if _, err := safeDiv(1, 0); err != nil {
        fmt.Println("caught:", err)
    }
}

The key insight from episode 3 carried straight in here: a failure is just a value you return and the caller checks, and the if with an init clause (which we cover properly below) is the idiomatic place to do that checking.

Exercise 2 -- a variadic average. Guard the empty case so it never divides by zero:

package main

import "fmt"

func average(nums ...float64) float64 {
    if len(nums) == 0 {
        return 0
    }
    total := 0.0
    for _, n := range nums {
        total += n
    }
    return total / float64(len(nums))
}

func main() {
    fmt.Printf("%.2f\n", average(2, 4, 6, 8))
    fmt.Printf("%.2f\n", average()) // 0.00, no panic
}

The if len(nums) == 0 guard is the whole point -- a variadic can be called with zero arguments, so a naive divide would blow up on the empty case. Handle the awkward input first, then let the happy path run flat underneath it.

Exercise 3 -- an adder factory. The closure captures a running total and adds n * step on each call, so each adder accumulates independently:

package main

import "fmt"

func adder(step int) func(int) int {
    total := 0
    return func(n int) int {
        total += n * step
        return total
    }
}

func main() {
    byTwos := adder(2)
    fmt.Println(byTwos(1), byTwos(1), byTwos(1)) // 2 4 6

    byFives := adder(5)
    fmt.Println(byFives(1)) // 5, a separate total
}

Each call to adder makes a fresh total, and the returned function captures that specific variable -- so byTwos and byFives never touch each other's total. That is the same closure mechanic we spent real time on last episode, now doing something practical. On to control flow.

if, and its init clause

An if in Go needs no parentheses around the condition -- the braces are mandatory instead, and gofmt puts the opening brace on the same line, so the shape is fixed and there is nothing to argue about. What is genuinely useful is that an if can carry a short init statement before the condition, separated by a semicolon. The variable it declares is scoped to the if/else -- it does not leak into the surrounding function. This is the idiomatic home of the error check:

package main

import (
    "fmt"
    "strconv"
)

func main() {
    // strconv.Atoi parses a string to an int, returning (int, error)
    if n, err := strconv.Atoi("42"); err == nil {
        fmt.Println("parsed:", n*2)
    } else {
        fmt.Println("not a number")
    }

    // n is not visible here -- it belonged to the if statement
    grade := 82
    if grade >= 90 {
        fmt.Println("A")
    } else if grade >= 80 {
        fmt.Println("B")
    } else {
        fmt.Println("C or below")
    }
}

Scoping the parsed value to the branch that checks it keeps the surrounding function clean, and -- more importantly -- it stops you accidentally using a value that might be invalid. If strconv.Atoi fails, n is meaningless, and Go's scoping makes n simply unavailable outside the branch where you have already confirmed err == nil. That is not a coding-style suggestion enforced by a linter, it is the language itself keeping a dubious value out of reach. You will write this exact if x, err := something(); err != nil line thousands of times, so it is worth having in your fingers early.

And note there is no ternary ?: in Go. If you came from C or JavaScript you will reach for cond ? a : b and it simply will not be there. An if/else is the whole story:

package main

import "fmt"

func abs(n int) int {
    if n < 0 {
        return -n
    }
    return n
}

func main() {
    fmt.Println(abs(-7), abs(7)) // 7 7
}

The Go authors dropped the ternary on purpose. Their argument (and I have come around to it) is that a ternary is lovely for max(a, b) and awful the moment the branches grow -- people nest them, chain them, and the result is a puzzle. Forcing everything through if/else costs you a couple of lines in the trivial case and saves you from unreadable one-liners in every non-trivial one. It is the same trade Go makes over and over: give up a little brevity, buy back a lot of the-code-reads-the-same-everywhere.

The only loop: for in its several shapes

Go has one loop keyword, for, and it wears several hats. The classic three-clause form, a condition-only form (the while you do not have), an infinite form, and the for range that walks collections:

package main

import "fmt"

func main() {
    // 1. three-clause
    for i := 0; i < 3; i++ {
        fmt.Print(i, " ")
    }
    fmt.Println()

    // 2. condition-only (a "while")
    n := 1
    for n < 100 {
        n *= 2
    }
    fmt.Println("first power of two >= 100:", n)

    // 3. infinite, with an explicit break
    count := 0
    for {
        count++
        if count == 5 {
            break
        }
    }
    fmt.Println("counted to", count)

    // 4. range over a slice: index and value
    for i, letter := range []string{"a", "b", "c"} {
        fmt.Printf("%d:%s ", i, letter)
    }
    fmt.Println()
}

That is four different loops from one keyword. If you only want the value from a range and not the index, you write for _, v := range xs and drop the index into the blank identifier we met in episode 3; if you only want the index, for i := range xs. One tool, several grips.

There is a fifth grip that landed recently and is genuinely handy: as of Go 1.22 you can range over an integer to count from zero. It reads cleaner than the three-clause form when all you want is "do this N times":

package main

import "fmt"

func main() {
    // range over an int: i goes 0, 1, 2, 3, 4
    for i := range 5 {
        fmt.Print(i, " ")
    }
    fmt.Println()

    // and if you do not even need i, just count:
    sum := 0
    for range 3 {
        sum += 10
    }
    fmt.Println("sum:", sum) // 30
}

for i := range 5 is exactly for i := 0; i < 5; i++, only shorter and harder to get wrong (no off-by-one in the bound, no stray i++). And for range 3 with no variables at all is the tidy way to say "repeat this three times" -- something that used to need a throwaway counter.

One thing about range that surprises newcomers, so let me plant the flag now: the value range hands you is a copy. In for _, v := range xs, v is a fresh copy of each element, so assigning to v does not change the slice. If you want to mutate the underlying element you index it directly with xs[i]. We will come back to this properly when we do slices, because it is the source of a classic "why did my change not stick?" moment. Ranging over a string is its own small adventure too (you get byte offsets and decoded runes, not plain characters), but that belongs to the strings-and-runes episode -- for now just file away that range copies, and that strings are not quite what they look like.

switch: cleaner than a chain of ifs

Go's switch does not fall through to the next case by default -- each case stands alone, so you almost never write break. A case can match several values, and a "tagless" switch (switch with no expression) is just a clean way to write an if/else if ladder:

package main

import "fmt"

func classify(n int) string {
    switch {
    case n < 0:
        return "negative"
    case n == 0:
        return "zero"
    case n < 10:
        return "small"
    default:
        return "large"
    }
}

func main() {
    for _, n := range []int{-3, 0, 7, 42} {
        fmt.Printf("%d is %s\n", n, classify(n))
    }

    // a switch on a value, with multiple values per case
    day := "Sat"
    switch day {
    case "Sat", "Sun":
        fmt.Println("weekend")
    default:
        fmt.Println("weekday")
    }
}

The tagless switch { case cond: ... } reads as a tidy ladder of conditions -- often nicer than if/else if/else, because each condition lines up under the last. And case "Sat", "Sun": matches either value without repeating the body, which is a small thing that adds up when your cases get long.

This no-fallthrough default is worth pausing on, because it is a deliberate reversal of C. In C (and its many descendants) a switch case falls into the next one unless you remember to write break, and forgetting that break has caused a genuinely huge number of bugs over the decades. Go flips the default: cases are independent, and if you genuinely want to fall through to the next case, you ask for it with an explicit fallthrough keyword, so it can never happen by accident:

package main

import "fmt"

func main() {
    switch n := 2; n {
    case 2:
        fmt.Println("two")
        fallthrough // explicitly continue into the next case
    case 1:
        fmt.Println("one")
    case 0:
        fmt.Println("zero") // NOT reached: fallthrough goes to the *next* case only
    }
}

Two things to notice in that snippet. First, switch n := 2; n { uses a switch init statement -- just like if, a switch can declare a variable scoped to the whole switch, which is perfect for "compute a thing, then branch on it" without leaking that thing into the surrounding function. Second, fallthrough transfers to the immediately following case and does so unconditionally -- it does not re-test the next case's condition, it just runs its body. In practice you will use fallthrough rarely (I reach for it maybe once a year), but when you want it, it is right there and it is explicit. That is the whole trade: the common case (no fallthrough) is free, and the rare case (fallthrough) is one honest keyword.

The type switch

Because Go has interfaces (a whole later episode), you sometimes hold a value whose concrete type you do not yet know -- an any. A type switch branches on that concrete type and hands you the value already converted. Here is the shape, using any (the empty interface that any value satisfies):

package main

import "fmt"

func describe(v any) string {
    switch x := v.(type) {
    case int:
        return fmt.Sprintf("an int: %d", x)
    case string:
        return fmt.Sprintf("a string of length %d", len(x))
    case bool:
        return fmt.Sprintf("a bool: %t", x)
    default:
        return fmt.Sprintf("some other type: %T", x)
    }
}

func main() {
    fmt.Println(describe(42))
    fmt.Println(describe("hello"))
    fmt.Println(describe(3.14))
}

v.(type) is special syntax that only works inside a switch, and within each case x already has the matched type -- an int in the int case, a string in the string case, so you can call len(x) in the string case without any extra conversion. This is the multi-case sibling of the single type assertion x, ok := v.(int), which uses that same comma-ok shape we saw on maps last episode. When you only expect one type, you assert; when you want to branch across several, you type-switch. We lean on this hard when we reach interfaces -- for now, just recognise the shape and note the %T verb in the default case, which prints the dynamic type of whatever came in (a handy little debugging trick in its own right).

Labels: break and continue across nested loops

A plain break or continue affects the innermost loop. When you need to break out of an outer loop from inside an inner one, you attach a label and break to it. This is the clean alternative to a "found" flag that you would otherwise have to declare, set, and re-check:

package main

import "fmt"

func main() {
    grid := [][]int{
        {1, 2, 3},
        {4, 5, 6},
        {7, 8, 9},
    }
    target := 5

search:
    for r, row := range grid {
        for c, v := range row {
            if v == target {
                fmt.Printf("found %d at row %d, col %d\n", target, r, c)
                break search // breaks the OUTER loop, not just the inner
            }
        }
    }
}

The label search: names the outer loop, and break search jumps out of it entirely -- inner loop and outer loop, gone in one statement. Without the label, that inner break would only escape the inner for, leaving the outer loop to keep grinding through the remaining rows for no reason.

continue takes a label too, and it means something slightly different: skip to the next iteration of the labelled loop. That is exactly what you want when the inner loop decides "this whole outer item is done, move to the next one":

package main

import "fmt"

func main() {
    rows := [][]int{
        {1, 2, 3},
        {4, -1, 6}, // contains a negative
        {7, 8, 9},
    }

rows:
    for i, row := range rows {
        for _, v := range row {
            if v < 0 {
                fmt.Printf("row %d has a negative, skipping it\n", i)
                continue rows // jump to the next row, abandoning this one
            }
        }
        fmt.Printf("row %d is all non-negative\n", i)
    }
}

Here continue rows abandons the current row the instant it spots a negative and moves the outer loop forward, so the "all non-negative" line only prints for clean rows. Go also has goto for the rare cases where a state machine reads more clearly as explicit jumps (parsers and lexers sometimes qualify), but you will reach for labelled break/continue far more often, and for almost everything else a well-shaped loop or switch beats either. I have written quit some Go over the years and used goto a handful of times total -- it exists, it is occasionally the honest choice, and it is not something to go looking for.

Why one loop, and no ternary

It is worth stepping back to see the shape of these decisions together, because they rhyme. One loop keyword instead of while/do/for. No ternary. A switch that will not fall through unless you insist. Each of these removes a way to write the same idea, and in exchange every Go codebase you ever open loops, branches, and switches the same way. When you have read a few hundred thousand lines of other people's Go (which you will, if you work in it), that uniformity stops being a constraint and starts being a superpower: there is far less dialect to decode, because the language quietly refused to offer the dialect in the first place. Having said that, Go is not being minimalist for its own sake -- it kept switch powerful, gave for its range forms, and added the init clause to both if and switch. The rule is not "fewer features", it is "fewer redundant features".

The same idea in Python

Python has while, for, and (recently) match, plus a ternary expression. Go folds while into for, has no ternary, and its switch does not fall through -- so a Go switch behaves more like Python's match than like C's switch:

def classify(n):
    if n < 0:
        return "negative"
    elif n == 0:
        return "zero"
    elif n < 10:
        return "small"
    return "large"

for n in (-3, 0, 7, 42):
    print(n, classify(n))

Python's for iterates any iterable directly; Go's for range does the same over slices, maps, strings and channels (and, since 1.22, over an integer count). Where Python offers a if cond else b as a ternary expression, Go asks you to spell out the if/else -- more lines, but the same shape whether the branches are one token or twenty. The big mental shift is small: one loop keyword, a switch you can trust not to fall through, and no clever one-line conditional to reach for. Learning to not miss the ternary is genuinely part of getting comfortable in Go.

Exercises

  1. FizzBuzz, the Go way. Print the numbers 1 to 30, but for multiples of 3 print Fizz, for multiples of 5 print Buzz, and for multiples of both print FizzBuzz. Use a for loop and a tagless switch (switch { case ...: }) rather than a chain of ifs.

  2. Parse-or-skip. Given a slice of strings like []string{"12", "x", "7", "-4"}, loop over it and sum only the ones that parse as integers, using strconv.Atoi inside an if with an init clause. Print the running total and skip the rest with continue.

  3. First match in a grid. Write a function that takes a [][]int and a target value, and returns the row and column of the first occurrence (and a found boolean), using a labelled break to leave both loops at once. Return -1, -1, false when it is not present.

What we learned

  • if needs no parentheses and can carry an init statement whose variable is scoped to the branch -- the idiomatic home of the error check, keeping a dubious value out of reach where it is not valid;
  • Go has exactly one loop keyword, for, in several shapes: three-clause, condition-only (the while), infinite (for {}), for range over collections, and (since 1.22) for range n over an integer count -- and remember that range hands you a copy of each value;
  • switch does not fall through by default (the opposite of C), a case can list several values, it can carry its own init statement, and a tagless switch { case cond: } is a clean if/else if ladder; fallthrough opts into the old behaviour explicitly;
  • A type switch (v.(type)) branches on a value's concrete type and hands you the converted value in each case -- the multi-case sibling of the comma-ok type assertion;
  • Labels let break/continue act on an outer loop, the clean way in and out of nested loops; goto exists for the rare state-machine case;
  • There is no ternary operator -- an if/else is the whole story, and the whole family of "one way to do it" choices buys a uniformity that pays off at scale.

Next episode we meet the collection you will use more than any other and the one with the most interesting internals: arrays, slices, and the aliasing gotcha that surprises everyone exactly once (the same "range copies, slices share" thread I kept teasing today). Bring the editor, there is real code to write. See you there.

Tot de volgende keer! ;-)

@scipio



0
0
0.000
1 comments