最近在看 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 比這個更嚴謹:
- 編譯器把你標註
derive的語法轉成TokenStream - macro crate 用
syn把 token parse 成 AST(語法樹) - 你在 AST 上做檢查與分析
- 用
quote產生新的 Rust tokens 丟回編譯器 - 編譯器把這段新程式碼當成真的程式去編譯
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(),
}
}這段有三個重點:
#[proc_macro_derive(Builder)]把這個函式註冊成 derive macro。parse_macro_input!把原始 token 解析成syn::DeriveInput。- 發生錯誤時用
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 enumsbuilder-derive 這裡做得很對:把「不可能支援」的 case 儘早在編譯期擋下來,使用者不用等到 runtime 才踩雷。
第 3 步:欄位分析(field.rs)
接著是整個 macro 的關鍵資料結構 FieldInfo,每個欄位會被分析出:
- 欄位名稱
name - 原始型別
ty - 是否
Option<T> Option<T>的內層型別T- 是否
Vec<T>
怎麼判斷 Option<T>?
它用 syn::Type pattern matching:
- 先確認是
Type::Path - 抓路徑最後一段(例如
Option) - 看識別字是不是
Option - 若是,從 angle-bracket 參數拿出
T
這是 proc macro 最常見也最實用的技巧: 不要自己 parse 字串;直接操作 AST。
syn 到底在做什麼?
可以把 syn 想成「Rust 語法的解析器 + AST 資料模型」。
在 builder-derive 裡,syn 主要做三件事:
- 把
TokenStream轉成DeriveInput(看到 struct 名稱、可見性、欄位) - 用 enum pattern matching 檢查資料型別(
Data::Struct/Fields::Named) - 深入欄位型別結構(
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 裡把生成工作拆成四塊,這個切法很值得學:
generate_builder_structgenerate_builder_constructorgenerate_setter_methodsgenerate_build_method
quote! 怎麼把 AST 變回 Rust 程式?
quote 的角色是「把分析結果重新組裝成 token」。
在這個專案裡,最常用的語法有兩個:
#var:把變數插進模板#(...)*:把 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 在這裡把欄位分三類處理:
Option<T>:直接 passthrough(沒設就None)Vec<T>:沒設就unwrap_or_default(),變空陣列- 其他欄位:必填,沒設就回
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 的好處是它「剛好夠真實,但不會太重」:
- 有完整 pipeline(parse -> analyze -> generate)
- 有 compile-fail test(
trybuild) - 有 integration tests 驗證行為
- 邏輯分層乾淨,不會全部擠在一個檔案
如果正要開始學 proc macro,建議順序是:
- 先看
src/lib.rs入口 - 再看
parse.rs怎麼做輸入保護 - 再看
field.rs怎麼做型別判斷 - 最後看
generate.rs的quote!拼裝
這樣你比較不會一開始就被 quote! 的 token interpolation 淹沒。
目前限制與下一步
以目前實作來看,還有幾個可以進化的方向:
- 支援 generics(例如
struct Foo<T>) - 支援
#[builder(...)]欄位屬性(default、setter(into)、skip) - 更結構化的錯誤型別(取代
String) - 允許 optional setter 傳
Option<T>(可顯式設None)
但以「教學用、理解 proc macro 原理」來說,現在這個版本其實已經很漂亮了。
結語
Rust 的 procedural macro 真正厲害的地方,不是語法炫技,而是把重複樣板在「編譯期」變成可驗證、可維護的程式碼生成流程。
builder-derive 這個專案剛好把這件事示範得很清楚:
- 用
syn看懂你的 Rust 程式 - 用
quote生成你本來不想手寫的 boilerplate - 用型別檢查和測試把 macro 行為收斂到可預期
下次看到 #[derive(...)],你可以把它想成:
「這不是魔法,是一個在編譯器裡跑的小型 code generator。」