Metaprogramming with macros instead of decorators
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 that we can metaprogram 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}; }
What is happening here is that the compiler is looking through all the uses of our struct throughout the whole program. It then creates structs that have those types. Generics are a good way of saving time and getting the compiler to write repetitive code. While this is the simplest form of metaprogramming, another form of metaprogramming in Rust is macros.
You may have noticed throughout the chapter that some of the functions that we use, such as the println!
function, have an !
at the end. This is because it is not technically a function, it is a macro. The !
denotes that the macro is being called. Defining our own macros is a blend of defining our own function and using lifetime notation within a match
statement within the function. To demonstrate this, we can define our own macro that capitalizes the first character in a string passed through it with the following code:
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 fn
term that is used for defining functions, we define our macro using macro_rules!
. We then say that the $a
is the expression passed into the macro. We then get the expression, convert it into a vector of chars, uppercase the first character, and then convert it back to a string. It must be noted that the macro that we defined does not return anything, and we do not assign any variable when calling our macro in the main function. However, when we print the x
variable at the end of the main function, it is capitalized. Therefore, we can deduce that our macro is altering the state of the variable.
However, we must remember that macros are a last resort. Our example shows that our macro alters the state even though it is not directly demonstrated in the main
function. As the complexity of the program grows, we could end up with a lot of brittle, highly coupled processes that we are not aware of. If we change one thing, it could break five other things. For capitalizing the first letter, it is better to just build a function that does this and returns a string value.
Macros do not just stop at what we have covered, they also have the same effect as our decorators in Python. To demonstrate this, let's look at our coordinate again. We can generate our coordinate and then pass it through a function so it can be moved. We then try to print the coordinate outside of the function with the following code:
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) }
It will be expected that Rust will refuse to compile the code because the coordinate has been moved into the scope of the print
function that we created and therefore we cannot use it in the final println!
. We could borrow the coordinate and pass that through to the function. However, there is another way we can do this. Remember that integers passed through functions without any trouble because they had a Copy
trait. Now, we could try and code a Copy
trait ourselves, but this would be convoluted and would require advanced knowledge. Luckily for us, we can implement the Copy
and Clone
traits by utilizing a derive
macro with the following code:
#[derive(Clone, Copy)] struct Coordinate { x: i8, y: i8 }
With this, our code works as we copy the coordinate when passing it through the function. Macros can be utilized by many packages and frameworks, from JavaScript Object Notation (JSON) serialization to entire web frameworks. In fact, here is the classic example of running a basic server in the Rocket framework:
#![feature(proc_macro_hygiene, decl_macro)] #[macro_use] extern crate rocket; #[get("/hello/<name>/<age>")] fn hello(name: String, age: u8) -> String { format!("Hello, {} year old named {}!", age, name) } fn main() { rocket::ignite().mount("/", routes![hello]).launch(); }
This is a striking resemblance to the Python Flask application example at the beginning of the chapter. These macros are acting exactly like our decorators in Python, which is not surprising as a decorator in Python is a form of metaprogramming that wraps a function.
This wraps up our brief introduction to the Rust language for Python developers. We are now able to move on to other concepts, such as structuring our code and building fully fledged programs coded in Rust.