Declarative and Procedural Macros in Depth

Macro Maintainability

Writing maintainable macros means keeping macro expansions small, delegating runtime work to normal generic or non-generic functions, evaluating expression arguments exactly once, and qualifying every path so macros remain readable, fast to compile, and resilient to caller environments.

Rule 1: Bind $expr Arguments to Local Variables Once (Prevent Multiple Evaluation)

In macro_rules!, substituting $val:expr multiple times inside the expansion body (if $val > 0 { $val * 2 } else { 0 }) duplicates the entire expression AST—causing side effects or expensive function calls (fetch_next_number()) to execute multiple times!

Always bind $expr metavariables to a local let variable (or a match reference match $val { ref val => ... }) once at the start of the macro expansion:

use std::cell::Cell;

// Anti-pattern: `$x` is evaluated TWICE if positive!
macro_rules! clamp_positive_bad {
    ($x:expr) => {
        if $x > 0 { $x } else { 0 }
    };
}

// Idiomatic: bind `$x` once to a hygienic local variable `val`:
macro_rules! clamp_positive_good {
    ($x:expr) => {{
        let val = $x;
        if val > 0 { val } else { 0 }
    }};
}

fn main() {
    let calls = Cell::new(0);
    let next_value = || {
        calls.set(calls.get() + 1);
        10
    };

    let _ = clamp_positive_bad!(next_value());
    println!("calls after bad macro:  {} (expected 1!)", calls.get());

    calls.set(0);
    let res = clamp_positive_good!(next_value());
    println!("calls after good macro: {} (result = {})", calls.get(), res);
}
calls after bad macro:  2 (expected 1!)
calls after good macro: 1 (result = 10)

Rule 2: Delegate Heavy Logic from Macros to Normal Functions

Every line of code generated inside a macro expansion is duplicated at every call site in the user's crate, increasing compile times, binary size, and stack-trace complexity. Keep the macro as a thin syntax shim that parses tokens and immediately delegates to a normal, pre-compiled Rust function in $crate:

Anti-Pattern (50 Lines of Logic Generated Inline at Every Macro Call):
  my_log!("hello {}", name) ---> Expands 50 lines of formatting, locking, and I/O at EVERY call site!

Idiomatic Pattern (Thin Syntax Shim -> Pre-Compiled Function):
  my_log!("hello {}", name) ---> Expands to 1 line:
                                 `$crate::__log_impl(::core::format_args!("hello {}", name))`
pub fn __run_assertion_failure(expr_str: &str, file: &str, line: u32) {
    println!("[ASSERT FAILED at {file}:{line}] `{expr_str}` evaluated to false");
}

macro_rules! soft_assert {
    ($cond:expr) => {{
        if !$cond {
            $crate::__run_assertion_failure(stringify!($cond), file!(), line!());
        }
    }};
}

fn main() {
    let x = 5;
    soft_assert!(x == 5);
    soft_assert!(x > 10);
}
[ASSERT FAILED at src/main.rs:17] `x > 10` evaluated to false

Checklist for Production-Grade Macros

Maintainability Rule Implementation
Prefer functions/generics first Only write a macro when variable arity, AST generation, compile-time token inspection, or call-site file!()/line!() capture cannot be done by a normal function or trait
Fully qualify all external paths Use $crate::..., ::core::option::Option::Some, ::core::result::Result::Ok
Accept trailing commas Add $(,)? to comma-separated repetition rules
Evaluate $expr once Bind let val = $arg; at the top of the expansion block
Wrap multi-statement expressions in {{ ... }} Double braces {{ let x = ...; x }} make the macro usable in expression position without leaking statements
Order rules specific-to-general Place literal/keyword rules before general $e:expr or $($tt:tt)* rules