巨集(macro)簡單地說就是用程式碼產生程式碼的方式,又稱為超程式設計(metaprogramming),目前我們也在很多地方使用過,比如輸出內容到終端機的 println!
和初始化向量的 vec!
,以及自訂型別上面加的 #[derive]
等等,實際上這些巨集在我們使用的地方都會展開更多的程式碼,另外很多框架也會設計自己的巨集。
可以把巨集視為現成的工具,可以減少我們自己撰寫和維護的程式碼量,聽起來和函數差不多,但是巨集更複雜也更彈性,開發和維護成本也更高。
比較一下兩者的差別,函數的參數數量是固定的,要呼叫函數的時候,傳遞的參數不能多也不能少,但巨集可以接收變動數量的參數,除此之外,比如說,#[derive(Debug)]
可以幫我們幫某個型別實作 Debug
特徵,讓我們很方便可以透過 println!
把資料輸出到終端機,巨集可以是因為巨集會在編譯前就展開,但是函數要到實際執行的時候才會呼叫,而特徵必須要在編譯前就實作完成。
其他差異包括兩者的作用域差異,比如說在同一個檔案中我們可以在任何地方定義函數,既可以在函數前也可以在函數後,另外函數可以當成參數傳給其他函數,但是巨集不行,因為它只是編譯的工具,沒有類型。
巨集可以分為兩種:
程序式又分為三種:
#[derive]
巨集,可以將指定的程式碼加在使用 derive
屬性的結構體和列舉對於宣告式巨集還有類函數巨集,在程式碼裡我們可以很明確用結尾的 !
來分辨這是一個函數還是巨集。
先看比較好理解的宣告式,又稱為巨集為例(macros by example)」、「macro_rules!
」
它的邏輯類似 match
,根據輸入找到對應的模式,然後替換成對應的程式碼。
我們先寫一個簡單的例子,首先除了 main.rs
以外多建一個檔案 hello_macros.rs
把巨集的定義放在這裡:
├── src
│ ├── hello_macros.rs
│ └── main.rs
兩個檔案各自的內容:
// hello_macros.rs
#[macro_export] // 將巨集導出讓它可以在專案其他地方被導入
macro_rules! greet {
() => {
println!("Hello");
};
($name:expr) => {
println!("Hello, {}!", $name)
};
}
// main.rs
mod hello_macros;
fn main() {
greet!(); // Hello
greet!("World"); // Hello, World!
}
#[macro_export]
用來將定義在模組內的巨集導出,讓專案其他地方可以直接使用,這裡已經提供了全局可見性,而 mod
則是用來宣告並組織模組,讓程式碼結構更清晰,並不是引入模組。
macro_rules!
是宣告自訂巨集的關鍵字,後面和寫 match
就很像。
=>
前面被稱為匹配臂 (match arm),後面則是巨集體 (macro body),當匹配臂 (match arm)有匹配到的時候巨集體的程式碼就會被展開,要注意不同的匹配臂分隔用 ;
而不是 ,
。
和 match
一樣模式匹配是照順序的,第一個 ()
是沒有任何參數的結果,第二個 ($name:expr)
, expr
會匹配任何 Rust 的表達式, $name
則是捕獲這個表達式,我們就可以在後續用這個 $name
代表這個表達式,就像變數那樣。
看 main
就是匹配到兩種模式和各自的輸出,這種匹配還有很多種延伸用法,例如我們目前的寫法沒辦法接收多個參數:
fn main() {
greet!("World", "Goodbye");
}
error: no rules expected the token `,`
--> src/main.rs:6:19
|
6 | greet!("World", "Goodbye");
| ^ no rules expected this token in macro call
|
::: src/hello_macros.rs:2:1
|
2 | macro_rules! greet {
| ------------------ when calling this macro
|
note: while trying to match meta-variable `$name:expr`
--> src/hello_macros.rs:6:6
|
6 | ($name:expr) => {
| ^^^^^^^^^^
所以我們加強一下我們的巨集讓他可以接收多個參數。
#[macro_export] // 將巨集導出讓它可以在專案其他地方被導入
macro_rules! greet {
() => {
println!("Hello");
};
($($name:expr),*) => {
$(println!("Hello, {}!", $name);)*
};
}
這邊我們把 ($name:expr)
又再多一層,表示可以有多個表達式, $($name:expr)
是用來表達表達個別的($name:expr)模式,每個表達式用逗號分隔, *
表示可以有0個或多個這樣的表達式。同樣巨集體內也用這個邏輯修改,所以現在這樣傳進幾個參數就會展開幾個 println!("Hello, {}!", $name);
,所以不只兩個參數,超過兩個參數也都可以正常運作。
另外呼叫巨集用的括號沒有限定是哪種括號,就算上面要修改成 greet!{"World", "Goodbye"};
也沒問題,不過一般都有習慣搭配的括號,例如 vec!
就是習慣用 []
,而且他可以傳多個參數的方式也和前面提到的方式是一樣的。
除了 expr
還有很多模式,可以到官網看看,可以做到的事情遠比上面複雜,需要用到的時候再查就好,畢竟大部分時候如果不是開發巨集用不到。
一些其他模式的例子:
// 字面量模式 literal
macro_rules! create_string {
($s:literal) => {
String::from($s)
};
}
// 識別符模式 ident
macro_rules! call_function {
($f:ident($($arg:expr),*)) => {
$f($($arg),*)
};
}
fn add(x: i32, y: i32) -> i32 {
x + y
}
fn main() {
let my_string = create_string!("Hello, world!");
println!("{}", my_string); // Hello, world!
let result = call_function!(add(2, 3));
println!("{}", result); // 5
}
程序式巨集的運作是輸入一些程式碼,然後會再輸出這些程式碼加工的新程式碼。和宣告式比起來,這類的巨集定義必須獨立放在特殊的 crate
中,然後要做一些特別設定。
可以把 crate
想像成一個獨立的程式或程式庫,是 Rust 程式碼的最小可編譯單元。
程序式巨集三種在呼叫上是這樣:
// 自訂 derive 產生 Debug 實作
#[derive(Debug)]
struct Person {
name: String,
age: u32,
}
// 類屬性巨集定義路由
#[route(GET, "/users")]
fn get_users() -> String {
"Hello, world!"
}
// 類函數巨集生成 SQL
let sql = sql!("SELECT * FROM users WHERE age > 18");
自訂 derive
用來為結構體或枚舉自動生成一些常用的特徵實現,類屬性巨集在框架設計會比較常看到,類函數的話使用的方式和宣告式倒是滿像的,看起來都很像呼叫一個函數,不過名稱最後會有!
區別。
相對於宣告式巨集的運作機制是簡單的文本替換和模式匹配,程序式巨集會去做更複雜的程式碼解析以及更精細地生成程式碼,而且不會替換之前的程式碼,就像是使用 #[derive(Debug)]
但我們的型別其他部分不會被取代。
derive
巨集我們先來看如何寫自訂的 derive
巨集。
我們目標最終專案的結構如下:
.
├── hello_macro
│ ├── Cargo.lock
│ ├── Cargo.toml
│ └── src
│ └── lib.rs
├── hello_macro_derive
│ ├── Cargo.toml
│ └── src
│ └── lib.rs
└── pancakes
├── Cargo.lock
├── Cargo.toml
└── src
└── main.rs
我們要做的設計一個 HelloMacro
特徵,它只有一個關聯函數 hello_macro
,可以讓有實作這個特徵的型別透過呼叫這個關聯函數在終端機印出 Hello, Macro! My name is xxx
,其中 xxx
就是這個型別的名稱。
首先先設定 hello_macro 的資料夾,因為要設計的是一個 crate
,可以用 cargo new hello_macro --lib
先建立一個樣板,然後修改 lib.rs 來定義 HelloMacro
特徵即可。
// hello_macro/src/lib.rs
pub trait HelloMacro {
fn hello_macro();
}
接著建立 pancakes 並移動到這個路徑底下,這是我們主要的專案路徑,先把基本的部分寫好和測試:
要先在Cargo.toml
加入hello_macro
依賴,因為是本地的 crate
所以用相對路徑加入:
[package]
name = "pancake"
version = "0.1.0"
edition = "2021"
[dependencies]
hello_macro = { path = "../hello_macro" }
再來就可以引入這個特徵來實作了:
// pancakes/src/main.rs
use hello_macro::HelloMacro;
use std::any::type_name;
struct Pancakes;
impl HelloMacro for Pancakes {
fn hello_macro() {
let type_name = type_name::<Self>(); // pancake::Pancakes 完整的類型名稱,包括模組路徑
let struct_name = type_name.split("::").last().unwrap();
println!("Hello, Macro! My name is {}!", struct_name);
}
}
fn main() {
Pancakes::hello_macro(); // Hello, Macro! My name is Pancakes!
}
到這邊前置作業完成,因為不想要每次每種型別都要寫一次這種特徵實作,再來要進入正題的實作 derive
巨集的 crate
。
derive
巨集的 crate再來我們看 hello_macro_derive 這個 crate
,名稱上雖然沒有強制規定,習慣上是對應 crate
加上尾綴 _derive
來明確表達 crate
是用來提供 derive 巨集,而且是搭配hello_macro
用的,有的則是加在前綴。
除此之外,一般是建議將兩種 crate
分開不要包在同一個 crate
裡,這樣可以降低耦合性,比如說有人不需要用到 hello_macro_derive 那他就不要安裝就好。
derive
巨集的 crate
如前述,要做特殊設定,所以先看它的 Cargo.toml
:
[package]
name = "hello_macro_derive"
version = "0.1.0"
edition = "2018"
[lib]
proc-macro = true
[dependencies]
syn = "2.0"
quote = "1.0"
首先[lib] proc-macro = true
這行設定表示這個 crate
是用來定義程序式巨集。[dependencies]
的部分我們要加入 syn
和 quote
,分別是解析和生成 Rust 程式碼。
下圖簡略展示從我們寫出來的程式碼到電腦執行的機械碼,中間經過的步驟,一般編譯就是只有上面單向。
程式碼會先被編譯器前端拆解成多個 Token
,這些 Token
轉換成抽象語法樹(Abstract Syntax Tree),經過編譯器產生中間碼在經過一些優化之後最後生成機械碼。
而 syn
和 quote
就是設計來方便針對 AST
到 TokenStream
中間的轉換。
都設定好就可以來寫程式碼:
// 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 {
// 建構 Rust 程式碼的語法樹呈現
// 讓我們可以進行操作
let ast = syn::parse(input).unwrap();
// 建構特徵實作
impl_hello_macro(&ast)
}
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()
}
一開始我們把需要依賴引入,syn
和 quote
如前述。proc_macro
是 Rust 內建 crate
,就是編譯器的 API。 TokenStream
是函數產生結果的型別定義要用。#[proc_macro_derive]
這個屬性標註告訴編譯器,當遇到 #[derive(HelloMacro)]
時,需要執行 hello_macro_derive
這個巨集函數,並將其生成的程式碼插入到相應的位置,(HelloMacro)
名稱也沒有強制規定,不過一般都是用對應的特徵名稱來明確表達避免混淆用途。
看到 #[proc_macro_derive]
就知道它其實是類屬性巨集,所以這邊其實是在用巨集寫巨集,很有趣也很複雜。
至於其他部分和我們平常寫的函數沒什麼不同,透過 syn::parse
取得的結果是Result
,很簡易的用unwrap
來取值,結果就是我們的抽象語法樹,我們把這個結果傳給另外一個函數 impl_hello_macro
,它會負責產生特徵實作的程式碼。
在impl_hello_macro
中,從&ast.ident
拿出這個結構體或枚舉的名稱。quote!
又是另外一個巨集,我們在這邊寫我們想要實作特徵的邏輯,其中 #var
是可用於任何實作了 ToTokens 特徵的環境中的語法,它會找目前作用域內的變數 var
,並將它的值插入到輸出結果中對應的位置。stringify!
是一個 Rust 內建巨集,會將一個 Rust 表達式在編譯期轉換成字串字面值,例如,例如 1 + 2
會被直接轉成 "1 + 2"。gen.into()
會把 quote
生成的 TokenStream
轉換成函數所需的 TokenStream
型別。
所以其實細節也都已經被其他的巨集包裝起來,在這邊看到的僅僅是冰山一角,如果要更了解quote!
就要直接看官方的文件了。
回到 pancakes,加上剛才完成的 crate:
[dependencies]
hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro_derive" }
我們現在可以把原有的實作替換掉了:
use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;
#[derive(HelloMacro)]
struct Pancakes;
fn main() {
Pancakes::hello_macro(); // Hello, Macro! My name is Pancakes!
}
這樣就算我們定義了其他結構體也可以用#[derive(HelloMacro)]
快速實作這個特徵。
類屬性巨集實作上和derive
類似,相對於 derive
巨集只能用在結構體和列舉,類屬性巨集類似可以建立新的屬性用在其他項目之上,例如函數,讓應用上更靈活,例如剛才的#[proc_macro_derive]
就是作用在函數上。
另外的例子像是前面的路由定義,Rust 的其中一個 Web 框架 rocket
提供的範例如下:
#[macro_use] extern crate rocket;
#[get("/")]
fn index() -> &'static str {
"Hello, world!"
}
#[launch]
fn rocket() -> _ {
rocket::build().mount("/", routes![index])
}
可以看得出來 [get]
、[launch]
都是類屬性巨集,大概能想像出背後有作哪些邏輯。
類函數巨集和宣告式巨集使用上很像,都是類似呼叫函數,不過比函數更有靈活,例如可以接收不定數量的參數。
類函數巨集可以做的又比宣告式更複雜,因為宣告式巨集只能比較模式,沒辦法針對程式碼內容做精細處理,但
類函數巨集另外兩個程序式巨集可以做的一樣,都可以拿 TokenStream 參數和其定義來操作 Rust 程式碼,做更細緻的應用,定義的方式和其他程序式巨集也是差不多。
例如如果有 sql!
類函數巨集,它可以檢查輸入的 SQL 陳述句語法是否正確,使用上會長這樣:
let sql = sql!(SELECT * FROM posts WHERE id=1);
但是宣告式巨集就沒辦法做到這件事,因為它沒有模式可以去匹配 SQL 陳述句,更不用說針對內容去檢查了。
所以類函數巨集和宣告式巨集應用上還是有很大的區別。
巨集有兩大類:宣告式巨集和程序式巨集,我們現在知道他們和函數的差異,有些必須在編譯前完成的邏輯可以藉由巨集完成,至少對使用的人來說,巨集的確幫了很多忙,讓我們不用再去處理一些很繁瑣或是重複的邏輯。另外我們也知道不同巨集存在的必要性以及各自應用的場景。
巨集的常見應用場景:
getter
和 setter
方法、實現常見的特徵等。巨集的局限性:
巨集是目前覺得最難和最複雜的部分了,彈性和複雜度都遠超過想像,甚至巨集也是用巨集寫,可以感受到要開發或維護的難度都很高。
雖然還不會遇到需要自己開發巨集的情況,不過至少現在知道平常會寫到的一些巨集語法背後代表的意義,不一定需要自己造這些工具,但理解宏觀的設計和原理,未來在使用的時候心裡會比較有底。