[Rust] 從 Derive 巨集 到 Trait 實作:深入理解與實戰

[Rust] 從 Derive 巨集 到 Trait 實作:深入理解與實戰
Photo by Jonathan Hislop on Unsplash

為什麼需要 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 巨集編譯期 自動產生標準實作,直接省下樣板時間,也降低維護成本

手刻 DebugCloneSerialize 花掉的工時,能拿去做更多事

基本使用方式

#[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

這樣就算你同時有 CatDogHamster… 只要它們實作 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

想同時保留兩份資料?兩條路:

  1. 借用(&s1): 省記憶體,但只能在借用生命週期內讀取/修改
  2. 複製(s1.clone()): 拿到全新所有權,互不干涉

Clone trait 就是複製這一條路的開關。當型別實作了 Clone,你就可以任意呼叫 .clone() 取得「第二份、獨立所有權」,不怕看到 value borrowed after move

不同場景下 .clone() 到底做了什麼?

欄位型別.clone() 行為內部成本典型例子
標註 Copy位元拷貝,就像 memcpy幾乎 0u32, bool, &T
擁有堆積資料深複製:重新配置、複製內容O(資料大小)String, Vec<T>
Rc<T> / Arc<T>共享:引用計數 +1O(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;然而在社群專案裡,開發者通常會額外引入:

  • syn — 解析輸入 AST(crates.io 上也叫 syn
  • quote — 以 quasi‑quote 語法產生輸出程式碼

這兩個工具箱都由社群大神 @dtolnay 維護,品質有口皆碑。只要加入 quoteproc‑macro2 會作為依賴自動被拉進來

[dependencies]
syn   = { version = "2", features = ["full"] } # 解析輸入 AST
quote = "1.0"                                   # 產生輸出程式碼
synquote 是絕大多數 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 → 不怕註解、空格、複雜 generic
  • quote!#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_loggertracing_subscriberlog4rs…)
  • 零額外成本:若編譯時整個程式沒啟用任何後端,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
log4rsYAML/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

基本套路

  1. 在型別上 #[derive(Clone)](或其他想用的 derive)
  2. 手寫 impl fmt::Debug,在 fmt() 內呼叫 log::debug!tracing::event!
  3. 最後用 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_ifwith 自訂把整個結構體 Debug 到 production log,洩密…
記憶體共享資料用 Rc/Arc,必要時 Weak 打斷循環Rc 彼此參考 → 引用循環 memory leak
可維護性自訂 Derive 前先寫 1‑2 個手動 impl 當樣本‑ 用 cargo expand 檢視展開結果一上來就寫巨集,沒搞懂目標行為,展開後看不懂也 debug 不動
測試trybuild 驗證「能編譯」與「該失敗」兩類案例沒測到編譯失敗路徑,壞輸入時報錯訊息慘不忍睹

看到以下徵兆,就該考慮「手寫 impl」而不是全交給 Derive:

  1. 需要跳過/重排欄位(尤其 HashEq 與排序相關)
  2. 要做 I/O 或昂貴計算(Derive 只適合純資料)
  3. 巨集展開後看起來比手寫還長

結語

Derive 巨集就像你私人的 Rust 機器助理 : 自動撰寫重複 impl、維持程式碼一致、同時不犧牲型別安全。閱讀到這裡,你已經知道:

  1. 為什麼存在:減少手刻 DebugCloneSerialize 等樣板,讓腦力用在商業邏輯
  2. 怎麼運作:編譯期解析 AST ➜ 產生對應程式碼 ➜ 零執行期成本
  3. 何時自訂:當「公司內部規範」或「重複格式化需求」出現,一支自訂 Derive 巨集可省成百上千行樣板
  4. 整合場景:結合日誌框架、Serde、Rc/Arc…,Derive 巨集能串起整個基礎設施
  5. 踩雷預警:過度自動化 ≠ 萬能;遇到高效能路徑或需要特殊邏輯時,寧可手寫 impl

把重複交給編譯器,把創意留給自己 : 這就是 Derive 巨集存在的真正價值。下一次再看到一長串重複 impl,不妨停下來想:這行是不是該改成 #[derive(...)]