A Resposta Curta É Sim, com uma Ressalva

Rust pode impor contratos de runtime em desenvolvimento e apagá-los completamente das builds de release. A ressalva é que a linguagem não trata contracts como um conceito de primeira classe. Você recebe os blocos de construção, mas precisa conectá-los você mesmo.

debug_assert! é o ponto de partida óbvio. Ele é avaliado em builds de debug e compilado para nada em release. Isso funciona para invariants simples, mas contracts reais precisam de preconditions, postconditions e, às vezes, loop invariants com comportamento de falha customizado. Nada disso vem pronto.

Este post mostra um padrão que oferece semântica real de design-by-contract com literalmente zero overhead em release, sem crates externas necessárias.

Por Que Contracts em Runtime Importam em Rust

O sistema de tipos e o borrow checker de Rust pegam uma enorme classe de erros em tempo de compilação. Eles não pegam logic errors, boundary violations ou business invariants quebrados.

Você pode escrever uma função que recebe um NonZeroU32 e ainda assim dividir pelo valor errado. Você pode impor slice bounds com o sistema de tipos e ainda assim passar o slice errado. Contracts preenchem a lacuna entre “compila” e “está correto”.

A objeção é sempre performance. Em programação de sistemas, uma build de release é uma promessa. Instruções de branch extras para runtime checks parecem quebrar essa promessa.

O objetivo é manter essas verificações onde elas ajudam, durante desenvolvimento e testes, e removê-las do binário que seus usuários executam.

O Que Rust Oferece Pronto

Rust tem duas famílias de assertions. assert! e assert_eq! executam em todas as builds. debug_assert! e debug_assert_eq! executam apenas quando cfg(debug_assertions) está ativo, o que é verdade para builds de debug por padrão.

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

Isso é bom para verificações ad-hoc. Não é bom para contracts sistemáticos porque:

  • Não há sintaxe embutida para preconditions, postconditions ou invariants
  • Você não pode facilmente alternar níveis de contract por build profile
  • Comportamento de falha customizado, como logging em vez de panicking, exige envolver cada chamada

Construindo Contracts de Custo Zero com Macros

O padrão que funciona é um pequeno conjunto de macros que se expandem para verificações em debug e para nada em release. Você controla a alternância com uma flag cfg customizada se quiser contract checks em testes de release, ou vincula diretamente a debug_assertions.

Aqui está uma implementação completa e funcional:

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

O uso é direto:

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

Em uma build de debug, cada verificação executa e entra em panic com uma mensagem clara. Em uma build de release, as macros se expandem para blocos vazios. O otimizador não vê nada e não gera instruções.

Verificando Que a Build de Release É Realmente Gratuita

Não basta acreditar nas macros. Você deve verificar o assembly.

Compile a função withdraw em modo release e inspecione a saída:

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

Com as macros usando cfg(debug_assertions), o assembly de release para Account::withdraw não contém branches de panic, constantes de string e nenhuma comparação extra além da própria aritmética. As verificações de contract estão completamente ausentes.

Se você vir tokens relacionados a panic na saída, há um bug na macro ou você está compilando com debug-assertions = true no seu release profile.

A Pegadinha de Profile Que Você Vai Encontrar

O Cargo permite habilitar debug assertions em builds de release via Cargo.toml:

[profile.release]
debug-assertions = true

Algumas equipes fazem isso temporariamente para pegar problemas em integration tests. Se você usar cfg(debug_assertions) como alternância de contract, isso de repente torna seu binário de release mais lento. Isso pode ser o que você quer para testes, mas é uma surpresa se você esperava custo zero.

Se você precisar de controle mais fino, use uma feature customizada em vez de debug_assertions:

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

Agora contracts executam em builds de debug por padrão, e você pode habilitá-las explicitamente em release com --features contracts quando precisar dessa segurança extra para uma execução específica de teste ou canary deployment.

Por Que Isso Não É Design-by-Contract no Estilo Eiffel

Linguagens reais de design-by-contract podem herdar contracts, compô-los através de boundaries de module e, às vezes, até mesmo prová-los estaticamente. Macros de Rust não oferecem nada disso.

O que você obtém é pragmático. As verificações executam no call site. Elas entram em panic em caso de falha. Elas desaparecem em release. Isso é suficiente para pegar uma enorme classe de bugs durante o desenvolvimento sem enviar o overhead para produção.

Também é suficiente para documentar intenção. Um bloco requires! no topo de uma função diz ao próximo leitor exatamente o que o autor assumiu, em forma executável.

Quando Você Deve Manter Contracts em Release

Nem todo contract deve desaparecer. Safety-critical invariants, external input validation e security checks frequentemente pertencem a assert!, não a debug_assert! ou macros de contract customizadas.

A regra é simples: se violar a condição significa memory unsafety, data corruption ou uma security vulnerability, mantenha a verificação em release. Use o padrão de custo zero para logic errors que deveriam ter sido pegos por testes.

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

O Padrão em Uma Crate

Se você não quiser manter suas próprias macros, a crate contracts no crates.io implementa quase exatamente esse padrão. Ela fornece proc macros #[requires], #[ensures] e #[invariant] que se anexam a definições de funções e structs.

Por baixo dos panos, ela usa a mesma expansão cfg para remover verificações em release. O principal benefício é uma sintaxe mais agradável. O principal custo é uma dependência de proc-macro.

Para equipes que já dependem de syn e quote, a crate é uma boa escolha. Para equipes que querem zero dependências, o padrão de macro acima são vinte linhas e nenhuma alteração no Cargo.toml.

Comece com Uma Função

Você não precisa anotar seu codebase inteiro. Escolha uma função com um invariant complicado, adicione um requires! e ensures!, execute seus testes e verifique o assembly de release.

Assim que você vir as verificações desaparecerem enquanto pega um bug real em debug, o valor se torna óbvio. Contracts não são um framework para adotar. São um hábito para construir, uma função de cada vez.