We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Macros, FFI and Type-Driven Design step 11 of 28
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.
Stuck?
Rust reference solution
Sign in to attempt this problem and reveal the reference solution.