featured.svg

最近又翻了一下 builder-derive 這個小專案,每次看都還是會被 Rust 的 procedural macro 給驚艷到。

你在程式碼裡只寫一行 #[derive(Builder)],編譯器就乖乖幫你生出一整套 Builder API。

但它到底是怎麼做到的呢?

這篇就拿 builder-derive 的實作來開刀,從 TokenStream 一路追到實際生成的程式碼,把 Rust proc macro 的完整流程整個攤開來看。

先看使用端:我們到底得到了什麼

先看最終使用方式:

use builder_derive::Builder;

#[derive(Builder, Debug)]
struct User {
    username: String,
    email: String,
    age: Option<u32>,
    tags: Vec<String>,
}

let user = User::builder()
    .username("alice".to_string())
    .email("[email protected]".to_string())
    .age(30)
    .build()?;

注意喔,這裡我們沒有手寫 UserBuilder,也沒有手寫 setter、build()

通通是 #[derive(Builder)] 在編譯期幫我們生出來的。

Proc Macro 的核心心法:編譯期程式碼產生器

很多人第一次接觸 macro,大概都會覺得它就是個「文字替換」吧。 不過 Rust 的 procedural macro 其實嚴謹得多:

  1. 編譯器把你標註 derive 的語法轉成 TokenStream
  2. macro crate 用 syn 把 token parse 成 AST(語法樹)
  3. 你在 AST 上做檢查與分析
  4. quote 產生新的 Rust tokens 丟回編譯器
  5. 編譯器把這段新程式碼當成真的程式去編譯

builder-derive 的模組切分很清楚:

  • src/lib.rs:入口
  • src/parse.rs:輸入合法性檢查
  • src/field.rs:欄位型別分析
  • src/generate.rs:程式碼生成

第 1 步:入口函式(lib.rs)

builder-derive 的入口非常典型:

#[proc_macro_derive(Builder)]
pub fn derive_builder(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);

    match generate::impl_builder(&input) {
        Ok(tokens) => tokens.into(),
        Err(e) => e.to_compile_error().into(),
    }
}

這段有三個地方值得留意:

  1. #[proc_macro_derive(Builder)] 把這個函式註冊成 derive macro。
  2. parse_macro_input! 把原始 token 解析成 syn::DeriveInput
  3. 出錯時用 to_compile_error() 轉成編譯錯誤,而不是直接 panic。

我想第三點蠻容易被忽略的:macro 的錯誤訊息,其實是你主動設計出來的 UX 啊。

第 2 步:先擋掉不支援的型別(parse.rs)

parse.rs 裡,專案先做輸入驗證:

  • 只接受「具名欄位 struct」
  • 拒絕 enum / union / tuple struct / unit struct

這個設計實在是頗務實。因為 Builder 的生成邏輯整個依賴欄位名稱,沒有欄位名就沒辦法安全地產生 setter。

像這段 compile-fail 測試會得到清楚錯誤:

#[derive(Builder)]
enum MyEnum {
    Variant1,
    Variant2,
}

錯誤訊息是:

Builder can only be derived for structs, not enums

builder-derive 這裡做得很對:把「不可能支援」的 case 儘早在編譯期就擋掉,使用者不用等到 runtime 才踩到雷。

第 3 步:欄位分析(field.rs)

接著是整個 macro 的關鍵資料結構 FieldInfo,每個欄位會被分析出:

  • 欄位名稱 name
  • 原始型別 ty
  • 是否 Option<T>
  • Option<T> 的內層型別 T
  • 是否 Vec<T>

怎麼判斷 Option<T>

它用 syn::Type pattern matching:

  1. 先確認是 Type::Path
  2. 抓路徑最後一段(例如 Option
  3. 看識別字是不是 Option
  4. 若是,從 angle-bracket 參數拿出 T

這是 proc macro 最常見、也最實用的一個技巧: 別自己去 parse 字串,直接操作 AST 就對了。

syn 到底在做什麼?

可以把 syn 想成「Rust 語法的解析器 + AST 資料模型」。

builder-derive 裡,syn 主要做三件事:

  1. TokenStream 轉成 DeriveInput(看到 struct 名稱、可見性、欄位)
  2. 用 enum pattern matching 檢查資料型別(Data::Struct / Fields::Named
  3. 深入欄位型別結構(Type::Path -> PathSegment -> PathArguments)判斷 Option<T>Vec<T>

為什麼這很重要呢?因為 procedural macro 要的是「語法結構」,不是字串比對。

舉個例子,Option<String>std::option::Option<String> 在字串上長得完全不一樣,可是在 AST 這一層卻能被一致地處理。這就是 syn 在 macro 裡最值錢的地方囉。

另外像 syn::Error::new_spanned(...) 也很關鍵:它可以把錯誤綁在原始程式碼 span 上,讓編譯錯誤直接指到真正寫錯的那一行。

這個專案一個值得注意的設計

Option<T> 欄位,setter 參數型別是 T,不是 Option<T>

也就是你寫:

.age(30)

而不是:

.age(Some(30))

這讓 API 用起來順手多了。不過 tradeoff 是:目前沒辦法顯式設定 None,你只能靠「不呼叫 setter」來得到 None

第 4 步:用 quote 生成程式碼(generate.rs)

generate.rs 把生成工作拆成四塊,我覺得這個切法很值得學:

  1. generate_builder_struct
  2. generate_builder_constructor
  3. generate_setter_methods
  4. generate_build_method

quote! 怎麼把 AST 變回 Rust 程式?

quote 的角色是「把分析結果重新組裝成 token」。

在這個專案裡,最常用的語法有兩個:

  1. #var:把變數插進模板
  2. #(...)*:把 iterator 產生的多段程式碼展開

例如:

let builder_fields = field_infos.iter().map(|field| {
    let name = &field.name;
    let builder_ty = field.builder_field_type();
    quote! { #name: #builder_ty }
});

quote! {
    struct #builder_name {
        #(#builder_fields,)*
    }
}

這段可以這樣讀:「每個欄位先變成一小段 token,最後用 repetition 一口氣展開成完整的 struct 欄位列表。」

也因為這種寫法,macro 才能維持很強的可組合性:

  • 每個生成函式只負責一種片段
  • 最後在 impl_builder 把片段拼回完整輸出

對大型 macro 專案來說,這可比塞一個巨大的 quote! 區塊好維護太多了。

4.1 生成 Builder struct

每個 builder 欄位都用 Option<...> 包起來,目的是追蹤「這個欄位有沒有被設定」。

4.2 生成 builder()

在原始 struct 上加:

impl User {
    pub fn builder() -> UserBuilder { ... }
}

所有欄位初始化成 None

4.3 生成 setter(可 chain)

每個 setter 都是這個形狀:

pub fn field(mut self, value: T) -> Self {
    self.field = Some(value);
    self
}

self by value 再回傳 Self,fluent API 能一路 .foo().bar() 串下去就是這麼來的。

4.4 生成 build(),而且不同欄位策略不同

builder-derive 在這裡把欄位分三類處理:

  1. Option<T>:直接 passthrough(沒設就 None
  2. Vec<T>:沒設就 unwrap_or_default(),變空陣列
  3. 其他欄位:必填,沒設就回 Err("field is required")

這個策略我想是頗實用的,畢竟 Vec<T> 在很多 config 型別裡,預設成空集合確實最自然。

展開後大概長怎樣?

以這個輸入:

#[derive(Builder)]
struct Config {
    host: String,
    port: u16,
    timeout: Option<u64>,
    features: Vec<String>,
}

macro 會產生近似這樣的程式碼(簡化版):

struct ConfigBuilder {
    host: Option<String>,
    port: Option<u16>,
    timeout: Option<u64>,
    features: Option<Vec<String>>,
}

impl Config {
    fn builder() -> ConfigBuilder {
        ConfigBuilder {
            host: None,
            port: None,
            timeout: None,
            features: None,
        }
    }
}

impl ConfigBuilder {
    fn host(mut self, value: String) -> Self {
        self.host = Some(value);
        self
    }

    fn port(mut self, value: u16) -> Self {
        self.port = Some(value);
        self
    }

    fn timeout(mut self, value: u64) -> Self {
        self.timeout = Some(value);
        self
    }

    fn features(mut self, value: Vec<String>) -> Self {
        self.features = Some(value);
        self
    }

    fn build(self) -> Result<Config, String> {
        Ok(Config {
            host: self.host.ok_or_else(|| "host is required".to_string())?,
            port: self.port.ok_or_else(|| "port is required".to_string())?,
            timeout: self.timeout,
            features: self.features.unwrap_or_default(),
        })
    }
}

重點是:這些通通發生在編譯期,所以你的 runtime 不用付任何額外的 macro 成本。

錯誤處理:compile-time 跟 runtime 分工

這個專案的錯誤策略很清楚:

Compile-time(macro 階段)

  • 型別不支援就直接編譯失敗
  • 錯誤訊息會標到對應程式碼位置(透過 syn::Error::new_spanned

Runtime(build 階段)

  • 缺必要欄位時,build()Result::Err(String)

這種分工我覺得蠻合理的:

  • 能在語法層就判斷的,就盡量提早失敗
  • 非得等使用者跑流程才知道的,就乖乖用 Result 回報

為什麼這個範例很適合學 proc macro

builder-derive 的好處,就是它「剛好夠真實,又不會太重」:

  1. 有完整 pipeline(parse -> analyze -> generate)
  2. 有 compile-fail test(trybuild
  3. 有 integration tests 驗證行為
  4. 邏輯分層乾淨,不會全部擠在一個檔案

如果你正要開始學 proc macro,我建議的順序是這樣:

  1. 先看 src/lib.rs 入口
  2. 再看 parse.rs 怎麼做輸入保護
  3. 接著看 field.rs 怎麼做型別判斷
  4. 最後才看 generate.rsquote! 拼裝

照這個順序走,你比較不會一開始就被 quote! 那堆 token interpolation 給淹沒。

目前限制與下一步

以目前的實作來看,還有幾個可以再進化的方向:

  1. 支援 generics(例如 struct Foo<T>
  2. 支援 #[builder(...)] 欄位屬性(default、setter(into)、skip)
  3. 換成更結構化的錯誤型別(取代 String
  4. 允許 optional setter 傳 Option<T>(這樣就能顯式設 None

不過就「拿來教學、理解 proc macro 原理」這個目的來說,現在這個版本其實已經夠漂亮了。

結語

我想 Rust 的 procedural macro 真正厲害的地方,不是語法上的炫技,而是把那些重複的樣板,在「編譯期」就變成可驗證、可維護的程式碼生成流程。

builder-derive 這個專案,剛好把這件事示範得很清楚:

  • syn 看懂你的 Rust 程式
  • quote 生成那些你本來壓根不想手寫的 boilerplate
  • 用型別檢查跟測試,把 macro 的行為收斂到可預期的範圍

下次再看到 #[derive(...)],你大可以把它想成: 「這哪是什麼魔法,就是一個在編譯器裡跑的小型 code generator 啦。」


專案連結:rust-52-projects/builder-derive