Thomas Heartman Developer, speaker, musician, and fitness instructor.

Procedural macros in Rust

5 min read 1539

Procedural Macros in Rust

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:

What are procedural macros in Rust?

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.

Declarative macros and procedural macros

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.

Derive, function-like, and attribute macros

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 macros work on structs, enums, and unions and are annotated with a #[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)
  • Function-like macros are similar to declarative macros in that they are invoked with the macro invocation operator ! and look like function calls. They operate on the code you put inside the parentheses
  • Attribute macros define new outer attributes, which can be attached to items. They’re similar to derive macros but can be attached to more items, such as trait definitions and functions

Creating a derive macro in Rust

Let’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"

Writing a procedural macro in Rust

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.

Further reading

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:

 



LogRocket: Full visibility into web frontends for Rust apps

Debugging Rust applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking 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 app. 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 — .

Thomas Heartman Developer, speaker, musician, and fitness instructor.

Leave a Reply