Go Closures — Middle Level¶
1. Introduction¶
At the middle level, closures become a tool for designing stateful APIs without classes, encoding policy as data, and building composable callbacks. You design closure-based APIs deliberately, recognize when a struct + methods is clearer, and handle the subtleties of capture in concurrent code.
2. Prerequisites¶
- Junior-level closure material
- Anonymous functions (2.6.4)
- Goroutines, channels, sync primitives
- Understanding of escape analysis (basic)
3. Glossary¶
| Term | Definition |
|---|---|
| Closure | Function value with captured environment |
| Capture environment | The set of free variables a closure captures |
| Stateful closure | A closure whose behavior depends on captured mutable state |
| Pure closure | A closure that doesn't capture or only captures immutable values |
| Closure factory | A function that returns a new closure |
| Snapshot capture | Capturing a value, not the live variable (via shadow) |
| Escape | The closure outliving its creating function |
4. Core Concepts¶
4.1 Closures as Lightweight Objects¶
A closure with captured state is functionally equivalent to a struct with one method:
// Closure version
func newCounter() func() int {
n := 0
return func() int { n++; return n }
}
// Struct version
type Counter struct{ n int }
func (c *Counter) Next() int { c.n++; return c.n }
When to use which: - Closure: 1-2 captured values, single method, no need for direct testing. - Struct: 3+ fields, multiple methods, need for serialization, public API.
4.2 Closures as Policy¶
You can encode behavior as a closure that callers pass in:
type Selector func(item Item) bool
func filterAll(items []Item, sel Selector) []Item {
out := items[:0]
for _, it := range items {
if sel(it) {
out = append(out, it)
}
}
return out
}
threshold := 100
filtered := filterAll(items, func(it Item) bool {
return it.Score > threshold // captures threshold
})
The closure encodes the dynamic policy (threshold).
4.3 The Snapshot-vs-Live Choice¶
// Live capture (default):
x := 1
f := func() int { return x }
x = 99
fmt.Println(f()) // 99
// Snapshot capture (via shadow):
x := 1
f := func() int {
x := x // snapshot when closure was created
return x
}
x = 99
fmt.Println(f()) // 1
Snapshot is also achieved by passing as an argument:
4.4 Sharing Captures Across Multiple Closures¶
n := 0
incr := func() { n++ }
get := func() int { return n }
reset := func() { n = 0 }
incr(); incr(); incr()
fmt.Println(get()) // 3
reset()
fmt.Println(get()) // 0
All three closures share n. This is a "closure object" pattern — multiple methods on shared state.
4.5 Loop Variable Capture (Go 1.22+)¶
Modern Go (1.22+) creates a fresh iteration variable per iteration:
fns := []func() int{}
for i := 0; i < 3; i++ {
fns = append(fns, func() int { return i })
}
// Go 1.22+: fns each return 0, 1, 2.
For pre-1.22 modules, shadow with i := i or pass as argument.
4.6 Closures and Generics¶
Generic functions can return closures:
func Adder[T int | float64](by T) func(T) T {
return func(x T) T { return x + by }
}
addInt := Adder(3)
addFloat := Adder(1.5)
fmt.Println(addInt(10), addFloat(2.5)) // 13 4.0
The type parameter is fixed at instantiation; the closure captures by of that type.
5. Real-World Analogies¶
A serial number printer: a closure that increments a number each call. Multiple printers each have their own counter.
A subscription: capture context (URL, auth, config) in a closure that you can call later. The closure carries all the setup.
A safe-deposit box with multiple keys: multiple closures sharing state — like multiple keys to one box.
6. Mental Models¶
Model 1 — Closure as Object¶
A closure with captures is a tiny "object":
Model 2 — Capture Environment¶
Think of the captured variables as the closure's "environment":
The compiler synthesizes this struct; the runtime accesses it via the closure context register.
7. Pros & Cons¶
Pros¶
- Encapsulate state without ceremony
- Natural factories, generators, decorators
- Composable callbacks
- Less boilerplate than struct + methods for simple cases
Cons¶
- Captures pin memory
- Concurrent capture mutation needs synchronization
- Stack traces show generic names
- Pre-1.22 loop-variable bugs
- Heavy captures may surprise
8. Use Cases¶
- Counters / generators
- Memoization
- Throttle / rate limit
- Decorators (logging, retry, metrics)
- Iterators using visitor pattern
- Functional options
- State machines as closures
- Event subscriptions with context
9. Code Examples¶
Example 1 — Multi-Closure State Machine¶
package main
import "fmt"
type State int
const (
Idle State = iota
Running
Stopped
)
func newMachine() (transition func(State), get func() State) {
state := Idle
transition = func(to State) { state = to }
get = func() State { return state }
return
}
func main() {
transition, get := newMachine()
transition(Running)
fmt.Println(get()) // 1
transition(Stopped)
fmt.Println(get()) // 2
}
Example 2 — Throttle With Channel Notify¶
package main
import (
"fmt"
"sync"
"time"
)
func throttler(d time.Duration) (allow func() bool, blocked chan struct{}) {
var mu sync.Mutex
var last time.Time
blocked = make(chan struct{}, 1)
allow = func() bool {
mu.Lock()
defer mu.Unlock()
now := time.Now()
if now.Sub(last) < d {
select { case blocked <- struct{}{}: default: }
return false
}
last = now
return true
}
return
}
func main() {
allow, blocked := throttler(50 * time.Millisecond)
for i := 0; i < 5; i++ {
if allow() { fmt.Println(i, "allowed") } else { fmt.Println(i, "blocked") }
time.Sleep(20 * time.Millisecond)
}
select {
case <-blocked:
fmt.Println("at least one blocked")
default:
}
}
Example 3 — LRU-Style Cache (simple)¶
package main
import "fmt"
func cached(fn func(int) int) (call func(int) int, stats func() (int, int)) {
cache := map[int]int{}
hits, misses := 0, 0
call = func(k int) int {
if v, ok := cache[k]; ok {
hits++
return v
}
misses++
v := fn(k)
cache[k] = v
return v
}
stats = func() (int, int) { return hits, misses }
return
}
func main() {
f, stats := cached(func(x int) int { return x * 2 })
f(1); f(2); f(1); f(3); f(2)
h, m := stats()
fmt.Printf("hits=%d misses=%d\n", h, m) // hits=2 misses=3
}
Example 4 — Decorator (Retry)¶
package main
import (
"errors"
"fmt"
"time"
)
func withRetry(n int, sleep time.Duration, fn func() error) func() error {
return func() error {
var err error
for i := 0; i < n; i++ {
err = fn()
if err == nil { return nil }
time.Sleep(sleep)
}
return fmt.Errorf("retried %d: %w", n, err)
}
}
var calls int
func flakey() error { calls++; if calls < 3 { return errors.New("flake") }; return nil }
func main() {
r := withRetry(5, 10*time.Millisecond, flakey)
fmt.Println(r(), calls)
}
Example 5 — Closures + Goroutines + Synchronization¶
package main
import (
"fmt"
"sync"
)
func newSafeCounter() (incr func(), get func() int) {
var mu sync.Mutex
n := 0
incr = func() {
mu.Lock(); defer mu.Unlock()
n++
}
get = func() int {
mu.Lock(); defer mu.Unlock()
return n
}
return
}
func main() {
incr, get := newSafeCounter()
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
incr()
}()
}
wg.Wait()
fmt.Println(get()) // 1000
}
10. Coding Patterns¶
Pattern 1 — Closure as State Machine¶
Multiple closures sharing captured state; each closure exposes one operation.
Pattern 2 — Decorator¶
Pattern 3 — Lazy Computation¶
func lazy[T any](compute func() T) func() T {
var v T
var done bool
return func() T {
if !done { v = compute(); done = true }
return v
}
}
Pattern 4 — Functional Options¶
Pattern 5 — Observer Pattern via Captured Channel¶
func newSubject() (notify func(int), subscribe func() <-chan int) {
var mu sync.Mutex
var subs []chan int
notify = func(v int) {
mu.Lock(); defer mu.Unlock()
for _, ch := range subs {
select { case ch <- v: default: }
}
}
subscribe = func() <-chan int {
ch := make(chan int, 1)
mu.Lock(); subs = append(subs, ch); mu.Unlock()
return ch
}
return
}
11. Clean Code Guidelines¶
- Capture only what you need — extract narrow values.
- Document the closure's lifetime if it escapes.
- Synchronize captured mutable state if accessed concurrently.
- Use struct + methods when state grows or you need testing.
- Avoid deep closure nesting — flatten or use named functions.
12. Product Use / Feature Example¶
A configurable rate-limited HTTP client:
package main
import (
"fmt"
"net/http"
"sync"
"time"
)
func rateLimitedClient(rps int) func(req *http.Request) (*http.Response, error) {
var mu sync.Mutex
interval := time.Second / time.Duration(rps)
var next time.Time
client := http.DefaultClient
return func(req *http.Request) (*http.Response, error) {
mu.Lock()
wait := time.Until(next)
if wait > 0 {
time.Sleep(wait)
}
next = time.Now().Add(interval)
mu.Unlock()
return client.Do(req)
}
}
func main() {
do := rateLimitedClient(2) // 2 req/sec
_ = do
fmt.Println("client ready")
}
The closure encapsulates the limiter state (next, interval, mu).
13. Error Handling¶
When a closure may fail, return an (error) from it; callers handle as usual:
func validator(min, max int) func(int) error {
return func(x int) error {
if x < min || x > max {
return fmt.Errorf("out of range [%d, %d]: %d", min, max, x)
}
return nil
}
}
14. Security Considerations¶
- Captured secrets pin sensitive data — wipe after use, ensure closure exits.
- Don't pass closures with sensitive captures to untrusted callers.
- Long-lived goroutines pin captures — design for cancellation.
- Avoid capturing privileged state in closures used by less-privileged code.
15. Performance Tips¶
- Non-escaping closures stack-allocate (free).
- Escaping closures heap-allocate (1 alloc per closure value).
- Indirect calls through closures can't inline — use direct calls in hot loops.
- Heavy captures add to closure size; minimize.
- Verify with
-gcflags="-m".
16. Metrics & Analytics¶
func instrumented(name string, fn func() error) func() error {
return func() error {
start := time.Now()
err := fn()
// metrics.Record(name, time.Since(start), err)
fmt.Printf("[%s] dur=%v err=%v\n", name, time.Since(start), err)
return err
}
}
17. Best Practices¶
- Use closures for state encapsulation when 1-2 fields suffice.
- Use struct + methods for richer state.
- Capture minimum data.
- Synchronize concurrent captures.
- Document closure lifetime.
- Use the shadow
x := xfor explicit snapshots. - Use generics for typed closure factories.
- Avoid closures in tight inner loops where possible.
18. Edge Cases & Pitfalls¶
Pitfall 1 — Pre-1.22 Loop Variable¶
Discussed extensively; pass as arg or shadow.
Pitfall 2 — Concurrent Capture Mutation¶
Fix: synchronize inside.Pitfall 3 — Closure Holding Resources¶
Pitfall 4 — Capturing Receiver Pointer in a Method¶
The returned closure pinss for its lifetime. Pitfall 5 — Reusing Snapshot in Multiple Iterations¶
for i := 0; i < 3; i++ {
snap := i
go func() { use(snap) }() // each closure captures its own snap (Go 1.22+ already does this for i)
}
19. Common Mistakes¶
| Mistake | Fix |
|---|---|
| Capturing loop var without shadow (pre-1.22) | Shadow or pass as arg |
| Sharing captured mutable state across goroutines | Synchronize |
| Heavy captures pinning memory | Extract minimum |
Recursion without var declaration | Declare first, then assign |
| Treating closure as struct alternative for everything | Use struct when state grows |
20. Common Misconceptions¶
Misconception 1: "Closures are like Java lambdas (immutable captures)." Truth: Go closures capture by reference; captures are mutable.
Misconception 2: "Each closure has its own copy of the variable." Truth: Closures share the SAME variable when defined in the same scope. Each closure INSTANCE (from a factory) has its own.
Misconception 3: "All captured variables go to the heap." Truth: Only when the closure escapes. Stack capture is preferred when possible.
Misconception 4: "Closures are slower than regular functions." Truth: Indirect-call cost is small (~3-5 cycles); allocation cost only when escaping.
Misconception 5: "I should use closures for everything stateful." Truth: Structs + methods scale better with state size and complexity.
21. Tricky Points¶
- Capture is by reference; use shadow for snapshots.
- Pre-1.22 loop variable shared; Go 1.22+ per-iteration.
- Concurrent captures need synchronization.
- Recursive closures need
vardeclaration. - Closure escape is invisible from the call site.
22. Test¶
package main
import (
"sync"
"testing"
)
func newCounter() func() int {
n := 0
return func() int { n++; return n }
}
func TestCounter(t *testing.T) {
c := newCounter()
for i := 1; i <= 5; i++ {
if got := c(); got != i {
t.Errorf("call %d: got %d, want %d", i, got, i)
}
}
}
func TestCounterIndependent(t *testing.T) {
c1 := newCounter()
c2 := newCounter()
c1(); c1()
if got := c2(); got != 1 {
t.Errorf("c2 got %d, want 1", got)
}
}
func TestCounterConcurrent(t *testing.T) {
// newCounter is NOT thread-safe; this test demonstrates the bug.
c := newCounter()
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() { defer wg.Done(); c() }()
}
wg.Wait()
// c() may not have reached 1000 due to race
}
23. Tricky Questions¶
Q1: What's the output?
counters := []func() int{}
for i := 0; i < 3; i++ {
counters = append(counters, newCounter())
}
fmt.Println(counters[0](), counters[1](), counters[2]())
1 1 1. Each newCounter() call creates a separate n. Each closure has its own counter. Q2: What's the output (Go 1.22+)?
fns := []func() int{}
for i := 0; i < 3; i++ {
fns = append(fns, func() int { return i })
}
fmt.Println(fns[0](), fns[1](), fns[2]())
0 1 2. Per-iteration capture in Go 1.22+. Pre-1.22 would be 3 3 3. Q3: Will this print 0 or 99?
99), then the eager-arg defer (prints 0). 24. Cheat Sheet¶
// Counter
n := 0
f := func() int { n++; return n }
// Factory
func make(by int) func(int) int {
return func(x int) int { return x + by }
}
// Snapshot
x := 1
f := func() int { x := x; return x } // captures 1
// Recursive
var fact func(int) int
fact = func(n int) int { if n <= 1 { return 1 }; return n * fact(n-1) }
// Concurrent-safe
var mu sync.Mutex
n := 0
incr := func() { mu.Lock(); defer mu.Unlock(); n++ }
// Shared state, multiple closures
n := 0
incr := func() { n++ }
get := func() int { return n }
25. Self-Assessment Checklist¶
- I can write a closure factory
- I can write a multi-closure state machine
- I know capture is by reference
- I know how to take snapshots (shadow)
- I synchronize concurrent captures
- I extract minimum captures
- I know the loop-variable change in Go 1.22
- I can write recursive closures
- I choose between closure and struct intentionally
26. Summary¶
Closures are functions that carry captured state by reference. They're lightweight objects, ideal for 1-2 fields and a single (or few) operations. Each closure instance has its own captures; closures defined in the same scope share. Watch for the loop-variable subtlety (mostly fixed in Go 1.22) and synchronize concurrent capture access. Use the shadow x := x for snapshots. Reach for structs + methods when state grows.
27. What You Can Build¶
- Counters, generators, ID providers
- Memoization wrappers
- Rate limiters, throttles
- Decorators (logging, retry, timing)
- State machines
- Pluggable policies
- Lazy initialization helpers
- Subscriber/observer abstractions
28. Further Reading¶
- Effective Go — Functions
- Go Tour — Closures
- Go 1.22 release notes — Loop variable change
- Dave Cheney — Closures and goroutines
29. Related Topics¶
- 2.6.4 Anonymous Functions
- 2.6.7 Call by Value
- 2.5 Loops (Go 1.22 semantics)
- Chapter 7 Concurrency
- 2.7 Pointers (capture as pointer-to-cell)
30. Diagrams & Visual Aids¶
Capture sharing across closures¶
Outer scope:
┌───────────┐
│ n = 0 │ ← captured by reference
└─┬─────────┘
│
┌─────┴─────┐
│ │
incr() get() ← share n