Metaprogramming with macros
Metaprogramming can generally be described as a way in which the program can manipulate itself based on certain instructions. Considering the strong typing Rust has, one of the simplest ways in which we can meta program is by using generics. A classic example of demonstrating generics is through coordinates:
struct Coordinate <T> { x: T, y: T } fn main() { let one = Coordinate{x: 50, y: 50}; let two = Coordinate{x: 500, y: 500}; let three = Coordinate{x: 5.6, y: 5.6}; }
Here, the compiler is looking for all the times where the coordinate struct is called and creates structs with the types that were used when compiling. The main mechanism of metaprogramming in Rust is done with macros. Macros enable us to abstract code. We've already been using macros in our print
functions. The !
notation at the end of the function denotes that this is a macro that's being called. Defining our own macros is a blend of defining a function and using a lifetime notation within a match
statement in the function. In order to demonstrate this, we will define a macro that capitalizes a string:
macro_rules! capitalize { ($a: expr) => { let mut v: Vec<char> = $a.chars().collect(); v[0] = v[0].to_uppercase().nth(0).unwrap(); $a = v.into_iter().collect(); } } fn main() { let mut x = String::from("test"); capitalize!(x); println!("{}", x); }
Instead of using the term fn
, we use the macro_rules!
definition. We then say that $a
is the expression that's passed into the macro. We get the expression, convert it into a vector of chars, uppercase the first char, and then convert it back into a string.
Note that we don't return anything in the capitalize macro and that when we call the macro, we don't assign a variable to it. However, when we print the x
variable at the end, we can see that it is capitalized. This does not behave like an ordinary function. We also have to note that we didn't define a type. Instead, we just said it was an expression; the macro still does checks via traits. Passing an integer into the macro results in the following error:
| capitalize!(32); | ---------------- in this macro invocation | = help: the trait `std::iter::FromIterator<char>` is not implemented for `{integer}`
Lifetimes, blocks, literals, paths, meta, and more can also be passed instead of an expression. While it's important to have a brief understanding of what's under the hood of a basic macro for debugging and further reading, diving more into developing complex macros will not help us when it comes to developing web apps.
We must remember that macros are a last resort and should be used sparingly. Errors that are thrown in macros can be hard to debug. In web development, a lot of the macros are already defined in third-party packages. Because of this, we do not need to write macros ourselves to get a web app up and running. Instead, we will mainly be using derive macros out of the box.
Derive macros can be analogous to decorators in JavaScript and Python. They sit on top of a function or struct and change its functionality. A good way to demonstrate this in action is by revisiting our coordinate struct. Here, we will put it through a print
function we define, and then try and print it again with the built-in print macro:
struct Coordinate { x: i8, y: i8 } fn print(point: Coordinate) { println!("{} {}", point.x, point.y); } fn main() { let test = Coordinate{x: 1, y:2}; print(test); println!("{}", test.x) }
Unsurprisingly, we get the following error when compiling:
| let test = Coordinate{x: 1, y:2}; | ---- move occurs because `test` has type `Coordinate`, which does not implement the `Copy` trait | print(test); | ---- value moved here | println!("{}", test.x) | ^^^^^^ value borrowed here after move
Here, we can see that we're getting the error that the coordinate was moved into our function and was then borrowed later. We can solve this with the &
notation. However, it's also worth noting the second line in the error, stating that our struct does not have a copy trait. Instead of trying to build a copy trait ourselves, we can use a derive macro to give our struct a copy trait:
#[derive(Clone, Copy)] struct Coordinate { x: i8, y: i8 }
Now, the code will run. The copy trait is fired when we move the coordinate into our print
function. We can stack these traits. By merely adding the debug trait to the derive
macro, we can print out the whole struct using the :?
operator in the print macro:
#[derive(Debug, Clone, Copy)] struct Coordinate { x: i8, y: i8 } fn main() { let test = Coordinate{x: 1, y:2}; println!("{:?}", test) }
This gives us a lot of powerful functionality in web development. For instance, we will be using them in JSON serialization using the serde
crate:
use serde::{Serialize, Deserialize}; #[derive(Serialize, Deserialize)] struct Coordinate { x: i8, y: i8 }
With this, we can pass the coordinate into the crate's functions to serialize into JSON, and then deserialize. We can create our own derive macros, but the code behind our own derive macros has to be packaged in its own crate. While we will go over cargo and file structure in the next chapter, we will not be building our own derive macros.