Makra
Makr takich jak println! używaliśmy w całej książce, ale nie zbadaliśmy
jeszcze w pełni, czym jest makro i jak działa. Termin makro odnosi się do
rodziny funkcji w Rust—makra deklaratywne z macro_rules! oraz trzy rodzaje
makr proceduralnych:
- Niestandardowe makra
#[derive], które określają kod dodany za pomocą atrybutuderiveużywanego w strukturach i enumach - Makra podobne do atrybutów, które definiują niestandardowe atrybuty możliwe do użycia z dowolnym elementem
- Makra podobne do funkcji, które wyglądają jak wywołania funkcji, ale operują na tokenach określonych jako ich argument
Będziemy mówić o każdym z nich po kolei, ale najpierw przyjrzyjmy się, dlaczego w ogóle potrzebujemy makr, skoro mamy już funkcje.
Różnica między makrami a funkcjami
Zasadniczo, makra to sposób pisania kodu, który pisze inny kod, co jest znane
jako metaprogramowanie. W Dodatku C omawiamy atrybut derive, który
generuje implementację różnych cech dla ciebie. Używaliśmy również makr
println! i vec! w całej książce. Wszystkie te makra rozszerzają się,
aby wyprodukować więcej kodu niż ten, który napisałeś ręcznie.
Metaprogramowanie jest przydatne do zmniejszania ilości kodu, który musisz napisać i utrzymać, co jest również jedną z ról funkcji. Jednak makra mają pewne dodatkowe moce, których funkcje nie mają.
Sygnatura funkcji musi deklarować liczbę i typ parametrów, które funkcja
posiada. Makra natomiast mogą przyjmować zmienną liczbę parametrów: Możemy
wywołać println!("hello") z jednym argumentem lub println!("hello {}", name)
z dwoma argumentami. Ponadto, makra są rozszerzane zanim kompilator zinterpretuje
znaczenie kodu, więc makro może na przykład zaimplementować cechę na danym
typie. Funkcja tego nie może, ponieważ jest wywoływana w czasie wykonania, a
cecha musi być zaimplementowana w czasie kompilacji.
Wadą implementacji makra zamiast funkcji jest to, że definicje makr są bardziej złożone niż definicje funkcji, ponieważ piszesz kod Rust, który pisze kod Rust. Z powodu tej pośredniości, definicje makr są zazwyczaj trudniejsze do czytania, zrozumienia i utrzymania niż definicje funkcji.
Inną ważną różnicą między makrami a funkcjami jest to, że musisz definiować makra lub wprowadzać je do zasięgu przed ich wywołaniem w pliku, w przeciwieństwie do funkcji, które możesz zdefiniować i wywołać w dowolnym miejscu.
Makra deklaratywne do ogólnego metaprogramowania
Najczęściej używaną formą makr w Rust są makra deklaratywne. Czasami są
również nazywane „makrami na przykładach”, „makrami macro_rules!” lub po
prostu „makrami”. W swej istocie makra deklaratywne pozwalają na pisanie
codś podobnego do wyrażenia match w Rust. Jak omówiono w Rozdziale 6,
wyrażenia match są strukturami kontrolnymi, które przyjmują wyrażenie,
porównują wynikową wartość wyrażenia z wzorcami, a następnie uruchamiają kod
skojarzony z pasującym wzorcem. Makra również porównują wartość z wzorcami,
które są skojarzone z konkretnym kodem: w tej sytuacji wartością jest literał
kodu źródłowego Rust przekazany do makra; wzorce są porównywane ze strukturą
tego kodu źródłowego; a kod skojarzony z każdym wzorcem, gdy zostanie dopasowany,
zastępuje kod przekazany do makra. Wszystko to dzieje się podczas kompilacji.
Aby zdefiniować makro, używasz konstrukcji macro_rules!. Zbadajmy, jak używać
macro_rules!, przyglądając się, jak zdefiniowane jest makro vec!. Rozdział 8
omówił, jak możemy używać makra vec! do tworzenia nowego wektora z
konkretnymi wartościami. Na przykład, poniższe makro tworzy nowy wektor
zawierający trzy liczby całkowite:
#![allow(unused)]
fn main() {
let v: Vec<u32> = vec![1, 2, 3];
}
Moglibyśmy również użyć makra vec!, aby stworzyć wektor dwóch liczb
całkowitych lub wektor pięciu wycinków ciągów znaków. Nie bylibyśmy w stanie
użyć funkcji do wykonania tego samego, ponieważ nie znalibyśmy liczby ani
typu wartości z góry.
Listing 20-35 pokazuje nieco uproszczoną definicję makra vec!.
#[macro_export]
macro_rules! vec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
}
vec!Uwaga: Rzeczywista definicja makra vec! w standardowej bibliotece zawiera
kod do wcześniejszego przydzielania odpowiedniej ilości pamięci. Ten kod
jest optymalizacją, której tutaj nie uwzględniamy, aby przykład był prostszy.
Adnotacja #[macro_export] wskazuje, że to makro powinno być dostępne
zawsze, gdy pakiet, w którym makro jest zdefiniowane, zostanie wprowadzony do
zasięgu. Bez tej adnotacji makro nie może zostać wprowadzone do zasięgu.
Następnie rozpoczynamy definicję makra od macro_rules! i nazwy definiowanego
makra bez wykrzyknika. Nazwa, w tym przypadku vec, jest poprzedzona
nawiasami klamrowymi oznaczającymi ciało definicji makra.
Struktura w ciele vec! jest podobna do struktury wyrażenia match. Tutaj
mamy jedno ramię ze wzorcem ( $( $x:expr ),* ), po którym następuje =>
i blok kodu skojarzony z tym wzorcem. Jeśli wzorzec pasuje, skojarzony blok
kodu zostanie wygenerowany. Biorąc pod uwagę, że jest to jedyny wzorzec w tym
makrze, istnieje tylko jeden prawidłowy sposób dopasowania; każdy inny wzorzec
spowoduje błąd. Bardziej złożone makra będą miały więcej niż jedno ramię.
Poprawna składnia wzorców w definicjach makr różni się od składni wzorców omówionej w Rozdziale 19, ponieważ wzorce makr są dopasowywane do struktury kodu Rust, a nie do wartości. Przejdźmy przez to, co oznaczają fragmenty wzorców w Listing 20-29; pełną składnię wzorców makr znajdziesz w Referencjach Rust.
Najpierw używamy zestawu nawiasów do objęcia całego wzorca. Używamy znaku
dolara ($), aby zadeklarować zmienną w systemie makr, która będzie
zawierać kod Rust pasujący do wzorca. Znak dolara jasno wskazuje, że jest to
zmienna makra, w przeciwieństwie do zwykłej zmiennej Rust. Następnie następuje
zestaw nawiasów, który przechwytuje wartości pasujące do wzorca w nawiasach
do wykorzystania w kodzie zastępującym. Wewnątrz $() znajduje się $x:expr,
który pasuje do dowolnego wyrażenia Rust i nadaje wyrażeniu nazwę $x.
Przecinek po $() wskazuje, że dosłowny znak separatora przecinka musi
powtarzać się między każdą instancją kodu, który pasuje do kodu w $().
Gwiazdka * określa, że wzorzec pasuje do zera lub więcej elementów
poprzedzających *.
Kiedy wywołujemy to makro za pomocą vec![1, 2, 3];, wzorzec $x pasuje
trzy razy do trzech wyrażeń 1, 2 i 3.
Teraz przyjrzyjmy się wzorcowi w ciele kodu skojarzonego z tym ramieniem:
temp_vec.push() w $()* jest generowane dla każdej części, która pasuje
do $() we wzorcu zero lub więcej razy, w zależności od tego, ile razy wzorzec
pasuje. $x jest zastępowane każdym pasującym wyrażeniem. Kiedy wywołujemy
to makro za pomocą vec![1, 2, 3];, wygenerowany kod, który zastępuje to
wywołanie makra, będzie następujący:
{
let mut temp_vec = Vec::new();
temp_vec.push(1);
temp_vec.push(2);
temp_vec.push(3);
temp_vec
}
Zdefiniowaliśmy makro, które może przyjmować dowolną liczbę argumentów dowolnego typu i generować kod do tworzenia wektora zawierającego określone elementy.
Aby dowiedzieć się więcej o tym, jak pisać makra, zajrzyj do dokumentacji online lub innych zasobów, takich jak „The Little Book of Rust Macros” rozpoczęta przez Daniela Keepa i kontynuowana przez Lukasa Wirtha.
Makra proceduralne do generowania kodu z atrybutów
Druga forma makr to makro proceduralne, które działa bardziej jak funkcja
(i jest rodzajem procedury). Makra proceduralne przyjmują pewien kod jako
wejście, operują na tym kodzie i produkują pewien kod jako wyjście, zamiast
dopasowywać się do wzorców i zastępować kod innym kodem, jak to robią makra
deklaratywne. Trzy rodzaje makr proceduralnych to niestandardowe derive,
podobne do atrybutów i podobne do funkcji, a wszystkie działają w podobny
sposób.
Podczas tworzenia makr proceduralnych definicje muszą znajdować się we
własnym pakiecie ze specjalnym typem pakietu. Wynika to ze złożonych przyczyn
technicznych, które mamy nadzieję wyeliminować w przyszłości. W Listing 20-36
pokazujemy, jak zdefiniować makro proceduralne, gdzie some_attribute jest
zastępcą dla użycia konkretnej odmiany makra.
use proc_macro::TokenStream;
#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}
Funkcja, która definiuje makro proceduralne, przyjmuje TokenStream jako
wejście i produkuje TokenStream jako wyjście. Typ TokenStream jest
zdefiniowany przez pakiet proc_macro, który jest dołączony do Rust i
reprezentuje sekwencję tokenów. To jest rdzeń makra: Kod źródłowy, na którym
makro operuje, tworzy wejściowy TokenStream, a kod, który makro produkuje,
jest wyjściowym TokenStream. Funkcja posiada również dołączony atrybut,
który określa, jaki rodzaj makra proceduralnego tworzymy. Możemy mieć wiele
rodzajów makr proceduralnych w tym samym pakiecie.
Przyjrzyjmy się różnym rodzajom makr proceduralnych. Zaczniemy od niestandardowego
makra derive, a następnie wyjaśnimy małe różnice, które sprawiają, że
inne formy są odmienne.
Niestandardowe makra derive
Stwórzmy pakiet o nazwie hello_macro, który definiuje cechę nazwaną
HelloMacro z jedną skojarzoną funkcją nazwaną hello_macro. Zamiast
wymagać od naszych użytkowników implementowania cechy HelloMacro dla każdego
z ich typów, dostarczymy makro proceduralne, aby użytkownicy mogli opatrzyć
swój typ adnotacją #[derive(HelloMacro)], aby uzyskać domyślną
implementację funkcji hello_macro. Domyślna implementacja wyświetli
Hello, Macro! My name is TypeName!, gdzie TypeName to nazwa typu, na
którym ta cecha została zdefiniowana. Innymi słowy, napiszemy pakiet, który
umożliwi innemu programiście napisanie kodu takiego jak w Listing 20-37
przy użyciu naszego pakietu.
use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;
#[derive(HelloMacro)]
struct Pancakes;
fn main() {
Pancakes::hello_macro();
}
Ten kod wyświetli Hello, Macro! My name is Pancakes! po zakończeniu. Pierwszym
krokiem jest stworzenie nowego pakietu bibliotecznego w ten sposób:
$ cargo new hello_macro --lib
Następnie, w Listing 20-38, zdefiniujemy cechę HelloMacro i jej skojarzoną
funkcję.
pub trait HelloMacro {
fn hello_macro();
}
deriveMamy cechę i jej funkcję. W tym momencie użytkownik naszego pakietu mógłby implementować cechę, aby osiągnąć pożądaną funkcjonalność, jak w Listing 20-39.
use hello_macro::HelloMacro;
struct Pancakes;
impl HelloMacro for Pancakes {
fn hello_macro() {
println!("Hello, Macro! My name is Pancakes!");
}
}
fn main() {
Pancakes::hello_macro();
}
HelloMacroJednak musieliby napisać blok implementacji dla każdego typu, którego chcieliby
użyć z hello_macro; chcemy ich oszczędzić tego wysiłku.
Dodatkowo, nie możemy jeszcze dostarczyć funkcji hello_macro z domyślną
implementacją, która wyświetli nazwę typu, na którym zaimplementowano cechę:
Rust nie posiada możliwości refleksji, więc nie może odczytać nazwy typu w
czasie wykonania. Potrzebujemy makra do generowania kodu w czasie kompilacji.
Następnym krokiem jest zdefiniowanie makra proceduralnego. W chwili pisania
tego tekstu makra proceduralne muszą znajdować się we własnym pakiecie.
Ostatecznie to ograniczenie może zostać zniesione. Konwencja strukturyzowania
pakietów i pakietów makr jest następująca: Dla pakietu o nazwie foo,
pakiet makra proceduralnego derive nazywa się foo_derive. Stwórzmy nowy
pakiet o nazwie hello_macro_derive w projekcie hello_macro:
$ cargo new hello_macro_derive --lib
Nasze dwa pakiety są ściśle powiązane, dlatego tworzymy pakiet makra
proceduralnego w katalogu naszego pakietu hello_macro. Jeśli zmienimy
definicję cechy w hello_macro, będziemy musieli również zmienić implementację
makra proceduralnego w hello_macro_derive. Oba pakiety będą musiały być
publikowane oddzielnie, a programiści używający tych pakietów będą musieli
dodać je oba jako zależności i wprowadzić je oba do zasięgu. Moglibyśmy zamiast
tego sprawić, by pakiet hello_macro używał hello_macro_derive jako zależności
i ponownie eksportował kod makra proceduralnego. Jednak sposób, w jaki
strukturyzowaliśmy projekt, umożliwia programistom używanie hello_macro
nawet jeśli nie chcą funkcjonalności derive.
Musimy zadeklarować pakiet hello_macro_derive jako pakiet makra
proceduralnego. Będziemy również potrzebować funkcjonalności z pakietów syn
i quote, jak zobaczysz za chwilę, więc musimy dodać je jako zależności.
Dodaj następujące wiersze do pliku Cargo.toml dla hello_macro_derive:
[lib]
proc-macro = true
[dependencies]
syn = "2.0"
quote = "1.0"
Aby rozpocząć definiowanie makra proceduralnego, umieść kod z Listing 20-40
w pliku src/lib.rs pakietu hello_macro_derive. Zauważ, że ten kod nie
skompiluje się, dopóki nie dodamy definicji funkcji impl_hello_macro.
use proc_macro::TokenStream;
use quote::quote;
#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
// Construct a representation of Rust code as a syntax tree
// that we can manipulate.
let ast = syn::parse(input).unwrap();
// Build the trait implementation.
impl_hello_macro(&ast)
}
Zauważ, że podzieliliśmy kod na funkcję hello_macro_derive, która jest
odpowiedzialna za parsowanie TokenStream, oraz funkcję impl_hello_macro,
która jest odpowiedzialna za przekształcanie drzewa składni: to sprawia,
że pisanie makra proceduralnego jest wygodniejsze. Kod w zewnętrznej funkcji
(w tym przypadku hello_macro_derive) będzie taki sam dla prawie każdego
pakietu makr proceduralnych, który zobaczysz lub stworzysz. Kod, który określisz
w ciele funkcji wewnętrznej (w tym przypadku impl_hello_macro), będzie
inny w zależności od celu twojego makra proceduralnego.
Wprowadziliśmy trzy nowe pakiety: proc_macro, syn,
oraz quote. Pakiet proc_macro jest dostarczany
z Rust, więc nie musieliśmy dodawać go do zależności w Cargo.toml. Pakiet
proc_macro to API kompilatora, które pozwala nam czytać i manipulować kodem
Rust z naszego kodu.
Pakiet syn parsuje kod Rust ze stringa do struktury danych, na której możemy
wykonywać operacje. Pakiet quote przekształca struktury danych syn z
powrotem w kod Rust. Te pakiety znacznie upraszczają parsowanie wszelkiego
rodzaju kodu Rust, który możemy chcieć obsłużyć: Napisanie pełnego parsera dla
kodu Rust nie jest prostym zadaniem.
Funkcja hello_macro_derive zostanie wywołana, gdy użytkownik naszej
biblioteki określi #[derive(HelloMacro)] dla typu. Jest to możliwe, ponieważ
otagowaliśmy tutaj funkcję hello_macro_derive za pomocą proc_macro_derive
i określiliśmy nazwę HelloMacro, która pasuje do nazwy naszej cechy; jest
to konwencja, której przestrzega większość makr proceduralnych.
Funkcja hello_macro_derive najpierw konwertuje input z TokenStream na
strukturę danych, którą możemy następnie interpretować i na której wykonywać
operacje. W tym miejscu do gry wkracza syn. Funkcja parse w syn pobiera
TokenStream i zwraca strukturę DeriveInput reprezentującą sparsowany kod
Rust. Listing 20-41 pokazuje odpowiednie części struktury DeriveInput,
którą otrzymujemy po sparsowaniu ciągu 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
)
}
)
}
DeriveInput, którą otrzymujemy podczas parsowania kodu posiadającego atrybut makra w Listing 20-37Pola tej struktury pokazują, że sparsowany kod Rust to struktura jednostkowa
z ident (identyfikatorem, czyli nazwą) Pancakes. W tej strukturze jest
więcej pól opisujących wszelkiego rodzaju kod Rust; sprawdź dokumentację
syn dla DeriveInput po więcej informacji.
Wkrótce zdefiniujemy funkcję impl_hello_macro, w której zbudujemy nowy kod
Rust, który chcemy uwzględnić. Ale zanim to zrobimy, zauważ, że wyjście dla
naszego makra derive jest również TokenStream. Zwrócony TokenStream jest
dodawany do kodu, który piszą użytkownicy naszego pakietu, więc kiedy skompilują
swój pakiet, uzyskają dodatkową funkcjonalność, którą zapewniamy w zmodyfikowanym
TokenStream.
Być może zauważyłeś, że wywołujemy unwrap, aby spowodować panikę funkcji
hello_macro_derive, jeśli wywołanie funkcji syn::parse tutaj zakończy się
niepowodzeniem. Konieczne jest, aby nasze makro proceduralne wywołało panikę
w przypadku błędów, ponieważ funkcje proc_macro_derive muszą zwracać
TokenStream zamiast Result, aby były zgodne z API makr proceduralnych.
Uprościliśmy ten przykład, używając unwrap; w kodzie produkcyjnym powinieneś
dostarczyć bardziej szczegółowe komunikaty o błędach, używając panic! lub
expect.
Teraz, gdy mamy kod do przekształcenia adnotowanego kodu Rust z TokenStream
w instancję DeriveInput, wygenerujmy kod, który implementuje cechę
HelloMacro na adnotowanym typie, jak pokazano w Listing 20-42.
use proc_macro::TokenStream;
use quote::quote;
#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
// Construct a representation of Rust code as a syntax tree
// that we can manipulate
let ast = syn::parse(input).unwrap();
// Build the trait implementation
impl_hello_macro(&ast)
}
fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
let generated = quote! {
impl HelloMacro for #name {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", stringify!(#name));
}
}
};
generated.into()
}
HelloMacro przy użyciu sparsowanego kodu RustUzyskujemy instancję struktury Ident zawierającą nazwę (identyfikator)
adnotowanego typu za pomocą ast.ident. Struktura w Listing 20-41 pokazuje,
że gdy uruchomimy funkcję impl_hello_macro na kodzie z Listing 20-37,
ident, który otrzymamy, będzie miał pole ident z wartością "Pancakes".
W ten sposób zmienna name w Listing 20-42 będzie zawierać instancję
struktury Ident, która po wydrukowaniu będzie ciągiem znaków "Pancakes",
nazwą struktury z Listing 20-37.
Makro quote! pozwala nam zdefiniować kod Rust, który chcemy zwrócić.
Kompilator oczekuje czegoś innego niż bezpośredni wynik wykonania makra quote!,
więc musimy przekonwertować go na TokenStream. Robimy to, wywołując metodę
into, która konsumuje tę pośrednią reprezentację i zwraca wartość
wymaganego typu TokenStream.
Makro quote! zapewnia również bardzo fajne mechanizmy szablonowania: Możemy
wpisać #name, a quote! zastąpi to wartością zmiennej name. Możesz nawet
wykonywać pewne powtórzenia podobnie do tego, jak działają zwykłe makra.
Sprawdź dokumentację pakietu quote dla szczegółowego
wprowadzenia.
Chcemy, aby nasze makro proceduralne generowało implementację naszej cechy
HelloMacro dla typu, który użytkownik adnotował, co możemy uzyskać za
pomocą #name. Implementacja cechy ma jedną funkcję hello_macro, której
ciało zawiera funkcjonalność, którą chcemy zapewnić: wyświetlenie Hello, Macro! My name is,
a następnie nazwy adnotowanego typu.
Używane tutaj makro stringify! jest wbudowane w Rust. Przyjmuje ono wyrażenie
Rust, takie jak 1 + 2, i w czasie kompilacji zamienia to wyrażenie w literał
ciągu znaków, taki jak "1 + 2". Różni się to od format! lub println!,
które są makrami, które ewaluują wyrażenie, a następnie zamieniają wynik na
String. Istnieje możliwość, że wejście #name może być wyrażeniem do
wydrukowania dosłownie, dlatego używamy stringify!. Użycie stringify!
oszczędza również alokację, konwertując #name na literał ciągu znaków w
czasie kompilacji.
W tym momencie cargo build powinno zakończyć się pomyślnie zarówno w
hello_macro, jak i hello_macro_derive. Podłączmy te pakiety do kodu w
Listing 20-37, aby zobaczyć makro proceduralne w akcji! Utwórz nowy projekt
binarny w katalogu projects za pomocą cargo new pancakes. Musimy dodać
hello_macro i hello_macro_derive jako zależności w pliku Cargo.toml
pakietu pancakes. Jeśli publikujesz swoje wersje hello_macro i
hello_macro_derive na crates.io,
byłyby to zwykłe zależności; jeśli nie, możesz określić je jako zależności
path w następujący sposób:
[dependencies]
hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }
Umieść kod z Listing 20-37 w pliku src/main.rs i uruchom cargo run:
powinien wyświetlić Hello, Macro! My name is Pancakes!. Implementacja cechy
HelloMacro z makra proceduralnego została włączona bez konieczności
implementowania jej przez pakiet pancakes; #[derive(HelloMacro)] dodało
implementację cechy.
Następnie zbadamy, jak inne rodzaje makr proceduralnych różnią się od
niestandardowych makr derive.
Makra podobne do atrybutów
Makra podobne do atrybutów są podobne do niestandardowych makr derive, ale
zamiast generować kod dla atrybutu derive, pozwalają tworzyć nowe atrybuty.
Są również bardziej elastyczne: derive działa tylko dla struktur i enumów;
atrybuty mogą być stosowane również do innych elementów, takich jak funkcje.
Oto przykład użycia makra podobnego do atrybutu. Powiedzmy, że masz atrybut o
nazwie route, który adnotuje funkcje podczas używania frameworka
aplikacji webowych:
#[route(GET, "/")]
fn index() {
Ten atrybut #[route] byłby zdefiniowany przez framework jako makro
proceduralne. Sygnatura funkcji definicji makra wyglądałaby tak:
#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {
Tutaj mamy dwa parametry typu TokenStream. Pierwszy dotyczy zawartości
atrybutu: część GET, "/". Drugi to ciało elementu, do którego atrybut jest
dołączony: w tym przypadku fn index() {} i reszta ciała funkcji.
Poza tym, makra podobne do atrybutów działają tak samo jak niestandardowe
makra derive: Tworzysz pakiet z typem pakietu proc-macro i implementujesz
funkcję, która generuje kod, którego potrzebujesz!
Makra podobne do funkcji
Makra podobne do funkcji definiują makra, które wyglądają jak wywołania
funkcji. Podobnie jak makra macro_rules!, są bardziej elastyczne niż funkcje;
na przykład mogą przyjmować nieznaną liczbę argumentów. Jednak makra
macro_rules! mogą być definiowane tylko za pomocą składni podobnej do
match, którą omówiliśmy wcześniej w sekcji „Makra deklaratywne do ogólnego
metaprogramowania”. Makra podobne do funkcji przyjmują
parametr TokenStream, a ich definicja manipuluje tym TokenStream za pomocą
kodu Rust, tak jak robią to inne dwa typy makr proceduralnych. Przykładem
makra podobnego do funkcji jest makro sql!, które może być wywołane w
następujący sposób:
let sql = sql!(SELECT * FROM posts WHERE id=1);
To makro parsowałoby instrukcję SQL wewnątrz i sprawdzałoby, czy jest
składniowo poprawna, co jest znacznie bardziej złożonym przetwarzaniem niż
to, co może wykonać makro macro_rules!. Makro sql! byłoby zdefiniowane tak:
#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {
Ta definicja jest podobna do sygnatury makra derive: Otrzymujemy tokeny,
które znajdują się w nawiasach, i zwracamy kod, który chcieliśmy wygenerować.
Podsumowanie
Uff! Teraz masz w swoim zestawie narzędzi Rust kilka funkcji, których prawdopodobnie nie będziesz często używać, ale będziesz wiedzieć, że są dostępne w bardzo szczególnych okolicznościach. Przedstawiliśmy kilka złożonych tematów, abyś mógł rozpoznać te koncepcje i składnię, gdy napotkasz je w sugestiach komunikatów o błędach lub w cudzym kodzie. Użyj tego rozdziału jako odniesienia, które poprowadzi cię do rozwiązań.
Następnie wprowadzimy w życie wszystko, co omówiliśmy w całej książce, i zrobimy jeszcze jeden projekt!