Day 4 — Functions, returns & defer

Updated

July 30, 2026

Day 4 — Functions, returns & defer

Stage I · ~3h (theory-heavy)
Goal: Make functions the unit of design—multiple returns, named results, defer timing, and first-class function values—then refactor a small pipeline so main only wires and exits.

Note

Go has no exceptions for ordinary failure. The idiomatic channel is (T, error). Panic is for truly exceptional situations (Day 14). Today builds the habits that make panic rare.

Why this day exists

Most Go APIs are shaped by function conventions:

  • Multiple return values instead of out-params or exceptions
  • error as the last return value
  • defer for cleanup that must run on every path
  • Small functions composed into pipelines

If you treat functions like C (single return, manual cleanup everywhere) or like Java (exceptions), you will fight the language. Day 4 aligns muscle memory with the stdlib.


Theory 1 — Declaration, parameters, and results

Signature anatomy

func Name(param1 Type1, param2 Type2) (Result1, Result2) {
    return value1, value2
}

Same-type parameters can share a type:

func add(a, b int) int { return a + b }

Multiple returns

func div(a, b float64) (float64, error) {
    if b == 0 {
        return 0, fmt.Errorf("division by zero")
    }
    return a / b, nil
}

Call site:

q, err := div(10, 2)
if err != nil {
    return err
}

Blank identifier

q, _ := div(10, 2) // discard error — almost always wrong in real code
_, err := os.Stat(path)

Use _ deliberately (e.g., you truly need only one result), never to silence errors you should handle.

Named result parameters

func lookup(id string) (name string, err error) {
    name, ok := db[id]
    if !ok {
        err = fmt.Errorf("id %q not found", id)
        return // naked return
    }
    return name, nil
}

Named results:

  • Document intent at the signature
  • Are zero-initialized
  • Enable naked returns (return alone)

Judgment: naked returns are fine in short functions; in long ones they obscure what is returned. Prefer explicit return name, err for clarity when the body grows.


Theory 2 — Errors as values (preview that you use today)

func readConfig(path string) ([]byte, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("read config %s: %w", path, err)
    }
    return data, nil
}

Conventions:

Rule Why
error is last Matches entire stdlib
nil error = success Check err != nil first
Wrap with %w Enables errors.Is / As later (Day 13)
Do not panic for expected failure Missing file, bad input, network blip

Today: return and check. Full wrapping toolkit on Day 13.


Theory 3 — defer: schedule, LIFO, and arguments

What defer does

f, err := os.Open(path)
if err != nil {
    return err
}
defer f.Close()

defer registers a call to run when the surrounding function returns—normally or via panic (before the panic continues unwinding).

LIFO order

func order() {
    defer fmt.Println("first deferred")  // runs second
    defer fmt.Println("second deferred") // runs first
    fmt.Println("body")
}
// prints: body, second deferred, first deferred

Open A, defer close A; open B, defer close B → B closes before A. Nested resource lifetimes stay correct.

Arguments are evaluated at defer time

func trace() {
    x := 1
    defer fmt.Println("x=", x) // prints x= 1
    x = 2
}

The call’s arguments are evaluated when defer executes, not when the deferred function runs. Closures can observe later mutations:

func trace2() {
    x := 1
    defer func() { fmt.Println("x=", x) }() // prints x= 2
    x = 2
}

Common cleanup patterns

mu.Lock()
defer mu.Unlock()

resp, err := http.Get(url)
if err != nil {
    return err
}
defer resp.Body.Close()

rows, err := db.Query(q)
if err != nil {
    return err
}
defer rows.Close()

defer and named returns

func readAll(path string) (data []byte, err error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    defer func() {
        cerr := f.Close()
        if err == nil {
            err = cerr // surface close error if read succeeded
        }
    }()
    return io.ReadAll(f)
}

This pattern matters for durable correctness. You will revisit it when writing real I/O packages.

What not to use defer for

  • Replacing ordinary control flow in the middle of a hot loop (cost is small but not free; more importantly, defer runs at function end, not loop end)
  • “Maybe cleanup” without clear ownership
  • Business logic that callers need to observe as primary control flow

Theory 4 — Function values and closures

Functions are first-class:

func apply(nums []int, fn func(int) int) []int {
    out := make([]int, len(nums))
    for i, n := range nums {
        out[i] = fn(n)
    }
    return out
}

doubled := apply([]int{1, 2, 3}, func(n int) int { return n * 2 })

Closures capture variables

func counter() func() int {
    n := 0
    return func() int {
        n++
        return n
    }
}

c := counter()
fmt.Println(c()) // 1
fmt.Println(c()) // 2

Each call to counter() gets its own n.

Methods as values (preview)

var w io.Writer = os.Stdout
write := w.Write // method value; receiver bound
write([]byte("hi\n"))

Day 7–11 deepen methods and interfaces. Today: know functions and methods can be passed around.


Theory 5 — Variadic parameters

func sum(vals ...int) int {
    total := 0
    for _, v := range vals {
        total += v
    }
    return total
}

sum()
sum(1, 2, 3)
nums := []int{4, 5}
sum(nums...) // expand slice

Only the last parameter may be variadic. Inside the function, vals has type []int.

fmt.Println and fmt.Sprintf are the classic stdlib examples.


Theory 6 — Structuring main

Idiomatic shape for CLIs:

func main() {
    if err := run(os.Args[1:]); err != nil {
        fmt.Fprintf(os.Stderr, "error: %v\n", err)
        os.Exit(1)
    }
}

func run(args []string) error {
    // all real work; return errors
    return nil
}

Benefits:

  • defer in run works; os.Exit in main skips defers in main if you exit there without returning—keeping work in run preserves defer semantics for cleanup registered in run
  • Testable: call run from tests later without starting a process

Worked examples bank

Example A — Multiple returns + validation

package main

import (
    "fmt"
    "strconv"
    "strings"
)

func parsePair(s string) (int, int, error) {
    parts := strings.Split(s, ",")
    if len(parts) != 2 {
        return 0, 0, fmt.Errorf("want a,b got %q", s)
    }
    a, err := strconv.Atoi(strings.TrimSpace(parts[0]))
    if err != nil {
        return 0, 0, fmt.Errorf("left: %w", err)
    }
    b, err := strconv.Atoi(strings.TrimSpace(parts[1]))
    if err != nil {
        return 0, 0, fmt.Errorf("right: %w", err)
    }
    return a, b, nil
}

Example B — Defer LIFO with files

func copyFile(dst, src string) (err error) {
    in, err := os.Open(src)
    if err != nil {
        return err
    }
    defer in.Close()

    out, err := os.Create(dst)
    if err != nil {
        return err
    }
    defer func() {
        cerr := out.Close()
        if err == nil {
            err = cerr
        }
    }()

    _, err = io.Copy(out, in)
    return err
}

Example C — Pipeline of helpers

func run(args []string) error {
    if len(args) != 1 {
        return fmt.Errorf("usage: tool <path>")
    }
    data, err := os.ReadFile(args[0])
    if err != nil {
        return err
    }
    lines := strings.Split(string(data), "\n")
    summary, err := summarize(lines)
    if err != nil {
        return err
    }
    fmt.Println(summary)
    return nil
}

Example D — Function value table

var ops = map[string]func(int, int) int{
    "+": func(a, b int) int { return a + b },
    "-": func(a, b int) int { return a - b },
    "*": func(a, b int) int { return a * b },
}

func eval(op string, a, b int) (int, error) {
    fn, ok := ops[op]
    if !ok {
        return 0, fmt.Errorf("unknown op %q", op)
    }
    return fn(a, b), nil
}

Example E — Defer argument evaluation trap

func demo() {
    for i := 0; i < 3; i++ {
        defer fmt.Println("arg", i) // args fixed per defer: 0,1,2 then LIFO print 2,1,0
    }
}

func demoClosureWrongOldMentalModel() {
    for i := 0; i < 3; i++ {
        defer func() { fmt.Println("clos", i) }() // Go 1.22+: 2,1,0 still LIFO but each i per iteration
    }
}

Run both; write down what you observe on Go 1.26.


Labs

Suggested workspace: ~/lab/90daysofx/go/day04

Lab 1 — Refactor classifier (or units) behind run

mkdir -p ~/lab/90daysofx/go/day04
cd ~/lab/90daysofx/go/day04
go mod init example.com/day04

Requirements:

  1. main only calls run and maps error → stderr + exit code.
  2. At least three helpers with (T, error) or (…, error) signatures.
  3. At least one defer that closes a resource (os.Open + defer f.Close(), or bufio over a file).
  4. Usage errors return a distinct sentinel or message so main can exit 2 vs 1.

Example exit policy:

func main() {
    err := run(os.Args[1:])
    if err == nil {
        return
    }
    fmt.Fprintf(os.Stderr, "%v\n", err)
    if errors.Is(err, errUsage) {
        os.Exit(2)
    }
    os.Exit(1)
}

Define var errUsage = errors.New("usage") or wrap with a clear prefix—keep it simple.

Lab 2 — Defer timing journal

Write a tiny program that prints:

  1. Body vs deferred order (LIFO)
  2. Argument evaluation vs closure capture
  3. That os.Exit(1) from main skips deferred functions in main

Document findings in NOTES.md or comments.

Lab 3 — Variadic + function value

Implement func join(sep string, parts ...string) string without calling strings.Join first—then compare to strings.Join. Pass a func(string) string mapper over a slice of names (trim + lower).


Common gotchas

Gotcha Fix
Ignoring error with _ Always handle or return
defer f.Close() before checking err from Open Only defer after successful open
Expecting defer at end of loop iteration Deferred until function returns
os.Exit inside helper Return error; exit in main
Naked return in long function Prefer explicit returns
Shadowing err in nested scopes Reuse carefully; name inner errors if needed
Thinking deferred arg sees final variable value Args evaluated at defer line; use closure if needed

Checkpoint

  • Multiple returns used idiomatically with err last
  • Named results explained (even if you rarely naked-return)
  • defer LIFO demonstrated
  • Argument evaluation vs closure capture understood
  • main / run split in place
  • At least one real resource cleaned with defer
  • Exit codes distinguish usage vs runtime failure

Commit

git add .
git commit -m "day04: functions, defer, run() pattern"

Tomorrow

Day 5 — Arrays, slices & append: the slice header, capacity, aliasing bugs, and why almost all “list” code in Go is really about slices—not arrays.