Skip to content

← Macros, FFI and Type-Driven Design step 11 of 28

Medium Primitives

Macro hygiene: why your variable didn't leak

Find out whose a is whose, then deliberately break the rule.

pub fn observe() -> (i32, i32)
pub fn introduced() -> i32
pub mod alpha { pub fn run() -> i32 }
pub mod beta  { pub fn run() -> i32 }

Three experiments, one macro system.

Experiment 1: the macro’s a and the caller’s a

macro_rules! using_a {
    ($e:expr) => {{
        let a = 42;
        (a, $e)
    }};
}

pub fn observe() -> (i32, i32) {
    let a = 1;
    using_a!(a)
}

There are two bindings named a — one written inside the macro, one written by the caller — and the expansion appears to put them in the same block. In C this would be a disaster: the macro’s let a = 42; would shadow the caller’s, and using_a!(a) would evaluate to 42.

It returns (42, 1).

The a in the macro body and the a the caller passed in are different variables, despite being spelled identically and ending up in the same block. The macro’s a resolves to the macro’s own let; the caller’s a — which arrived as a captured expr fragment — resolves to the caller’s let. Neither can see the other.

That is hygiene, and it is the headline advantage over the C preprocessor. Every expansion gets a fresh syntax context. Two identifiers with the same spelling but different contexts are different names. A macro therefore cannot accidentally capture your variable, and you cannot accidentally capture its.

Experiment 2: it is not fully hygienic

macro_rules! call_helper {
    () => { helper() };
}

Invoke that inside mod alpha, which has its own fn helper() -> i32 { 10 }, and it calls alpha::helper. Invoke it inside mod beta, which has fn helper() -> i32 { 20 }, and it calls beta::helper.

The macro body says helper() and means whatever helper is in scope where the macro was invoked.

macro_rules! has mixed-site hygiene:

resolves at the definition site resolves at the invocation site
local variables functions
loop labels types
block labels constants, statics, modules

That split is not arbitrary. Local variables are the thing that would silently break your code if captured, so they get protected. Functions and types are the thing a macro has to be able to reach — a macro that could not call anything at the call site could not do much — so they are left to normal name resolution.

The practical consequence: generated code can be broken by what is in scope where it is used. This is mild for macro_rules! and severe for procedural macros, which have no hygiene at all (18.19). It is why generated code so often writes ::std::option::Option::None instead of None.

Experiment 3: introducing a binding on purpose

Sometimes you want a macro to define a variable the caller can see. Hygiene forbids it — the macro’s let x produces a name only the macro can reach:

error[E0425]: cannot find value `total` in this scope

The way through is to pass the name in as an ident:

macro_rules! let_named {
    ($n:ident = $v:expr) => { let $n = $v; };
}

let_named!(total = 7);
total + extra          // works

$n` was captured from the call site, so it carries the *caller's* syntax context. Emitting `let $n = ... binds the caller’s total, and the caller can use it. Hygiene is not bypassed; it is satisfied — the identifier genuinely belongs to the caller, because the caller wrote it.

::: question Two invocations of a macro that defines a loop label — can the second break out of the first’s loop?

No, and this is one of the more surprising consequences of “fresh syntax context per expansion”.

Loop labels follow the same rule as local variables: they resolve at the definition site, and each expansion gets its own context. So two expansions of the same macro produce two labels that are spelled the same and are nonetheless distinct. A break 'outer generated by expansion two cannot name the 'outer generated by expansion one.

Usually this is exactly what you want — it stops nested invocations from interfering. When you genuinely need a shared label, the fix is the same as for variables: take it as a parameter, $label:lifetime, so it carries the caller’s context. :::

Your job

observe, call_helper and the two modules are written. Fix let_named! so it introduces the binding the caller asked for. The starter hardcodes the name value, so introduced() does not compile.