Jawaban Singkatnya Ya, dengan Sebuah Catatan
Rust dapat menegakkan runtime contract saat development dan menghapusnya sepenuhnya dari release build. Catatannya, bahasa ini tidak memperlakukan contract sebagai konsep first-class. Anda mendapatkan building block-nya, tetapi harus menyambungkannya sendiri.
debug_assert! adalah titik awal yang jelas. Ia dievaluasi di debug build dan dikompilasi menjadi nothing di release. Itu berfungsi untuk invariant sederhana, tetapi contract yang sesungguhnya membutuhkan preconditions, postconditions, dan terkadang loop invariants dengan custom failure behavior. Tidak ada yang datang out of the box.
Post ini menunjukkan sebuah pattern yang memberi Anda semantic design-by-contract yang sesungguhnya dengan benar-benar zero overhead di release, tanpa memerlukan crate eksternal.
Mengapa Contract Runtime Penting di Rust
Type system dan borrow checker Rust menangkap sekelas besar error saat compile time. Mereka tidak menangkap logic error, boundary violation, atau business invariant yang rusak.
Anda bisa menulis fungsi yang menerima NonZeroU32 dan tetap membagi dengan nilai yang salah. Anda bisa menegakkan slice bounds dengan type system dan tetap melewatkan slice yang salah. Contract mengisi celah antara “kompilasi” dan “benar.”
Keberatan yang selalu muncul adalah performance. Dalam systems programming, release build adalah sebuah janji. Instruksi branch tambahan untuk runtime check terasa seperti melanggar janji itu.
Tujuannya adalah menjaga check tersebut di tempat yang bermanfaat, selama development dan testing, dan menghapusnya dari binary yang dijalankan pengguna Anda.
Apa yang Rust Berikan Out of the Box
Rust memiliki dua keluarga assertion. assert! dan assert_eq! berjalan di semua build. debug_assert! dan debug_assert_eq! berjalan hanya ketika cfg(debug_assertions) aktif, yang secara default bernilai true untuk debug build.
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;
}
Ini baik untuk check ad-hoc. Ini tidak baik untuk contract yang sistematis karena:
- Tidak ada syntax built-in untuk preconditions, postconditions, atau invariants
- Anda tidak dapat dengan mudah men-toggle level contract per build profile
- Custom failure behavior, seperti logging alih-alih panicking, memerlukan wrapping setiap pemanggilan
Membangun Contract Zero-Cost dengan Macro
Pattern yang berfungsi adalah suite macro kecil yang mengembang menjadi check di debug dan menjadi nothing di release. Anda mengontrol toggle dengan flag cfg kustom jika ingin contract check di release test, atau menghubungkannya langsung ke debug_assertions.
Berikut adalah implementasi lengkap yang berfungsi:
// 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)*
);
}
}
};
}
Penggunaannya sederhana:
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
}
}
Di debug build, setiap check berjalan dan panics dengan pesan yang jelas. Di release build, macro-macro tersebut mengembang menjadi empty blocks. Optimizer tidak melihat apa pun dan tidak menghasilkan instruksi.
Memverifikasi Bahwa Release Build Benar-benar Tanpa Biaya
Percaya pada macro saja tidak cukup. Anda harus memeriksa assembly-nya.
Build fungsi withdraw dalam mode release dan periksa output-nya:
rustc --edition 2021 -C opt-level=3 --emit asm contracts.rs
Dengan macro yang menggunakan cfg(debug_assertions), release assembly untuk Account::withdraw tidak mengandung panic branches, string constants, dan perbandingan tambahan di luar aritmetika itu sendiri. Contract check sama sekali tidak ada.
Jika Anda melihat simbol yang terkait panic di output, ada bug di macro atau Anda sedang build dengan debug-assertions = true di release profile Anda.
Gotcha Profile yang Akan Anda Temui
Cargo mengizinkan mengaktifkan debug assertion di release build melalui Cargo.toml:
[profile.release]
debug-assertions = true
Beberapa tim melakukan ini secara sementara untuk menangkap issue di integration test. Jika Anda menggunakan cfg(debug_assertions) sebagai toggle contract, ini tiba-tiba membuat binary release Anda lebih lambat. Itu mungkin yang Anda inginkan untuk testing, tetapi itu adalah kejutan jika Anda mengharapkan zero cost.
Jika Anda membutuhkan kontrol yang lebih halus, gunakan custom feature alih-alih debug_assertions:
#[cfg(any(feature = "contracts", debug_assertions))]
Sekarang contract berjalan di debug build secara default, dan Anda dapat mengaktifkannya secara eksplisit di release dengan --features contracts ketika Anda membutuhkan safety tambahan untuk test run tertentu atau canary deployment.
Mengapa Ini Bukan Design-by-Contract Gaya Eiffel
Bahasa design-by-contract yang sesungguhnya dapat mewarisi contract, mengkomposisikannya melintasi batasan module, dan terkadang bahkan membuktikannya secara statis. Macro Rust tidak memberikan apa pun dari itu.
Yang Anda dapatkan adalah pragmatis. Check berjalan di call site. Mereka panic saat gagal. Mereka menghilang di release. Itu cukup untuk menangkap sekelas besar bug selama development tanpa mengirim overhead ke production.
Itu juga cukup untuk mendokumentasikan intent. Sebuah blok requires! di awal fungsi memberi tahu pembaca berikutnya secara tepat apa yang penulis asumsikan, dalam bentuk yang dapat dieksekusi.
Kapan Anda Harus Menyimpan Contract di Release
Tidak setiap contract harus lenyap. Safety-critical invariants, external input validation, dan security check sering kali termasuk dalam assert!, bukan debug_assert! atau macro contract kustom.
Aturannya sederhana: jika melanggar kondisi berarti memory unsafety, data corruption, atau security vulnerability, simpan check di release. Gunakan pattern zero-cost untuk logic error yang seharusnya ditangkap oleh test.
// 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);
Pattern dalam Satu Crate
Jika Anda tidak ingin memelihara macro sendiri, crate contracts di crates.io mengimplementasikan hampir persis pattern ini. Ia menyediakan proc macro #[requires], #[ensures], dan #[invariant] yang menempel pada definisi fungsi dan struct.
Di balik layar, ia menggunakan pengembangan cfg yang sama untuk menghilangkan check di release. Manfaat utamanya adalah syntax yang lebih baik. Biaya utamanya adalah dependency proc-macro.
Untuk tim yang sudah bergantung pada syn dan quote, crate ini adalah pilihan yang baik. Untuk tim yang ingin zero dependencies, pattern macro di atas adalah dua puluh baris dan tanpa perubahan Cargo.toml.
Mulai dengan Satu Fungsi
Anda tidak perlu menganotasi seluruh codebase. Pilih satu fungsi dengan invariant yang rumit, tambahkan requires! dan ensures!, jalankan test Anda, dan periksa release assembly-nya.
Begitu Anda melihat check menghilang sambil menangkap bug nyata di debug, nilainya menjadi jelas. Contract bukan framework yang harus diadopsi. Mereka adalah kebiasaan yang harus dibangun, satu fungsi dalam satu waktu.