La réponse courte est oui, avec une mise en garde
Rust peut appliquer des runtime contracts en développement et les effacer complètement des builds release. La mise en garde est que le langage ne traite pas les contracts comme un concept de première classe. Vous obtenez les briques de base, mais vous devez les assembler vous-même.
debug_assert! est le point de départ évident. Il s’évalue dans les builds debug et se compile en rien en release. Cela fonctionne pour des invariants simples, mais de vrais contracts ont besoin de preconditions, de postconditions, et parfois de loop invariants avec un comportement d’échec personnalisé. Rien de tout cela n’est fourni par défaut.
Cet article montre un pattern qui vous donne de vraies sémantiques de design-by-contract avec littéralement zéro overhead en release, sans crate externe nécessaire.
Pourquoi les contracts runtime comptent en Rust
Le système de types de Rust et le borrow checker attrapent une énorme classe d’erreurs à la compilation. Ils n’attrapent pas les erreurs de logique, les boundary violations, ou les business invariants cassés.
Vous pouvez écrire une fonction qui prend un NonZeroU32 et diviser quand même par la mauvaise valeur. Vous pouvez imposer des slice bounds avec le système de types et passer quand même le mauvais slice. Les contracts comblent le fossé entre « ça compile » et « c’est correct ».
L’objection est toujours la performance. En programmation système, un build release est une promesse. Des instructions de branchement supplémentaires pour des vérifications runtime semblent briser cette promesse.
Le but est de garder ces vérifications là où elles aident, pendant le développement et les tests, et de les retirer du binaire que vos utilisateurs exécutent.
Ce que Rust vous donne par défaut
Rust a deux familles d’assertions. assert! et assert_eq! s’exécutent dans tous les builds. debug_assert! et debug_assert_eq! ne s’exécutent que quand cfg(debug_assertions) est actif, ce qui est vrai pour les builds debug par défaut.
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;
}
C’est bien pour des vérifications ad-hoc. Ce n’est pas bien pour des contracts systématiques parce que :
- Il n’y a pas de syntaxe intégrée pour les preconditions, postconditions, ou invariants
- Vous ne pouvez pas facilement basculer les niveaux de contract par build profile
- Un comportement d’échec personnalisé, comme logger au lieu de paniquer, nécessite d’encapsuler chaque appel
Construire des contracts sans coût avec des macros
Le pattern qui fonctionne est une petite suite de macros qui s’étend en vérifications en debug et en rien en release. Vous contrôlez le basculement avec un flag cfg personnalisé si vous voulez des vérifications de contract dans les tests release, ou vous le liez directement à debug_assertions.
Voici une implémentation complète et fonctionnelle :
// 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)*
);
}
}
};
}
L’utilisation est simple :
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
}
}
Dans un build debug, chaque vérification s’exécute et panique avec un message clair. Dans un build release, les macros s’étendent en blocs vides. L’optimiseur ne voit rien et ne génère aucune instruction.
Vérifier que le build release est réellement gratuit
Il ne suffit pas de croire les macros. Vous devriez vérifier l’assembly.
Compilez la fonction withdraw en mode release et inspectez la sortie :
rustc --edition 2021 -C opt-level=3 --emit asm contracts.rs
Avec les macros utilisant cfg(debug_assertions), l’assembly release pour Account::withdraw ne contient pas de branches de panic, pas de constantes de chaîne, et pas de comparaisons supplémentaires au-delà de l’arithmétique elle-même. Les vérifications de contract sont complètement absentes.
Si vous voyez des symboles liés à panic dans la sortie, vous avez un bug dans la macro ou vous compilez avec debug-assertions = true dans votre profil release.
Le piège de profile que vous allez rencontrer
Cargo permet d’activer les debug assertions dans les builds release via Cargo.toml :
[profile.release]
debug-assertions = true
Certaines équipes font cela temporairement pour attraper des problèmes dans les tests d’intégration. Si vous utilisez cfg(debug_assertions) comme bascule de contract, cela rend soudainement votre binaire release plus lent. C’est peut-être ce que vous voulez pour les tests, mais c’est une surprise si vous attendiez un coût zéro.
Si vous avez besoin d’un contrôle plus fin, utilisez une feature personnalisée au lieu de debug_assertions :
#[cfg(any(feature = "contracts", debug_assertions))]
Maintenant les contracts s’exécutent dans les builds debug par défaut, et vous pouvez les activer explicitement en release avec --features contracts quand vous avez besoin de cette sécurité supplémentaire pour une exécution de test spécifique ou un déploiement canary.
Pourquoi ce n’est pas du design-by-contract à la Eiffel
De vrais langages de design-by-contract peuvent hériter des contracts, les composer à travers les limites de modules, et parfois même les prouver statiquement. Les macros de Rust ne vous donnent rien de tout cela.
Ce que vous obtenez est pragmatique. Les vérifications s’exécutent au site d’appel. Elles paniquent en cas d’échec. Elles disparaissent en release. C’est suffisant pour attraper une énorme classe de bugs pendant le développement sans livrer l’overhead en production.
C’est aussi suffisant pour documenter l’intention. Un bloc requires! en haut d’une fonction dit au prochain lecteur exactement ce que l’auteur a supposé, sous forme exécutable.
Quand vous devriez garder les contracts en release
Tous les contracts ne devraient pas disparaître. Les invariants safety-critical, la validation des entrées externes, et les vérifications de sécurité appartiennent souvent à assert!, pas à debug_assert! ou aux macros de contract personnalisées.
La règle est simple : si violer la condition signifie de la memory unsafety, de la data corruption, ou une vulnérabilité de sécurité, gardez la vérification en release. Utilisez le pattern sans coût pour les erreurs de logique qui auraient dû être attrapées par les tests.
// 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);
Le pattern dans une crate
Si vous ne voulez pas maintenir vos propres macros, la crate contracts sur crates.io implémente presque exactement ce pattern. Elle fournit des proc macros #[requires], #[ensures], et #[invariant] qui s’attachent aux définitions de fonctions et de structs.
Sous le capot, elle utilise la même expansion cfg pour retirer les vérifications en release. Le principal bénéfice est une syntaxe plus agréable. Le principal coût est une dépendance proc-macro.
Pour les équipes qui dépendent déjà de syn et quote, la crate est un bon choix. Pour les équipes qui veulent zéro dépendance, le pattern de macro ci-dessus fait vingt lignes et aucune modification de Cargo.toml.
Commencez par une fonction
Vous n’avez pas besoin d’annoter tout votre codebase. Choisissez une fonction avec un invariant délicat, ajoutez un requires! et un ensures!, exécutez vos tests, et vérifiez l’assembly release.
Une fois que vous voyez les vérifications disparaître tout en attrapant un vrai bug en debug, la valeur devient évidente. Les contracts ne sont pas un framework à adopter. Ce sont une habitude à construire, une fonction à la fois.