Rust function-like procedural Macros
This tutorial explains the distinction between functions and function like macros. For example, why does
msg!
have an exclamation point after it? This tutorial will explain this syntax.As a strongly typed language, Rust cannot accept an arbitrary number of arguments to a function.For example, the Python
print
function can accept an arbitrary number of arguments:
print(1)
print(1, 2)
print(1, 2, 3)
The
!
denotes that the “function” is a function-like macro.Rust function-like macros are identified by the presence of a
!
symbol, for example in
println!(...)
or
msg!(...)
in Solana.In Rust, a regular function (not function-like macro) to print something is
std::io::stdout().write
and it only accepts a single byte string as an argument.*If you want to run the following code, the
Rust Playground is a convenient tool if you don’t want to set up a development environment.*Let’s use the following example (taken from
here):
use std::io::Write;
fn main() {
std::io::stdout().write(b"Hello, world!\n").unwrap();
}
Note that write is a function, not a macro as it does not have the
!
.If you try to do what we did above in Python, the code won’t compile because
write
only accepts one argument:
// this does not compile
use std::io::Write;
fn main() {
std::io::stdout().write(b"1\n").unwrap();
std::io::stdout().write(b"1", b"2\n").unwrap();
std::io::stdout().write(b"1", b"2", b"3\n").unwrap();
}
As such, if you wish to print an arbitrary number of arguments, *you need to write a custom
print
function to handle each case for each number of arguments — that is extremely inefficient!*Here is what such code would look like (this is highly not recommended!):
use std::io::Write;
// print one argument
fn print1(arg1: &[u8]) -> () {
std::io::stdout().write(arg1).unwrap();
}
// print two arguments
fn print2(arg1: &[u8], arg2: &[u8]) -> () {
let combined_vec = [arg1, b" ", arg2].concat();
let combined_slice = combined_vec.as_slice();
std::io::stdout().write(combined_slice).unwrap();
}
// print three arguments
fn print3(arg1: &[u8], arg2: &[u8], arg3: &[u8]) -> () {
let combined_vec = [arg1, b" ", arg2, b" ", arg3].concat();
let combined_slice = combined_vec.as_slice();
std::io::stdout().write(combined_slice).unwrap();
}
fn main() {
print1(b"1\n");
print2(b"1", b"2\n");
print3(b"1", b"2", b"3\n");
}
If we look for a pattern in the
print1
,
print2
,
print3
functions, it is simply inserting the arguments into a vector and adding a space in between them, then converting the vector back into a bytes string (a bytes slice to be precise).Wouldn’t it be nice if we could take a piece of code like
println!
and automatically expand it into a
print
function that takes exactly as many arguments as we need?This is what a Rust macro does.
A Rust macro takes Rust code as input and programmatically expands it into more Rust code.This helps us avoid the boredom of having to write a
print
function for every kind of
print
statement our code requires.
Expanding the macro
To see an example of how the Rust compiler is expanding the
println!
macro, check out the
cargo expand github repo. The result is quite verbose so we will not show it here.
It’s okay to treat macros as black boxes
Macros are very handy when supplied by a library, but very tedious to write by hand as it requires literally parsing the Rust code.
Different kinds of macros in Rust
The example we have given with
println!
is a function-like macro. Rust has other kinds of macros but the other two we care about are the *custom derive macro* and the *attribute-like macro*.Let’s look at a fresh program created by anchor:
We will explain how these work in the following tutorial.
Learn more with RareSkills
This tutorial is part of our free
Solana course.*Originally Published February, 15, 2024*