[Rust] 從 Derive 巨集 到 Trait 實作:深入理解與實戰
![[Rust] 從 Derive 巨集 到 Trait 實作:深入理解與實戰](/content/images/size/w960/2024/11/jonathan-hislop-XSSibD1bt80-unsplash.jpg)
為什麼需要 Derive?
如果你可以讓編譯器幫你免費寫樣板程式碼,為什麼還要親自上陣?
在日常開發裡,我們常為自訂型別重複撰寫下列 traits:
Debug
:方便println!("{:?}", ...)
除錯Clone
:需要深層複製時不會碰到所有權阻礙Serialize
/Deserialize
:資料 ↔️ JSON/YAML 的通行證PartialEq
/Eq
:比大小、做 map key… 都靠它們
手刻既耗時又容易 typo,一不小心就踩雷。Derive 巨集 透過 編譯期 程式碼生成,把繁瑣工作全自動化,同時確保行為一致且安全
Derive 巨集到底是什麼?
把 Derive 想像成一位超勤奮的會計師:你把帳本(AST)丟給他,他在報稅截止(編譯)前自動幫你填好所有表格(trait impl),然後再把結果交給國稅局(後續編譯流程)
- 身分證:屬於 procedural macro 家族
- 工作時機:跑在語法分析之後、生成位元碼之前
- 能力範圍:只能新增程式碼,無法改寫原始型別 → 保證可預測且不亂動你已寫好的邏輯
- 常見任務:為 struct/enum 自動補上
Debug
,Clone
,Serialize
… 等重複度 200% 的樣板
為什麼需要 Derive 巨集?
在 Rust 專案裡,你經常遇到以下「重複勞動」:
- 除錯輸出 (
Debug
) — 隨時印結構內容,觀察程式狀態 - 深層複製 (
Clone
) — 想保留資料另一份副本,不被所有權規則「卡住」 - 序列化/反序列化 (
Serialize
/Deserialize
) — 資料 ↔️ JSON/YAML/TOML 的通行證 - 比較與雜湊 (
PartialEq
,Eq
,Hash
) — 讓自訂型別能丟進HashMap
或做==
比較
手工撰寫 impl 不僅耗時、容易 typo,還難以確保風格一致。Derive 巨集 在 編譯期 自動產生標準實作,直接省下樣板時間,也降低維護成本
手刻Debug
、Clone
、Serialize
花掉的工時,能拿去做更多事
基本使用方式
#[derive(Debug, Clone, Serialize)]
struct Point {
x: f64,
y: f64,
}
執行 cargo check
,編譯器瞬間替 Point
生成三份 impl
常用 Derive Traits:從 Debug 到 Serialize
Debug — {:?}
是你的小夜燈
對新手來說,Debug
就是一種「臨時把結構體攤開來看的能力」。只要型別實作了 Debug
,就能用 println!("{:?}", value)
把內容一股腦印出來。最簡單的做法?在型別上加 #[derive(Debug)]
,編譯器自動幫你生好程式碼
#[derive(Debug)]
struct Person {
name: String,
age: u8,
}
fn main() {
let p = Person { name: "Alice".into(), age: 30 };
println!("{:?}", p);
// 輸出:Person { name: "Alice", age: 30 }
}
什麼時候需要「自己寫」?
有時候萬用版格式不夠貼心,例如:
- 想隱藏敏感欄位(密碼、Token…)
- 想改變欄位排序或加註解
- enum 不同變體需要各自的顯示邏輯
此時可手動實作 fmt
:
use std::fmt;
impl fmt::Debug for Person {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// 宣告這是一個 struct,名字顯示為 "Person"
f.debug_struct("Person")
// 指定要顯示的欄位
.field("name", &self.name)
// 刻意不顯示 age
.finish()
}
}
常用格式助手
想要的外觀 | 方法 | 說明 |
---|---|---|
Struct { ... } | debug_struct | 類似鍵值對的結構體格式 |
(a, b, c) | debug_tuple | 適用元組或帶名字的元組結構體 |
[...] | debug_list | 任何可疊代的列表 |
{a, b, c} | debug_set | 集合或去重列表 |
Debug
給開發者看,詳細一點沒關係;如果要給終端使用者看,請另外實作Display
,語氣、格式換得更親民
在 log 裡印東西時,記得別把敏感字串直接輸出,可用 "***"
遮蔽或乾脆不印
如果是「trait 物件」呢?用 dyn Trait
有時你想把「實作了某個 trait 的東西」存進變數,直到執行期才決定具體型別: 這種就叫 trait 物件,語法是 dyn Trait
。 因為編譯器無法在編譯期寫死型別,所以對 dyn Trait
的方法呼叫都走 動態派發(runtime 幫你選擇要調用哪支函式)
若想為 所有 這類 trait 物件統一自訂 Debug
格式,就可以直接對 dyn Trait
實作:
use std::fmt;
trait Pet {
fn name(&self) -> &str;
}
impl fmt::Debug for dyn Pet {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Pet(name = \"{}\")", self.name())
}
}
dyn Pet
不代表某一個具體型別,而是「任何 實作了 Pet 的值」。只要後續把Box<dyn Pet>
或&dyn Pet
丟進println!("{:?}", _)
,就會使用上面這段Debug
這樣就算你同時有 Cat
、Dog
、Hamster
… 只要它們實作 Pet
,包成 trait 物件後都能使用同一套輸出格式
Clone Trait: 擺平「所有權搬家」錯誤的救生圈
Rust 的所有權規則保證記憶體安全,但對新手來說最常遇到的 compile error 之一就是:
let s1 = String::from("hello");
let s2 = s1; // ⚠️ s1 的所有權被「搬家」給 s2
println!("{}", s1); // ❌ error: value borrowed here after move
想同時保留兩份資料?兩條路:
- 借用(
&s1
): 省記憶體,但只能在借用生命週期內讀取/修改 - 複製(
s1.clone()
): 拿到全新所有權,互不干涉
Clone
trait 就是複製這一條路的開關。當型別實作了 Clone
,你就可以任意呼叫 .clone()
取得「第二份、獨立所有權」,不怕看到 value borrowed after move
不同場景下 .clone()
到底做了什麼?
欄位型別 | .clone() 行為 | 內部成本 | 典型例子 |
標註 Copy | 位元拷貝,就像 memcpy | 幾乎 0 | u32 , bool , &T |
擁有堆積資料 | 深複製:重新配置、複製內容 | O(資料大小) | String , Vec<T> |
Rc<T> / Arc<T> | 共享:引用計數 +1 | O(1) 原子或非原子遞增 | Rc<String> |
何時該 Clone傳值給函式:函式簽名吃T
而你又想保留原值跨執行緒傳遞:用Arc<T>
先 clone 再丟 thread做快取 / 歷史快照:需要凍結當前狀態以後分析
但也別動不動就.clone()
,深複製大物件可能燒記憶體、拖慢效能;能借用就借用、能改用Rc
/Arc
共享就共享
Rc 的 clone:不是複製,是 +1
use std::rc::Rc;
let msg = Rc::new(String::from("Hi"));
let a = Rc::clone(&msg); // ✅ 建議用顯式 Rc::clone()
let b = msg.clone(); // 同效果,但易誤解為深複製
assert_eq!(Rc::strong_count(&msg), 3); // 三把指標,共用一份字串
Rc::clone(&msg)
比msg.clone()
更能提醒讀者:只複製指標,不複製內容- 最後一把
Rc
離開作用域時才真正drop
- 要跨執行緒?改用
Arc<T>
(Atomic Reference Counted)。介面一樣,計數器是原子遞增
deep copy 示範:User
#[derive(Clone, Debug)]
struct User {
name: String, // 深複製:重新配字串
age: u32, // Copy:位元拷貝
}
let u1 = User { name: "Amy".into(), age: 18 };
let u2 = u1.clone();
println!("{:?} | {:?}", u1, u2);
Serialize 和 Deserialize: 用 Serde 打通跨服務資料管線
Rust 本身沒有內建「把型別⇆ byte stream」的標準介面,Serde 就是社群公認的一站式解決方案:
serialize = 把 struct ➜ bytes(JSON / CBOR / binary…)方便跨服務、跨語言或儲存
deserialize = 把 bytes ➜ struct,還原成強型別資料結構繼續愉快開發
在 Cargo.toml
啟用 derive
在使用帶有 derive 巨集的 Serde 時,重要的是要在你的 Cargo.toml 中啟用 "derive"
功能:
[dependencies]
serde = { version = "1.0", features = ["derive"] }
最小工作範例:HTTP API 來回丟 JSON
use serde::{Serialize, Deserialize};
use serde_json::{to_string, from_str};
#[derive(Serialize, Deserialize, Debug)]
struct User {
#[serde(rename = "userId")] // 自訂欄位名稱
user_id: u32,
#[serde(skip_serializing_if = "Option::is_none")]
email: Option<String>, // 可以設定不傳空值
}
fn main() -> anyhow::Result<()> {
// Client ➡️ Server:serialize
let req_body = User { user_id: 1, email: None };
let json = to_string(&req_body)?; // "{\"userId\":1}"
// ... imagine 將 json 送出 HTTP POST ...
// Server ⬅️ Client:deserialize
let parsed: User = from_str(&json)?;
println!("收到 = {:?}", parsed);
Ok(())
}
常用 Attribute
屬性 | 用途 | 範例 |
rename | 單欄位改名 | #[serde(rename = "firstName")] |
rename_all | 批量改名 (camelCase、snake_case…) | #[serde(rename_all = "camelCase")] |
skip_serializing_if | 條件省略欄位 | Option::is_none , Vec::is_empty |
default | 反序列化時,缺欄位給預設值 | #[serde(default)] |
flatten | 把巢狀結構展平成同一層 | 常用於設定檔 Merge |
tag / content | 枚舉加型別標籤 (tagged enum) | #[serde(tag = "type", content = "data")] |
進階技巧 & 範例
自訂 Derive 巨集:一步步教你生出編譯器小助手
想要針對自己的庫自動生成重複 impl?自訂 Derive 巨集 就是把「寫樣板」外包給編譯器的終極招式
所有 proc‑macro 必須放在 獨立 crate,而且 crate type = proc-macro
建立專用巨集 crate
cargo new my_trait_derive --lib
在 my_trait_derive/Cargo.toml
補上官方教學提到的段落,宣告這是一個 proc‑macro crate:
[lib]
proc-macro = true
這行就等同於 crate-type = ["proc-macro"]
,讓 cargo
在編譯時自動插入必要的 linker flags
添加 dependency
官方 Reference 的範例直接操作 proc_macro::TokenStream
,沒有用任何第三方 crate;然而在社群專案裡,開發者通常會額外引入:
這兩個工具箱都由社群大神 @dtolnay
維護,品質有口皆碑。只要加入 quote
,proc‑macro2
會作為依賴自動被拉進來
[dependencies]
syn = { version = "2", features = ["full"] } # 解析輸入 AST
quote = "1.0" # 產生輸出程式碼
syn
與quote
是絕大多數 derive 巨集的標準組合,如果你不需要自行處理TokenStream
,其他相依會被自動拉進來,無須手動列出
官方最小範例:直接拼 TokenStream
以下改寫自 Rust 官方文件 - Procedural Macros,強調「零依賴」做法:
use proc_macro::TokenStream;
#[proc_macro_derive(MyTrait)]
pub fn my_trait_derive(input: TokenStream) -> TokenStream {
// 把輸入直接轉字串,再手動拼出 impl
let ident = input.to_string(); // e.g. "struct Foo"
let expanded = format!(
"impl MyTrait for {} {{ fn hello() {{ println!(\"hello\"); }} }}",
ident.trim_start_matches("struct ") // 粗略取型別名稱
);
expanded.parse().unwrap() // 交回給編譯器
}
- 直接操作字串,對語法結構一無所知,容易因空白或註解就炸裂
- 字串插值看起來簡單,但稍複雜就迷失在括號、逗號與生命週期標註中間
- 無型別保護,也無法產生良好錯誤訊息
用 syn + 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 {
let ast: DeriveInput = parse_macro_input!(input);
let name = &ast.ident; // 型別名稱安全取得
let expanded = quote! {
impl MyTrait for #name {
fn hello() {
println!("hello");
}
}
};
TokenStream::from(expanded)
}
優勢一眼可見:
syn
解析 AST → 不怕註解、空格、複雜 genericquote!
用#name
內插識別子,程式碼結構直觀- 若型別不是
struct
,可match ast.data
早早報錯
使用 derive
use my_trait_derive::MyTrait; // bring in derive 巨集
#[derive(MyTrait)]
struct Foo;
fn main() { Foo::hello(); }
執行 cargo run
,看到 Hello from Foo!
就成功
Derive 巨集到底幫了你什麼忙?
你以前得自己做 | 現在交給 Derive 巨集 | 好處 |
手刻 Debug , Clone , Serialize … | #[derive(...)] 一行搞定 | 省時、避免手滑 |
在多個型別維護一致的 impl 風格 | 編譯期一次生成 | 風格統一、可讀性高 |
複雜 trait 邊界檢查 | 編譯器自動驗證 | 型別安全、少踩雷 |
重複樣板讓程式碼膨脹 | 巨集集中定義 | 程式碼精簡、好維護 |
Derive 巨集就是你專屬的自動打字機,負責重複、枯燥、但不能出錯的程式碼。 當專案開始出現「再寫一次 impl 就想翻桌」的感覺,八成就是該寫(或套用)Derive 巨集的時候了
Derive 巨集 × 日誌框架:常用情境實戰
Rust 的日誌生態系統提供了多個強大的框架,能夠與語言的 derive 巨集 和 trait 系統無縫整合。最核心的角色是 log
crate──它並不負責真正輸出任何東西,而是一個輕量級「外觀層」(facade)
- 統一 API:函式庫作者只要
use log::{info, debug…}
,不必關心最終要印到終端、寫檔還是送到集中式伺服器 - 可插拔後端:應用程式在
main()
裡呼叫一次logger::init()
,即可把日誌導向喜歡的實作(env_logger
、tracing_subscriber
、log4rs
…) - 零額外成本:若編譯時整個程式沒啟用任何後端,
log
會被優化掉,產物 size 不受影響 - 與衍生巨集協作:
- 自訂
Debug
impl 時可直接呼叫debug!()
把關鍵欄位打進 log - 透過
#[derive(Error)]
(thiserror
)等巨集,錯誤型別也能自帶格式化與日誌資訊
- 自訂
這種「一層介面,多個實作」的設計讓函式庫保持輕薄,可被任何環境採用;而最終應用端能依需求挑選後端或同時接多顆,彈性最大
log
:所有人的共同語言
# Cargo.toml
log = "0.4"
- 提供五個巨集:
error!
warn!
info!
debug!
trace!
- 本身 不輸出,只在執行期呼叫已註冊的後端(logger)
選一個後端
後端 | 特色 | 適合誰 |
env_logger | 讀環境變數 RUST_LOG=info,foo=debug 即可開啟 | CLI 工具、快速 demo |
log4rs | YAML/JSON 配置、滾動檔案、網路 appender | 需集中式收集或多檔輪替 |
tracing | 支援 span、事件;可輸出 JSON | 非同步 / 服務端 (Tokio) |
quick start:
fn main() {
env_logger::init(); // 或 tracing_subscriber::fmt().init();
info!("server started");
}
與 Debug
/ Derive 合體
#[derive(Debug)]
幫你自動印出結構體所有欄位,但現實專案常遇到「光印值還不夠」的需求──比方說要寫進 集中式日誌、要標記 trace id、或是想在印之前 過濾敏感資訊。這時就輪到「自訂 Debug + log 巨集」C8763
基本套路
- 在型別上
#[derive(Clone)]
(或其他想用的 derive) - 手寫
impl fmt::Debug
,在fmt()
內呼叫log::debug!
或tracing::event!
- 最後用
f.debug_struct()
把真正要輸出的欄位補上
use log::debug; // 或 use tracing::debug;
use std::fmt;
#[derive(Clone)]
pub struct Complex {
pub id: u32,
token: String, // 敏感,不想輸出全文
items: Vec<i32>,
}
impl fmt::Debug for Complex {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// 把狀態寫進log
debug!(target: "complex", "dbg id={}, items={}", self.id, self.items.len());
// 回傳給 {:?} 用的格式化字串
f.debug_struct("Complex")
.field("id", &self.id)
.field("token", &"***") // 遮蔽敏感資料
.field("item_count", &self.items.len())
.finish()
}
}
與 tracing
span/事件結合
如果專案用的是 tracing
,你可以在 Debug 期間打事件或新增 span:
use tracing::{debug, span, Level};
impl fmt::Debug for Complex {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let _span = span!(Level::DEBUG, "format_complex", id = self.id).entered();
debug!(items = self.items.len(), "format start");
f.debug_struct("Complex").field("id", &self.id).finish()
}
}
這樣每次 {:?}
呼叫都會自動帶一個 span,方便在分散式追蹤系統裡把「誰在格式化」串起來
減少重複:自訂 Derive 巨集
若專案裡有十幾個類似 Complex
的結構體──都要遮 token、都要 log item 數,一直手寫 Debug
也很痛。此時可以自己寫一個 derive 巨集(例如 #[derive(ComplexDebug)]
):
- 巨集讀取所有欄位 AST
- 自動生成
debug!(target = "...")
語句 - 為敏感欄位套用
"***"
或Option::map(|_| "***")
詳細巨集教學請回看「自訂 Derive 巨集」章節
serialize logging:把結構資料一次打包給日誌系統
文字日誌在眼前用 tail -f
看很方便,但到了 雲端集中式收集(ELK、Loki、Datadog、Stackdriver…)時,最好把日誌做成 結構化 JSON,方便搜尋與分析。下面示範三種等級的做法:
env_logger
× 手動 serde_json
最陽春、相容最廣。你自己先把資料序列化,再塞進字串:
use log::info;
use serde::Serialize;
#[derive(Serialize)]
struct Event { user_id: u32, action: String }
fn main() {
env_logger::init();
let e = Event { user_id: 7, action: "logout".into() };
info!("event={}", serde_json::to_string(&e).unwrap());
}
- 優點:完全不換 logger,CLI 工具也能看
- 缺點:字串裡嵌字串 → 需要 log pipeline 再 parse 一次
tracing_subscriber::fmt().json()
: 自帶 JSON
若已換 tracing
,直接把 formatter 切成 JSON:
use tracing::{info, subscriber::set_global_default, Level};
use tracing_subscriber::fmt;
fn main() {
let fmt_layer = fmt::layer().json();
let sub = tracing_subscriber::registry().with(fmt_layer);
set_global_default(sub).unwrap();
info!(user_id = 9, action = "purchase", amount = 120u32);
}
- 優點:全 JSON,欄位都是獨立 key
- 缺點:
tracing
生態才能享受;老專案須換 API
log4rs
+ JSON Appender — 檔案輪替+自動壓縮
# log4rs.yaml
appenders:
json_file:
kind: rolling_file
path: logs/app.json
encoder:
kind: json
policy:
kind: compound
trigger:
kind: size
limit: 10mb
roller:
kind: gzip
root:
level: info
appenders: [json_file]
fn main() {
log4rs::init_file("log4rs.yaml", Default::default()).unwrap();
log::info!(target: "auth", "login success");
}
- 優點:檔案自動滾動、壓縮,格式 JSON
- 缺點:設定檔 YAML 偏長,動態重載得自己加 watch
要在tracing
事件裡帶任何可序列化型別,只要serde::Serialize
再加?
格式化即可:event!(Level::INFO, payload = ?my_struct)
Derive 巨集最佳實踐與踩雷筆記
主題 | 建議做法 | 常見地雷 |
編譯效能 | 只對真正重複的 impl 用 Derive‑ 針對大型 proc‑macro 啟用 cargo --timings 觀察 build 時間 | 每個模組都套複雜巨集 → CI 編譯變烏龜 |
執行期效能 | 自動產生程式碼通常與手寫等效;Hot path 若要極致優化,可手寫 impl 覆蓋 | 相信 Derive 萬能,結果生成的 Eq /Hash 忽略資料分佈,導致 HashMap 退化 |
安全/隱私 | 在 Debug 遮蔽 token、密碼‑ Serialize 時用 skip_serializing_if 、with 自訂 | 把整個結構體 Debug 到 production log,洩密… |
記憶體 | 共享資料用 Rc/Arc ,必要時 Weak 打斷循環 | Rc 彼此參考 → 引用循環 memory leak |
可維護性 | 自訂 Derive 前先寫 1‑2 個手動 impl 當樣本‑ 用 cargo expand 檢視展開結果 | 一上來就寫巨集,沒搞懂目標行為,展開後看不懂也 debug 不動 |
測試 | 用 trybuild 驗證「能編譯」與「該失敗」兩類案例 | 沒測到編譯失敗路徑,壞輸入時報錯訊息慘不忍睹 |
看到以下徵兆,就該考慮「手寫 impl」而不是全交給 Derive:
- 需要跳過/重排欄位(尤其
Hash
、Eq
與排序相關) - 要做 I/O 或昂貴計算(Derive 只適合純資料)
- 巨集展開後看起來比手寫還長
結語
Derive 巨集就像你私人的 Rust 機器助理 : 自動撰寫重複 impl、維持程式碼一致、同時不犧牲型別安全。閱讀到這裡,你已經知道:
- 為什麼存在:減少手刻
Debug
、Clone
、Serialize
等樣板,讓腦力用在商業邏輯 - 怎麼運作:編譯期解析 AST ➜ 產生對應程式碼 ➜ 零執行期成本
- 何時自訂:當「公司內部規範」或「重複格式化需求」出現,一支自訂 Derive 巨集可省成百上千行樣板
- 整合場景:結合日誌框架、Serde、Rc/Arc…,Derive 巨集能串起整個基礎設施
- 踩雷預警:過度自動化 ≠ 萬能;遇到高效能路徑或需要特殊邏輯時,寧可手寫 impl
把重複交給編譯器,把創意留給自己 : 這就是 Derive 巨集存在的真正價值。下一次再看到一長串重複 impl,不妨停下來想:這行是不是該改成 #[derive(...)]
?
Comments ()