I once told my colleague that there were macros in Rust, it seemed to him that this was bad. I used to have the same reaction, but Rust showed me that macros are not necessarily bad.
Where and how to apply them? Look under the cut.
Macros are a form of metaprogramming : they are code that manipulates code. Metaprogramming has received a bad reputation, because using them is not easy to avoid writing bad code. Examples are #define
in C, which can easily interact with the code in unpredictable ways , or eval
in JavaScript, which increase the danger of code injection .
Many of these problems can be solved by using the necessary tools, while macros provide some of these tools:
In order to achieve these goals, Rust includes two types of macros -2-. They are known by various names (procedural, declarative, macro_rules
, etc.), although I believe that these names are somewhat confusing. Fortunately, they are not so important, so I will call them functional and attribute .
The reason that there are two types of macros is that they are well suited for various tasks:
In all other respects, the results of their use are similar: the compiler "erases" macros during compilation, replacing them with code generated from a macro, and compiling it with "normal", not macro code -3-. The implementation of the two types of macros is very different, but we will not go deeply into this here.
A functional macro can be executed almost as a function. This type of macro has !
in the call:
let x = action(); // let y = action!(); //
Why use macros when you can use functions? It must be remembered that functional macros have nothing to do with functions — they are similar to functions so that they, macros, are easier to use. Therefore, the question is not whether this type of macros is better than functions or not, but whether we need the ability to change the source code.
Let's start by looking at assert!
which is used to verify that a condition is met, causing panic if it is not. They are checked at runtime, so what does metaprogramming give us here? Let's look at the message that is printed when assert!
fails:
fn main() { let mut vec = Vec::new(); // vec.push(1); // assert!(vec.is_empty()) // - assert! // : // thread 'main' panicked at 'assertion failed: vec.is_empty()', src\main.rs:4 }
This message contains a condition that we are checking. In other words, the macro creates an error message that is based on the source code, we receive a meaningful error message without inserting it into the program manually.
Many programming languages support setting output formats for -4- lines. Rust is no exception and also supports defining string formats with format!
. However, the question still remains: why should we use metaprogramming to solve the problem? Let's look at println!
(he uses format!
inside to process the passed string) -5-.
fn main() { // println!("{} is {} in binary", 2, 10); // : 2 is 10 in binary // println!("{0} is {0:b} in binary", 3) // : 3 is 11 in binary }
There are many reasons that format!
implemented as a macro -6-, I want to emphasize the fact that it can split a line into parts during compilation, analyze it and check whether the processing of the passed arguments is type-safe. We can change our code and get a compilation error:
fn main() { println!("{} is {} in binary", 2/*, 10*/); // : , println!("{0} is {0:b} in binary", "3") // : }
In many other languages, these errors would appear at runtime, but in Rust we can use macros to perform this check at compile time and generate productive code to process the format of the string without run-time checks -7-.
In this example, we’ll go into the language ecosystem a bit. Rust has a log package , which is used as the main front end logging. Like other solutions for logging, it provides different levels of logging, but, unlike other solutions, these levels are represented by macros, rather than functions.
Logging shows the power of metaprogramming in how it uses file!
macros file!
and line!
; These macros make it possible to determine the exact location of the call to the logging function in the source code. Let's look at an example. Since log
is frontend, let's add a backend, the flexi_logger package.
#[macro_use] extern crate log; extern crate flexi_logger; use flexi_logger::{Logger, LogSpecification, LevelFilter}; fn main() { // `trace` let log_config = LogSpecification::default(LevelFilter::Trace).build(); Logger::with(log_config) .format(flexi_logger::opt_format) // Specify how we want the logs formatted .start() .unwrap(); // . info!("Fired up and ready!"); complex_algorithm() } fn complex_algorithm() { debug!("Running complex algorithm."); for x in 0..3 { let y = x * 2; trace!("Step {} gives result {}", x, y) } }
This program will print:
[2018-01-25 14:48:42.416680 +01:00] INFO [src\main.rs:16] Fired up and ready! [2018-01-25 14:48:42.418680 +01:00] DEBUG [src\main.rs:22] Running complex algorithm. [2018-01-25 14:48:42.418680 +01:00] TRACE [src\main.rs:25] Step 0 gives result 0 [2018-01-25 14:48:42.418680 +01:00] TRACE [src\main.rs:25] Step 1 gives result 2 [2018-01-25 14:48:42.418680 +01:00] TRACE [src\main.rs:25] Step 2 gives result 4
As you can see, our logs contain file names and line numbers.
In the first case, the compiler inserts the necessary information into executable files, which we can print if necessary. If we did not solve this problem at compile time, we would have to examine the stack at run time , which is fraught with errors and reduces performance.
If we replace the logging macros with functions, we can still call file!
and line!
:
fn info(input: String) { // info! Log::log( logger(), RecordBuilder::new() .args(input) .file(Some(file!())) .line(Some(line!())) .build() ) }
And this code would output the following:
[2018-01-25 14:48:42.416680 +01:00] INFO [src\loggers\info.rs:7] Fired up and ready!
The file name and line number are useless, for they indicate where the logging function was called. In other words, the first example worked just because we used macros that were replaced by the generated code, putting the file!
and line!
directly to the source code, providing us with the necessary information (the file name and line number are now in the executable file) -8-.
Rust includes the concept of attributes , which is needed for tagging code. For example, the test function looks like this:
#[test] // <- fn my_test() { assert!(1 > 0) }
Running the cargo test
will launch this feature. Attribute macros allow you to create new attributes that are similar to native attributes, but have other effects. At the moment there is an important limitation: in the compiler from the stable branch, only macros using the derive attribute are working, while custom attributes work in nightly builds . Consider the difference below.
Considering the advantages given by attribute macros, it is advisable to compare code that can manipulate source code with one that cannot .
The derive
attribute is used in Rust to generate the implementation of types. Let's look at PartialEq
.
#[derive(PartialEq, Eq)] struct Data { content: u8 } fn main() { let data = Data { content: 2 }; assert!(data == Data { content: 2 }) }
Here we create a structure whose instances we want to check for equality (use ==
), so we get the implementation of PartialEq
-9-. We could implement PartialEq
independently, but our implementation would be trivial, because we only want to check objects for equality:
impl PartialEq for Data { fn eq(&self, other: &Data) -> bool { self.content == other.content } }
This code also generates a compiler for us, so using the macro saves us time, however, more importantly, it saves us from having to support checking for equality of the code in the current state. If we add a field to the structure, we need to change the validation in our manual implementation of PartialEq
, otherwise (for example, if we forget to change the verification code), verification of different objects can be successful.
Getting rid of the burden of support is a big advantage that the attribute macro gives us. We wrote the structure code in one place and automatically got the realization of the function of checking and guaranteeing the compilation time that the verification code corresponds to the current structure definition. A striking example of this is the serde package used to serialize data, and without macros we would need to use strings to indicate serde to the names of the structure fields , keeping these strings up to date with respect to defining the structure -10-.
derive
is one of the many possibilities for code generation by attribute macros, and not just the implementation of types. At the moment it is available in nightly assemblies , which I hope will be stabilized this year .
The most outstanding use case at the moment is Rocket - a library for writing web servers. Creating REST-endpoints requires adding an attribute to a function, so now the function contains all the necessary information to process the request.
#[post("/user", data = "<new_user>")] fn new_user(admin: AdminUser, new_user: Form<User>) -> T { //... }
If you have worked with web libraries in other languages (for example, Flask or Spring ), then this style is probably not new to you. I will not compare these libraries here, I will only note that you can write similar code in Rust, taking advantage of its advantages (high performance of the resulting native code, etc.) -11-.
Macros are not perfect, consider some of their flaws:
compiler_error!
And packages like syn ).format!
accepts a string written in a mini-language that is not Rust, but DSL . Although DSL is a powerful tool, its use can easily put into difficulty if the developer wants to create his own embedded language. If you decide to write DSL, remember that big opportunities mean a lot of responsibility, and the fact that you can do DSL does not imply the need to do it.Macros are a powerful tool that can help in development. I hope I was able to instil in you the idea that the macros in Rust are a positive thing and have cases when their use is appropriate.
-1-: Do not confuse with const fn
.
-2-: Known as Macros 1.1.
-3-: Replacing a macro with a generated code is called a macro extension.
-4-: For example, printf in C , String.Format in C # , formatting strings in Python .
-5-: format!
deals with formatting a string that can be used by println!
macros println!
and others .
-6-: varargs uses format!
. This feature (varargs) conflicts with the decision to prohibit overloading functions , so using a macro is very appropriate - no need to add support to the core language.
-7-: Scala has a good implementation of string interpolation , which does checks at the compilation stage. I do not know whether the interpolation of lines in Rust will be added, although we have already seen similar examples: try!
It has evolved from a macro to a built-in ability in a language , so that this is possible with appropriateness.
-8-: Rust has a problem - panicked methods (for example, unwrap
and expect
) give useless error messages because they do not have access to information about the calling code .
-9-: PartialEq
is a type used to check objects for equality, we also use Eq
for correctness. The PartialEq
documentation explains why there is a similar division in Rust.
-10-: The problem can be solved by reflection, which is not supported in Rust, because it contradicts the design of the language, as it reduces the performance of the runtime, because it requires an appropriate runtime .
-11-: Sergio Benitez, author of Rocket, made a good performance related to this.
Source: https://habr.com/ru/post/350716/