Although I haven't been able to set aside much time for a while, I try to get into Rust and Go whenever I find the time. Both are very popular among modern languages. Both do a very good job in different areas. If you're doing higher-level work like web programming or local app development, Go is definitely a very good language. If you're doing lower-level work that's a bit closer to the hardware, Rust is a good choice. Even though I don't get the chance to use them actively, I try to learn something about all languages at a surface level. In this article, I wanted to touch on metaprogramming in the Ruat language, one of the topics that challenged me in my learning process. But it turned out to be a veeeery long article. I hope you can read it patiently. I wish you happy reading in advance.
In the Rust language, a macro is, in its simplest definition, defined as code that writes code. In general, we call this metaprogramming. The Rust macro concept and structure were largely inspired by the Lisp language. Besides that, it was also inspired by the C, C++ and Scheme languages. But macro usage in the Rust language is more useful and safer compared to these languages. Many developers in Turkey, including me, usually come from a Java, C#, JavaScript and Python background. Frankly, none of these languages has a technology that fully covers the macro concept in the Rust language as a standard. Of course, similar things can be done with different techniques or libraries, but when you examine it, you'll realize that it's a really powerful structure. Yes, enough marketing, let's examine the product a bit.
Before getting into metaprogramming, let's start with a simple introduction. If you've done some small coding with Rust, you've surely used macros. At least you've written println! :). At first glance, Println! looks like just a function. Frankly, the first question that came to my mind was "if I write it like a function, why is it called a macro and why is there an exclamation mark?"
Before diving deep, let's slowly get into the topic by looking for answers to questions like: what are the advantages of using macros? Why should or shouldn't we prefer macros over functions?
What Are the Advantages of Using Macros and the Differences from Functions?
- Functions are blocks of code that perform a specific task and are run when they're called. Functions take a fixed number and type of parameters.
fn add(a: i32, b: i32) -> i32 { a + b } fn main() { let sum = add(5, 3); println!("Sum: {}", sum); } - Macros are structures that expand and generate code at compile time, and they can take a variable number and type of parameters. Macros create other Rust code by writing Rust code.
macro_rules! multiply { ($num1:expr, $num2:expr) => { println!("{}", ($num1 as f64 * $num2 as f64)); }; ($num1:expr, $num2:expr, $num3:expr) => { println!("{}", ($num1 as f64 * $num2 as f64 * $num3 as f64)); }; } fn multiply(num1:f64, num2:f64) { println!("{}", (num1 * num2)); } fn main() { multiply!(1.5, 2); multiply!(1.5, 1.5); multiply!((1.5 * 1.5), 2); multiply!((1.5 * 1.5), 2, -1); multiply(1.5, 2.0); } You may not understand the code right now; don't get hung up on it, we'll touch on the details later.
This macro creates two functions with the create_function! macro: foo and bar. The macro creates the bodies of these functions using the given names. In other words, the system creates two functions for us. Imagine how much we can enrich this, how we can make many more extensions such as how these two functions will behave in which situation, their parameter counts, etc., and how much we can reduce code repetition. We haven't seen anything
yet, but even this much information gives us the freedom to do a lot.
But in the explanation we mentioned that macros expand the code at compile time, that is, they generate code for us. How will we be sure of this? In other words, we defined the macro and ran it with the same name; it looks as if no code was generated.
To see whether code is really generated at the compile stage, we'll use a crate called cargo-expand. For this, we first install the crate with the cargo install cargo-expand command and run the cargo expand command in the terminal or powershell in the directory where our project is. We'll get an output similar to the one below.
fn multiply(num1: f64, num2: f64) { { ::std::io::_print(format_args!("{0}\n", (num1 * num2))); }; } fn main() { { ::std::io::_print(format_args!("{0}\n", (1.5 as f64 * 2 as f64))); }; { ::std::io::_print(format_args!("{0}\n", (1.5 as f64 * 1.5 as f64))); }; { ::std::io::_print(format_args!("{0}\n", ((1.5 * 1.5) as f64 * 2 as f64))); }; { ::std::io::_print( format_args!("{0}\n", ((1.5 * 1.5) as f64 * 2 as f64 * -1 as f64)), ); }; multiply(1.5, 2.0); } If you notice, while the function we defined stays as it is, we can see that as many functions were created as we used in the main function where we used the macro we wrote.
So now, by examining the example below, let's examine the technical differences between a macro and a function.
macro_rules! multiply { ($num1:expr, $num2:expr) => { println!("{}", ($num1 as f64 * $num2 as f64)); }; ($num1:expr, $num2:expr, $num3:expr) => { println!("{}", ($num1 as f64 * $num2 as f64 * $num3 as f64)); }; } fn multiply(num1:f64, num2:f64) { println!("{}", (num1 * num2)); } fn get_num()->i32{ return -1 } fn main() { multiply!(1.5, 2); multiply!(1.5, 1.5); multiply!((1.5 * 1.5), 2); multiply!((1.5 * 1.5), 2, get_num()); multiply(1.5, 2.0); } A function signature specifies the number and type of the parameters the function has. In other words, this structure is like its signature. The multiply function is defined once and used. Later, operations like changing it, extending it or overloading it at runtime can't be done.
On the other hand, if we examine the multiply! macro, the parameters it takes are each an expression, that is, we can pass any Rust code we want as a parameter to the macro, provided it returns a numerical value.
Also, the macro can work with 2 or 3 parameters. This sort of resembles the overload concept.
As we mentioned above, macros expand the code at the compile stage. The macro controls how many different expansions it'll make using pattern macthing. As you can see here, we have a pattern with 2 and 3 parameters. So our code actually expands in 2 different ways.
Of course, we could have done these two expansions by writing functions too. In that case, our code would compile faster. But of course, when we write code in real life, in a world without macros we'd have to write and maintain thousands of functions.
- Also, macros are expanded before the compiler interprets the meaning of the code, so a macro can, for example, implement a trait on a given type. A function can't do this, because it's called at runtime, and a trait needs to be implemented at compile time.
Let's try to understand what's meant through the example below.
We'll touch on the details later, but let's just give it here as an explanation: '#[derive(Debug)]' is actually a macro call. In Rust, the derive macro is used to automatically implement certain traits.
use std::fmt; // macro definition for implementing the trait macro_rules! impl_display { ($t:ty) => { impl fmt::Display for $t { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{:?}", self) } } }; } // using derive to add the Debug trait #[derive(Debug)] struct Point { x: i32, y: i32, } // implementing the Display trait for the Point struct impl_display!(Point); fn main() { let point = Point { x: 10, y: 20 }; println!("{}", point); // Output: Point { x: 10, y: 20 } } Our goal is to implement the Display trait on the Point struct. For this, we wrote the impl_display macro. The Display trait lets the println! macro print the Point Struct. Let's examine it with the cargo expand command as we did above.
use std::fmt; struct Point { x: i32, y: i32, } #[automatically_derived] impl ::core::fmt::Debug for Point { #[inline] fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result { ::core::fmt::Formatter::debug_struct_field2_finish( f, "Point", "x", &self.x, "y", &&self.y, ) } } impl fmt::Display for Point { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { f.write_fmt(format_args!("{0:?}", self)) } } fn main() { let point = Point { x: 10, y: 20 }; { ::std::io::_print(format_args!("{0}\n", point)); }; } If you notice, the Display trait appears to have been implemented on the Point struct. We don't need to focus much on the rest of the code. We can see how the other macros generate code too, but that's not our topic right now.
The trait is written before the code even starts to be interpreted. If this weren't the case, the println! function wouldn't work at all; in fact, our code wouldn't even compile. For example, I use RustRover, and when I comment out the line where I made the macro call, it gives a sytax error without even letting it compile.
- Another important difference between macros and functions is that we have to define macros or bring them into scope before calling them in the file; functions, on the other hand, can be defined anywhere and called anywhere.
In the code block below, even though the function definition is defined after the main function where it's used, it will run without errors.
fn main() { // The function is called here greet(); } // The function can be defined anywhere fn greet() { println!("Hello, world!"); } The macro definition below, however, will give an error because it's defined after the main function.s
fn main() { // This call will give an error because the macro hasn't been defined yet greet!(); } // You get an error when the macro is defined here macro_rules! greet { () => { println!("Hello, world!"); }; } If the macro was defined outside the scope we're in, it won't work. In the example below, the macro in the my_macros.rs file is imported with #[macro_use]. At the same time, the macro is also exported on the macro with the #[macro_export] line. We can delete one of the two definitions. In other words, either macro_ue or macro_export has to stay.
// main.rs #[macro_use] mod my_macros; fn main() { greet!(); } // my_macros.rs #[macro_export] macro_rules! greet { () => { println!("Hello from macro!"); }; } What we need to know here is that we need to include the macro in scope before using it. In other words, the mod my_macros;
line must have been imported before using the macro in the main function.
Of course, as nice as they are, there are situations where macros are disadvantageous too. The main purpose of macros isn't
to replace functions anyway. The topics we mentioned above are just to understand how mokros work. But
let's still look at their negative sides too.
Debugging and Understandability: Macros usually generate code that's hard to debug. Errors may be in the macro's definition rather than in the code the macro expanded into, and tracking these errors can be hard.
Functions produce more distinct and understandable errors, which makes debugging easier.Type Checking: Functions work with specific types, and type checking is done at compile time. This helps catch type errors early.
Macros, on the other hand, don't do type checking, and type errors can occur when they're expanded. This can make compile errors harder to understand.Performance and Compile Time: Macros are expanded and recompiled every time they're called, which can lengthen compile time. Functions, on the other hand, are compiled once and optimize compile time.
Code Reuse and Modularity: Functions provide a modular and reusable structure. This makes the code easier to read and maintain. Macros reuse code by expanding it, which can increase the complexity of the code.
Error Messages: The error messages produced by macros can usually be more complex and hard to understand.
Functions produce more direct and understandable error messages, which helps developers solve errors faster.Scope Management: Macros can create problems related to scope. Especially when macros are used nested
within each other, scope management can get harder. Functions, on the other hand, have a specific scope, and scope management is simpler.
Now we can dive a bit deeper.
Abstract Syntax Tree (AST)
Before starting the topic, we need to understand the Abstract Syntax Tree concept.Abstract Syntax Tree (AST) is an abstract structure that turns a program's source code into a data structure representing the program's syntactic structure. AST makes it easier to understand and process the program's structure and hierarchy.
In an AST, each node represents a specific part of the program, and these nodes form a tree structure in relation to each other. Understanding and using the AST in the Rust language is quite important, especially in areas such as compiler writing, code analysis and tool development.
Let's go through an example.
fn add(a: i32, b: i32) -> i32 { a + b } The AST of the code above will be something like this.
Function ├─ Ident: "add" ├─ Inputs │ ├─ Argument │ │ ├─ Ident: "a" │ │ └─ Type: i32 │ └─ Argument │ ├─ Ident: "b" │ └─ Type: i32 ├─ Output: i32 └─ Body └─ Expression └─ BinaryOperation: "+" ├─ Identifier: "a" └─ Identifier: "b" This structure exists in most modern programming languages. For example, below you can see an example from the C# Roselyn compiler.
Some benefits the AST provides us.
- Syntactic Analysis:
- Understanding the Code: AST represents the syntactic structure of the source code and allows the code to be analyzed in a structured way.
- Error Detection: Compilers and analysis tools can detect and report syntax errors using the AST.
-
Code Transformation and Optimization:
- Code Transformations: AST allows the code to be transformed into different forms. For example, a compiler can turn the source code into machine code or intermediate code using the AST.
- Optimizations: Compilers can produce more efficient code by doing various optimizations on the AST.
-
Code Analysis and Tools:
- Static Code Analysis: AST lets static analysis tools examine the code in detail. These tools detect errors and potential issues to improve the quality of the code.
- Code Formatters and Refactoring Tools: AST allows the code to be formatted according to specific style rules or the code to be restructured.
Let's also take a look at the benefits the AST provides in the Rust Language
-
Safety and Error Management:
- Type Safety: Rust is known for its strong type system. The AST provides type checking, detecting type errors at compile time and making sure the code runs safely.
- Memory Safety: Rust's ownership model provides memory safety. The AST applies this model and prevents memory-related errors and memory leaks.
-
Performance and Optimization:
- Optimizations: The Rust compiler produces high-performance code by doing various optimizations on the AST. These optimizations minimize memory and processor usage.
- Zero-Cost Abstractions: Rust allows abstractions to be used without a performance cost. The AST makes sure these abstractions are applied and optimized efficiently.
-
Macros and Metaprogramming:
- Macro System: Rust's powerful macro system allows code to be generated automatically and repetitive code to be reduced. The AST allows the code to be analyzed and transformed during macro operations.
- Derive Macros: With derive macros, Rust allows certain features to be applied automatically. The AST is used to process these macros and generate the appropriate code.
-
Advanced Compilation Process:
- Fast Compilation: The Rust compiler provides a fast and efficient compilation process using the AST. This keeps compile times short even in large projects.
- Detailed Error Messages: The AST allows detailed and understandable error messages to be produced during compilation. This helps developers quickly detect and fix errors.
Now we can continue with macros.
Macro Types and Metaprogramming
In the Rust language, macros can be created in two ways.
- Declarative: Done using the
macro-rules!macro. It's the easiest way to define a macro. - Procedural: Lets you run, at compile time, code that works on Rust syntax and both consumes and produces Rust syntax. We can think of procedural macros as functions that go from one AST (abstract syntax tree) to another AST.
- Custom derive macro - custom!(...)
- Attribute-like macro - #[derive(CustomDerive)]
- Function-like macro #[CustomAttribute]
Now let's examine them one by one.
Declarative Macro
We had seen declarative macros superficially before, but there are many parts we haven't touched on.
It has a structure similar to match expressions. The code block in the matching pattern is run. It's written using the macro-rules! macro.
macro_rules! add{ // a pattern that takes 2 parameters // while a and b are the parameter names, the word expr indicates that this parameter is an expression, that is, a rust expression ($a:expr,$b:expr)=>{ // returns the result by adding the a and b parameters. // this way, this macro expands our code with the sum of the two expressions. // at the compile stage, code is added to our code for every place this macro is used. $a+$b } } } fn main(){ // $a=1 and $b=2 println!("{}", add!(1,2)); println!("{}", add!((3*5), (2.0 + 4.0))); } If we examine this code with the cargo expand command, we'll see a result like the one below.
fn main() { { ::std::io::_print(format_args!("{0}\n", 1 + 2)); }; { ::std::io::_print(format_args!("{0}\n", (3 * 5) + (2.0 + 4.0))); }; } As you can see, as much code was generated as the number of times the macro was run.
Of course, defining and using parameters isn't the only thing we can do. Besides expr, we have other token types too.
- item: function, struct, module, etc.
- block: a block of code inside curly braces
- stmt: statement (let x = 5; is a statement)
- pat: pattern
- expr: expression (let x = 5 + 6; // 5 + 6 is an expression)
- ty: type
- ident: identifier (for example, like a function name)
- path: path (e.g., foo, ::std::mem::replace, transmute::<_, int>, …)
- meta: meta item, the expressions that go inside #[...] and #![...] attributes
- tt: a single token tree
- vis: pub (expresses whether it's public or not)
Now let's do some examples for these and more.
macro_rules! print{ ($a:expr,$b:expr,$typ:ty)=>{ println!("{}", ($a as $typ + $b as $typ)); }; ($a:expr)=>{ println!("{}", ($a as &str)); } } fn main(){ print!(0,2,u16); print!("Hello"); } We've defined and used two different different patterns.
- Repetition in Declarative Macros
macro_rules! create_vector { // repeated expression: thanks to *, the expression can be repeated. ($($elem:expr),*) => { { let mut vec = Vec::new(); $(vec.push($elem);)* vec } }; } fn main() { // a closure that returns the sum of 2 numbers let num=|x| 3 + x; // we can send as many expressions as we want to the macro we defined. let v = create_vector
