Funktionen sind in Go First-Class-Werte — du kannst sie in Variablen speichern, an andere Funktionen weitergeben und an Ort und Stelle definieren. Sobald eine solche anonyme Funktion auch noch Variablen aus ihrem umgebenden Scope mitnimmt, wird aus dem Function Literal eine Closure: ein kleines Bündel aus Code und eingefangenen Variablen, das den ursprünglichen Block überleben kann. Dieser Artikel zeigt den Syntax der Function Literals, was lexikalisches Capture in Go bedeutet, warum die Variablen per Referenz eingefangen werden und welche Konsequenzen das hat — bis hin zur berüchtigten Loop-Variable-Falle, die Go bis Version 1.21 jahrelang geplagt hat und die in Go 1.22 auf Sprachebene entschärft wurde. Am Ende stehen die typischen Praxis-Patterns, der Heap-Allokations-Aspekt und eine Sammlung der Stolperfallen, die in echten Codebases regelmäßig zuschlagen.
Function Literal — anonyme Funktion auf Sprachebene
Die Go-Spec definiert Function Literals als eigene Ausdrucks-Form. Die Grammatik ist die einer regulären Funktion, nur ohne Namen:
FunctionLit = "func" Signature FunctionBody .
Signature = Parameters [ Result ] .
FunctionBody = Block .Ein Function Literal ist ein Wert vom Funktionstyp. Du kannst ihn einer Variable zuweisen, an eine andere Funktion übergeben, aus einer Funktion zurückgeben — oder sofort aufrufen, indem du an die Definition gleich () anhängst:
package main
import "fmt"
func main() {
// (1) Einer Variable zuweisen
square := func(x int) int {
return x * x
}
fmt.Println(square(5))
// (2) Sofort aufrufen — IIFE-Pattern
result := func(a, b int) int {
return a + b
}(3, 4)
fmt.Println(result)
// (3) Als Argument übergeben
apply := func(f func(int) int, v int) int {
return f(v)
}
fmt.Println(apply(square, 9))
// (4) Aus einer Funktion zurückgeben
adder := makeAdder(10)
fmt.Println(adder(5))
}
func makeAdder(base int) func(int) int {
return func(x int) int { return base + x }
}25
7
81
15Drei Beobachtungen, die das Fundament für den Rest des Artikels legen:
- Anonym heißt namenlos. Im Definitions-Ausdruck
func(x int) int { ... }steht zwischenfuncund(nichts. Werfunc square(x int) int { ... }schreibt, deklariert eine reguläre Funktion auf Top-Level — das ist kein Function Literal. - Klammern am Ende = Aufruf.
func(){...}ist nur der Wert,func(){...}()ruft ihn sofort auf. Wer den Aufruf vergisst, kriegt eine ungenutzte Wert-Konstruktion, die der Compiler als „evaluated but not used" oder als ungenutzte Closure markiert. - Function Literals haben einen Funktionstyp. Eine Variable, die ein Literal aufnimmt, hat den Typ
func(args) ret. Funktionswerte lassen sich nur mitnilvergleichen —==zwischen zwei Funktionswerten ist Compile-Fehler.
Was eine Closure ausmacht — lexikalisches Capture
Sobald ein Function Literal Variablen referenziert, die außerhalb seines eigenen Bodies deklariert sind, wird es zu einer Closure. Die Variable bleibt am Leben, solange die Closure auf sie zugreifen kann — auch wenn der ursprüngliche Block längst verlassen wurde:
package main
import "fmt"
// makeCounter gibt eine Closure zurück, die count einfängt.
// count lebt im Heap weiter — die Closure hält die Variable am Leben.
func makeCounter() func() int {
count := 0
return func() int {
count++
return count
}
}
func main() {
c := makeCounter()
fmt.Println(c()) // 1
fmt.Println(c()) // 2
fmt.Println(c()) // 3
fmt.Println(c()) // 4
}1
2
3
4Das ist lexikalisches Capture: Das Function Literal schaut bei der Definition nach, welche Identifier es im umgebenden Scope auflösen kann — count ist die lokale Variable von makeCounter. Die Closure speichert eine Referenz auf genau diese Variable. Dass makeCounter längst zurückgekehrt ist, wenn c() läuft, spielt keine Rolle: Der Go-Compiler erkennt per Escape-Analyse, dass count den Funktions-Frame verlässt, und legt sie deshalb nicht auf dem Stack, sondern auf dem Heap an.
Eine Subtilität: Jeder Aufruf von makeCounter erzeugt eine eigene Closure mit eigenem count. Zwei unabhängige Zähler beeinflussen sich nicht:
package main
import "fmt"
func makeCounter() func() int {
count := 0
return func() int {
count++
return count
}
}
func main() {
a := makeCounter()
b := makeCounter()
fmt.Println(a(), a(), a()) // 1 2 3
fmt.Println(b(), b()) // 1 2 — eigenes count
fmt.Println(a()) // 4 — a läuft unabhängig weiter
}1 2 3
1 2
4Jeder makeCounter()-Aufruf legt einen frischen count an. Die zurückgegebene Closure hält diese Instanz fest — nicht „die count-Variable" allgemein.
Capture by Reference, nicht by Value
Das wichtigste Detail, das viele zunächst überrascht: Go-Closures fangen die eingefangenen Variablen per Referenz ein. Wenn zwei Closures dieselbe Variable einfangen, sehen sie immer denselben Wert — Änderungen über die eine Closure sind über die andere sichtbar:
package main
import "fmt"
func main() {
x := 10
// Zwei Closures, die beide auf dasselbe x zugreifen
read := func() int { return x }
write := func(v int) { x = v }
fmt.Println("vor write:", read()) // 10
write(42)
fmt.Println("nach write:", read()) // 42
// Auch der direkte Zugriff in main sieht den modifizierten Wert.
fmt.Println("direkt:", x) // 42
}vor write: 10
nach write: 42
direkt: 42Das ist genau das Verhalten, das man von einer „echten" Closure erwartet — und gleichzeitig die Quelle der bekanntesten Closure-Falle: Wenn dieselbe Variable in einer Schleife wiederverwendet wird und mehrere Closures sie einfangen, sehen alle den letzten Wert.
Wer eine Kopie einfangen will, muss sie explizit anlegen — entweder über einen Parameter (siehe Workaround-Abschnitt) oder über eine neue Variable im inneren Scope:
package main
import "fmt"
func main() {
x := 10
// Closure fängt eine Kopie von x ein — über eine eigene Variable
snapshot := x
frozen := func() int { return snapshot }
x = 99
fmt.Println(frozen()) // 10 — die Kopie bleibt unverändert
}10Closures überleben den Erstellungs-Scope
Eine Closure hält die eingefangenen Variablen so lange am Leben, wie sie selbst erreichbar ist. Das ist nicht magisch, sondern eine direkte Konsequenz der Escape-Analyse: Sobald eine lokale Variable von einer Closure referenziert wird, die den Funktions-Frame verlässt, wird sie auf den Heap verschoben und vom Garbage Collector verwaltet:
package main
import "fmt"
// Eine kleine Bank: einzahlen, abheben, Stand abfragen.
// balance lebt so lange, wie irgendeine der drei Closures noch existiert.
func openAccount(start int) (deposit func(int), withdraw func(int) bool, balance func() int) {
funds := start
deposit = func(amount int) {
funds += amount
}
withdraw = func(amount int) bool {
if amount > funds {
return false
}
funds -= amount
return true
}
balance = func() int { return funds }
return
}
func main() {
dep, wd, bal := openAccount(100)
fmt.Println("Start:", bal())
dep(50)
fmt.Println("Nach Einzahlung:", bal()) // 150
ok := wd(30)
fmt.Println("Abhebung 30 OK?", ok, "Stand:", bal()) // 120
ok = wd(500)
fmt.Println("Abhebung 500 OK?", ok, "Stand:", bal()) // false, 120
}Start: 100
Nach Einzahlung: 150
Abhebung 30 OK? true Stand: 120
Abhebung 500 OK? false Stand: 120Alle drei Closures teilen sich dieselbe funds-Variable — sie ist quasi der private State eines Mini-Objekts. Das ist eines der ältesten Argumente für Closures: Sie liefern Datenkapselung ohne Struct, ohne Methoden-Receiver, ohne explizite Klassen. Für kleine, scharf umrissene Zustands-Träger ist das ein eleganter Stil. Für größere Aggregate ist ein struct mit Methoden meist klarer.
Die Loop-Variable-Falle — der Klassiker bis Go 1.21
Das berüchtigtste Closure-Verhalten in Go bis einschließlich Go 1.21: Eine for-Schleife verwendet eine einzige Variable für alle Iterationen. Wenn eine Closure diese Variable einfängt und erst später (etwa in einer Goroutine oder einem Deferred Call) ausgeführt wird, sieht sie den letzten Wert — nicht den, den die Iteration „eigentlich" meinte:
// go.mod: go 1.21 (oder älter)
package main
import (
"fmt"
"sync"
)
func main() {
values := []string{"a", "b", "c"}
var wg sync.WaitGroup
for _, v := range values {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(v)
}()
}
wg.Wait()
// Ausgabe (Reihenfolge variabel): drei Mal "c"
// Alle drei Goroutines fangen DIESELBE v-Variable ein,
// und wenn sie laufen, hält v längst den letzten Wert.
}c
c
cMechanisch passiert Folgendes: Die Range-Klausel deklariert v einmal. Jede Iteration weist v einen neuen Wert zu, aber es ist immer dasselbe Speicherplatz. Die drei Closures fangen einen Pointer auf eine Variable ein. Während die Closures noch auf ihre Goroutines warten, läuft die Schleife durch — am Ende steht "c" in v. Sobald die Goroutines starten, lesen sie genau diesen Wert.
Diese Klasse von Bugs war jahrelang ein Dauerbrenner in Code-Reviews und Postmortems — bei Let's Encrypt war sie 2020 die Ursache eines weltweiten Zertifikats-Vorfalls. Linter haben mitgewarnt, aber nicht zuverlässig.
Go 1.22 — Loop-Variable pro Iteration
Mit Go 1.22 hat sich das Sprach-Semantik-Modell geändert: Die Loop-Variable wird in jedem for und for range pro Iteration neu angelegt. Derselbe Code aus dem vorigen Abschnitt produziert jetzt das „erwartete" Ergebnis — ohne dass du eine Zeile veränderst:
// go.mod: go 1.22 oder neuer
package main
import (
"fmt"
"sort"
"sync"
)
func main() {
values := []string{"a", "b", "c"}
var wg sync.WaitGroup
var mu sync.Mutex
var seen []string
for _, v := range values {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
seen = append(seen, v)
mu.Unlock()
}()
}
wg.Wait()
sort.Strings(seen)
fmt.Println(seen) // [a b c]
}[a b c]Wichtig zu verstehen: Diese Änderung ist an die in go.mod deklarierte Go-Version gebunden. Ein Modul mit go 1.21 bekommt weiterhin die alte Semantik — auch wenn es mit Go 1.22 oder neuer kompiliert wird. Erst wer in go.mod go 1.22 (oder höher) angibt, profitiert vom neuen Verhalten. Das macht die Migration sicher: Altcode bricht nicht, Neucode ist von Anfang an korrekt.
Die Änderung gilt für beide Schleifenformen:
// Beide Formen geben in Go 1.22+ pro Iteration eigene Variablen.
for i := 0; i < 3; i++ {
// Jede Iteration sieht ihr eigenes i.
// Eine hier erzeugte Closure fängt das i dieser Iteration ein.
}
for k, v := range someMap {
// k und v sind pro Iteration neu — Closures fangen die jeweilige Kopie.
_ = k
_ = v
}Wer Bibliotheken pflegt, die noch mit älteren Go-Versionen kompatibel sein müssen, sollte die Workarounds aus dem nächsten Abschnitt weiter einsetzen.
Workarounds für Pre-1.22-Code
Vor Go 1.22 gab es zwei kanonische Wege, das Loop-Variable-Problem zu lösen. Beide funktionieren weiterhin — und sind in Codebases, die ältere Go-Versionen unterstützen, weiterhin korrekt:
package main
import (
"fmt"
"sort"
"sync"
)
func main() {
values := []string{"a", "b", "c"}
var wg sync.WaitGroup
var mu sync.Mutex
var seen []string
// Variante A: Shadow-Trick — neue Variable im Schleifenbody
for _, v := range values {
v := v // schattet die äußere Variable — frische Kopie pro Iteration
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
seen = append(seen, "A:"+v)
mu.Unlock()
}()
}
// Variante B: Wert als Parameter der Goroutine übergeben
for _, v := range values {
wg.Add(1)
go func(v string) {
defer wg.Done()
mu.Lock()
seen = append(seen, "B:"+v)
mu.Unlock()
}(v) // <-- v wird hier kopiert, bevor die Goroutine startet
}
wg.Wait()
sort.Strings(seen)
fmt.Println(seen)
}[A:a A:b A:c B:a B:b B:c]Variante A (v := v) ist die historisch verbreitetste Form — sie macht im Body eine neue Variable mit dem Namen v auf, die die äußere schattet. Da die innere Variable in jedem Durchlauf neu deklariert wird, hat jede Closure ihre eigene Kopie. Auf Go 1.22+ ist dieses v := v überflüssig, aber nicht schädlich; viele Codebases lassen es bewusst drin, solange ältere Go-Versionen unterstützt werden.
Variante B ist sauberer für Goroutine-Aufrufe: Die Closure nimmt den Wert als Parameter, und der wird im Moment des Calls evaluiert — also bevor die Goroutine startet. Damit ist klar dokumentiert, dass die Goroutine den Wert zur Aufruf-Zeit einfangen soll.
Praxis-Patterns mit Closures
Closures tauchen in echtem Go-Code an vielen Stellen auf. Vier besonders häufige Muster:
(1) HTTP-Handler-Factory. Eine Funktion liefert einen Handler zurück, der einen Logger, eine Datenbank-Verbindung oder Konfiguration eingefangen hat:
package main
import (
"fmt"
"log"
"net/http"
)
func makeUserHandler(logger *log.Logger, db *Database) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
logger.Println("user request:", r.URL.Path)
user, err := db.LoadUser(r.URL.Query().Get("id"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "user: %s", user.Name)
}
}
type Database struct{}
type User struct{ Name string }
func (d *Database) LoadUser(id string) (*User, error) {
return &User{Name: "alice"}, nil
}(2) Middleware-Dekoration. Eine Middleware nimmt einen Handler und gibt einen neuen Handler zurück, der zusätzliches Verhalten implementiert — die eingefangene Variable ist der Original-Handler:
package main
import (
"log"
"net/http"
"time"
)
func withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r) // next ist eingefangen
log.Printf("%s %s -> %s", r.Method, r.URL.Path, time.Since(start))
})
}(3) Deferred Cleanup mit Werten zur Aufruf-Zeit. defer und Closure mischen besonders gut: Eine deferred Closure liest die Werte ihrer eingefangenen Variablen erst beim Ablaufen, nicht beim Setzen des defer:
package main
import "fmt"
func process(items []string) (count int) {
// Diese Closure sieht den finalen Wert von count.
defer func() {
fmt.Println("verarbeitet:", count, "items")
}()
for _, item := range items {
_ = item
count++
}
return
}
func main() {
process([]string{"a", "b", "c"})
}verarbeitet: 3 items(4) Higher-Order-Funktionen. Filter, Map, Reduce mit Closures als Prädikat oder Transformation — in Go weniger verbreitet als in funktionalen Sprachen, aber bei Test-Helpern, Slice-Utilities und in sort.Slice ständig anzutreffen:
package main
import "fmt"
func filter(xs []int, pred func(int) bool) []int {
var out []int
for _, x := range xs {
if pred(x) {
out = append(out, x)
}
}
return out
}
func main() {
nums := []int{1, 2, 3, 4, 5, 6}
threshold := 3
// Die Closure fängt threshold ein — kein extra State nötig.
big := filter(nums, func(n int) bool { return n > threshold })
fmt.Println(big)
}[4 5 6]Closures und Goroutines — Vorsicht bei geteiltem State
Sobald eine Closure in einer Goroutine läuft und auf eine eingefangene Variable schreibt, die auch eine andere Goroutine (oder die Main-Goroutine) liest oder schreibt, hast du eine Race Condition. Das Closure-Modell ändert nichts an Gos Speicher-Modell: Geteilte Variablen brauchen Synchronisation, auch wenn der Zugriff über eine Closure läuft:
package main
import (
"fmt"
"sync"
)
func main() {
count := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
count++ // RACE: alle Goroutines schreiben unsynchronisiert
}()
}
wg.Wait()
fmt.Println(count) // selten 1000, oft weniger
}Den Bug findest du sicher mit go run -race main.go oder go test -race. Lösungen sind die üblichen: sync.Mutex um die Schreib-Zugriffe, sync/atomic für einfache Counter, oder Channel-basierte Aggregation. Details dazu im Goroutines-Kapitel — hier nur die Warnung, dass Closures kein Schutz vor Races sind.
Performance — Closures allokieren auf dem Heap
Wenn die Escape-Analyse erkennt, dass eine eingefangene Variable den Funktions-Frame verlässt — etwa weil die Closure zurückgegeben oder einer Goroutine übergeben wird — wandert sie auf den Heap. Das ist nichts Dramatisches; der Garbage Collector kümmert sich. Aber es ist eine Allokation, die in heißen Schleifen auffällig werden kann.
Wer es genau wissen will, lässt sich die Escape-Entscheidungen vom Compiler zeigen:
go build -gcflags="-m" ./...Typische Ausgabe für eine Closure, die einen Wert einfängt und zurückgegeben wird:
./counter.go:7:9: func literal escapes to heap
./counter.go:6:5: moved to heap: countPraktisch heißt das: Function Literals, die nicht entkommen — etwa direkt aufgerufene IIFEs oder Argumente an sort.Slice —, bleiben oft auf dem Stack und sind allokations-frei. Closures, die einer langlebigen Struktur (Goroutine, Slice, Map) hinzugefügt werden, sind das nicht. Für die meisten Anwendungen ist der Unterschied irrelevant; bei Hot Paths in Hochlast-Diensten lohnt der Blick mit -gcflags="-m" oder dem pprof-Profiler.
Self-Referenz — wie sich eine Closure selbst aufruft
Ein subtiles Problem: Eine anonyme Funktion hat keinen Namen — sie kann sich nicht direkt selbst rekursiv aufrufen. Wer Rekursion in einer Closure braucht (etwa für eine inline-Tree-Traversal), muss vorab eine Variable deklarieren und die Closure dann an sie binden:
package main
import "fmt"
func main() {
// FEHLER: man kann sich selbst nicht beim Namen rufen.
// factorial := func(n int) int {
// if n <= 1 { return 1 }
// return n * factorial(n - 1) // undefined: factorial
// }
// Korrekt: var-Deklaration, dann Zuweisung
var factorial func(int) int
factorial = func(n int) int {
if n <= 1 {
return 1
}
return n * factorial(n-1)
}
fmt.Println(factorial(5)) // 120
}120Warum die var-Deklaration nötig ist: Beim := ist factorial am Ende des Statements deklariert — der Body des Function Literals wird vorher analysiert, und der Name ist noch nicht im Scope. Die var-Form trennt Deklaration und Zuweisung: Beim Anlegen ist factorial zwar nil, aber als Name existiert er bereits. Im Body wird der Name erst zur Aufruf-Zeit aufgelöst, und dann zeigt die Variable auf die fertige Closure.
Häufige Stolperfallen
Loop-Variable pre-1.22 ist shared — der Bug Nummer eins.
In Go-Modulen mit go 1.21 oder älter teilen alle Iterationen einer for-Schleife dieselbe Variable. Closures, die diese Variable einfangen und später (Goroutine, Defer) laufen, sehen den letzten Wert — nicht den der eigenen Iteration. Lösung: v := v im Body, Wert als Parameter der Goroutine übergeben, oder auf go 1.22+ upgraden.
Closure-Capture ist by reference, nicht by value.
Auch in Go 1.22+ gilt: Wenn zwei Closures dieselbe Variable einfangen, sehen sie immer denselben Wert. Änderungen via die eine Closure sind in der anderen sofort sichtbar. Wer eine Kopie will, muss sie explizit anlegen — etwa über einen Parameter oder eine neue Variable im inneren Scope.
Closures in Goroutines ohne Synchronisation = Race.
Eine Closure schützt nicht vor Race Conditions. Wenn mehrere Goroutines auf eine eingefangene Variable schreiben oder lesen+schreiben, brauchst du sync.Mutex, sync/atomic oder Channel-Kommunikation. go run -race deckt das auf.
defer-Closures lesen Werte zur Ausführungszeit.
Eine via defer registrierte Closure greift erst beim Ablaufen auf die eingefangenen Variablen zu — sie sieht dann den finalen Wert, nicht den zur Zeit des defer-Statements. Anders als bei defer foo(x), wo x zur Defer-Zeit ausgewertet wird, wird in defer func() { use(x) }() der Zugriff auf x aufgeschoben.
Methoden mit Value-Receiver in Closures kopieren den Receiver.
Wenn du eine Methode mit Value-Receiver als Funktionswert speicherst (f := obj.Method), wird obj zur Bindung kopiert — spätere Änderungen an obj sieht die Closure nicht. Bei Pointer-Receiver bleibt die Verbindung lebendig. Für Closures auf Objekten meist Pointer-Receiver verwenden.
Anonyme Funktionen können sich nicht beim Namen rufen.
Direkte Selbst-Rekursion in einer anonymen Funktion geht nicht — der Name ist beim Auflösen des Bodies noch unbekannt. Lösung: var f func(...) ...; f = func(...) ... { ... f(...) ... }. Die var-Form trennt Deklaration und Zuweisung, sodass der Name im Body referenzierbar ist.
Closures allokieren auf dem Heap, wenn sie entkommen.
Function Literals, die zurückgegeben, gespeichert oder einer Goroutine übergeben werden, escapen — der Compiler legt die eingefangenen Variablen auf dem Heap an. Inline aufgerufene Function Literals und kurzlebige Callbacks an Stdlib-Funktionen (sort.Slice etc.) bleiben oft auf dem Stack. Mit go build -gcflags="-m" sichtbar.
Aufruf einer nil-Funktion = Panic.
Eine deklarierte, aber nicht zugewiesene func-Variable hat den Zero Value nil. Der Aufruf einer nil-Funktion läuft direkt in runtime error: invalid memory address or nil pointer dereference. Vor dem Aufruf prüfen oder garantieren, dass die Variable belegt ist.
Funktions-Werte sind nur mit nil vergleichbar.
f == g für zwei Funktionswerte ist Compile-Fehler — die Spec erlaubt nur f == nil und f != nil. Wer Funktionen als Map-Keys oder in Set-Strukturen verwenden will, hat Pech: Funktionen sind nicht hashbar. Workaround: Pointer auf die Funktion (oder einen Wrapper-Struct) als Key.
Weiterführende Ressourcen
Externe Quellen
- Function literals – Go Language Specification
- Function types – Go Specification
- Declarations and scope – Go Specification
- Fixing For Loops in Go 1.22 (Go Blog)
- Effective Go: Function literals
- Go FAQ: What's the deal with closures and goroutines?
- Go 1.22 Release Notes – Language changes
Verwandte Artikel
- Funktionen – Übersicht über alle Funktions-Mechaniken
- Multiple Returns – Mehrfach-Rückgaben und Named Returns
- Variadic Functions – Funktionen mit variabler Argumentzahl
- First-Class-Funktionen – Funktionen als Werte
- Rekursion – Selbst-Aufruf, Tail-Calls, Iteration als Alternative
- Scoping-Regeln – die fünf Ebenen und Shadowing
- for-Schleife – Range, Init, Post und die Loop-Variable
- Goroutines – Concurrency-Grundlagen und Race-Detection