La Respuesta Corta Es Sí, con una Salvedad

Rust puede hacer cumplir runtime contracts en desarrollo y borrarlos por completo de los release builds. La salvedad es que el lenguaje no trata los contracts como un concepto de primera clase. Obtienes los bloques de construcción, pero tienes que conectarlos tú mismo.

debug_assert! es el punto de partida obvio. Se evalúa en debug builds y se compila a nada en release. Eso funciona para invariants simples, pero los contracts reales necesitan preconditions, postconditions y a veces loop invariants con un comportamiento de fallo personalizado. Nada de eso viene de serie.

Este post muestra un patrón que te ofrece semánticas reales de design-by-contract con literalmente cero overhead en release, sin necesidad de crates externos.

Por Qué los Contracts en Runtime Importan en Rust

El sistema de tipos y el borrow checker de Rust capturan una enorme clase de errores en tiempo de compilación. No capturan logic errors, boundary violations ni business invariants rotos.

Puedes escribir una función que tome un NonZeroU32 y aun así dividir por el valor equivocado. Puedes hacer cumplir slice bounds con el sistema de tipos y aun así pasar el slice equivocado. Los contracts llenan el vacío entre “compila” y “es correcto”.

La objeción siempre es el rendimiento. En programación de sistemas, un release build es una promesa. Las instrucciones de branch adicionales para runtime checks se sienten como romper esa promesa.

El objetivo es mantener esas comprobaciones donde ayudan, durante el desarrollo y las pruebas, y eliminarlas del binary que ejecutan tus usuarios.

Lo Que Rust Te Da de Serie

Rust tiene dos familias de assertions. assert! y assert_eq! se ejecutan en todos los builds. debug_assert! y debug_assert_eq! se ejecutan solo cuando cfg(debug_assertions) está activo, lo cual es cierto para los debug builds por defecto.

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

Esto está bien para comprobaciones ad-hoc. No está bien para contracts sistemáticos porque:

  • No hay sintaxis incorporada para preconditions, postconditions ni invariants
  • No puedes alternar fácilmente los niveles de contract por build profile
  • Un comportamiento de fallo personalizado, como logging en lugar de panicking, requiere envolver cada llamada

Construyendo Contracts de Costo Cero con Macros

El patrón que funciona es un pequeño conjunto de macros que se expanden a comprobaciones en debug y a nada en release. Controlas el interruptor con un flag cfg personalizado si quieres contract checks en release tests, o lo vinculas directamente a debug_assertions.

Aquí tienes una implementación completa y 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)*
                );
            }
        }
    };
}

El uso es sencillo:

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

En un debug build, cada comprobación se ejecuta y hace panic con un mensaje claro. En un release build, las macros se expanden a bloques vacíos. El optimizador no ve nada y no genera instrucciones.

Verificando Que el Release Build Realmente Sea Libre de Costo

No basta con creer en las macros. Deberías revisar el assembly.

Compila la función withdraw en modo release e inspecciona la salida:

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

Con las macros usando cfg(debug_assertions), el release assembly de Account::withdraw no contiene panic branches, ni string constants, ni comparaciones adicionales más allá de la propia aritmética. Los contract checks están completamente ausentes.

Si ves símbolos relacionados con panic en la salida, tienes un bug en la macro o estás compilando con debug-assertions = true en tu release profile.

El Gotcha del Profile Que Te Encontrarás

Cargo permite habilitar debug assertions en release builds a través de Cargo.toml:

[profile.release]
debug-assertions = true

Algunos equipos hacen esto temporalmente para detectar problemas en integration tests. Si usas cfg(debug_assertions) como tu interruptor de contracts, esto hace que tu release binary sea repentinamente más lento. Eso puede ser lo que quieres para las pruebas, pero es una sorpresa si esperabas costo cero.

Si necesitas un control más fino, usa una feature personalizada en lugar de debug_assertions:

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

Ahora los contracts se ejecutan en debug builds por defecto, y puedes habilitarlos explícitamente en release con --features contracts cuando necesites esa seguridad extra para una ejecución de pruebas específica o un canary deployment.

Por Qué Esto No Es Design-by-Contract al Estilo Eiffel

Los lenguajes reales de design-by-contract pueden heredar contracts, componerlos a través de module boundaries, y a veces incluso probarlos estáticamente. Las macros de Rust no te dan nada de eso.

Lo que obtienes es pragmático. Las comprobaciones se ejecutan en el call site. Hacen panic ante el fallo. Desaparecen en release. Eso es suficiente para capturar una enorme clase de bugs durante el desarrollo sin enviar el overhead a producción.

También es suficiente para documentar la intención. Un bloque requires! en la parte superior de una función le dice al siguiente lector exactamente lo que el autor asumió, en forma ejecutable.

Cuándo Deberías Mantener Contracts en Release

No todo contract debería desaparecer. Los safety-critical invariants, la external input validation y los security checks a menudo pertenecen a assert!, no a debug_assert! ni a macros de contracts personalizadas.

La regla es simple: si violar la condición significa memory unsafety, data corruption o una security vulnerability, mantén la comprobación en release. Usa el patrón de costo cero para logic errors que deberían haber sido detectados por las pruebas.

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

El Patrón en Un Solo Crate

Si no quieres mantener tus propias macros, el crate contracts en crates.io implementa casi exactamente este patrón. Proporciona proc macros #[requires], #[ensures] y #[invariant] que se adjuntan a funciones y definiciones de structs.

Por dentro, usa la misma expansión de cfg para eliminar comprobaciones en release. El principal beneficio es una sintaxis más agradable. El principal costo es una dependencia de proc-macro.

Para equipos que ya dependen de syn y quote, el crate es una buena opción. Para equipos que quieren cero dependencias, el patrón de macro de arriba son veinte líneas y ningún cambio en Cargo.toml.

Empieza con Una Función

No necesitas anotar todo tu codebase. Elige una función con un invariant complicado, añade un requires! y un ensures!, ejecuta tus pruebas y revisa el release assembly.

Una vez que veas las comprobaciones desaparecer mientras capturas un bug real en debug, el valor se vuelve obvio. Los contracts no son un framework que adoptar. Son un hábito que construir, una función a la vez.