Rust · lesson 27 of 30
Beginner
15- 1 Introduction to Rust
- 2 Setting Up Rust
- 3 Variables in Rust
- 4 Data Types in Rust
- 5 Operators in Rust
- 6 Control Flow in Rust
- 7 Functions in Rust
- 8 Ownership in Rust
- 9 Borrowing and References
- 11 Structs in Rust
- 12 Enums in Rust
- 13 Pattern Matching in Rust
- 17 Collections in Rust
- 25 Testing in Rust
- 26 File I/O in Rust
Intermediate
9Advanced
6Macros in Rust
Write Rust macros with macro_rules!, build derive and attribute procedural macros, and use serde, thiserror and clap — all compiled and run for real.
By SkillByExample · Updated
Declarative Macros with macro_rules!
Declarative macros match patterns on the input token stream and expand to Rust code:
macro_rules! say_hello {
() => {
println!("Hello!");
};
($name:expr) => {
println!("Hello, {}!", $name);
};
}
fn main() {
say_hello!(); // Hello!
say_hello!("Alice"); // Hello, Alice!
say_hello!("Bob"); // Hello, Bob!
}
$ rustc macros_declarative_macros_with_macro_rules.rs && ./macros_declarative_macros_with_macro_rules
Hello!
Hello, Alice!
Hello, Bob!
Metavariable Types
| Designator | Matches |
|---|---|
expr | An expression |
ident | An identifier or keyword |
ty | A type |
pat | A pattern |
stmt | A statement |
block | A block { ... } |
item | A top-level item (fn, struct, etc.) |
literal | A literal value |
tt | A single token tree |
path | A path like std::io::Write |
Variadic Macros with Repetition
$(...)* matches zero or more; $(...)+ matches one or more:
macro_rules! max {
($x:expr) => { $x };
($x:expr, $($rest:expr),+) => {
{
let rest_max = max!($($rest),+);
if $x > rest_max { $x } else { rest_max }
}
};
}
macro_rules! hashmap {
($($key:expr => $val:expr),* $(,)?) => {
{
let mut m = std::collections::HashMap::new();
$(m.insert($key, $val);)*
m
}
};
}
fn main() {
println!("{}", max!(3, 1, 4, 1, 5, 9, 2, 6));
let map = hashmap! {
"one" => 1,
"two" => 2,
"three" => 3,
};
println!("{}", map["two"]);
}
$ rustc macros_variadic_macros_with_repetition.rs && ./macros_variadic_macros_with_repetition
9
2
A Practical assert_approx_eq! Macro
macro_rules! assert_approx_eq {
($a:expr, $b:expr) => {
assert_approx_eq!($a, $b, 1e-9)
};
($a:expr, $b:expr, $eps:expr) => {
// the f64 annotation matters: without it `(a - b).abs()` is ambiguous
let (a, b, eps): (f64, f64, f64) = ($a, $b, $eps);
assert!(
(a - b).abs() < eps,
"assertion failed: |{} - {}| = {} >= {}",
a, b, (a - b).abs(), eps
);
};
}
fn main() {
println!("0.1 + 0.2 == 0.3? {}", 0.1 + 0.2 == 0.3);
assert_approx_eq!(0.1 + 0.2, 0.3);
assert_approx_eq!(1.0, 1.0001, 0.001);
println!("all assertions passed");
assert_approx_eq!(1.0, 1.1, 0.01);
}
$ rustc macros_a_practical_assert_approx_eq_macro.rs && ./macros_a_practical_assert_approx_eq_macro
0.1 + 0.2 == 0.3? false
all assertions passed
thread 'main' (20536) panicked at macros_a_practical_assert_approx_eq_macro.rs:21:5:
assertion failed: |1 - 1.1| = 0.10000000000000009 >= 0.01
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
== says 0.1 + 0.2 is not 0.3, but it is within 1e-9, so the two-argument form passes; the second call uses the three-argument arm with a looser tolerance. The last call fails, and the message is the macro’s own format string with the real values substituted. (The number in thread 'main' (N) is the OS thread ID; it varies from run to run.)
Without the (f64, f64, f64) annotation this macro does not compile at all: the literals 0.1, 0.3 are an ambiguous {float} until something pins the type, and the compiler refuses to call .abs() on it (error[E0689]: can't call method 'abs' on ambiguous numeric type '{float}').
Generating Boilerplate with Macros
macro_rules! impl_from_str_for_enum {
($enum:ident, $($variant:ident => $s:literal),+) => {
impl std::str::FromStr for $enum {
type Err = String;
fn from_str(s: &str) -> Result<Self, String> {
match s {
$($s => Ok($enum::$variant),)+
_ => Err(format!("unknown {}: {}", stringify!($enum), s)),
}
}
}
impl std::fmt::Display for $enum {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
$($enum::$variant => write!(f, $s),)+
}
}
}
};
}
#[derive(Debug, PartialEq)]
enum Color { Red, Green, Blue }
impl_from_str_for_enum!(Color,
Red => "red",
Green => "green",
Blue => "blue"
);
fn main() {
let c: Color = "green".parse().unwrap();
println!("{:?}", c);
println!("{}", Color::Blue); // blue
}
$ rustc macros_generating_boilerplate_with_macros.rs && ./macros_generating_boilerplate_with_macros
Green
blue
Procedural Macros
Procedural macros are Rust functions that transform token streams. They live in their own crate with proc-macro = true.
Cargo.toml for the macro crate:
[lib]
proc-macro = true
[dependencies]
syn = { version = "2", features = ["full"] }
quote = "1"
proc-macro2 = "1"
Custom Derive Macro
// In the proc-macro crate: my_macros/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(Describe)]
pub fn describe_derive(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = &input.ident;
let expanded = quote! {
impl #name {
pub fn describe() -> &'static str {
concat!("I am a ", stringify!(#name))
}
}
};
TokenStream::from(expanded)
}
Usage in the main crate:
use my_macros::Describe;
#[derive(Describe)]
struct Robot;
#[derive(Describe)]
struct Human;
fn main() {
println!("{}", Robot::describe());
println!("{}", Human::describe());
}
With my_macros added as a path dependency (my_macros = { path = "../my_macros" }):
$ cargo run -q
I am a Robot
I am a Human
The derive ran at compile time and wrote an impl Robot { fn describe() ... } block for each type; stringify!(#name) turned the type’s identifier into the string literal.
Attribute Macros
An attribute macro receives the item it is attached to and returns a replacement. This one keeps the function’s signature and wraps its body with trace lines:
// proc-macro crate (same lib.rs as above, plus `use syn::ItemFn;`)
#[proc_macro_attribute]
pub fn log_call(_attr: TokenStream, item: TokenStream) -> TokenStream {
let input = parse_macro_input!(item as ItemFn);
let vis = &input.vis;
let sig = &input.sig;
let body = &input.block;
let name = sig.ident.to_string();
// Re-emit the same signature, wrapping the original body
let expanded = quote! {
#vis #sig {
println!("-> {}", #name);
let result = (|| #body)();
println!("<- {}", #name);
result
}
};
TokenStream::from(expanded)
}
use my_macros::log_call;
#[log_call]
fn add(a: i32, b: i32) -> i32 {
println!(" adding {} + {}", a, b);
a + b
}
fn main() {
let total = add(2, 3);
println!("total = {}", total);
}
$ cargo run -q
-> add
adding 2 + 3
<- add
total = 5
The original body runs inside a closure so that its return value can be captured and returned after the exit line prints. add’s callers never see the change — its signature is untouched.
derive Macros from the Ecosystem
serde — Serialization
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
struct User {
name: String,
age: u32,
#[serde(skip_serializing_if = "Option::is_none")]
email: Option<String>,
#[serde(rename = "createdAt")]
created_at: String,
}
fn main() {
let user = User {
name: "Alice".into(),
age: 30,
email: None,
created_at: "2024-01-01".into(),
};
let json = serde_json::to_string_pretty(&user).unwrap();
println!("{}", json);
let back: User = serde_json::from_str(&json).unwrap();
println!("{:?}", back);
}
$ cargo run -q
{
"name": "Alice",
"age": 30,
"createdAt": "2024-01-01"
}
User { name: "Alice", age: 30, email: None, created_at: "2024-01-01" }
Both field attributes show up in the JSON: email is omitted because it is None, and created_at is written as createdAt. Reading it back still works — a missing Option field deserializes to None.
thiserror — Error Types
[dependencies]
thiserror = "1"
use thiserror::Error;
#[derive(Debug, Error)]
enum AppError {
#[error("not found: {0}")]
NotFound(String),
#[error("invalid input: {0}")]
InvalidInput(String),
#[error(transparent)]
Io(#[from] std::io::Error),
}
fn load(name: &str) -> Result<String, AppError> {
if name.is_empty() {
return Err(AppError::InvalidInput("empty name".into()));
}
if name == "ghost" {
return Err(AppError::NotFound(name.into()));
}
Ok(std::fs::read_to_string(name)?)
}
fn main() {
for name in ["", "ghost", "missing.txt"] {
println!("{:?} -> {}", name, load(name).unwrap_err());
}
}
$ cargo run -q
"" -> invalid input: empty name
"ghost" -> not found: ghost
"missing.txt" -> No such file or directory (os error 2)
#[error(transparent)] forwards Display straight to the wrapped io::Error, so the third line is the OS message with no prefix of its own.
clap — CLI Parsing
[dependencies]
clap = { version = "4", features = ["derive"] }
use clap::Parser;
#[derive(Parser, Debug)]
#[command(name = "my-tool", about = "A CLI tool")]
struct Args {
/// Who to greet
#[arg(short, long)]
name: String,
/// How many times to greet
#[arg(short, long, default_value = "1")]
count: u32,
/// Print the parsed arguments first
#[arg(long)]
verbose: bool,
}
fn main() {
let args = Args::parse();
if args.verbose {
println!("{:?}", args);
}
for _ in 0..args.count {
println!("Hello, {}!", args.name);
}
}
In a package named my-tool:
$ cargo run -q -- --name Ada -c 2 --verbose
Args { name: "Ada", count: 2, verbose: true }
Hello, Ada!
Hello, Ada!
$ cargo run -q -- --help
A CLI tool
Usage: my-tool [OPTIONS] --name <NAME>
Options:
-n, --name <NAME> Who to greet
-c, --count <COUNT> How many times to greet [default: 1]
--verbose Print the parsed arguments first
-h, --help Print help
$ cargo run -q -- --count 3
error: the following required arguments were not provided:
--name <NAME>
Usage: my-tool --name <NAME> --count <COUNT>
For more information, try '--help'.
The /// doc comments became the help text, short derived -n/-c from the field names, and a String field with no default is required — the last run exits with status 2 before main’s loop ever runs. The Usage: line takes the program name from how it was launched (the binary is my-tool because the package is), not from #[command(name)].
Built-in Macros Reference
fn main() {
// Formatting
println!("[{}]", format!("{:>10}", "right")); // right-aligned in 10 columns
println!("[{}]", format!("{:0>5}", 42)); // zero-padded
// Compile-time information
println!("{}:{}:{}", file!(), line!(), column!());
println!("{}", stringify!(1 + 2 * x)); // tokens to a string, unevaluated
println!("{}", concat!("v", 1, ".", 2)); // literals joined at compile time
println!("{:?}", option_env!("NO_SUCH_VAR")); // env var read at *compile* time
// Debug helpers — dbg! prints to stderr and returns its value
let y = dbg!(1 + 2) * 10;
println!("y = {}", y);
}
$ rustc macros_built_in_macros_reference.rs && ./macros_built_in_macros_reference
[ right]
[00042]
macros_built_in_macros_reference.rs:7:44
1 + 2 * x
v1.2
None
[macros_built_in_macros_reference.rs:13:13] 1 + 2 = 3
y = 30
dbg! prints the file, line, the expression’s source text and its value, then hands the value back — so it can be dropped into the middle of an expression. env!("CARGO_PKG_NAME") works the same way as option_env! but fails to compile if the variable is missing; Cargo sets CARGO_PKG_NAME for every build, plain rustc does not. include_str!("data.txt") and include_bytes! embed a file’s contents into the binary at compile time.
The panicking macros — panic!, todo!, unimplemented!, unreachable! — all compile to a panic; they differ only in the message and the intent they document:
fn parse_flag(s: &str) -> bool {
match s {
"on" => true,
"off" => false,
_ => todo!("handle {:?}", s),
}
}
fn main() {
println!("{}", parse_flag("on"));
println!("{}", parse_flag("maybe"));
}
$ rustc macros_built_in_macros_reference_2.rs && ./macros_built_in_macros_reference_2
true
thread 'main' (9600) panicked at macros_built_in_macros_reference_2.rs:5:14:
not yet implemented: handle "maybe"
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
todo! type-checks as any type — here bool — which is what lets you leave an arm unfinished and still compile. As before, the number in thread 'main' (N) is the OS thread ID and varies per run.
Practice
-
Write a
strings!macro that turns any list ofDisplayvalues into aVec<String>, and allows a trailing comma:strings!["a", 'b', 3, 4.5,].Solution
macro_rules! strings { ($($s:expr),* $(,)?) => { vec![$($s.to_string()),*] }; }It produces
["a", "b", "3", "4.5"].$(,)?matches zero or one trailing comma; the$(...),*in the body repeats once per captured$s. -
Write a
map!macro so thatmap! { "alice" => 30, "bob" => 25 }builds aBTreeMap.Solution
macro_rules! map { ($($k:expr => $v:expr),* $(,)?) => {{ let mut m = BTreeMap::new(); $( m.insert($k, $v); )* m }}; }Prints
{"alice": 30, "bob": 25}. The double braces make the expansion a block expression, so theletand theinserts stay local and the block evaluates tom. -
Call
assert_approx_eq!(1, 1.0). The macro text is pasted in before type checking — so where does the compiler point?Solution
error[E0308]: mismatched types --> macros_badarg.rs:12:23 | 12 | assert_approx_eq!(1, 1.0); | ^ expected `f64`, found integerIt points at the
1at the call site, not inside the macro — the compiler tracks where each token came from. The expectedf64comes from the(f64, f64, f64)annotation in the expansion.