Zurück zum Blog

Das Go-Keyword if: Bedingungen, Init-Statements und Early Returns

So funktioniert if in Go: Pflicht-Klammern, nur bool-Bedingungen, else-Platzierung, Init-Statements, Shadowing, Early Returns und kein ternärer Operator.

Das Go-Keyword if: Bedingungen, Init-Statements und Early Returns

Das Keyword if führt einen Codeblock aus, wenn eine boolesche Bedingung wahr ist. Die Go-Variante hat drei Regeln, die Leute aus C, JavaScript oder Python überraschen. Die Bedingung steht ohne runde Klammern, die geschweiften Klammern sind immer Pflicht, und die Bedingung muss ein echter bool sein, denn Go kennt keine Truthy- oder Falsy-Werte. Ein if kann außerdem mit einer kurzen Anweisung beginnen. Deshalb steht if err := f(); err != nil in fast jeder Go-Datei (Go spec).

Kurzfassung

  • if status >= 500 { ... } hat keine runden Klammern um die Bedingung, und die geschweiften Klammern sind selbst bei einer einzigen Zeile Pflicht.
  • Die Bedingung muss den Typ bool haben. if retries { ... } scheitert mit non-boolean condition in if statement.
  • else muss in derselben Zeile stehen wie die schließende }. In einer eigenen Zeile ist es ein Syntaxfehler.
  • if v, err := parse(s); err != nil { ... } deklariert Variablen, die im if und in allen seinen else-Zweigen existieren und danach nirgends mehr.
  • := in einem if-Block kann eine äußere Variable überschatten. Der Code kompiliert und verwendet stillschweigend den alten Wert.
  • Idiomatisches Go kehrt bei Fehlern früh zurück und lässt den Happy Path ohne Einrückung. Ein else nach einem return fällt meist weg.
  • Go hat keinen ternären Operator. Nimm eine Zuweisung plus if, cmp.Or für Defaults oder min und max zum Begrenzen.
  • && und || werten verkürzt aus, deshalb dereferenziert req.User != nil && req.User.IsAdmin nie einen nil-Pointer.
  • Eine lange if/else if-Kette liest sich meist besser als switch ohne Ausdruck.

Wie schreibt man eine if-Anweisung in Go?

Schreib if, einen booleschen Ausdruck und einen Block in geschweiften Klammern. Hier ist eine Retry-Prüfung für einen HTTP-Client:

example.gogo
package main

import "fmt"

func shouldRetry(status, attempt, maxAttempts int) bool {
	if attempt >= maxAttempts {
		return false
	}
	return status == 429 || status >= 500
}

func main() {
	fmt.Println(shouldRetry(503, 1, 3))
	fmt.Println(shouldRetry(503, 3, 3))
	fmt.Println(shouldRetry(404, 1, 3))
}
example.texttext
true
false
false

Um attempt >= maxAttempts stehen keine runden Klammern. Du kannst sie schreiben, und der Code kompiliert trotzdem, aber gofmt entfernt sie beim nächsten Speichern der Datei. Auf den geschweiften Klammern besteht Go dagegen. if attempt >= maxAttempts return false in einer Zeile lässt sich nicht parsen, und eine Bedingung mit einer Anweisung in der nächsten Zeile genauso wenig:

example.gogo
if status >= 500
	fmt.Println("server error")
example.texttext
./main.go:8:19: syntax error: unexpected newline, expected { after if clause

Pflicht-Klammern schließen einen typischen C-Bug aus. Du ergänzt unter einem if ohne Klammern eine zweite Zeile, sie sieht aus wie ein Teil der Bedingung, und sie läuft jedes Mal. Apples TLS-Bug „goto fail“ von 2014 war genau dieser Fehler. In Go hat jeder if-Rumpf explizite Grenzen, und gofmt rückt sie in jeder Codebasis gleich ein.

Gibt es in Go Truthy- und Falsy-Werte?

Nein. Die Bedingung muss ein Ausdruck vom Typ bool sein. Ein int, ein String, ein Pointer, ein Slice oder ein Error wird nie automatisch in true oder false umgewandelt:

example.gogo
retries := 3
if retries {
	fmt.Println("retrying")
}
example.texttext
./main.go:7:5: non-boolean condition in if statement

Du schreibst den Vergleich, den du meinst: retries > 0, name != "", user != nil, len(items) > 0 oder err != nil. Das kostet ein paar Zeichen und macht die Prüfung explizit. In JavaScript überspringt if (count) den Block, wenn count gleich 0 ist. Das ist oft ein Bug, wenn null ein gültiger Wert ist. In Go sieht der Leser immer, welche Bedingung geprüft wird.

Wie funktionieren else und else if in Go?

else läuft, wenn die Bedingung falsch ist, und else if prüft eine weitere Bedingung. Hier wählt ein Request-Logger das Log-Level anhand des Statuscodes:

example.gogo
func logRequest(ctx context.Context, logger *slog.Logger, path string, status int) {
	var level slog.Level
	if status >= 500 {
		level = slog.LevelError
	} else if status >= 400 {
		level = slog.LevelWarn
	} else {
		level = slog.LevelInfo
	}
	logger.Log(ctx, level, "request", "path", path, "status", status)
}
example.texttext
level=INFO msg=request path=/api/orders status=200
level=WARN msg=request path=/api/orders/99 status=404
level=ERROR msg=request path=/api/checkout status=502

Die Zweige werden von oben nach unten geprüft, und der erste wahre läuft. Ein 502 passt zuerst auf status >= 500 und erreicht die Prüfung status >= 400 deshalb nie. Das Go-Keyword else erklärt else und else if-Ketten im Detail.

Warum muss else in derselben Zeile wie die schließende Klammer stehen?

Wegen des automatischen Einfügens von Semikolons. Die Grammatik von Go beendet Anweisungen mit Semikolons, und der Lexer fügt eines am Ende jeder Zeile ein, die mit }, einem Bezeichner, einem Literal oder ein paar anderen Tokens endet (Go spec). Eine } allein in einer Zeile beendet also die if-Anweisung. Das else in der nächsten Zeile beginnt dann eine neue Anweisung, und keine Anweisung darf mit else beginnen:

example.gogo
if status >= 500 {
	fmt.Println("server error")
}
else {
	fmt.Println("ok")
}
example.texttext
./main.go:10:2: syntax error: unexpected keyword else, expected }

Die Lösung ist } else { in einer Zeile. Dieselbe Regel erklärt, warum die öffnende Klammer eines if, for oder func nicht in einer eigenen Zeile stehen kann. Außerdem streiten Go-Projekte deshalb nicht über den Klammerstil. Der Compiler akzeptiert nur eine Platzierung, und gofmt übernimmt den Rest der Formatierung.

Was ist eine if-Anweisung mit kurzer Anweisung?

Ein if kann mit einer kurzen Anweisung beginnen, die durch ein Semikolon von der Bedingung getrennt ist. Die Anweisung läuft zuerst, und alle Variablen, die sie deklariert, gelten nur im if. Am häufigsten nutzt man das für die Fehlerbehandlung:

example.gogo
func loadConfig(path string) (Config, error) {
	var cfg Config
	data, err := os.ReadFile(path)
	if err != nil {
		return cfg, fmt.Errorf("read config: %w", err)
	}
	if err := json.Unmarshal(data, &cfg); err != nil {
		return cfg, fmt.Errorf("parse %s: %w", path, err)
	}
	return cfg, nil
}

json.Unmarshal gibt nur einen Error zurück, also passen Aufruf und Prüfung in eine Zeile, und dieses err existiert nach der schließenden Klammer nicht mehr. os.ReadFile liefert Daten, die du danach brauchst, deshalb steht der Aufruf in einer eigenen Zeile. So wird das in Go-Code üblicherweise aufgeteilt. Die Anweisung gehört ins if, wenn ihre Ergebnisse nur für die Prüfung gebraucht werden, und davor, wenn der Rest der Funktion sie verwendet.

Dieselbe Form funktioniert mit jedem Ausdruck, der einen Wert und einen Boolean zurückgibt. Das ist in Go das Comma-ok-Idiom. Ein Map-Lookup verrät dir, ob der Key vorhanden war:

example.gogo
roles := map[string]string{"u_42": "admin", "u_7": "billing"}

if role, ok := roles["u_42"]; ok {
	fmt.Println("u_42 is", role)
}
if _, ok := roles["u_99"]; !ok {
	fmt.Println("u_99 has no role")
}
example.texttext
u_42 is admin
u_99 has no role

Eine Type Assertion prüft auf optionales Verhalten, ohne eine Panic zu riskieren. Ein Streaming-Handler ruft Flush nur auf, wenn der http.ResponseWriter das unterstützt:

example.gogo
if f, ok := w.(http.Flusher); ok {
	f.Flush()
}

Go 1.26 hat errors.AsType eingeführt, eine generische Version von errors.As, die den passenden Error und einen Boolean zurückgibt. Damit passt sie ins selbe Muster. Ein Service kann auf Defaults zurückfallen, wenn die Config-Datei fehlt, bei einer kaputten Datei aber trotzdem abbrechen:

example.gogo
for _, path := range []string{"missing.json", "bad.json"} {
	_, err := loadConfig(path)
	if pathErr, ok := errors.AsType[*fs.PathError](err); ok {
		fmt.Println("no config file at", pathErr.Path, "so using defaults")
	} else if err != nil {
		fmt.Println("fatal:", err)
	}
}
example.texttext
no config file at missing.json so using defaults
fatal: parse bad.json: invalid character '}' looking for beginning of object key string

Mit dem älteren errors.As deklarierst du var pathErr *fs.PathError vor dem if und übergibst &pathErr. Die Variable lebt dann länger als die Prüfung.

Welchen Scope hat eine Variable, die in einer if-Anweisung deklariert wird?

Die Variable existiert in der Bedingung, im if-Block und in jedem else if- und else-Block, der daran hängt. Nach dem Ende der Anweisung existiert sie nicht mehr (Go spec):

example.gogo
raw := "abc"
if n, err := strconv.Atoi(raw); err != nil {
	fmt.Println("bad MAX_CONNS:", err)
} else {
	fmt.Println("max conns", n)
}
fmt.Println(n)

Der else-Zweig kann n verwenden, aber die letzte Zeile scheitert mit undefined: n. Das ist gewollt. Die Variable kann nicht in Code gelangen, der nicht von ihr abhängen soll, und der Name ist für das nächste if wieder frei. So kann eine Go-Funktion zehn Prüfungen der Form if err := ...; err != nil enthalten, ohne zehn verschiedene Namen für Errors zu brauchen.

Wie verursacht := in einem if Shadowing-Bugs?

Jeder Block öffnet einen neuen Scope, und := deklariert immer im aktuellen Scope. Wenn du innerhalb eines if-Blocks mit := einer äußeren Variable etwas zuweisen willst, bekommst du stattdessen eine neue Variable mit demselben Namen:

example.gogo
func requestTimeout() time.Duration {
	timeout := 5 * time.Second
	if raw := os.Getenv("HTTP_TIMEOUT"); raw != "" {
		timeout, err := time.ParseDuration(raw)
		if err != nil {
			fmt.Println("ignoring HTTP_TIMEOUT:", err)
			return 5 * time.Second
		}
		fmt.Println("HTTP_TIMEOUT set to", timeout)
	}
	return timeout
}
example.texttext
HTTP_TIMEOUT set to 30s
using 5s

err ist innerhalb des Blocks neu, also ist := erlaubt, und es deklariert dabei stillschweigend auch ein zweites timeout. Der geparste Wert landet in der inneren Variable und verschwindet an der schließenden Klammer. Der Compiler akzeptiert das, und die Standardprüfungen von go vet melden es nicht. Die Lösung ist, err mit var err error zu deklarieren und = zu verwenden, damit das äußere timeout den Wert bekommt. Das Go-Keyword var geht tiefer auf Shadowing ein, inklusive des shadow-Analyzers, der diesen Fehler findet.

Warum vermeidet idiomatisches Go else?

Weil die meisten else-Zweige in Go auf ein if folgen, das bereits zurückgekehrt ist. Effective Go formuliert es so: Wenn eine if-Anweisung nicht in die nächste Anweisung weiterläuft, weil der Rumpf mit break, continue, goto oder return endet, lässt man das überflüssige else weg (Effective Go). Das Ergebnis ist Code, in dem Fehler dort behandelt und zurückgegeben werden, wo sie auftreten, und der Erfolgspfad geradlinig am linken Rand der Funktion entlangläuft.

Hier ist ein Refund-Handler, der für jede Prüfung ein verschachteltes if verwendet:

example.gogo
func handleRefund(w http.ResponseWriter, r *http.Request) {
	user, ok := userFromContext(r.Context())
	if ok {
		if user.CanRefund {
			var req RefundRequest
			if err := json.NewDecoder(r.Body).Decode(&req); err == nil {
				if req.AmountCents > 0 {
					fmt.Fprintf(w, "refund of %d cents queued for %s", req.AmountCents, req.OrderID)
				} else {
					http.Error(w, "amount_cents must be positive", http.StatusBadRequest)
				}
			} else {
				http.Error(w, "invalid JSON body", http.StatusBadRequest)
			}
		} else {
			http.Error(w, "forbidden", http.StatusForbidden)
		}
	} else {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
	}
}

Das funktioniert, aber die eigentliche Rückerstattung steckt fünf Ebenen tief, und jede Fehlermeldung steht weit weg von der Bedingung, die sie auslöst. Um zu sehen, warum ein Request eine 401 bekommt, musst du das letzte else dem ersten if zuordnen. Wenn du jede Bedingung umkehrst und früh zurückkehrst, bleibt das Verhalten gleich:

example.gogo
func handleRefund(w http.ResponseWriter, r *http.Request) {
	user, ok := userFromContext(r.Context())
	if !ok {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}
	if !user.CanRefund {
		http.Error(w, "forbidden", http.StatusForbidden)
		return
	}

	var req RefundRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "invalid JSON body", http.StatusBadRequest)
		return
	}
	if req.AmountCents <= 0 {
		http.Error(w, "amount_cents must be positive", http.StatusBadRequest)
		return
	}

	fmt.Fprintf(w, "refund of %d cents queued for %s", req.AmountCents, req.OrderID)
}

Schickt man beide Versionen mit denselben fünf Requests durch httptest, sind die Antworten identisch:

example.texttext
401 unauthorized
403 forbidden
400 invalid JSON body
400 amount_cents must be positive
200 refund of 4999 cents queued for ord_1001

Jede Prüfung in der flachen Version ist eine Guard Clause, also eine Bedingung, die direkt mit ihrer Antwort gepaart ist. Eine neue Regel wie ein Limit für Rückerstattungen bedeutet einen weiteren if-Block über der letzten Zeile, statt alles in eine weitere Ebene zu packen.

Was bedeutet „drop this else and outdent its block“?

Das ist die Meldung der Regel indent-error-flow in revive, dem Linter, der golint abgelöst hat und innerhalb von golangci-lint läuft. Sie schlägt bei einem if-Block an, der mit return endet und auf den ein else folgt:

example.gogo
func parsePort(raw string) (int, error) {
	if port, err := strconv.Atoi(raw); err != nil {
		return 0, fmt.Errorf("invalid port %q: %w", raw, err)
	} else if port < 1 || port > 65535 {
		return 0, errors.New("port out of range")
	} else {
		return port, nil
	}
}
example.texttext
main.go:14:9: if block ends with a return statement, so drop this else and outdent its block (move short variable declaration to its own line if necessary)

Der Hinweis in Klammern betrifft den Scope von if. port wird in der if-Anweisung deklariert, also würde return port, nil ohne das else außerhalb seines Scopes stehen. Die Lösung ist, port, err := strconv.Atoi(raw) in eine eigene Zeile über dem if zu verschieben. Danach wird jede Prüfung zu einer einfachen Guard Clause, die zurückkehrt.

Die revive-Regel superfluous-else deckt dasselbe Muster nach break, continue, goto, panic und os.Exit ab, und die optionale Regel early-return schlägt vor, ein if/else umzukehren, dessen else-Zweig mit return endet. Das heißt nicht, dass else falsch ist. Das Beispiel logRequest oben verwendet es richtig, weil jeder Zweig einen Wert zuweist und die Ausführung nach der Anweisung weiterläuft. Das Go-Keyword else zeigt, wann else die richtige Wahl ist und wie du die Fälle umbaust, in denen es das nicht ist.

Hat Go einen ternären Operator?

Nein. Die Go FAQ beantwortet das direkt. Die Designer hatten gesehen, dass ?: „too often to create impenetrably complex expressions“ verwendet wurde, also zu oft für undurchdringlich komplexe Ausdrücke. Sie entschieden, dass „a language needs only one conditional control flow construct“, eine Sprache also nur ein Konstrukt für bedingten Kontrollfluss braucht (Go FAQ). Als Ersatz nennt die FAQ ein if, das eine Variable zuweist. In der Praxis ist die kürzeste Form, zuerst den Default zu setzen und ihn dann zu überschreiben:

example.gogo
timeout := 5 * time.Second
if cfg.Debug {
	timeout = 5 * time.Minute
}

Für zwei häufige Einsatzzwecke des ternären Operators gibt es inzwischen eigene Helfer. cmp.Or, eingeführt mit Go 1.22, gibt das erste Argument zurück, das nicht der Nullwert ist. Das deckt die meisten Fälle nach dem Muster „nimm das hier oder falle auf jenes zurück“ ab. Die Builtins min und max aus Go 1.21 übernehmen das Begrenzen:

example.gogo
port := cmp.Or(os.Getenv("PORT"), "8080")
fmt.Println("listening on :" + port)

requested := 500
pageSize := max(1, min(requested, 100))
fmt.Println("page size", pageSize)
example.texttext
listening on :8080
page size 100

cmp.Or nimmt beliebig viele Argumente, deshalb prüft cmp.Or(flagAddr, os.Getenv("ADDR"), ":8080") erst ein Flag, dann die Umgebung und zuletzt einen Default.

Warum nicht eine generische Ternary-Funktion schreiben?

Du kannst func ternary[T any](cond bool, a, b T) T schreiben, und manche Codebasen tun das auch. Die Funktion hat aber eine Falle, die die if-Form nicht hat. Go wertet vor einem Funktionsaufruf jedes Argument aus, also laufen immer beide Zweige:

example.gogo
var u *User
name := ternary(u != nil, u.Name, "anonymous")
example.texttext
panic: runtime error: invalid memory address or nil pointer dereference

Ein echter ?:-Operator würde u.Name überspringen, wenn u nil ist. Die Funktion liest u.Name zuerst und stürzt ab. Dasselbe gilt für teure Aufrufe, die laufen, egal ob ihr Ergebnis verwendet wird. Ein if wertet nur den Zweig aus, den es nimmt.

Wie werten && und || in einem if verkürzt aus?

&& wertet die rechte Seite nur aus, wenn die linke true ist, und || wertet die rechte Seite nur aus, wenn die linke false ist (Go spec). Die Reihenfolge der Bedingungen kann also darüber entscheiden, ob der Code eine Panic auslöst. Eine nil-Prüfung muss vor dem Feldzugriff stehen:

example.gogo
if req.User != nil && req.User.IsAdmin {
	fmt.Println("admin panel")
} else {
	fmt.Println("access denied")
}

Mit einem nil-req.User gibt das access denied aus. Vertauschst du die beiden Bedingungen zu req.User.IsAdmin && req.User != nil, löst derselbe Request eine Panic mit invalid memory address or nil pointer dereference aus, weil das Feld gelesen wird, bevor die Prüfung läuft.

|| funktioniert beim frühen Ablehnen genauso. Dieser Helfer parst einen Authorization-Header und hört bei der ersten Bedingung auf, die fehlschlägt:

example.gogo
func bearerToken(header string) (string, bool) {
	scheme, token, found := strings.Cut(header, " ")
	if !found || !strings.EqualFold(scheme, "Bearer") || token == "" {
		return "", false
	}
	return token, true
}

Für "Bearer eyJhbGciOi" gibt er das Token und true zurück. Für "Basic dXNlcjpwYXNz" und für ein nacktes "Bearer" gibt er false zurück. Setz billige Prüfungen an den Anfang und langsame, etwa einen Datenbank-Lookup, ans Ende. Dann läuft der langsame Aufruf nur, wenn er das Ergebnis noch ändern kann. && bindet stärker als ||, also bedeutet a || b && c dasselbe wie a || (b && c). Setz Klammern, wenn du beide mischst, denn gofmt lässt diese stehen.

Wann sollte man switch statt if/else if verwenden?

Wenn du einen Wert mit mehreren Fällen vergleichst oder eine Kette mehr als zwei oder drei else if-Zweige hat. Ein switch ohne Ausdruck prüft jeden case als Boolean von oben nach unten, genau wie eine if/else if-Kette:

example.gogo
switch {
case status >= 500:
	level = slog.LevelError
case status >= 400:
	level = slog.LevelWarn
default:
	level = slog.LevelInfo
}

Die Bedingungen stehen untereinander in einer Spalte, und es gibt kein break, weil ein switch in Go nach dem ersten passenden Case aufhört. Ein switch akzeptiert ebenfalls eine kurze Anweisung, etwa switch ext := filepath.Ext(name); ext { ... }, mit denselben Scope-Regeln wie if. Nimm if für eine oder zwei Bedingungen und vor allem für Fehlerprüfungen, wo jeder Go-Leser if err != nil erwartet. Der Abschnitt zu switch im Überblick über die Go Keywords erklärt Expression Switches, Type Switches und fallthrough.

Wo LevelUpGo ins Spiel kommt

LevelUpGo bringt dir Go mit Übungen bei, die echten Go-Code im Browser ausführen. Go Basics behandelt if, else und die logischen Operatoren gleich zu Beginn, mit Übungen, in denen du die Bedingungen selbst schreibst. Simplification enthält Lektionen dazu, verschachtelten Code mit Early Returns flach zu machen, boolesche Logik zu vereinfachen und lange if-Ketten durch einen switch zu ersetzen. Im Training Ground findest du kurze, eigenständige Übungen, auch außerhalb eines Kurses. Die anderen 24 reservierten Wörter findest du in Go Keywords: Alle 25 erklärt.

Häufig gestellte Fragen

Hat Go einen ternären Operator?

Nein. Go hat keinen ?:-Operator, und laut Go FAQ haben die Designer ihn weggelassen, weil er oft für schwer lesbare Ausdrücke verwendet wurde. Nimm eine Variable mit einem Default und ein if, das ihn überschreibt. Für Fallback-Werte gibt cmp.Or(a, b) das erste Argument zurück, das nicht null ist, und min und max übernehmen das Begrenzen.

Warum verlangt Go geschweifte Klammern bei if?

Die Klammern legen fest, wo jeder if-Rumpf beginnt und endet. Eine zusätzliche Zeile kann also nicht versehentlich ändern, welche Anweisungen die Bedingung steuert. Außerdem kann Go deshalb auf die runden Klammern um die Bedingung verzichten, weil die { markiert, wo die Bedingung endet. gofmt formatiert dann jedes if gleich.

Gibt es in Go Truthy- und Falsy-Werte?

Nein. Eine if-Bedingung muss vom Typ bool sein. Zahlen, Strings, Pointer, Slices und Errors werden nie automatisch umgewandelt, und if count { ... } scheitert mit non-boolean condition in if statement. Schreib den Vergleich explizit aus, etwa count > 0, s != "" oder err != nil.

Kann eine if-Anweisung in Go eine Variable deklarieren?

Ja. Ein if kann mit einer kurzen Anweisung beginnen, etwa if err := save(order); err != nil. Dort deklarierte Variablen gelten in der Bedingung, im if-Block und in allen else if- oder else-Blöcken, und nach dem Ende der Anweisung verschwinden sie.

Warum muss else in Go in derselben Zeile wie die schließende Klammer stehen?

Der Lexer von Go fügt am Ende einer Zeile, die mit } endet, ein Semikolon ein. Beginnt else die nächste Zeile, ist die if-Anweisung schon beendet, und der Compiler meldet syntax error: unexpected keyword else, expected }. Wenn du } else { in eine Zeile schreibst, entsteht kein eingefügtes Semikolon.

Quellen

Schreib Go wie ein Senior Engineer

Interaktive Lektionen im Browser. Die ersten sind kostenlos.

Kostenlose Lektion testenOder kostenloses Konto erstellen