Macro Design Principles
Writing maintainable, hygienic, and fast-compiling macros in Rust follows a clear set of design rules that prevent subtle double-evaluation bugs, name-resolution breakages, and binary bloat.
1. Bind $expr Arguments to Local Variables Once (Prevent Multiple Evaluation)
Just like C macros, substituting a metavariable $x:expr twice inside a macro body evaluates the expression twice at runtime—duplicating work or running side effects twice! Always bind $x:expr to a single local let variable (or match subject) at the top of the macro block:
// BAD: Evaluates `$x` twice if `$x` is a function call with side effects!
macro_rules! square_bad {
($x:expr) => {
$x * $x
};
}
// GOOD: Evaluates `$x` once and binds it to `val` in a hygienic block:
macro_rules! square_good {
($x:expr) => {{
let val = $x;
val * val
}};
}
fn fetch_and_log(n: i32) -> i32 {
println!(" [side effect] fetch_and_log({n}) called");
n
}
fn main() {
println!("Calling square_bad:");
let a = square_bad!(fetch_and_log(5));
println!("Calling square_good:");
let b = square_good!(fetch_and_log(5));
println!("a = {a}, b = {b}");
}
Calling square_bad:
[side effect] fetch_and_log(5) called
[side effect] fetch_and_log(5) called
Calling square_good:
[side effect] fetch_and_log(5) called
a = 25, b = 25
2. Use Fully Qualified Paths ($crate:: and ::core:: / ::std::)
Because macro_rules! resolves type, trait, and module paths at the call site, a caller module might not have imported a helper trait or might have shadowed Result or Option with a custom type alias. In exported macros:
- Prefix your own crate's items with
$crate::($crate::internal::Helper). - Prefix standard library items with
::core::or::std::(::core::result::Result::Ok(x),::core::option::Option::Some(x)).
3. Keep Macro Expansions Thin (Delegate Heavy Logic to Regular Functions)
Every time a macro expands 100 lines of code at 50 call sites, the compiler must parse, type-check, borrow-check, and optimize all 5,000 lines of expanded code. Instead, have the macro expand into a 1–3 line call to a regular generic or non-generic helper function:
// Heavy logic lives in a normal, type-checked, compiled-once function:
pub fn log_structured_impl(file: &str, line: u32, key: &str, value: &dyn std::fmt::Debug) {
println!("{file}:{line} | {key}={value:?}");
}
// The macro only captures caller location (`file!()`, `line!()`) and forwards to the function:
macro_rules! log_kv {
($key:expr, $val:expr) => {
log_structured_impl(file!(), line!(), $key, &$val)
};
}
fn main() {
log_kv!("port", 8080);
log_kv!("hosts", ["db-1", "db-2"]);
}
src/main.rs:14 | port=8080
src/main.rs:15 | hosts=["db-1", "db-2"]
Summary Checklist for Macro Authors
| Principle | Practice |
|---|---|
| Prefer functions/generics first | Only write a macro when variadic arity, item generation, or token inspection is required |
| Single evaluation | Bind $x:expr to let val = $x; (or match $x { val => ... }) if referenced more than once |
| Trailing commas | Add $(,)? to comma-separated repetition rules so multi-line calls format cleanly with rustfmt |
| Path hygiene | Use $crate:: for local items and ::core:: / ::std:: for standard library types and traits |
| Thin expansions | Delegate runtime work from the macro transcriber to a regular Rust function |