Rust Builder Macros
Introduction
One of the most common applications of Rust’s very capable macro system is streamlining the implementation of the behaviour of new structs, a task that is often repetitive and predictable.
When appropriate, using macros for this purpose has a number of advantages:
- Productivity Not having to write an implementation reduces the amount of code that we have to type and thus saves us time and mental effort, freeing up resources that can be used for more valuable parts of the code.
- Correctness The fact that the code is generated automatically means that we are relying on a single implementation that was (hopefully) carefully designed and thoroughly tested. Such a code is likely to be more robust and reliable than the one that we would have written ourselves.
- Readability The code that we wold have written is instead generated at compile time, meaning that we have less code to browse when navigating and reading the file.
Unfortunately, the ecosystem is not mature yet: while the number and the quality of useful Rust macros is increasing at a rapid pace, they are still scattered around a multitude of third-party crates and aren’t yet mentioned in the mainstream guides. This makes them hard to discover.
I’m confident that this will change in the near future, but I thought that in the meanwhile I could share some notes on my favourite macros. I will limit myself to an overview and an example for each of them, leaving more in-depth explanations to the official documentation of their crates.
Common Macros
Writing Getters and Setters
getset
is currently most popular crate for this purpose. Its macros are configurable and allow the user to easily define any combination of reference getters, setters, mutable getters and copy getters.
There are other useful libraries too:
derive-getters
is a competitor ofgetset
. It’s more recent, so it is less mature and does not have the same level of adoptioon, but you might prefer its API over the one provided bygetset
.readonly
solves a specific use case not covered by other two crates: it is able to make an attribute publicly readable, but not motifiable.Strictly speaking, it operates as a visibility modifier, but for most intents and purposes it’s equivalent to generating a reference getter for a private attribute, but with the additional benefit that the brackets after the attribute name are not required.
Deriving Common Traits
Many of the most common traits have obvious implementations. So obvious, in fact, that the Rust langauge provides derive
macros for them. This is great, but doesn’t cover all the use cases. Luckily, derive_more
exposes a good number of derive
marcos for additional traits, with a focus on the newtype pattern.
Facilitating Forwarding
Forwarding is a term that was first introduced in object oriented programming to refer to situation in which an object implements some functionality by simply calling the corresponding method of one of its attributes.
For example, in the following code
struct Foo {
...
}
impl Foo {
pub fn do_something(&self) {
...
};
}
struct Bar {
foo: Foo
// other attributes
...
}
impl Bar {
pub fn do_something(&self) {
self.foo.do_something();
}
}
Bar
forwards any calls to its do_something()
method to the corresponding one of its foo
attribute.
Forwarding can and is often useful, but it can require writing a significant amount of boilerplate. The delegate
crate helps with this by providing easily configurable derive
macros that reduce the amount of code that we have to write. In the example above would it would not be very useful:
use delegate::delegate;
impl Bar {
delegate! {
to self.foo {
pub fn do_something(&self)
}
}
}
but in more complex cases it can become very convenient.
Before we proceed with the next section, I’d like to close with a note on the terminology of the delegate
library in order to prevent some confusion. This crate uses the term delegation to refer to the more general pattern of having some code that delegates the implementation of some of its functionality to other code. Forwarding is an example of delegation, but it’s not the only one: in OOP, for example, delegation can also mean something more specific, which however has no equivalent in Rust. delegate
actually performs forwarding, but the authors chose to be loose on the terminology 😉.
Destructuring
Imagine you have designed a struct whose fileds can’t be directly modifiable by external code, for example because the new values have to be validated before they can be used to replace the previous ones. Having made the fields private helps you ensure that these conditions are respected, but what if you also want users to be able to move the values out of the struct? Unfortunately, doing so directly is forbidden by the compiler, because it would amount to accessing private fields.
A common solution to this problem is to write a destructurer method that consumes the instance and returns the attributes packed in a tuple, so that they can then be used freely. For example, if we where to have the struct
struct Foo {
a: bool
b: uint8
c: f32
}
impl Foo {
pub fn new(a: bool, b: uint8, c: f32) -> Self {
// Perform validation
...
Self { a, b, c }
}
// Setters with validation and other logic
...
}
we could add the following method:
impl Foo {
pub fn dissolve(self) -> (bool, uint8, f32) {
(self.a, self.b, self.c)
}
}
As you can see, its implementation is mostly boilerplate, so it is possible to generate it through a macro. The Dissolve
derive macro of the derive-getters
crate does exactly this.
Conclusions: The Causes of the Fragmentation & Where Rust Is Going
As we as seen, the Rust ecosystem provides a good number of macros that make the task of building data structures more convenient. This functionality is, however, hard to discover and spread out across many different and unrelated libraries.
As far as I can tell, there are two main causes for this fragmentation. First, the Rust community intentionally tries to keep the standard library as small as possible. Second, the ecosystem is still young. These two factors combined imply that most of the useful functionality has to be implemented by third party libraries, which often have not yet received enough adoption in order to be considered de-facto standards or even become widely known. Moreover, in some cases it’s not yet clear which way of designing the public interface of the macro is the best, so that there are various competing variants, each with its own way of doing things.
As time passes the various creates are becoming more mature and popular. Given the direction in which the community is headed, I expect that in a couple of years we will see some of the libraries that we have discussed here become the default recommendation in their fields and that we’ll witness the creation a mainstream “collector” crate, i.e. like num
is for basic numeric traits and types.
Finally, the Rust community is growing steadily each year, so the number of open source contributors and of users is continuously increasing. The more contributors there are, the larger and the better the ecosystem will become, a fact that will attract even more users. In turn, the more users a create has, the more contributors are incentivized to work on it and the more likely it is to attract sponsorship. The growth of the user base and the increase of the number of contributors are therefore locked in a positive feedback loop and will drive the growth of the ecosystem.
In short, we already have some pretty good tools and the ecosystem is both heathly and growing.
Do you have any comments or feedback? Write me on LinkedIn!