iT邦幫忙

2024 iThome 鐵人賽

DAY 29
0

簡介

巨集(macro)簡單地說就是用程式碼產生程式碼的方式,又稱為超程式設計(metaprogramming),目前我們也在很多地方使用過,比如輸出內容到終端機的 println! 和初始化向量的 vec!,以及自訂型別上面加的 #[derive] 等等,實際上這些巨集在我們使用的地方都會展開更多的程式碼,另外很多框架也會設計自己的巨集。
可以把巨集視為現成的工具,可以減少我們自己撰寫和維護的程式碼量,聽起來和函數差不多,但是巨集更複雜也更彈性,開發和維護成本也更高。

比較一下兩者的差別,函數的參數數量是固定的,要呼叫函數的時候,傳遞的參數不能多也不能少,但巨集可以接收變動數量的參數,除此之外,比如說,#[derive(Debug)]可以幫我們幫某個型別實作 Debug 特徵,讓我們很方便可以透過 println! 把資料輸出到終端機,巨集可以是因為巨集會在編譯前就展開,但是函數要到實際執行的時候才會呼叫,而特徵必須要在編譯前就實作完成。

其他差異包括兩者的作用域差異,比如說在同一個檔案中我們可以在任何地方定義函數,既可以在函數前也可以在函數後,另外函數可以當成參數傳給其他函數,但是巨集不行,因為它只是編譯的工具,沒有類型。

巨集種類

巨集可以分為兩種:

  • 宣告式(declarative)巨集
  • 程序式(procedural)巨集

程序式又分為三種:

  • 自訂 #[derive] 巨集,可以將指定的程式碼加在使用 derive 屬性的結構體和列舉
  • 類屬性(Attribute-like)巨集,定義可以用在任何物件的自訂屬性
  • 類函數(Function-like)巨集,看起來像是函數的呼叫但實際上將標記(token)當作引數來處理

對於宣告式巨集還有類函數巨集,在程式碼裡我們可以很明確用結尾的 ! 來分辨這是一個函數還是巨集。

宣告式巨集

先看比較好理解的宣告式,又稱為巨集為例(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] 的部分我們要加入 synquote,分別是解析和生成 Rust 程式碼。

下圖簡略展示從我們寫出來的程式碼到電腦執行的機械碼,中間經過的步驟,一般編譯就是只有上面單向。
程式碼會先被編譯器前端拆解成多個 Token,這些 Token 轉換成抽象語法樹(Abstract Syntax Tree),經過編譯器產生中間碼在經過一些優化之後最後生成機械碼。
synquote 就是設計來方便針對 ASTTokenStream 中間的轉換。
https://ithelp.ithome.com.tw/upload/images/20241013/20168952woOX2G1L2O.png

都設定好就可以來寫程式碼:

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

一開始我們把需要依賴引入,synquote 如前述。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!就要直接看官方的文件了。

更新成用 derive 實作特徵預設行為

回到 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 陳述句,更不用說針對內容去檢查了。
所以類函數巨集和宣告式巨集應用上還是有很大的區別。

結語

巨集有兩大類:宣告式巨集程序式巨集,我們現在知道他們和函數的差異,有些必須在編譯前完成的邏輯可以藉由巨集完成,至少對使用的人來說,巨集的確幫了很多忙,讓我們不用再去處理一些很繁瑣或是重複的邏輯。另外我們也知道不同巨集存在的必要性以及各自應用的場景。

巨集的常見應用場景:

  • 生成重複性程式碼:比如生成 gettersetter 方法、實現常見的特徵等。
  • 嵌入 DSL(Domain-Specific Language):方便將特定領域的語言嵌入到 Rust 中,例如 SQL、HTML 等。
  • 元編程:在編譯期進行一些代碼轉換和優化。

巨集的局限性:

  • 編譯時間:過多的巨集使用可能會增加編譯時間。
  • 錯誤訊息:巨集產生的錯誤訊息可能不夠直觀,難以排查。

巨集是目前覺得最難和最複雜的部分了,彈性和複雜度都遠超過想像,甚至巨集也是用巨集寫,可以感受到要開發或維護的難度都很高。
雖然還不會遇到需要自己開發巨集的情況,不過至少現在知道平常會寫到的一些巨集語法背後代表的意義,不一定需要自己造這些工具,但理解宏觀的設計和原理,未來在使用的時候心裡會比較有底。


上一篇
Day28 - 疊代器
下一篇
Day30 - 無懼並行
系列文
螃蟹幼幼班:Rust 入門指南30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言