Rust: Generic, Trait, Declarative, and Procedural Macros
My thoughts on different ways to write Rust code
This article is about a process I went through when refactoring Rust code. I was looking for a way to reduce the amount of code used to expose data structures in different ways, including database, API, and WASM. This exercise led me to reflect on the following ways Rust tackles the problem of writing code that can be reused, that is, how it handles generics and traits, declarative macros, and procedural macros.
This article is primarily to clarify my ideas and understanding. I will introduce my thinking process similarly to how I experienced it.
The source code is released under an MIT license. You can find it in this GitHub repository.
The views/opinions expressed in this article are my own. This article relates to my personal experience and choices. The article, demonstrations, and source code are provided in the hope that they will be useful without any warranty.
Let’s create code that includes a struct
that defines a point as a set of x
and y
coordinates, and two struct
s that define a triangle and a square:
After that, let's draw the shapes, i.e., the square and triangle, so that they will generate an SVG path from the shape definitions.
A straightforward implementation would (a) add a function that outputs a vector of Point
to the definitions of Triangle
and Square
objects, and (b) takes in a reference to this vector and outputs the SVG path. Here’s the code:
Unfortunately, my situation was sufficiently different concerning the complexity of the features to be implemented that such a straightforward approach became quickly complicated. I was keen to find a solution that would generalize and that I would feel comfortable with. So, I started by enumerating the concepts Rust provides and picking those I could apply to this situation:
- Closure would allow one to pass a function to the drawing function in place of arguments. I will not focus on this here as I don’t think it is appropriate. Its benefit would be to decouple the interface of the
Triangle
andSquare
objects from the interface of thedraw
function. - Trait to define an interface to be implemented on the
Triangle
andSquare
objects that are then employed in a drawing function using Generic - Declarative macro to implement the “right” function on the
Triangle
andSquare
objects directly - Procedural macro scaffolds on the
Triangle
andSquare
definitions to generate the “right” functions.
The use of trait and generic seems to be a perfect fit in the current context. A trait, Drawable
, can define an interface with a function that returns a vector, Point
, from a reference to the object. With the generic draw
function, they can get the vector of Point
and output the SVG path. Here’s the code:
The use of trait can be pushed a little further by including the draw
function implementation as part of the Trait
shown below, where you can see a draw
member function is added to the Triangle
and Square
objects.
With this approach, the draw
functionality can be called on as either a standalone function applied to the object or by calling the object function — provided that the Trait
is in scope.
I appreciate the benefits of trait
, but unfortunately, I find it quite difficult to get trait definitions ‘right,’ particularly when exploring new functionalities. When one modifies a trait, not only does the trait definition need to be modified, but also all trait implementations.
A declarative macro can be used to define and implement the necessary draw
functionality ‘automatically.’ As the declarative macro defines the interface, any future changes only require changing the declarative macro (and the places where it is called). In the present case, if Triangle
and Square
definitions contain a list of Point
, the declarative macro can scaffold on this list to directly implement the draw
functionality. Here’s the code:
The declarative macro alleviates the repetition of the implementation of the function, returning an array of points for the Square
and Triangle
object, but at the cost of requiring both objects to have references to each Point
to be drawn — or at least something equivalent.
As there is no trait involved, there is no need to bring the trait in scope — but neither is it possible to have a vector containing both Square
and Triangle
. One would have to wrap them into an enum and implement a draw function on the enum that dispatches the draw request to the right object).
Declarative macros are very powerful when implementing a small amount of code, but I steer towards procedural macros when implementing more substantial functionalities. The code below shows the use of the procedural macro DeriveDraw
in the workspace crate e2_derive_draw
. We can apply it to the Square
definition to automatically create a draw
function.
The procedural macro reads the Square
object members of type Point
to create a vector of Point
that is used in the draw
function, which is created by the procedural macro. The procedural macro's advantages lie in its coding flexibility. The procedural macro is Rust code, and functionalities can be added via attributes at the type and members level. A possible implementation of the DeriveDraw
macro is shown below:
Whilst implementing the procedural macro is longer than the declarative macro, I prefer using a procedural macro as additional functionalities and changes will lead me to outlive my coding capabilities with declarative macros.
As I went through the refactoring exercise, it became clear that I preferred procedural macros. Part of it might be because it is an advanced topic and attractive due to its technical challenge. Another part seems to be that procedural macro gives me more freedom. It’s similar to JavaScript duck typing, as the code generated by the procedural macro is compiled in situ — compared to traits and generics. However, when pushed to the extreme, I wonder if the use of procedural macro means they are writing a configuration rather than code.
Writing this article, I am realising that there are great benefits to combining trait with procedural macro. The procedural macro makes it easy to implement similar functionalities for different context — but trait allows these implementations to be done in edge cases when the procedural macro is no longer adequate.
For example, what if a Square
is not defined by 4 Point
by 1 Point
and 1 Length
?