Defensive Programmiermuster in Rust
(corrode.dev)- 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ändigematch-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 happensind 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-Implementierunglet Self { size, toppings, .. } = self;
- Beispiel: In einer
- Kommt ein neues Feld wie
extra_cheesehinzu, wird eine erneute Prüfung der Vergleichslogik erzwungen - Dasselbe Prinzip lässt sich auch auf andere Traits wie
Hash,DebugundCloneanwenden
Code Smell: TryFrom statt From erforderlich
- Wenn eine Konvertierung nicht immer erfolgreich sein kann, sollte statt
FromexplizitTryFromverwendet werden, um die Möglichkeit eines Fehlers sichtbar zu machen - Die Verwendung von
unwrap_or_elseist 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 | Variant4gruppiert 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 };
- Beispiel:
- 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
- Mit einem Feld wie
- Wenn dies auch innerhalb interner Module erzwungen werden soll, kann eine verschachtelte Modulstruktur mit einem privaten Typ (
Seal) verwendet werden- Da
Sealnur intern existiert, ist direkte Konstruktion außerhalb vonnew()nicht möglich
- Da
- Werden Felder privat gehalten und Getter bereitgestellt, bleibt der unveränderliche Zustand gewahrt
- Kriterien für die Anwendung
- Externen Code blockieren:
_privateoder#[non_exhaustive] - Internen Code blockieren: privates Modul +
Seal - Validierungslogik in Garantien auf Compiler-Ebene überführen
- Externen Code blockieren:
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"]
- Beispiel:
- 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 Encryptionusw. 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
- Mit Methoden wie
- 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 verbietenfallible_impl_from:TryFromstattFromempfehlenwildcard_enum_match_arm:_-Pattern verbietenfn_params_excessive_bools: vor zu vielen booleschen Parametern warnenmust_use_candidate: Kandidaten für#[must_use]vorschlagen
- Über
#![deny(clippy::...)]oder Konfiguration inCargo.tomllä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
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_atbeim Vergleich auszuschließen, halte ich es für besser,PizzaDetailsundPizzaOrderin zwei structs aufzuteilen.So kann man bei der Implementierung von
PartialEqklar festlegen, dass nurdetailsverglichen wird.Wenn sich die Bestellzeit unterscheidet, ist es nicht dieselbe Bestellung; das auf Typebene als gleich zu definieren, ist riskant.
PartialEqaufPizzaDetailszu haben, ist okay, aber die Logik zum Vergleichen von Bestellungen sollte besser in einer separaten Business-Funktion liegen.PizzaDetailsdie 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
PizzaComparatoroderPizzaFlavorin Betracht ziehen.Es wäre schön, wenn man wie bei Protobuf Feldannotationen wie
{important_to_flavour=true}an Feldern anbringen könnte.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
selfkeine Methodenaufrufe mehr möglich sind.Dadurch werden Thread-Sicherheit, Lifetimes, Klonbarkeit usw. global zur Compile-Zeit verifiziert.
In anderen Sprachen bekommt man die Vorteile nur, wenn man mit funktionalem Stil Unveränderlichkeit beibehält; Rust erzwingt das über das Typsystem.
Thema des Artikels waren logische Bugs, die selbst der Borrow Checker nicht erkennt.
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.
unwrap-Vorfall als „Vorfall“ bezeichnen muss.Rusts
unwrapist dasselbe wieassertin C. Beim Fehlschlag meldet es nur, dass es ein Problem gibt.Auch in Rust kann man weiterhin Bugs schreiben.
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.
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.
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.
impl Derefausprobieren, aber ich bin mir nicht sicher, ob das eine gute Idee ist.Die
match-Anweisung im ersten Beispiel wirkt übertrieben.Vec.first()oderVec.iter().nth(0)sind klarer und passender zur Absicht.matchverwendet, wird die Lösung komplizierter als das Problem.Wenn man
ifentfernen kann, kann man auchmatchentfernen; in Bezug auf Sicherheit gibt es keinen Unterschied.first()ist viel knapper und klarer.matchinsofern 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.
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.
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.
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.