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 是:目前 API 不能顯式設定 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 的來源。

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