La escritura de macros

Escribir una macro puede ser mucho complejo. Casi nunca necesitarás escribir una, pero en ocasiones puede ser necesario. Sus reglas de lenguaje pueden ser muy diferentes. Una forma de escribir una macro nueva es usar la macro macro_rules!, dándole un nombre y seguida de un bloque {}. Dentro del bloque, se comporta como una especie de sentencia match.

A continuación, se muestra un ejemplo que solo toma () como parámetro y devuelve únicamente el número 6.

macro_rules! give_six {
    () => {
        6
    };
}

fn main() {
    let six = give_six!();
    println!("{}", six);
}

Pero no es lo mismo que una sentencia match, debido a que en realidad no compila nada. Solo usa la entrada y devuelve una salida. Con esa salida, el compilador comprueba si tiene sentido. Por eso se dice que las macros son "código que escribe código". En una macro, además, los valores de retorno tienen que ser del mismo tipo, por lo que el siguiente código no funcionaría:

fn main() {
// ⚠️
    let my_number = 10;
    match my_number {
        10 => println!("You got a ten"),
        _ => 10,
    }
}

Dará error indicando que una de las ramas devuelve (), mientras que otra devuelve un 10.

error[E0308]: `match` arms have incompatible types
 --> src\main.rs:5:14
  |
3 | /     match my_number {
4 | |         10 => println!("You got a ten"),
  | |               ------------------------- this is found to be of type `()`
5 | |         _ => 10,
  | |              ^^ expected `()`, found integer
6 | |     }
  | |_____- `match` arms have incompatible types

En el caso de una macro, sí se puede "devolver" diferente código, no es algo compilado, es código lo que se devuelve. Por lo que se puede hacer esto:

macro_rules! six_or_print {
    (6) => {
        6
    };
    () => {
        println!("You didn't give me 6.");
    };
}

fn main() {
    let my_number = six_or_print!(6);
    six_or_print!();
}

Lo anterior es correcto e imprime You didn't give me 6.. Tampoco existe una rama _ porque funciona como un match. Solo se le puede pasar como parámetro (6) o (). Cualquier otra cosa dará error. El 6 que se está pasando, no es de tipo i32, no es de ningún tipo, solo es el código de Rust 6. Esto hace que la entrada de una macro pueda ser cualquier cosa. Se puede pasar cualquier cosa, porque internamente solo se mira lo que se pasa, para determinar qué hacer. Por ejemplo:

macro_rules! might_print {
    (THis is strange input 하하はは哈哈 but it still works) => {
        println!("You guessed the secret message!")
    };
    () => {
        println!("You didn't guess it");
    };
}

fn main() {
    might_print!(THis is strange input 하하はは哈哈 but it still works);
    might_print!();
}

La macro anterior solo responde a dos entradas () y (THis is strange input 하하はは哈哈 but it still works). A nada más. En este caso imprime:

You guessed the secret message!
You didn't guess it

En conclusión, una macro no sigue exactamente la sintaxis habitual de Rust. Pero una macro sí puede comprender diferentes tipos de entrada. Por ejemplo:

macro_rules! might_print {
    ($input:expr) => {
        println!("You gave me: {}", $input);
    }
}

fn main() {
    might_print!(6);
}

Esto imprime You gave me: 6. La parte $input:expr es importante. Significa: si hay una expresión de entrada, asígnala a la variable $input. En las macros, las variables comienzan con el símbolo $. En esta macro, si se le pasa una expresión, la imprimirá. El siguiente ejemplo intenta algo más:

macro_rules! might_print {
    ($input:expr) => {
        println!("You gave me: {:?}", $input); // Now we'll use {:?} because we will give it different kinds of expressions
    }
}

fn main() {
    might_print!(()); // give it a ()
    might_print!(6); // give it a 6
    might_print!(vec![8, 9, 7, 10]); // give it a vec
}

Que imprimirá:

You gave me: ()
You gave me: 6
You gave me: [8, 9, 7, 10]

Se debe observar que se escribió {:?}, pero en el momento de la macro no se comprueba si &input implementa Debug. Solo generará el código correspondiente println!("You gave me: {:?}", ()) y lo intenta compilar y si no compila da error.

Aparte de expr, una macro puede recibir estos tipos de parámetro: block, expr, ident, item, lifetime, literal, meta, pat, path, stmt, tt, ty y vis. Esta parte es compleja, se puede ver lo que significa cada una aquí, que dice:

item: an Item
block: a BlockExpression
stmt: a Statement without the trailing semicolon (except for item statements that require semicolons)
pat: a Pattern
expr: an Expression
ty: a Type
ident: an IDENTIFIER_OR_KEYWORD
path: a TypePath style path
tt: a TokenTree (a single token or tokens in matching delimiters (), [], or {})
meta: an Attr, the contents of an attribute
lifetime: a LIFETIME_TOKEN
vis: a possibly empty Visibility qualifier
literal: matches -?LiteralExpression

Hay otro lugar denominado cheats.rs que los explica con ejemplos.

Sin embargo, para la mayoría de macros, será suficiente usar expr, ident y tt. ident significa identificador y sirve para pasar variables o nombres de función. tt significa árbol de elementos (token tree), que permite cualquier tipo de entrada. A continuación se muestra un ejemplo usando ambos:

macro_rules! check {
    ($input1:ident, $input2:expr) => {
        println!(
            "Is {:?} equal to {:?}? {:?}",
            $input1,
            $input2,
            $input1 == $input2
        );
    };
}

fn main() {
    let x = 6;
    let my_vec = vec![7, 8, 9];
    check!(x, 6);
    check!(my_vec, vec![7, 8, 9]);
    check!(x, 10);
}

Esta macro recibe un ident (el nombre de una variable, por ejemplo) y una expr expresión y comprueban si son iguales. En el ejemplo se imprime:

Is 6 equal to 6? true
Is [7, 8, 9] equal to [7, 8, 9]? true
Is 6 equal to 10? false

A continuación se muestra una macro que recibe un tt y lo imprime. Usa otra macro denominada stringify! que construye una cadena de lo que recibe:

macro_rules! print_anything {
    ($input:tt) => {
        let output = stringify!($input);
        println!("{}", output);
    };
}

fn main() {
    print_anything!(ththdoetd);
    print_anything!(87575oehq75onth);
}

Que imprime:

ththdoetd
87575oehq75onth

Pero no imprimirá nada si se le pasa algo con espacios, comas, etc. La macro creerá que se le está pasando más de un elemento u otra información extra y no se cumplirá la selección.

Esto es lo que hace que las macros comiencen a ser difíciles.

Para pasar más de un elemento a una macro es necesario utilizar una sintaxis diferente. En lugar de $input. debería ser $($input1),*. Esto signfica cero o más (es para lo que está el *), seperados por una coma. Si lo que se quiere es una o más, se utilizar + en lugar de *.

Ahora, la macro quedaría así:

macro_rules! print_anything {
    ($($input1:tt),*) => {
        let output = stringify!($($input1),*);
        println!("{}", output);
    };
}


fn main() {
    print_anything!(ththdoetd, rcofe);
    print_anything!();
    print_anything!(87575oehq75onth, ntohe, 987987o, 097);
}

Recibe un árbol de tokens separado por comas y utiliza stringify! para convertirlo en una cadena de caracteres. Luego, la imprime. El resultado es:

ththdoetd, rcofe

87575oehq75onth, ntohe, 987987o, 097

Si se usara + en lugar de * daría error debido a que en uno de los usos no se pasa ningún parámetro. Por ello, es más seguro * para este uso.

Ahora se puede empezar a ver la potencia de las macros. En el siguiente ejemplo se usa una macro para construir una función:

macro_rules! make_a_function {
    ($name:ident, $($input:tt),*) => { // En primer lugar se le da nombre a la función y luego se comprueba todo lo demás
        fn $name() {
            let output = stringify!($($input),*); // La función imprimirá el resto de los parámetros
            println!("{}", output);
        }
    };
}


fn main() {
    make_a_function!(print_it, 5, 5, 6, I); // Crea la función print_it() que imprime todo lo que se pasa como parámetro
    print_it();
    make_a_function!(say_its_nice, this, is, really, nice); // igual, pero con otro nombre de función
    say_its_nice();
}

Esto imprime:

5, 5, 6, I
this, is, really, nice

Ahora se puede empezar a comprender otras macros. Se puede ver que algunas de las macros que se han usado anteriormente son bastante sencillas. La macro write! que se ha usado para escribir a ficheros es como sigue:

#![allow(unused)]
fn main() {
macro_rules! write {
    ($dst:expr, $($arg:tt)*) => ($dst.write_fmt($crate::format_args!($($arg)*)))
}
}

Para usarla, se introduce:

  • Una expresión (expr) que se asigna a la variable $dst.
  • Todo lo que siga a esa expresión se asigna a $($arg)*, ya que se ha escrito que el parámetro será $($arg:tt)*, que significa que puede seguir desde cero a cualquier número de parámetros.

Posteriormente se usa el método write_fmt de $dst para volcarlo al fichero. Aunque previamente, ser usa otra madro llamada format_args! que toma $($arg)*. Es decir, todos los parámetros recibidos.

A continuación se echa un vistazo a la macro todo!. Que se usa para que el programa compile cuando aún no se ha escrito el código. El código de esta macro es como sigue:

#![allow(unused)]
fn main() {
macro_rules! todo {
    () => (panic!("not yet implemented"));
    ($($arg:tt)+) => (panic!("not yet implemented: {}", $crate::format_args!($($arg)+)));
}
}

Esta macro tiene dos opciones de uso. Se puede usar con () o con unúmero de árboles de token ( tt ).

  • Si se usa con (), simplemente lo sustituye por panic! con un mensaje.
  • Si se introducien algunos parámetros, se intentará imprimirlos. Como en el caso anterior, se usa la macro format_args! que funciona como println!.

El siguiente código también funciona:

fn not_done() {
    let time = 8;
    let reason = "lack of time";
    todo!("Not done yet because of {}. Check back in {} hours", reason, time);
}

fn main() {
    not_done();
}

Que imprime:

thread 'main' panicked at 'not yet implemented: Not done yet because of lack of time. Check back in 8 hours', src/main.rs:4:5

Una macro se puede llamar a sí misma. Por ejemplo:

macro_rules! my_macro {
    () => {
        println!("Let's print this.");
    };
    ($input:expr) => {
        my_macro!();
    };
    ($($input:expr),*) => {
        my_macro!();
    }
}

fn main() {
    my_macro!(vec![8, 9, 0]);
    my_macro!(toheteh);
    my_macro!(8, 7, 0, 10);
    my_macro!();
}

Esta macro toma () o una expresión o muchas expresiones. Pero ignora esas posibles expresiones al volver a llamar a my_macro! sin parámetros (). POr eso, la salida es Let's print this, four times.

Se puede ver lo mismo en la macro dbg! que se llama a sí misma:

#![allow(unused)]
fn main() {
macro_rules! dbg {
    () => {
        $crate::eprintln!("[{}:{}]", $crate::file!(), $crate::line!()); //$crate significa la librería en la que se encuentra
    };
    ($val:expr) => {
        // el uso de `match` aquí es intencionado ya que afecta los ciclos de vida        
        // de los temporales - https://stackoverflow.com/a/48732525/1063961
        match $val {
            tmp => {
                $crate::eprintln!("[{}:{}] {} = {:#?}",
                    $crate::file!(), $crate::line!(), $crate::stringify!($val), &tmp);
                tmp
            }
        }
    };
    // Se ignora la coma final con un solo argumento
    ($val:expr,) => { $crate::dbg!($val) };
    ($($val:expr),+ $(,)?) => {
        ($($crate::dbg!($val)),+,)
    };
}
}

eprintln! es igual que println!, salvo que imprime a io::stderr en lugar de a io::stdout. Existe también una macro eprint! que no añade una nueva línea.

Se puede intentar esto:

fn main() {
    dbg!();
}

El código anterior, coincide con la primera rama, por lo que imprimirá el nombre del fichero y la línea usando las macros file! y line!. Imprime [src/main.rs:2].

Si se prueba con este otro código:

fn main() {
    dbg!(vec![8, 9, 10]);
}

Este código coincide con la siguiente rama, ya que es una expresión. Llamará a la entrada tmp que usa el siguiente código $crate::eprintln!("[{}:{}] {} = {:#?}", $crate::file!(), $crate::line!(), $crate::stringify!($val), &tmp);. Por lo que imprimirá file!, line! y $val convertido a una cadena de caracteres. Usa {:#?} para imprimir "bonito" tmp. Para el ejemplo del código anterior, se imprime:

[src/main.rs:2] vec![8, 9, 10] = [
    8,
    9,
    10,
]

Las ramas restantes permiten que funcione la macro incluso aunque se añada una coma de más.

Como se puede ver, la programación de macros es un tema complejo. Normalmente, se usan macros para resolver de forma automática algo que una simple función no puede hacer bien. La mejor forma de aprender a usarlas es mirar otros ejemplos de uso de macros. No es fácil escribirlas sin entrar en problemas, pero tampoco es necesario usarlas de forma perfecta para usar Rust. Partiendo de otras macros, se puede aprender mucho y programar otras de forma más sencilla, aprovechando el conocimiento de ellas.