Tech Wiki

TOPICSSERIES

[Rust Zero to Production 20] Rust Macros: Remove Repetition Without Hiding the Program

Macros can shorten source code without making the program simpler. They add a layer of indirection that is usually harder to read and trace than a function. If the repetition is a runtime operation, start with a function or iterator. Consider a small macro only when the repeated material is Rust syntax or item structure.

This chapter targets Rust 2024 edition with rustc and Cargo 1.98.1. Its dependency-free Endpoint Monitor example uses one macro_rules! declaration to create three named constants and one registry.

1. Can a Function Do It?

A macro is unnecessary when the job is to build one endpoint from runtime values. Functions such as Endpoint::new and format_runtime_endpoint accept values and return values while leaving type checking, control flow, and tool support in plain sight.

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Endpoint {
    pub name: &'static str,
    pub url: &'static str,
}

impl Endpoint {
    pub const fn new(name: &'static str, url: &'static str) -> Self {
        Self { name, url }
    }
}

pub fn format_runtime_endpoint(name: &str, url: &str) -> String {
    format!("{name} -> {url}")
}

The constraint changes when a fixed compile-time list must produce both named pub const items and ALL_ENDPOINTS. A function can construct a value, but it cannot create identifiers or module items such as HOME, HEALTH, and METRICS. That limitation justifies a narrow item macro here. If callers do not need named constants, a runtime slice plus a constructor is clearer.

2. One Small macro_rules! Macro

The example defines this macro:

macro_rules! define_endpoints {
    ($( $const_name:ident => ($label:literal, $url:literal) ),+ $(,)?) => {
        $(
            pub const $const_name: Endpoint = Endpoint::new($label, $url);
        )+

        pub const ALL_ENDPOINTS: &[Endpoint] = &[
            $( $const_name, )+
        ];
    };
}

The left side of => is the matcher; the block on the right is the transcriber. $const_name:ident accepts identifier syntax, while $label:literal and $url:literal accept literal syntax. Fragment specifiers describe syntax categories, not runtime types. $url:literal does not mean a value of type &str; it means that the matcher accepts a literal at that position. Rust checks the resulting types after expansion. A macro can use expr for a complete expression, while item and ty represent item and type syntax.

In $( ... ),+, the + requires one or more comma-separated entries. The final $(,)? permits one trailing comma. Macro matching follows tokens syntactically and does not perform arbitrary lookahead to resolve ambiguity. Keeping the invocation to identifiers, literals, commas, and => avoids turning it into a clever mini-language.

The call reads like a small static table:

use crate::Endpoint;

define_endpoints! {
    HOME => ("homepage", "https://example.com/"),
    HEALTH => ("health", "https://example.com/health"),
    METRICS => ("metrics", "https://example.com/metrics"),
}

The compiler resolves and expands macro invocations while building the crate's AST. It then lowers the expanded AST to HIR, where type inference, trait solving, and type checking occur. This is not a separate textual preprocessor that runs first. Name resolution and expansion interact, and output tokens are parsed back into AST fragments.

3. Inspect Expansion Truthfully

Stable rustc 1.98.1 and Cargo have no documented stable command that prints fully expanded Rust source. cargo rustc can forward arguments to the final compiler invocation, but forwarding -Zunpretty=expanded does not turn an unstable rustc option into a stable feature.

The example instead keeps a hand-maintained equivalent in a separate module. The code below is an equivalent transcription for inspection and testing. It is not compiler-emitted expansion text.

//! This is a hand-maintained equivalent transcription for inspection and testing.
//! It is not compiler-emitted expansion text.

use crate::Endpoint;

pub const HOME: Endpoint = Endpoint::new("homepage", "https://example.com/");
pub const HEALTH: Endpoint = Endpoint::new("health", "https://example.com/health");
pub const METRICS: Endpoint = Endpoint::new("metrics", "https://example.com/metrics");

pub const ALL_ENDPOINTS: &[Endpoint] = &[HOME, HEALTH, METRICS];

A side-by-side review shows whether each invocation row becomes one constant and whether the same identifier enters the registry slice. Parity tests compare the constants and slices from the macro-backed and explicit modules by value. Both representations must also satisfy the same ordering, label, URL, and length invariants. This proves observable value equivalence, not textual identity with rustc's internal AST, hygiene contexts, or compiler-generated support code.

cargo-expand 1.0.126 is an optional third-party debugging aid. That release wraps cargo rustc --profile=check -- -Zunpretty=expanded and sets RUSTC_BOOTSTRAP=1 in its implementation. Its README warns that conversion back to text is lossy, so the result is not guaranteed to compile or preserve behavior. The stable test path in this chapter therefore neither installs nor runs it.

4. Contexts, Hygiene, and Diagnostics

A macro invocation must expand to syntax valid for its location. The familiar vec! macro expands to an expression where an expression is expected. define_endpoints! is invoked in item context at module scope and creates pub const items.

macro_rules! uses mixed-site hygiene. Local variables and labels follow the definition site; many other names follow the invocation site. Hygiene does not prevent every collision. Caller-supplied public item names such as HOME deliberately enter the surrounding module and can conflict with existing names.

$crate refers to the crate that defines a macro. An exported macro can use a path such as $crate::path::Helper to find a helper that is not imported at the invocation site, but $crate does not bypass visibility. The example keeps its macro private to one module and resolves Endpoint at the invocation site, so it needs neither $crate nor #[macro_export].

Putting a comma where the matcher expects => produces this rustc 1.98.1 diagnostic:

include!("../../src/endpoint_macro.rs");

struct Endpoint;

impl Endpoint {
    const fn new(_name: &'static str, _url: &'static str) -> Self {
        Self
    }
}

define_endpoints! {
    HOME, ("homepage", "https://example.com/"),
}
error: no rules expected `,`
  --> tests/compile_fail/bad_separator.rs:12:9
   |
12 |     HOME, ("homepage", "https://example.com/"),
   |         ^ no rules expected this token in macro call
   |
  ::: tests/compile_fail/../../src/endpoint_macro.rs:1:1
   |
 1 | macro_rules! define_endpoints {
   | ----------------------------- when calling this macro
   |
note: while trying to match `=>`
  --> tests/compile_fail/../../src/endpoint_macro.rs:2:27
   |
 2 |     ($( $const_name:ident => ($label:literal, $url:literal) ),+ $(,)?) => {
   |                           ^^

error: aborting due to 1 previous error

The diagnostic points to the unexpected comma in the invocation and shows the => token being matched in the definition. That does not mean every macro error has one call-site-only span. rustc can retain expansion information containing the invocation span and an optional definition-site span; errors in generated code may include expansion-origin notes.

5. Choose Function, Macro, or Derive

Need First Choice Endpoint Monitor Example
Apply one operation to runtime values Function, iterator, generic, or trait Endpoint::new and iteration over configuration
Generate repeated Rust items from a fixed list Small declarative macro Keep named constants and ALL_ENDPOINTS synchronized
Implement a standard trait from type structure Existing #[derive(...)] Debug, Clone, Copy, PartialEq, and Eq for Endpoint
Generate custom syntax, validation, or implementations Procedural macro after its payoff is clear Outside this example's scope

A derive fits trait implementations that follow mechanically from a type's fields or variants. That is a different problem from generating a list of constant declarations. If an existing standard derive expresses the contract, there is no reason to build a custom derive.

Function-like, custom derive, and attribute procedural macros consume and produce token streams at compile time. They must be defined in a separate proc-macro crate and cannot be used within the crate where they are defined. Procedural macros are unhygienic, so their authors must choose paths and generated names carefully. That rule must not be projected onto macro_rules!, which has mixed-site hygiene. Introducing a procedural macro here would add another crate, a token parser, generated diagnostics, and more tests for little benefit.

6. Remove Repetition Without Hiding Structure

Run the complete example check from the project directory. The final rustc command is expected to return a nonzero status because it exercises the intentional matcher error.

cd examples/article-20-macros
cargo fmt --all -- --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet
rustc --edition=2024 --crate-type=lib tests/compile_fail/bad_separator.rs

With Rust 1.98.1 and Cargo 1.98.1, all five Cargo commands should return status 0. The six test functions include the integration test that checks the compile-fail case. Program output is deterministic:

endpoint: homepage https://example.com/
endpoint: health https://example.com/health
endpoint: metrics https://example.com/metrics

This macro hides only three constants and one slice. Its invocation still reads as a table, and an explicit transcription plus parity tests exposes the generated behavior. If the named items and registry no longer need to stay synchronized through one declaration, remove the macro and return to functions and data. Visible structure matters more than a shorter file.

Full source code

The complete runnable source for this article is available in the Chapter 20 project on GitHub.

Sources


Leave a Reply

Your email address will not be published. Required fields are marked *

Tech Wiki

Built with WordPress · Learn in public.