深入理解 Rust 的 Pin:從問題到解法
涵蓋 Pin、Unpin、pin! macro、tokio::pin!,以及它們背後的設計邏輯。
一、問題的根源:自引用結構
要理解 Pin,得先知道它解決的是什麼問題。
在 Rust 中,「移動(move)」一個值,本質上是把它的記憶體內容複製到新位置,然後讓舊的失效。大多數情況下這完全沒問題,但有一種情況會爆炸——自引用結構(self-referential struct):
struct SelfRef {
data: String,
ptr: *const String, // 指向自己的 data!
}
如果這個結構被移動了,data 跑到新地址,但 ptr 還指著舊地址,就成了懸空指標(dangling pointer),記憶體安全直接玩完。
這聽起來像是很少見的邊緣案例,但其實非常普遍——async/await 產生的 Future 幾乎都是自引用結構:
async fn example() {
let data = vec![1, 2, 3];
some_await().await;
println!("{:?}", data); // Future 需要跨 await 持有對 data 的引用
}
編譯器為這個 async fn 生成的狀態機,會同時持有 data 和指向它的引用,這就是自引用。
二、Pin 的核心思想
Pin<P> 是一個包裝器,它對指標 P(如 &mut T 或 Box<T>)做出保證:
「被指向的值 T,在它被 drop 之前,不會再被移動。」
use std::pin::Pin;
// 普通的 Box,裡面的值可以被 move 出來
let b: Box<String> = Box::new("hello".into());
let s: String = *b; // ✅ 可以 move
// Pin 住的 Box,裡面的值無法被安全地 move 出來
let p: Pin<Box<String>> = Box::pin("hello".into());
// let s: String = *p; // ❌ 無法取得 &mut T 來 move
Pin 本身不是什麼魔法鎖,而是透過型別系統來防止你取得能夠 move 該值的 &mut T。
三、Unpin:大多數型別其實不在乎
Rust 有個 auto trait 叫 Unpin:
- 實作了
Unpin 的型別,即使被 Pin 住也可以安全地移動,因為它們不是自引用結構。
- 幾乎所有普通型別(
i32、String、Vec、…)都自動實作 Unpin。
async 產生的 Future 不實作 Unpin,因為它們可能是自引用的。
普通型別(String、Vec...)→ 自動 impl Unpin → Pin 對它形同虛設
async fn 的 Future → 不 impl Unpin → Pin 真正發揮作用
四、Pin 的兩個重要方法
// 如果 T: Unpin,可以安全拿到 &mut T
impl<P: DerefMut<Target: Unpin>> Pin<P> {
pub fn get_mut(self) -> &mut T
}
// 不管 T 是否 Unpin,都能繼續操作 Pin 住的值
impl<P: DerefMut> Pin<P> {
pub fn as_mut(&mut self) -> Pin<&mut T>
}
五、手動實作 Future 時的 Pin
Pin 最直接的使用場景之一是手動實作 Future:
impl Future for MyFuture {
type Output = i32;
// self 必須是 Pin<&mut Self>
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<i32> {
todo!()
}
}
六、pin! macro:把值 pin 在 Stack 上
Box::pin() 把值放在 heap 上,有分配成本。很多情況下我們希望把 Future pin 在 stack 上,這時就需要 pin! macro。
手動在 stack 上 pin 既麻煩又需要 unsafe:
let mut future = some_async_fn();
// 你必須保證之後不會 move future
let mut pinned = unsafe { Pin::new_unchecked(&mut future) };
pin! macro 讓這件事變得安全且方便。
tokio::pin!
tokio::pin! 接受變數名稱,直接在當前 scope 做 shadowing:
// tokio::pin! 大致展開成這樣:
macro_rules! pin {
($x:ident) => {
let mut $x = $x; // (1) move 到新綁定
let mut $x = unsafe { Pin::new_unchecked(&mut $x) }; // (2) shadow 成 Pin<&mut T>
}
}
使用方式:
use tokio::pin;
let future = some_async_fn();
pin!(future); // future 的型別現在是 Pin<&mut impl Future>
future.await;
步驟 (1) 把值 move 到新綁定,步驟 (2) 把名字蓋掉,改成 Pin<&mut T>。此後你只看得到 Pin 版本,原本可以 move 的那個綁定已不可見,借用規則自然防止你移動它。
std::pin::pin!(Rust 1.68+)
標準庫的版本接受任意表達式,靠的是另一個機制——Temporary Lifetime Extension(暫存值生命週期延伸):
use std::pin::pin;
let fut = pin!(some_async_fn());
展開後:
let fut = {
let mut _pinned = some_async_fn();
unsafe { Pin::new_unchecked(&mut _pinned) }
};
Rust 編譯器發現回傳值裡含有對 block 內部暫存值的引用,於是自動把 _pinned 的生命週期延伸到與 fut 相同的 scope,確保不會出現懸空指標。這是編譯器特殊規則,不是 unsafe hack。
兩者對比
|
tokio::pin! |
std::pin::pin! |
| 接受 |
變數名 ident |
任意表達式 expr |
| 機制 |
變數 shadowing |
Temporary lifetime extension |
| 語法 |
pin!(fut); |
let fut = pin!(expr); |
七、「move 進 pin! 不就跟 Pin 的目的矛盾?」
這是個很常見的疑惑。答案是:move 發生在 pin 之前,Pin 的承諾是從 pin 的那一刻之後才開始的。
Pin 的合約不是「這個值永遠不能移動」,而是:
「一旦被 pin 住之後,就不能再移動。」
async fn 產生的 Future,在第一次被 poll 之前,內部根本還沒有任何自引用,只是一個普通的初始狀態機。自引用是在 poll 的過程中才逐漸形成的:
[建立 Future] → move 進 _pinned → [Pin 住] → [第一次 poll] → 自引用開始形成
✅ 安全 ✅ 此後不再 move
所以 pin! 在 pin 之前 move 值是完全合法的,並不矛盾。
八、最重要的使用場景:select!
這是你最常需要 pin! 的地方。tokio::select! 需要能夠跨多次 poll 同一個 Future,每次都必須是同一個 pinned Future,不能 move 它:
use tokio::{pin, time};
use std::time::Duration;
#[tokio::main]
async fn main() {
let long_operation = do_something_slow();
pin!(long_operation);
let mut interval = time::interval(Duration::from_secs(1));
loop {
tokio::select! {
result = &mut long_operation => {
println!("完成:{:?}", result);
break;
}
_ = interval.tick() => {
println!("還在等待中...");
}
}
}
}
如果沒有 pin!,每次進入 loop 都會 move long_operation,之前的 poll 狀態就丟失了。
九、什麼時候用哪個?
| 情境 |
建議 |
| 現代專案,不依賴 Tokio |
std::pin::pin!(Rust 1.68+) |
| Tokio 專案 |
tokio::pin! 或 std::pin::pin! 皆可 |
| 需要跨執行緒傳遞 |
Box::pin()(heap 分配,Box 本身可 move) |
| 效能敏感,避免 heap 分配 |
pin!(stack) |
總結
- Pin 是 Rust 用型別系統做出的「這個值不能再被移動」的承諾,專為自引用結構(尤其是 async Future)而設計。
- Unpin 讓普通型別不受 Pin 影響,只有真正需要固定位置的型別(如 async Future)才會受到約束。
tokio::pin! 靠 shadowing,std::pin::pin! 靠 temporary lifetime extension,兩者都能安全地把值 pin 在 stack 上。
- Pin 的承諾從「pin 的那一刻」開始,pin 之前的 move 完全合法。
Rust 媒體元資料探索器:用 FFmpeg 剖析影音檔案與打造 TUI
這次的 rust-52-projects 系列帶來了 media-metadata-explorer——一個能深入剖析影音檔案內部結構的 CLI 工具。它透過 FFmpeg 的 libavformat 函式庫讀取媒體容器資訊,同時還做了一件很有趣的事:附帶了一個 libavformat-ffi companion crate,示範三種不同的 Rust FFI 呼叫方式(詳見之前的 FFI 比較文章)。
這篇文章會聚焦在工具本身的設計——三個實用的 CLI 子命令,以及用 crossterm 打造互動式 TUI 的過程。
專案功能概覽
media-metadata-explorer 提供三個子命令,對應三種使用情境:
inspect <file> [--json] 是最基本的單檔檢視模式。打開一個 .mkv 或 .mp3,它會列出容器格式名稱、總時長、檔案大小、位元率、容器層級的 metadata 標籤(如 title、encoder),以及每條串流的詳細資訊:影片串流會顯示解析度與幀率,音訊串流則顯示取樣率、聲道數、語言標籤等。加上 --json 旗標可以輸出結構化的 MediaReport,方便接到其他工具做二次處理。
catalog <dir> [--recursive] [--json] 是媒體庫整理利器。它會走訪目錄,篩選 mp4、mkv、mov、avi、webm、mp3、flac、wav 等常見副檔名,逐一探測後彙整成 CatalogReport:總時長、容器格式出現頻率(依次排序)、編解碼器統計,以及探測失敗的檔案清單。有個小細節:FFmpeg 回傳的格式名稱常常是 "mov,mp4,m4a,3gp,3g2,mj2" 這樣一串,程式只取第一個 token,讓統計結果更好讀。
tui <file> [--max-packets N] 則是整個專案最有趣的部分——互動式終端瀏覽器,下一節細說。
TUI 設計:用 crossterm 打造終端介面
tui 子命令會先讀取最多 N 個封包(預設 2000),在記憶體中建立一棵樹,再用 crossterm 啟動全螢幕互動介面。
樹狀結構分三個頂層節點:
- Container Info:格式名稱、時長、位元率、檔案大小、metadata 標籤
- Streams:每條串流展開後顯示編解碼器參數、串流標籤
- Packets:依串流索引分群,每個封包顯示 PTS、DTS、大小、是否為關鍵幀
導覽鍵支援 Vim 風格(j/k 上下、h/l 收合/展開)以及方向鍵、PageUp/PageDown、Home/End。Space 切換展開狀態,q 或 Esc 退出。顏色區分不同節點類型:cyan 是標題、magenta 是容器資訊、blue 是串流節點、green 是封包節點、yellow 是標籤值。
crossterm 的角色
crossterm 是 Rust 生態系裡最常見的跨平台終端控制函式庫,名字裡的 “cross” 就是這個意思——同一套 API 可以在 Windows、macOS、Linux 上跑,不用自己處理 ANSI escape code 或 Windows Console API 的差異。
它的 API 設計圍繞著「命令(command)」的概念,每個動作都是一個實作 Command trait 的型別:
use crossterm::{
cursor::{Hide, MoveTo, Show},
event::{self, Event, KeyCode},
style::{Color, Print, ResetColor, SetBackgroundColor, SetForegroundColor},
terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
};
執行命令有兩個 macro:execute! 會立刻寫入並 flush,queue! 只是把命令放進緩衝區。TUI 的 render 函式全程用 queue! 積攢所有繪製動作,最後才呼叫一次 out.flush()——這樣終端只會看到一個完整畫面,不會因為部分更新而閃爍:
queue!(out, MoveTo(0, 0), Clear(ClearType::All))?;
// ... 積攢所有列的繪製命令 ...
out.flush()?; // 一次送出
進入 TUI 之前,程式需要做三件準備工作:啟用 raw mode(讓按鍵立刻送達、不等換行、不回顯到螢幕)、切換到 alternate screen(保留原本的終端內容,退出後可以還原)、以及隱藏游標。這三步統一在 TerminalGuard::enter() 裡完成,Drop 時反向還原:
fn enter(out: &mut Stdout) -> Result<Self> {
terminal::enable_raw_mode()?;
execute!(out, EnterAlternateScreen, Hide)?;
Ok(Self)
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = terminal::disable_raw_mode();
let _ = execute!(stdout(), Show, LeaveAlternateScreen);
}
}
事件迴圈用 event::read() 阻塞等待下一個事件,Event::Key 處理按鍵,Event::Resize 處理視窗大小改變。Resize 事件不需要任何邏輯——只要設 dirty = true,下一個 frame 就會用新的 terminal::size() 重繪,自然就適應了新的寬高。
三層資料結構設計
TUI 的樹狀結構採用了一個乾淨的三層分離設計。
第一層:TreeNode(資料層)
struct TreeNode {
id: usize,
label: String,
children: Vec<TreeNode>,
}
這是一棵遞迴的 owned tree,children 直接擁有子節點,不用指標也不用 Rc。每個節點的 id 由建構時的 &mut usize counter 遞增分配,全域唯一。這棵樹只管結構,完全不知道展開/收合狀態。
第二層:TreeState(狀態層)
struct TreeState {
selected: usize, // 游標在第幾行(flat index)
scroll: usize, // viewport 捲動偏移
expanded: BTreeSet<usize>, // 哪些 node_id 目前是展開的
}
展開狀態完全分離在 BTreeSet<node_id> 裡,不碰 TreeNode 本身。要展開一個節點就 insert(id),要收合就 remove(id),操作極其簡單。
第三層:FlatLine(視圖層)
struct FlatLine {
node_id: usize,
label: String,
depth: usize,
has_children: bool,
expanded: bool,
}
每次重繪前,flatten_tree() 遍歷 TreeNode、搭配 expanded set,產生「目前可見行」的扁平 Vec<FlatLine>。如果一個節點的 id 不在 expanded 裡,它的子節點就不會被加入——視覺上就「消失」了:
fn flatten_tree(node: &TreeNode, expanded_ids: &BTreeSet<usize>, depth: usize, out: &mut Vec<FlatLine>) {
let expanded = expanded_ids.contains(&node.id);
out.push(FlatLine { node_id: node.id, label: ..., depth, has_children: ..., expanded });
if has_children && expanded {
for child in &node.children {
flatten_tree(child, expanded_ids, depth + 1, out);
}
}
}
這是典型的 document-view 分離:TreeNode 是不可變的文件結構,一次建立後不再修改;TreeState.expanded 是可變的互動狀態;Vec<FlatLine> 則是每個 render frame 重新推導的視圖,不需要維護反向指標。selected 和 scroll 追蹤的是 flat list 的 index,不是 node_id,因為鍵盤導覽只關心「螢幕上第幾行」。
實作上有幾個設計值得一提:
Dirty flag 避免不必要重繪。 TUI 的事件迴圈維護一個 dirty: bool,只有真正改變畫面狀態的按鍵(移動選取、展開/收合、視窗 resize)才設 dirty = true,下一個迴圈才重繪。這比每個 tick 無條件清屏再重畫要省 CPU,也避免畫面閃爍。
TerminalGuard 確保終端狀態復原。 進入 TUI 前需要切換到 alternate screen、啟用 raw mode、隱藏游標。為了確保不論正常退出還是 panic,終端都能恢復正常,程式定義了一個零大小的 TerminalGuard struct,在 Drop 裡執行清理動作(disable raw mode、show cursor、leave alternate screen)。這是 Rust RAII 慣用法的典型應用。
Parent 節點搜尋是 O(n) 的線性掃描。 當使用者按 h 要跳到父節點時,程式從當前行往上掃,找第一個 depth == current_depth - 1 的行。雖然理論上是 O(n),但實際上樹的深度有限、總行數也不多,這個簡單實作完全夠用,不需要額外維護父節點指標。
開發心得與踩坑記錄
BTreeMap 讓輸出穩定有序。 容器和串流的 metadata 標籤改用 BTreeMap<String, String> 而非 HashMap,好處是不論 JSON 輸出還是 TUI 顯示,標籤永遠按字母順序排列,不會因 hash 隨機性產生不穩定的輸出。
FFmpeg 的「不知道」值要小心處理。 FFmpeg 大量使用 0 或 -1 表示「未知/不可用」。程式定義了 to_u64(i64) -> Option<u64> 和 to_u32(i32) -> Option<u32> 兩個轉換幫手,把 0 和負值都變成 None,對外 API 就能乾淨地用 Option 表達可選欄位,不用每次都檢查魔法數值。
EOF 的位元運算。 FFmpeg 用負數的四字元碼表示 EOF 錯誤(AVERROR_EOF = -(('E' | 'O'<<8 | 'F'<<16 | ' '<<24)))。Rust 端重建這個常數的方式和 C 端一樣,不依賴外部匯入的常數,讓 EOF 和真正的讀取錯誤可以明確區分。第一次看到這個寫法還楞了一下,仔細想才明白這是 FFmpeg 的 FOURCC 慣例。
整體而言,這個專案把「實用工具」和「教學示範」結合得很好——media-metadata-explorer 本身就是個可以日常使用的媒體檔案檢視器,而 libavformat-ffi companion crate 則是學習 Rust FFI 的絕佳參考資料。
TileSplit WASM:把 Ultra HDR 圖片切割搬到瀏覽器
上一篇介紹了 tilesplit CLI,一個用 Rust 寫的工具,可以在切割圖片的同時保留 Ultra HDR 資訊。但要用 CLI 切個照片還得先裝 Rust 工具鏈… 不如直接在瀏覽器裡搞定?
這篇就來聊聊怎麼把 tilesplit 移植到 WebAssembly,讓任何人打開網頁就能用。
先玩再說
使用方式:拖一張 JPEG 進去(支援 3:2 或 16:10 比例),調整品質後按 Split。如果是 Ultra HDR 照片,會自動偵測並顯示 HDR 標籤。切完的圖可以直接下載。
從 CLI 到 WASM:關鍵差異
把 tilesplit 搬到瀏覽器不是改個編譯目標就完事的。最大的挑戰在於依賴庫的替換。
CLI 版本用了兩個 C/C++ FFI 的 crate:
ultrahdr-rs:Google libultrahdr 的封裝,處理 Ultra HDR 的編解碼和容器組裝
jpegli-rs:Google jpegli 的封裝,高品質 JPEG 編解碼器,支援讀寫 Gain Map
問題是:WASM 沒辦法呼叫 C FFI。這兩個 crate 都需要編譯 C/C++ 原始碼,在 wasm32-unknown-unknown 目標下根本編不過。
WASM 版本的替代方案:
| 功能 |
CLI 版本 |
WASM 版本 |
| JPEG 編解碼 |
jpegli-rs (C FFI) |
image crate (純 Rust) |
| Ultra HDR metadata |
ultrahdr-rs (C FFI) |
ultrahdr-core (純 Rust) |
| 容器組裝 |
ultrahdr-rs 的 add_gainmap() |
手動組裝 MPF 格式 |
ultrahdr-core 是個輕量的純 Rust crate,只做 metadata 解析和 XMP 生成,不碰任何 C 程式碼——正好適合 WASM。而 image crate 的 JPEG 功能雖然沒有 jpegli 那麼強,但對我們的需求來說夠用了。
最麻煩的是容器組裝。CLI 版本靠 jpegli-rs 的 .add_gainmap() 一行搞定,但 WASM 版本得自己手動把 XMP marker、MPF header、Gain Map JPEG 按照正確的格式塞回主圖裡。這部分後面會詳細說明。
WASM API 設計
WASM 模組只暴露三個函式給 JavaScript:
#[wasm_bindgen]
pub fn validate_image(data: &[u8]) -> Result<JsValue, JsValue> {
console_error_panic_hook::set_once();
let img = image::load_from_memory(data)
.map_err(|e| JsValue::from_str(&format!("Failed to decode image: {e}")))?;
let (width, height) = (img.width(), img.height());
let is_ultra_hdr = detect_ultrahdr(data).is_some();
let (left, _) = compute_split_rectangles(width, height)
.map_err(|e| JsValue::from_str(&e))?;
let info = ImageInfo { width, height, /* ... */ };
serde_wasm_bindgen::to_value(&info)
.map_err(|e| JsValue::from_str(&format!("Serialization error: {e}")))
}
#[wasm_bindgen]
pub fn split_left(data: &[u8], quality: u8) -> Result<Vec<u8>, JsValue> {
console_error_panic_hook::set_once();
split_tile(data, quality, Side::Left).map_err(|e| JsValue::from_str(&e))
}
#[wasm_bindgen]
pub fn split_right(data: &[u8], quality: u8) -> Result<Vec<u8>, JsValue> {
console_error_panic_hook::set_once();
split_tile(data, quality, Side::Right).map_err(|e| JsValue::from_str(&e))
}
回傳給 JS 的 ImageInfo 用 serde 序列化成 JS object:
#[derive(Serialize)]
struct ImageInfo {
width: u32,
height: u32,
aspect: String,
#[serde(rename = "isUltraHdr")]
is_ultra_hdr: bool,
#[serde(rename = "tileWidth")]
tile_width: u32,
#[serde(rename = "tileHeight")]
tile_height: u32,
}
資料流很直覺:JS 透過 FileReader 讀取使用者選的檔案,轉成 Uint8Array 傳進 WASM,WASM 處理完回傳 Vec<u8>(自動轉成 Uint8Array),JS 再包成 Blob URL 給 <img> 顯示和下載。
Ultra HDR 偵測
WASM 版本的 Ultra HDR 偵測是直接解析 JPEG 的二進制結構,不靠任何外部庫的 JPEG 解碼器:
fn detect_ultrahdr(data: &[u8]) -> Option<UltraHdrData> {
// 1. 從 JPEG 的 APP1 marker 裡撈出 XMP metadata
let xmp = extract_xmp_from_jpeg_bytes(data)?;
// 2. 解析 XMP 得到 GainMapMetadata
let (mut metadata, _) = ultrahdr_core::metadata::xmp::parse_xmp(&xmp).ok()?;
// 3. 從 MPF (Multi-Picture Format) 裡提取 Gain Map JPEG
let gainmap_jpeg = extract_gainmap_from_mpf(data)?;
// 主圖的 metadata 可能不完整,試試從 Gain Map 自己的 XMP 補齊
if metadata_looks_default_or_incomplete(&metadata) {
if let Some(gm_xmp) = extract_xmp_from_jpeg_bytes(&gainmap_jpeg) {
if let Ok((mut gm_meta, _)) = ultrahdr_core::metadata::xmp::parse_xmp(&gm_xmp) {
apply_lenient_xmp_overrides(&gm_xmp, &mut gm_meta);
if !metadata_looks_default_or_incomplete(&gm_meta) {
metadata = gm_meta;
}
}
}
}
apply_lenient_xmp_overrides(&xmp, &mut metadata);
Some(UltraHdrData { metadata, gainmap_jpeg })
}
跟 CLI 版本比起來,最大的差異是 XMP 提取。CLI 版本靠 jpegli 解碼器順便把 XMP 和 Gain Map 從 JPEG extras 裡讀出來。WASM 版本沒有 jpegli,只好自己掃描 JPEG marker——找 0xFFE1 (APP1) 拿 XMP,再用 ultrahdr-core 的 MPF parser 找 Gain Map 的位置。
手動組裝 Ultra HDR 容器
這是 WASM 移植裡最複雜的部分。CLI 版本一行 .add_gainmap() 就搞定的事,在 WASM 版本得手動處理 MPF (Multi-Picture Format) 的二進制結構。
一張 Ultra HDR JPEG 的結構大概長這樣:
┌─────────────────────────────────┐
│ SOI (0xFFD8) │
│ APP1 - XMP metadata │ ← 告訴系統「我是 Ultra HDR」
│ APP2 - MPF header │ ← 告訴系統「Gain Map 在哪裡」
│ ... 正常的 JPEG 資料 ... │
│ EOI (0xFFD9) │
├─────────────────────────────────┤
│ Gain Map JPEG (完整的第二張圖) │ ← 亮度增益資訊
└─────────────────────────────────┘
assemble_ultrahdr_tile 函式負責把這些東西拼在一起:
fn assemble_ultrahdr_tile(
sdr_jpeg: &[u8],
gainmap_jpeg: &[u8],
metadata: &GainMapMetadata,
) -> Result<Vec<u8>, String> {
// 1. 在 Gain Map JPEG 裡嵌入 XMP metadata
let gainmap_xmp = generate_gainmap_xmp(metadata);
let gainmap_jpeg_with_xmp = embed_xmp_in_jpeg(gainmap_jpeg, &gainmap_xmp);
// 2. 產生主圖的 XMP APP1 marker(包含完整參數 + Container directory)
let xmp = generate_primary_xmp(metadata, gainmap_jpeg_with_xmp.len());
let xmp_marker = ultrahdr_core::metadata::xmp::create_xmp_app1_marker(&xmp);
// 3. 把 XMP 插到 SOI 後面
let mut primary_with_xmp = Vec::with_capacity(sdr_jpeg.len() + xmp_marker.len());
primary_with_xmp.extend_from_slice(&sdr_jpeg[..2]); // SOI
primary_with_xmp.extend_from_slice(&xmp_marker);
primary_with_xmp.extend_from_slice(&sdr_jpeg[2..]);
// 4. 找到插入 MPF APP2 的位置(在 APP0/APP1 之後)
let insert_pos = find_mpf_insert_position(&primary_with_xmp)?;
// 5. 計算 MPF header(需要知道最終的 primary 大小,所以先算一次)
let gm_len = gainmap_jpeg_with_xmp.len() as u32;
let placeholder_mpf = create_mpf_app2(u32::MAX, &[gm_len], insert_pos);
let primary_final_size = (primary_with_xmp.len() + placeholder_mpf.len()) as u32;
let mpf_header = create_mpf_app2(primary_final_size, &[gm_len], insert_pos);
// 6. 組裝最終輸出
let mut output = Vec::with_capacity(
primary_with_xmp.len() + mpf_header.len() + gainmap_jpeg_with_xmp.len()
);
output.extend_from_slice(&primary_with_xmp[..insert_pos]);
output.extend_from_slice(&mpf_header);
output.extend_from_slice(&primary_with_xmp[insert_pos..]);
output.extend_from_slice(&gainmap_jpeg_with_xmp);
Ok(output)
}
MPF header 裡面是個迷你的 TIFF IFD 結構,記錄了每張圖片的大小和偏移量。這裡有個雞生蛋的問題:MPF header 裡需要填 primary 的最終大小,但 primary 的大小又取決於 MPF header 有多大。解法是先用 placeholder 算出 MPF 的固定大小,再用正確的數值重新生成。
WASM 體積優化
WASM 版本最終編出來的 .wasm 檔案大約 286 KB,對一個包含完整 JPEG 編解碼和 Ultra HDR 處理的模組來說算很小了。主要靠幾個手段:
[profile.release]
opt-level = "s" # 優化體積而非速度
lto = true # 跨 crate 的 Link-Time Optimization
加上 image crate 只啟用 jpeg feature,不拉進 PNG、GIF 等不需要的格式:
[dependencies]
image = { version = "0.25.5", default-features = false, features = ["jpeg"] }
default-features = false 很重要——image crate 預設會拉進一大堆圖片格式的支援,對 WASM 體積影響很大。
JS 端的整合
前端部分很簡單,用原生的 ES module 載入 WASM:
import init, { validate_image, split_left, split_right } from './tilesplit_wasm.js';
async function run() {
await init(); // 載入並初始化 WASM 模組
console.log('TileSplit WASM loaded');
}
run();
wasm-pack build --target web 產生的 JS glue code 會自動處理 WASM 的載入和記憶體管理。init() 內部用 WebAssembly.instantiateStreaming 做串流載入,瀏覽器可以在下載 WASM 的同時開始編譯,體驗很流暢。
圖片處理的流程:
// 使用者選擇檔案後
const reader = new FileReader();
reader.onload = (e) => {
const data = new Uint8Array(e.target.result);
const info = validate_image(data); // WASM 驗證
// 顯示圖片資訊...
};
reader.readAsArrayBuffer(file);
// 按下 Split 後
const leftBytes = split_left(currentData, quality); // WASM 切割
const rightBytes = split_right(currentData, quality);
const leftBlob = new Blob([leftBytes], { type: 'image/jpeg' });
previewLeft.src = URL.createObjectURL(leftBlob); // 顯示預覽
整個過程都在瀏覽器本地完成,圖片不會上傳到任何伺服器。
小結
Ultra HDR 的「正確」其實散落在規格的各個角落。光看 ISO 21496-1 規格書不夠,得拿 Lightroom、Google Photos 等軟體的實際輸出來對照,才能確定哪些欄位是必要的、viewer 實際上怎麼解析。
結語
把 Rust CLI 工具移植到 WASM 最大的收穫是:降低了使用門檻。不需要安裝任何東西,打開網頁就能用。對於像 tilesplit 這種偶爾才用一次的小工具,WASM 版本可能比 CLI 更實用。
當然也有取捨——WASM 版本用的 image crate JPEG 編碼器品質沒有 jpegli 好,處理速度也稍慢一些。但對於「切一張照片發 IG」這個使用情境來說,完全夠用。
完整程式碼在 GitHub 上。
TileSplit:用 Rust 打造保留 Ultra HDR 資訊的圖片切割工具
每次要把橫幅風景照發 Instagram,都得打開 Lightroom 手動裁切——調整裁切框、對齊、最後再匯出。於是我就寫了 tilesplit,一個用 Rust 打造的小工具,專門在分割圖片的同時保留 Ultra HDR 資訊。這篇文章來聊聊 Ultra HDR 是什麼,以及這個工具背後的技術細節。
為什麼需要 TileSplit?
我最主要的使用情境是把 3:2 的橫幅風景照發到 Instagram 上。
雖然 Instagram 現在允許以原始比例上傳照片,但橫幅照片在動態牆上顯示得比直幅照片小很多,視覺衝擊力大打折扣。所以現在 IG 很流行「無縫滑動」的發法:把一張橫幅照片切成兩張(或更多)直幅圖,使用者左右滑的時候會有連續的視覺體驗,同時每張圖在動態牆上都能佔滿版面。但如果這張照片是 Ultra HDR 格式,用一般工具切完,HDR 就沒了——原本在 HDR 螢幕上那種亮眼的層次感,瞬間變得平淡。
原因很簡單:大多數工具在處理圖片時,根本不知道 Gain Map 的存在。它們只保留了 SDR 的部分,Gain Map 直接被丟掉了。
什麼是 Ultra HDR?
Ultra HDR 是 Google 在 Android 14 引入的圖片格式,本質上還是 JPEG,但多藏了一些東西。它最厲害的地方在於向後兼容。
傳統的 HDR 圖片(像 10-bit HEIF 或 AVIF)在舊手機或不支援 HDR 的螢幕上,常常會顏色怪怪的或是過暗。Ultra HDR 用了一個很聰明的方法解決這個問題:
- SDR 基礎圖片:本質上就是一張普通的 JPEG,任何軟體都能打開、正常顯示。
- Gain Map(增益圖):在 JPEG 裡面偷偷塞了一張「變亮地圖」——告訴系統每個區域可以變多亮。
- HDR 重建:在支援 HDR 的螢幕上,系統把 SDR 圖片和 Gain Map 合在一起算,就能還原出更亮的高光和更豐富的細節。
同一張圖片,舊手機上正常顯示,新手機上閃閃發光。
TileSplit 是如何工作的?
核心目標很單純:切圖的同時,把 Ultra HDR 的資料完整保留下來。整個流程大概分四步:
1. 偵測與解析
程式先用 jpegli 去讀輸入檔案,檢查 JPEG 裡有沒有 XMP Metadata 和 Gain Map。如果 jpegli 讀不出來(不同手機產生的 Ultra HDR 格式會有差異),就退回去用 Google 原生的 ultrahdr 解碼器做更深入的解析。
2. 雙層解碼
確認是 Ultra HDR 之後,程式會把圖片拆成兩層:SDR 主圖(標準 RGB 像素)和 Gain Map(亮度增益資料)。同時提取 Metadata(像 hdrgm:GainMapMin、hdrgm:Gamma 等),這些參數決定了 SDR 和 Gain Map 怎麼合成 HDR。
來看看程式碼長什麼樣子(省略了 debug logging):
fn try_extract_ultrahdr_with_jpegli(
source_bytes: &[u8],
debug: bool,
) -> Option<(ultrahdr::GainMapMetadata, RawImage, GainMap, Option<Vec<u8>>)> {
// 用 catch_unwind 保護 jpegli 解碼——某些損壞的 JPEG 會造成 panic
let decoded = match catch_unwind_quiet(AssertUnwindSafe(|| {
JpegDecoder::new()
.preserve(PreserveConfig::default())
.output_format(JpegPixelFormat::Rgb)
.decode(source_bytes)
})) {
Ok(Ok(image)) => image,
_ => return None,
};
// 從 JPEG extras 裡挖出 XMP metadata 和 Gain Map
let extras = decoded.extras()?;
let xmp = extras.xmp()?;
let (metadata, _) = ultrahdr::metadata::xmp::parse_xmp(xmp).ok()?;
let gainmap = match extras.gainmap() {
Some(gainmap) => gainmap.to_vec(),
// extras 裡找不到的話,試試 MPF(Multi-Picture Format)
None => extract_gainmap_jpeg_from_mpf(source_bytes, debug)?,
};
// 有些手機的 XMP metadata 不完整,嘗試從 Gain Map 本身的 XMP 補齊
let metadata = if metadata_looks_default_or_incomplete(&metadata) {
extract_metadata_from_gainmap_xmp(&gainmap, debug).unwrap_or(metadata)
} else {
metadata
};
let icc_profile = extras.icc_profile().map(|icc| icc.to_vec());
// 把像素資料包成 RawImage 結構
let sdr = RawImage::from_data(
decoded.width, decoded.height,
PixelFormat::Rgb8, ColorGamut::Bt709, ColorTransfer::Srgb,
decoded.data,
).ok()?;
// Gain Map 的 JPEG 也需要解碼成像素資料
let gainmap = decode_gainmap_jpeg(&gainmap, ColorGamut::Bt709).ok()?;
Some((metadata, sdr, gainmap, icc_profile))
}
幾個值得注意的設計:
catch_unwind_quiet:jpegli 是 C 庫的封裝,遇到格式損壞可能會 panic,用 catch_unwind 接住避免整個程式崩潰
- MPF fallback:有些手機(特別是早期支援 Ultra HDR 的機型)把 Gain Map 存在 JPEG 的 Multi-Picture Format 區段而非 Extras 裡,需要額外處理
- Metadata 補齊:某些裝置產生的 XMP metadata 不完整,程式會嘗試從 Gain Map 自身的 XMP 中提取更完整的參數
3. 幾何映射與分割
根據使用者要的比例(像 4:5 for Instagram),程式算出 SDR 主圖的裁剪區域。
這裡有個坑:Gain Map 的解析度通常比主圖小(常見是 1/4 大小),所以不能直接拿主圖的座標去切 Gain Map。需要根據兩者的比例做精確映射,不然切出來的 HDR 重建會位置偏移,高光對不準。
fn map_rect_to_gainmap(
rect: Rect,
source_width: u32,
source_height: u32,
gainmap_width: u32,
gainmap_height: u32,
) -> Rect {
// 用整數運算避免浮點誤差
let x0 = (rect.x as u64 * gainmap_width as u64 / source_width as u64) as u32;
let y0 = (rect.y as u64 * gainmap_height as u64 / source_height as u64) as u32;
let right_edge = rect.x.saturating_add(rect.width);
let bottom_edge = rect.y.saturating_add(rect.height);
// 右下角用 ceiling division,確保不會少切到像素
let mut x1 = div_ceil_u64(
right_edge as u64 * gainmap_width as u64,
source_width as u64,
) as u32;
let mut y1 = div_ceil_u64(
bottom_edge as u64 * gainmap_height as u64,
source_height as u64,
) as u32;
// 邊界保護
x1 = x1.clamp(0, gainmap_width);
y1 = y1.clamp(0, gainmap_height);
if x1 <= x0 {
x1 = (x0 + 1).min(gainmap_width);
}
if y1 <= y0 {
y1 = (y0 + 1).min(gainmap_height);
}
Rect {
x: x0,
y: y0,
width: x1.saturating_sub(x0),
height: y1.saturating_sub(y0),
}
}
4. 編碼與重組
最後一步是把切好的東西重新包裝成合法的 Ultra HDR JPEG。流程是:
- 把裁剪後的 Gain Map 編碼成獨立的 JPEG,並嵌入 XMP metadata
- 用
jpegli 編碼 SDR 主圖,保留 ICC Color Profile
- 手動組裝 Ultra HDR 容器:插入 XMP APP1 和 MPF APP2,再接上 Gain Map JPEG
為什麼不用 jpegli 的 .add_gainmap() 一行搞定?因為它生成的 MPF 偏移量是錯的(用了絕對偏移而非 MPF 規格要求的相對偏移),導致 viewer 找不到 Gain Map,HDR 就失效了。所以得自己手動組裝容器。
fn encode_ultrahdr_tile(
sdr_tile: RawImage,
gainmap_tile: GainMap,
metadata: &ultrahdr::GainMapMetadata,
source_icc_profile: Option<&[u8]>,
) -> Result<Vec<u8>, i32> {
// 1. Gain Map 編碼成 JPEG,同時嵌入 XMP metadata
let gainmap_jpeg = encode_gainmap_jpeg(&gainmap_tile, metadata)?;
// 2. 編碼 SDR 主圖(不含 Gain Map,容器組裝交給 assemble_ultrahdr_jpeg)
let mut config = EncoderConfig::ycbcr(SDR_TILE_JPEG_QUALITY, ChromaSubsampling::None);
if let Some(icc_profile) = source_icc_profile
&& !icc_profile.is_empty()
{
config = config.icc_profile(icc_profile.to_vec());
}
let (pixel_layout, pixel_data) = match sdr_tile.format {
PixelFormat::Rgb8 => (PixelLayout::Rgb8Srgb, Cow::Borrowed(&sdr_tile.data)),
PixelFormat::Rgba8 => { /* RGBA → RGB 轉換 */ }
_ => return Err(EXIT_IO_ERROR),
};
let sdr_jpeg = /* jpegli encode */;
// 3. 手動組裝 Ultra HDR 容器
assemble_ultrahdr_jpeg(&sdr_jpeg, &gainmap_jpeg, metadata)
}
assemble_ultrahdr_jpeg 做的事:把 XMP APP1 和 MPF APP2 插到主圖裡,再把 Gain Map JPEG 接在主圖後面。MPF header 裡的偏移量必須相對於 TIFF header(mpf_marker_offset + 8),這個細節後面的「踩坑」章節會詳細說明。
這樣輸出的每一張切片都是完整的 Ultra HDR 檔案,可以在支援的設備上獨立顯示 HDR 效果。
關於重新編碼與畫質
你可能會問:「重新編碼會不會讓照片變糟?」
會。但可以把損失降到很低。
JPEG 每次重新編碼都會因為 DCT 量化而失去一些細節。為了盡量保住畫質,tilesplit 做了幾件事:
- 用 Jpegli 編碼器:Google 開源的
jpegli 在同樣檔案大小下,畫質比傳統 libjpeg 好不少
- 高畫質設定:預設 Quality 100,在 Instagram 上基本看不出和原圖的差異
- 避免不必要的轉換:直接操作解碼後的原始像素,不做多餘的格式轉換
技術上不是「絕對無損」——要做到無損得用 jpegtran 那種直接操作 DCT 係數的方式,但那要同時處理 Ultra HDR 的 Metadata 封裝會極其困難。實際用起來,tilesplit 產出的畫質對攝影愛好者來說完全夠用。
關鍵相依庫
tilesplit 主要靠這三個 Rust crate:
image:Rust 生態系最常用的圖像處理庫,負責基礎的裁剪、格式轉換和非 HDR 圖片的處理。
jpegli-rs:Google jpegli 的 Rust 封裝,壓縮率比 libjpeg 好,而且支援 JPEG Extras——可以讀寫嵌在 JPEG 裡的 XMP Metadata 和 Gain Map。
ultrahdr-rs:Google libultrahdr 的封裝,包含 Ultra HDR 的核心邏輯(Metadata 解析、SDR/HDR 轉換公式等)。遇到格式比較特殊的圖片時,靠它來兜底。它的純 Rust 子 crate ultrahdr-core 也用來生成 XMP APP1 marker。
為什麼選擇 Rust?
Rust 剛好適合這種場景:記憶體安全(不用擔心 segfault)、執行效率接近 C++、而且 FFI 很方便——libultrahdr 和 jpegli 都是 C/C++ 的庫,Rust 可以直接呼叫再包一層安全介面。加上 Result 型別強制處理所有錯誤情況,遇到檔案損壞或 metadata 缺失,不會直接 crash,而是給出有意義的錯誤訊息。
安裝與使用
如果你也有類似需求,可以直接從 GitHub 安裝:
cargo install --git https://github.com/p47t/rust-52-projects --branch tilesplit
用法很簡單:
# 基本用法,自動生成 photo-left.jpg 和 photo-right.jpg
tilesplit --input photo.jpg
# 指定輸出路徑
tilesplit --input photo.jpg --left-output left.jpg --right-output right.jpg
原始碼在 rust-52-projects/tilesplit 上。
踩過的坑:亮度為什麼不對?
做完初版後,切出來的圖在 HDR 螢幕上看起來比原圖暗很多。花了不少時間 debug 才搞清楚,「把 Gain Map 塞進去」跟「讓 viewer 正確解讀」是兩回事。以下是踩過的幾個主要坑:
MPF 偏移量的基準點
Ultra HDR JPEG 是兩張圖接在一起——主圖和 Gain Map。MPF (Multi-Picture Format) APP2 marker 裡有個 TIFF IFD 結構,記錄 Gain Map 的位置。關鍵是:偏移量是相對於 MPF 裡的 TIFF header,不是檔案開頭。
MPF APP2 的二進制結構:
FF E2 ← marker (2 bytes)
xx xx ← length (2 bytes)
4D 50 46 00 ← "MPF\0" (4 bytes)
4D 4D 00 2A ← TIFF header ← 偏移量從這裡算起
TIFF header 在 MPF marker 起始位置 +8 bytes 的地方。一開始用了 jpegli 的 .add_gainmap() API,發現它寫入的是絕對偏移而非相對偏移,viewer 根本找不到 Gain Map。改成手動組裝容器才修好。
主圖和 Gain Map 都要有 XMP
原本以為只要主圖的 XMP 有完整參數就行。結果拿 Lightroom 匯出的 Ultra HDR 對比——主圖的 XMP 只有 hdrgm:Version="1.0",完整的 GainMapMin、GainMapMax、Gamma 等參數全在 Gain Map JPEG 自己的 XMP 裡。
這表示很多 viewer(包括 Android 的 Photos 和 Chrome)是優先從 Gain Map 的 XMP 讀取參數的。Gain Map JPEG 如果沒嵌入 XMP,viewer 可能拿不到正確的 boost 參數,畫面就是暗的。
rdf:Seq 才是標準格式
Gain Map 參數是 per-channel 的(RGB 各通道可以不同),在 XMP 裡有兩種表達方式:
<!-- 逗號分隔(有些 viewer 不認) -->
<rdf:Description hdrgm:GainMapMax="-0.699, -0.615, -0.603" />
<!-- rdf:Seq(標準,所有 viewer 都認) -->
<hdrgm:GainMapMax>
<rdf:Seq>
<rdf:li>-0.699220</rdf:li>
<rdf:li>-0.614892</rdf:li>
<rdf:li>-0.603440</rdf:li>
</rdf:Seq>
</hdrgm:GainMapMax>
ultrahdr-rs crate 的 generate_xmp() 用的是逗號分隔格式,實測某些 viewer 會 fallback 到預設值。改成 rdf:Seq 格式後就正常了。
Gain Map 品質不能省
Gain Map 每個像素代表 HDR boost 強度,套用公式大致是 boost = max_boost^(pixel/255)。因為是指數關係,JPEG 壓縮的量化誤差會被指數放大——pixel 值差個 5,亮度可能差 5% 以上。所以 Gain Map 一律用 quality 100 編碼,不管使用者設的 SDR 品質是多少。
教訓
Ultra HDR 的正確性散落在 MPF 規格、XMP schema、和各家 viewer 的實作細節裡。光看 ISO 21496-1 不夠,得拿 Lightroom、Google Photos 等軟體的實際輸出做二進制比對,才能確認哪些欄位是必要的、viewer 怎麼解析。
結語
HDR 螢幕越來越普及,Ultra HDR 這種格式以後只會更常見。現有的圖像處理工具大多還沒跟上,tilesplit 算是填補了這個小空白。如果你也有保留 HDR 切圖的需求,希望這個工具能幫到你。
Rust Proc Macro:builder-derive 實戰
最近在看 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 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:
- 先確認是
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>。
也就是你寫:
而不是:
這讓 API 更順手。不過 tradeoff 是:目前 API 不能顯式設定 None,只能「不呼叫 setter」來得到 None。
第 4 步:用 quote 生成程式碼(generate.rs)
generate.rs 裡把生成工作拆成四塊,這個切法很值得學:
generate_builder_struct
generate_builder_constructor
generate_setter_methods
generate_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。」
專案連結:rust-52-projects/builder-derive