- 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.“
Noch keine Kommentare.