As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!
Let me tell you about one of Rust’s most distinctive features, something that initially seemed like magic to me. It’s a system that lets you write code that writes other code, all before your program even runs. This isn’t about copying and pasting text; it’s about teaching the compiler how to generate Rust code for you, with all the safety and speed the language promises.
I remember staring at macros in other languages, where they often felt like a necessary evil—powerful but dangerous, like a clever trick that could backfire. Rust approached the problem differently. Its macro system is built into the compiler’s understanding of the language itself. This means the code it generates is checked just as thoroughly as the code you type by hand. There’s no compromise on safety.
Think about the repetitive tasks in programming. You define a struct, then you need a function to create it, maybe another to print it, and perhaps code to convert it to JSON. Writing this by hand for every struct is tedious and error-prone. This is where Rust macros step in. They automate this boilerplate, but they do it in a way that feels like a natural part of the language, not a separate, clunky tool.
Let’s start with the more straightforward kind: declarative macros, created with macro_rules!. You can think of these as sophisticated find-and-replace rules, but they understand the structure of Rust code. You give them patterns to look for, and you tell them what code to generate in its place. The compiler does the rest.
I’ll show you a simple one I wrote when I got tired of initializing small vectors in tests.
macro_rules! small_vec {
($($element:expr),*) => {
{
let mut temp_vec = Vec::new();
$(temp_vec.push($element);)*
temp_vec
}
};
}
fn main() {
// Using the macro is cleaner than repeated push() calls.
let numbers = small_vec![1, 2, 3, 4];
println!("{:?}", numbers); // Output: [1, 2, 3, 4]
}
The macro_rules! line defines a new macro called small_vec. Inside, you see a pattern: ($($element:expr),*). This means: match zero or more expressions, separated by commas, and call each one $element. In the replacement code, $(temp_vec.push($element);)* means: for every $element we found, generate a temp_vec.push(...); statement. The asterisk repeats the whole block.
This is a basic example, but the principle is powerful. The macro doesn’t just paste text; it knows that $element is a Rust expression. This prevents a whole class of silly syntax errors. Declarative macros are fantastic for creating pleasant, concise APIs and removing visual clutter from your code.
However, macro_rules! has its limits. It’s great for pattern matching, but for more complex logic—like reading a struct’s fields and generating a whole trait implementation—you need more power. This is where procedural macros come in. These are not defined in your main code; they are separate, special Rust functions that the compiler calls during compilation.
Procedural macros are the engine behind some of Rust’s most popular libraries. Have you ever used #[derive(Debug)] or #[derive(Clone)] above a struct? That’s a procedural macro at work. It’s called a derive macro. You write #[derive(MyTrait)], and a macro function runs, looks at your struct, and writes the correct impl MyTrait for MyStruct block automatically.
Let’s imagine we want a trait that returns a simple string description. Writing it by hand for many structs is dull. A derive macro can do it for us. Creating one involves a few steps and special crates, but the core idea is this: you write a function that receives the code of the struct as input and output the new impl block as code.
Here’s a simplified look at what that function’s logic might be, using the helper crates syn for parsing and quote for generating code.
// This is inside a procedural macro crate, not your main program.
use proc_macro::TokenStream;
use quote::quote;
use syn;
#[proc_macro_derive(Describe)]
pub fn describe_derive(input: TokenStream) -> TokenStream {
// Parse the input tokens into a syntax tree Rust understands.
let ast = syn::parse(input).unwrap();
// Our function to build the implementation.
impl_describe(&ast)
}
fn impl_describe(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident; // Get the struct's name, e.g., "Person"
// This is the code we will generate.
let gen = quote! {
impl Describe for #name {
fn describe() -> String {
// Insert the struct's name into the string.
format!("This is a struct called {}.", stringify!(#name))
}
}
};
// Convert our generated code back into tokens for the compiler.
gen.into()
}
In your main code, you would then use it like this:
use my_macros::Describe;
#[derive(Describe)]
struct Person {
name: String,
age: u32,
}
fn main() {
println!("{}", Person::describe()); // Output: This is a struct called Person.
}
The beauty is in the automation. The macro author writes the logic once. Every user who slaps #[derive(Describe)] on their struct gets a perfect, custom implementation. The serde library for serialization uses this exact technique to generate incredibly efficient code for converting any struct to and from JSON or other formats.
There’s another type called attribute macros. These are even more flexible. While a derive macro only works on structs or enums and only for traits, an attribute macro can be attached to almost anything—functions, structs, modules—and can modify them or generate entirely new items alongside them. They look like #[my_attribute(some_argument)].
For example, a web framework might use an attribute macro to turn a simple function into a web route.
#[route(GET, "/user/:id")]
fn get_user(id: u32) -> Json<User> {
// ... fetch user from database
}
During compilation, the #[route] macro would see this function, read its signature and the path, and generate all the boilerplate code needed to register it with the web server, parse the :id from the URL, convert it to a u32, and call this function. You write the simple business logic; the macro handles the repetitive web glue.
You might wonder how this compares to what other languages do. In C and C++, the preprocessor does text substitution. It doesn’t understand the language; it just replaces one string with another. This can lead to bizarre bugs because the context is lost. Lisp has powerful macros that work on code as data, but it doesn’t have Rust’s static type system woven into the process.
Rust’s macros operate on the abstract syntax tree—the structured representation of your code. This is why they are “hygienic.” Identifiers inside a macro won’t accidentally clash with identifiers in the code you’re using it in. You don’t get the mysterious bugs that C programmers sometimes face with their macros. It’s metaprogramming with guardrails.
A practical place I use macros is in configuration. Let’s say I have a settings struct for my application.
#[derive(Deserialize)]
struct AppConfig {
host: String,
port: u16,
debug_mode: bool,
}
The #[derive(Deserialize)] macro (from serde) will generate code so I can write let config: AppConfig = toml::from_str(config_file_text)?;. The macro has read my struct, and for each field, it generated code to parse the correct type from the TOML string. If I add a new field, the parsing code updates automatically. The safety is incredible; if the TOML file has a string where a number should be, I get a clear type error at startup, not a runtime crash.
Performance is a key point. All this macro expansion happens when you run cargo build. By the time you run your program, the macros are gone. They have been replaced by the exact Rust code they were designed to generate. This means there is zero runtime overhead. The generated code is just as fast as if you’d written it yourself, and it gets optimized by the compiler in the same way.
Of course, there is a cost: compile time. Complex macros, especially procedural ones, make the compiler work harder. It has to load the macro, run it, and then compile the generated code. The Rust team is constantly improving this, and for most projects, the trade-off is worth it. The time saved in writing and maintaining code far outweighs a few extra seconds of compilation.
For library authors, macros are a tool for creating amazing developer experiences. The println! macro you use every day is more powerful than a simple function. It checks your format string against the types of your arguments at compile time. Try to write println!("{}", 10); and then println!("{}");—the second will cause a compiler error because an argument is missing. A regular function couldn’t do that check.
Getting started with writing your own macros can feel daunting. My advice is to start with a small macro_rules! macro to automate a tiny bit of repetition in your current project. Get a feel for the pattern-matching syntax. When you hit its limits, explore a derive macro using the excellent syn and quote crates. The documentation for these crates is full of examples.
The ecosystem around macros is robust. Tools like Rust Analyzer in your IDE can often show you the expanded macro output. This is invaluable for debugging. When a macro behaves unexpectedly, you can ask your IDE to show you the generated code, and it’s like lifting the hood to see the engine.
Error messages from within macros have gotten much better. As a macro author, you can use compile_error! to provide helpful, guiding errors if someone uses your macro incorrectly. Instead of a generic "syntax error," you can output "The log_value macro requires a string literal as its first argument."
Looking ahead, the role of macros in Rust is solid. They are not a fringe feature but a core part of how expressive and safe abstractions are built. They allow experts to build powerful tools—like entire embedded domain-specific languages for hardware or type-safe SQL query builders—that beginners can use safely and effectively.
In my own work, macros transformed how I think about problems. I no longer see repetitive code as just a chore. I see it as a pattern, a signal that there might be an abstraction waiting to be captured. With Rust’s macro system, I can capture that pattern once, bake the safety in, and let the compiler replicate it perfectly everywhere it’s needed. It feels less like programming and more like teaching the compiler to help me write better code. That’s the real power: metaprogramming that feels like a natural extension of the language, not a separate, risky hack.
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva
Top comments (0)