本文 AI 產出,尚未審核

程序宏(Procedural Macros)

簡介

在 Rust 生態系統中,宏(macro)是讓程式碼在編譯期自動產生、轉換或檢查的重要工具。除了最常見的 宣告式宏(macro_rules!),Rust 也提供了更強大且彈性的 程序宏(Procedural Macros)。程序宏允許開發者以函式的方式接收語法樹(TokenStream),自行決定產生什麼程式碼,從而實現自訂屬性、派生(derive)以及函式樣式的宏。

對於想要在大型專案中減少樣板程式碼、提升 API 可讀性,或是實作領域特定語言(DSL)的開發者來說,掌握程序宏是邁向 「寫得更少、跑得更快」 的關鍵一步。本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,帶你一步步了解並運用程序宏。


核心概念

1. 程序宏的三種類型

類型 說明 使用方式
自訂屬性宏(Attribute-like macros) #[my_attribute] 形式掛在函式、結構、模組等項目上,允許在編譯期改寫或產生額外程式碼。 #[proc_macro_attribute]
派生宏(Derive macros) #[derive(MyTrait)] 自動產生 impl MyTrait for Type 的實作。 #[proc_macro_derive]
函式樣式宏(Function-like macros) my_macro!( ...) 的呼叫方式,接受任意 TokenStream,返回新的程式碼。 #[proc_macro]

:所有程序宏都必須放在 獨立的 crate(通常命名為 xxx-macro),並在 Cargo.toml 中宣告 proc-macro = true

# Cargo.toml of the macro crate
[lib]
proc-macro = true

2. TokenStream 與 TokenTree

程序宏的核心是 proc_macro::TokenStream,它是一串語法代號(token)的集合。每個 token 代表語法的最小單位,如關鍵字、識別子、符號等。開發者可以使用 proc_macro2(更友善的 wrapper)與 syn(語法解析)來將 TokenStream 轉成結構化的抽象語法樹(AST),再利用 quote 產生新的程式碼。

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};

#[proc_macro_derive(MyTrait)]
pub fn my_trait_derive(input: TokenStream) -> TokenStream {
    // 1. 解析輸入的語法樹
    let input = parse_macro_input!(input as DeriveInput);
    // 2. 產生實作程式碼
    let name = input.ident;
    let expanded = quote! {
        impl MyTrait for #name {
            fn hello(&self) {
                println!("Hello from {}", stringify!(#name));
            }
        }
    };
    // 3. 回傳 TokenStream
    TokenStream::from(expanded)
}

3. 建立一個最小的程序宏專案

  1. 建立 macro crate
    cargo new hello-macro --lib
    cd hello-macro
    
  2. Cargo.toml 加入
    [lib]
    proc-macro = true
    
    [dependencies]
    proc-macro2 = "1.0"
    quote = "1.0"
    syn = { version = "2.0", features = ["full"] }
    
  3. 編寫宏(參考上面的範例)
  4. 在主程式中使用
    # Cargo.toml of the binary crate
    [dependencies]
    hello-macro = { path = "../hello-macro" }
    
    // main.rs
    use hello_macro::MyTrait; // 由宏自動產生的 trait
    
    #[derive(MyTrait)]
    struct Foo;
    
    fn main() {
        Foo.hello(); // 印出 "Hello from Foo"
    }
    

程式碼範例

範例 1️⃣:自訂屬性宏 – 自動記錄函式執行時間

// time_logger/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemFn};

#[proc_macro_attribute]
pub fn log_time(_attr: TokenStream, item: TokenStream) -> TokenStream {
    // 解析成函式結構
    let input = parse_macro_input!(item as ItemFn);
    let fn_name = &input.sig.ident;
    let block = &input.block;
    let vis = &input.vis;
    let sig = &input.sig;

    // 產生包裝後的函式
    let expanded = quote! {
        #vis #sig {
            let start = std::time::Instant::now();
            let result = (|| #block)();
            let elapsed = start.elapsed();
            println!("[log_time] {} 執行時間: {:?}", stringify!(#fn_name), elapsed);
            result
        }
    };
    TokenStream::from(expanded)
}
// 使用範例
use time_logger::log_time;

#[log_time]
fn heavy_computation() -> i32 {
    // 模擬耗時工作
    std::thread::sleep(std::time::Duration::from_millis(150));
    42
}

fn main() {
    let v = heavy_computation();
    println!("結果 = {}", v);
}

說明log_time 會在函式執行前後插入計時程式碼,且不改變原本的回傳型別與行為。


範例 2️⃣:派生宏 – 為結構自動實作 FromStr

// from_str_derive/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput, Data, Fields};

#[proc_macro_derive(FromStr)]
pub fn derive_from_str(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    let name = input.ident;

    // 只支援單欄位 tuple struct,如: struct Point(i32, i32);
    let (field_ty, field_idx) = match input.data {
        Data::Struct(s) => match s.fields {
            Fields::Unnamed(ref f) if f.unnamed.len() == 1 => {
                let ty = &f.unnamed.first().unwrap().ty;
                (ty, 0usize)
            }
            _ => panic!("FromStr 只能套用在單欄位 tuple struct"),
        },
        _ => panic!("FromStr 只能套用在 struct"),
    };

    let expanded = quote! {
        impl std::str::FromStr for #name {
            type Err = std::num::ParseIntError; // 依需求自行調整

            fn from_str(s: &str) -> Result<Self, Self::Err> {
                let inner: #field_ty = s.parse()?;
                Ok(#name(inner))
            }
        }
    };
    TokenStream::from(expanded)
}
// 使用範例
use from_str_derive::FromStr;
use std::str::FromStr;

#[derive(Debug, FromStr)]
struct Age(u8);

fn main() {
    let a = Age::from_str("27").unwrap();
    println!("Age = {:?}", a);
}

說明:透過 #[derive(FromStr)],開發者只需要寫結構定義,即可得到 FromStr 實作,減少重複樣板。


範例 3️⃣:函式樣式宏 – 建立類似 println! 的簡易日誌宏

// simple_log/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, LitStr, Token, Expr};
use syn::parse::{Parse, ParseStream};

struct LogInput {
    level: LitStr,
    _comma: Token![,],
    msg: Expr,
}

impl Parse for LogInput {
    fn parse(input: ParseStream) -> syn::Result<Self> {
        Ok(LogInput {
            level: input.parse()?,
            _comma: input.parse()?,
            msg: input.parse()?,
        })
    }
}

#[proc_macro]
pub fn simple_log(item: TokenStream) -> TokenStream {
    let LogInput { level, msg } = parse_macro_input!(item as LogInput);
    let expanded = quote! {
        println!("[{}] {}", #level, #msg);
    };
    TokenStream::from(expanded)
}
// 使用範例
use simple_log::simple_log;

fn main() {
    simple_log!("INFO", "程式啟動");
    simple_log!("WARN", format!("剩餘 {} 次機會", 3));
}

說明simple_log! 接收兩個參數(字串層級與訊息),在編譯期直接展開成 println! 呼叫,示範了函式樣式宏的基本寫法。


範例 4️⃣:結合 synquote – 自動產生測試模組

// auto_test/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemFn};

#[proc_macro_attribute]
pub fn auto_test(_attr: TokenStream, item: TokenStream) -> TokenStream {
    let func = parse_macro_input!(item as ItemFn);
    let fn_name = &func.sig.ident;
    let test_name = syn::Ident::new(&format!("test_{}", fn_name), fn_name.span());

    let expanded = quote! {
        #[cfg(test)]
        mod __auto_test {
            use super::*;
            #[test]
            fn #test_name() {
                // 呼叫原始函式,若 panic 則測試失敗
                #fn_name();
            }
        }

        #func
    };
    TokenStream::from(expanded)
}
// 使用範例
use auto_test::auto_test;

#[auto_test]
fn example() {
    assert_eq!(2 + 2, 4);
}

說明:開發者只要在函式上加上 #[auto_test],編譯器就會自動產生對應的測試函式,減少手動寫測試的繁瑣。


範例 5️⃣:屬性宏搭配參數 – 自訂序列化欄位名稱

// rename_serde/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput, AttributeArgs, NestedMeta, Meta, Lit};

#[proc_macro_attribute]
pub fn rename_serde(args: TokenStream, input: TokenStream) -> TokenStream {
    // 解析屬性參數,例如 #[rename_serde(to = "snake_case")]
    let args = parse_macro_input!(args as AttributeArgs);
    let mut to_snake = false;
    for arg in args {
        if let NestedMeta::Meta(Meta::NameValue(kv)) = arg {
            if kv.path.is_ident("to") {
                if let Lit::Str(lit) = kv.lit {
                    if lit.value() == "snake_case" {
                        to_snake = true;
                    }
                }
            }
        }
    }

    let ast = parse_macro_input!(input as DeriveInput);
    let name = ast.ident;

    // 只示範對所有欄位加上 #[serde(rename = "...")]
    let mut fields_renamed = Vec::new();
    if let syn::Data::Struct(data) = ast.data {
        for field in data.fields {
            let ident = field.ident.unwrap();
            let new_name = if to_snake {
                // 簡易的 camel → snake 轉換
                let s = ident.to_string();
                let mut snake = String::new();
                for (i, ch) in s.chars().enumerate() {
                    if ch.is_uppercase() {
                        if i != 0 { snake.push('_'); }
                        snake.push(ch.to_ascii_lowercase());
                    } else {
                        snake.push(ch);
                    }
                }
                snake
            } else {
                ident.to_string()
            };
            fields_renamed.push(quote! {
                #[serde(rename = #new_name)]
                #ident: #field.ty,
            });
        }
    }

    let expanded = quote! {
        #[derive(serde::Serialize, serde::Deserialize)]
        struct #name {
            #(#fields_renamed)*
        }
    };
    TokenStream::from(expanded)
}
// 使用範例
use rename_serde::rename_serde;

#[rename_serde(to = "snake_case")]
struct UserProfile {
    UserName: String,
    EmailAddress: String,
    CreatedAt: i64,
}

說明:此宏讓開發者只需要在結構上寫一次屬性,即可自動為每個欄位產生符合 serde 命名慣例的 rename 設定,降低手動錯誤的機會。


常見陷阱與最佳實踐

陷阱 說明 解決方案 / 最佳實踐
1. 產生的程式碼未加 #[allow(...)] 宏展開後的程式碼可能觸發未使用變數、dead code 等警告,影響編譯體驗。 在產生的程式碼前加上 #[allow(dead_code, unused_imports)],或在 Cargo.toml 設定 #![allow(...)]
2. 使用 proc_macro::TokenStream 直接操作 直接拼接字串容易出錯,且缺乏語法檢查。 使用 proc_macro2 + syn + quote 三個生態套件,它們提供結構化解析與安全產生。
3. 宏的錯誤訊息不友善 panic! 只會顯示「proc macro panicked」而不指明位置。 使用 syn::Error::new_spanned(...).to_compile_error() 產生編譯錯誤,讓使用者看到具體的行號與訊息。
4. 過度抽象導致可讀性下降 宏隱藏太多邏輯,後續維護者可能不易理解。 保持宏的範圍小:每個宏只負責一件事,並在文件中提供完整說明與範例。
5. 產生的程式碼與外部 crate 版本不相容 宏內部直接使用其他 crate 的型別,若版本升級會破壞編譯。 使用 extern crate 重新導入,或在宏中使用 ::my_crate:: 絕對路徑,避免依賴隱式版本。
6. 循環依賴 宏 crate 與被宏使用的 crate 互相依賴會產生編譯錯誤。 分層設計:宏 crate 只依賴公共介面(traits、types),而主 crate 依賴宏 crate。

最佳實踐總結

  1. 最小化公開 API:只在 lib.rspub use 必要的宏,其他輔助函式保持私有。
  2. 寫測試:即使是宏本身,也要寫 #[test] 來驗證展開後的語法樹是否正確。
  3. 提供 cargo expand 範例:在文件中示範使用 cargo expand 觀察宏展開結果,幫助使用者除錯。
  4. 遵守 Rustfmt:產生的程式碼盡量符合 rustfmt 標準,提升可讀性。
  5. 記錄限制:若宏只支援特定結構(如單欄位 tuple struct),務必在文件與錯誤訊息中說明。

實際應用場景

場景 為何使用程序宏 範例
自動產生 REST API 客戶端 依據 OpenAPI 定義產生 structenumimpl,減少手寫序列化/反序列化程式碼。 swagger-codegen 類似的 openapi-macro
領域特定語言(DSL) 讓使用者以類似 SQL、GraphQL 的語法在 Rust 中直接寫查詢,宏負責轉換成型別安全的 API。 dieseltable!sql! 宏。
跨平台 FFI 包裝 為每個外部函式自動產生安全的 wrapper,避免手寫重複的 unsafe 程式碼。 cbindgenbindgen 產生的 extern "C" 介面。
測試自動化 為每個公開函式自動產生基礎測試骨架,提升測試覆蓋率。 前述的 auto_test 範例。
日誌與度量 在函式入口/出口自動插入 tracingmetrics 相關程式碼,保持業務邏輯乾淨。 #[instrument]#[measure] 等屬性宏。

實務建議:在大型團隊中,將常用的宏抽成獨立的內部 crate,並以語意化的名稱(如 log_timeserde_rename)提供給所有子專案,能顯著提升開發效率與程式碼一致性。


總結

程序宏是 Rust 提供的 編譯期程式碼生成利器,它讓開發者能在保持靜態型別安全的前提下,減少樣板、提升抽象層級。本文從三種宏的分類、核心概念、實作步驟,到 5 個完整範例,再到常見陷阱與最佳實踐,最後列舉了實務上常見的應用場景。掌握以下要點,即可在自己的專案中安全、有效地運用程序宏:

  1. 使用 proc_macro2synquote 來解析與產生程式碼,避免手寫字串。
  2. 保持宏的功能單一,並提供清晰的錯誤訊息與文件。
  3. 在獨立的 macro crate 中開發,並以測試驗證展開結果。
  4. 善用屬性宏與派生宏,自動化日誌、序列化、測試等重複工作。
  5. 在團隊中建立宏的共用庫,讓整個代碼基礎受益。

透過這些技巧,你不僅能寫出更 簡潔、可讀 的 Rust 程式碼,也能在大型系統中保持 高維護性與擴充性。祝你在宏的世界裡玩得開心,寫出更好、更快的 Rust 程式!