Lesson 1's very first code example came with a one-line aside: println! is "a macro (note the !)" that prints text to the console. Thirty-six lessons later, that aside has never been explained - even as more !s piled up: vec!, format!, write!, assert_eq!, panic!, and last lesson's tokio::join!. Every one of those is a macro. This lesson explains what that means - and macro_rules!, how to write your own.
One name, three signatures
Start with something already familiar:
Run it: [], [1, 2, 3], [5, 5, 5]. Three calls to vec!, three completely different argument shapes - zero arguments, a comma-separated list, and a value; count pair. A real function has one signature: a fixed number of parameters, fixed types, in a fixed order. vec! has all three. No function can do that.
vec! can, because it isn't a function. A macro is a transformation from code to code - it runs at compile time, before type-checking, and produces the actual Rust that gets compiled. vec![1, 2, 3] doesn't call anything at runtime; it expands, before the compiler even starts checking types, into ordinary Rust - roughly an empty vector followed by three pushes. Three different inputs, three different expansions. That's the whole trick.
macro_rules!: patterns in, code out
Run it: Hello from a macro!, twice. macro_rules! defines a macro as one or more arms, and the resemblance to match arms is not an accident. match compares a value against patterns and runs the first arm that fits. macro_rules! compares the tokens written inside the parentheses at the call site against patterns and expands into the first arm that fits. Here there's one arm, with the simplest possible pattern: empty parentheses, "nothing." say_hello!() matches it, and is replaced - literally, textually - with println!("Hello from a macro!");.
Capturing pieces: $name:fragment
$x:expr means "match one Rust expression here, and call it $x." The expansion, $x * 2, substitutes whatever was captured. double!(5) expands to 5 * 2 - 10. Unsurprising.
double!(2 + 3) is the interesting one. Run it: 10 - not 8. If this were naive text substitution, $x * 2 with $x standing in for 2 + 3 would read as 2 + 3 * 2, and multiplication binds tighter than addition: 2 + 6 = 8. That's the classic bug behind C's #define SQUARE(x) x*x - SQUARE(1+2) silently becomes 1+2*1+2 = 5, not 9. $x:expr doesn't work that way: it captures 2 + 3 as one sealed unit and substitutes it as a unit, as if parenthesized - (2 + 3) * 2 = 10. Part of why "Rust macros are dangerous, like C macros" is a myth: macro_rules! works on syntax trees, not text.
When nothing matches
double! has exactly one arm, and its pattern is $x:expr - one expression, nothing else. 5, 10 isn't one expression; it's two, separated by a comma the pattern has no place for. macro_rules! tries each arm in order - here, just the one - and when none match, the error is blunt: no rules expected `,`. This is the same idea as a non-exhaustive match, but at compile time, before types even exist: not "no arm handles this value" but "no arm handles this shape of call."
Repetition: $(...),* and a tiny vec!
Run it: [1, 2, 3]. The new syntax is $(...),* - "zero or more of this, separated by commas." In the pattern, $($x:expr),* matches 1, 2, 3 as three separate captures of $x. In the expansion, $(v.push($x);)* repeats v.push($x); once per capture - v.push(1); v.push(2); v.push(3);. The whole macro expands to a small block: build an empty Vec::new(), push everything, then evaluate to v. This - roughly - is what vec![1, 2, 3] has expanded to since lesson 17. $(...),* is also why vec! can take zero arguments, three, or three hundred: there's no fixed parameter list, just a pattern that repeats.
Multiple arms, and macros calling themselves
Run it: 5, 1, 2. Two arms, tried in order - exactly like match arms, top to bottom, first match wins. my_min!(5) - one expression - matches the first arm and expands to just 5. my_min!(3, 1, 2) doesn't match the first arm (that's three expressions, not one) but does match the second: $x is 3, and $($rest:expr),+ - one or more, this time - captures 1, 2.
The expansion calls my_min! again, on the smaller list: my_min!(1, 2). That expands again - $x is 1, $rest is 2 - calling my_min!(2), which finally hits the first arm and stops. Unwind the recursion and my_min!(3, 1, 2) has become a small tree of comparisons between the literals 3, 1, and 2 - and all of this happens at compile time. By the time the program runs, the macro is gone; only the comparisons remain. Same promise as generics' monomorphization: zero runtime cost - there, for types; here, for syntax.
Quick exercise
- Real
vec!allows a trailing comma -vec![1, 2, 3,]. Try the same withmy_vec!:my_vec![1, 2, 3,]. The error -unexpected end of macro invocation- happens because$($x:expr),*'s commas sit between captures, with none left over for a trailing one. Fix it by changing the pattern to$($x:expr),* $(,)?- an optional, separately-matched trailing comma - and the same call compiles. - Copy
my_min!, rename itmy_max!, and flip$x < rest_minto$x > rest_min. Verifymy_max!(7, 2, 9, 4)is9.
Generics and macro_rules! are both "write it once, reuse it many ways" - along different axes. Generics: one function or struct, many types, each checked and compiled separately (monomorphization). Macros: one definition, many shapes of calls - different argument counts, different syntax entirely - expanded before types are even in the picture. Most Rust code never defines a macro_rules!; reaching for one is rare, and the error messages above point at the macro definition, one layer removed from your code, which is part of why they're usually a last resort. But println!, vec!, format!, write!, assert!, assert_eq!, panic!, tokio::join! - this entire course, every ! - is exactly these three pieces: arms, captures, repetition.
One more kind exists - procedural macros, which is what powers #[derive(Debug)] and #[derive(Serialize, Deserialize)]: full Rust programs that run at compile time and generate code, rather than pattern-and-template. Different machinery, same spirit - and now a recognizable one.
One lesson left: not new syntax, but a map - what this course covered, what it deliberately didn't, and where to go from here.