Anders als die meisten C-stämmigen Sprachen kennt Go Mehrfach-Rückgaben auf Sprachebene — keine Tupel-Verpackung, kein Out-Parameter-Trick, keine Struct-Wrapper. Eine Funktion gibt einfach mehrere Werte zurück, und der Aufrufer packt sie aus. Genau dieser Mechanismus liegt hinter Gos berühmtem (wert, err)-Pattern: Statt Exceptions reicht jede Funktion mit Fehlerquelle den Wert und einen Error zurück. Aufbauend darauf gibt es Named Returns für die Selbst-Dokumentation der Funktions-Signatur, den Naked Return für ganz knappe Helper, und ein subtiles Zusammenspiel mit defer, das Error-Wrapping in einer einzigen Zeile erlaubt. Dieser Artikel arbeitet alle Formen sauber durch und macht die Stellen, an denen man sich verheben kann, explizit.
Die Signatur — eine Funktion, mehrere Rückgaben
Die Go-Spec definiert das Ergebnis einer Funktion als optionale Liste, gleich aufgebaut wie die Parameterliste:
Signature = Parameters [ Result ] .
Result = Parameters | Type .
Parameters = "(" [ ParameterList [ "," ] ] ")" .Praktisch heißt das: Eine Funktion kann keinen, einen oder mehrere Rückgabewerte deklarieren. Bei einem einzelnen Wert ohne Namen darf man die Klammern weglassen — sonst sind sie Pflicht:
package main
// Kein Rückgabewert
func greet(name string) {
println("hi", name)
}
// Genau ein Wert — Klammern optional
func square(x int) int {
return x * x
}
// Genau ein Wert — mit Klammern (auch erlaubt)
func cube(x int) (int) {
return x * x * x
}
// Mehrere Werte — Klammern Pflicht
func divmod(a, b int) (int, int) {
return a / b, a % b
}
// Mehrere Werte mit unterschiedlichen Typen
func lookup(key string) (string, bool) {
return "Wert", true
}Drei Punkte, die häufig übersehen werden:
- Keine Tupel.
(int, int)ist kein Datentyp in Go. Es ist eine Signatur-Beschreibung — du kannst keine Variable vom Typ(int, int)deklarieren. Wer einen Tupel-Typ braucht, nimmt einen Struct. - Bei mehreren Werten Klammern Pflicht. Auch bei zwei
ints:func f() int, intist Syntax-Fehler. Korrekt:func f() (int, int). - Reihenfolge ist signifikant. Beim Aufruf wird positional ausgepackt — keine Namen, keine Reihenfolge-Tausch.
Auspacken am Aufruf-Ort
Auf der Aufrufer-Seite gibt es fünf gängige Auspack-Formen, die alle aus den anderen Sprach-Mechaniken folgen (:=, =, Blank Identifier). Das Wissen darüber ist nicht neu — aber die Kombination lohnt sich als geschlossene Übersicht:
package main
import (
"fmt"
"strconv"
)
func main() {
// (1) Komplette Deklaration: := und alle Variablen neu
n, err := strconv.Atoi("42")
fmt.Println(n, err)
// (2) Einen Wert verwerfen mit _
value, _ := strconv.Atoi("7")
fmt.Println(value)
// (3) Redeclaration — n existiert, m ist neu, err wird wiederverwendet
m, err := strconv.Atoi("100")
fmt.Println(m, err)
// (4) Reine Zuweisung — alle Variablen müssen schon existieren
n, err = strconv.Atoi("99")
fmt.Println(n, err)
// (5) Inline in if-Init — Variablen leben nur im if-Block
if k, errK := strconv.Atoi("3"); errK == nil {
fmt.Println("k im if:", k)
}
}42 <nil>
7
100 <nil>
99 <nil>
k im if: 3Was hier mitspielt: Die Redeclaration-Regel von := braucht mindestens einen neuen Namen links. In (3) ist m neu, err wird wiederverwendet — das ist die übliche Form bei aufeinanderfolgenden Aufrufen, die alle einen Error zurückliefern. Ohne neue Variable ist := nicht erlaubt; dann nimmt man = wie in (4).
Schwerwiegender Fehler bei Multi-Return: Du musst alle Werte auspacken — du kannst nicht nur einen aufnehmen:
// FEHLER: multiple-value strconv.Atoi() in single-value context
// n := strconv.Atoi("42")
// KORREKT — beide Werte annehmen oder beide verwerfen
n, _ := strconv.Atoi("42")
_, err := strconv.Atoi("xyz")
_ = n
_ = errDas (wert, err)-Idiom
Das wichtigste Anwendungs-Pattern für Multi-Return ist Gos kanonisches Error-Handling. Statt Exceptions zu werfen, gibt jede potentiell fehlschlagende Funktion zwei Werte zurück: das Ergebnis und einen Error. Der Aufrufer prüft den Error explizit und entscheidet, wie er weiterläuft:
package main
import (
"fmt"
"os"
"strconv"
)
func main() {
// Drei aufeinanderfolgende Operationen, jede mit Error-Möglichkeit
data, err := os.ReadFile("config.txt")
if err != nil {
fmt.Println("kann Datei nicht lesen:", err)
return
}
n, err := strconv.Atoi(string(data))
if err != nil {
fmt.Println("kein gültiger Integer:", err)
return
}
fmt.Println("Wert:", n*2)
}kann Datei nicht lesen: open config.txt: no such file or directoryDrei Konventionen, die durch die gesamte Stdlib durchgehalten werden:
errorist immer der letzte Rückgabewert. Niemals der erste, niemals in der Mitte.func f() (error, int)ist syntaktisch erlaubt, aber jeder Reviewer markiert es.- Wenn
err != nil, sind die anderen Werte undefiniert. Lies sie nicht — nicht weil der Compiler es verbietet, sondern weil der Funktions-Autor nichts versprochen hat. Im Stdlib-Quelltext sind die Werte bei Fehler oft0,""odernil, aber darauf darfst du nicht bauen. - Bei
(wert, ok)istokder letzte Rückgabewert. Das ist die comma-ok-Variante — kein Error, sondern ein Boolean. Sieht man beivalue, ok := m[key], Channel-Receives und Type Assertions.
Named Returns — die selbst-dokumentierende Signatur
Genauso wie Parameter Namen tragen, dürfen auch Rückgabewerte benannt sein. Sie werden dann zu lokalen Variablen der Funktion, sind beim Eintritt mit dem Zero Value initialisiert und können wie reguläre Variablen geschrieben werden:
package main
import "fmt"
// Ohne Named Returns
func split1(sum int) (int, int) {
x := sum * 4 / 9
y := sum - x
return x, y
}
// Mit Named Returns — x und y sind beim Eintritt 0
func split2(sum int) (x, y int) {
x = sum * 4 / 9
y = sum - x
return // implizit return x, y
}
func main() {
a, b := split1(17)
c, d := split2(17)
fmt.Println(a, b, c, d)
}7 10 7 10Die Spec sagt zur Namens-Regel:
Within a list of parameters or results, the names must either all be present or all be absent.
Konsequenz: Wenn ein Return-Wert benannt ist, müssen alle einen Namen haben. func f() (x int, error) ist Syntax-Fehler — entweder beide benennen (func f() (x int, err error)) oder beide unbenannt lassen (func f() (int, error)).
Was Named Returns wirklich bringen:
- Doku am API. Statt
func User(id int) (string, string, error)schreibst dufunc User(id int) (name, email string, err error)— der Aufrufer sieht in der Signatur sofort, was kommt. - Multi-Return mit gleichem Typ klar machen.
func bounds() (lo, hi int)ist lesbarer alsfunc bounds() (int, int). - Zero-Init nutzen. Die Variablen sind bei Funktions-Eintritt schon mit dem Zero Value belegt — du kannst direkt mit
=zuweisen und musst nicht erstvarschreiben. - Mit
deferkombinieren. Mehr dazu im nächsten Abschnitt.
Naked Return — wann er erlaubt, wann er gewünscht ist
Sobald Returns benannt sind, darfst du das return-Statement ohne Werte schreiben — der sogenannte Naked Return. Go nimmt dann automatisch die aktuellen Werte der benannten Variablen:
package main
import "fmt"
func parseLine(line string) (key, value string, ok bool) {
for i := 0; i < len(line); i++ {
if line[i] == '=' {
key = line[:i]
value = line[i+1:]
ok = true
return // identisch zu: return key, value, ok
}
}
return // ok ist false (Zero Value), key und value sind ""
}
func main() {
k, v, ok := parseLine("name=alice")
fmt.Println(k, v, ok)
k, v, ok = parseLine("kein-gleichheitszeichen")
fmt.Printf("%q %q %v\n", k, v, ok)
}name alice true
"" "" falseEffective Go formuliert den Stil-Hinweis vorsichtig: „Naked returns should be used only in short functions, as with the example shown here. They can harm readability in longer functions."
Faustregel der Codebase-Praxis: Naked Return ist nur in sehr kurzen Funktionen vertretbar — wenn der Body in einen Blick passt und der Leser nicht jagen muss, um zu sehen, was zurückgegeben wird. Bei längeren Funktionen oder mehreren Returns aus verschiedenen Branches macht der explizite return key, value, ok die Sache lesbarer — auch wenn das technisch redundant ist.
golangci-lint mit der Regel nakedret warnt automatisch bei Funktionen, die länger als eine konfigurierte Zeilenzahl sind und Naked Returns nutzen — eine sinnvolle Default-Konfiguration.
Named Returns und defer — Error-Wrapping in einer Zeile
Die mächtigste Kombination von Named Returns ist das Zusammenspiel mit defer. Eine deferred Funktion läuft nach dem return, aber bevor der Wert tatsächlich an den Aufrufer geht — und kann auf die benannten Return-Variablen zugreifen und sie verändern:
package main
import (
"errors"
"fmt"
)
func loadConfig(path string) (cfg string, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("loadConfig(%q): %w", path, err)
}
}()
if path == "" {
return "", errors.New("leerer Pfad")
}
// ... hier würde echte Arbeit stehen, weitere mögliche Fehler
return "fertiger config", nil
}
func main() {
_, err := loadConfig("")
fmt.Println(err)
}loadConfig(""): leerer PfadWas passiert hier in Reihenfolge:
loadConfig("")wird aufgerufen.cfgunderrwerden mit Zero Values initialisiert.- Die
defer-Closure wird auf den LIFO-Stack gelegt — sie läuft noch nicht. return "", errors.New(...)weistcfg = ""underr = errors.New(...)zu.- Jetzt läuft die deferred Closure. Sie sieht
errund packt es infmt.Errorfmit Kontext. - Der modifizierte
errist das, was der Aufrufer sieht.
Dieses Pattern erspart dir den Sprung-Code, an jeder Return-Stelle den Error manuell zu wrappen. Eine einzige defer-Closure am Funktions-Anfang reicht — alle Fehler kriegen automatisch den Kontext „loadConfig(...)".
Voraussetzung: Die Closure muss die benannte err-Variable referenzieren. Mit unbenannten Returns funktioniert das nicht, weil du keine Handle hast.
Multi-Return direkt als Argumente weiterreichen
Eine wenig bekannte Spezial-Regel: Wenn die Rückgabewerte einer Funktion exakt der Parameter-Signatur einer anderen Funktion entsprechen, darfst du den Call direkt einsetzen — ohne Zwischen-Variablen, ohne Umstellung:
package main
import "fmt"
func divmod(a, b int) (int, int) {
return a / b, a % b
}
// Diese Funktion hat genau die Signatur (int, int) wie die Rückgabe von divmod
func describe(quotient, remainder int) {
fmt.Printf("q=%d, r=%d\n", quotient, remainder)
}
func main() {
describe(divmod(17, 5)) // direktes Weiterreichen — ohne Zwischen-Variable
// fmt.Println akzeptiert auch alle Werte direkt
fmt.Println(divmod(17, 5))
}q=2, r=3
2 3Die Spec ist hier streng — das funktioniert nur, wenn der Multi-Return die einzige Argumentliste ist. Mischformen sind nicht erlaubt:
func f() (int, int) { return 1, 2 }
func g(a, b, c int) {}
func h(prefix string, a, b int) {}
func wrong() {
// FEHLER: extra-Argumente neben Multi-Return-Aufruf nicht erlaubt
// g(0, f())
// h("x:", f())
// KORREKT: Multi-Return zuerst auspacken
a, b := f()
g(0, a, b)
h("x:", a, b)
}In der Praxis siehst du das Pattern selten — meist will man den Error sowieso prüfen, bevor man die Werte weiterreicht. Für Test-Helper und ein paar Stdlib-Stellen (fmt.Println(strconv.Atoi("42"))) ist es aber elegant.
Mehrere Werte zurückgeben — wie viele sind zu viele?
Technisch kannst du beliebig viele Werte zurückgeben. Praktisch wird die Sache ab drei Werten unübersichtlich und ab vier Werten ein Code-Smell. Die Codebase-Konvention:
| Anzahl Rückgaben | Typischer Fall |
|---|---|
| 0 | Funktion mit Seiteneffekt (Print, Set, Notify) |
| 1 | reine Berechnung — Wert oder Error allein |
| 2 | das Standard-Pattern: (wert, err) oder (wert, ok) |
| 3 | Selten — z. B. (start, end, ok), (host, port, err) |
| 4+ | Refactoring-Signal: Struct als Return-Typ |
Beispiel für die 4+-Falle und ihren Fix:
// Anti-Pattern — zu viele Rückgabewerte
func parseAddress(s string) (host string, port int, scheme string, user string, err error) {
return "", 0, "", "", nil
}
// Besser — Ergebnis-Struct
type Address struct {
Host string
Port int
Scheme string
User string
}
func parseAddress2(s string) (Address, error) {
return Address{}, nil
}Der Struct hat zusätzlich den Vorteil, dass das Hinzufügen eines neuen Feldes keine Caller-Anpassungen erfordert — bei Multi-Return müsstest du jede Aufrufstelle umschreiben.
Terminating Statement — der Compiler-Zwang
Eine Funktion mit deklarierten Returns muss in einem Terminating Statement enden. Was das ist, regelt die Spec präzise: ein return, ein panic, ein goto rückwärts, oder eine endlose for { ... }-Schleife ohne break. Wer das missachtet, kriegt einen Compile-Fehler:
// FEHLER: missing return at end of function
// func indexRune(s string, r rune) int {
// for i, c := range s {
// if c == r {
// return i
// }
// }
// // <-- hier fehlt ein return
// }
// KORREKT — Default-Wert am Ende
func indexRune(s string, r rune) int {
for i, c := range s {
if c == r {
return i
}
}
return -1
}
// Auch korrekt — Endlos-Schleife terminiert (das Statement wird nie verlassen)
func eventLoop() int {
for {
handleEvent()
}
// unreachable — Compiler stört das nicht
}
func handleEvent() {}Konsequenz: Du kannst nicht einfach „nichts machen" am Ende. Bei jeder Verzweigung muss klar sein, dass die Funktion irgendwann zurückkehrt — oder bewusst nie zurückkehrt. Das fängt eine ganze Klasse von Bugs ab, die in anderen Sprachen still durchgehen.
Häufige Stolperfallen
error ist immer der letzte Rückgabewert.
Niemals der erste, niemals in der Mitte. Wer func f() (error, int) schreibt, läuft gegen jede Konvention der Stdlib und gegen die Erwartung jedes Lesers. Bei comma-ok-Funktionen ist ok analog der letzte Wert.
Bei Fehler sind die Nicht-Error-Werte undefiniert.
Wenn err != nil, sind die anderen Rückgabewerte nicht verlässlich. Mal 0, mal "", mal Müll — das hängt vom Funktions-Autor ab. Niemals nach einem Fehler den Wert lesen, ohne den Error vorher zu prüfen. Lint-Tools wie errcheck fangen das.
Multi-Return muss vollständig ausgepackt werden.
n := strconv.Atoi("42") ist Compile-Fehler — du musst beide Werte annehmen. Mindestens den ungewollten mit _ ausblenden. Im if-Init typisch: if _, err := f(); err != nil { ... } — nur der Error interessiert.
Named Returns sind Variablen — sie können geshadowed werden.
Wer in einem Block eine neue lokale Variable mit dem gleichen Namen wie ein Named Return deklariert (x := ...), arbeitet auf einer Kopie. Das Naked-Return liest dann immer noch den alten Wert. Klassisches Shadowing-Problem — vor allem mit := in if/for-Blocks.
Naked Return in langen Funktionen schadet Lesbarkeit.
Bei einer Funktion mit drei Branches und 40 Zeilen Body weiß der Leser nicht mehr, was return gerade zurückgibt. Effective Go empfiehlt Naked Returns nur in sehr kurzen Funktionen. golangci-lint mit nakedret warnt automatisch ab konfigurierter Länge.
defer kann Named Returns mutieren — auch nach einem expliziten return wert.
Wenn die deferred Funktion auf einen Named Return zugreift und ihn schreibt, sieht der Aufrufer den modifizierten Wert. Genutzt wird das für Error-Wrapping; missbraucht wird es, wenn die Mutation nicht offensichtlich ist. Bei unbenannten Returns funktioniert die Technik nicht — die Werte sind nach return nicht mehr ansprechbar.
Mischformen aus benannten und unbenannten Returns sind nicht erlaubt.
Entweder alle Returns haben einen Namen oder keiner. func f() (x int, error) ist Syntax-Fehler. Korrekt: func f() (x int, err error) oder func f() (int, error). Lint-Tools melden das, der Compiler erst recht.
Multi-Return als Argument geht nur, wenn die Signaturen exakt passen.
g(prefix, f()) mit f() (int, int) und g(string, int, int) ist nicht erlaubt — Multi-Return-Werte kommen nicht zwischen anderen Argumenten. Nur g(f()) (Signatur 1:1) ist legal. Wer mischen will, muss vorher auspacken.
Ab drei Rückgabewerten lohnt sich oft ein Struct.
Vier oder mehr Werte machen die Signatur unleserlich und jede neue Information bricht Caller. Ein Result-Struct ist erweiterbar (neues Feld stört keinen Caller, der die alten weiter referenziert) und selbst-dokumentierend (Feld-Namen statt Positions-Magie).
Weiterführende Ressourcen
Externe Quellen
- Function declarations – Go Language Specification
- Return statements – Go Specification
- Calls – Go Specification (Multi-Return-Passthrough)
- Effective Go: Multiple return values
- Effective Go: Named result parameters
nakedretLinter