Короткий ответ: да, но с оговоркой

Rust может enforce runtime contracts в development и полностью стирать их из релизных сборок. Оговорка в том, что язык не рассматривает contracts как first-class concept. Вы получаете строительные блоки, но соединять их придётся самостоятельно.

debug_assert! — очевидная отправная точка. Он вычисляется в debug-сборках и компилируется в ничто в release. Это работает для простых invariants, но настоящие contracts требуют preconditions, postconditions, а иногда и loop invariants с кастомным поведением при сбое. Ничего из этого не поставляется из коробки.

Этот пост демонстрирует паттерн, который даёт вам настоящую design-by-contract семантику с буквально zero overhead в release, без внешних crates.

Почему runtime contracts важны в Rust

Система типов и borrow checker Rust ловят огромный класс ошибок на этапе компиляции. Они не ловят logic errors, boundary violations или нарушенные business invariants.

Вы можете написать функцию, которая принимает NonZeroU32, и всё равно поделить на неверное значение. Вы можете enforce slice bounds через систему типов и всё равно передать неверный slice. Contracts заполняют пробел между «компилируется» и «верно».

Возражение всегда одно — performance. В systems programming релизная сборка — это обещание. Лишние branch instructions для runtime checks кажутся нарушением этого обещания.

Цель — оставить эти проверки там, где они помогают, во время development и testing, и удалить их из бинарника, который запускают ваши пользователи.

Что Rust даёт из коробки

В Rust есть две семьи assertions. assert! и assert_eq! выполняются во всех сборках. debug_assert! и debug_assert_eq! выполняются только когда активен cfg(debug_assertions), что по умолчанию верно для debug-сборок.

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;
}

Это нормально для ad-hoc проверок. Это не подходит для систематических contracts, потому что:

  • Нет встроенного синтаксиса для preconditions, postconditions или invariants
  • Нельзя легко переключать уровни contracts для каждого build profile
  • Кастомное поведение при сбое, например logging вместо panicking, требует оборачивания каждого вызова

Создание zero-cost contracts с помощью макросов

Работающий паттерн — это небольшой набор макросов, который раскрывается в проверки в debug и в ничто в release. Вы управляете переключателем через кастомный флаг cfg, если хотите проверки contracts в релизных тестах, или привязываете его напрямую к debug_assertions.

Вот полная, рабочая реализация:

// 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)*
                );
            }
        }
    };
}

Использование простое:

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
    }
}

В debug-сборке каждая проверка выполняется и паникует с понятным сообщением. В release-сборке макросы раскрываются в пустые блоки. Оптимизатор ничего не видит и не генерирует инструкций.

Проверка того, что релизная сборка действительно бесплатна

Недостаточно просто верить макросам. Нужно проверять assembly.

Соберите функцию withdraw в release-режиме и изучите вывод:

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

С макросами, использующими cfg(debug_assertions), релизный assembly для Account::withdraw не содержит panic branches, строковых констант и лишних сравнений, помимо самой арифметики. Проверки contracts полностью отсутствуют.

Если вы видите panic-related символы в выводе, у вас баг в макросе или вы собираете с debug-assertions = true в вашем release profile.

Подводный камень с profile, на который вы наткнётесь

Cargo позволяет включать debug assertions в релизных сборках через Cargo.toml:

[profile.release]
debug-assertions = true

Некоторые команды делают это временно, чтобы ловить проблемы в integration tests. Если вы используете cfg(debug_assertions) как переключатель contracts, это внезапно замедляет ваш релизный бинарник. Это может быть тем, что вы хотите для тестирования, но это сюрприз, если вы ожидали zero cost.

Если вам нужен более точный контроль, используйте кастомный feature вместо debug_assertions:

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

Теперь contracts выполняются в debug-сборках по умолчанию, и вы можете явно включить их в release с помощью --features contracts, когда вам нужна дополнительная безопасность для конкретного прогона тестов или canary deployment.

Почему это не design-by-contract в стиле Eiffel

Настоящие design-by-contract языки могут наследовать contracts, компоновать их через границы модулей и иногда даже статически доказывать их. Макросы Rust не дают вам ничего из этого.

То, что вы получаете, — прагматично. Проверки выполняются в точке вызова. Они паникуют при сбое. Они исчезают в release. Этого достаточно, чтобы ловить огромный класс багов во время development, не отправляя overhead в production.

Это также достаточно, чтобы документировать intent. Блок requires! в начале функции говорит следующему читателю точно, что автор предполагал, в исполняемой форме.

Когда следует оставлять contracts в release

Не каждый contract следует удалять. Safety-critical invariants, external input validation и security checks часто должны оставаться в assert!, а не в debug_assert! или кастомных contract macros.

Правило простое: если нарушение условия означает memory unsafety, data corruption или security vulnerability, оставляйте проверку в release. Используйте zero-cost pattern для logic errors, которые должны были быть пойманы тестами.

// 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);

Паттерн в одном crate

Если вы не хотите поддерживать свои собственные макросы, crate contracts на crates.io реализует практически точно этот паттерн. Он предоставляет proc macros #[requires], #[ensures] и #[invariant], которые прикрепляются к функциям и определениям структур.

Под капотом он использует то же самое cfg expansion, чтобы удалять проверки в release. Главное преимущество — более приятный синтаксис. Главный недостаток — зависимость от proc-macro.

Для команд, которые уже зависят от syn и quote, этот crate — хороший выбор. Для команд, которые хотят zero dependencies, паттерн с макросами выше — это двадцать строк и никаких изменений в Cargo.toml.

Начните с одной функции

Вам не нужно аннотировать весь codebase. Выберите одну функцию с хитрым invariant, добавьте requires! и ensures!, запустите тесты и проверьте релизный assembly.

Как только вы увидите, что проверки исчезают, при этом ловя реальный баг в debug, ценность становится очевидной. Contracts — это не фреймворк, который нужно внедрить. Это привычка, которую нужно выработать, по одной функции за раз.