Die kurze Antwort ist ja, mit einer Einschränkung

Rust kann runtime contracts in der Entwicklung erzwingen und sie komplett aus Release-Builds entfernen. Die Einschränkung ist, dass die Sprache contracts nicht als first-class concept behandelt. Du bekommst die Bausteine, musst sie aber selbst zusammensetzen.

debug_assert! ist der offensichtliche Einstiegspunkt. Es wird in Debug-Builds ausgewertet und kompiliert in Release zu nichts. Das funktioniert für einfache invariants, aber echte contracts brauchen preconditions, postconditions und manchmal loop invariants mit eigenem failure behavior. Das kommt alles nicht out of the box.

Dieser Post zeigt ein Pattern, das dir echte Design-by-Contract-Semantik mit wortwörtlich zero overhead in Release gibt, ohne externe crates.

Warum contracts zur Laufzeit in Rust wichtig sind

Rusts Typsystem und borrow checker fangen eine riesige Klasse von Fehlern zur Compile-Zeit ab. Sie fangen keine logic errors, boundary violations oder gebrochene business invariants ab.

Du kannst eine Funktion schreiben, die ein NonZeroU32 nimmt, und trotzdem durch den falschen Wert dividieren. Du kannst slice bounds mit dem Typsystem erzwingen und trotzdem den falschen slice übergeben. Contracts schließen die Lücke zwischen „compiles” und „is correct”.

Der Einwand ist immer Performance. In der Systemprogrammierung ist ein Release-Build ein Versprechen. Extra branch instructions für runtime checks fühlen sich an, als würde man dieses Versprechen brechen.

Das Ziel ist, diese checks dort zu behalten, wo sie helfen – während der Entwicklung und beim Testen – und sie aus der Binary zu entfernen, die deine Nutzer ausführen.

Was Rust dir out of the box gibt

Rust hat zwei assertion families. assert! und assert_eq! laufen in allen Builds. debug_assert! und debug_assert_eq! laufen nur, wenn cfg(debug_assertions) aktiv ist, was standardmäßig für Debug-Builds der Fall ist.

pub fn withdraw(balance: &mut i64, amount: i64) {
    // Always runs. You pay for this in release.
    assert!(amount > 0, "withdrawal must be positive");
    assert!(amount <= *balance, "insufficient funds");
    *balance -= amount;
}

pub fn withdraw_debug_only(balance: &mut i64, amount: i64) {
    // Vanishes in release builds.
    debug_assert!(amount > 0, "withdrawal must be positive");
    debug_assert!(amount <= *balance, "insufficient funds");
    *balance -= amount;
}

Das ist in Ordnung für ad-hoc checks. Es ist nicht in Ordnung für systematische contracts, weil:

  • Es gibt keine built-in syntax für preconditions, postconditions oder invariants
  • Du kannst contract levels nicht einfach per build profile togglen
  • Custom failure behavior, wie logging statt panicking, erfordert, jeden Aufruf zu wrappen

Zero-cost contracts mit Macros bauen

Das Pattern, das funktioniert, ist ein kleines macro suite, das in Debug zu checks expandiert und in Release zu nichts. Du steuerst den toggle mit einem eigenen cfg flag, wenn du contract checks in release tests willst, oder bindest es direkt an debug_assertions.

Hier ist eine komplette, funktionierende Implementierung:

// contracts.rs

/// Precondition check. Compiles to nothing in release.
#[macro_export]
macro_rules! requires {
    ($cond:expr $(, $msg:tt)*) => {
        #[cfg(debug_assertions)]
        {
            if !$cond {
                panic!(
                    concat!("Precondition violated: ", stringify!($cond))
                    $(, ": ", $msg)*
                );
            }
        }
    };
}

/// Postcondition check. Compiles to nothing in release.
#[macro_export]
macro_rules! ensures {
    ($cond:expr $(, $msg:tt)*) => {
        #[cfg(debug_assertions)]
        {
            if !$cond {
                panic!(
                    concat!("Postcondition violated: ", stringify!($cond))
                    $(, ": ", $msg)*
                );
            }
        }
    };
}

/// Invariant check for data structures. Compiles to nothing in release.
#[macro_export]
macro_rules! invariant {
    ($cond:expr $(, $msg:tt)*) => {
        #[cfg(debug_assertions)]
        {
            if !$cond {
                panic!(
                    concat!("Invariant violated: ", stringify!($cond))
                    $(, ": ", $msg)*
                );
            }
        }
    };
}

Die Verwendung ist unkompliziert:

use contracts::{requires, ensures, invariant};

pub struct Account {
    balance: i64,
}

impl Account {
    pub fn new(initial: i64) -> Self {
        requires!(initial >= 0, "initial balance cannot be negative");
        let acct = Self { balance: initial };
        ensures!(acct.balance >= 0);
        acct
    }

    pub fn withdraw(&mut self, amount: i64) {
        requires!(amount > 0, "amount must be positive");
        requires!(
            amount <= self.balance,
            "amount must not exceed balance"
        );

        self.balance -= amount;

        ensures!(self.balance >= 0, "balance must stay non-negative");
        invariant!(self.is_consistent());
    }

    fn is_consistent(&self) -> bool {
        self.balance >= 0
    }
}

In einem Debug-Build läuft jeder check und panict mit einer klaren Nachricht. In einem Release-Build expandieren die macros zu leeren blocks. Der optimizer sieht nichts und generiert keine instructions.

Verifizieren, dass der Release-Build wirklich kostenlos ist

Es reicht nicht, den macros zu vertrauen. Du solltest das assembly prüfen.

Baue die withdraw-Funktion im Release-Modus und inspiziere die Ausgabe:

rustc --edition 2021 -C opt-level=3 --emit asm contracts.rs

Wenn die macros cfg(debug_assertions) verwenden, enthält das release assembly für Account::withdraw keine panic branches, keine string constants und keine extra comparisons jenseits der Arithmetik selbst. Die contract checks sind komplett abwesend.

Wenn du panic-bezogene Symbole in der Ausgabe siehst, hast du einen bug im macro oder baust mit debug-assertions = true in deinem release profile.

Das profile gotcha, auf das du stoßen wirst

Cargo erlaubt es, debug assertions in release builds über Cargo.toml zu aktivieren:

[profile.release]
debug-assertions = true

Manche Teams machen das temporär, um Probleme in integration tests zu fangen. Wenn du cfg(debug_assertions) als contract toggle verwendest, macht das deine release binary plötzlich langsamer. Das mag für Tests sein, was du willst, aber es ist eine Überraschung, wenn du zero cost erwartet hast.

Wenn du feinere Kontrolle brauchst, verwende ein eigenes feature anstelle von debug_assertions:

#[cfg(any(feature = "contracts", debug_assertions))]

Jetzt laufen contracts standardmäßig in debug builds, und du kannst sie explizit in release mit --features contracts aktivieren, wenn du die extra safety für einen bestimmten test run oder eine canary deployment brauchst.

Warum das kein Eiffel-style design-by-contract ist

Echte design-by-contract languages können contracts erben, sie über module boundaries hinweg composen und manchmal sogar statisch beweisen. Rust macros geben dir nichts davon.

Was du bekommst, ist pragmatisch. Die checks laufen am call site. Sie panic-en bei Fehlern. Sie verschwinden in release. Das reicht, um eine riesige Klasse von bugs während der Entwicklung zu fangen, ohne den overhead in Produktion auszuliefern.

Es reicht auch, um intent zu dokumentieren. Ein requires!-Block am Anfang einer Funktion sagt dem nächsten Leser genau, was der Autor angenommen hat, in ausführbarer Form.

Wann du contracts im Release behalten solltest

Nicht jeder contract sollte verschwinden. Safety-critical invariants, externe input validation und security checks gehören oft in assert!, nicht in debug_assert! oder custom contract macros.

Die Regel ist einfach: Wenn das Verletzen der condition memory unsafety, Datenkorruption oder eine Sicherheitslücke bedeutet, behalte den check im release. Verwende das zero-cost pattern für logic errors, die von tests gefangen werden sollten.

// Keep this in release. Memory safety depends on it.
assert!(!ptr.is_null());

// Strip this in release. A logic bug, but not unsafe.
requires!(discount_rate <= 1.0);

Das Pattern in einer crate

Wenn du deine eigenen macros nicht maintainen willst, implementiert die contracts-crate auf crates.io fast genau dieses Pattern. Sie bietet #[requires], #[ensures] und #[invariant] proc macros, die an Funktionen und struct definitions angehängt werden.

Unter der Haube verwendet sie dieselbe cfg-Expansion, um checks in release zu entfernen. Der Hauptvorteil ist eine schönere syntax. Der Hauptnachteil ist eine proc-macro dependency.

Für Teams, die bereits von syn und quote abhängen, ist die crate eine gute Wahl. Für Teams, die zero dependencies wollen, ist das macro pattern oben zwanzig Zeilen und keine Cargo.toml-Änderungen.

Fange mit einer Funktion an

Du musst nicht deinen gesamten codebase annotieren. Wähle eine Funktion mit einem kniffligen invariant, füge ein requires! und ensures! hinzu, führe deine tests aus und prüfe das release assembly.

Sobald du siehst, dass die checks verschwinden, während sie einen echten bug in debug fangen, wird der Wert offensichtlich. Contracts sind kein framework, das man adoptiert. Sie sind eine Gewohnheit, die man baut, eine Funktion nach der anderen.