You may not have seen the implementation of Rust macros, but you’ve certainly used them. The first line of “hello world” we wrote when learning Rust programming was printed using the println! macro. It is probably one of the most commonly used macros in Rust, and its biggest difference from a function is that it has an extra ”!” when called. In addition to println!, there are also quite commonly used macros like vec! and assert_eq!. One could say that macros are ubiquitous in Rust.
If you’ve used enough macros, you might have noticed an issue: why do some macros use () for invocation, others use [], and still others use {}. In fact, all three methods can be used:
fn main() {
println!("hello world");
println!["hello world"];
println!{"hello world"}
}
However, it’s just a convention that println! uses (), and vec! uses [].
Declarative Macros
Rust has two types of macros: declarative macros (macro_rules!) and procedural macros.
Procedural macros are relatively complex, so let’s leave them for another time.
Declarative macros allow us to create custom code structures that resemble the built-in match expression in syntax. As a control flow construct, match takes an expression and compares its result with multiple predefined patterns. When a pattern matches successfully, the corresponding code block is executed immediately, providing an intuitive and concise way to handle various possible scenarios. With the power of declarative macros, developers can not only emulate the behavior of match but also customize more complex and flexible matching logic according to specific needs, thus greatly enhancing the expressiveness and maintainability of Rust code.
What Can We Do With Them?
Code Generation
Most library authors use declarative macros primarily to eliminate boilerplate code, as no one wants to write repetitive tasks.
By generating highly repetitive, structurally similar code through macros, it helps reduce the workload of manually writing large amounts of redundant code and improves code consistency and maintainability.
Simplified API
Developers can use macros to create more concise and user-friendly API interfaces. For example, by encapsulating complex configuration options or initialization processes within macros, callers only need to provide a few parameters to complete operations. A typical example is vec!.
DSL
Some people use declarative macros to implement small DSLs (Domain-Specific Languages). DSLs make the logic of a specific domain more intuitive, improving development efficiency and code readability.
Implementing a Declarative Macro
Unlike attribute macros, which require a separate package declaration, declarative macros can be defined similarly to functions.
Declarative macros are defined using macro_rules!, and if you need to export them for external use, you should add #[macro_export]. They look like this:
#[macro_export]
macro_rules! my_macro {
( $( $x:expr ),* ) => {
{
//...
}
};
}
Let’s start with an example to see how to use declarative macros to eliminate boilerplate code.
Suppose we need to define an enum in our business logic, looking something like this:
pub enum Value {
None,
Bool(bool),
Int8(i8),
Int16(i16),
Int32(i32),
Long(i64),
Float(f32),
Double(f64),
}
Typically, we would need to implement a series of From<T> traits for this enum, like so:
impl From<bool> for Value {
fn from(value: bool) -> Self {
Self::Bool(value)
}
}
Without implementing a declarative macro, we’d have to write similar code seven times. Even with only eight members, the actual business logic might involve far more than eight. Let’s see how to implement a declarative macro to eliminate this repetitive code.
macro_rules! impl_from_for_field {
( $($t:ty, $o:ident),+ ) => {$(
impl From<$t> for Value {
fn from(v: $t) -> Self {
Self::$o(v as _)
}
}
)*};
}
Let’s explain this declarative macro:
macro_rules!is the keyword for defining a declarative macro in Rust.impl_from_for_fieldis the name of the macro.( $($t:ty, $o:ident),+ )is the parameter pattern of the macro:- The outer parentheses
()wrap the entire macro pattern. $()captures values that match the pattern inside the parentheses for code replacement.$t:tymeans this pattern will match any Rust type and gives the pattern a name$t.$o:identmeans this pattern will match any Rust identifier and gives the pattern a name$o.
- The outer parentheses
The ty and ident above are known as Macro Fragment Specifiers (MacroFragSpec), and their possible values are limited to: item | block | stmt | pat_param | pat | expr | expr_2021 | ident | lifetime | literal | meta | path | tt | ty | vis.
Here are some explanations:
- item: An
Item(Function, Struct, etc.) - block: A
BlockExpressionblock expression ({}) - stmt: A
Statementwithout a trailing semicolon (except foritemStatementwhich requires a semicolon) - pat_param: A
PatternNoTopAlt - pat: At least any
PatternNoTopAlt, and possibly more, depending on the version - expr: An
Expressionexcept forUnderscoreExpressionandConstBlockExpression(see macro.decl.meta.expr-underscore) - expr_2021: Same as
expr(see macro.decl.meta.edition2021) - ty: A type
- ident: An
IDENTIFIER_OR_KEYWORDorRAW_IDENTIFIER - path: A
TypePathstyle path - tt: A
TokenTree(a single token or sequence of tokens within delimiters(),[], or{}) - meta: An
Attr, i.e., the content of an attribute - lifetime: A
LIFETIME_TOKEN - vis: A possibly empty
Visibilityqualifier - literal: Matches a
?LiteralExpression(literal expression)
The + in $($t:ty, $o:ident),+ is somewhat similar to regular expressions, meaning one or more occurrences.
Therefore, when calling the macro, we can invoke it all at once like this:
impl_from_for_field! {
bool, Bool,
i8, Int8,
i16, Int16,
i32, Int32,
i64, Long,
f32, Float,
f64, Double
}
Besides +, there are two other operators:
*— indicates zero or more repetitions.?— indicates an optional fragment that appears zero or one time.
Those who are interested or have a need can learn about the usage of other macro fragment specifiers.