Exploración de Macros en Rust en LabEx

Beginner

This tutorial is from open-source community. Access the source code

Introducción

Bienvenido a Macros. Esta práctica es parte del Rust Book. Puedes practicar tus habilidades de Rust en LabEx.

En esta práctica, exploraremos el concepto de macros en Rust, incluyendo macros declarativas con macro_rules! y tres tipos de macros procedimentales: macros personalizadas #[derive], macros similares a atributos y macros similares a funciones.

Macros

Hemos utilizado macros como println! en todo este libro, pero no hemos explorado por completo qué es una macro y cómo funciona. El término macro se refiere a una familia de características en Rust: macros declarativas con macro_rules! y tres tipos de macros procedimentales:

  • Macros personalizadas #[derive] que especifican el código agregado con el atributo derive utilizado en structs y enums
  • Macros similares a atributos que definen atributos personalizados que se pueden utilizar en cualquier elemento
  • Macros similares a funciones que se parecen a llamadas a funciones pero operan sobre los tokens especificados como su argumento

Hablaremos de cada una de estas a continuación, pero primero, echemos un vistazo a por qué necesitamos macros cuando ya tenemos funciones.

La Diferencia Entre Macros y Funciones

En esencia, las macros son una forma de escribir código que escribe otro código, lo que se conoce como metaprogramación. En el Apéndice C, discutimos el atributo derive, que genera una implementación de varios tratos para ti. También hemos utilizado las macros println! y vec! en todo el libro. Todas estas macros se expanden para producir más código que el código que has escrito manualmente.

La metaprogramación es útil para reducir la cantidad de código que tienes que escribir y mantener, lo que también es uno de los roles de las funciones. Sin embargo, las macros tienen algunas capacidades adicionales que las funciones no tienen.

La firma de una función debe declarar el número y el tipo de parámetros que tiene la función. Las macros, por otro lado, pueden tomar un número variable de parámetros: podemos llamar a println!("hello") con un argumento o println!("hello {}", name) con dos argumentos. Además, las macros se expanden antes de que el compilador interprete el significado del código, por lo que una macro puede, por ejemplo, implementar un trato en un tipo dado. Una función no puede, porque se llama en tiempo de ejecución y un trato necesita ser implementado en tiempo de compilación.

La desventaja de implementar una macro en lugar de una función es que las definiciones de macros son más complejas que las definiciones de funciones porque estás escribiendo código de Rust que escribe código de Rust. Debido a esta indirección, las definiciones de macros generalmente son más difíciles de leer, entender y mantener que las definiciones de funciones.

Otra diferencia importante entre macros y funciones es que debes definir las macros o traerlas al ámbito antes de llamarlas en un archivo, al contrario de las funciones que se pueden definir en cualquier lugar y llamar en cualquier lugar.

Macros Declarativas con macro_rules! para Metaprogramación General

La forma más utilizada de macros en Rust es la macro declarativa. A veces también se les conoce como "macros por ejemplo", "macros macro_rules!" o simplemente "macros". En esencia, las macros declarativas te permiten escribir algo similar a una expresión match de Rust. Como se discutió en el Capítulo 6, las expresiones match son estructuras de control que toman una expresión, comparan el valor resultante de la expresión con patrones y luego ejecutan el código asociado con el patrón coincidente. Las macros también comparan un valor con patrones que están asociados con código particular: en esta situación, el valor es el código fuente literal de Rust pasado a la macro; los patrones se comparan con la estructura de ese código fuente; y el código asociado con cada patrón, cuando coincide, reemplaza el código pasado a la macro. Todo esto sucede durante la compilación.

Para definir una macro, se utiliza la construcción macro_rules!. Vamos a explorar cómo usar macro_rules! viendo cómo se define la macro vec!. El Capítulo 8 cubrió cómo podemos usar la macro vec! para crear un nuevo vector con valores particulares. Por ejemplo, la siguiente macro crea un nuevo vector que contiene tres enteros:

let v: Vec<u32> = vec![1, 2, 3];

También podríamos usar la macro vec! para crear un vector de dos enteros o un vector de cinco rebanadas de cadena. No podríamos usar una función para hacer lo mismo porque no conoceríamos el número o el tipo de valores de antemano.

La Lista 19-28 muestra una definición ligeramente simplificada de la macro vec!.

Nombre del archivo: src/lib.rs

1 #[macro_export]
2 macro_rules! vec {
  3 ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
          4 $(
              5 temp_vec.push(6 $x);
            )*
          7 temp_vec
        }
    };
}

Lista 19-28: Versión simplificada de la definición de la macro vec!

Nota: La definición real de la macro vec! en la biblioteca estándar incluye código para preasignar la cantidad correcta de memoria de antemano. Ese código es una optimización que no incluimos aquí para simplificar el ejemplo.

La anotación #[macro_export] [1] indica que esta macro debe estar disponible siempre que el crat en el que se define la macro se haga visible. Sin esta anotación, la macro no puede ser llevada al ámbito.

Luego comenzamos la definición de la macro con macro_rules! y el nombre de la macro que estamos definiendo sin el signo de exclamación [2]. El nombre, en este caso vec, está seguido de llaves que denotan el cuerpo de la definición de la macro.

La estructura en el cuerpo de vec! es similar a la estructura de una expresión match. Aquí tenemos un brazo con el patrón ( $( $x:expr ),* ), seguido de => y el bloque de código asociado con este patrón [3]. Si el patrón coincide, el bloque de código asociado se emitirá. Dado que este es el único patrón en esta macro, solo hay una forma válida de coincidir; cualquier otro patrón resultará en un error. Las macros más complejas tendrán más de un brazo.

La sintaxis de patrón válida en las definiciones de macros es diferente de la sintaxis de patrón cubierta en el Capítulo 18 porque los patrones de macros se coinciden contra la estructura del código de Rust en lugar de valores. Vamos a analizar lo que significan las piezas de patrón en la Lista 19-28; para la sintaxis completa de patrón de macro, consulte la Referencia de Rust en https://doc.rust-lang.org/reference/macros-by-example.html.

Primero usamos un par de paréntesis para encerrar todo el patrón. Usamos un signo de dólar ($) para declarar una variable en el sistema de macros que contendrá el código de Rust que coincide con el patrón. El signo de dólar hace evidente que esta es una variable de macro en oposición a una variable regular de Rust. A continuación viene un par de paréntesis que captura los valores que coinciden con el patrón dentro de los paréntesis para su uso en el código de reemplazo. Dentro de $() está $x:expr, que coincide con cualquier expresión de Rust y le da el nombre $x a la expresión.

La coma que sigue a $() indica que un carácter separador literal de coma podría aparecer opcionalmente después del código que coincide con el código en $(). El * especifica que el patrón coincide con cero o más de lo que precede al *.

Cuando llamamos a esta macro con vec![1, 2, 3];, el patrón $x coincide tres veces con las tres expresiones 1, 2 y 3.

Ahora echemos un vistazo al patrón en el cuerpo del código asociado con este brazo: temp_vec.push() [5] dentro de $()* en [4] y [7] se genera para cada parte que coincide con()` en el patrón cero o más veces dependiendo de cuántas veces el patrón coincide. El `x[6] se reemplaza con cada expresión coincidente. Cuando llamamos a esta macro convec[1, 2, 3];`, el código generado que reemplaza esta llamada a macro será el siguiente:

{
    let mut temp_vec = Vec::new();
    temp_vec.push(1);
    temp_vec.push(2);
    temp_vec.push(3);
    temp_vec
}

Hemos definido una macro que puede tomar cualquier número de argumentos de cualquier tipo y puede generar código para crear un vector que contiene los elementos especificados.

Para aprender más sobre cómo escribir macros, consulte la documentación en línea u otros recursos, como "The Little Book of Rust Macros" en https://veykril.github.io/tlborm iniciado por Daniel Keep y continuado por Lukas Wirth.

Macros Procedimentales para Generar Código a Partir de Atributos

La segunda forma de macros es la macro procedimental, que actúa más como una función (y es un tipo de procedimiento). Las macros procedimentales aceptan algún código como entrada, operan sobre ese código y producen algún código como salida, en lugar de coincidir con patrones y reemplazar el código con otro código como lo hacen las macros declarativas. Los tres tipos de macros procedimentales son personalizadas derive, similares a atributos y similares a funciones, y todos funcionan de manera similar.

Al crear macros procedimentales, las definiciones deben residir en su propio crat con un tipo de crat especial. Esto es por razones técnicas complejas que esperamos eliminar en el futuro. En la Lista 19-29, mostramos cómo definir una macro procedimental, donde some_attribute es un marcador de posición para usar una variedad específica de macro.

Nombre del archivo: src/lib.rs

use proc_macro::TokenStream;

#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}

Lista 19-29: Un ejemplo de definición de una macro procedimental

La función que define una macro procedimental toma un TokenStream como entrada y produce un TokenStream como salida. El tipo TokenStream está definido por el crat proc_macro que viene incluido con Rust y representa una secuencia de tokens. Esta es la esencia de la macro: el código fuente sobre el que opera la macro forma el TokenStream de entrada, y el código que produce la macro es el TokenStream de salida. La función también tiene un atributo adjunto que especifica qué tipo de macro procedimental estamos creando. Podemos tener múltiples tipos de macros procedimentales en el mismo crat.

Echemos un vistazo a los diferentes tipos de macros procedimentales. Empezaremos con una macro personalizada derive y luego explicaremos las pequeñas diferencias que hacen que las otras formas sean diferentes.

Cómo Escribir una Macro derive Personalizada

Vamos a crear un crat llamado hello_macro que defina un trato llamado HelloMacro con una función asociada llamada hello_macro. En lugar de hacer que nuestros usuarios implementen el trato HelloMacro para cada uno de sus tipos, proporcionaremos una macro procedimental para que los usuarios puedan anotar su tipo con #[derive(HelloMacro)] para obtener una implementación predeterminada de la función hello_macro. La implementación predeterminada imprimirá ¡Hola, Macro! Me llamo NombreTipo! donde NombreTipo es el nombre del tipo en el que se ha definido este trato. En otras palabras, escribiremos un crat que permita a otro programador escribir código como el de la Lista 19-30 usando nuestro crat.

Nombre del archivo: src/main.rs

use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;

#[derive(HelloMacro)]
struct Pancakes;

fn main() {
    Pancakes::hello_macro();
}

Lista 19-30: El código que un usuario de nuestro crat podrá escribir al usar nuestra macro procedimental

Este código imprimirá ¡Hola, Macro! Me llamo Pancakes! cuando hayamos terminado. El primer paso es crear un nuevo crat de biblioteca, así:

cargo new hello_macro --lib

A continuación, definiremos el trato HelloMacro y su función asociada:

Nombre del archivo: src/lib.rs

pub trait HelloMacro {
    fn hello_macro();
}

Tenemos un trato y su función. En este momento, el usuario de nuestro crat podría implementar el trato para obtener la funcionalidad deseada, así:

use hello_macro::HelloMacro;

struct Pancakes;

impl HelloMacro for Pancakes {
    fn hello_macro() {
        println!("¡Hola, Macro! Me llamo Pancakes!");
    }
}

fn main() {
    Pancakes::hello_macro();
}

Sin embargo, tendrían que escribir el bloque de implementación para cada tipo que quisieran usar con hello_macro; queremos ahorrarlos esa labor.

Además, aún no podemos proporcionar a la función hello_macro una implementación predeterminada que imprima el nombre del tipo en el que se implementa el trato: Rust no tiene capacidades de reflexión, por lo que no puede buscar el nombre del tipo en tiempo de ejecución. Necesitamos una macro para generar código en tiempo de compilación.

El siguiente paso es definir la macro procedimental. En el momento de escribir esto, las macros procedimentales deben estar en su propio crat. Eventualmente, esta restricción podría ser levantada. La convención para estructurar crates y crates de macros es la siguiente: para un crat llamado foo, un crat de macro procedimental personalizado se llama foo_derive. Vamos a comenzar un nuevo crat llamado hello_macro_derive dentro de nuestro proyecto hello_macro:

cargo new hello_macro_derive --lib

Nuestros dos crates están estrechamente relacionados, por lo que creamos el crat de macro procedimental dentro del directorio de nuestro crat hello_macro. Si cambiamos la definición del trato en hello_macro, también tendremos que cambiar la implementación de la macro procedimental en hello_macro_derive. Los dos crates se tendrán que publicar por separado, y los programadores que usan estos crates tendrán que agregarlos ambos como dependencias y traerlos ambos al ámbito. En su lugar, podríamos hacer que el crat hello_macro use hello_macro_derive como dependencia y reexportar el código de la macro procedimental. Sin embargo, la forma en que hemos estructurado el proyecto permite a los programadores usar hello_macro incluso si no quieren la funcionalidad derive.

Necesitamos declarar el crat hello_macro_derive como un crat de macro procedimental. También necesitaremos funcionalidad de los crates syn y quote, como verás en un momento, por lo que debemos agregarlos como dependencias. Agregue lo siguiente al archivo Cargo.toml para hello_macro_derive:

Nombre del archivo: hello_macro_derive/Cargo.toml

[lib]
proc-macro = true

[dependencies]
syn = "1.0"
quote = "1.0"

Para comenzar a definir la macro procedimental, coloque el código de la Lista 19-31 en su archivo src/lib.rs para el crat hello_macro_derive. Tenga en cuenta que este código no se compilará hasta que agreguemos una definición para la función impl_hello_macro.

Nombre del archivo: hello_macro_derive/src/lib.rs

use proc_macro::TokenStream;
use quote::quote;
use syn;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // Construya una representación del código de Rust como un árbol de sintaxis
    // que podemos manipular
    let ast = syn::parse(input).unwrap();

    // Construya la implementación del trato
    impl_hello_macro(&ast)
}

Lista 19-31: Código que la mayoría de los crates de macros procedimentales requerirá para procesar el código de Rust

Tenga en cuenta que hemos dividido el código en la función hello_macro_derive, que se encarga de analizar el TokenStream, y la función impl_hello_macro, que se encarga de transformar el árbol de sintaxis: esto hace que escribir una macro procedimental sea más conveniente. El código en la función externa (hello_macro_derive en este caso) será el mismo para casi todos los crates de macros procedimentales que vea o cree. El código que especifique en el cuerpo de la función interna (impl_hello_macro en este caso) será diferente según el propósito de su macro procedimental.

Hemos introducido tres nuevos crates: proc_macro, syn (disponible en https://crates.io/crates/syn) y quote (disponible en https://crates.io/crates/quote). El crat proc_macro viene con Rust, por lo que no tuvimos que agregarlo a las dependencias en Cargo.toml. El crat proc_macro es la API del compilador que nos permite leer y manipular el código de Rust a partir de nuestro código.

El crat syn analiza el código de Rust de una cadena en una estructura de datos en la que podemos realizar operaciones. El crat quote convierte las estructuras de datos de syn de vuelta en código de Rust. Estos crates hacen que sea mucho más sencillo analizar cualquier tipo de código de Rust que podamos querer manejar: escribir un analizador completo para el código de Rust no es una tarea sencilla.

La función hello_macro_derive se llamará cuando un usuario de nuestra biblioteca especifique #[derive(HelloMacro)] en un tipo. Esto es posible porque hemos anotado la función hello_macro_derive aquí con proc_macro_derive y hemos especificado el nombre HelloMacro, que coincide con el nombre de nuestro trato; esta es la convención que siguen la mayoría de las macros procedimentales.

La función hello_macro_derive primero convierte el input de un TokenStream a una estructura de datos que luego podemos interpretar y realizar operaciones. Aquí es donde entra en juego syn. La función parse en syn toma un TokenStream y devuelve una estructura DeriveInput que representa el código de Rust analizado. La Lista 19-32 muestra las partes relevantes de la estructura DeriveInput que obtenemos al analizar la cadena struct Pancakes;.

DeriveInput {
    --snip--

    ident: Ident {
        ident: "Pancakes",
        span: #0 bytes(95..103)
    },
    data: Struct(
        DataStruct {
            struct_token: Struct,
            fields: Unit,
            semi_token: Some(
                Semi
            )
        }
    )
}

Lista 19-32: La instancia DeriveInput que obtenemos al analizar el código que tiene el atributo de la macro en la Lista 19-30

Los campos de esta estructura muestran que el código de Rust que hemos analizado es un struct unitario con el ident (identificador, es decir, el nombre) de Pancakes. Hay más campos en esta estructura para describir todo tipo de código de Rust; consulte la documentación de syn para DeriveInput en https://docs.rs/syn/1.0/syn/struct.DeriveInput.html para obtener más información.

Pronto definiremos la función impl_hello_macro, que es donde construiremos el nuevo código de Rust que queremos incluir. Pero antes de hacerlo, tenga en cuenta que la salida de nuestra macro derive también es un TokenStream. El TokenStream devuelto se agrega al código que escriben los usuarios de nuestro crat, por lo que cuando compilan su crat, obtendrán la funcionalidad adicional que proporcionamos en el TokenStream modificado.

Es posible que hayas notado que estamos llamando a unwrap para hacer que la función hello_macro_derive se detenga con un error si la llamada a la función syn::parse falla aquí. Es necesario que nuestra macro procedimental se detenga con errores porque las funciones proc_macro_derive deben devolver TokenStream en lugar de Result para conformarse a la API de la macro procedimental. Hemos simplificado este ejemplo usando unwrap; en el código de producción, deberías proporcionar mensajes de error más específicos sobre lo que salió mal usando panic! o expect.

Ahora que tenemos el código para convertir el código de Rust anotado de un TokenStream en una instancia DeriveInput, generemos el código que implementa el trato HelloMacro en el tipo anotado, como se muestra en la Lista 19-33.

Nombre del archivo: hello_macro_derive/src/lib.rs

fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
    let name = &ast.ident;
    let gen = quote! {
        impl HelloMacro for #name {
            fn hello_macro() {
                println!(
                    "¡Hola, Macro! Me llamo {}!",
                    stringify!(#name)
                );
            }
        }
    };
    gen.into()
}

Lista 19-33: Implementando el trato HelloMacro usando el código de Rust analizado

Obtenemos una instancia de la estructura Ident que contiene el nombre (identificador) del tipo anotado usando ast.ident. La estructura en la Lista 19-32 muestra que cuando ejecutamos la función impl_hello_macro en el código de la Lista 19-30, el ident que obtenemos tendrá el campo ident con un valor de "Pancakes". Por lo tanto, la variable name en la Lista 19-33 contendrá una instancia de la estructura Ident que, cuando se imprima, será la cadena "Pancakes", el nombre del struct en la Lista 19-30.

La macro quote! nos permite definir el código de Rust que queremos devolver. El compilador espera algo diferente al resultado directo de la ejecución de la macro quote!, por lo que necesitamos convertirla a un TokenStream. Hacemos esto llamando al método into, que consume esta representación intermedia y devuelve un valor del tipo TokenStream requerido.

La macro quote! también proporciona algunos mecanismos de plantilla muy geniales: podemos escribir #name, y quote! lo reemplazará con el valor de la variable name. Incluso puedes hacer algo de repetición similar a la forma en que funcionan las macros regulares. Consulte la documentación del crat quote en https://docs.rs/quote para una introducción exhaustiva.

Queremos que nuestra macro procedimental genere una implementación de nuestro trato HelloMacro para el tipo que el usuario anotó, que podemos obtener usando #name. La implementación del trato tiene la función hello_macro, cuyo cuerpo contiene la funcionalidad que queremos proporcionar: imprimir ¡Hola, Macro! Me llamo y luego el nombre del tipo anotado.

La macro stringify! usada aquí está integrada en Rust. Toma una expresión de Rust, como 1 + 2, y en tiempo de compilación convierte la expresión en un literal de cadena, como "1 + 2". Esto es diferente de format! o println!, macros que evalúan la expresión y luego convierten el resultado en un String. Es posible que la entrada #name sea una expresión para imprimir literalmente, por lo que usamos stringify!. Usar stringify! también ahorra una asignación convirtiendo #name en un literal de cadena en tiempo de compilación.

En este momento, cargo build debería completarse con éxito tanto en hello_macro como en hello_macro_derive. Vamos a conectar estos crates al código de la Lista 19-30 para ver la macro procedimental en acción! Cree un nuevo proyecto binario en su directorio project usando cargo new pancakes. Necesitamos agregar hello_macro y hello_macro_derive como dependencias en el Cargo.toml del crat pancakes. Si publicas tus versiones de hello_macro y hello_macro_derive en https://crates.io, serían dependencias normales; si no, puedes especificarlas como dependencias de path de la siguiente manera:

[dependencies]
hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }

Coloque el código de la Lista 19-30 en src/main.rs, y ejecute cargo run: debería imprimir ¡Hola, Macro! Me llamo Pancakes! La implementación del trato HelloMacro de la macro procedimental se incluyó sin que el crat pancakes tuviera que implementarla; el #[derive(HelloMacro)] agregó la implementación del trato.

A continuación, exploremos cómo las otras clases de macros procedimentales difieren de las macros derive personalizadas.

Macros Similares a Atributos

Las macros similares a atributos son similares a las macros derive personalizadas, pero en lugar de generar código para el atributo derive, te permiten crear nuevos atributos. También son más flexibles: derive solo funciona para structs y enums; los atributos se pueden aplicar a otros elementos también, como funciones. Aquí hay un ejemplo de uso de una macro similar a un atributo. Digamos que tienes un atributo llamado route que anota funciones cuando se usa un marco de aplicación web:

#[route(GET, "/")]
fn index() {

Este atributo #[route] sería definido por el marco como una macro procedimental. La firma de la función de definición de la macro se vería así:

#[proc_macro_attribute]
pub fn route(
    attr: TokenStream,
    item: TokenStream
) -> TokenStream {

Aquí, tenemos dos parámetros de tipo TokenStream. El primero es para el contenido del atributo: la parte GET, "/". El segundo es el cuerpo del elemento al que se adjunta el atributo: en este caso, fn index() {} y el resto del cuerpo de la función.

Excepto por eso, las macros similares a atributos funcionan de la misma manera que las macros derive personalizadas: creas un crat con el tipo de crat proc-macro e implementas una función que genere el código que quieres!

Macros Similares a Funciones

Las macros similares a funciones definen macros que se ven como llamadas a funciones. Al igual que las macros macro_rules!, son más flexibles que las funciones; por ejemplo, pueden tomar un número desconocido de argumentos. Sin embargo, las macros macro_rules! solo se pueden definir usando la sintaxis similar a match que discutimos en "Macros Declarativas con macro_rules! para Metaprogramación General". Las macros similares a funciones toman un parámetro TokenStream, y su definición manipula ese TokenStream usando código de Rust como lo hacen las otras dos clases de macros procedimentales. Un ejemplo de una macro similar a una función es una macro sql! que podría ser llamada así:

let sql = sql!(SELECT * FROM posts WHERE id=1);

Esta macro analizaría la declaración SQL dentro de ella y comprobaría que es sintácticamente correcta, lo que es un procesamiento mucho más complejo que lo que puede hacer una macro macro_rules!. La macro sql! se definiría así:

#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {

Esta definición es similar a la firma de la macro derive personalizada: recibimos los tokens que están dentro de los paréntesis y devolvemos el código que queríamos generar.

Resumen

¡Felicidades! Has completado el laboratorio de Macros. Puedes practicar más laboratorios en LabEx para mejorar tus habilidades.