Chapter 4

Functions, Closures and Defer

Functions, Closures and Defer

Go's function system appears deceptively simple — no overloading, no default parameters, no decorators — yet this simplicity masks carefully considered engineering decisions. Functions are Go's sole unit of code reuse; closures allow state to survive between invocations; and defer replaces the fragile try-finally patterns of other languages with deterministic resource cleanup.

This chapter progresses from basic function definitions through the memory model of closures to the precise execution mechanics of defer. If you only need to get productive quickly, Level 1 suffices. If you're preparing for interviews or optimizing hot paths, Levels 3 and 4 are your destination.

Level 1: What You Need to Know

Basic Function Definition

Go defines functions with the func keyword. Unlike C or Java, Go places type annotations after variable names — a design inspired by Pascal that makes declarations read more naturally from left to right (Rob Pike, "Go's Declaration Syntax", 2010).

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

// Parameters of the same type can share the type declaration
func add(a, b int) int {
    return a + b
}

Functions are first-class citizens — they can be assigned to variables, passed as arguments, and returned from other functions:

// Assign to variable
var fn func(int, int) int = add

// Function as parameter
func apply(f func(int, int) int, a, b int) int {
    return f(a, b)
}

// Function as return value
func makeAdder(x int) func(int) int {
    return func(y int) int {
        return x + y
    }
}

Multiple Return Values

Multiple return values aren't syntactic sugar — they're the core mechanism for error handling in Go. In C, functions can only return one value, forcing errors through global errno (thread-unsafe) or pointer parameters (callers often forget to check). Go's multiple return values solve this cleanly:

// Standard "result + error" pattern
func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

// Caller must handle both return values
result, err := divide(10, 0)
if err != nil {
    log.Fatal(err)
}

Why not exceptions? Rob Pike and Ken Thompson explicitly rejected exceptions when designing Go (Rob Pike, "Errors are values", Go Blog, 2015), for three reasons:

  1. Exceptions disrupt control flow: When reading code, you cannot know which line might throw
  2. Exceptions encourage laziness: Developers tend to catch at the outermost level rather than handling errors where they occur
  3. Exception performance is unpredictable: try-catch has zero overhead when no exception occurs, but stack unwinding on throw is extremely expensive

Multiple return values make error handling explicit, local, and predictable.

Named Return Values

Named return values give names to return parameters. They're initialized to their zero values when the function begins:

func divide(a, b float64) (result float64, err error) {
    if b == 0 {
        err = errors.New("division by zero")
        return // naked return, equivalent to: return result, err
    }
    result = a / b
    return
}

When to use named return values:

  1. Documentation: When a function returns multiple values of the same type, names improve readability
  2. Modifying return values in defer: This is their most important use case (covered in the defer section)
  3. Reducing variable declarations: Avoid redundant declarations in complex functions

Caution: The Go community consensus is — don't overuse naked returns. In functions longer than 10 lines, naked returns reduce readability because readers must look back at the signature to know what's being returned. The official Go CodeReviewComments wiki explicitly states: naked returns are fine in short functions, but long functions should explicitly name what they return.

Variadic Functions

Variadic parameters use ...T syntax and appear as []T slices inside the function:

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

// Calling conventions
sum(1, 2, 3)        // nums = []int{1, 2, 3}
sum()               // nums = []int{} (empty slice, not nil)

// Expanding an existing slice
numbers := []int{1, 2, 3}
sum(numbers...)     // equivalent to sum(1, 2, 3)

The variadic parameter must be the last parameter. This isn't an arbitrary restriction — the compiler needs to unambiguously determine which actual arguments correspond to which formal parameters. If variadic parameters could appear in the middle, both callers and the compiler would face ambiguity.

A common mistake — passing a slice to a variadic parameter without expansion:

func printAll(args ...interface{}) {
    for _, arg := range args {
        fmt.Println(arg)
    }
}

names := []string{"Alice", "Bob"}
// Wrong: this passes the entire slice as a single interface{} argument
printAll(names)    // prints: [Alice Bob]

// Correct approach requires manual construction of []interface{}
ifaces := make([]interface{}, len(names))
for i, n := range names {
    ifaces[i] = n
}
printAll(ifaces...)  // prints: Alice\nBob

Anonymous Functions

Go supports anonymous functions (also called function literals). They can be defined and immediately invoked, or assigned to variables:

// Immediately invoked
func() {
    fmt.Println("I'm anonymous")
}()

// Assigned to variable
handler := func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hello")
}
http.HandleFunc("/", handler)

// As goroutine entry point
go func(msg string) {
    fmt.Println(msg)
}("hello from goroutine")

The most important capability of anonymous functions is capturing external variables — which brings us to closures.

Closures Introduction

A closure = function + the external variables it references. When an anonymous function references variables from an outer scope, those variables' lifetimes are extended until the closure is no longer in use:

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

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

The count variable is normally a local variable of counter() — by conventional understanding, it should be destroyed after the function returns. But because the returned closure still references it, the compiler "escapes" count from the stack to the heap, ensuring the closure can continue to access it.

Defer Basics

A defer statement postpones a function call until the surrounding function returns. The most typical use is resource cleanup:

func readFile(path string) ([]byte, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    defer f.Close() // file will be closed regardless of how the function returns

    return io.ReadAll(f)
}

The core value of defer: resource acquisition and release are written together, preventing forgotten releases as code paths grow complex (multiple returns, panics).

Multiple defers execute in LIFO (last-in, first-out) order:

func example() {
    defer fmt.Println("first")
    defer fmt.Println("second")
    defer fmt.Println("third")
}
// Output: third, second, first

Defer arguments are evaluated when the defer statement executes (not when the deferred call runs):

func example() {
    x := 0
    defer fmt.Println(x) // x is 0 at this point, prints 0
    x = 42
}
// Output: 0 (not 42)

This is a critically important property — if you need a defer to use a variable's final value, you must use a closure or pointer:

func example() {
    x := 0
    defer func() {
        fmt.Println(x) // closure captures reference to x, prints final value
    }()
    x = 42
}
// Output: 42

Panic and Recover

Go doesn't have exceptions, but provides panic and recover for handling unrecoverable errors:

func safeDivide(a, b int) (result int, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("recovered: %v", r)
        }
    }()
    
    return a / b, nil // panics when b=0
}

recover() is only effective inside a defer function. Calling recover() anywhere else always returns nil. This is because after a panic triggers, the runtime executes the current goroutine's defer chain, and only during this execution can recover() intercept the panic.

Usage principles:

Level 2: How It Works Under the Hood

Function Calling Convention

Understanding Go's calling convention helps explain performance characteristics and some seemingly bizarre behaviors.

Before Go 1.17, all function parameters and return values were passed via the stack. This differs from C's System V AMD64 ABI (which passes the first 6 integer parameters in registers), making Go function calls approximately 10-15% slower at the micro level.

Go 1.17 introduced a register-based calling convention, using 9 integer registers and 15 floating-point registers on amd64 (Austin Clements, "Proposal: Register-based Go calling convention", 2020). This change improved most programs' performance by 5-14%.

Go 1.16 (stack passing):
caller:
    MOVQ arg1, 0(SP)
    MOVQ arg2, 8(SP)
    CALL foo
    MOVQ 16(SP), ret1   // return value also on stack

Go 1.17+ (register passing):
caller:
    MOVQ arg1, AX
    MOVQ arg2, BX
    CALL foo
    // ret1 already in AX

Multiple return values are simply multiple "slots" on the stack/in registers at the low level — no wrapping overhead unlike Python's tuple creation.

Closure Memory Model

A closure is, at its core, a struct containing a function pointer and pointers to captured variables:

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

The compiler transforms this into something like (pseudocode):

type closureCounter struct {
    F     uintptr  // pointer to anonymous function code
    count *int     // pointer to captured variable
}

func counter() func() int {
    count := new(int) // escapes to heap
    *count = 0
    return &closureCounter{
        F:     counterAnonymous,
        count: count,
    }
}

func counterAnonymous(c *closureCounter) int {
    *c.count++
    return *c.count
}

The critical point: closures capture variable references (pointers), not value copies. This means:

  1. Multiple closures can share the same variable
  2. A closure's modifications to a variable are visible externally
  3. External modifications to a variable are visible to the closure
func sharedState() (func(), func(), func() int) {
    x := 0
    inc := func() { x++ }
    dec := func() { x-- }
    get := func() int { return x }
    return inc, dec, get
}

inc, dec, get := sharedState()
inc()
inc()
dec()
fmt.Println(get()) // 1 — all three closures share the same x

Escape Analysis and Closures

The Go compiler uses escape analysis to decide whether variables are allocated on the stack or heap. When a variable is captured by a closure, the compiler detects that its lifetime may exceed the current function's stack frame and allocates it on the heap:

$ go build -gcflags="-m" main.go
./main.go:4:2: moved to heap: count

Heap allocation means increased GC pressure. Creating many closures in hot paths can lead to GC pauses. Optimization strategies:

  1. Avoid creating closures in loops — if the closure doesn't need to capture loop variables, extract it as a named function
  2. Use sync.Pool to cache temporary objects used by closures
  3. Consider methods instead of closures — method receivers don't trigger escape analysis the same way

The Loop Variable Closure Trap

This is one of Go's most classic pitfalls, plaguing developers for 10+ years:

// Buggy code
funcs := make([]func(), 5)
for i := 0; i < 5; i++ {
    funcs[i] = func() {
        fmt.Println(i) // all closures share the same i
    }
}
for _, f := range funcs {
    f() // all print 5, not 0,1,2,3,4
}

Why? The loop variable i has only one instance; each iteration merely modifies its value. All closures capture a pointer to the same i. When they execute, the loop has ended and i is 5.

Fix before Go 1.22:

for i := 0; i < 5; i++ {
    i := i // create new variable (shadow), one per iteration
    funcs[i] = func() {
        fmt.Println(i) // captures the newly created variable
    }
}

Go 1.22 fix (Russ Cox, "Fixing For Loops in Go 1.22", 2023): Starting in Go 1.22, each loop iteration creates a new loop variable. This is a language semantics change — old code that relies on "all iterations share the same variable" behavior will behave differently in Go 1.22+.

// Go 1.22+: each iteration i is a new variable
for i := 0; i < 5; i++ {
    funcs[i] = func() {
        fmt.Println(i) // correctly prints 0,1,2,3,4
    }
}

This change was preview-testable via GOEXPERIMENT=loopvar in Go 1.21 and became the default in Go 1.22. The compiler warns through go vet's loopclosure check if old code might be affected.

Defer Execution Mechanics

Defer is managed at runtime by a _defer struct linked list. Each goroutine maintains a defer chain (stored in g._defer), with new defers inserted at the head and execution starting from the head — this is the underlying implementation of LIFO ordering.

// Simplified structure from runtime (Go 1.13)
type _defer struct {
    siz     int32    // size of arguments and results
    started bool     // whether defer has started executing
    sp      uintptr  // caller's stack pointer
    pc      uintptr  // caller's program counter
    fn      *funcval // the deferred function
    _panic  *_panic  // panic that triggered this defer (if any)
    link    *_defer  // linked list pointer to next defer
}

Defer execution occurs precisely before the function's RET instruction but after return value assignment. This is the key to understanding defer's interaction with return values:

func f() (result int) {
    defer func() {
        result++  // can modify named return value
    }()
    return 0  // first assigns result = 0, then defer executes, result becomes 1
}
// f() returns 1

The complete execution model:

  1. Assign return values (return x is equivalent to result = x)
  2. Execute all defers in LIFO order
  3. RET instruction executes, function actually returns

Three Implementations of Defer

The Go runtime has gone through three generations of defer optimization:

First generation: Heap-allocated defer (Go 1.12 and earlier)

Each defer statement allocates a _defer struct on the heap, costing approximately 50-70ns.

Second generation: Stack-allocated defer (Go 1.13)

Dan Scales proposed allocating _defer structs on the function's stack frame, avoiding heap allocation and GC. Stack allocation costs approximately 25-35ns — about 30% faster. Limitation: only works when the compiler can determine the number of defers at compile time (defers in loops still use heap allocation).

Third generation: Open-coded defer (Go 1.14)

Dan Scales further optimized ("Proposal: Low-cost defers through inline code", 2019): for simple cases, the compiler directly inlines the deferred call code before each return, completely eliminating _defer struct allocation. Overhead drops to approximately 6ns — essentially zero.

Requirements:

// This defer gets open-coded optimization
func simple() {
    mu.Lock()
    defer mu.Unlock() // compiler inserts mu.Unlock() before all returns
    // ...
}

// This defer cannot be open-coded (inside loop)
func loop(files []string) {
    for _, f := range files {
        fd, _ := os.Open(f)
        defer fd.Close() // still uses heap allocation
    }
}

Closure and Defer Synergy

defer + closure is one of Go's most powerful resource management patterns:

// Pattern 1: Measure function execution time
func trace(name string) func() {
    start := time.Now()
    return func() {
        fmt.Printf("%s took %v\n", name, time.Since(start))
    }
}

func processData() {
    defer trace("processData")()  // note: two sets of parentheses
    // first () calls trace(), returning a closure
    // defer delays the returned closure
    time.Sleep(100 * time.Millisecond)
}
// Pattern 2: Error handling enhancement
func doWork() (err error) {
    defer func() {
        if err != nil {
            err = fmt.Errorf("doWork failed: %w", err)
        }
    }()
    
    // all returned errors get wrapped
    if err := step1(); err != nil {
        return err
    }
    return step2()
}
// Pattern 3: Transaction pattern
func transfer(db *sql.DB, from, to int, amount int64) (err error) {
    tx, err := db.Begin()
    if err != nil {
        return err
    }
    defer func() {
        if err != nil {
            tx.Rollback()
        } else {
            err = tx.Commit()
        }
    }()
    
    // business logic...
    _, err = tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, from)
    if err != nil {
        return err
    }
    _, err = tx.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, to)
    return err
}

Level 3: What the Specification Says

Function Types in the Go Specification

The Go Programming Language Specification defines function types as follows:

A function type denotes the set of all functions with the same parameter and result types.

FunctionType = "func" Signature . Signature = Parameters [ Result ] . Result = Parameters | Type .

Key specification details:

  1. Function type comparison: Function types are incomparable. You cannot use == to compare two function variables (except against nil). This is because function values may be closures with complex internal structures, and the Go team considered defining "equality" semantics too difficult (if two closures have the same code but capture different variables, are they equal?).

  2. Nil function values: The zero value of a function variable is nil. Calling a nil function panics.

var f func()
f == nil  // true, this is the only legal function comparison
f()       // panic: call of nil function value
  1. Variadic parameters specification:

The final incoming parameter in a function signature may have a type prefixed with .... A function with such a parameter is called variadic and may be invoked with zero or more arguments for that parameter.

The specification explicitly states: inside the function, variadic parameters are equivalent to []T type. But when calling f(args...), no new slice is created — args is passed directly (this has performance implications).

Defer in the Specification

The specification describes defer with precision:

A "defer" statement invokes a function whose execution is deferred to the moment the surrounding function returns, either because the surrounding function executed a return statement, reached the end of its function body, or because the corresponding goroutine is panicking.

Note "surrounding function" — defer binds to the nearest enclosing function, not block scope:

func example() {
    if true {
        defer fmt.Println("in if block")
    }
    // this defer binds to example(), not the if block
    // even though the if block "ends", the defer doesn't execute immediately
    fmt.Println("after if")
}
// Output: after if, in if block

The specification on defer argument evaluation:

Each time a "defer" statement executes, the function value and parameters to the call are evaluated as usual and saved anew but the actual function is not invoked.

"Evaluated as usual and saved anew" — arguments are evaluated when the defer statement executes, not when the deferred call runs.

Panic and Recover Specification

The specification defines a strict execution model for panic/recover:

  1. When panic triggers, the current function immediately stops executing
  2. All defers of the current function execute in LIFO order
  3. Then returns to the caller, which also immediately stops and executes its defers
  4. This continues up to the goroutine stack top, then the program crashes

The recover specification:

The recover built-in function allows a program to manage behavior of a panicking goroutine. Suppose a function G defers a function D that calls recover and a panic occurs in a function on the same goroutine in which G is executing. When the running of deferred functions reaches D, the value D's call to recover will be the value that was passed to the call of panic. If D returns normally without starting a new panic, the panicking sequence stops.

Key details:

func main() {
    fmt.Println("start")
    safeCall()
    fmt.Println("end") // this executes
}

func safeCall() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("recovered:", r)
        }
    }()
    dangerousFunc()
    fmt.Println("after dangerous") // this does NOT execute
}

func dangerousFunc() {
    panic("boom")
}
// Output: start, recovered: boom, end

Function Memory Representation

In the Go runtime, a function value is a pointer to a funcval struct:

// runtime/runtime2.go
type funcval struct {
    fn uintptr  // function entry address
    // variable-length: followed by closure's captured variables
}

Direct function calls (non-closures) have their target address determined at compile time and don't need funcval. Only when a function is used as a value (assigned, passed, returned) does funcval get created.

A closure's funcval is immediately followed by captured variables. The compiler analyzes whether the closure captures a variable's value or reference:

Open-coded Defer Design Decisions (Go 1.14)

Why the limit of 8 defers? The implementation uses an 8-bit bitmap (df field) to track which defers have been registered. Each defer corresponds to one bit:

// Compiler-generated pseudocode
func f() {
    var df byte = 0  // defer bitmap
    
    // Original: defer foo()
    df |= 1 << 0  // mark defer #0 as registered
    
    // Original: defer bar()
    df |= 1 << 1  // mark defer #1 as registered
    
    // ... function body ...
    
    // Inserted before each return:
    if df & (1 << 1) != 0 { bar() }
    if df & (1 << 0) != 0 { foo() }
    return
}

Bitmap benefits:

  1. Only 1 byte of stack space needed
  2. Condition checks are extremely cheap (one AND + one branch)
  3. Correctly handles conditional defers (if cond { defer ... })

Why does open-coded defer still work during panic? When a panic occurs, the runtime scans the stack frame's bitmap to determine which defers need execution, then invokes them one by one.

Stack Growth and Function Calls

Go uses segmented stacks (before Go 1.3) and contiguous stacks (Go 1.4+) to implement goroutines' small initial stacks (starting at 2KB) with dynamic growth.

Every function entry has a stack check preamble:

TEXT ·foo(SB), NOSPLIT, $128-0
    MOVQ (TLS), CX          // get current goroutine's g struct
    CMPQ SP, 16(CX)         // compare SP with g.stackguard0
    JLS  morestack          // jump to stack growth if SP too small
    // ... normal function code ...
morestack:
    CALL runtime.morestack_noctxt(SB)
    JMP  foo(SB)            // retry function after stack growth

When the stack needs to grow:

  1. Allocate a new stack at 2x size
  2. Copy old stack contents to new stack
  3. Update all pointers to the old stack (this is why you can't pass Go pointers to C code — the stack might move)
  4. Free the old stack

The //go:nosplit compiler directive skips stack checks, but the function must guarantee it uses less than 128 bytes of stack space. Some low-level standard library functions use it to avoid stack check overhead.

Level 4: Edge Cases and Pitfalls

Interview Question: Defer Execution Order

Question: What does the following code output?

func main() {
    fmt.Println(f1())
    fmt.Println(f2())
    fmt.Println(f3())
}

func f1() int {
    x := 5
    defer func() {
        x++
    }()
    return x
}

func f2() (x int) {
    defer func() {
        x++
    }()
    return 5
}

func f3() (y int) {
    x := 5
    defer func() {
        x++
    }()
    return x
}

Answer: 5, 6, 5

Explanation:

Interview Question: Closure Printing Problem

Question: What does the following code output in Go 1.21 vs Go 1.22?

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            fmt.Println(i)
        }()
    }
    wg.Wait()
}

Go 1.21 answer: 3, 3, 3 (order undefined, but all are 3)

All goroutines capture the same variable i. By the time goroutines start executing, the main goroutine's loop has finished and i equals 3.

Go 1.22 answer: 0, 1, 2 (order undefined)

Each iteration creates a new i, each goroutine captures its own iteration's i.

Core knowledge tested:

  1. Closures capture variable references, not value copies
  2. Goroutine scheduling timing is nondeterministic
  3. Go 1.22's language semantics change

Interview Question: panic + defer + recover Execution Flow

Question: What does the following code output?

func main() {
    defer func() {
        fmt.Println("main defer 1")
    }()
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("recovered:", r)
        }
    }()
    defer func() {
        fmt.Println("main defer 3")
    }()
    
    fmt.Println("before panic")
    panic("something wrong")
    fmt.Println("after panic") // never executes
}

Answer:

before panic
main defer 3
recovered: something wrong
main defer 1

Explanation:

  1. Normal execution prints "before panic"
  2. Panic triggers, defer chain executes (LIFO)
  3. The last registered "main defer 3" executes first
  4. The second-to-last defer successfully recovers, prints "recovered: something wrong"
  5. After recover, the panic sequence stops, but remaining defers still execute
  6. "main defer 1" executes

Common misconception: Do remaining defers execute after recover? Yes. Recover only prevents the program from crashing, but all defers in the current function still execute to completion.

Pitfall: Defer in Loops

// Anti-pattern: file descriptor leak
func processFiles(paths []string) error {
    for _, path := range paths {
        f, err := os.Open(path)
        if err != nil {
            return err
        }
        defer f.Close() // all Close calls deferred until processFiles returns
        // if paths has 10000 files, 10000 file descriptors open simultaneously
        process(f)
    }
    return nil
}

// Correct approach: extract to sub-function
func processFiles(paths []string) error {
    for _, path := range paths {
        if err := processOneFile(path); err != nil {
            return err
        }
    }
    return nil
}

func processOneFile(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close()
    return process(f)
}

Pitfall: Defer Argument Evaluation

func logElapsed(name string) {
    start := time.Now()
    defer fmt.Printf("%s took %v\n", name, time.Since(start)) 
    // BUG: time.Since(start) is evaluated when defer registers, result always near 0
    
    time.Sleep(time.Second)
}

// Fix: use a closure
func logElapsed(name string) {
    start := time.Now()
    defer func() {
        fmt.Printf("%s took %v\n", name, time.Since(start))
    }()
    
    time.Sleep(time.Second)
}

Pitfall: os.Exit Doesn't Execute Defer

func main() {
    defer fmt.Println("cleanup") // never executes
    
    if err := run(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1) // terminates process directly, skips defer
    }
}

// Fix pattern
func main() {
    if err := run(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

func run() error {
    f, err := os.Open("file")
    if err != nil {
        return err
    }
    defer f.Close() // executes when run() returns (normal return or panic)
    // ...
    return nil
}

os.Exit calls the exit(2) system call to terminate the process directly, bypassing the normal function return path. Similarly, log.Fatal internally calls os.Exit(1) and also skips defer.

Pitfall: Recover's Scope

// Wrong: recover must be called directly in a defer function
func wrong() {
    defer recover() // ineffective! recover isn't in a defer function body
    panic("boom")   // program still crashes
}

// Wrong: too deeply nested doesn't work either
func alsoWrong() {
    defer func() {
        func() {
            recover() // ineffective! recover in nested function, not direct defer
        }()
    }()
    panic("boom") // program still crashes
}

// Correct
func correct() {
    defer func() {
        recover() // effective: directly in the defer function body
    }()
    panic("boom") // recovered
}

The Go specification mandates: recover is only effective when directly called by a defer function. This "directly" means recover's call depth must be exactly defer function + 1. This restriction prevents library code from accidentally swallowing panics.

Real-world Case: net/http's Recover

The Go standard library's net/http Server uses recover in each request's goroutine to prevent a single request's panic from crashing the entire service:

// net/http/server.go (simplified)
func (c *conn) serve(ctx context.Context) {
    defer func() {
        if err := recover(); err != nil && err != http.ErrAbortHandler {
            const size = 64 << 10
            buf := make([]byte, size)
            buf = buf[:runtime.Stack(buf, false)]
            c.server.logf("http: panic serving %v: %v\n%s", 
                c.remoteAddr, err, buf)
        }
        c.close()
    }()
    // ... handle request ...
}

This is the canonical use of recover — defensive recovery at the framework level, with full stack trace logging for debugging.

Performance Comparison: Function Call vs Closure vs Method Call

Measuring performance differences between calling styles via benchmarks:

// Direct function call
func directCall(x int) int { return x + 1 }

// Closure call
var closureCall = func(x int) int { return x + 1 }

// Method call (value receiver)
type Adder struct{ n int }
func (a Adder) Add(x int) int { return x + a.n }

// Interface call
type Incrementer interface { Inc(int) int }

Typical results on amd64 (Go 1.21):

Call style Time (ns/op) Notes
Direct call ~0.3 compiler can inline
Closure call ~1.5 indirect call, no inlining
Method call ~0.3 can be inlined
Interface call ~2.0 indirect call + itab lookup

Key insight: If a closure's function body is simple enough and the concrete function can be determined at the call site, the compiler's devirtualization optimization can convert indirect calls to direct calls. Go 1.20+ has made significant improvements in this area.

Advanced Pattern: Functional Options

The most elegant application of closures in API design is the functional options pattern proposed by Dave Cheney ("Functional options for friendly APIs", 2014):

type Server struct {
    addr    string
    port    int
    timeout time.Duration
    maxConn int
}

type Option func(*Server)

func WithPort(port int) Option {
    return func(s *Server) {
        s.port = port
    }
}

func WithTimeout(t time.Duration) Option {
    return func(s *Server) {
        s.timeout = t
    }
}

func WithMaxConn(n int) Option {
    return func(s *Server) {
        s.maxConn = n
    }
}

func NewServer(addr string, opts ...Option) *Server {
    s := &Server{
        addr:    addr,
        port:    8080,
        timeout: 30 * time.Second,
        maxConn: 100,
    }
    for _, opt := range opts {
        opt(s)
    }
    return s
}

// Usage
srv := NewServer("localhost",
    WithPort(9090),
    WithTimeout(5*time.Second),
    WithMaxConn(1000),
)

This pattern leverages closures:

  1. Each WithXxx function returns a closure that captures the configuration value
  2. All closures share the unified Option type, enabling composition
  3. Adding new options only requires new WithXxx functions, without breaking existing APIs

Deep Interview Question: What's Wrong with This Code?

func worker(tasks <-chan func()) {
    for task := range tasks {
        go func() {
            defer func() {
                if r := recover(); r != nil {
                    log.Printf("task panicked: %v", r)
                }
            }()
            task() // bug here
        }()
    }
}

Answer: In Go 1.21 and earlier, task is a loop variable shared by all goroutines. When a goroutine executes task(), task may already point to the next iteration's value. Fix:

// Fix for Go 1.21 and earlier
for task := range tasks {
    task := task // create local copy
    go func() {
        defer func() {
            if r := recover(); r != nil {
                log.Printf("task panicked: %v", r)
            }
        }()
        task()
    }()
}

// Or pass through parameter
for task := range tasks {
    go func(t func()) {
        defer func() {
            if r := recover(); r != nil {
                log.Printf("task panicked: %v", r)
            }
        }()
        t()
    }(task)
}

In Go 1.22+ this bug is automatically fixed, but as an interview question, you need to explain the mechanism clearly.


This chapter covered the core mechanics of Go's function system. Multiple return values provide explicit error handling, closures provide state encapsulation, and defer provides deterministic resource cleanup. The synergy of these three forms the most common code patterns in Go programs. The next chapter enters the world of composite types — arrays, slices, and maps — whose underlying implementations determine 90% of a Go program's performance characteristics.

Rate this chapter
4.5  / 5  (90 ratings)

💬 Comments