1 Punkte von GN⁺ 4 시간 전 | 1 Kommentare | Auf WhatsApp teilen
  • 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 überhaupt nil sein 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, nil nur im Konstruktor auszusortieren; Fehler müssen bereits am Initialisierungspunkt wie NewRedisClient(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 überhaupt nil sein 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 kann nil sein“ und vermitteln damit oft die falsche Bedeutung

Das Problem mit nil-Prüfungen bei Abhängigkeiten

  • Code, in dem ein RateLimiter ein Feld vom Typ *redis.Client hält und innerhalb von Allow r.redis != nil prüft, wirkt auf den ersten Blick sicher
  • Wenn der Redis-Client nil ist, ist das Problem jedoch nicht beim Ausführen von Allow entstanden, sondern bereits zum Zeitpunkt der Erzeugung
  • Wird nil in 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 nil unmöglich sein sollte, aus dem Blick verloren hat

Eine nil-Prüfung im Konstruktor allein reicht nicht

  • In NewRateLimiter(client *redis.Client) bei client == nil einen 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
  • 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 nil nicht 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 nil zu 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 == nil zu prüfen, ist derselbe Fehler wie eine nil-Prüfung bei Abhängigkeiten
  • Der Request ist nicht zuerst in Allow ins System gelangt, sondern bereits früher an einer Transportgrenze angekommen und dann durch den Code weitergereicht worden
  • Wenn eine interne Funktion wie Allow erneut 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 *Request umgewandelt werden
  • Im Beispiel eines HTTP-Handlers antwortet DecodeRequest(r) bei einem Fehler mit http.StatusBadRequest und kehrt zurück
  • Nach abgeschlossener Validierung ist req ein gültiger Wert, und h.limiter.Allow(r.Context(), req) kann diesem Wert vertrauen
  • Weil extern gelieferte Daten nicht kontrollierbar sind, ist es sinnvoll, an der Grenze auf nil und 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 Allow kann sich dann ohne nil-Prüfung auf die eigentliche Logik konzentrieren
    • userID := GetUserID(req)
    • Wenn userID == "", false, nil zurückgeben
    • Andernfalls r.checkLimit(ctx, userID) aufrufen
  • Die Prüfung auf leere userID kö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 modellieren
  • nil-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

 
GN⁺ 4 시간 전
Kommentare auf Lobste.rs
  • Noch einmal die Bitte an andere Go-Programmierer: Bitte wrappt Fehler

    redisClient, err := NewRedisClient(addr)  
    if err != nil {  
      return nil, fmt.Errorf("Couldn't obtain new RedisClient: %w", err)  
    }  
    

    Beim Abwickeln des Call Stacks sollte sich der Kontext zum Fehler ansammeln.

    • Ein idiomatischeres Beispiel sieht so aus:
      redisClient, err := NewRedisClient(addr)  
      if err != nil {  
        return nil, fmt.Errorf("NewRedisClient: %w", err)  
      }  
      
      Danach sollte jede Schicht nur ergänzen, wo der Fehler aufgetreten ist, während das innerste err sagt, was passiert ist.
    • Leider gibt es keinen einheitlichen, faktischen Standard für einen Stack Trace bei Fehlern.
      In der Praxis wird „Wrapping“ leicht zu einer Übung darin, Fehlerstrings mit grep zu durchsuchen, zu hoffen, dass der String eindeutig ist, und sich krampfhaft kreativ anzustrengen, um ihn eindeutig zu machen.
    • Manche beschweren sich, dass Fehler-Stacks zu lang seien, aber die meisten halten solche Meldungen für handlungsrelevant und nützlich.
      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.
    • Der heutige Ansatz geht, soweit ich mich erinnere, eher in Richtung errors.Join.
  • Ich denke, Go erzeugt hier zwei Probleme:

    1. Hätte Go explizite Nullability, würde dieses Problem fast vollständig verschwinden.
    2. Es scheint keine Möglichkeit zu geben, die Zero-Initialisierung benennbarer Typen zu verhindern, sodass sich Fehler jederzeit einschleichen können.
    • Dieser Satz aus dem Artikel bringt das Grundproblem gut auf den Punkt:
      Es geht um die Stelle: „Da man nicht kontrollieren kann, was man übergeben bekommt, ist es vernünftig, an dieser Grenze auf nil zu prüfen.“
      Für externe Eingaben stimmt das, aber wenn jeder Pointer nil sein 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.

    • Aus der Gegenposition betrachtet ist die Fähigkeit, „nicht vorhanden“ oder „fehlend“ knapp auszudrücken, besonders beim Umgang mit beliebigen Datenstrukturen wie JSON sehr nützlich.
      Syntax ist in Sprachen meist der weniger interessante Teil, aber in der bevorzugten Skriptsprache foo.bar.baz zu schreiben ist viel einfacher als Rusts foo.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.
    • Wenn man keine Pointer verwendet, gibt es auch kein null, hurra … 😭
  • Nach meinem Verständnis ist Go keine Sprache, die nicht-nullbare Objekte gut modelliert.
    In dieser Hinsicht ähnelt sie C: Option<T> kann als T* dargestellt werden, aber T* bedeutet nicht zwangsläufig Option<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++.

    • Eine nil-Dereferenzierung in Go führt deterministischer zu einem Panic als eine Null-Pointer-Dereferenzierung in C, ist aber trotzdem nicht so großartig, weil der Fehler erst auftritt, wenn der tatsächliche Pointer dereferenziert wird.
      Im Beispiel des Artikels würde es irgendwo tief in checkLimit krachen, 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 NewRateLimiter eindeutig ein Gewinn. Im Beispielcode würde man
      if client == nil {  
          return nil, errors.New("redis client is nil")  
      }  
      
      in
      if client == nil {  
          panic("redis client is nil")  
      }  
      
      ä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.
    • Null-Checks und assert sind meiner Meinung nach völlig verschieden.
      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.

    • Invarianten per assert abzusichern, ist großartig, wenn man von Anfang an so startet und es konsequent beibehält.
      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 danach if (error) { ... }.
    In Go ist Fehlerbehandlung kein First-Class-Feature, weshalb viele Funktionen vorsorglich ein (result, err)-Tupel zurückgeben, und weil Linter den Check err != nil de 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?

    • Ich bin kein Go-Fan, aber dieses Framing stört mich.
      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.