Go geht an einigen Stellen recht eigene Wege und dies gilt insbesondere auch für den Umgang mit Fehlern, in dem sich Go sowohl von dem Konzept der Exceptions wie in Java, C++ oder PHP distanziert, als auch einen anderen Weg als C mit seinen Error-Codes, wie sie auch in C++ noch verwendet werden. Dabei scheint mir der Umgang mit Fehlern in Go durch drei Prinzipien geprägt zu sein:
- Fehler sind keine außergewöhnlichen Geschehnisse, sondern ein ganz normaler Vorgang in Softwareprogrammen, also eher die Regel als eine Ausnahme.
- Fehler sollten immer explizit behandelt werden. Ein implizites Durchreichen wie bei Exceptions gilt es zu vermeiden.
- Der Umgang mit Fehlern soll einfach sein und die sprachlichen Mittel nicht unnötig aufblähen.
Die beiden letzten Punkte sind sicherlich allgemeine Grundsätze, die das Design der Sprache Go prägen, und keine spezifischen Besonderheiten in Bezug auf Fehler.
Ich möchte hier ein paar Grundsätze für das Go-typische Error-Handling vorstellen und begründen, warum ich in vielen Punkten an Konzepten und Regeln festhalte, die uns aus der Verwendung von Exceptions bekannt sind. Meines Erachtens unterscheiden sich die Prinzipien eines effektiven Error-Handlings nicht grundlegend zwischen Go und Sprachen mit Exceptions.
Das Interface
Dem Grundkonzept nach braucht es für die Fehlerbehandlung in Go gar keine besonderen Sprachmittel. So lautet das native Interface:
type error interface {
Error() string
}
Die Standardimplementierung braucht dann auch nichts weiter als eine struct
, die dieses Interface implementiert. Alles, was dies zu einer nativen Implementierung macht, ist die Tatsache, dass das error-Interface ohne Import eines Packages zur Verfügung steht und in allen Packages verwendet werden kann, obwohl sie nicht explizit exportiert wurde. Die Funktionaltät könnten wir auch ohne dieses Built-In-Interface durch ein eigenes Interface erwerben.
Multiple Rückgabewerte
Mächtig wird das Konstrukt durch eine andere Eigentümlichkeit der Sprache: Multiple Rückgabewerte. So kann eine Funktion neben ihrem 'eigentlichen' Ergebnis noch ein zweites (oder mehr) weitere Ergebnisse haben und beispielsweise einen Fehler zurückgeben:
func (app *App) readConfig() (string, error) {
data, err := os.ReadFile(app.configFile)
if err != nil {
return "", err
}
...
}
Die Funktion ReadFile
aus dem Package os
gibt neben den gelesenen Daten einen error
zurück. Dieser ist nil
, wenn die Funktion fehlerfrei ausgeführt werden konnte, enthält aber einen entsprechenden Fehler, wenn etwa die Datei nicht existiert. Falls ein möglicherweise auftretender Fehler ignoriert werden soll, kann dies durch das Built-In _
erreicht werden:
func (app *App) readConfig() string {
data, _ := os.ReadFile(app.configFile)
...
}
Allerdings gilt es hier zu beachten, dass auftretende Fehler nicht bemerkt werden und entsprechend nicht behandelt werden können.
Bubbling
Ein oft beobachtbares Vorgehen ist das sogenannte Bubbling. Dabei werden Fehler aus einer aufgerufenen Funktion direkt in die nächsthöhere Funktion weitergereicht:
func doStuff() {
err := fnt()
if err != nil {
return err
}
}
Das kann über viele Stationen geschehen und macht explizit, was Exceptions in anderen Sprachen implizit machen: Es verlagert den Programmablauf aus einer tiefen Stelle im Programm an eine höhere Stelle, wo sinnvoll auf eine bestimmte Situation reagiert werden kann. Im Falle von Exceptions geschieht der Spring automatisch von dem throw
-Aufruf zum korrespondierenden catch
-Block.
panic() und recover()
Gänzlich unterschieden von error
ist das Konzept von panic(str string)
und recover()
, die eine völlig andere Art der Fehlerbehandlung einführen. Ein Aufruf von panic
führt zunächst zum sofortigen Absturz des Programms, insofern er nicht durch ein recover()
abgefangen wird. Dadurch sind Sprünge von einer Stelle des Programms zu einer ganz anderen Stelle ähnlich wie bei Exceptions möglich. Mein Eindruck ist, dass die korrekte Verwendung von panic
am ehesten der von Logic-Exceptions in Sprachen mit Exceptions ähnelt, die Nutzung von error
sich hingegen in vielen Dingen an dem Umgang mit Runtime-Exceptions orientieren sollte. Ein error
ist oft die sinnvollste Reaktion auf ungünstige Rahmenbedingungen, während panic
dort sinnvoll ist, wo eine Situation auf einen schwerwiegenden Programmierfehler hindeutet. Entsprechend sollte panic
nicht verwendet werden, um reparable Fehler zur Laufzeit zu behandeln. Man sieht immer wieder Versuche, ein 'eleganteres' Error-Handlung durch panic
-recover
-Kombinationen zu implementieren und teils auch Exceptions nachzuahmen. Hier gilt der Grundsatz: Don't be clever! Die Sprache Go nutzt dafür error
, nicht panic
!
Best Practices
Go ist so konzipiert, dass es Teams mit unterschiedlichem Erfahrungsstand helfen soll, gemeinsam gute, robuste Software mit hoher Qualität zu entwickeln. Etwas despektierlicher ließe sich auch sagen: Ein schlechter Entwickler soll in der Sprache möglichst wenig Schaden anrichten können. Dennoch verlangt Go von den Entwicklerinnen und Entwicklern, sich auf die Sprache einzustellen und idiomatische Programmierstile zu entwickeln (ich habe darauf bereits hier verwiesen). Es lassen sich eben doch suboptimale Ergebnisse erzielen, wenn die Eigentümlichkeiten der Sprache nicht hinreichend reflektiert werden.
Im Bereich des Umgangs mit Fehlern gibt es eine ganze Reihe von Caveats, die spätestens dann relevant werden, wenn das Projekt komplexer wird, im Produktivbetrieb Bugs auftreten oder neue Anforderungen eine Revision erfordern. Ein häufiges Problem in Go ist das Auftreten vieler, teils redundanter Logmeldungen mit geringem Informationsgehalt und ohne wirklichen Mehrwert:
2023-01-15 11:02:13.1261 timeout
2023-01-15 11:02:13.1265 sending failed
2023-01-15 11:02:14.2514 could not store data
Hier weiß niemand, in welchem Kontext das Timeout auftrat, ob es in einem Zusammenhang zu der Meldung "sending failed" steht und ob es vielleicht auch das "could not store data" verursacht hat. Um solche Situationen zu vermeiden, sollten wir einige Regeln beachten.
Ich halte die folgenden Grundsätze für entscheidend:
- Jeder Fehler wird an genau einem Ort behandelt. Vermeide partielle Fehlerbehandlungen.
- Vermeide missbräuchliche Verwendungen von
error
und beschränke die Verwendung auf Situationen, in denen eine Funktion ihre Aufgabe aufgrund ungünstiger Rahmenbedingungen nicht ausführen konnte. - Ignoriere (fast) keine Fehler, sonder behandle (fast) jeden Fehler explizit. Ausnahmen hierzu deuten auf ein schlechtes Design der Funktionen und Methoden hin.
- Wird ein Fehler aus einer untergeordneten Funktion an eine übergeordnete Funktion durchgereicht, sollen genau diejenigen relevanten Kontextinformationen hinzugefügt werden, über die die aufrufende Funktion nicht verfügt. Niemals sollten Informationen über Art und Inhalt eines Fehlers verloren gehen.
Jeder Fehler wird an genau einem Ort behandelt
Es gibt viele Situationen, in denen es nahe liegt, Fehler stückweise zu behandeln. Da wird dann erstmal eine Logmeldung erstellt und später wird entschieden, dass eine Operation wiederholt, ein Vorgang abgebrochen oder einfach weitergemacht wird. Ja, auch Logging oder die Entscheidung, einen Fehler zu ignorieren und den Vorgang dennoch fortzusetzen, sind Arten der Fehlerbehandlung, ebenso wie das Beenden des Programms, wenn ein konsistenter Zustand nicht mehr erreicht werden kann.
...
if err := fnt(param); err != nil {
log.Error(err.Error())
return err
}
...
Ein solches Vorgehen ist oft einfach umzusetzen und stellt keine Anforderungen an die Architektur der Anwendung, hat aber den Nachteil größerer Unübersichtlichkeit, aus der dann oft folgt, dass Fehlerbehandlung doppelt ausgeführt wird oder unabsichtlich unterbleibt. An dieser Stelle ist zu befürchten, dass an übergeordneter Stelle wiederum eine Log-Meldung verfasst wird, was zu Redundanzen und Log-Spam führt. Es ist daher besser, eine einzelne Stelle zu finden, an der auf den Fehler eingegangen wird, und den Fehler dann auch nicht weiterzureichen. Besser wäre es, den Fehler an die aufrufende Funktion weiterzureichen:
...
if err := fnt(param); err != nil {
return fmt.Errorf("failed doing XYZ with param %s: %w", param, err)
}
...
Anzumerken bleibt, dass die Anreicherung des Fehlers mit Informationen natürlich nicht als Fehlerbehandlung zählt, ebensowenig wie die Freigabe von Resourcen, die innerhalb der Funktion allokiert wurden – wozu aber besser auf ein defer
zurückgegriffen werden sollte.
Vermeide missbräuchliche Verwendungen von error
Wie oben beschriebenen baut Go auf der Prämisse auf, dass Fehler nichts außergewöhnliches sind, sondern im Ablauf eines Programms regelmäßg vorkommen. Deshalb gilt es auch als Anforderung, dass die Erstellung eines error
-Objekts mit geringen und feststehenden Kosten verbunden ist (siehe etwa hier; das unterscheidet error
in Go bspw. von Exceptions in C++, wo es keine Laufzeitgarantien zur Performance gibt). Auch wenn dies vermuten lässt, dass mit Fehlern in Go sehr freizügig umgegangen werden kann, möchte ich doch an einer klassischen Sichtweise festhalten: Ein Fehler ist etwas, was eine Ausnahme darstellen sollte. Er sagt: Ups, das hat leider nicht funktioniert. Er darf nicht dazu dienen, ein normales Ergebnis mitzuteilen. Um den Gedanken zu verdeutlichen, betrachte man die Deklaration einer SQL-Verbindungen:
type SQL interface {
Query(statement string) (*Result, error)
}
In welchem Fall wäre es vertretbar, einen error
zu nutzen? Unzweifelhaft wäre es in dem Fall korrekt, dass die Verbindung zur Datenbank abbricht. Ein solcher Fall ist nicht nur intuitiv ein Ausnahmefall, er führt auch dazu, dass der Zweck der Methode vereitelt wird. Manchmal nutzen Entwickler aber auch ein error
-Objekt, um mitzuteilen, dass das Ergebnis leer ist, weil keine Zeile der Datenbank die Bedingungen der Where-Clause erfüllt. In diesem Fall konnte die Methode aber einwandfrei ausgeführt werden, es liegt also gar kein Fehler vor. Hier dennoch einen error
zu verwenden, erschwert nachfolgenden Entwicklerinnen und Entwicklern das Verständnis des Programmablaufs und sollte daher vermieden werden.
Alternativ zu error
-Werten lassen sich in Go Booleans als Rückgabewerte nutzen, falls neben dem primären Ergebnis noch eine Information zu seinem Entstehen gewünscht ist. Beispiele dafür finden sich etwa bei Zugriffen auf map
: obj, ok := m[key]
. Hier zeigt der Wert der Variablen ok
(bool) an, ob ein Objekt in der Map vorhanden war oder erst erzeugt wurde. Es wäre irreführend, hier einen error
zu verwenden.
Ein weiterer Grund für die Bevorzugung von bool
über error
liegt im Laufzeitverhalten. Denn wenngleich auf die Fehlererstellung in Go schnell und mit fixen Kosten verbunden sein soll, gibt es doch erhebliche Unterschiede gegenüber der Verwendung von Booleans. In einem kurzen Benchmark habe ich beide Varianten einander gegenübergestellt. Dazu habe ich in jeweils einer Funktion einen Zufallswert (über rand.Uint32
) erstellt und geprüft, ob er durch 2 teilbar ist. In einem Fall gab ich einen boolschen Wert zurück in Abhängigkeit von der Teilbarkeit, im anderen Fall einen error
(wobei der error
einen einfachen String verwendete und nicht auf teure Funktionen wie fmt.Sprintf
zurückgriff). Außerdem zog ich noch das beliebte Substitut für natives Error-handling in Go heran: github.com/pkg/errors
. In einem vierten Szenario habe ich nur den Test auf Teilbarkeit laufen lassen, um die Grundkosten zu ermitteln. Das Ergebnis war folgendes:
Test | Iterationen | ø-Dauer pro Iteration | Differenz zur Basis |
---|---|---|---|
error |
21176719 | 50.22 ns/op | 37.54 ns |
boolen |
47823517 | 24.34 ns/op | 1.66 ns |
github.com/pkg/errors |
1756707 | 655.9 ns/op | 633.22 ns |
Vergleichbasis | 51298843 | 22.68 ns/op | – |
Die Vergleichsbasis dient hier dazu, zu ermitteln, wie lange die Erstellung des Zufallswertes, die Modulo-Operation etc. benötigen, die Differenz zeigt dann die durchschnittlichen Kosten für Funktionsaufruf und Rückgabe des Ergebnisses an. Das Ergebnis ist eindeutig, die Verwendung eines error
-Objekts braucht mit durchschnittlich knapp 38 Nanosekunden mehr als 20 mal so lange, wie die Verwendung eines boolschen Wertes. Im Falle des beliebten github.com/pkg/errors
haben wir eine Verschlechterung der Performance um den Faktor 380! Das wiederum liegt sicherlich daran, dass die error
-Objekte in diesem Paket im Interesse der Nutzerfreundlichkeit gleich noch einen Callstack bekommen, was allerdings die Kosten in Abhängigkeit zur Tiefe des Stacks steigen lässt. Damit sollte auch aus dieser Perspektive klar sein, dass Fehler nur in Ausnahmefällen genutzt werden sollten.
Ignoriere keine Fehler
Sobald wir uns den letzten Punkt verinnerlicht haben und die Verwendung von error
-Objekten auf die Fälle eines Fehlschlagens eines Funktionsaufrufs beschränken, ergibt sich die nächste Regel quasi von selbst: Ignoriere keine Fehler! In Sprachen mit Exceptions ist es nicht ganz so leicht, Fehler zu ignorieren, denn dazu muss eine Exception erstmal aktiv gefangen werden. In Go hingegen muss gar kein Code geschrieben werden, um einen Fehler einfach unbeachtet verschwinden zu lassen.
Es gibt mehrere mögliche Erwartungshaltungen, aus denen heraus Entwickler Fehler ignorieren:
- Ein Fehler kann an dieser Stelle gar nicht auftreten.
- Ein Fehler kann hier auftreten, ist aber nicht relevant oder lässt sich ohnehin nicht sinnvoll behandeln (das kommt v.a. während des Shutdowns vor).
Im ersten Fall müssen wir uns dann natürlich fragen, warum die aufgerufene Funktion einen error
in ihrer Signatur trägt, wenn er doch gar nicht auftreten kann. Im zweiten Fall sollten wir uns die Frage stellen, warum wir eine Funktion aufgerufen haben, wenn uns gar nicht interessiert, ob sie ihre Aufgabe erfüllen konnte.
Das wesentliche Problem daran ist, dass bei einer späteren Überarbeitung aufgrund geänderter Anforderung oder aufgetretener Bugs ein anderer Entwickler Schwierigkeiten haben wird, die Intention zu verstehen. Vor kurzem war ich selbst in einer Anwendung auf der Suche nach der Ursache eines Bugs auf eine Vielzahl ignorierter Fehler gestoßen. Nachdem ich mich zunächst entschied, diese Fehler zumindest im Log zu protokollieren, sah ich, dass einige davon temporär sehr oft vorkamen. Leider war es extrem schwer, in Einzelfällen zu entscheiden, ob das vielleicht die Ursache des Bugs war oder etwas, was der vorherige Entwickler einfach als irrelevant einplante. Wie sich herausstellte, waren die meisten Fehler bewusst ignoriert worden, was die Wartbarkeit des Source Codes massiv einschränkte. Denn wie soll ein anderer Entwickler zwischen relevanten und irrelevanten Fehlern unterscheiden? Eine solche Unterscheidung ist nur unter Berücksichtigung umfangreicher Kontextinformationen möglich: Wann wird diese Funktion aufgerufen? Welche Rahmenbedingungen werden zuvor an ganz anderer Stelle herbeigeführt? Was geschieht innerhalb der aufgerufenen Funktion? Gab es vielleicht eine Teamabsprache, in diesem Fall einen Absturz oder einen Datenverlust zu riskieren? Man sieht schnell, wie das Verständnis erschwert wird.
Oft deutet ein Ignorieren von Fehlern darauf hin, dass in der aufgerufenen Funktion das error
-Konstrukt missbraucht wird. Wenn möglich, sollte dann die aufgerufene Funktion überarbeitet oder zunächst das Vorliegen entsprechender Vorbedingungen geprüft werden. Das Ziel muss auch die Expressivität sein: Nachfolgende Entwicklerinnen und Entwickler müssen schnell erkennen können, wann ein Programmablauf korrekt funktioniert.
Reichere Fehler im Bubbling mit hilfreichen Informationen an
Wir kommen nun zu unserem Einstiegsszenario: Weniges ist dem Assignee eines Bugtickets unnützer als ein Kibana voller Logs mit dem Inhalt "timeout" ohne nähere Angabe, welche Operation denn abgebrochen weden musste, wodurch sie aufgerufen werden musste, mit welchen Parametern etc. pp. Meine Maxime heißt daher: Wird ein Fehler aus einer untergeordneten Funktion an eine übergeordnete Funktion durchgereicht, sollen genau diejenigen relevanten Kontextinformationen hinzugefügt werden, über die die aufrufende Funktion nicht verfügt.
Vielleicht wünscht man sich zunächst einen Callstack, um die Entstehung des Fehlers eruieren zu können. Wer mit entsprechenden Callstacks in PHP oder Java Erfahrung gesammelt hat und bereits versuchte, hilfreiche Informationen aus mehreren Metern Call-Stack-Beschreibung zu extrahieren, weiß auch um die Grenzen dieses Konzepts. Das Grundproblem ist, dass das Programm nicht automatisch die relevanten Informationen als solche erkennen und ausgeben kann.
Die Best Practice in Go geht daher einen anderen Weg, der die Qualität der Informationen verbessert: Statt automatisch den Callstack zu nutzen, wird der Fehler in jeder Funktion mit weiteren Informationen angereichert und an die aufrufende Funktion weitergegeben. Dazu wird der ursprüngliche Fehler in ein neues error
-Objekt eingebunden, etwa mit Hilfe der Funktion Errorf
aus dem Paket fmt
:
func DoStuff(param string) error {
...
if err := receiveSomething(calculatedValue); err != nil {
return fmt.Errorf("could not receive information with value %s: %w", calculatedValue, err)
}
...
}
Zwei Dinge sind in diesem Beispiel zu sehen:
- Dem weitergereichten
error
-Objekt wurdecalculatedValue
mitgegeben, aber nichtparam
. Das liegt daran, dass die aufrufende Funktionparam
kennt und ihr diese Information nicht gegeben werden muss. Andernfalls ist es wahrscheinlich, dassparam
von mehreren Funktionen in daserror
-Objekt geschrieben wird. - Obwohl
err
dasStringer
-Interface implementiert, wird inErrorf
nicht%s
verwendet, sondern%w
. Das hat zur Folge, dass der Fehler im neuen Fehler als Objekt erhalten bleibt und nicht nur seine Fehlermeldung Teil der neuen Fehlermeldung wird. Im Anschluss wird es möglich sein, mittels der MethodeUnwrap() error
auf den Ursprungsfehler zurückzugreifen.
Was natürlich generell vermieden werden sollte, ist ein Ignorieren des Fehlerursprungs:
...
res, err := fnt()
if err != nil {
return errors.New("operation failed, probably some network trouble")
}
...
Leider sieht man sowas nur allzu oft, im Zweifel kann es äußerst irreführend sein. Deshab ergänze ich das Prinzip der Informationsanreicherung durch den Grundsatz, dass Informationen niemals verworfen werden dürfen.
Wird dies eingehalten, dann ergeben sich informative Logmeldungen, die etwa so aussehen:
2023-01-15 11:02:13.1261 fetching config information failed: could not fetch ETCD information for key 'my-app': could not dial 162.123.154.2: connect timeout
Nun lässt sich mit einem Blick sehen, was passiert ist und warum es passiert ist. Genau das ist der Vorteil des expliziten Anreicherns von Informationen in Go, wenn wir uns an den oben aufgestellten Grundsatz halten.
Noch ein paar technische Details
Das waren die grundlegenden Prinzipien, die uns helfen sollten, besser mit auftretenden Fehlern umzugehen. Abschließend möchte ich noch auf ein paar technische Details eingehen, die dabei helfen.
Wrapping und eigene error
-Objekte
In dem Beispiel hatte ich fmt.Errorf
verwendet und %w
statt %s
oder %v
als Platzhalter für den Ursprungsfehler verwendet. Dies hat den Vorteil, dass nicht nur ein neuer error
erstellt, sondern der Ursprungsfehler in den neuen Fehler eingebunden wird. Mittels Unwrap()
kann dann aus dem neuen Fehler auf den alten Fehler zurückgegriffen werden. Seit Version 1.20 ist es auch möglich, mehrere Fehler durch errors.Join(errs ...error) error
in einen einzigen Fehler zu wrappen. Sicherlich war dies zuvor auch mit Standardmitteln möglich (und es gab auch verbreitete Packages, die dies ermöglichten). Hilfreich ist außerdem, dass Unwrap
nun auch ein error
-Slice zurückgeben kann und dies an den entsprechenden Stellen der Standardbibliothek berücksichtigt wird.
Wenn wir einen Fehler behandeln wollen, ist es oft wichtig zu wissen, um was für eine Art von Fehler es sich handelt. Schlägt ein HTTP-Request fehl und die Ursache ist ein Timeout, dann mag es vernünftig sein, den Request noch einmal zu senden. Lautet der Fehler jedoch, dass der Request selbst fehlerhaft sei (400 Bad Request
), dann kann auch ein erneuter Versuch keinen Erfolg bringen. Um solche Unterscheidungen zuverlässig treffen zu können, ist es in Sprachen mit Exceptions üblich, keine Exception-Typen zu definieren, die von nativen Exception-Typen abgeleitet werden (bspw. von std::runtime_exception
in C++). Viele Linter kreiden sogar die Verwendung nativer Exception-Typen als Code Smell an und fordern Entwicklerinnen und Entwickler zur Implementierung eigener konkreter Exceptions auf.
Go verfügt zwar nicht über das Konzept der Ableitung, aber wir hatten bereits gesehen, dass etwas nur das error
-Interface und damit die Methode Error() string
implementieren muss, um als Fehler in Go zu zählen. Wir können also unser Timeout einfach mit einem eigenen Fehlertypen versehen und anschließend das seit Go 1.13 vorhandene errors.As
verwenden, um einen Fehler auf einen konkreten Fehlertypen zu prüfen (oder auch errors.Is
zur Prüfung auf Gleichheit von Typ und Inhalt):
type Timeout struct {
msg string
}
func (t *Timeout) error {
return t.msg
}
func doStuff() error {
...
var cmp *Timeout
if errors.As(err, &cmp) {
// retry
...
} else {
return fmt.Errorf("operation %s failed: %w", param, err)
}
...
}
Dieses Vorgehen ist deutlich besser, als den Standard-error
aus errors.New
zu verwenden und dann die Fehlermeldung zu analysieren.
Nun kann es aber natürlich sein, dass unser Timeout-Objekt zwischendurch weitergleitet und dabei in ein anderes error
-Objekt eingebunden wurde, beispielsweise mit fmt.Errorf
. Die Funktion errors.As
schafft es hier, auch mit Ursprungsfehlern zu vergleichen, insofern diese über eine Methode Unwrap() error
(ab Version 1.20 auch Unwrap() []error
) zugänglich sind. Wird ein Fehler mit fmt.Errorf
aus einem anderen Fehler erstellt, dann hat der neue Fehler eine entsprechende Methode zum Zugriff auf den Ursprungsfehler und errors.As
kann auch mit dem Ursprung vergleichen.
Man beachte, dass bei Funktionen bei inkorrekter Verwendung Panics auslösen und daher dringend getestet werden müssen. Ansonsten kann es zum Absturz der gesamten Anwendung kommen, wenn tatsächlich mal ein (ansonsten vielleicht harmloser) Fehler auftritt.
Bei der Erstellung eigener error
-Typen gilt es daher, folgendes zu beachten: Ein error
-Type, der auf andere Fehler regiert, sollte in aller Regel eine Unwrap() error
-Methode haben, um Zugriffe auf den Urprungsfehler zu ermöglichen. In Fällen, in denen ein Zugriff auf den Ursprung explizit unerwünscht ist, sollte zumindest die Fehlermeldung Informationen über den Ursprungsfehler beinhalten. Dazu ist es oft hilfreich, das Formatter-Interface zu implementieren.
Zusammenfassung
Die Fehlerbehandlung in Go ist gewöhnungsbedürftig und oft auch etwas umständlich. Es gibt viele Situationen, in denen ich Exceptions ernsthaft vermisse und die Möglichkeiten von Go für unzureichend halte. Vielleicht ist es tatsächlich das nächste entscheidende Thema nach den Generics, an dem sich die weitere Entwicklung der Sprache entscheiden wird.
Der Grundsatz, Fehler nicht zu ignorieren, hat in Go zur Folge, dass unser Code sehr explizit und mitunter unleserlich werden kann:
data, err := fnt()
if err != nil {
...
}
if err = fnt2(data); err != nil {
....
}
...
Das möchte ich gar nicht beschönigen. Ein so explizites Vorgehen wird schnell unübersichtlich und lenkt von der eigentlichen Programmlogik ab. Es existieren inzwischen mehrere Vorschläge, Go mit einem sprachlichen Konstrukt (try
oder check
) auszustatten, das die Fehlerbehandlung weniger umständlich macht (siehe dazu auch die Sicht von Frank Müller). Bis sich eine Lösung findet, bleibt hier möglicherweise ein Manko der Sprache bestehen. Dem Entwickler hilft natürlich kein Jammern und daher gilt es, die vorhandenen Sprachmittel (z.B. Lambdas) zur Vereinfachung zu nutzen. Die Community kommt bisher schließlich durchaus gut ohne weitere sprachliche Mittel aus.
Die Einfachheit hat schließlich auch entscheidende Vorteile (und Exceptions haben erhebliche Nachteile), so dass es sich lohnt, sich einmal auf das Konzept einzulassen. Ohnehin bleibt einem als Go-Entwickler aktuell nichts anderes übrig, als das Beste aus der Situation zu machen. Im vorhergehenden glaube ich die Grundprinzipien beschrieben zu haben, an denen wir uns dafür orientieren sollten.