25.07.2021

Rust-dokumentaation lopussa kerrotaan proseduraalisista makroista. Siinä vaiheessa useimmat meistä pitävät tauon lukemisessa. Dokumentaatio taitaakin luottaa siihen, sillä dokumentaatio on proseduraalisten makrojen kohdalta luvattoman huonoa. Dokumentaatio ei ole myöskään ajan tasalla, sillä proseduraaliset makrot ovat muuttuneet vuoden 2018 Rust-päivityksessä. Proseduraaliset makrot jäävät helposti epäselviksi pitkäksi aikaa.

Mutta itse asiassa kyse on hyvin yksinkertaisesta asiasta:

Käännöksessä Rust-koodi voidaan esikäsitellä Rust-funktiolla.

Näin koodia voidaan muokata miten vain, ja makron argumenteille voidaan tehdä myös perusteellinen virhetarkistus.

Tavallisemmat makrot ovat deklaratiivisia. C:ssä makroilla kerrotaan miten tietyt merkkjonot korvataan toisilla. Rustin deklaratiiviset makrot ovat kehittyneempiä, ja niillä voi käsitellä hierarkkisia tokenpuita. Mutta ne eivät ole varsinaisia ohjelmia.

Token on ohjelmointikielen perusyksikkö. esimerkiksi luku 123 on yksi token. Sana let on token. Rustin makrot saavat argumentteinaan valmiiksi tokenisoidun Rust-ohjelman, jonka rakenne on myös Rustin sääntöjen mukaan purettu valmiiksi tokenpuuksi.

Rustin proseduraaliset makrot menevät vielä pidemmälle, ne ovat oikeita ohjelmia.

Esimerkiksi cratessa hex-literal on proseduraalinen makro hex!(), joka muuttaa minkä tahansa heksadesimaaliliteraalin tavutaulukoksi:

let bytes: [u8; 16] = hex!(
    "00010203 04050607" // first half
    "08090a0b 0c0d0e0f" // second half
);

Perus-Rustilla tämä jouduttaisiin määrittelemään

let bytes: [u8; 16] = [ 0x00, 0x01, 0x02, ... ]

eli säästetty työmäärä on huomattava ja merkintä on selkeämpi.

Makro hex!() yhdistää merkkijonot ja poistaa kommentit. Ohjelma näkee makron ajamisen jälkeen makron argumenttien sijaan kokonaislukutaulukon initialisoinnin heksadesimaaliarvot muutettuna kokonaislukuliteraaleiksi (proseduraalinen makro palauttaa suoraan tokeneja, ei tekstuaalista esitysmuotoa)

[0, 1, 2, ...]

Jos makron argumenteissa on virhe, proseduraalinen makro voi tehdä panic! -keskeytyksen, ja se nähdään kääntäjän virheilmoituksena. Esimerkiksi jos ensimmäinen nolla jätetään pois, heksadesimaalilukuja on pariton määrä:

 --> src/main.rs:4:27
  |
4 |       let bytes: [u8; 16] = hex!(
  |  ___________________________^
5 | |         "0010203 04050607" // first half
6 | |         "08090a0b 0c0d0e0f" // second half
7 | |     );
  | |_____^
  |
  = help: message: expected even number of hex characters

error: aborting due to previous error

Makro siis voi tehdä hyvinkin kehittynyttä käsittelyä argumenteilleen. Se voi tehdä mitä tahansa, koska käsittelijä on Rust-ohjelma.

Proseduraalisen makron käyttö

Proseduraalisen makron sisältämä crate lisätään normaalisti cargo.toml-tiedostoon ja makro lisätään use-lauseeseen ilman huutomerkkiä. Esimerkiksi Cargo.toml:

hex-literal = "0.3.3"

Lähdekoodissa:

use hex_literal::hex;

Aikaisemmin Rust vaati proseduraalisten makrojen tuomiseksi käyttämään lisäksi erityistä macro_use -merkintää jokaisessa makroja käyttävässä lähdetiedostossa:

#[macro_use] extern crate hex_literal;

Tätä ei enää tarvita Rustin versiossa 2018. Tavallinen use riittää.

Ansoja

Huomaa, että proseduraalisen makron on oltava ajokelpoinen ohjelma siinä vaiheessa, kun sitä käytetään. Siksi se pitää määritellä eri cratessa kuin missä sitä käytetään.

Proseduraalinen makro ajaa Rust-ohjelman ilman, että ohjelman kääntäjä sitä tulee ajatelleeksi. Tämä suoritus ei ole missään suojatussa hiekkalaatikossa vaan suoraan kehityskoneessa. On mahdollista, että makro on pahantahtoinen ja tekee ei-haluttuja muutoksia kehityskoneeseen.

Proseduraalisten makrojen tyypit

hex!() -makro on yleisintä makrotyyppiä eli funktion näköinen makro. Rustissa nämä voivat olla deklaratiivisia (määritely erityisellä makrokielellä) tai proseduraalisia (määritelty Rustilla itsellään).

Attribuuttimakrot

Toinen makrotyyppi on proseduraaliset attribuuttimakrot. Nämä näyttävät koodissa tältä

#[attribuuttimakron_nimi(argumentteja)]
fn tee_jotain(){
  // koodia
}

Tällainen attribuuttimakro saa argumentikseen koko seuraavan funktion koodin. Se on vapaa muuttamaan tätä koodia miten haluaa.

Huomaa, etteivät kaikki attribuutit ole makroja. Osa niistä on sisäänrakennettuja. Esimerkiksi proseduraalisen makron esittelevä attribuutti proc_macro ei ole makro. Sitä ei tarvitse ottaa käyttöön use-lauseella kuten makro pitäisi.

Derive-makrot

Kolmas proseduraalisen makron tyyppi on derive-makro.

#[derive(attribuutti)]

Tällä voidaan luoda esimerkiksi structeille puoliautomaattisesti funktioiden/traittien toteutuksia.

Proseduraalisten makrojen teko

Cratessa, joka määrittelee proseduraalisia makroja tulee olla attribuutti proc-macro määriteltynä:

[lib]
proc-macro = true

Proseduraaliset makrot käsittelevät token-jonoja ja -puita. Koska tämä on työlästä, useimmissa tapauksissa halutaan käyttää tähän erikoistuneita kirjastoja.

Crate proc-macro on kääntäjän peruspaketti proseduraalisten makrojen tekemiseen ja TokenStream-tyypin käsittelyyn.

Proseduraalisten makrojen on kuitenkin usein käsiteltävä myös Rust-koodia tasolla, joka on työlästä pelkällä TokenStream- tyypillä. Tätä varten on kaksi cratea, syn ja quote.

Syn on Rust-kielen parseri proseduraalisten makrojen käyttöön.

Quote tokenisoi merkkijonossa olevaa Rust-kieltä. Koska proseduraalisen makron on palautettava tokeneita, koodi, jonka haluamme palauttaa sellaisenaan on muutettava quotella tokeneiksi.

Lisäksi cratella rustc-span voidaan selvittää lähdekoodin tarkka rivi parempia virheilmoituksia varten.

Funktion kaltaisen proseduraalisen makron määrittely

Funtion kaltaista makroa voi käyttää kaikkialla missä funktiotakin.

Makron määrittelee #[proc_macro] ja funktio, jonka sisään- ja ulosparametrit ovat tyyppiä TokenStream. Funktion nimi on makron nimi.

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

#[proc_macro]
pub fn ping(_input: TokenStream) -> TokenStream {
    TokenStream::from(quote!(
            info!("Here we are");
    ))
}

Tämä makro tekee yksinkertaisimman mahdollisen asian eli muuttaa makron

ping!();

Makrokutsuksi

info!("Here we are");

Makro ei lue sisään tulevaa TokenStreamia.

Makro info!() tulee paketista log, ja se tallettaa viestin lokiin vakavuustasolla info.

Proseduraalinen attribuuttimakro

Attribuuttimakro määritellään melko samalla tavalla. Määrittelevä attribuutti on proc_macro_attribute ja sisääntulevia TokenStreameja on kaksi, toinen metadatalle.

Seuraava makro #![foo] luo structin Foo{}.

Huomaa, että attribuutit #[] koskevat seuraavaa elementtiä, esim. funktiota, jonka ne saavat sisääntulevana TokenStreaminä. Huutomerkillä lisättynä #![] koskee koko yksikköä, missä se on. Muuttujia ja tyyppejä voi siis esitellä #![]-notaatiolla.

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

#[proc_macro_attribute]
pub fn foo(_metadata: TokenStream, _input: TokenStream) -> TokenStream {
    TokenStream::from(quote!{struct Foo{}})
}

Monimutkaisempia esimerkkejä löytyy lähteistä.

Lisätietoja

The Rust Reference: Procedural Macros

Macros in Rust: A tutorial with examples

The Rust Programming Language: Procedural Macros for Generating Code from Attributes

6 useful Rust macros that you might not have seen before

Crate proc_macro

crates.io: syn

crates.io: quote

Crate hex-literal, lähdekoodi

Procedural Macros in Rust 2018