GGistDev

Closures in Go

Closures are functions that capture variables from their surrounding scope. They enable stateful functions, factories, and composition.

Core ideas

  • Capture: inner functions can access outer variables
  • Lifetime: captured variables live with the closure
  • State: closures provide private, persistent state without globals
  • By reference: captured variables are shared between closures and the outer scope

Basic capture

package main
import "fmt"

func Greeter(name string) func() {
    // name is captured
    return func() { fmt.Println("Hello,", name) }
}

func main() {
    g := Greeter("Alice")
    g() // Hello, Alice
}

Stateful closures

package main
import "fmt"

func Counter(start int) func() int {
    n := start
    return func() int {
        n++
        return n
    }
}

func main() {
    c := Counter(0)
    fmt.Println(c(), c(), c()) // 1 2 3
}

Loop variable capture (pitfall and fix)

Each iteration of for range reuses the same loop variable. Copy it (shadow) or pass as a parameter when capturing.

package main
import "fmt"

func main() {
    // BAD: all closures print the same final i
    var bad []func()
    for i := 0; i < 3; i++ {
        bad = append(bad, func() { fmt.Println("bad:", i) })
    }
    for _, f := range bad { f() }

    // GOOD: shadow i or pass as parameter
    var good []func()
    for i := 0; i < 3; i++ {
        i := i
        good = append(good, func() { fmt.Println("good:", i) })
    }
    for _, f := range good { f() }
}

Function factories

package main
import (
    "fmt"
    "strings"
)

func MakeValidator(min int, required bool) func(string) error {
    return func(s string) error {
        if required && strings.TrimSpace(s) == "" {
            return fmt.Errorf("required")
        }
        if len(s) < min { return fmt.Errorf("min %d", min) }
        return nil
    }
}

func main() {
    validate := MakeValidator(3, true)
    fmt.Println(validate("ok"))  // <nil>
    fmt.Println(validate("  "))  // required
}

Memoization (simple cache)

Closures can maintain caches across invocations for expensive computations.

package main
import "fmt"

func Memoize(f func(int) int) func(int) int {
    cache := map[int]int{}
    return func(n int) int {
        if v, ok := cache[n]; ok { return v }
        v := f(n)
        cache[n] = v
        return v
    }
}

func Fib(n int) int {
    if n < 2 { return n }
    return Fib(n-1) + Fib(n-2)
}

func main() {
    mfib := Memoize(Fib)
    fmt.Println(mfib(10))
}

Middleware-like composition

Wrap a handler with cross-cutting behavior (logging, timing, metrics) without changing core logic.

package main
import (
    "fmt"
    "time"
)

type Handler func(string) string

type Middleware func(Handler) Handler

func Log(next Handler) Handler {
    return func(s string) string {
        t := time.Now()
        out := next(s)
        fmt.Printf("took %v\n", time.Since(t))
        return out
    }
}

func Compose(ms ...Middleware) Middleware {
    return func(next Handler) Handler {
        for i := len(ms)-1; i >= 0; i-- { next = ms[i](next) }
        return next
    }
}

func Echo(s string) string { return "echo: " + s }

func main() {
    h := Compose(Log)(Echo)
    fmt.Println(h("hello"))
}

Best practices

  • Capture only what you need (avoid holding large structs)
  • Watch loop variable capture in for-loops and goroutines
  • Prefer small closures; use named functions for complex logic
  • Document stateful behavior and concurrency expectations
  • Avoid capturing pointers to short-lived data that may be mutated elsewhere

Summary

  • Closures enable local state, factories, and composition
  • Handle capture semantics carefully to avoid bugs and leaks
  • Use when per-function configuration/state is needed