Control Flow in Go

📚 Beginner Level ⏱️ 40 minutes 🔧 Hands-on Examples

Control Flow Overview

Go provides a simple set of control flow constructs that are powerful enough to build complex programs. Unlike many languages, Go has only one looping construct: the for loop.

Go's Philosophy: Less is More

Go deliberately omits many control flow features found in other languages:

  • No while loops - Use for with a condition
  • No do-while loops - Use for with break
  • No ternary operator (?:) - Use if-else for clarity
  • No foreach - Use for-range instead

This minimalist approach reduces complexity and improves code readability.

Control Flow Fundamentals

Control flow determines the order in which statements execute in your program. Go's control flow constructs follow these principles:

Decision Flow Patterns

Go programs typically follow these decision flow patterns:

Pattern Use Case Example
Guard Clause Early return for error conditions if err != nil { return err }
Happy Path Main logic at the end Handle errors first, then process
Table-Driven Multiple conditions with actions Map or slice of handlers

If Statements

Understanding Boolean Logic

If statements evaluate boolean expressions to determine execution flow. Go uses short-circuit evaluation, meaning it stops evaluating as soon as the result is determined.

Short-Circuit Evaluation

In expressions using && (AND) and || (OR):

  • && stops at the first false condition
  • || stops at the first true condition

This allows safe patterns like: if ptr != nil && ptr.value > 0

Basic If

if x > 0 {
    fmt.Println("x is positive")
}

// If-else
if age >= 18 {
    fmt.Println("Adult")
} else {
    fmt.Println("Minor")
}

// If-else if-else
if score >= 90 {
    grade = "A"
} else if score >= 80 {
    grade = "B"
} else if score >= 70 {
    grade = "C"
} else {
    grade = "F"
}

If with Short Statement

Go allows a short statement before the condition, creating variables scoped to the if block. This pattern reduces variable scope and improves code clarity.

// Variable scoped to if block
if err := doSomething(); err != nil {
    return err
}

// Common pattern for map access
if val, ok := myMap[key]; ok {
    fmt.Printf("Value: %v
", val)
} else {
    fmt.Println("Key not found")
}

Switch Statements

Switch Theory and Design

Go's switch statement is more flexible and powerful than in many C-like languages. Key differences:

Switch vs If-Else Performance

For 3+ conditions, switch statements are generally faster than if-else chains. The Go compiler can optimize switches into jump tables or binary searches, while if-else chains always evaluate sequentially.

Basic Switch

switch day {
case "Monday":
    fmt.Println("Start of work week")
case "Friday":
    fmt.Println("TGIF!")
case "Saturday", "Sunday":
    fmt.Println("Weekend!")
default:
    fmt.Println("Midweek")
}

Switch without Expression

// Like if-else chain
switch {
case score >= 90:
    grade = "A"
case score >= 80:
    grade = "B"
case score >= 70:
    grade = "C"
default:
    grade = "F"
}

// Type switch
switch v := x.(type) {
case int:
    fmt.Printf("Integer: %d
", v)
case string:
    fmt.Printf("String: %s
", v)
case bool:
    fmt.Printf("Boolean: %t
", v)
default:
    fmt.Printf("Unknown type
")
}

For Loops

The Universal Loop Construct

Go uses a single for keyword for all loops, replacing while, do-while, and foreach from other languages. This unification simplifies the language while maintaining full expressiveness.

Three Forms of For Loop

  1. Complete form: for init; condition; post { }
  2. Condition only: for condition { } (like while)
  3. Infinite loop: for { } (exit with break/return)

Loop Performance Considerations

Traditional For Loop

// Classic for loop
for i := 0; i < 10; i++ {
    fmt.Println(i)
}

// While-style loop
count := 0
for count < 5 {
    fmt.Println(count)
    count++
}

// Infinite loop
for {
    // Break or return to exit
    if condition {
        break
    }
}

Range Loops

⚠️ Range Variable Capture Pitfall

The loop variables in a range are reused on each iteration. This can cause bugs when capturing them in goroutines or closures. Always copy the value or use the index to access the original.

Range provides a clean way to iterate over slices, arrays, maps, strings, and channels. It returns one or two values depending on what you're iterating over.

// Range over slice
numbers := []int{1, 2, 3, 4, 5}
for index, value := range numbers {
    fmt.Printf("%d: %d
", index, value)
}

// Range over map
ages := map[string]int{"Alice": 30, "Bob": 25}
for name, age := range ages {
    fmt.Printf("%s is %d years old
", name, age)
}

// Range over string (runes)
for i, char := range "Hello, 世界" {
    fmt.Printf("%d: %c
", i, char)
}

Defer Statements

Understanding Defer Mechanics

Defer pushes function calls onto a stack that executes in LIFO (Last In, First Out) order when the surrounding function returns. This guarantees cleanup even if a panic occurs.

Defer Execution Timing

  • Arguments evaluated immediately: Function arguments are evaluated when defer is called
  • Function executed later: The function itself runs when the surrounding function returns
  • Multiple defers: Execute in reverse order (stack behavior)
  • Runs on panic: Deferred functions still execute during panic unwinding

⚠️ Defer in Loops

Avoid defer in loops unless necessary. Deferred functions don't execute until the function returns, potentially accumulating many deferred calls and consuming memory.

// Bad: Accumulates file handles
for _, file := range files {
    f, _ := os.Open(file)
    defer f.Close()  // Won't close until function ends!
}

// Good: Close immediately
for _, file := range files {
    func() {
        f, _ := os.Open(file)
        defer f.Close()  // Closes when anonymous func ends
        // Process file
    }()
}

Common Defer Patterns

// Defer executes when function returns
func readFile() {
    file, err := os.Open("file.txt")
    if err != nil {
        return
    }
    defer file.Close()  // Ensures file is closed
    
    // Read file content
}

// Multiple defers (LIFO order)
func example() {
    defer fmt.Println("Third")
    defer fmt.Println("Second")
    defer fmt.Println("First")
    // Output: First, Second, Third
}

Break and Continue

// Break exits the loop
for i := 0; i < 10; i++ {
    if i == 5 {
        break
    }
    fmt.Println(i)
}

// Continue skips iteration
for i := 0; i < 10; i++ {
    if i%2 == 0 {
        continue
    }
    fmt.Println(i)  // Only odd numbers
}

// Labels for nested loops
outer:
for i := 0; i < 3; i++ {
    for j := 0; j < 3; j++ {
        if i == 1 && j == 1 {
            break outer
        }
        fmt.Printf("(%d, %d) ", i, j)
    }
}

Control Flow Best Practices

Idiomatic Go Patterns

Guard Clauses and Early Returns

Handle error cases first and return early. This keeps the happy path at the main indentation level.

// Good: Early return pattern
func process(data []byte) error {
    if data == nil {
        return errors.New("data is nil")
    }
    if len(data) == 0 {
        return errors.New("data is empty")
    }
    
    // Happy path with minimal nesting
    return doProcess(data)
}

Common Anti-Patterns to Avoid

Table-Driven Tests Pattern

For complex conditional logic, consider table-driven approaches for clarity and maintainability:

// Table-driven approach for complex conditions
type rule struct {
    condition func(int) bool
    action    func()
}

rules := []rule{
    {condition: func(x int) bool { return x < 0 }, action: handleNegative},
    {condition: func(x int) bool { return x == 0 }, action: handleZero},
    {condition: func(x int) bool { return x > 100 }, action: handleLarge},
}

for _, r := range rules {
    if r.condition(value) {
        r.action()
        break
    }
}

🎯 Practice Exercises

Exercise 1: FizzBuzz

Implement the classic FizzBuzz problem using Go's control flow.

Exercise 2: Number Guessing Game

Create a game where the user guesses a random number with hints.

Exercise 3: Pattern Printer

Use nested loops to print various patterns (triangles, diamonds).