Back to top

Macros in Rust: Empowering System Developers with Metaprogramming

Published: Mar 4, 2024


Introduction to Macros


Macros are a powerful feature in Rust that enable metaprogramming - generating code at compile time. They provide a way to abstract and reuse code in a very flexible manner.


What are Macros?


At a high level, macros can be thought of as functions that operate on Rust syntax structures and transform them, rather than operating on values. When invoked, macros receive a stream of tokens as input, which they can parse and manipulate, outputting a modified stream of tokens.


  • Macros run at compile time, so they can generate extensive code with little runtime cost
  • Used for eliminating boilerplate code and reducing repetition
  • Common applications: deriving traits, conditional compilation, embedding DSLs

// Example of a macro to eliminate repetition

macro_rules! print_message {
    ($msg:expr) => {
        println!("Logging: {}", $msg)
    }
}

fn main() {
   print_message!("Starting up"); 
   print_message!("Shutting down"); // No need to repeat println!
}

Types of Macros


There are two kinds of macros in Rust:

  1. Declarative macros: Created using macro_rules! construct. Based on pattern matching and replacement.
  2. Procedural macros: More complex compiled Rust code registered as compiler plugins. Often used for custom derive functionality.

Procedural macros are more flexible and powerful, but declarative macros are simpler to define.


Procedural Macros


Procedural macros allow for more advanced compile-time code generation than declarative macros. They are written in Rust and loaded/registered with the compiler as plugins.


Overview


  • Procedural macros receive a TokenStream as input which they can analyze and transform programmatically before outputting the modified tokens.
  • Commonly used for:
    • Custom derive functionality for traits
    • Conditional compilation depending on system attributes
    • Parsing domain-specific language code embedded in Rust
  • More complex to implement than declarative macros but extremely versatile.
  • Must reside in their own crate and be registered via attributes:

// Importing custom derive macro from external crate

#[derive(MyCustomDerive)] 
struct MyStruct;

Syntax


Defining procedural macros involves implementing a trait from the syn crate:


use proc_macro;
use syn;

#[proc_macro_derive(MyCustomDerive)]
pub fn my_custom_derive(input: TokenStream) -> TokenStream {
   // Macro logic goes here    
}

The function is annotated with the relevant proc macro trait depending on the context:


  • proc_macro_derive : For custom derive macros
  • proc_macro : For general attribute macros

Within the function, the syn crate is used to parse the input TokenStream from Rust code and analyze or transform it before outputting the final TokenStream that gets compiled.


Common Use Cases


Some examples of procedural macros in action:


// Custom derivation for Serialize trait
#[derive(Serialize)] 
struct MyData; 

// Conditional compilation based on OS 
#[cfg(target_os = "linux")]
fn linux_specific() {}

Procedural macros are extremely versatile for code generation tasks so these are just a sample of what’s possible. Later sections will cover more advanced macro techniques.


Declarative Macros


Declarative macros, also referred to as “macros by example”, provide a simpler approach to meta-programming compared to procedural macros. They are defined using the macro_rules! construct and rely on pattern matching and replacement.


Overview


  • Declarative macros take a set of matched tokens, transform them, and output the expansion at compile time.
  • They follow an intuitive syntax made up of two components:
    • A matcher ($() syntax)
    • The templated code to expand to {} braces
  • Some examples of declarative macro use cases:
    • Repeat code with variations
    • Embed small DSLs (domain-specific languages)
    • Simplify mappings from one syntax to another

Syntax


Defining declarative macros uses macro_rules!:


macro_rules! print_message {
   ($message:expr) => {
      println!("Log: {}", $message) 
   };
} 

print_message!("Hello World!");

The matcher $message:expr captures any Rust expression passed to the macro and inserts it into the expanded templated print statement when invoked.


Matcher patterns leverage the same syntax for capturing arguments as the match expression in Rust. Some examples:


// Match specific argument types
$numer:expr
$string:expr 

// Capture multiple arguments
($a:expr, $b:expr)

// Rest arguments 
$(*rest:expr)

Overall, declarative macros provide a simple yet flexible abstraction for handling repetitive code and hiding boilerplate through customized matching and expansion.


Macro Rules


Macro rules provide an alternate way to define declarative macros that can simplify some use cases compared to the standard macro_rules! syntax.


Writing Macro Rules


The macro_rules macro allows matching and replacement without needing to worry about delimiters or semicolons:


macro_rules! my_macro {
    ( $x:expr ) => {
        println!("x is: {}", $x);
    };

    ( $x:expr, $y:expr ) => {
        println!("x is: {}, y is: {}", $x, $y);
    }
}

The syntax for macro rules macros is clean and easy to write for simple match/replace use cases.


Macro rules also support repeating matches and template placeholders for repetition:


{ $($x:expr),+ } => {
    $(println!("{}", $x);)+
}

The $(...),+ syntax matches one or more occurrences of the pattern contained inside.


Macros Within the Same Crate


As of Rust 1.32, macros can be used in the same crate they are defined by exporting them:


mod macros {
   pub(crate) use my_macro;

   macro_rules! my_macro {
       // ...
   }
}

macros::my_macro!() // Works!

The pub(crate) makes the macro accessible to the rest of the crate.


Macro rules provide a concise, flexible way to define macros in Rust. They shine for simple match/replace cases compared to the more verbose macro_rules! syntax.


Hygiene in Macros


Hygiene refers to the way macros handle identifiers to avoid unintended variable capture or modification of values from the calling context. Hygienic macros ensure macros act like black boxes without leaking across contexts.


Understanding Hygiene


  • By default, macros create their own local scope - they are hygienic.
  • This prevents macros from accidentally modifying variables in the calling code:

let x = 1;

macro_rules! my_macro {
    () => {
        let x = 2;
        println!("{}", x); // Prints 2
    }
}

my_macro!(); // Calling context's x remains 1 

  • Hygiene makes macros more reusable across contexts without worrying about name conflicts.
  • Certain use cases require non-hygienic behavior to modify the calling context:

macro_rules! mut_x {
    () => {
        let mut x = 1; 
    }
}

mut_x!();
x += 1; // Errors without non-hygienic macro

  • Non-hygiene can be enabled in macros using $crate to reference the calling crate.

Managing Hygiene


Some ways to manage hygiene within macros:


  • Use stringify! to convert identifiers to strings to reinsert them.
  • Selectively modify the calling context with $crate.
  • Define separate mutable and immutable versions of macros.
  • Limit macro scope to minimize non-hygienic effects.

Careful hygiene management ensures macros remain reusable and modular instead of tightly coupled to calling contexts. Non-hygiene should be limited to cases where needed to modify external names.


Advanced Macro Concepts


Once the basics of declarative and procedural macros are understood, there are several more advanced macro capabilities that enable extremely powerful code generation and abstraction abilities.


Recursive Macros


Macros can invoke themselves recursively to process data structures of arbitrary sizes:


macro_rules! print_tree {
    (node $val:expr, $(subtree $sub:expr),*) => {
        println!("{}", $val);
        $(print_tree!(subtree $sub);)*
    }   
}

print_tree! {
  node 1, 
  subtree 2,
  subtree 3 
}

The $(...)* matcher allows matching zero or more comma separated subtree patterns, making the macro recursive.


Recursion enables processing tree-like data structures easily. Error handling also needs to be considered to prevent infinite expansion.


Parameterized Macros


Macros can also take more complex parameterized inputs:


macro_rules! hashmap {
   (key => $key:ty, value => $value:ty) => {
       // Generate HashMap implementation  
   }
}

hashmap! {
   key => u32, 
   value => String
}

This allows customizing implementations where parts can be substituted, reducing code repetition.


In general, macros add the ability to ingest code-like syntax and manipulate it through Matchers, Capturers, Repeaters and other parsing constructs, opening up wide possibilities for generative programming.


When combined with procedural macros and custom derivation, extremely concise yet tailored implementations can be derived from simple declarations.


Macros for Code Generation


One of the most powerful applications of macros is automating code generation for common patterns. This is especially useful for system programming where certain constructs follow a standard boilerplate pattern.


Use Cases in System Development


Some examples of boilerplate code that macros can generate in system development:


Thread Pools


thread_pool! {
   pool_size: 8,
   entry_fn: process_job,
   error_handling: {
      use log::error;
      error!("Job processing failed!");
   }
}

Custom Serialization


#[derive(Serialize, Deserialize)]
struct Data {
   id: u32   
}

serde_codegen!(Data); // Generates serialization logic

Message Passing Infrastructure


message_pass! {
   message InsertUser {
      id: u32,
      name: String,
   }

   message GetUser { id: u32 } -> User 
}

The procedural macro generates the messaging code along with handlers.


Macros Across Crates


Macros can also be shared across crates using #[macro_export]:


util crate


#[macro_export] 
macro_rules! log_error {
  ($msg:expr) => {
     // logging implementation
  }
}

main crate


use util::log_error;

log_error!("OH NO!");

With this approach entire frameworks of generative code can be built, where projects only need to declare high-level constructs and boilerplate system code is derived for them.


Debugging and Testing Macros


Since macros operate on code structures and generate extensive code, debugging them can be challenging compared to normal functions. Additionally, thorough testing is critical before relying on macro-generated code.


Techniques for Debugging Macros


Some tips for debugging macros:


  • Use println! statements within macros to trace execution and print generated tokens
  • View expanded macro output code using cargo expand
  • Step through macro code with debugger breakpoints in procedural macros
  • For syntax errors, break macro into smaller test cases to isolate issue
  • Create sample invocations of macro to iterate development

Additional code generation diagnostics can be enabled using:


#![feature(trace_macros)]

trace_macros!(true); 

Testing Macros


Strategies for testing macros:


  • Unit test macro execution and output for common cases
  • Generate code with macro and test resulting modules
  • Provide invalid inputs and expected compilation errors
  • Use macro in context of larger project as integration test

Testing up front identifies issues early before functionality relies on macro-based implementations.


Macros require additional validation compared to functions, but catch issues early by testing thoroughly and tracing incremental output during development.


Macros vs Functions


Macros and functions in Rust serve distinct purposes even though they can abstract code. Below is a comparison between macros and functions:



FeatureMacrosFunctions
Invocation
Declarative style: `my_macro! {}`
Normal function calls: `my_func()`
Arguments
Take in tokens, pattern match code structures
Take strongly typed data values
Execution
Run at **compile-time**, generate code
Execute at **runtime** as CPU instructions
Scope
Introduce new hygienic scope
Inherit outer scope, can modify external state

When to Use Macros


Use cases where macros shine over functions:


  • Eliminate repetitive code/enforce patterns
  • Customize implementations based on arguments
  • Embed domain specific languages (DSLs)
  • Enable flexible conditional compilation
  • Derive boilerplate traits e.g. serialization

Macros trade runtime performance for compile time flexibility through extensive code generation from concise declarations.


Functions are preferred for generic logic that should run performantly within normal Rust runtime environment.


Overall macro usage should be justified based on specific needs, not used arbitrarily. Signs macros may be helpful include boilerplate reduction and code generation requirements.


Best Practices for Macros


When leveraged effectively, macros can accelerate development and reduce duplication. However, some general guidelines should be followed to ensure maintainable and readable macro usage.


Guidelines


Limit Scope

  • Define macros in modules and explicitly import them when needed
  • Avoid globally exposing all macros to minimize dependencies

Favor Hygiene

  • Ensure macros introduce local scope by default
  • Carefully manage interactions with outer context

Validate Inputs

  • Check for valid parameters and expected syntax patterns
  • Return clear errors on incorrect usage

Test Thoroughly

  • Unit test macro logic and outputs for common cases
  • Stress test edge cases and failure modes

Document Public Macros

  • Provide doc comments explaining usage and parameters
  • Note macro compilation requirements

Use Judiciously

  • Prefer functions over macros where possible
  • Justify uses of macros for code generation needs

Following best practices ensures macros enhance codebases rather than causing confusion and brittleness from overuse.


Macro Development Guidelines


Some additional tips specifically for macro developers:


  • Build incrementally and validate each expansion
  • Use trace_macros! for debugging
  • Refactor long macros into reusable components
  • Support conditional compilation features if applicable

Recap and Next Steps


Macros are a unique feature in Rust that enable powerful compile-time code generation and abstraction abilities.


Key Takeaways


  • Macros operate on syntax trees, transforming code rather than values
  • Declarative and procedural macros provide different approaches
  • Common use cases include eliminating boilerplate and customizing implementations
  • Hygiene introduces local scope to avoid unintended capture
  • Macros excel where extensive code generation is required
  • Follow best practices to keep macro usage maintainable

Exercises


To practice macros:


  • Implement a declarative macro for common logging functionality
  • Create a procedural macro to derive a custom trait like Display
  • Use macros to generate thread pool implementation
  • Stress test macro error handling

These exercises reinforce key concepts and help build intuition.


Further Learning


To dive deeper into Rust macros:


  • Read advanced macro techniques like recursion
  • Understand declarative macro matcher syntax
  • Explore niche applications like embedded domain specific languages
  • Research macro APIs and integration for common projects

I hope this post provided a solid introduction to leveraging macros effectively in Rust! Let me know if you have any other questions.