If you’ve been playing around with Rust for at least a little while, you’ve probably come across procedural macros — or proc macros for short — whether you realized it or not. The most popular proc macros may well be Serde’s derive macros for serialize
and deserialize
; you may have also come across them if you’ve worked with the Rocket web framework, the wasm-bindgen project, or any number of other libraries.
In this tutorial, we’ll tell you everything you need to know about procedural macros in Rust. We’ll cover the following:
In short, procedural macros allow you to take a piece of Rust code, analyze it, and generate more Rust code from it. It’s a form of metaprogramming, a way of using Rust to write Rust code.
In Star Wars Episode II: Attack of the Clones, the droid C-3PO, stumbling through a droid manufacturing plant, utters, “Machines making machines? How perverse.”
Personally, I’d have gone with “awesome,” but that may be a matter of taste.
Anyway, proc macros allow you to generate Rust code at compile time. It’s a fairly advanced topic, but a very, very powerful feature of the Rust language.
Rust has two different kinds of macros: declarative macros (declared with macro_rules!
) and procedural macros. Both kinds of macros allow you to write code that writes more code and is evaluated at compile time, but they work in very different ways.
Declarative macros (also known colloquially — and somewhat confusingly — as simply “macros”) enable you to write something similar to a match
expression that operates on the Rust code you provide as arguments. It uses the code you provide to generate code that replaces the macro invocation. In a sense, a declarative macro is just sophisticated text substitution.
Procedural macros, on the other hand, allow you to operate on the abstract syntax tree (AST) of the Rust code it is given. As far as function signatures go, a proc macro is a function from a TokenStream
(or two) to another TokenStream
, where the output replaces the macro invocation. This allows you to inspect the Rust code at compile time and facilitates sophisticated code generation based on the program’s AST.
Put simply, declarative macros allow you to match against patterns of code and generate code based on the pattern. Procedural macros allow you to inspect and operate on the code you’re provided before generating the output, giving you much more power.
There are three kinds of procedural macros: derive macros, function-like macros, and attribute macros. They all operate on TokenStreams
, but they do it in slightly different ways.
#[derive(MyMacro)]
declaration. They can also declare helper attributes, which can be attached to members of the items (such as enum variants or struct fields)!
and look like function calls. They operate on the code you put inside the parenthesesLet’s get our hands a little dirty. Now that we’ve established a rough idea of what proc macros are, let’s create a simple one just to get a feel for it. We’ll create a derive macro that prints information about the struct, enum, or union that it’s attached to, including what kind of item it is and what members or variants it has, if any.
The first thing you need to do when creating a procedural macro crate is to let Cargo know about this in your Cargo.toml
file. You do this by adding an entry that looks like this:
[lib] proc-macro = true
In our case, to test the macro, we’ll use Cargo’s workspaces feature and create a main executable file (main.rs
) and a library that holds the proc macro (derive-macro/src/lib.rs
). The whole directory structure looks something like this:
. ├── Cargo.toml ├── derive-macro │ ├── Cargo.toml │ └── src │ └── lib.rs └── main.rs >
The top-level Cargo.toml
file holds information about our binary and the dependencies it has:
[package] name = "proc_macros" version = "0.1.0" edition = "2018" publish = false [workspace] [[bin]] name = "proc-macros" path = "main.rs" [dependencies] derive-macro = { path = "derive-macro" }
The derive-macro/Cargo.toml
contains the dependencies for the proc macro project as well as the aforementioned proc-macro = true
annotation.
[package] name = "derive-macro" version = "0.1.0" edition = "2018" [lib] proc-macro = true [dependencies] syn = { version = "1.0", features = ["full"] } quote = "1.0" proc-macro2 = "1.0"
A thorough introduction to writing proc macros is outside the scope of this article. Instead, please refer to the resources mentioned at the end of this tutorial.
What I will do, though, is show you a quick example using the syn
, quote
, and proc_macro2
crates. These are your bread and butter when working with proc macros.
As mentioned, we want our macro to describe the item it’s attached to. Let’s see how this is handled in derive-macro/src/lib.rs
:
use proc_macro::{self, TokenStream}; use quote::quote; use syn::{parse_macro_input, DataEnum, DataUnion, DeriveInput, FieldsNamed, FieldsUnnamed}; #[proc_macro_derive(Describe)] pub fn describe(input: TokenStream) -> TokenStream { let DeriveInput { ident, data, .. } = parse_macro_input!(input); let description = match data { syn::Data::Struct(s) => match s.fields { syn::Fields::Named(FieldsNamed { named, .. }) => { let idents = named.iter().map(|f| &f.ident); format!( "a struct with these named fields: {}", quote! {#(#idents), *} ) } syn::Fields::Unnamed(FieldsUnnamed { unnamed, .. }) => { let num_fields = unnamed.iter().count(); format!("a struct with {} unnamed fields", num_fields) } syn::Fields::Unit => format!("a unit struct"), }, syn::Data::Enum(DataEnum { variants, .. }) => { let vs = variants.iter().map(|v| &v.ident); format!("an enum with these variants: {}", quote! {#(#vs),*}) } syn::Data::Union(DataUnion { fields: FieldsNamed { named, .. }, .. }) => { let idents = named.iter().map(|f| &f.ident); format!("a union with these named fields: {}", quote! {#(#idents),*}) } }; let output = quote! { impl #ident { fn describe() { println!("{} is {}.", stringify!(#ident), #description); } } }; output.into() }
The first step is to take the TokenStream
we receive as an argument. We parse it using syn
‘s parse_macro_input
macro. This gives us access to the ident
(the identifier, name, of the item) and data about what the item contains. We match on this data to find out whether the item is a struct, an enum, or a union. If it’s a struct, we do a further match to check whether it has fields and whether they are named. Based on the kind of item it is, we inspect the fields or variants it has and create a string that describes it.
While the syn
crate gives us the power to parse an incoming TokenStream
and work on it, the quote
crate is what lets us generate the new Rust code. The quote
macro allows us to write almost regular Rust code, which can be turned into actual Rust code. The weird syntax (such as quote! {#(#idents), *}
) is how we do variable interpolation and repetition.
With this macro, let’s put this in the main.rs
file:
use derive_macro::Describe; #[derive(Describe)] struct MyStruct { my_string: String, my_enum: MyEnum, my_number: f64, } #[derive(Describe)] struct MyTupleStruct(u32, String, i8); #[derive(Describe)] enum MyEnum { VariantA, VariantB, } #[derive(Describe)] union MyUnion { unsigned: u32, signed: i32, } fn main() { MyStruct::describe(); MyTupleStruct::describe(); MyEnum::describe(); MyUnion::describe(); }
Running the program will produce output that looks like this.
MyStruct is a struct with these named fields: my_string, my_enum, my_number. MyTupleStruct is a struct with 3 unnamed fields. MyEnum is an enum with these variants: VariantA, VariantB. MyUnion is a union with these named fields: unsigned, signed.
Procedural macros are a very powerful feature of the Rust language and have incredible potential. But they are also very complex and require some time and deliberate effort to really understand and get comfortable with.
This guide serves as a high-level overview of what they are and how you can get started with them. If you want to crawl deeper into the rabbit hole, I recommend the following resources:
TokenStream
type, and where we find proc macros in the wild
Debugging Rust applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking the performance of your Rust apps, automatically surfacing errors, and tracking slow network requests and load time, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Rust application. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.
Modernize how you debug your Rust apps — start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.