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 |