Exploring the Power of Declarative Macros in Rust
Rust is a modern systems programming language that has gained immense popularity due to its performance, safety, and expressive syntax. One of the most powerful and interesting features of Rust is its support for macros, which allow you to extend the language in a variety of ways. In particular, Rust’s declarative macros provide a way to generate code at compile time using simple and concise syntax.
Whether you are a beginner or an experienced developer, this post will provide you with a deeper understanding of the power and beauty of declarative macros in Rust. So sit back, relax, and join us on a journey of exploration and discovery in the world of Rust macros!
The Challenge
I was recently working on one of my projects and had to write some logic for error handling. The task was simple. I had to make sure that all custom error definitions implemented the std::string::ToString
trait, since they were going to be sent over the wire via a custom networking protocol made on top of TCP/IP. Let’s suppose that we have the following enum:
#[derive(Debug)]
enum Error {
Io(std::io::Error),
NotFound(&'static str),
EmptyPayload(&'static str),
Conversion(serde_json::Error),
}
Normally, when we have one or two Error enums, we would manually implement the ToString
trait for them like this:
impl ToString for Error {
fn to_string(&self) -> String {
match self {
Self::Io(e) => e.to_string(),
Self::NotFound(e) => e.to_string(),
Self::EmptyPayload(e) => e.to_string(),
Self::Conversion(e) => e.to_string(),
}
}
}
However, this trivial task might get more complex. As the number of custom error definitions grows, it becomes increasingly tedious and error-prone to manually implement the ToString
trait for each one. This is where Rust’s declarative macros come into play.
Following Intuition
One of the first things I have considered doing is writing a declarative macro. By defining a macro that generates the necessary code for implementing ToString
on any given enum, we can greatly simplify the process and reduce the potential for errors. This is called metaprogramming, where the compiler basically writes code for you. Instead of having to maintain a burden like the following:
#[derive(Debug)
enum Error {
A(..),
B(..),
C(..),
D(..),
E(..),
...
}
impl ToString for Error {
fn to_string(&self) -> String {
match self {
Self::A(e) => e.to_string(),
Self::B(e) => e.to_string(),
Self::C(e) => e.to_string(),
Self::D(e) => e.to_string(),
Self::E(e) => e.to_string(),
Self::F(e) => e.to_string(),
Self::G(e) => e.to_string(),
Self::H(e) => e.to_string(),
...
}
}
}
We would just need to define our enum once without worrying about the implementation part, since it would be fully abstracted away with the declarative macro:
enum_with_impl_to_string! {
Error, // Name of the enum
.A(..) // Enum variant
.B(..)
.C(..)
.D(..)
.E(..)
~Debug // #[derive(..)] traits
~PartialEq
}
Macros in Rust can seem like a whole other language, and it is true that they look quite different from regular Rust syntax. This is because macros use a different set of rules for parsing and generating code, which allows developers to create new syntax and language constructs that can be used alongside regular Rust code.
Writing the Macro
Let’s start by defining the name of the macro, the arguments that it is going to take, and the syntax:
macro_rules! enum_with_impl_to_string {
(
$enum_name:ident,
$(.$variant_name:ident($variant_type:ty))*
$(~$derive_name:ident)*
) => {}
}
Here, the macro takes 4 arguments where:
$enum_name
is the name of the enum definition.$(.$variant_name:ident($variant_type:ty))*
is a pattern that matches zero or more comma-separated tuples of two elements. The first element is a Rust identifier that will be used as the name of a variant of the generated enum, and the second element is a Rust type that will be used as the type of the associated data of the variant. Notice how each field is also required to have a dot (.) prefix.$(~$derive_name:ident)*
is a pattern that matches zero or more Rust identifiers preceded by a tilde (~). These identifiers represent the names of the#[derive]
attributes that will be applied to the generated enum.
Now, all that is left to do is to define the enum in Rust-compatible syntax and implement the std::string::ToString
trait. Here is how we can approach this:
macro_rules! enum_with_impl_to_string {
(
$enum_name:ident,
$(.$variant_name:ident($variant_type:ty))*
$(~$derive_name:ident)*
) => {
#[derive($($derive_name),*)]
pub enum $enum_name {
$($variant_name($variant_type)),*
}
...
};
}
Initially, we initialize the enum with all of its variants and fields. Afterwards, we apply all the derivable traits which were supplied at compile-time via the tilde (~) prefix. The code above alone, will generate the enum, however, it will not implement the trait just yet.
Next, we are going to define the implementation of the ToString
trait for our new enum:
macro_rules! enum_with_impl_to_string {
(
$enum_name:ident,
$(.$variant_name:ident($variant_type:ty))*
$(~$derive_name:ident)*
) => {
#[derive($($derive_name),*)]
pub enum $enum_name {
$($variant_name($variant_type)),*
}
#[automatically_derived]
impl std::string::ToString for $enum_name {
fn to_string(&self) -> String {
match self {
$(Self::$variant_name(val) => val.to_string(),)*
}
}
}
};
}
Here, we implement the ToString
trait for the enum by iterating over all possible values of the enum and matching them against their corresponding variant. If a variant is matched, we convert the associated value to a string and return it.
To use the macro, we simply need to import the macro file in our project and invoke the macro with our desired arguments:
enum_with_impl_to_string!(
MyEnum,
.Variant1(u8)
.Variant2(String)
~Clone
);
let my_enum = MyEnum::Variant1(42);
println!("{}", my_enum.to_string()); // prints "42"
let my_enum = MyEnum::Variant2(String::from("Hello, world!"));
println!("{}", my_enum.to_string()); // prints "Hello, world!"
Here, we define a new enum MyEnum
with two variants — Variant1
and Variant2
, with their respective types — u8
and String
. We also supply the Clone
trait as an optional derivation. Once the macro is invoked, it generates the enum definition and implements the ToString
trait for the enum. Finally, we create two instances of MyEnum and print their respective string representations.
Now, instead of repeatedly writing the same code, we can rely on the compiler to do that for us, thus abstracting away the unnecessary boilerplate and overhead.
Final Words
I hope you found this blog post helpful in exploring the beauty and power of declarative macros in Rust.
In conclusion, declarative macros in Rust are a powerful tool that allows developers to extend the language’s syntax and generate code at compile time using a concise and simple syntax. They can help simplify complex tasks, reduce the potential for errors, and improve the overall quality of code.
In this post, we explored how to use declarative macros to implement the std::string::ToString
trait for custom error definitions, which can be tedious and error-prone when done manually.
By defining a macro that generates the necessary code for implementing ToString
on any given enum, we were able to greatly simplify the process. Although macros in Rust can seem intimidating at first, they are an essential tool for writing efficient and concise code.