Closures in Go: Functions That Remember
Chapter 03 of 03

Closures in Go: Functions That Remember

How closures capture variables, why those variables move to the heap, the := shadowing trap, and the Go 1.22 loop-variable change.

Sabbir Ahmmed3 min read0
On this page

A closure is a function that remembers the variables from the place where it was created, and keeps using them even after that outer function has returned. Closures are how Go builds counters, generators, middleware and many configuration patterns without classes.

Nested functions can see outer variables

Start simple: a function value declared inside another function can read the outer variables.

go
1
2
3
4
5
6
7
func greet() {
	name := "Sabbir"
	display := func() {
		fmt.Printf("Hi, %s\n", name) // reads name from the outer function
	}
	display()
}

Returning a function that remembers

The interesting part begins when the inner function is returned and called later.

counter.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import "fmt"

func counter() func() int {
	count := 0
	return func() int {
		count++ // the same count variable, every call
		return count
	}
}

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

	d := counter()
	fmt.Println(d(), d()) // 1 2  (a brand-new count)
}

counter() has already returned, yet count is still alive and keeps its value between calls. Each call to counter() creates a new count, so c and d do not share state.

Where does the variable live? (heap vs stack)

Normally a local variable lives on the function's stack frame and disappears when the function returns. When the Go compiler sees that a returned closure still references count, its escape analysis moves count to the heap instead. The closure keeps a reference to it, and the garbage collector frees it once nothing references the closure anymore.

You can see the compiler's decision yourself:

bash
1
2
go build -gcflags=-m .
# ./main.go:6:2: moved to heap: count

That is the "memorised on the heap" behaviour from my original notes: the closure closes over the variable, which is where the name comes from.

Shadowing: a subtle trap

Here is the example from the repository:

go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const a = 10
var p = 100

func outer() func() {
	money := 100
	show := func() {
		money := money + a + p // := creates a NEW local money
		fmt.Println("Money =", money)
	}
	return show
}

f := outer()
f() // Money = 210
f() // Money = 210

It prints 210 every time, not 210, 320, 430. Because of :=, the inner money is a new variable that shadows the outer one, so the outer money never changes. Use = if you want to update the captured variable:

go
1
2
3
4
5
show := func() {
	money = money + a + p // updates the captured variable
	fmt.Println("Money =", money)
}
// 210, 320, 430, ...
Warning
Accidental shadowing with := is one of the most common Go bugs, especially with err inside if blocks. go vet with the shadow analyzer can catch some cases.

Closures in loops (and the Go 1.22 change)

Before Go 1.22, a for loop had one loop variable reused across iterations, so every closure saw its final value:

go
1
2
3
4
5
6
7
8
9
var funcs []func()
for i := 0; i < 3; i++ {
	funcs = append(funcs, func() { fmt.Print(i, " ") })
}
for _, f := range funcs {
	f()
}
// Go 1.22+: 0 1 2
// Go 1.21 and older: 3 3 3

Since Go 1.22 each iteration gets its own i, so this prints 0 1 2. That fixed a whole class of goroutine bugs. If your go.mod declares an older Go version, you still get the old behaviour, which is why older code passes the value as an argument: go func(i int) { ... }(i).

Practical uses

Configuration and generators: return a function pre-loaded with settings.

go
1
2
3
4
5
6
7
func multiplier(factor int) func(int) int {
	return func(n int) int { return n * factor }
}

double := multiplier(2)
triple := multiplier(3)
fmt.Println(double(5), triple(5)) // 10 15

Middleware: wrap behaviour around another function. The web chapter builds HTTP middleware exactly like this.

go
1
2
3
4
5
6
7
func logged(name string, fn func()) func() {
	return func() {
		start := time.Now()
		fn()
		fmt.Printf("%s took %v\n", name, time.Since(start))
	}
}
Important
A closure shares its captured variables. If several goroutines call the same closure, they share that state too, and you need a mutex or a channel to keep it safe (see the concurrency chapter).

Key takeaways

A closure is a function plus the variables it captured from its surrounding scope.
Captured variables outlive the outer function because the compiler moves them to the heap.
Each call to the outer function creates fresh captured variables.
Watch out for := shadowing inside closures.
Since Go 1.22, every loop iteration has its own loop variable.
#Golang#Closures#Functions

Last updated on

Discussion0