Take a quick glance at the code snippet below. Without thinking too hard, is get
a function or a method? How about post
?
What did you decide: is get
a function or a method? It appears to be both! And the same with post
!
If you are familiar with API development in Rust, you may recognize this syntax from Axum. This puzzling question piqued my curiosity and led me to build Cairo, where I re-implemented some key Axum concepts in a simpler way as a learning exercise. I’ve long been fascinated by the intermediate + advanced concepts library authors employ to make their interfaces feel frictionless. Rust sits in a brilliant space because of the way it melds high-level expressiveness with low-level control and performance. One of the ways it accomplishes this is using macros.
What is a macro in Rust?
Macros in Rust come in two key forms: declarative and procedural. This post will focus on declarative, but let’s briefly define both.
A declarative macro in Rust uses the function!(..)
syntax, which you may be familiar with from print!(“Hello World!”)
or panic!(“at the disco!”)
. It is a form of metaprogramming which allows developers to reduce boilerplate code. It does this by evaluating the declarative macro at compile time, which produces more Rust code which ultimately ends up in the binary of the program.
If you have wondered why print!(..)
in Rust is a macro, it is due to its dynamic nature. All of these are valid Rust:
A procedural macro in Rust uses the #[...]
syntax, which you may have seen in #[tokio:main]
. This macro is applied to a Rust function or struct, allowing the macro to modify the syntax tree of the annotated item at compile time. While it often helps reduce boilerplate code, it does so by transforming the function or struct as a whole rather than directly inserting statements into the function body. For example, #[tokio::main]
wraps the entire main
function in a tokio
async runtime, enabling it to block on the execution of async code in main
.
Code without macros can be repetitive and say the same thing in multiple ways
Axum is one of the most popular web frameworks in Rust. Its compatibility with the Tokio ecosystem and its powerful syntax, among other features, keep it near the front of the pack.
Skipping back to my original bafflement at whether get
is a function or a method, the answer is both and it does this using a declarative macro.
If we look at our snippet of interest, get
must be a function available in the outer scope and a method on whatever type is returned by invoking post
. Similarly, post
must be a function available in the outer scope and a method on whatever type is returned by invoking get
. This symmetry pleases me. Let’s write some code that accomplishes exactly that and nothing more.
At this point, we see some duplication, but the boilerplate isn’t overwhelmingly negative. However, what about when we add support for the remaining HTTP verbs: OPTIONS
, HEAD
, PUT
, DELETE
, PATCH
? We’d find ourselves maintaining 7 functions and 7 methods. Given the choice between maintaining 14 blocks of code versus adding complexity to prove I know a language, I’ll choose the latter every single time. Enter declarative macros.
Want to build your own HTTP server from scratch in Rust?
|
|
Declarative Macros in Axum
The code below is a mix of Axum and Cairo. We introduce two macros: add_http_function
and add_http_method
.
You’ll notice that these both accept two ident
parameters, a $name
and a $method
. The $name
becomes the function name, which will be get
, post
, etc., while the $method
is concatenated to Method::
, meaning we must give a valid Method
enum variant. It’s okay if this is confusing at first—metaprogramming is a different way of thinking about programming. Instead of asking “what should my code do?” we are now asking “what code should my code produce?”
You’ll also notice that while they both define a “function” with the name $name
, add_http_method
accepts a parameter self
. This means we must invoke our macro (by calling add_http_method!(get, Get)
) in a context which is aware of a self
. For us, this will be inside our impl InnerType
block.
Putting it all together
Putting it all together, here is our new library with minimal boilerplate and support for method chaining for 7 HTTP methods.
The End
While we’ve only scratched the surface of the metapossibilities of Rust’s declarative macros, I hope this post has inspired you to explore more of what Rust can do at compile time. Procedural macros are another wonderful beast which I would encourage you to dig into if you are interested in ASTs and language development.
If you are curious about more ways Axum and APIs work under-the-hood, I encourage you to check out my course From Scratch: HTTP Server in Rust. The final module is public as the repo Cairo, which shows the north star you will build towards throughout the course. In addition to the macro usage we described here, you’ll explore the details of HTTP and how to create a Router
which accepts handlers with variable parameters and return types using advanced type erasure.
Now it’s your turn! I’d love your perspective on these two questions:
1. How have you used declarative macros in your own Rust projects to reduce boilerplate code?
2. Are there any Rust crates you have used with an interfaces that makes you go “how did they do that?!”
Click here to view this post in your browser, copy the code snippets, and leave a comment.