Öffnen Sie in Python eine Datei, die nicht existiert, wirft open() einen FileNotFoundError, der aus Ihrer Funktion heraus nach oben durchläuft. Tun Sie dasselbe in Go, gibt os.ReadFile einen error-Wert neben einem nil-Byte-Slice zurück. Derselbe Fehlschlag, zwei völlig unterschiedliche Code-Formen darum herum. Der Unterschied ist nicht kosmetisch. Er entscheidet, ob ein Aufrufer den Fehlschlag versehentlich ignorieren kann, und ob der Compiler es merkt, wenn er es tut.

Was machen Exceptions und Error Values wirklich unterschiedlich?

Eine Exception ist Kontrollfluss, der springt. Etwas schlägt fehl, die Funktion wirft, und die Ausführung verlässt den aktuellen Call-Stack und landet im nächsten passenden catch- oder except-Block, egal wie viele Ebenen weiter oben das ist. Die Signatur der Funktion gibt keinen Hinweis darauf, dass das passieren kann. loadConfig(path: string): Config in TypeScript sieht genau wie eine Funktion aus, die nicht fehlschlagen kann – bis sie bei einer fehlenden Datei eine Exception wirft.

Ein Error Value ist ein Datum. Die Funktion gibt es zusätzlich zum, oder statt des, angeforderten Ergebnisses zurück. Das (Config, error) von Go, das Result<Config, ConfigError> von Rust und ein Lookup, das bei einem Miss null zurückgibt, sind dieselbe Idee: Der Fehlschlag ist Teil des Rückgabetyps, also sieht der Aufrufer ihn direkt an der Aufrufstelle. In Rust zwingt Sie das Typsystem, ihn zu behandeln, bevor Sie das Ergebnis berühren. In Go erledigt das die Konvention.

Keiner der beiden Ansätze ist neu, und keiner hat sich durchgesetzt. C gab Sentinel-Werte wie -1 und NULL zurück; Exceptions kamen später, um die Fehlerbehandlung aus der Hauptlogik herauszuhalten. Python, Java, C# und JavaScript setzen standardmäßig auf Exceptions. Go und Rust setzen standardmäßig auf Error Values. Die meisten Sprachen, die sich für einen Standard entscheiden, lassen Sie trotzdem zum anderen greifen, wenn er besser passt.

Wie gibt Go Fehler als Werte zurück?

Go hat keine Exceptions für gewöhnliche Fehlschläge. Eine Funktion, die fehlschlagen kann, gibt einen error als letzten Wert zurück, und der Aufrufer prüft ihn sofort:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package main

import (
	"encoding/json"
	"fmt"
	"os"
)

type Config struct {
	Port int `json:"port"`
}

func loadConfig(path string) (Config, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return Config{}, fmt.Errorf("reading config: %w", err)
	}

	var cfg Config
	if err := json.Unmarshal(data, &cfg); err != nil {
		return Config{}, fmt.Errorf("parsing config: %w", err)
	}

	return cfg, nil
}

func main() {
	cfg, err := loadConfig("app.json")
	if err != nil {
		fmt.Fprintln(os.Stderr, "startup failed:", err)
		os.Exit(1)
	}
	fmt.Println("listening on port", cfg.Port)
}

%w umschließt den ursprünglichen Fehler, statt ihn zu verwerfen, sodass errors.Is und errors.As weiter oben im Stack prüfen können, was schiefging, ohne die Nachricht als Zeichenkette zu vergleichen.

Die Schwachstelle: Nichts hindert Sie daran, data, _ := os.ReadFile(path) zu schreiben und den Fehler wegzuwerfen. Der Compiler akzeptiert das klaglos. Ein Linter wie errcheck findet ungeprüfte Fehler, aber nur, wenn er in Ihrer CI läuft.

Wie springen Python-Exceptions zu einem Handler?

Derselbe Konfigurationslader in Python hat keine Inline-Fehlerprüfungen nach jedem Aufruf. Die Prüfung findet rund um den Aufruf statt, nicht in ihm:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import json

class ConfigError(Exception):
    pass

def load_config(path):
    try:
        with open(path) as f:
            data = json.load(f)
    except FileNotFoundError as e:
        raise ConfigError(f"config file not found: {path}") from e
    except json.JSONDecodeError as e:
        raise ConfigError(f"invalid JSON in {path}: {e}") from e

    if "port" not in data:
        raise ConfigError(f"missing 'port' key in {path}")

    return data


try:
    config = load_config("app.json")
except ConfigError as e:
    print(f"startup failed: {e}")
    raise SystemExit(1)

print("listening on port", config["port"])

raise ... from e hält den ursprünglichen Traceback am neuen ConfigError fest, genau wie %w in Go den ursprünglichen Fehler erreichbar hält.

Der entscheidende Unterschied ist die Signatur. load_config sagt nie, dass sie eine Exception werfen kann. Ein Aufrufer drei Ebenen weiter oben, der das try vergessen hat, merkt es, wenn das Programm in Produktion mit einem Traceback abstürzt, weil nichts in der Sprache die Aufrufstelle prüft.

Wie zwingt Rusts Result Sie dazu, den Fehler zu behandeln?

Rust behält die Idee des Fehlers als Wert bei und fügt ein Typsystem hinzu, das Sie den Erfolgswert nicht berühren lässt, ohne den Fehlerfall anzuerkennen:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
use std::fs;

#[derive(Debug)]
enum ConfigError {
    Io(std::io::Error),
    Parse(serde_json::Error),
}

#[derive(serde::Deserialize)]
struct Config {
    port: u16,
}

fn load_config(path: &str) -> Result<Config, ConfigError> {
    let data = fs::read_to_string(path).map_err(ConfigError::Io)?;
    let config: Config = serde_json::from_str(&data).map_err(ConfigError::Parse)?;
    Ok(config)
}

fn main() {
    match load_config("app.json") {
        Ok(config) => println!("listening on port {}", config.port),
        Err(e) => {
            eprintln!("startup failed: {:?}", e);
            std::process::exit(1);
        }
    }
}

Dafür braucht man serde (mit dem Feature derive) und serde_json in der Cargo.toml.

Result<T, E> ist ein Enum mit zwei Varianten, Ok(T) und Err(E), und Sie können keine Config herausbekommen, ohne beide zu behandeln. Der ?-Operator ist eine Abkürzung für genau dieses Match: Gibt fs::read_to_string Err zurück, kehrt load_config sofort mit diesem Fehler zurück, auf ConfigError::Io gemappt. Andernfalls packt er das Ok aus und macht weiter.

Sie können all das trotzdem mit .unwrap() umgehen, das bei Err einen Panic auslöst. Das ist Rusts Version einer ungefangenen Exception. Behandeln Sie es als etwas, das im Code-Review hinterfragt wird, nicht als Abkürzung.

Warum wirft fetch() bei einem 404 keine Exception?

Die Trennlinie verläuft nicht nur zwischen Sprachen. Eine einzige API kann beide Modelle für unterschiedliche Fehlschläge nutzen. fetch() lehnt sein Promise bei einem Netzwerkfehler ab (DNS löst nicht auf, Verbindung verweigert), löst sich aber bei einem 404 oder 500 ganz normal auf. Der HTTP-Status ist ein Wert auf der Response, die Sie bereits haben: Sie prüfen response.ok, Sie fangen nichts. Fehlerbehandlung mit der Fetch API in JavaScript erklärt, warum das alle überrascht, die erwarten, dass ein HTTP-Fehler eine Exception wirft, und wie man fetch() umhüllt, um das Exception-artige Verhalten zurückzuholen, falls gewünscht.

Wann sollten Sie Exceptions nutzen, und wann Fehler zurückgeben?

Stellen Sie eine Frage zum konkreten Fehlschlag: Muss der unmittelbare Aufrufer darauf reagieren, oder sollte er nach oben wandern, bis ihn eine Grenze abfängt?

Ein Cache-Miss oder ein Validierungsfehler bei einer Nutzereingabe ist ein Ergebnis, das Ihr Code erwarten und direkt dort verzweigen sollte. Ein Error Value passt. Der Aufrufer kann seine Arbeit nicht ohne Reaktion erledigen, also ist ein Rückgabetyp, der den Fehlschlag sichtbar macht, eine Hilfe, keine Förmlichkeit.

Eine Datenbankverbindung, die mitten in einer Transaktion abreißt, oder eine Annahme über den Programmzustand, die sich als falsch herausstellt, sind anders. Nichts, was der unmittelbare Aufrufer tut, wird das beheben. Es als Exception oder Panic bis zu einer einzigen Stelle propagieren zu lassen, die protokolliert und sauber herunterfährt, ist kürzer und ehrlicher, als den Fehler manuell durch jeden Frame zu reichen.

Sprachen, die standardmäßig auf ein Modell setzen, nutzen das andere bereits dort, wo es passt. Go gerät bei einem Slice-Index außerhalb der Grenzen in Panic, weil das ein Bug ist, keine Bedingung, die der Aufrufer behandeln sollte. Rust gerät bei Integer-Overflow in Debug-Builds in Panic. Pythons dict.get(key, default) gibt einen Wert zurück, statt KeyError zu werfen, für den Fall, dass ein fehlender Schlüssel erwartet wird statt eine Ausnahme zu sein.

Was geht bei jeder Strategie in der Praxis schief?

Verschluckte Fehler. Ein leeres except:, das nichts tut, oder ein if err != nil { } in Go mit leerem Körper, löscht den Fehlschlag. Das Symptom zeigt sich später als fehlende Daten oder leerer Bildschirm, ohne einen einzigen Fehler nahe der eigentlichen Ursache. Das ist der schwer zu verfolgende Ablauf, der in Was ist Spaghetti-Code und wie man ihn refactort beschrieben wird.

Panics in der falschen Goroutine. recover() funktioniert nur innerhalb der Goroutine, die in Panic geraten ist. Ein verzögertes recover in main rettet Sie nicht vor einem Panic in einer Worker-Goroutine; das ganze Programm stürzt ab. Goroutines und Channels in Go Erklärt zeigt, warum Fehler aus nebenläufiger Arbeit über einen Channel zurückkommen sollten, als Werte.

.unwrap() und ein pauschales catch statt einer Entscheidung. Beide lassen Sie aufhören, über den Fehlerfall nachzudenken, ohne ihn wirklich zum Verschwinden zu bringen. Schreiben Sie einen Test, der den Fehlerpfad erzwingt. Das ist viel leichter, wenn die Funktion einen Fehler zurückgibt, den Sie selbst konstruieren können, als wenn er tief aus einer Bibliothek kommt, die Sie nicht kontrollieren – und deshalb bekommen Fehlerpfade eigene Tests in Unit-Tests vs Integrationstests vs End-to-End-Tests.

Wie wählen Sie pro Funktion, nicht pro Sprache?

Bevor Sie das nächste Mal throw oder return err schreiben, schauen Sie sich die Funktion vor Ihnen an. Muss der Aufrufer sofort reagieren, gehört der Fehlschlag in den Rückgabetyp: (T, error) in Go, Result in Rust, None oder ein Default in Python. Kann nichts Nützliches passieren, bevor der Fehlschlag einen übergeordneten Handler erreicht, werfen Sie eine Exception oder einen Panic und behandeln Sie ihn genau dort, an dieser Grenze, einmal. Fügen Sie dann einen Test für den Fehlerpfad hinzu, egal welchen Sie gewählt haben.

Verwandte Artikel