Unlock Your Python Backend Career: Build 30 Projects in 30 Days. Join now for just $54

Rust Advanced

Advanced Rust

Rust Advanced

Meta-programming with Macros in Rust

Meta-programming with Macros in Rust

Have you ever heard of automatic code generation? Or code that writes code? Well, in Rust, macros are a way to write code that writes code. They enable you to generate repetitive or boilerplate code automatically. In this section, we will explore the fundamentals of macros, learn how to get started writing custom macros if needed, and dive into procedural macros for more advanced use cases.

Note: Sometimes developers make the mistake of learning everything they can about a language instead of only what they need to focus on and start building projects as soon as possible. Rust is a wide general-purpose language, and you must learn many things. But there are also lots of things you may not need to learn. For example, if you are not into operating system-level programming or embedded systems development, you probably will not learn how to write [no_std] code. You should learn only what you need at the moment you need it.

Macros are applicable in various scenarios and can be extremely useful in specific situations. Here's when and why you might use macros:

  1. Code Generation: Macros are often used to generate repetitive code constructs. For example, if you have a set of data structures that require similar getter and setter methods, you can use a macro to generate these methods automatically.

  2. Domain-Specific Languages (DSLs): Macros can create syntactic constructs that resemble a specialized language within your programming language. This can lead to more readable and expressive code. For instance, you might create DSL-like macros for defining HTML templates or SQL queries.

  3. Ergonomics and Readability: Macros can be employed to create more ergonomic and readable APIs. By abstracting complex or repetitive code patterns into simple macro invocations, you improve the user experience of your library or framework.

  4. Custom Derive and Annotations: Procedural macros are useful for automatically generating trait implementations or adding custom annotations to your code. This can save developers time and reduce the risk of errors.

  5. Conditional Compilation: Macros can enable conditional compilation based on certain conditions or target platforms. This is useful when supporting different features or behaviors depending on the environment.

  6. Meta-programming and Reflection: Macros allow you to perform meta-programming, where your code inspects or modifies itself. This can lead to creative solutions and optimization techniques.

In Rust, anyone working on projects that involve repetitive code patterns, DSLs, custom annotations, or performance optimization can benefit from learning macros. This includes:

  • Library Authors: Creating ergonomic APIs using macros can enhance the usability of your library.

  • Application Developers: Macros can help you reduce boilerplate code and improve code readability.

  • Systems Programmers: Writing procedural macros can be essential when generating specialized code or working closely with the AST.

  • Performance Optimizers: Macros can be used for optimizations and code transformations that lead to better performance.

While macros can be incredibly powerful, they also come with challenges. Macros can be hard to debug, introduce complexity, and hinder code readability if used excessively or improperly. Writing efficient and maintainable macros requires a solid understanding of Rust's syntax, ownership system, and macro hygiene. Therefore, individuals looking to harness the power of macros should invest time in learning and practicing this aspect of Rust programming.

Examples of popular Rust macros

Let's explore some common macros in Rust, dissect their components, and understand how they work to generate or transform code.

Example 1: vec! Macro

The vec! macro creates a new Vec (dynamic array) with initial elements. It provides a convenient way to create and initialize a vector.

let numbers: Vec<i32> = vec![1, 2, 3, 4, 5];

Dissection:

  • The vec! macro takes a comma-separated list of values inside square brackets [...].

  • The macro expands to code that creates a new Vec, initializes it with the provided values, and returns the resulting vector.

Under the Hood: When you write vec![1, 2, 3, 4, 5], the macro expands into something like:

{
    let mut temp_vec = Vec::new();
    temp_vec.push(1);
    temp_vec.push(2);
    temp_vec.push(3);
    temp_vec.push(4);
    temp_vec.push(5);
    temp_vec
}

The macro captures the provided values and generates the corresponding code to create and initialize the vector.

Example 2: println! Macro

The println! macro is used to print formatted output to the console.

let name = "Alice";
let age = 30;
println!("Hello, {}! You are {} years old.", name, age);

Dissection:

  • The println! macro takes a format string followed by arguments.

  • Placeholders like {} are used to insert values from the arguments inside the format string.

Under the Hood: When you write println!("Hello, {}! You are {} years old.", name, age), the macro expands into a series of codes that constructs the formatted string and outputs it:

{
    use std::io::Write;
    let mut output = std::io::stdout();
    write!(output, "Hello, {}! You are {} years old.\\\\n", name, age).unwrap();
}

The macro generates code to write the formatted string to the standard output.

Example 3: derive Attribute

The derive attribute is used to automatically generate implementations for certain traits. For instance, you can use #[derive(Debug)] to automatically implement the Debug trait for a struct.

#[derive(Debug)]
struct Person {
    name: String,
    age: u32,
}

Dissection:

  • The derive attribute is applied to a struct or enum.

  • It indicates that Rust should automatically generate code for the specified trait(s).

Under the Hood: When you write #[derive(Debug)], the compiler generates the Debug trait implementation for the Person struct:

impl std::fmt::Debug for Person {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Person")
            .field("name", &self.name)
            .field("age", &self.age)
            .finish()
    }
}

The macro saves you from manually implementing the Debug trait's formatting methods.

These examples demonstrate how macros in Rust save you from writing repetitive or boilerplate code by generating or transforming code based on your input. They simplify code writing, improve code readability, and enhance code maintenance. While these explanations are simplified, they showcase how macros work under the hood to generate or manipulate code constructs.

Writing custom macros and procedural macros

In addition to using built-in macros, Rust allows you to define your custom macros. Furthermore, Rust's powerful macro system supports procedural macros, which enable you to write macros that generate code based on the structure of your program. Let's delve into custom and procedural macros, along with examples to illustrate their usage.

Custom Macros Custom macros in Rust are created using the macro_rules! keyword. These macros are called declarative macros and use pattern matching to transform code.

macro_rules! greeting {
    ($name:expr) => {
        println!("Hello, {}!", $name);
    };
}

fn main() {
    greeting!("Alice"); // Expands to: println!("Hello, Alice!");
    greeting!("Bob");   // Expands to: println!("Hello, Bob!");
}

In the example above, the greeting! macro takes an expression ($name) and expands it into a println! statement. This allows you to create reusable code patterns.

Procedural Macros Procedural macros are more powerful and flexible than custom macros. They generate or transform code based on the program's structure, and they're defined in external crates.

Consider creating a procedural macro that generates a derive attribute similar to #[derive(Debug)]. Let's create a procedural macro called PrintDebug.

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};

#[proc_macro_derive(PrintDebug)]
pub fn print_debug_derive(input: TokenStream) -> TokenStream {
    let ast = parse_macro_input!(input as DeriveInput);
    let name = &ast.ident;

    let gen = quote! {
        impl std::fmt::Debug for #name {
            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
                f.debug_struct(stringify!(#name))
                    .finish()
            }
        }
    };

    gen.into()
}

We use the proc_macro crate to define the PrintDebug procedural macro in this example. When applied to a struct, it generates a minimal Debug trait implementation that prints its name.

Usage of Procedural Macros

use print_debug_derive::PrintDebug;

#[derive(PrintDebug)]
struct Person {
    name: String,
    age: u32,
}

fn main() {
    let person = Person {
        name: "Alice".to_string(),
        age: 30,
    };
    println!("{:?}", person);
}

In the code above, the PrintDebug procedural macro generates a simple Debug implementation for the Person struct, enabling us to print the struct's content using the println!("{:?}", person) statement. Custom macros and procedural macros are powerful tools that provide Rust's code generation and transformation capabilities. Custom macros are declarative and rely on pattern matching, while procedural macros operate on the program's syntax and structure. Procedural macros, in particular, enable you to create custom derive attributes and other code transformations, making them essential for more advanced use cases. Macros play a crucial role in Rust programming, whether for reducing boilerplate code or implementing domain-specific language features.

Exercise

  1. Custom Attribute Macro: Write a procedural macro that generates serialization and deserialization code for a custom struct. This could be an alternative to Serde's derived macros.

  2. Domain-Specific Language (DSL): Design a simple DSL using macros for expressing mathematical equations or configuration settings. Showcase how macros can make the code more concise and domain-specific.

Whenever you're ready

There are 4 ways we can help you become a great backend engineer:

The MB Platform

Join 1000+ backend engineers learning backend engineering. Build real-world backend projects, learn from expert-vetted courses and roadmaps, track your learnings and set schedules, and solve backend engineering tasks, exercises, and challenges.

The MB Academy

The “MB Academy” is a 6-month intensive Advanced Backend Engineering BootCamp to produce great backend engineers.

Join Backend Weekly

If you like post like this, you will absolutely enjoy our exclusive weekly newsletter, Sharing exclusive backend engineering resources to help you become a great Backend Engineer.

Get Backend Jobs

Find over 2,000+ Tailored International Remote Backend Jobs or Reach 50,000+ backend engineers on the #1 Backend Engineering Job Board