Rust 宏

2023-03-22 15:16 更新
ch19-06-macros.md
commit acc806a06b5a23c7397b7218aecec0e774619512

我們已經(jīng)在本書中使用過像 println! 這樣的宏了,不過還沒完全探索什么是宏以及它是如何工作的。Macro)指的是 Rust 中一系列的功能:使用 macro_rules! 的 聲明Declarative)宏,和三種 過程Procedural)宏:

  • 自定義 ?#[derive]? 宏在結(jié)構(gòu)體和枚舉上指定通過 ?derive? 屬性添加的代碼
  • 類屬性(Attribute-like)宏定義可用于任意項的自定義屬性
  • 類函數(shù)宏看起來像函數(shù)不過作用于作為參數(shù)傳遞的 token

我們會依次討論每一種宏,不過首要的是,為什么已經(jīng)有了函數(shù)還需要宏呢?

宏和函數(shù)的區(qū)別

從根本上來說,宏是一種為寫其他代碼而寫代碼的方式,即所謂的 元編程metaprogramming)。在附錄 C 中會探討 derive 屬性,其生成各種 trait 的實現(xiàn)。我們也在本書中使用過 println! 宏和 vec! 宏。所有的這些宏以 展開 的方式來生成比你所手寫出的更多的代碼。

元編程對于減少大量編寫和維護的代碼是非常有用的,它也扮演了函數(shù)扮演的角色。但宏有一些函數(shù)所沒有的附加能力。

一個函數(shù)簽名必須聲明函數(shù)參數(shù)個數(shù)和類型。相比之下,宏能夠接收不同數(shù)量的參數(shù):用一個參數(shù)調(diào)用 println!("hello") 或用兩個參數(shù)調(diào)用 println!("hello {}", name) 。而且,宏可以在編譯器翻譯代碼前展開,例如,宏可以在一個給定類型上實現(xiàn) trait 。而函數(shù)則不行,因為函數(shù)是在運行時被調(diào)用,同時 trait 需要在編譯時實現(xiàn)。

實現(xiàn)宏不如實現(xiàn)函數(shù)的一面是宏定義要比函數(shù)定義更復雜,因為你正在編寫生成 Rust 代碼的 Rust 代碼。由于這樣的間接性,宏定義通常要比函數(shù)定義更難閱讀、理解以及維護。

宏和函數(shù)的最后一個重要的區(qū)別是:在一個文件里調(diào)用宏 之前 必須定義它,或?qū)⑵湟胱饔糜?,而函?shù)則可以在任何地方定義和調(diào)用。

使用 macro_rules! 的聲明宏用于通用元編程

Rust 最常用的宏形式是 聲明宏declarative macros)。它們有時也被稱為 “macros by example”、“macro_rules! 宏” 或者就是 “macros”。其核心概念是,聲明宏允許我們編寫一些類似 Rust match 表達式的代碼。正如在第六章討論的那樣,match 表達式是控制結(jié)構(gòu),其接收一個表達式,與表達式的結(jié)果進行模式匹配,然后根據(jù)模式匹配執(zhí)行相關(guān)代碼。宏也將一個值和包含相關(guān)代碼的模式進行比較;此種情況下,該值是傳遞給宏的 Rust 源代碼字面值,模式用于和前面提到的源代碼字面值進行比較,每個模式的相關(guān)代碼會替換傳遞給宏的代碼。所有這一切都發(fā)生于編譯時。

可以使用 macro_rules! 來定義宏。讓我們通過查看 vec! 宏定義來探索如何使用 macro_rules! 結(jié)構(gòu)。第八章講述了如何使用 vec! 宏來生成一個給定值的 vector。例如,下面的宏用三個整數(shù)創(chuàng)建一個 vector:

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

也可以使用 vec! 宏來構(gòu)造兩個整數(shù)的 vector 或五個字符串 slice 的 vector 。但卻無法使用函數(shù)做相同的事情,因為我們無法預先知道參數(shù)值的數(shù)量和類型。

在示例 19-28 中展示了一個 vec! 稍微簡化的定義。

文件名: src/lib.rs

#[macro_export]
macro_rules! vec {
    ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
            $(
                temp_vec.push($x);
            )*
            temp_vec
        }
    };
}

示例 19-28: 一個 vec! 宏定義的簡化版本

注意:標準庫中實際定義的 ?vec!? 包括預分配適當量的內(nèi)存的代碼。這部分為代碼優(yōu)化,為了讓示例簡化,此處并沒有包含在內(nèi)。

#[macro_export] 注解表明只要導入了定義這個宏的crate,該宏就應該是可用的。 如果沒有該注解,這個宏不能被引入作用域。

接著使用 macro_rules! 和宏名稱開始宏定義,且所定義的宏并 不帶 感嘆號。名字后跟大括號表示宏定義體,在該例中宏名稱是 vec 。

vec! 宏的結(jié)構(gòu)和 match 表達式的結(jié)構(gòu)類似。此處有一個分支模式 ( $( $x:expr ),* ) ,后跟 => 以及和模式相關(guān)的代碼塊。如果模式匹配,該相關(guān)代碼塊將被執(zhí)行。這里這個宏只有一個模式,那就只有一個有效匹配方向,其他任何模式方向(譯者注:不匹配這個模式)都會導致錯誤。更復雜的宏會有多個分支模式。

宏定義中有效模式語法和在第十八章提及的模式語法是不同的,因為宏模式所匹配的是 Rust 代碼結(jié)構(gòu)而不是值?;剡^頭來檢查下示例 19-28 中模式片段什么意思。對于全部的宏模式語法,請查閱參考。

首先,一對括號包含了整個模式。接下來是美元符號( $ ),后跟一對括號,其捕獲了符合括號內(nèi)模式的值用以在替代代碼中使用。$() 內(nèi)則是 $x:expr ,其匹配 Rust 的任意表達式,并將該表達式命名為 $x。

$() 之后的逗號說明一個可有可無的逗號分隔符可以出現(xiàn)在 $() 所匹配的代碼之后。緊隨逗號之后的 * 說明該模式匹配零個或更多個 * 之前的任何模式。

當以 vec![1, 2, 3]; 調(diào)用宏時,$x 模式與三個表達式 1、2 和 3 進行了三次匹配。

現(xiàn)在讓我們來看看與此分支模式相關(guān)聯(lián)的代碼塊中的模式:匹配到模式中的$()的每一部分,都會在(=>右側(cè))$()* 里生成temp_vec.push($x),生成零次還是多次取決于模式匹配到多少次。$x 由每個與之相匹配的表達式所替換。當以 vec![1, 2, 3]; 調(diào)用該宏時,替換該宏調(diào)用所生成的代碼會是下面這樣:

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

我們已經(jīng)定義了一個宏,其可以接收任意數(shù)量和類型的參數(shù),同時可以生成能夠創(chuàng)建包含指定元素的 vector 的代碼。

macro_rules! 中有一些奇怪的地方。在將來,會有第二種采用 macro 關(guān)鍵字的聲明宏,其工作方式類似但修復了這些極端情況。在此之后,macro_rules! 實際上就過時(deprecated)了。在此基礎(chǔ)之上,同時鑒于大多數(shù) Rust 程序員 使用 宏而非 編寫 宏的事實,此處不再深入探討 macro_rules!。請查閱在線文檔或其他資源,如 “The Little Book of Rust Macros” 來更多地了解如何寫宏。

用于從屬性生成代碼的過程宏

第二種形式的宏被稱為 過程宏procedural macros),因為它們更像函數(shù)(一種過程類型)。過程宏接收 Rust 代碼作為輸入,在這些代碼上進行操作,然后產(chǎn)生另一些代碼作為輸出,而非像聲明式宏那樣匹配對應模式然后以另一部分代碼替換當前代碼。

有三種類型的過程宏(自定義派生(derive),類屬性和類函數(shù)),不過它們的工作方式都類似。

創(chuàng)建過程宏時,其定義必須駐留在它們自己的具有特殊 crate 類型的 crate 中。這么做出于復雜的技術(shù)原因,將來我們希望能夠消除這些限制。使用這些宏需采用類似示例 19-29 所示的代碼形式,其中 some_attribute 是一個使用特定宏的占位符。

文件名: src/lib.rs

use proc_macro;

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

示例 19-29: 一個使用過程宏的例子

定義過程宏的函數(shù)接收一個 TokenStream 作為輸入并生成 TokenStream 作為輸出。TokenStream 是定義于proc_macro crate里代表一系列token的類型,Rust默認攜帶了proc_macro crate。 這就是宏的核心:宏所處理的源代碼組成了輸入 TokenStream,宏生成的代碼是輸出 TokenStream。函數(shù)上還有一個屬性;這個屬性指明了我們創(chuàng)建的過程宏的類型。在同一 crate 中可以有多種的過程宏。

讓我們看看不同種類的程序宏。 我們將從一個自定義的派生宏開始,然后解釋使其他形式不同的小差異。

如何編寫自定義 derive 宏

讓我們創(chuàng)建一個 hello_macro crate,其包含名為 HelloMacro 的 trait 和關(guān)聯(lián)函數(shù) hello_macro。不同于讓 crate 的用戶為其每一個類型實現(xiàn) HelloMacro trait,我們將會提供一個過程式宏以便用戶可以使用 #[derive(HelloMacro)] 注解他們的類型來得到 hello_macro 函數(shù)的默認實現(xiàn)。該默認實現(xiàn)會打印 Hello, Macro! My name is TypeName!,其中 TypeName 為定義了 trait 的類型名。換言之,我們會創(chuàng)建一個 crate,使程序員能夠?qū)戭愃剖纠?19-30 中的代碼。

文件名: src/main.rs

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

#[derive(HelloMacro)]
struct Pancakes;

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

示例 19-30: crate 用戶所寫的能夠使用過程式宏的代碼

運行該代碼將會打印 Hello, Macro! My name is Pancakes! 第一步是像下面這樣新建一個庫 crate:

$ cargo new hello_macro --lib

接下來,會定義 HelloMacro trait 以及其關(guān)聯(lián)函數(shù):

文件名: src/lib.rs

pub trait HelloMacro {
    fn hello_macro();
}

現(xiàn)在有了一個包含函數(shù)的 trait 。此時,crate 用戶可以實現(xiàn)該 trait 以達到其期望的功能,像這樣:

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();
}

然而,他們需要為每一個他們想使用 hello_macro 的類型編寫實現(xiàn)的代碼塊。我們希望為其節(jié)約這些工作。

另外,我們也無法為 hello_macro 函數(shù)提供一個能夠打印實現(xiàn)了該 trait 的類型的名字的默認實現(xiàn):Rust 沒有反射的能力,因此其無法在運行時獲取類型名。我們需要一個在編譯時生成代碼的宏。

下一步是定義過程式宏。在編寫本部分時,過程式宏必須在其自己的 crate 內(nèi)。該限制最終可能被取消。構(gòu)造 crate 和其中宏的慣例如下:對于一個 foo 的包來說,一個自定義的派生過程宏的包被稱為 foo_derive 。在 hello_macro 項目中新建名為 hello_macro_derive 的包。

$ cargo new hello_macro_derive --lib

由于兩個 crate 緊密相關(guān),因此在 hello_macro 包的目錄下創(chuàng)建過程式宏的 crate。如果改變在 hello_macro 中定義的 trait ,同時也必須改變在 hello_macro_derive 中實現(xiàn)的過程式宏。這兩個包需要分別發(fā)布,編程人員如果使用這些包,則需要同時添加這兩個依賴并將其引入作用域。我們也可以只用 hello_macro 包而將 hello_macro_derive 作為一個依賴,并重新導出過程式宏的代碼。但現(xiàn)在我們組織項目的方式使編程人員在無需 derive 功能時也能夠單獨使用 hello_macro

我們需要聲明 hello_macro_derive crate 是過程宏(proc-macro) crate。我們還需要 syn 和 quote crate 中的功能,正如你即將看到的,需要將他們加到依賴中。將下面的代碼加入到 hello_macro_derive 的 Cargo.toml 文件中。

文件名: hello_macro_derive/Cargo.toml

[lib]
proc-macro = true

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

為定義一個過程式宏,請將示例 19-31 中的代碼放在 hello_macro_derive crate 的 src/lib.rs 文件里面。注意這段代碼在我們添加 impl_hello_macro 函數(shù)的定義之前是無法編譯的。

文件名: hello_macro_derive/src/lib.rs

extern crate proc_macro;

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

#[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)
}

示例 19-31: 大多數(shù)過程式宏處理 Rust 代碼時所需的代碼

注意我們將代碼分成了hello_macro_derive 和 impl_macro_derive 兩個函數(shù),前者負責解析 TokenStream,后者負責轉(zhuǎn)換語法樹:這使得編寫過程宏更方便。幾乎你看到或者創(chuàng)建的每一個過程宏的外部函數(shù)(這里是hello_macro_derive)中的代碼都跟這里是一樣的。你放入內(nèi)部函數(shù)(這里是impl_macro_derive)中的代碼根據(jù)你的過程宏的設(shè)計目的會有所不同。

現(xiàn)在,我們已經(jīng)引入了三個新的 crate:proc_macro 、 syn 和 quote 。Rust 自帶 proc_macro crate,因此無需將其加到 Cargo.toml 文件的依賴中。proc_macro crate 是編譯器用來讀取和操作我們 Rust 代碼的 API。

syn crate 將字符串中的 Rust 代碼解析成為一個可以操作的數(shù)據(jù)結(jié)構(gòu)。quote 則將 syn 解析的數(shù)據(jù)結(jié)構(gòu)轉(zhuǎn)換回 Rust 代碼。這些 crate 讓解析任何我們所要處理的 Rust 代碼變得更簡單:為 Rust 編寫整個的解析器并不是一件簡單的工作。

當用戶在一個類型上指定 #[derive(HelloMacro)] 時,hello_macro_derive 函數(shù)將會被調(diào)用。因為我們已經(jīng)使用 proc_macro_derive 及其指定名稱HelloMacro對 hello_macro_derive 函數(shù)進行了注解,指定名稱HelloMacro就是 trait 名,這是大多數(shù)過程宏遵循的習慣。

該函數(shù)首先將來自 TokenStream 的 input 轉(zhuǎn)換為一個我們可以解釋和操作的數(shù)據(jù)結(jié)構(gòu)。這正是 syn 派上用場的地方。syn 中的 parse 函數(shù)獲取一個 TokenStream 并返回一個表示解析出 Rust 代碼的 DeriveInput 結(jié)構(gòu)體。示例 19-32 展示了從字符串 struct Pancakes; 中解析出來的 DeriveInput 結(jié)構(gòu)體的相關(guān)部分:

DeriveInput {
    // --snip--

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

示例 19-32: 解析示例 19-30 中帶有宏屬性的代碼時得到的 DeriveInput 實例

該結(jié)構(gòu)體的字段展示了我們解析的 Rust 代碼是一個類單元結(jié)構(gòu)體,其 ident( identifier,表示名字)為 Pancakes。該結(jié)構(gòu)體里面有更多字段描述了所有類型的 Rust 代碼,查閱 syn 中 DeriveInput 的文檔 以獲取更多信息。

很快我們將定義 impl_hello_macro 函數(shù),其用于構(gòu)建所要包含在內(nèi)的 Rust 新代碼。但在此之前,注意其輸出也是 TokenStream。所返回的 TokenStream 會被加到我們的 crate 用戶所寫的代碼中,因此,當用戶編譯他們的 crate 時,他們會通過修改后的 TokenStream 獲取到我們所提供的額外功能。

你可能也注意到了,當調(diào)用 syn::parse 函數(shù)失敗時,我們用 unwrap 來使 hello_macro_derive 函數(shù) panic。在錯誤時 panic 對過程宏來說是必須的,因為 proc_macro_derive 函數(shù)必須返回 TokenStream 而不是 Result,以此來符合過程宏的 API。這里選擇用 unwrap 來簡化了這個例子;在生產(chǎn)代碼中,則應該通過 panic! 或 expect 來提供關(guān)于發(fā)生何種錯誤的更加明確的錯誤信息。

現(xiàn)在我們有了將注解的 Rust 代碼從 TokenStream 轉(zhuǎn)換為 DeriveInput 實例的代碼,讓我們來創(chuàng)建在注解類型上實現(xiàn) HelloMacro trait 的代碼,如示例 19-33 所示。

文件名: 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!("Hello, Macro! My name is {}!", stringify!(#name));
            }
        }
    };
    gen.into()
}

示例 19-33: 使用解析過的 Rust 代碼實現(xiàn) HelloMacro trait

我們得到一個包含以 ast.ident 作為注解類型名字(標識符)的 Ident 結(jié)構(gòu)體實例。示例 19-32 中的結(jié)構(gòu)體表明當 impl_hello_macro 函數(shù)運行于示例 19-30 中的代碼上時 ident 字段的值是 "Pancakes"。因此,示例 19-33 中 name 變量會包含一個 Ident 結(jié)構(gòu)體的實例,當打印時,會是字符串 "Pancakes",也就是示例 19-30 中結(jié)構(gòu)體的名稱。

quote! 宏能讓我們編寫希望返回的 Rust 代碼。quote! 宏執(zhí)行的直接結(jié)果并不是編譯器所期望的所以需要轉(zhuǎn)換為 TokenStream。為此需要調(diào)用 into 方法,它會消費這個中間表示(intermediate representation,IR)并返回所需的 TokenStream 類型值。

這個宏也提供了一些非??岬哪0鍣C制;我們可以寫 #name ,然后 quote! 會以名為 name 的變量值來替換它。你甚至可以做一些類似常用宏那樣的重復代碼的工作。查閱 quote crate 的文檔 來獲取詳盡的介紹。

我們期望我們的過程式宏能夠為通過 #name 獲取到的用戶注解類型生成 HelloMacro trait 的實現(xiàn)。該 trait 的實現(xiàn)有一個函數(shù) hello_macro ,其函數(shù)體包括了我們期望提供的功能:打印 Hello, Macro! My name is 和注解的類型名。

此處所使用的 stringify! 為 Rust 內(nèi)置宏。其接收一個 Rust 表達式,如 1 + 2 , 然后在編譯時將表達式轉(zhuǎn)換為一個字符串常量,如 "1 + 2" 。這與 format! 或 println! 是不同的,它計算表達式并將結(jié)果轉(zhuǎn)換為 String 。有一種可能的情況是,所輸入的 #name 可能是一個需要打印的表達式,因此我們用 stringify! 。stringify! 也能通過在編譯時將 #name 轉(zhuǎn)換為字符串來節(jié)省內(nèi)存分配。

此時,cargo build 應該都能成功編譯 hello_macro 和 hello_macro_derive 。我們將這些 crate 連接到示例 19-30 的代碼中來看看過程宏的行為!在 projects 目錄下用 cargo new pancakes 命令新建一個二進制項目。需要將 hello_macro 和 hello_macro_derive 作為依賴加到 pancakes 包的 Cargo.toml 文件中去。如果你正將 hello_macro 和 hello_macro_derive 的版本發(fā)布到 crates.io 上,其應為常規(guī)依賴;如果不是,則可以像下面這樣將其指定為 path 依賴:

hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }

把示例 19-30 中的代碼放在 src/main.rs ,然后執(zhí)行 cargo run:其應該打印 Hello, Macro! My name is Pancakes!。其包含了該過程宏中 HelloMacro trait 的實現(xiàn),而無需 pancakes crate 實現(xiàn)它;#[derive(HelloMacro)] 增加了該 trait 實現(xiàn)。

接下來,讓我們探索一下其他類型的過程宏與自定義派生宏有何區(qū)別。

類屬性宏

類屬性宏與自定義派生宏相似,不同的是 derive 屬性生成代碼,它們(類屬性宏)能讓你創(chuàng)建新的屬性。它們也更為靈活;derive 只能用于結(jié)構(gòu)體和枚舉;屬性還可以用于其它的項,比如函數(shù)。作為一個使用類屬性宏的例子,可以創(chuàng)建一個名為 route 的屬性用于注解 web 應用程序框架(web application framework)的函數(shù):

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

#[route] 屬性將由框架本身定義為一個過程宏。其宏定義的函數(shù)簽名看起來像這樣:

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

這里有兩個 TokenStream 類型的參數(shù);第一個用于屬性內(nèi)容本身,也就是 GET, "/" 部分。第二個是屬性所標記的項:在本例中,是 fn index() {} 和剩下的函數(shù)體。

除此之外,類屬性宏與自定義派生宏工作方式一致:創(chuàng)建 proc-macro crate 類型的 crate 并實現(xiàn)希望生成代碼的函數(shù)!

類函數(shù)宏

類函數(shù)(Function-like)宏的定義看起來像函數(shù)調(diào)用的宏。類似于 macro_rules!,它們比函數(shù)更靈活;例如,可以接受未知數(shù)量的參數(shù)。然而 macro_rules! 宏只能使用之前 “使用 macro_rules! 的聲明宏用于通用元編程” 介紹的類匹配的語法定義。類函數(shù)宏獲取 TokenStream 參數(shù),其定義使用 Rust 代碼操縱 TokenStream,就像另兩種過程宏一樣。一個類函數(shù)宏例子是可以像這樣被調(diào)用的 sql! 宏:

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

這個宏會解析其中的 SQL 語句并檢查其是否是句法正確的,這是比 macro_rules! 可以做到的更為復雜的處理。sql! 宏應該被定義為如此:

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

這類似于自定義派生宏的簽名:獲取括號中的 token,并返回希望生成的代碼。

總結(jié)

好的!現(xiàn)在我們學習了 Rust 并不常用但在特定情況下你可能用得到的功能。我們介紹了很多復雜的主題,這樣若你在錯誤信息提示或閱讀他人代碼時遇到他們,至少可以說之前已經(jīng)見過這些概念和語法了。你可以使用本章作為一個解決方案的參考。

接下來,我們將再開始一個項目,將本書所學的所有內(nèi)容付與實踐!

以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號