32 Punkte von GN⁺ 2025-12-07 | 1 Kommentare | Auf WhatsApp teilen
  • Es werden Coding-Gewohnheiten vorgestellt, die Rusts Typsystem und Compiler aktiv nutzen, um Bugs im Vorfeld zu verhindern
  • Anhand von Beispielen für anfällige Code Smells wie Vektor-Indizierung, übermäßige Nutzung von Default, unvollständige match-Anweisungen und unnötige boolesche Parameter werden Alternativen erläutert
  • Das zentrale Prinzip ist, Strukturen so zu entwerfen, dass der Compiler Invarianten erzwingt, etwa mithilfe von Pattern Matching, privaten Feldern und dem Attribut #[must_use]
  • Es werden konkrete defensive Techniken auf Code-Ebene vorgestellt, darunter die Verwendung von TryFrom, vollständiges Destrukturieren von Structs, temporäre Mutabilität und Validierung im Konstruktor
  • Solche Muster sind essenziell, um bei Refactorings Stabilität zu sichern und die langfristige Wartbarkeit zu verbessern

Überblick über defensives Programmieren

  • Stellen mit Kommentaren wie // this should never happen sind Punkte, an denen implizite Invarianten verletzt werden
    • In den meisten Fällen hat der Entwickler nicht alle Randfälle oder künftigen Codeänderungen berücksichtigt
  • Der Rust-Compiler garantiert Speichersicherheit, aber Fehler in der Business-Logik sind weiterhin möglich
  • Kleine gewohnheitsmäßige Muster (Idioms) aus langjähriger Praxiserfahrung können die Codequalität deutlich verbessern

Code Smell: Vektor-Indizierung

  • Die Form if !vec.is_empty() { let x = &vec[0]; } birgt ein Risiko für Runtime-Panics, weil Längenprüfung und Indizierung getrennt sind
  • Verwendet man Slice-Pattern-Matching (match vec.as_slice()), kann der Compiler alle Zustände zur Prüfung erzwingen
    • Leere Vektoren, einzelne Elemente, doppelte Elemente und andere Fälle lassen sich explizit behandeln
  • Das ist ein typisches Beispiel für Design, bei dem der Compiler Invarianten garantiert

Code Smell: unbedachter Einsatz von Default

  • ..Default::default() führt zu Problemen wie dem Risiko ausgelassener Initialisierung bei neuen Feldern und impliziten Wertzuweisungen
  • Wenn alle Felder explizit initialisiert werden, erzwingt der Compiler auch das Setzen neuer Felder
  • Mit Formen wie let Foo { field1, field2, .. } = Foo::default(); kann man eine Default-Struktur destrukturieren und anschließend gezielt überschreiben
    • So entsteht ein Gleichgewicht zwischen Standardwerten und expliziten Overrides

Code Smell: fragile Trait-Implementierungen

  • Wenn Struct-Felder für Vergleiche vollständig destrukturiert werden, führt das Hinzufügen neuer Felder zu Compiler-Fehlern als Warnsignal
    • Beispiel: In einer PartialEq-Implementierung let Self { size, toppings, .. } = self;
  • Kommt ein neues Feld wie extra_cheese hinzu, wird eine erneute Prüfung der Vergleichslogik erzwungen
  • Dasselbe Prinzip lässt sich auch auf andere Traits wie Hash, Debug und Clone anwenden

Code Smell: TryFrom statt From erforderlich

  • Wenn eine Konvertierung nicht immer erfolgreich sein kann, sollte statt From explizit TryFrom verwendet werden, um die Möglichkeit eines Fehlers sichtbar zu machen
  • Die Verwendung von unwrap_or_else ist ein Signal dafür, dass potenzielles Scheitern verborgen wird; ein früher Abbruch (fail fast) ist sicherer

Code Smell: unvollständiges match

  • Catch-all-Patterns wie _ => {} bergen das Risiko ausgelassener Fälle, wenn neue Variants hinzukommen
  • Werden alle Variants explizit aufgelistet, warnt der Compiler bei fehlender Behandlung neuer Fälle
  • Dieselbe Logik kann in Formen wie Variant3 | Variant4 gruppiert werden

Code Smell: übermäßige Verwendung des Platzhalters _

  • Wenn nur _ verwendet wird, ist unklar, welche Variable ausgelassen wurde
  • Mit expliziten Namen wie has_fuel: _, has_crew: _ verbessert sich die Lesbarkeit

Pattern: temporäre Mutabilität (Temporary Mutability)

  • Wenn Daten nur während der Initialisierung mutierbar sein sollen, kann man Formen wie let mut data = ...; data.sort(); let data = data; verwenden
  • Mit Block-Scopes lässt sich verhindern, dass temporäre Variablen nach außen sichtbar werden
    • Beispiel: let data = { let mut d = get_vec(); d.sort(); d };
  • In Initialisierungsabläufen mit mehreren temporären Variablen ist so eine klare Abgrenzung der Gültigkeitsbereiche möglich

Pattern: Konstruktorvalidierung erzwingen

  • Beim Erzeugen eines Structs sollte zwingend Validierungslogik durchlaufen werden
    • Mit einem Feld wie _private: () wird direkte Konstruktion von außen verhindert
    • Das Attribut #[non_exhaustive] verhindert Konstruktion außerhalb des Crates und signalisiert zukünftige Erweiterbarkeit
  • Wenn dies auch innerhalb interner Module erzwungen werden soll, kann eine verschachtelte Modulstruktur mit einem privaten Typ (Seal) verwendet werden
    • Da Seal nur intern existiert, ist direkte Konstruktion außerhalb von new() nicht möglich
  • Werden Felder privat gehalten und Getter bereitgestellt, bleibt der unveränderliche Zustand gewahrt
  • Kriterien für die Anwendung
    • Externen Code blockieren: _private oder #[non_exhaustive]
    • Internen Code blockieren: privates Modul + Seal
    • Validierungslogik in Garantien auf Compiler-Ebene überführen

Pattern: Verwendung des Attributs #[must_use]

  • #[must_use] verhindert, dass wichtige Rückgabewerte ignoriert werden
    • Beispiel: #[must_use = "Configuration must be applied to take effect"]
  • Ignoriert ein Nutzer den Rückgabewert, gibt der Compiler eine Warnung aus
  • Das ist ein einfaches, aber starkes defensives Mittel, das auch in der Standardbibliothek, etwa bei Result, breit eingesetzt wird

Code Smell: boolesche Parameter

  • Formen wie fn process_data(..., compress: bool, encrypt: bool, validate: bool) sind semantisch unklar und fehleranfällig durch vertauschte Reihenfolge
  • Mit enum Compression, enum Encryption usw. lässt sich die Absicht explizit ausdrücken
  • Bei mehreren Optionen sollte ein Parameter-Struct (Params struct) verwendet werden
    • Mit Methoden wie ProcessDataParams::production() lässt sich die Wiederverwendbarkeit durch Voreinstellungen verbessern
  • Beim Hinzufügen neuer Optionen bleibt der Einfluss auf bestehende Aufrufstellen minimal

Automatisierung mit Clippy-Lints

  • Wichtige defensive Muster lassen sich automatisch mit Clippy-Lints prüfen
    • indexing_slicing: direkte Indizierung verbieten
    • fallible_impl_from: TryFrom statt From empfehlen
    • wildcard_enum_match_arm: _-Pattern verbieten
    • fn_params_excessive_bools: vor zu vielen booleschen Parametern warnen
    • must_use_candidate: Kandidaten für #[must_use] vorschlagen
  • Über #![deny(clippy::...)] oder Konfiguration in Cargo.toml lässt sich dies projektweit anwenden

Fazit

  • Der Kern defensiven Programmierens in Rust besteht darin, mithilfe von Typsystem und Compiler Invarianten explizit und überprüfbar zu machen
  • Diese Muster tragen dazu bei, bei Refactorings Stabilität zu sichern, die Wahrscheinlichkeit von Bugs zu minimieren und die langfristige Wartbarkeit zu stärken
  • Es ist ein Ansatz, der das Prinzip verwirklicht: „Der beste Bug ist der, der gar nicht erst kompiliert.“

1 Kommentare

 
GN⁺ 2025-12-07
Hacker-News-Kommentare
  • Der Artikel war gut. Allerdings wirkt das PizzaOrder-Beispiel so, als würden zu viele Belange in eine einzige struct gepackt.
    Wenn das Ziel ist, ordered_at beim Vergleich auszuschließen, halte ich es für besser, PizzaDetails und PizzaOrder in zwei structs aufzuteilen.
    So kann man bei der Implementierung von PartialEq klar festlegen, dass nur details verglichen wird.

    • Guter Punkt. Trotzdem halte ich das logisch weiterhin für falsches Modellieren.
      Wenn sich die Bestellzeit unterscheidet, ist es nicht dieselbe Bestellung; das auf Typebene als gleich zu definieren, ist riskant.
      PartialEq auf PizzaDetails zu haben, ist okay, aber die Logik zum Vergleichen von Bestellungen sollte besser in einer separaten Business-Funktion liegen.
    • Der Ansatz, die Struktur aufzuteilen, ist gut, aber das Problem ist, dass Änderungen an PizzaDetails die Logik zur Erkennung doppelter Pizzen beeinflussen können.
      Idealerweise sollte eine struct nur zum Bündeln von Daten verwendet werden.
      Um zu vermeiden, dass Änderungen andere Stellen beeinflussen, könnte man auch einen separaten Typ wie PizzaComparator oder PizzaFlavor in Betracht ziehen.
      Es wäre schön, wenn man wie bei Protobuf Feldannotationen wie {important_to_flavour=true} an Feldern anbringen könnte.
    • Eine Struktur nur für unterschiedliche Vergleichsweisen aufzuteilen, lässt sich nicht verallgemeinern.
      Wie würde man sie zum Beispiel aufteilen, wenn man Strings ohne Beachtung von Groß- und Kleinschreibung vergleichen will?
  • Das wirklich Tolle an Rust ist, dass defensives Programmieren oft gar nicht nötig ist.
    Dank Ownership und Borrowing-Regeln kann garantiert werden, dass der Zugriff auf ein bestimmtes Objekt im gesamten Programm eindeutig ist.
    Referenzen können nicht null sein, und Smart Pointer können ebenfalls nicht null sein.
    Das Typsystem garantiert auch, dass nach der Übergabe des Ownership von self keine Methodenaufrufe mehr möglich sind.
    Dadurch werden Thread-Sicherheit, Lifetimes, Klonbarkeit usw. global zur Compile-Zeit verifiziert.

    • Ich denke auch, dass Rusts eigentliche Stärke in den Dingen liegt, um die man sich nicht kümmern muss.
      In anderen Sprachen bekommt man die Vorteile nur, wenn man mit funktionalem Stil Unveränderlichkeit beibehält; Rust erzwingt das über das Typsystem.
    • Dieser Kommentar scheint aber nichts mit dem ursprünglichen Artikel zu tun zu haben.
      Thema des Artikels waren logische Bugs, die selbst der Borrow Checker nicht erkennt.
    • Im Artikel ging es vor allem um Coding-Patterns, die logische Fehler vermeiden, wenn man ein Programm wiederholt verbessert.
  • Es scheint klug zu sein, direkte Indizierung in Arrays oder Vektoren zu vermeiden.
    Am Tag des Cloudflare-unwrap-Vorfalls habe ich selbst einen Bug gefunden, bei dem ein Slice über das Ende eines Vektors hinausging.
    Seitdem bin ich auf einen iteratorbasierten Ansatz umgestiegen und fühle mich damit deutlich sicherer.

    • Ich glaube nicht, dass man den unwrap-Vorfall als „Vorfall“ bezeichnen muss.
      Rusts unwrap ist dasselbe wie assert in C. Beim Fehlschlag meldet es nur, dass es ein Problem gibt.
      Auch in Rust kann man weiterhin Bugs schreiben.
    • Am Ende ist es dasselbe Problem. Im Rust-Lager wird zwar gefordert, C aufzugeben, aber auch in C ist es üblich, statt Indizes Handles zu verwenden.
  • Eine Gewohnheit, gegen die Rust-Entwickler sich wehren sollten, ist das Hinzufügen unnötiger crate-Abhängigkeiten.
    Rust neigt dazu, so eine Gewohnheit zu fördern. Dass im Rust Book zum Beispiel das rand-crate als Standardbeispiel verwendet wird, erzeugt genau diese Stimmung.
    Natürlich war das eine strategische Entscheidung, um kryptografiebezogene Pakete leicht austauschbar zu machen, aber dass sich daraus eine Gewohnheit entwickelt, bleibt dennoch problematisch.

    • Wegen dieses Beispiels hatte ich anfangs auch eine Abneigung gegen Rust.
      Später habe ich die Absicht dahinter aber verstanden und meine Meinung geändert.
  • Die Implementierung partieller Gleichheit fand ich interessant.
    Eine weitere Sache, die mich interessiert, ist die Verwendung von enum, um boolesche Parameter zu vermeiden.
    Ich verwende structs, die einen bool kapseln, aber schade finde ich, dass man sie nicht wie normale bools behandeln kann.
    Ich frage mich, ob es eine Möglichkeit gibt, enum wie bool zu verwenden.

    • Ich bevorzuge auch fast immer enum + match!.
      Die nötige Logik kann man in einem Trait bündeln oder gemeinsame Methoden in einen impl <Enum>-Block legen.
      Das verbessert die Lesbarkeit und erlaubt es, das Verhalten für jedes Mitglied klar zu definieren.
    • Man könnte etwas wie impl Deref ausprobieren, aber ich bin mir nicht sicher, ob das eine gute Idee ist.
  • Die match-Anweisung im ersten Beispiel wirkt übertrieben.
    Vec.first() oder Vec.iter().nth(0) sind klarer und passender zur Absicht.

    • Da stimme ich zu. Wenn man match verwendet, wird die Lösung komplizierter als das Problem.
      Wenn man if entfernen kann, kann man auch match entfernen; in Bezug auf Sicherheit gibt es keinen Unterschied.
      first() ist viel knapper und klarer.
    • Um dasselbe Verhalten einfacher auszudrücken, könnte man auch itertools' exactly_one verwenden.
    • Allerdings hat match insofern einen Sinn, als es dazu drängt, auch den Fall „mehr als ein Element“ zu behandeln.
      Es macht also das Prinzip sichtbar, Prüfung und davon abhängigen Code nicht zu trennen.
  • Immer wenn ich solche Artikel lese, frage ich mich, warum es kein dediziertes Team zur Überwachung von Code-Patterns gibt.
    Wie bei SOC oder QA wäre ein Team sinnvoll, das langfristig die Patterns in einer Codebasis beobachtet.
    Automatisierte Tools zur Erkennung von Code Smells haben Grenzen.

    • In unserem Unternehmen (etwa 300 Mitarbeitende) gibt es tatsächlich ein Team für technische Schulden, das so eine Rolle übernimmt.
      Es kümmert sich um die Verwaltung von Lint-Regeln, Dokumentation, Entwickler-Schulungen und die Wartung gemeinsamer Bibliotheken.
      Wenn mehrere Teams dasselbe Problem wiederholen, entwirft es eine zentrale API, mit der sich das vereinheitlichen lässt.
    • In großen Tech-Unternehmen gibt es solche Teams meistens.
      Die Realität ist allerdings, dass die Verwaltung extrem schwierig wird, sobald der Code aus Millionen Zeilen besteht.
  • Ich überlege, wie man solche guten Coding-Patterns im Team fördern kann.
    In Code-Reviews schlägt das oft in Stildebatten um und wird unproduktiv.
    Interessanterweise verschwinden diese Debatten fast völlig, sobald ein Linter eine Warnung ausgibt.

  • Dass das TryFrom-Trait in Version 1.34 hinzugefügt wurde, war wirklich nützlich.
    Code, der unwrap_or_else() verwendet, ist wahrscheinlich ein Überbleibsel aus der Zeit davor.
    Die Dokumentation zum From-Trait erklärt inzwischen sehr klar, wann man es implementieren sollte.

    • Ich lerne Rust noch, aber der Name unwrap_or_else() klingt für mich lustig, fast so, als würde man dem Computer auf drohende Weise einen Befehl geben.
  • Ich denke, solche Patterns für defensives Programmieren könnten auch helfen, die Qualität großskaliger KI-Codegenerierung zu verbessern.
    Das konkrete Feedback von Clippy oder dem Rust-Compiler kann eine große Rolle dabei spielen, dass KI-Agenten weniger Fehler machen und die richtige Richtung einschlagen.