Rust: Generic, Trait, Declarative, and Procedural Macros

My thoughts on different ways to write Rust code

Julien de Charentenay
Better Programming

--

Image by StockSnap from Pixabay

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 structs 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 and Square objects from the interface of the draw function.
  • Trait to define an interface to be implemented on the Triangle and Square objects that are then employed in a drawing function using Generic
  • Declarative macro to implement the “right” function on the Triangle and Square objects directly
  • Procedural macro scaffolds on the Triangle and Square 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?

--

--