Übermäßige `nil`-Pointer-Prüfungen in Go
(konradreiche.com)nil-Prüfungen in Go können Panics verhindern, aber wenn sie an den falschen Stellen wiederholt werden, erklärt der Code nicht mehr selbst, „was überhauptnilsein kann“- Wenn zwingende Abhängigkeiten wie ein Redis-Client in internen Methoden geprüft werden, wird ein Fehler bei der Erzeugung so behandelt, als gehöre er zum normalen Ausführungspfad
- Es reicht nicht,
nilnur im Konstruktor auszusortieren; Fehler müssen bereits am Initialisierungspunkt wieNewRedisClient(addr)sofort behandelt werden - Werte, die von außen hereinkommen, wie Request-Objekte, sollten in Grenzschichten wie HTTP-Handlern, RPC-Dispatchern oder Queue-Consumern validiert werden; die interne Logik sollte dieser Garantie vertrauen
- Wenn Zustände, die unmöglich sein sollten, stillschweigend zugelassen werden, werden Fehler still, verzögert und mehrdeutig; später entstehen dann Kosten, um das verschwundene Signal mit Metriken, Dashboards und Alerts wiederherzustellen
nil-Prüfungen sind nicht immer defensive Programmierung
- Um Panics in Produktion zu verhindern, braucht es defensive Programmierung, die Eingaben, Bereiche und Pointer prüft, bevor ein
deferred recoverüberhaupt relevant wird nil-Prüfungen an der richtigen Stelle machen Code sicherer; Prüfungen an der falschen Stelle sind dagegen ein Signal dafür, dass sich nicht mehr nachverfolgen lässt, welche Werte überhauptnilsein können- Dieses Muster sieht man besonders oft in Erzeugungscode, aber es ist weder neu noch auf AI beschränkt
nil-Prüfungen wirken billig und sicher, hinterlassen für die nächste Person im Code aber die Botschaft „dieser Wert kannnilsein“ und vermitteln damit oft die falsche Bedeutung
Das Problem mit nil-Prüfungen bei Abhängigkeiten
- Code, in dem ein
RateLimiterein Feld vom Typ*redis.Clienthält und innerhalb vonAllowr.redis != nilprüft, wirkt auf den ersten Blick sicher - Wenn der Redis-Client
nilist, ist das Problem jedoch nicht beim Ausführen vonAllowentstanden, sondern bereits zum Zeitpunkt der Erzeugung - Wird
nilin einer internen Methode geprüft, wird stillschweigend so getan, als wäre ein Weiterlaufen nach fehlgeschlagener Erzeugung ein zulässiger Zustand - Solche Prüfungen sind ein Signal dafür, dass der Code die Herkunft des Objekts, die Verantwortung für die Initialisierung und die Invarianten, nach denen
nilunmöglich sein sollte, aus dem Blick verloren hat
Eine nil-Prüfung im Konstruktor allein reicht nicht
- In
NewRateLimiter(client *redis.Client)beiclient == nileinen Fehler zurückzugeben ist besser, aber keine vollständige Lösung - Dass ein
nil-Pointer überhaupt bis zu dieser Funktion durchgereicht wurde, bedeutet bereits, dass ein ungültiger Zustand ins System gelangt ist - Der eigentliche Fehler muss am Initialisierungspunkt behandelt werden, an dem der Redis-Client erzeugt wird
- Wenn in
redisClient, err := NewRedisClient(addr)ein Fehler auftritt, muss sofort zurückgegeben werden - Danach sollte an
NewRateLimiter(redisClient)nur noch ein gültiger Client übergeben werden
- Wenn in
- Dann entfällt auch die Notwendigkeit, dass der
RateLimiter-Konstruktor überhaupt einen Fehler zurückgibt - Wenn zugelassen werden muss, dass das Backend zeitweise nicht verfügbar ist, sollte man
nilnicht weiterreichen, sondern es in einen stets nicht-nilen äußeren Typ kapseln, der Retries oder Degradationsverhalten intern behandelt - Das ähnelt
NOT NULL- oder Fremdschlüssel-Constraints in Datenbanken- Wenn ungültige Zeilen gar nicht erst existieren können, muss nicht jede Query die Daten erneut prüfen
- Genauso kann übriger Code bei Laufzeitwerten wiederholte Prüfungen vermeiden, wenn die Invariante einmal hergestellt ist
Die Kosten stiller Fehler
- Es kann stabil wirken, bei kleinen Änderungen das Programm nicht zu stoppen und stattdessen nur
nilzu prüfen oder etwas zu loggen - Die eigentliche Wahl ist aber weniger „Crash oder weiterlaufen“ als laut scheitern oder leise scheitern
- Explizit zurückgegebene Fehler haben drei Eigenschaften
- Klarheit: Es ist erkennbar, dass ein Fehler aufgetreten ist
- Unmittelbarkeit: Der Fehler wird nahe an seiner Ursache sichtbar
- Zurechenbarkeit: Der Aufrufer kann den Fehler der betreffenden Operation zuordnen
- Verschluckte Fehler wirken genau entgegengesetzt
- Der Fehler verschwindet still
- Mehr Code läuft weiter, bevor sich später Symptome zeigen
- Wenn Symptome sichtbar werden, ist die Ursache nur noch schwer zu identifizieren
- Je mehr Aufrufe in einem ungültigen Zustand überleben, desto größer wird die Lücke zwischen Ursache und Symptom
- Die richtige Korrektur besteht nicht darin, Fehler lokal zu verstecken, sondern zu verstehen, wohin Fehler propagiert werden und wo sie in Request-Ablehnung, Job-Fehlschlag, Retry, Alert oder Beendigung umgewandelt werden
- Wenn das Zurückgeben eines Fehlers mehr stoppt, als das System eigentlich stoppen müsste, liegt das Problem nicht in der Funktion, sondern an der Grenze der Fehlerbehandlung
Die Folgekosten, ein verschwundenes Signal neu aufzubauen
- Wenn Fehler still werden, lässt sich nicht mehr erkennen, was tatsächlich passiert ist, und Bugs können sich verstecken
- Dann muss Beobachtungsinfrastruktur wie Metriken, Dashboards und Alerts aufgebaut werden, um die Abwesenheit von Verhalten überhaupt feststellen zu können
- Jedes Mal, wenn unmögliche oder unbehandelte Zustände zugelassen werden, zahlt man später Engineering-Kosten, um das verworfene Signal durch Beobachtbarkeit wiederherzustellen
Rollen von externen und internen Schichten
- Der Ort, an dem die Ausführung beginnt und externe Daten eintreffen, ist die äußere Schicht; tiefer liegender Code, den dieser Aufruf erreicht, bildet die interne Schicht
- Zu Beginn der Ausführung ist noch nichts garantiert, aber es wurde auch noch keine Arbeit verrichtet
- In der Initialisierung werden die Abhängigkeiten des Programms eingerichtet, und es muss entschieden werden, welche davon zwingend nötig sind und welche nur vorübergehend ausfallen dürfen
- Das Design sollte sich grundsätzlich zu immer verfügbaren Abhängigkeiten hin neigen und Abhängigkeiten minimieren, die unterwegs verschwinden können
Request-bezogene Daten sollten an der Grenze validiert werden
- Request-Objekte, Request-Felder und aus Requests abgeleitete Werte unterscheiden sich von festen Abhängigkeiten
- Requests kommen bei jedem Aufruf von außen herein, etwa über HTTP-Handler, RPC, Queues, Test-Helper oder andere Pakete
- Auch innerhalb von
RateLimiter.Allow(ctx, req)req == nilzu prüfen, ist derselbe Fehler wie einenil-Prüfung bei Abhängigkeiten - Der Request ist nicht zuerst in
Allowins System gelangt, sondern bereits früher an einer Transportgrenze angekommen und dann durch den Code weitergereicht worden - Wenn eine interne Funktion wie
Allowerneut validiert, prüft eine tiefe Funktion noch einmal etwas nach, das die äußere Schicht garantieren sollte, und die Unsicherheit breitet sich aus
Nach Grenzvalidierung vertraut interne Logik den Invarianten
- Die
nil-Prüfung gehört an den Grenzpunkt, an dem unvertrauenswürdige Bytes in einen internen Typ wie*Requestumgewandelt werden - Im Beispiel eines HTTP-Handlers antwortet
DecodeRequest(r)bei einem Fehler mithttp.StatusBadRequestund kehrt zurück - Nach abgeschlossener Validierung ist
reqein gültiger Wert, undh.limiter.Allow(r.Context(), req)kann diesem Wert vertrauen - Weil extern gelieferte Daten nicht kontrollierbar sind, ist es sinnvoll, an der Grenze auf
nilund weitere notwendige Constraints zu prüfen - Nachdem die Daten die Grenze passiert haben, werden sie auf interne Typen und Business-Logik abgebildet und sind ab dann eine Invariante des Systems
- Das abschließende
Allowkann sich dann ohnenil-Prüfung auf die eigentliche Logik konzentrierenuserID := GetUserID(req)- Wenn
userID == "",false, nilzurückgeben - Andernfalls
r.checkLimit(ctx, userID)aufrufen
- Die Prüfung auf leere
userIDkönnte ebenfalls in die HTTP-Schicht verschoben werden; im Beispiel bleibt sie aber beim Rate Limiter, damit dieser diese Policy besitzt
Wiederholte nil-Prüfungen erzeugen neue Verzweigungen und neues Verhalten
- Ein so aufgebautes System lässt sich leicht verstehen und leicht verändern
- In einem System ohne Invarianten werden dagegen überall Prüfungen ergänzt, und für jede Prüfung muss dann entschieden werden, was zu tun ist
- Jede
nil-Prüfung ist eine neue Verzweigung, und jede Verzweigung definiert Verhalten für einen Zustand neu, der eigentlich gar nicht existieren sollte nil-Prüfungen sind nützlich, um dokumentierte Grenzen durchzusetzen oder absichtlich optionale Zustände zu modellierennil-Prüfungen, die Zustände stillschweigend behandeln, die das Programm eigentlich als unmöglich betrachtet, sollten misstrauisch machen- Wenn
nil-Prüfungen überall auftauchen, ist eines von zwei Dingen der Fall- normaler Code, der unvertrauenswürdige Eingaben an Grenzen absichert
- ein Designproblem, weil die Codebasis keine Invarianten etabliert hat
- In einem System, in dem kein Parameter vertrauenswürdig ist, muss man vielleicht sofort Prüfungen ergänzen; die eigentliche Arbeit besteht aber darin, die Invariante herzustellen, die diese Prüfung momentan ersetzt, und sie in eine belastbare Garantie zu verwandeln
1 Kommentare
Kommentare auf Lobste.rs
Noch einmal die Bitte an andere Go-Programmierer: Bitte wrappt Fehler
Beim Abwickeln des Call Stacks sollte sich der Kontext zum Fehler ansammeln.
errsagt, was passiert ist.In der Praxis wird „Wrapping“ leicht zu einer Übung darin, Fehlerstrings mit
grepzu durchsuchen, zu hoffen, dass der String eindeutig ist, und sich krampfhaft kreativ anzustrengen, um ihn eindeutig zu machen.Früher hat in einem Networking-Produkt ein Engineer einen Monat damit verbracht, Hunderte Fehlermeldungen zu korrigieren, weil „What the f-ck?“ im Log für Endnutzer nicht hilfreich war.
Diese Meldungen mussten nützlich gemacht werden, und aus den oben genannten Gründen mussten auch Fehler-Stacks ergänzt werden.
Ich denke, Go erzeugt hier zwei Probleme:
Es geht um die Stelle: „Da man nicht kontrollieren kann, was man übergeben bekommt, ist es vernünftig, an dieser Grenze auf
nilzu prüfen.“Für externe Eingaben stimmt das, aber wenn jeder Pointer
nilsein kann, braucht es Schlussfolgerungen, um innerhalb der Codebase sichere Grenzen nachzuverfolgen.Das Problem von Go ist, dass diese Schlussfolgerung nicht der Compiler erledigt, sondern sie in die Köpfe aller Programmierer verlagert wird.
Rust hat
Option<T>, und C# hat nullable Typen.Im Jahr 2026 sollte man solche Probleme meiner Meinung nach nicht mehr haben müssen.
Syntax ist in Sprachen meist der weniger interessante Teil, aber in der bevorzugten Skriptsprache
foo.bar.bazzu schreiben ist viel einfacher als Rustsfoo.unwrap().bar.unwrap().baz.Das sage ich, obwohl ich Rust mag; auch wenn Go und Rust oft in einen Topf geworfen werden, ist Go viel näher an einer von C-Programmierern neu erfundenen Skriptsprache.
Wenn eine Sprache dennoch null verwendet, ist nicht-null als Default besser. Besonders wenn es kurze Syntax wie
?oder.?gibt, ist die syntaktische Last in großen Projekten vertretbar.Nach meinem Verständnis ist Go keine Sprache, die nicht-nullbare Objekte gut modelliert.
In dieser Hinsicht ähnelt sie C:
Option<T>kann alsT*dargestellt werden, aberT*bedeutet nicht zwangsläufigOption<T>.Insgesamt stimme ich dem Artikel zu. Als ich bei einer Embedded-Firmware-Firma arbeitete, habe ich auch dafür geworben, im C++-Code nicht überall Null-Checks zu schreiben, sondern assert zu verwenden.
Asserts sind leichter zu debuggen, erscheinen aus Coverage-Sicht nicht als Branches und vermitteln Lesern die erwarteten Bedingungen klar. In Release-Builds werden sie entfernt und sind dadurch auch effizienter.
Allerdings liefert in Go eine nil-Dereferenzierung bereits gute Debugging-Informationen, daher ist der Vorteil von assert dort meines Verständnisses nach nicht so groß wie in C++.
Im Beispiel des Artikels würde es irgendwo tief in
checkLimitkrachen, und von dort müsste man die Herkunft von nil zurückverfolgen. Je nach System oder Architektur kann das ziemlich komplex sein.Deshalb ist ein assert direkt in
NewRateLimitereindeutig ein Gewinn. Im Beispielcode würde man in ändern.Allerdings ist das Go-Team stark gegen Assertions, und panic ist ebenfalls nicht ideal, weil es, wenn es nicht abgefangen wird, die gesamte Runtime zum Absturz bringt.
assert bedeutet: „Dieser Zustand ist ungültig“, und ein assert-Makro kann diesen Null-Check in Release-Builds zu einem No-op machen.
Je nach Definition des assert-Makros können Optimierungen im Zusammenhang mit undefiniertem Verhalten stattfinden, wodurch spätere Checks entfernt werden und es zu verwirrenden Crashes kommt.
Ich habe zum Beispiel schon eine assert-Definition gesehen, bei der in
assert(p); if (!p) { ... }der nachfolgende Check entfernt wurde.Pauschal zu sagen „mach keine Null-Checks, nutze assert“ kann für Zustandsinvarianten passen, aber nicht für Fehlerprüfungen.
Im Fazit steht ein guter Rat
Wenn überall
nil-Checks auftauchen, ist es eines von zwei Dingen: entweder normaler Code, der sich gegen nicht vertrauenswürdige Eingaben an Grenzen absichert, oder ein Designproblem, bei dem die Codebasis keine Invarianten etabliert hat.In einem System, in dem keinem Parameter vertraut werden kann, besteht die Lösung nicht darin, noch mehr Checks hinzuzufügen. Kurzfristig mag das nötig sein, aber die eigentliche Arbeit besteht darin, die Invarianten aufzubauen, die diese Checks ersetzen, und das aus Angst entstandene Rauschen nach und nach in Garantien zu verwandeln, auf die sich das System verlassen kann.
Ich denke, das geht über nil-Checks hinaus. Checks oder defensiven Code an den „Blättern“ eines Systems hinzuzufügen, ist oft ein Versuch, Symptome fehlender oder nicht richtig erzwungener Invarianten zu behandeln.
„Noch einen Check hinzufügen“ ist als Default leicht naheliegend, skaliert aber nur begrenzt. Irgendwann gibt es mehr Check-Logik als Funktionslogik, und die Gesamtkomplexität gerät außer Kontrolle.
Zusätzliche Checks, um ein oder zwei Bugs zu verhindern, sind normalerweise nicht schädlich. Wenn man aber das Gefühl hat, dass Anzahl und Komplexität der Checks zu stark wachsen, war es langfristig für das System und das Leben der Wartenden besser, einen Schritt zurückzutreten und die Grundursache zu suchen, statt immer weiter nur die Blätter zu reparieren.
Schwieriger ist allerdings, Entwicklerinnen und Entwickler darauf zu trainieren, mit defensiver Programmierung aufzuhören.
Solche Invarianten, hier etwa Nicht-Nullbarkeit, lassen sich in Typsystemen, die ausdrucksstärker sind als das von Go, deutlich besser modellieren.
Mein Lieblingstext zu diesem Thema ist Alexis Kings Artikel von 2019, Parse, don't validate.
Das Prinzip ist überall anwendbar, aber in Haskells Typsystem wirkt es wirklich einfach. Ich habe einige Jahre versucht, Alexis’ Rat in TypeScript zu befolgen, aber leicht war das nicht.
Kurz gesagt: Das Problem ist nicht, dass es zu viele Checks gibt, sondern dass nil in einen Wert verpackt wird.
Dieses Problem kam immer wieder auf, und ich sehe es als Folge einer Sprache, in der Fehlerbehandlung kein First-Class-Feature ist.
Wie, wenn ich mich richtig erinnere, auch in einem anderen Thread erwähnt wurde, erzwingen de facto Standard-Linter solche Strukturen.
Ob diese nil-Checks logisch schlecht sind, weiß ich nicht. Viele Sprachen haben Fehlerbehandlung eingebaut, und der Unterschied liegt eher in der Konsistenz und Einfachheit der Weitergabe.
Für ein Interface, das Fehler zurückgeben kann, gibt es grob vier Optionen: behandeln und wiederherstellen, ignorieren, den Fehler weitergeben, oder den Fehler verwerfen und einen eigenen Fehler weitergeben; Letzteres kann den bestehenden Fehler auch wrappen.
Sprachen, in denen Fehlerbehandlung ein First-Class-Feature ist, machen normalerweise 2 und 3 einfach, und je moderner die Sprache, desto eher gilt das. Dadurch kann auch 4 je nach Sprache ziemlich sauber werden.
Bei 1 kann auch First-Class-Unterstützung nicht viel helfen, außer deutlicher zu machen, dass eine solche Behandlung nötig ist.
Im Kern gilt: Wenn eine Funktion einen Fehler liefern kann, machen alle Sprachen, unabhängig von der Implementierung, sinngemäß
{error,result} = functioncall()und danachif (error) { ... }.In Go ist Fehlerbehandlung kein First-Class-Feature, weshalb viele Funktionen vorsorglich ein
(result, err)-Tupel zurückgeben, und weil Linter den Checkerr != nilde facto erzwingen, entsteht der Eindruck, dass der Code mit diesem Muster vollgestopft ist.Dass korrekte Fehlerbehandlung nicht direkt von der Sprache behandelt wird, sehe ich als Designfehler der Sprache. Wenn man aber einmal an dieser Stelle steht, wirkt dieses Modell vermutlich fast wie die bestmögliche Lösung.
Ich weiß nicht genau, ob Go-Code idiomatisch optionale Rückgabetypen verwendet, um funktional ignorierbare Fehler von Fehlern zu unterscheiden, „um die man sich kümmern muss“. Wenn es auch in solchen Fällen idiomatisch ist, immer einen Fehlertyp zurückzugeben, wird der Linter wohl stets dieses Muster erzwingen.
Ich hasse Go nicht; ich stimme nur einer Designentscheidung nicht zu. Über Designentscheidungen fast jeder Sprache kann man sich beschweren.
Gos größter Fehler ist aus meiner Sicht, dass explizite
err != nil-Checks praktisch überall funktional erforderlich sind und Linter sie deshalb ebenfalls verlangen.Schon als Go erstmals erschien, haben Hunderte darauf hingewiesen, wie lächerlich diese ganze Struktur ist.
Aber die Sprache wurde sehr populär, und in der Atmosphäre, Rob Pike wisse es besser, wurde die Kritik beiseitegewischt.
Es ist schön zu sehen, dass die Leute das jetzt endlich mit logischen Argumenten vernünftig diskutieren.
Es ist ja nicht so, als wäre das nicht schon seit Jahrzehnten als schlechte Idee bekannt gewesen, aber wenn Google es macht, wird es schon gut sein … oder?
Wenn man etwas als „lächerlichen Bullshit“ bezeichnet, unterdrückt man leicht genau das logische Denken, von dem man angeblich mehr sehen will.
Ich weiß nicht mehr, in welchem Oxide-Podcast es war, aber Bryan Cantrill sagte einmal sinngemäß: „Ich möchte das studieren, um es besser hassen zu können.“
In diesem Sinne möchte ich verstehen, warum die Leute in den 2010ern so begeistert von Go waren. Ein Teil war sicher Hype, und ich habe damals am Arbeitsplatz selbst erlebt, wie Entwickler begeistert waren, ohne erklären zu können, was daran gut sei.
Aber es wird nicht nur reiner Hype gewesen sein. Ich frage mich, was damals das stärkste Steel-Man-Argument dafür war, Go einzusetzen.