Die Golumne

Netter Versuch: Fehlerbehandlung in Go

Frank Müller

© Reneé French (CC BY 3.0 DE) ©SuS Media

Wer kennt es nicht? Man gibt sich Mühe, entwirft, programmiert sorgfältig, testet sowohl manuell als auch automatisch, hat also ein gefühlt perfektes Programm. Und doch kommt es immer wieder zur Laufzeit zu Fehlern. Es sind Dateien beim Öffnen nicht vorhanden, sie haben ein falsches Format, sind wegen fehlender Rechte nicht les- oder schreibbar oder können wegen eines gefüllten Dateisystems nicht geschrieben werden. Gleiches gilt auch für das Netz. Adressen sind nicht zu erreichen, Latenzen zu groß, Zugriff wird nicht gestattet und Verbindungen brechen ab. Datenbanken oder Verzeichnisdienste liefern nicht die gesuchten Daten. Und wenn, dann enthalten sie vielleicht ungültige Werte. Die Liste der möglichen Bedrohungen ist unendlich.

Damit ist das Leben eines Programms ein wilder Dschungel. Es muss natürlich zusehen, einige dieser Fehler selbst zu vermeiden. So muss eine Division by Zero nicht unbedingt sein. Doch wie bereits angedeutet kann der eigene Code nicht immer in Watte gepackt werden, insbesondere bei den erwähnten externen Störungen. Selbst wenn man eine zwar sehr funktionale Bibliothek entwickelt und diese auch besonders sorgfältig dokumentiert hat, ist ihre fehlerhafte Nutzung dennoch immer möglich. Man muss sich also auch um eine Fehlerbehandlung kümmern.

In den guten alten C-Zeiten war es der Return-Code: Unterschiedlich negative Integer-Werte konnten als unterschiedliche Fehler interpretiert werden. Doch das wurde in vielen Sprachen durch ein Exception Handling abgelöst. Funktionen beziehungsweise Methoden können hier bei internen Fehlern ihre eigenen Exceptions werfen sowie auch die Exceptions genutzter Funktionen weiterreichen. Sie können aber auch Anweisungen in einem Try-Catch-Block einrahmen, um geworfene Ausnahmen intern aufzufangen und als eigene Exception wieder zu werfen. Derartige Ausnahmen kann man sich als spezielle, individuelle Fehlertypen mit eigenen Attributen für mehr Details vorstellen. Die wichtige Idee hinter diesem Mechanismus ist es, den fachlichen Block syntaktisch von der Fehlerbehandlung zu trennen. Verständlich, dass das heute der verbreitetste Weg ist, mit Fehlern umzugehen.

Der Weg in Go

Dann betrat Go die Bühne und mit sich führte es 1A-Exceptions. Oder doch nicht? Stattdessen verfügte es über den Typ error, eigentlich nicht mehr als ein Interface mit der Methode Error() string. Das Package errors und auch eine Funktion in fmt helfen, sie zu erzeugen. Doch können auch eigene Typen implementiert werden, die dieses Interface erfüllen und gleichzeitig mehr Informationen als nur einen Text mit sich führen können.

Das allein hilft noch nicht. Funktionen mit einem potenziellen Fehler können diesen als individuellen Rückgabewert oder als letzten in einer Reihe von Werten zurückgeben, ähnlich wie in C. Bei den Vätern von Go verwundert das nicht. Ist dieser Fehlerwert nun nil, dann war die Ausführung erfolgreich. Andernfalls drückt der Fehlerwert das interne Problem aus und der Aufruf kann hierauf reagieren. Das kann direkt geschehen, oder das Problem kann an den Aufrufer der eigenen Funktion weitergegeben werden, idealerweise mit ergänzenden Informationen. So gibt es heute diverse Bibliotheken, die das Annotieren von Fehlern mit eigenen Informationen unterstützen. Auch die Bemühungen in den golang.org/x Packages gehen in diese Richtung.

Mit diesem Verfahren rückt die Behandlung von Fehlern wieder näher an die Ausführung von Anweisungen heran und wird unmittelbarer. Oft geschieht das zusammen mit der Möglichkeit, eine Ausführung und die Überprüfung einer Bedingung gemeinsam in nur einer if-Anweisung auszuführen:

if b, err = json.Marshal(myData)); err != nil {
  return fmt.Errorf("cannot marshal data: %v", err)
}

Ein anderes oft gesehenes Muster ist die Erzeugung eines komplexeren Typs zur Weiterarbeit. Bei einem Fehler wird der eigene Kontext verlassen, bei Erfolg wird hingegen die saubere Beendigung mit einem defer auf den Ausführungsstapel gelegt und dann in Ruhe mit der Instanz gearbeitet. Unabhängig von einem erfolgreichen oder fehlerhaften Verlauf der Funktion wird so eine saubere Beendigung sichergestellt. Das sorgt für wenig verschachtelte und sehr lineare Abläufe:

file, err := os.Open("helloworld.txt")
if err != nil {
  log.Printf("error opening the hello world file: %v", err)
  return fmt.Errorf("cannot open file: %v", err) 
}
defer file.Close()

...

Wieso kommen nun die Go-Freunde so gut mit diesem Modell zurecht, im Gegensatz zu vielen Umsteigern aus anderen Sprachen mit Exceptions? Oft genug findet sich diese Frage in Diskussionen auf Google Groups, Slack, Reddit und weiteren Medien. Nun, geschätzte Eigenschaften von Go sind ihre unmittelbare Geradlinigkeit und ihre geringe Abstraktion. Komplexe Funktionalitäten im Design wurden ausgeschlossen, die Anzahl der Schlüsselworte beträgt nur 25. Das führt in der Folge natürlich zu so manchem für Wechselnde ungewohnten Aufwand. Die fragen sich dann, wieso es denn notwendig ist, wo doch seit Jahren mächtigere Wege bekannt sind.

Diese Lösungen verschleiern durch ihren Abstraktionsgrad auch den unmittelbaren Zusammenhang zwischen Ursache und Wirkung. Somit kann es schnell passieren, dass sich Wartung und Weiterentwicklung nur mit leistungsfähigen Entwicklungsumgebungen beherrschen lassen. Ebenso sind die Verursacher von Exceptions zur Laufzeit schwieriger zu finden. Sie treten irgendwo in Blöcken auf und werden hinterher nicht immer dediziert behandelt. Oder sie werden aus den Tiefen von Call-Stacks geworfen und irgendein catch darf sich weit oben darum kümmern.

Genau das wollten die Entwickler der Sprache Go nicht. Ihnen lag das stets klare Verständnis für Funktionsblöcke am Herzen. Dazu gehört eben die Behandlung von Fehlern in unmittelbarer Nähe zum Erzeuger. Ein Funktionsaufruf gibt also einen eventuellen Fehler als einziges oder als beiläufiges Ergebnis zurück und über die Abfrage auf nil entscheide ich, wie ich an Ort und Stelle mit dem Problem umgehe. Lässt es sich direkt beheben, dann ist das natürlich gut, doch ich kann den Fehler auch mit einer Anmerkung über den Kontext erweitern und an meinen Aufrufer zurückgeben. Allerdings zwingt mich die Sprache nicht dazu, ich kann den Fehler natürlich auch ignorieren oder unverändert zurückgeben. Das ist dann jedoch meine eigene Dummheit, ich komme meiner Verantwortung nicht nach.

Ideen für Go 2

Seit Version 1 fokussiert sich die Weiterentwicklung von Go auf die unterstützten Plattformen, auf Geschwindigkeit und Fehlerbereinigung, auf Tools und auf eine Verbesserung der Standardbibliothek. Der Sprachstandard bleibt hingegen stabil, so das Versprechen und Ziel der Entwickler. Für Go 2 werden dagegen auch neue Konstrukte besprochen, die auf den Erfahrungen der letzten Jahre, speziell mit der zunehmenden Größe und Komplexität der entwickelten Systeme, basieren. Hierzu gehören, neben den immer wieder angefragten Generics, auch erweiterte Lösungen für die Behandlung von Fehlersituationen.

Die wesentliche Idee des aktuellen Drafts trennt Prüfung und Behandlung eines Fehlers wieder etwas mehr, behält sie allerdings im gleichen Kontext. Sie erlaubt es, ähnlich wie Exceptions, eine Folge von Anweisungen durchführen zu können und eventuelle Fehler an nur einer zugehörigen Stelle zu behandeln. Innerhalb eines Blocks kann diese Kombination auch mehrfach auftreten, quasi wie mehrere Try-Catch-Blöcke in Folge.

Werfen wir hierzu noch einmal einen Blick auf die aktuelle Situation. Wir haben Anweisungen in der Form f, err := Foo(), gefolgt von einem if err != nil { ... }. Eine Funktion gibt also als Bestandteil eventuell einen Fehler zurück, mit einem if wird dieser geprüft — ein Check — und im Block wird er dann bei Bedarf gehandhabt – ein Handle. Genau das sollen die beiden Anweisungen check und handle zukünftig übernehmen, wie es Marcel van Lohuizen in seinem Draft Design beschreibt [1].

Die erste der Anweisungen macht sich die Konvention der Fehlervariable als letztem Rückgabewert zunutze. Das check vor einem Funktionsaufruf lässt nur diesen oder die anderen Rückgabewerte durch und fängt einen error ab. Der obige Aufruf verändert sich damit in ein f := check Foo(), wobei sich Foo() selbst mit seinen beiden Rückgabewerten nicht verändert. Ist die Fehlervariable nil, dann enthält f den zugewiesenen Wert und die Anweisung wird fortgesetzt, zum Beispiel mit einem b1, b2 := check Bar(). Doch was nun, wenn Foo() oder Bar() doch einen Fehlerwert ungleich nil zurückgeben? Dieser wird von check abgefangen und zu einem Handler geleitet. Hierbei handelt es sich um einen im gleichen oder höheren Block vor dem check-notierten handle-Block, zu dem der Fehler geleitet wird. Und so verändert sich das Beispiel zum Öffnen einer Datei, hier einmal um Varianten von Foo() und Bar() erweitert, wie in Listing 1.

// Step 1: Open file for foo and bar.
handle err {
  log.Printf("error working with the hello world file: %v", err)
  return fmt.Errorf("cannot work with file: %v", err) 
}

file := check os.Open("helloworld.txt")
defer file.Close()

b := check Bar(check Foo(file))

// Step 2: Do this and that.
handle err {
    ...
}

...

Schön ist hierbei zu sehen, wie sich check als Statement auch bei Funktionsaufrufen für Argumente einsetzen lässt. Bei aller Liebe zu Go ist das eine der Stellen, in denen das Konzept des Extrarückgabewerts für einen Fehler bisher unangenehm auffällt. Ebenso lassen sich Blöcke mit Handlern in unterschiedlichen Blöcken einsetzen, auch mehrere Handler aufeinanderfolgend in einem Block. So ist zum Beispiel ein erster Handler auf Funktionsebene denkbar, weitere hingegen in Schleifen oder Verzweigungen. Sie werden bei einem Fehler mit umgekehrter Reihenfolge von innen nach außen ausgeführt. check und handle machen den Code eleganter und dennoch können bei Bedarf Fehler weiterhin feingliedrig abgefangen werden, diese Stärke bleibt.

Ein umstrittener Versuch

Diese Form eines zukünftigen Error Handling in Go wird bis dato positiv angenommen und diskutiert. Natürlich gab und gibt es auch hierzu alternative Vorschläge, doch der Großteil der sich mit der Zukunft von Go befassenden Entwickler stimmt dem Entwurf zu. Aber es ist noch kein Beschluss. Auch aus dem Go-Team selbst werden noch weitere Ideen vorgestellt und diskutiert. So stellte Robert Griesemer im Mai einen Ansatz zur Erweiterung des Sprachkerns um die Funktion try() vor. Sie ist ähnlich dem catch mit einem Default-Handler versehen. Der wird aktiv, wenn eine Funktion über keinen eigenen Handler verfügt. In diesem Fall wird die aktuelle Funktion mit dem vom catch gefangenen Fehlerwert beendet.

Ebenso sollte try() arbeiten, allerdings ausschließlich so. Jegliche Variante mit einem Error Handling sollte hingegen so wie bisher mit if err != nil { ... } erfolgen. In Listing 2 würden Fehler bei den Aufrufen von Foo() und Bar() direkt zurückgegeben werden, der string-Wert wäre per Default die leere Zeichenkette. Da ich als Entwickler bei Combine() eine Extrafehlermeldung wünsche, muss ich ihn jedoch wie bisher abfragen.

func DoSomething() (string, error) {
  f := try(Foo())
  b := try(Bar())
  
  s, err := Combine(f, b)
  if err != nil {
    return "", fmt.Errorf("cannot combine %v and %v", f, b)
  }
  return s, nil
}

Ein echtes Handling der Fehler brachte dieser Entwurf nicht mit sich. Vielmehr wurde das Package fmt um die Funktion fmt.HandleErrof() erweitert. Das Konzept ist hier, zuerst eine Fehlervariable zu deklarieren, zum Beispiel als benannter Rückgabewert. Die neue Funktion lässt sich dann via defer fmt.HandlerError(&err, "I have an error") auf den Stapel der beim Verlassen ausgeführten Funktionen legen. Ist der Fehler dann ungleich nil, so wird er mit der definierten Meldung dekoriert. Andernfalls bleibt der Wert bei nil. Dieser Vorschlag wurde für eine kurze Zeit sehr intensiv und kontrovers diskutiert. Ob es das Wort try ist, die Definition als Funktion, der unausgegorene Ansatz, teils mit und teils ohne try zu arbeiten, oder das nicht zur Sprache passende Handling – die Community lehnte das alles überwiegend ab.

Die Zukunft

Die Diskussionen zeigten, dass trotz aller Fragen der Neueinsteiger die erfahrene Community sehr gut mit dem aktuellen, rudimentären Konzept arbeiten kann. Sie ist zufrieden und freut sich über die so erzwungene Transparenz und das leichtere Bugfixing im Falle von Fehlern, die bis zum Programmabbruch durchschlagen. Bilder von riesigen Call-Stacks im Falle von Java Exceptions sind vielen Go-Entwicklern bekannt.

Doch gleichzeitig wird erkannt, wie Änderungen in der Form von catch und handle oder Varianten davon helfen können. Hier, ebenso wie beim try(), zeigt sich, wie sehr sich der Go-Proposal-Prozess mit der offenen Diskussion der Vorschläge bewährt. Nun muss es beim zweiten großen Thema, den Generics, nur noch ebenso gut verlaufen. Bisher lässt es sich gut an.

Verwandte Themen:

Geschrieben von
Frank Müller
Frank Müller
Der Oldenburger Frank Müller ist seit über dreißig Jahren in der IT zu Hause und im Netz vielfach als @themue anzutreffen. Das Interesse an Googles Sprache Go begann 2009 und führte inzwischen zu einem Buch sowie mehreren Artikeln und Vorträgen zum Thema.
Kommentare

Hinterlasse einen Kommentar

avatar
4000
  Subscribe  
Benachrichtige mich zu: