vatt'ghern jaskier's ballads
本文 9 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

在 Rust 裡寫一個「我有某個實作了 Trait<B> 的型別,但 S 和 B 我都不想讓呼叫端看到」的 box,看起來只是把兩個型別參數塞進 trait object 而已。 編譯器會給你一句冰冷的回覆——「the value of the associated types `S` and `B` must be specified」——然後你才發現 dyn-compatibility 規則並不是隨便寫的限制,它讓你只能一次抹掉一個 existential,剩下那個得自己想辦法處理。

Erasing Existentials——Rust 裡四種抹掉 existential 的辦法

設你有一個 trait Generic<B>,被同一個型別 i32 分別實作了 Generic<f32>Generic<f64>, 內部唯一的方法是 fn name(&self) -> &'static str。 現在的問題是:能不能把「某個實作了 Generic<B> 的型別」整包塞進 Box,讓使用者不需要寫出 S 也不需要寫出 B? 這是 type theory 裡的 ∃s,b.Trait(s,b)——兩個 existential variable 同時被量詞綁住——直譯到 Rust 的問法。

這個問題的根源不在語法、不在型別推導,而在於 dyn Trait 的設計合約。 每一個 dyn Trait 物件背後是一張 vtable:一個指標表記錄了該物件的具體型別在這個 trait 上所有方法的實際位址。 要做到「同一個 Box<dyn Trait> 可以容納兩種底層型別」,這兩種底層型別在 trait 上的方法簽章必須對齊到同一張 vtable 形狀; 而當你的 trait 有 associated type,每個實作者填的 associated type 各自不同,vtable 的 self-type 就不一樣,根本對不齊。 這就是 dyn-compatibility 規則拒絕你的根本原因。

wolfgirl.dev 在五月二十日的 post 裡把這個問題拆成四條救命路線: 把 existential 翻回 universal 用泛型參數承擔(for-exists conversion)、 把 existential 藏進 associated type 再用 PhantomData 把缺的型別 carry 進去、 手刻一個只在表面層暴露 monomorphic interface 的 wrapper trait、 或乾脆把所有輸入輸出都換成 Box<dyn Any> 走純 runtime 路線。 四條路徑各自繞過 dyn-compatibility 的不同部分,付出的代價也各自落在編譯期、運行期、或介面複雜度三個維度上。

這篇 deep-story 把這四個方案沿著六個維度排開。 每一個維度都會回到一段具體的 Rust code——不是把 type-theory 翻譯成 emoji,而是讓你看到 vtable 形狀、associated type lookup、PhantomData 的 layout 影響、以及 downcast 的 runtime 成本是怎麼一步一步浮現的。 讀完之後你應該能夠回答兩個問題:在你眼前這個 case,到底是哪個 existential 必須消、哪個可以留? 以及,如果你選擇了某條路線,你正在把哪一種成本攤到你的 codebase 上?

這個 comparison 不是 library shootout——四個方案不是競品而是不同 trade-off 的座標點。 所以開頭這張表不是「誰贏誰輸」的判斷,而是「哪個方案抹掉哪個 existential、付出什麼代價」的對應關係。 讀者可以點 column header 重排,看哪個方案在你最在意的那一行上是綠的、在你能接受的那一行上是紅的。

click column header to sort · 5 columns × 7 rows

四個方案在六個維度上的能力對照(資料來源:wolfgirl.dev 2026-05-20 post;點擊 header 可重排)
維度 for-exists 轉換 associated type + PhantomData 手刻 wrapper trait Box<dyn Any> runtime
S 是否被消去 是(轉成泛型) 否(associated)
B 是否被消去 是(轉成泛型) 否(associated)
可放入 Box<dyn _> 否(associated 需指定)
B 可出現在方法介面 否(必須隱藏) 是(但走 Any)
runtime 成本 零(單型化) 一次 vtable indirection vtable + downcast 檢查
boilerplate 量 最少 中等 每個方法手寫 forwarding 最多(每個簽章手寫 Any 轉換)
「S 與 B 是否消去」這兩行只有方案三、四同時是綠的;而「可放入 Box」這行只有方案三、四是綠的——這不是巧合,是 dyn-compatibility 規則的直接結果。

「S 與 B 是否消去」這兩行只有方案三、四同時是綠的;而「可放入 Box」這行只有方案三、四是綠的——這不是巧合,是…

dyn 消一個 existential;wrapper trait 消兩個;enum 零開銷但封閉;unsafe transmute 破 soundness。

第一眼會看到的事:沒有任何一個方案在六行裡都是強項。 for-exists 轉換靠把 existential 翻回 universal、把選擇權推給呼叫端,所以維度一二都是綠的、runtime 成本零; 但代價是「呼叫端必須提供具體型別」,這破壞了「我有某個東西、但不告訴你是什麼」的封裝意圖,所以「可放入 Box」那行是紅的。 associated type 路線同樣零成本,但你會發現它根本沒消掉 existential——它只是把 existential 藏進 trait 的 associated type 裡,於是你要建構 trait object 時還是得指定,繞了一圈回到起點。

真正能把兩個 existential 都吃進 Box<dyn _> 的,只有方案三(手刻 wrapper trait)和方案四(Box<dyn Any> runtime erasure)。 這不是巧合:dyn-compatibility 的條件就是「所有方法簽章不能含有 self 之外的 generic 參數」、「不能透過 self 取得 associated type 的值」, 所以唯一能塞進 vtable 的型別資訊就是「self 的指標 + 一張固定形狀的方法表」——任何超出這個 budget 的 existential,要嘛改寫成 universal、要嘛只能在 runtime 用 Any 再 downcast 回來。

這張表只是入口。 後面五個 H2 各自展開一個維度,最後一節給出決策規則。 在那之前,先看 wolfgirl 在 post 開頭給的那個玩具 example,因為後面所有討論都會回到這個 example。

pub trait Generic<B> {
    fn name(&self) -> &'static str;
}

impl Generic<f32> for i32 {
    fn name(&self) -> &'static str { "f32" }
}

impl Generic<f64> for i32 {
    fn name(&self) -> &'static str { "f64" }
}

這個 example 故意取最小:一個 trait、一個方法、一個型別搭配兩個不同的型別參數。 要把 i32 as Generic<f32>i32 as Generic<f64> 包成同一個 Box<dyn Generic<_>>,就是「兩個 existential 同時被量詞綁住」的最小可重現。 後面四個方案,都是針對這個 example 直接給的。

方案一的維度:把 existential 翻成 universal,但封裝就跟著消失

for-exists conversion 是 type theory 教科書級的招式: 一個 (∃s,b. Trait(s,b)) -> () 的 closure 可以被機械地翻成 ∀s,b. (Trait(s,b) -> ()), 也就是「對於任何滿足 Trait 的 s 和 b,這個 closure 都能處理」。 在 Rust 裡這就是直接把 S 和 B 寫成函式的 generic 參數:

fn use_generic<B, S: Generic<B>>(s: &S) -> &'static str {
    s.name()
}

這個版本在編譯期被 monomorphize 成兩份 code:一份 use_generic::<f32, i32>、一份 use_generic::<f64, i32>, 每一份都是直接 inline 的 zero-cost 程式碼。 它解決了「我寫一個函式,使用者餵什麼進來都能跑」的需求,runtime 成本是零,因為根本沒有 vtable、沒有 dynamic dispatch。

但這個方案的問題在於:它不是真正的 existential。 existential type 的語意是「我有某個東西、但我不告訴你是什麼」——封裝在資料側。 universal type 的語意是「對於任何東西、我都能處理」——封裝在函式側。 這兩個方向是 De Morgan 對偶,邏輯上等價,但工程上完全不同。

具體一點看:假設你要寫一個 plugin 系統,每個 plugin 實作了 Generic<B>,你想把所有 plugin 收進一個 Vec 裡。 走 for-exists 路線你會卡在這裡——你需要 Vec<Box<dyn Generic<?>>>, 但 generic 路線根本給不出來,因為一個 Vec 的所有 element 必須是同一個 type, 而 i32 as Generic<f32>i32 as Generic<f64> 是不同的 trait impl,於是它們在 dyn Generic<_> 中也是不同的 vtable target。

這就是 wolfgirl 文中強調的「universal propagation」現象: 一旦你選擇 for-exists conversion,你就必須讓 S 和 B 的型別參數從 use site 一路傳播到 storage site, 任何想要儲存這些值的中介層都要跟著變成 generic。 對於小型 utility 函式,這個成本很低——加兩個型別參數而已; 對於需要 heterogeneous storage 的 framework(plugin loader、event dispatcher、execution engine),這個方案會逼你把每個 storage layer 都泛化掉, 最後整個 codebase 變成 type parameter 的 carry-along。

更微妙的代價是 compile-time。 每一個不同的 type parameter 組合都會 instantiate 一份 monomorphic code, 對於 plugin 系統這種「N 個 plugin × M 個 B」的場景,產出的二進位會線性放大。 Rust 的 incremental compilation 對 generic 函式不太友善——一個 monomorphization 的 cache 一旦失效,所有受影響的 instantiation 都要重編, cargo 編譯時間在大型專案上呈現 super-linear 趨勢就是這麼來的。

那 for-exists conversion 什麼時候是正解? 答案是:當「我有某個東西」這個 existential 的封裝其實不重要的時候。 例如 fn print_name<T: Display>(x: &T) 寫法,呼叫端就是知道自己手上有什麼,根本不需要 hide; 或像 iterator adapter,fn map<F: FnMut(...)>(self, f: F),map 不在意 F 是什麼,但 caller 知道。 這些 case 用 generic 是最自然的——你並不是在做 existential erasure,你只是在寫 polymorphic code。

分辨原則很簡單:問自己「呼叫端需要把這個東西存起來嗎?需要把多個不同型別的版本放在同一個 collection 嗎?」 如果答案是「不需要、用完就丟」,for-exists 就夠了; 如果答案是「要存起來、要 heterogeneous」,這個方案幫不上忙,你需要往下看方案三或四。

方案二的維度:associated type 與 PhantomData,把 existential 藏進 trait 裡

這個方案是 type-system enthusiast 最常推的第一個 trick: 既然 Trait<B> 的 B 是 generic parameter、會破壞 dyn-compatibility,那把它改成 associated type 不就行了?

trait DoubleAssociated {
    type S: Generic<Self::B>;
    type B;
    fn as_ref(&self) -> &Self::S;
}

impl<S, B> DoubleAssociated for (S, PhantomData<B>)
where S: Generic<B>,
{
    type S = S;
    type B = B;
    fn as_ref(&self) -> &Self::S { &self.0 }
}

這段 code 的巧思在 (S, PhantomData<B>): PhantomData 是 zero-sized type,它不佔記憶體,但它讓 type checker 知道「這個 tuple type 裡有 B 的痕跡」—— 這對 variance、drop check、以及 type inference 都有影響。 沒有 PhantomData 的話,(S,) 的 type 裡不會出現 B,而 where S: Generic<B> 這個 bound 就無法在 type level 上 carry B 的資訊。

寫到這裡看起來很乾淨——associated type 兩個、impl 一個、PhantomData 把 B 撐起來。 然後你寫下這一行:

let x: Box<dyn DoubleAssociated> = Box::new((42i32, PhantomData::<f32>));

編譯器立刻翻臉:

error[E0191]: the value of the associated types `S` (from trait
`DoubleAssociated`) and `B` (from trait `DoubleAssociated`) must be specified
  --> src/main.rs
   |
   | let x: Box<dyn DoubleAssociated> = Box::new(...);
   |        ^^^^^^^^^^^^^^^^^^^^^^^^^ help: specify the associated types:
   |                                  `DoubleAssociated<S = ..., B = ...>`

這就是 dyn-compatibility 的硬牆:trait object 的 type 必須是 complete, 意思是所有 associated type 都必須在 type-level 被解出來,因為 trait object 的 vtable schema 在編譯期就要確定。 associated type 沒指定的話,編譯器不知道這個 vtable 該長什麼樣—— 具體一點說,dyn DoubleAssociated 並不是一個 trait object 的 type,而是「某個 trait object family」的抽象,得補上 S 和 B 才能 ground。

這時你可能會說:好,那我把兩個 associated type 寫滿,Box<dyn DoubleAssociated<S = i32, B = f32>> 行嗎? 可以——但 existential 就消失了,你已經把 S 和 B 寫死了,這跟方案一沒兩樣。 associated type 路線的結局是:你以為它幫你抹掉了 existential,其實它只是把 existential 從 generic parameter 改名成 associated type,dyn-compatibility 規則照樣攔截你。

為什麼 dyn-compatibility 規則這麼嚴? 回到 vtable 的本質:vtable 是一張「方法名 → 函式指標」的表,配上一個 self pointer。 要透過 self pointer 取得 self 的 associated type 的「值」(即 type-level 的值),編譯器必須在 monomorphization 的某個階段把 associated type 解出來; 但 dyn 的本意就是「把 monomorphization 推遲」——它要在 runtime 才知道實際 type 是什麼。 這兩個需求互相矛盾:vtable 需要 compile-time 的完整 type,dyn 要 runtime 的延後決定,associated type 是兩者的接縫,所以必須在編譯期被 ground。

wolfgirl 在這裡的核心觀察是:dyn-compatibility 對 associated type 的限制讓你「最多只能 erase 一個 existential」。 如果你的 trait 只有一個 type parameter(轉成一個 associated type),你可以靠 Box<dyn Trait<Assoc = X>> 抹掉 self type 那個 existential, associated type 的 X 你照樣得指定,但因為 X 只有一個,這個方案勉強撐得起一些 case; 如果你有兩個 type parameter,這個 trick 就破功了——你只能 erase self,剩下兩個 associated 全部都得補完。

所以這個方案的真實角色是「教育性 stepping stone」,不是 production solution。 它告訴你 dyn-compatibility 規則為什麼存在、associated type 為什麼不能單獨救你出這個困境。 要真正抹掉兩個 existential,必須在 trait 表面層做進一步的隱藏——而那是方案三的事。

順帶一提,PhantomData 在這裡還有第二層用處:它讓 impl Drop for (S, PhantomData<B>) 有意義。 如果 B 是某個 lifetime'd type,Rust 的 drop check 會需要看見 B 才能正確判定 drop 順序; 沒有 PhantomData 撐著,drop check 會放鬆得太多,可能讓你寫出 unsafe 的 Drop 實作。 所以即便這個方案最終撞牆,PhantomData 這個工具本身是值得學起來的——後續方案三、四都會用到。

方案三的維度:手刻 wrapper trait,付一次 vtable 換真正的 erasure

方案三的核心思想很簡單:既然 dyn-compatibility 不准你在 trait 上暴露 B、不准你的 trait 有 associated type 沒指定, 那就寫一個新的、只暴露 monomorphic interface 的 wrapper trait,把 B 完全藏在 impl 裡。

trait Erased {
    fn name(&self) -> &'static str;
}

impl<B, S: Generic<B>> Erased for (S, PhantomData<B>) {
    fn name(&self) -> &'static str {
        self.0.name()
    }
}

fn mk_box<B: 'static, S: Generic<B> + 'static>(s: S) -> Box<dyn Erased> {
    Box::new((s, PhantomData))
}

這個版本能 compile,而且 Box<dyn Erased> 不帶任何 type parameter—— 呼叫端拿到的就是一個 monomorphic 的 trait object,內部的 S 和 B 全部消失。 重點在 impl<B, S: Generic<B>> Erased for (S, PhantomData<B>) 這一行: B 出現在 impl header 的 generic 上、出現在 PhantomData 上、出現在 where bound 上,但沒有出現在 Erased trait 本身—— 這就是讓 dyn-compatibility 過關的關鍵。

Erased trait 的 vtable 形狀完全是 monomorphic: 一個方法叫 name,回傳 &'static str,self 是 &dyn Erased。 所有 impl Erased for (S, PhantomData<B>) 的版本——不管 S 是 i32 還是 String、不管 B 是 f32 還是 f64—— 產出的 vtable 都符合這個形狀,於是它們可以放進同一個 Box<dyn Erased>

runtime 成本:一次 vtable indirection。 呼叫 boxed.name() 時,編譯器產生的 code 是「讀 boxed 的 vtable pointer → load name 的 function pointer → 跳過去」, 比起 monomorphized direct call 多了兩次記憶體存取與一次 indirect branch。 在 modern CPU 上這通常是 10-30 cycles 的開銷,hot loop 裡會 measurable,cold path 完全可忽略。

這個方案最大的限制 wolfgirl 講得很清楚:B 不能出現在 trait 介面裡。 如果原本 Generic<B> 的方法簽章長得像 fn process(&self, b: &B) -> B,B 在 input 和 output 都出現了,你就無法把這個方法直接平移到 Erased—— Erased 是 monomorphic 的,它的方法簽章不能含 B。

這時候你有兩個選擇:要嘛接受這個方案只能處理「B 不出現在介面」的 case, 要嘛跳到方案四,用 Box<dyn Any> 把 B 包起來繞過簽章限制。 這個分岔點是兩個方案實際的應用領域邊界——你的 trait 多 B-flavored,就越往方案四傾斜。

另一個實務細節:mk_box 的 signature 裡有 B: 'static, S: 'static bound。 這是因為 Box<dyn Erased> 預設要求 'static lifetime,否則編譯器無法保證 box 內的型別不會 dangle。 如果你的 S 或 B 帶 lifetime,得寫成 Box<dyn Erased + 'a>,並把 lifetime 也手動 propagate 到 wrapper trait。 這個 lifetime carry 在實務上不算複雜,但容易忘——忘記寫 + 'a,編譯器會給你一句 misleading 的 「borrowed value does not live long enough」。

把方案二、三放在一起看,就能看清楚 PhantomData 真正的角色。 方案二的 PhantomData 只是 type-level 標記,沒有實質效果——associated type 那邊照樣破功; 方案三的 PhantomData 才是必要的,它在 impl 的 type 上 carry B,讓 type checker 能驗證 S: Generic<B> 這個 bound。 沒有 PhantomData,(S,) 這個 type 不知道自己跟 B 有什麼關係,bound 無法解出。

想直接比對「方案二被拒」與「方案三放行」的 minimal diff 嗎? 把下方的分隔線往右拖,左邊是方案二(associated type 觸發 E0191),右邊是方案三(wrapper trait 編過)。 左右兩段除了 trait 定義不一樣之外,impl 的 type、where bound、PhantomData 的用法都一模一樣——差異就藏在那個 trait 的形狀。

方案二(拒)
// 方案二:trait 帶 associated type
// → E0191 must be specified
trait DoubleAssociated {
    type S: Generic<Self::B>;
    type B;
    fn as_ref(&self) -> &Self::S;
}

impl<S, B> DoubleAssociated for (S, PhantomData<B>)
where S: Generic<B>,
{
    type S = S;
    type B = B;
    fn as_ref(&self) -> &Self::S { &self.0 }
}

// 試圖建構 trait object:
let x: Box<dyn DoubleAssociated> = Box::new(...);
// error[E0191]: the value of the associated
// types `S` and `B` must be specified
方案三(過)
// 方案三:wrapper trait Erased
// (無 generic、無 associated)→ compile 通過
trait Erased {
    fn name(&self) -> &'static str;
}

impl<B, S: Generic<B>> Erased
    for (S, PhantomData<B>)
{
    fn name(&self) -> &'static str {
        self.0.name()
        // 透過 (S, _) tuple 的 .0 拿 S
    }
}

fn mk_box<B: 'static,
          S: Generic<B> + 'static>(s: S)
    -> Box<dyn Erased>
{
    Box::new((s, PhantomData))
    // dyn Erased 沒有 unbound associated → 通過
}
左:trait 帶 associated type,dyn DoubleAssociated 必須 ground,E0191 報出兩個 associated 都得指定。右:把 generic 全部關進 impl header、trait 表面層變 monomorphic,dyn Erased 直接通過。impl 的 type、where bound、PhantomData 完全一樣——差異只在 trait 形狀。

左:trait 帶 associated type,dyn DoubleAssociated 必須 ground,E0…

方案二 associated type 在 typeck 觸發 E0191;方案三 wrapper trait 通過,差別只在 dyn 有無 assoc。

當你把這兩段對齊看,就會體會到 dyn-compatibility 規則究竟在 enforce 什麼: 它不是禁止你寫 generic、不是禁止你用 associated type, 它只是堅持「trait object 的 vtable schema 在編譯期就必須 ground」。 把 generic 留在 impl 上、不把它升到 trait 上,這個 invariant 就能滿足。 這也是為什麼方案三能用——它沒有違反規則,它只是把規則的邊界用到極致。

方案四的維度:Box<dyn Any> 走到底,付 downcast 換最強 erasure

方案三的限制是「B 不能出現在介面」,但真實世界的 trait 不總是這麼乾淨。 考慮一個更現實的 trait:

pub trait Generic<B> {
    fn input(b: &B);
    fn output(&self) -> B;
}

這個 trait 的 input 接受 B、output 回傳 B,B 從頭到尾貫穿介面,方案三完全平移不過去。 這時 Box<dyn Any> 上場:把所有 input/output 全部包成 Box<dyn Any>, 在 wrapper 的實作裡 downcast 回真正的 B,呼叫真正的 method,再把結果 box 起來。

trait Erased {
    fn input(&self, b: &Box<dyn Any>);
    fn output(&self) -> Box<dyn Any>;
}

impl<B: 'static, S: Generic<B>> Erased for (S, PhantomData<B>) {
    fn input(&self, b: &Box<dyn Any>) {
        S::input(b.downcast_ref().unwrap())
    }
    fn output(&self) -> Box<dyn Any> {
        Box::new(self.0.output())
    }
}

這個版本能編、能裝箱、兩個 existential 都被消去,介面上完全看不見 B。 代價有三: 第一,每個方法簽章都得手寫 Box<dyn Any> 轉換邏輯,方法多了 boilerplate 會線性放大; 第二,downcast_ref().unwrap() 在 type 對不上時會 panic——這把 type-system 該抓的錯推到 runtime; 第三,Box::new(...) 會在 heap 上配置記憶體,每次 method call 都會 allocate,hot path 會 measurable。

第二點是這個方案最尖銳的問題。 呼叫者拿到 Box<dyn Erased> 之後,如果餵錯 B 進去 input(例如本該餵 f32 結果餵 f64),編譯器無從察覺——這個 box 在 type-system 看來就是個 opaque 容器,B 已經被抹掉了。 downcast 在 runtime 發現 type 不對才會 panic。 這違反了 Rust 的核心承諾:「能編過的程式不會在 runtime 因為 type error 崩潰」。 Any 路線是這個承諾的破口。

第三點的成本可以量化。 Box::new(b) 在 default allocator 下大約 30-100ns,因為要走一次 malloc。 對於 plugin event dispatcher 這種「每秒幾百次 call、每次資料量小」的場景,這個 allocation 開銷會 dominate 整個 latency 預算。 解法是用 typed arena 或 small-box optimization(把小 B 直接放進 fat pointer 的 padding),但這些都得自己實作,標準庫不提供。

為了讓「downcast 成本 vs vtable 成本」這件事不只停留在直覺,下面這張 scatter 圖把四個方案在「runtime overhead per call (ns)」與「extra boilerplate per method (LoC)」這兩個維度上的位置畫出來。 Y 軸是 wolfgirl 文中沒給出具體數字、但對讀者最重要的兩個成本維度,數字是綜合 Rust 標準工具的 microbenchmark 推估(cycle counts × 3GHz CPU),請當作量級而非精確值。

for-exists(左下,零成本但無法 box) associated type(旁邊,等同被拒) 手刻 wrapper(中段,最佳平衡) Box<dyn Any>(右上,能塞最多但最貴)
X 軸:每個方法 erasure 所需的額外程式碼行數(典型估計)。Y 軸:單次 method call 的 runtime overhead(ns)。原點代表「零開銷、無 erasure」,右上方代表「最強 erasure、最高開銷」。四個方案沿著一條 trade-off 曲線排開——這條曲線就是 dyn-compatibility 規則的 cost frontier。

X 軸:每個方法 erasure 所需的額外程式碼行數(典型估計)

enum dispatch 零 overhead 但封閉;dyn wrapper O(1) 但支援開放擴展,二者在 cost frontier 的兩端。

這張圖的形狀本身就是 wolfgirl post 的 thesis:四個方案不是在比效能,而是在比「我願意拿多少 runtime 成本換多少 erasure 能力」。 從原點往右上走,每多一點 erasure 都要多付一點開銷; 左下角的 for-exists 看似最便宜,但它根本沒實現 existential——它是把問題退回給呼叫端。 associated type 與 for-exists 都在左半邊,對 box 化的需求毫無幫助; wrapper trait 是中段最佳平衡點; Box<dyn Any> 把能解決的範圍推到最大,代價是 70 ns 量級的 per-call overhead 與 type safety 的弱化。

為了讓你直接對齊「同一個 trait,四個方案各自的 Rust 簽章長什麼樣」,下方這個 tab 切換器把四份 code 並列。 這不是把方案重講一遍——是把它們的核心簽章 distill 到一個視覺單位裡,方便你在實際選擇時來回對比。

switch tabs to compare 4 approaches · 4 tabs

// 方案一:把 ∃ 翻成 ∀,由呼叫端攜帶具體型別 fn use_generic<B, S: Generic<B>>(s: &S) -> &'static str { s.name() } // 呼叫: // use_generic::<f32, i32>(&42i32); // use_generic::<f64, i32>(&42i32); // // 缺陷:S、B 從 use site 傳播到 storage,無法存進 // Vec<Box<dyn _>>,封裝意圖消失。
// 方案二:把 B 改成 associated type,用 PhantomData 撐 type-level trait DoubleAssociated { type S: Generic<Self::B>; type B; fn as_ref(&self) -> &Self::S; } impl<S, B> DoubleAssociated for (S, PhantomData<B>) where S: Generic<B>, { type S = S; type B = B; fn as_ref(&self) -> &Self::S { &self.0 } } // let x: Box<dyn DoubleAssociated> = ... // → error[E0191]: associated types `S`, `B` must be specified
// 方案三:把 generic 收進 impl header,trait 表面層完全 monomorphic trait Erased { fn name(&self) -> &'static str; } impl<B, S: Generic<B>> Erased for (S, PhantomData<B>) { fn name(&self) -> &'static str { self.0.name() } } fn mk_box<B: 'static, S: Generic<B> + 'static>(s: S) -> Box<dyn Erased> { Box::new((s, PhantomData)) } // 限制:B 不能出現在 trait 方法簽章。
// 方案四:input/output 全部走 Box<dyn Any>,runtime downcast use std::any::Any; trait Erased { fn input(&self, b: &Box<dyn Any>); fn output(&self) -> Box<dyn Any>; } impl<B: 'static, S: Generic<B>> Erased for (S, PhantomData<B>) { fn input(&self, b: &Box<dyn Any>) { S::input(b.downcast_ref().unwrap()) // 型別錯 → panic } fn output(&self) -> Box<dyn Any> { Box::new(self.0.output()) // 每次都 alloc } }
四個方案的最小骨架。注意方案三和方案四的 impl header 完全一樣(impl<B, S: Generic<B>> ... for (S, PhantomData<B>)),差別只在 trait 上暴露什麼——這就是「dyn-compat 規則只在 trait 表面層管事」的具體展現。

四個方案的最小骨架

四種簽章全部可消除兩個 existential;封閉 variant 選 enum,開放擴展選 dyn wrapper。

把四份簽章對齊看,最該注意的觀察是方案三和方案四的 impl header 完全一樣。 這不是巧合——他們繞過 dyn-compatibility 的策略是相同的:把所有 generic 收進 impl header、讓 trait 表面層保持 monomorphic。 差別只在「trait 上要暴露什麼樣的 monomorphic 介面」。 方案三選擇了「乾脆不暴露 B」(適用 B 不出現在方法的 case); 方案四選擇了「用 Any 包住 B」(適用 B 必須出現的 case)。 當你下次面對「我該選方案三還是方案四」這個問題時,問題的形狀就是「B 是否出現在介面」。

跨方案的維度:dyn-compatibility 規則本身到底在 enforce 什麼

把四個方案放在一起看完,現在可以回到根問題:為什麼 dyn-compatibility 規則長這樣? Rust 對 dyn-compat 的官方定義(Reference 5.16)有兩條核心限制: 所有方法不能在 self type 之外帶 generic 參數所有方法的簽章不能含有未 ground 的 associated type。 這兩條看似不同,其實是同一個約束的兩種表達。

本質的約束是 vtable 的形狀必須在 compile time 被固定。 一個 dyn Trait 物件的記憶體佈局是 (data_ptr, vtable_ptr)——一個 fat pointer。 vtable 本身是個 read-only 的 static 結構,內容是「方法的函式指標、self 的 size、self 的 alignment、self 的 drop function」。 要建構這個 static 結構,所有方法的簽章必須完全 ground—— 這意味著方法不能有任何 caller-supplied generic 參數(不然 vtable 裡得放一個 generic function pointer,C 語言層級辦不到), 也不能有未解的 associated type(不然 sizeof 算不出來)。

某個微妙的後果是:dyn-compat 並不是「trait 本身的屬性」,而是「trait 在當前 type context 下能不能 ground」的屬性。 dyn Trait 不行、但 dyn Trait<Assoc = u32> 可能行; dyn Trait<Assoc = u32, Other = f64> 把所有 associated 都填完之後也行。 這就是為什麼方案二會卡在 E0191——錯誤訊息 literal 寫著 「the value of the associated types must be specified」, 它不是說 trait 不能 dyn,而是說「你給的 type 不夠完整」。

既然 vtable 形狀是這套規則的基石,把四個方案各自會產生(或拒絕產生)的 vtable layout 畫成記憶體骨架,比文字更能看出差別。 下圖把每個方案下 name() 這個方法在 box 物件裡會佔到的位元組視覺化—— fat pointer 的兩半(data ptr 與 vtable ptr)、vtable 裡的固定欄位(drop、size、align)、以及 method slot 在每個方案下分別填了什麼。

四個方案下 name() 方法在 vtable 裡的記憶體骨架

  • ① for-exists——沒有 vtable,全部 inlinei32 (4 bytes)

    直接在 stack 上展開——無法放進 Box<dyn _>

  • ② associated type——被 E0191 攔下E0191

    vtable schema 無法 ground——編譯期就拒絕,根本走不到 vtable 階段

  • ③ wrapper trait——乾淨 vtablefat ptr 16B

    data ptr 指向 &(i32, ∅) + vtable ptr 指向 VT_Erased——fat pointer 16 bytes

  • ③ vtable VT_Erased(方法表)monomorphic

    drop_in_place 指向 fn(*mut T) + size 為 8(i32 + ∅)+ align 為 4 + name → name_impl——無 B 欄位,monomorphic schema

  • ④ Box<dyn Any>——vtable + heap alloc 串接+ malloc

    fat ptr 接 heap-alloc Box<dyn Any>(data ptr + TypeId + vtable)再走 downcast——每次 method call 多一次 malloc + TypeId 比對;type 不對 → panic

① for-exists:沒有 vtable,全部 inline i32 (4 bytes, inline) 直接在 stack 上展開 → 無法放進 Box<dyn _> ② associated type:被 E0191 攔下 vtable schema 無法 ground 編譯期就拒絕 → 根本走不到 vtable 階段 ③ wrapper trait:乾淨 vtable data ptr &(i32, ∅) vtable ptr → VT_Erased → fat ptr = 16 bytes ③ vtable VT_Erased(方法表) drop_in_place fn(*mut T) size 8 (i32 + ∅) align 4 name → name_impl (無 B 欄位) monomorphic ✓ ④ Box<dyn Any>:vtable + heap alloc 串接 data ptr &(i32, ∅) vtable ptr → VT_Erased output() heap-alloc Box<dyn Any> data ptr | TypeId | vtable downcast TypeId == ⟨B⟩? → unwrap → 每次 method call 多一次 malloc + TypeId 比對;type 不對 → panic
同樣呼叫 name(),方案一沒有 vtable(inline)、方案二根本到不了 vtable(編譯失敗)、方案三的 vtable 是 monomorphic 的 4 欄位定值表、方案四在這個 vtable 之上又多串一條 heap-alloc + TypeId 比對的 chain。name 欄旁邊「無 B 欄位」是 dyn-compat 的核心 invariant——任何含未 ground 型別的方法都進不了這張表。

同樣呼叫 name(),方案一沒有 vtable(inline)、方案二根本到不了 vtable(編譯失敗)、方案三的…

wrapper trait 兩層 vtable,比 dyn Trait 多一個 fat-pointer;方案二在 typeck 就被拒,vtable 不產生。

把這個約束類比到 C++ 會比較直觀: C++ 的 virtual function 不能是 template member function,也是因為 vtable 沒辦法放 template instantiation 的指標。 Rust 的 dyn-compat 就是 Rust 版本的「virtual 不能 template」——它把這個限制具體化、用 trait coherence 的詞彙講清楚,但底層是同一個 ABI 約束。

這個約束有沒有可能放鬆? Rust 社群在 rfcs/3192(async fn in trait)的討論裡反覆碰到 dyn-compat 的邊界—— async fn 在 trait 上會回傳一個 anonymous future type(即 impl Future), 這個 anonymous type 等同 associated type,dyn 不下去。 Rust 1.75 引入的 RPITIT(return-position impl trait in trait)讓 trait 可以暴露 fn foo() -> impl X, 但 dyn 化這種 trait 還在 work in progress——RFC 3318 提了 dyn* 概念, 打算把 vtable schema 從「fat pointer」改成「兩個 thin pointer,其中一個指向 type erasure adapter」, 這個 adapter 在 runtime 才補上 anonymous type 的具體 fit。

這個方向如果走得通,就是 dyn-compat 約束的一次重大放鬆—— 但它仍然不會解決「兩個 associated type 同時 existential」這個問題, 因為 dyn* 還是只能 erase 一層。 本質上,trait object 是「把多個 implementation type 對齊到同一個 ABI」的機制, 對齊的代價就是「ABI 必須是 monomorphic」——這個代價不會因為機制變更而消失,只會被推到不同的位置。

所以 wolfgirl 在文末的判斷「impossible to fully automatically erase multiple existential bounds in Rust」並不是 Rust 的設計失誤—— 它是任何 statically typed language 在做 trait object / virtual dispatch 時都會碰到的根本約束。 Java 的 List<? extends Foo> 可以 erase 一層(wildcard),但要 nested wildcard 也得用 capture conversion 把 existential 推到方法簽章上; Haskell 的 existential type(forall a. C a => ...)配上 type class 可以 erase 多層,但代價是 dictionary passing 永遠 runtime indirect。 沒有免費的 erasure。

講完 dyn-compat 的根因之後,還有一層比 microbenchmark 更隱性的成本要列出來: benchmark 數字之外,這四個方案在大型 codebase 上會浮現一些 per-call latency 看不到的代價。 這幾項合起來才是真正的「production cost」,也是決策時最容易被忽略的維度。

第一個是 incremental compilation 友善度。 for-exists(方案一)依賴 monomorphization,每多一個 instantiation site 都會在 metadata 裡留一份 cache key; 大型專案中只要某個 generic function 的定義有任何改動,所有用到它的下游 crate 都要重編。 方案三、四走 dyn 路線,wrapper trait 的方法表是 monomorphic 的,下游 crate 看到的 ABI 是固定的, 只要 wrapper trait 本身的簽章不變,下游就不用重編——大型 workspace 上這個差距可能是十倍量級的 incremental build time。

第二個是 二進位大小。 for-exists 路線每個 type parameter 組合都產生一份 monomorphic code, 對於 fn foo<A, B, C, D> 配上四個各兩個 type 的 case,理論上能 instantiate 出 16 個版本。 LLVM 的 dead-code elimination 與 ICF(identical code folding)能消去一部分,但 release binary 的 size 比 dyn 路線通常大 20-50%。 Embedded scenarios(嵌入式或 wasm size budget 嚴格)這個差距會 dominate 整個選擇。

第三個是 error message 的 onboarding 成本。 方案二會給出 E0191;方案一遇到 type 不滿足 bound 會給出 trait bound 鏈—— compiler diagnostics 在 generic-heavy code 上會 cascade 出十幾行; 方案三、四的 error message 通常較短,因為 wrapper trait 是 monomorphic 的,問題出在 wrapper 內部時錯誤訊息會收斂到一兩行。 對新進工程師 onboarding 一個 codebase,方案三、四的「介面比較好讀」是真實 productivity gain。

第四個是 unsafe 邊界的擴張。 方案四的 downcast_ref().unwrap() 把 type-system 的不變式推到 runtime, 一旦這個 unwrap panic,整個 process 就崩了; 更糟的是,如果有人寫 downcast_unchecked 想要省 type check,就把 unsafe 引入了—— 這對於 process-isolation 不足的環境(單一 OS process 跑多個 component),會把整個程式的安全邊界一起拖下水。 方案三完全在 safe Rust 裡解決,這個邊界擴張不會發生。

第五個是 跨語言 FFI 的可達性。 如果你的 wrapper 之後要被 C / C++ / Python 透過 FFI 呼叫, 方案三的 monomorphic Erased trait 配上 extern "C" 的 wrapper function 是最容易做的—— C 端看到的就是 fn(*mut c_void) -> *const c_char 之類的 plain signature。 方案四的 Box<dyn Any> 跨 FFI 完全不能用, 因為 Any 是 Rust 內部 type、C 端拿到的 *mut c_void 沒有 type tag 可以 downcast。 這個限制會把方案四從某些場景直接刪除(任何 Python binding、任何 C ABI export)。

把這些隱性成本加上 wolfgirl 文中提到的顯性 trade-off,可以歸納出一個簡單的決策結構: 凡是「需要 box 化、heterogeneous storage、跨 FFI、binary size 敏感」這四個條件中有三個以上成立的,方案三是最好的選擇; 凡是「B 必須出現在介面、不需要跨 FFI、單一 trait 方法數 ≤ 5」三個都成立的,方案四是可接受的; 其他情況——尤其是「呼叫端就是知道型別、不需要 box」——方案一是正解。 方案二永遠不該作為終點,它只是教學案例。

如何選:把 dyn-compat 約束翻譯成決策流程

走到這裡,所有維度已經攤開。 現在把它們收成一個決策流程——這不是泛泛的「視情況而定」,而是基於前面五節的 trade-off 結構給出的明確判斷規則。

┌──────────────────────────────────────────────────────────────────┐
│ Q1: 你需要把這些值「存起來、放進 heterogeneous collection」嗎?   │
└──────────────────────────────────────────────────────────────────┘
     │
     ├── 否 → 用 fn foo<B, S: Trait<B>>(...) [方案一・for-exists]
     │       // 這不是真的 erase existential,是把它推回 caller// 但 caller 本來就知道型別,這就是最自然的寫法。
     │
     └── 是 →
         ┌─────────────────────────────────────────────────────┐
         │ Q2: 你的 trait 方法簽章「需不需要在 input/output 上 │
         │     出現 B」?                                         │
         └─────────────────────────────────────────────────────┘
             │
             ├── 不需要 → impl Erased for (S, PhantomData<B>)
             │           [方案三・wrapper trait]
             │           // monomorphic 表面 + generic impl,乾淨。
             │
             └── 需要 → 再問:
                 ┌─────────────────────────────────────────────┐
                 │ Q3: 這個 box 需要跨 FFI 嗎?binary size      │
                 │     敏感嗎?方法數量會否成長到 ≥10?         │
                 └─────────────────────────────────────────────┘
                     │
                     ├── 任一為「是」→ 重新設計 trait,把 B 從介面拔出來
                     │                 退回 Q2,走方案三。
                     │
                     └── 全為「否」→ Box<dyn Any>
                                    [方案四・runtime erasure]
                                    // 你接受 downcast panic 與每 call
                                    // 一次 heap alloc 的 cost。

[方案二・associated type]在這個流程裡不會出現——
它在 Q2「需不需要 box」這一關就會被 E0191 攔下來。
如果你考慮過它,把它替換成方案三。

這個流程的特性是:每一個分岔都是可以被「程式碼結構」直接判斷的問題,不是「視專案」這種模糊判準。 Q1 是 storage 需求、Q2 是介面形狀、Q3 是工程約束,全部都能在 code review 時 30 秒內回答完。 如果你發現某一題答不出來,多半是 trait 本身的設計還沒收斂——這時與其勉強選一個 erasure 方案,不如先把 trait 重新切分。

一個常見的場景是:原本的 trait 把 B 既當 input 又當 output 用, 但仔細看會發現「output 用到 B」其實只在某幾個方法。 把這些方法 extract 成另一個 trait,主 trait 就符合 Q2 的「不需要」分支,方案三能直接套用。 這種 trait 拆分通常會讓整個 API 變得更清楚——一個 trait 一件事,是 Rust trait 設計的核心美學。

另一個常見場景是:你以為自己需要 erasure,但其實只是 generic propagation 嫌煩。 這種「為了少寫幾個 type parameter 而引入 dyn」的衝動,幾乎總是錯的—— 引入 dyn 之後跑出來的 runtime cost、binary size 影響、debug 困難度,加起來遠超「多寫 3 行 type parameter」的成本。 Rust 的 monomorphization 是它最強的工具之一,能用 generic 解決就不要引入 dyn。

最後留一個 sanity check:如果你看完這篇還不能在 30 秒內判斷自己手上的 case 應該走哪條路,那很可能你的 trait 設計本身需要重新審視—— 要嘛 B 的角色不夠清楚(應該是 generic 還是 associated?), 要嘛 trait 的職責不夠收斂(是不是該拆成兩個 trait?)。 erasure 方案是 trait 設計合理之後的最後一步,不是設計過程中的擋箭牌。

為了讓決策流程能 anchor 到 Rust 的具體語法元素,下方這張 kbd 對照表把每個方案會用到的關鍵字/型別/feature flag 鋪開—— 這不是 syntax cheatsheet,而是「當你看到某個 Rust 關鍵字或標準庫型別出現在 codebase 裡,背後其實在 enforce 哪一個 erasure 維度」的索引。 讀完上面的決策流程之後,這張表是把流程 ground 回語法層的速查。

方案一・for-exists fn foo<B, S> generic parameter 把 existential 翻成 universal——B、S 寫在 fn header 上,monomorphize 在編譯期完成。
方案一・for-exists impl Trait return-position fn foo() -> impl X 是隱式 existential——但仍是 monomorphic,不會 erase 跨多個 caller。
方案二・associated type Assoc trait 成員 把 B 改寫成 associated type——E0191 攔截「dyn 化時 associated 必須 ground」。
方案二・associated PhantomData<B> type-level 標記 zero-sized type,在 impl 的 self type 上 carry B;type checker 用它解 variance 與 drop check。
方案三・wrapper dyn Trait trait object fat pointer (data, vtable);要求 trait 是 dyn-compatible——表面 monomorphic、無未 ground associated。
方案三・wrapper impl<B, S: ...> impl header 收 generic generic 留在 impl,不上 trait——讓 trait 表面層 ground 為 monomorphic vtable schema。
方案四・Any Box<dyn Any> runtime erasure 內含 TypeId + data ptr——把 type 推遲到 runtime 比對,付 heap alloc + downcast 成本。
方案四・Any .downcast_ref() runtime type check 回傳 Option<&B>——TypeId 不對則 None;unwrap() 把錯誤 panic 化。
八個 Rust 語法元素,兩兩對應四個方案。左邊框顏色對齊本文 chart 圖例:灰=方案一,深墨=方案二,鼠尾草綠=方案三,焦糖橙=方案四。每個 cell 的 role 標籤點名「這個 token 在語意層做的事」——當你 review 一段 Rust code,看到這些 token 就能立刻定位它在 dyn-compat 規則上的角色。

八個 Rust 語法元素,兩兩對應四個方案

impl Trait 方案一;PhantomData 被拒(E0191);Box[dyn W] 方案三;TypeId/downcast 方案四。

把語法層的 token 對齊回方案之後,下一個維度是「實作這些方案的 cost 到底在 compile pipeline 的哪一階段被付出」? Rust 的 pipeline 從 parse / HIR / typeck / MIR / monomorphization / codegen 一路走下來,每一個方案的「erasure 動作」其實發生在不同的 stage—— 這影響的不只是 runtime,還有 incremental build 的 cache 粒度、error message 出現的 phase、以及 panic 的可定位性。

四個方案的 erasure 動作在 Rust compile pipeline 中發生的階段

  • 方案一——monomorphize 階段 eraseerase here

    每個 (B + S) 組合 instantiate 一份 monomorphic code——erase 動作落在 monomorphize stage

  • 方案二——typeck 階段被擋E0191 here

    typeck 階段就拒——dyn 化時 associated type 未 ground,根本走不到後續 stage

  • 方案三——MIR 階段 vtable elabvtable here

    MIR 階段 elab dyn dispatch——wrapper trait 的 vtable schema 固定,monomorphic 表面

  • 方案四——MIR + runtime 串接vtable + Any

    MIR 階段 elab vtable + Any——downcast 與 alloc 推遲到 runtime 的 TypeId 比對 + heap alloc,type 不對 → panic,超出 pipeline 邊界

parse / HIR source → AST typeck trait resolve + bound check MIR borrow check + drop elab monomorphize generic → concrete codegen / LLVM → machine code 方案一 erase here 每個 (B, S) 組合 instantiate 一份 monomorphic code 方案二 E0191 here typeck 階段就拒——dyn 化時 associated 未 ground 方案三 vtable here MIR 階段 elab dyn dispatch;wrapper trait 的 vtable schema 固定 方案四 vtable + Any downcast + alloc 推遲到 runtime——超出 pipeline 邊界 runtime(pipeline 之後) TypeId 比對 + heap alloc type 不對 → panic pipeline 越往左 erase,cache 粒度越粗、incremental build 越容易失效;越往右 erase,runtime cost 越高、可定位性越差。
四個方案的「erasure 動作」分別發生在 Rust pipeline 的不同 stage——方案二在 typeck 階段就被擋下(根本沒走到 vtable),方案三、四共用同一個 MIR-stage vtable elaboration,但方案四把實際的 type-matching 推到 runtime。pipeline 越右側 erase,rebuild cache 越穩定,但 runtime 成本與 panic 風險越大;越左側 erase,反之。

四個方案的「erasure 動作」分別發生在 Rust pipeline 的不同 stage——方案二在 typeck…

方案一在 monomorphize 階段 erase;方案二在 typeck 被擋;方案三在 MIR;方案四延伸到 runtime TypeId 比對。

Pipeline 視角把「方案三 vs 方案四」的差異定位得很精確:兩者在 MIR-stage 共用同一份 vtable schema,差別只在「Any 把多出一條 runtime branch 拖出 pipeline 邊界」。 當你 profile 一個 production 系統發現 hot path 上有 downcast_ref 的 cycles,這條 branch 就是元兇——它不是 monomorphization 沒做好,是設計上就把 type-matching 延後到 runtime。

最後一個 anchor:把上面整套理論落到「實際生產 codebase」上—— Rust ecosystem 的主流 crate 各自選了哪個方案?他們的選擇是不是 align 上面的決策流程? 下表蒐集了五個有代表性的 crate,逐一比對「該 crate 為什麼選這條路、有沒有付出對應的代價」。

五個主流 Rust crate 的 erasure 策略——對照上面決策流程的 Q1 / Q2 / Q3 看選擇是否一致
crate 選擇 場景 付的代價
serde 方案一 Serialize / Deserialize 全靠 generic——caller 提供 type,編譯期 monomorphize。 編譯時間長、binary 膨脹;換來零 runtime 開銷與 type safety 完整。
erased-serde 方案三 專為解決 serde 不能 dyn 化的痛點——手刻 monomorphic wrapper trait,把 generic Serialize 包成 dyn ErasedSerialize 一次 vtable indirection;介面少了 type-level information,error message 較短。
bevy_ecs 方案四 Component / Resource storage 走 HashMap<TypeId, Box<dyn Any>>——必須 heterogeneous 且 B(component type)出現在 query 介面。 每 query 都付 TypeId 比對;無法直接 FFI;接受了 runtime panic 風險換 plugin ergonomics。
tokio 方案三 JoinHandle<T> 對外是 generic,但 runtime 內部 task storage 走 monomorphic dyn Future<Output = ()> wrapper。 每 task 一次 vtable indirection——對 ms 級 task 完全可忽略。
anyhow 方案四 anyhow::ErrorBox<dyn Error + Send + Sync>——error type 必須 heterogeneous、需要 downcast 取原 type。 每 error 一次 heap alloc;downcast 用 .downcast_ref::<T>() 回傳 Option 而非 unwrap,避開 panic。
五個 crate、三條路線——沒有人選方案二,因為它本來就走不通。serdeerased-serde 並存於 Rust 生態,正好是「同一個 use case 沿著 cost frontier 走兩端」的標本:core 用方案一保性能,erased-serde 用方案三補 heterogeneous storage 的空缺。

五個 crate、三條路線——沒有人選方案二,因為它本來就走不通

serde 用方案一;erased-serde 用方案三補 heterogeneous 缺口,二者並存是 cost frontier 兩端的標本。

這張表的核心觀察是:serdeerased-serde 同時存在不是冗餘——它們是同一個 trait 沿著 cost frontier 走出來的兩端。 當你的 use case 落在 frontier 的中段(需要 box、又不想付 Any 成本),ecosystem 早就 evolve 出 erased wrapper 模式,這也是 wolfgirl post 隱含的設計教訓:與其和 dyn-compat 規則對抗,不如沿著規則走出一條 wrapper trait 的橫向擴充路線。

Erasure cost axis: four solutions on the boilerplate-vs-runtime frontier cheap, restrictive expensive, permissive #1 impl Trait monomorphic #2 PhantomData rejected: not dyn-compatible #3 wrapper trait Box<dyn W> #4 Box<dyn Any> downcast at use grey marker = rejected path; readers walk past it, not through it
把四個方案攤在同一條軸上看,#2 留下灰點不是空格——它是規則邊界。

把四個方案攤在同一條軸上看,#2 留下灰點不是空格——它是規則邊界

由廉至貴:impl Trait → PhantomData(被拒)→ dyn W wrapper → dyn Any downcast。

How to choose:能不 box 就走方案一;非 box 不可且 B 不在介面就走方案三;B 不得不在介面才考慮方案四,且接受 downcast panic 與每 call heap alloc 的成本——方案二永遠只是用來理解 dyn-compatibility 為什麼長這樣的 stepping stone。