[Rust] Trait 設計模式元素拆解與組合規則

Rust Trait 設計指南|決策、取捨與可執行範例
An engineering field guide

先決定哪些部分需要變動、由誰持有資料,再選擇派發方式與介面。把 11 個常用主題放回各自的設計位置,讓這份文章成為下一次開發時能直接查用的指南。

11 個獨立範例僅使用標準函式庫Stable · Edition 2024離線可閱讀與複製

適合已熟悉 struct、enum、借用與基本 trait 語法的讀者。內容查核:2026-09-29。

01 / MENTAL MODEL

先分開三個設計維度

Trait 定義可被實作的契約。泛型、trait object、關聯型別與轉換 trait 解決的是不同問題;它們可以組合,不是 11 個互斥選項。若只有一種實作,也沒有替換或測試隔離需求,先使用具體型別與 inherent impl 即可。

① 行為如何選擇?

T: Trait 保留具體型別;dyn Trait 擦除具體型別;enum + match 在已知變體間選擇行為。

② 資料由誰持有?

T 持有值、&T 借用值、Box<T> 持有配置於堆積的值。所有權與派發方式分開決定。

③ 介面承諾什麼?

用關聯型別描述輸出、用 supertraits 表達能力需求,再用建構、轉換與運算子設計呼叫語意。

效能用具體成本描述。 靜態派發不需要 trait object 的虛擬呼叫,但不代表函式內部沒有配置、複製或運算。動態派發可能限制內聯,最佳化器也可能消除間接呼叫;不要保證固定指令數或宣稱某方案永遠較快。

機制依據:泛型與單態化、Trait objects。

02 / DECISION TREES

先選派發,再選所有權

執行時才知道要使用哪個值,不等於一定要使用 dyn。關鍵是:型別集合是否封閉,以及呼叫端能否保留具體型別。

決策樹 A:行為與型別集合

需要跨型別共用契約嗎?
├─ 否 → 具體型別 + inherent impl
└─ 是 → 同一個儲存位置/回傳介面,需要容納不同具體型別嗎?
   ├─ 否 → 泛型 T: Trait(01)
   │       只想隱藏單一回傳型別?→ 回傳 impl Trait(01)
   └─ 是 → 型別集合由目前模組掌握、可逐一列舉嗎?
      ├─ 是 → 優先評估 enum + match(03)
      │       若更重視統一物件介面,也可選 dyn Trait
      └─ 否 → dyn Trait(02),先通過 dyn compatibility 檢查

例:啟動時在兩種支付管道中選一個 → enum 或 dyn 都可
例:函式接受任意一種風控引擎,每次呼叫型別固定 → 泛型

決策樹 B:選了 dyn 之後,誰管理值?

呼叫端需要持有該物件嗎?
├─ 否 → &dyn Trait;需要獨占修改則用 &mut dyn Trait
└─ 是 → Box<dyn Trait>(單一所有者)
        多個所有者?→ Rc<dyn Trait>;跨執行緒則評估 Arc
        跨執行緒共享通常還需要 Trait + Send + Sync

具體物件內部是否借用區域資料?
├─ 是 → 宣告合適的物件生命週期,例如 Box<dyn Trait + 'a>(04)
└─ 否 → 常見的 Box<dyn Trait> 欄位可採用預設 'static bound

注意:'static bound 不代表物件必須存活到程式結束。
Arc 只處理共享所有權;可變存取仍須另外設計同步機制。

決策樹 C:契約還缺哪些元素?

需要表達的關係
├─ 每個實作決定一個輸出型別 → 關聯型別(05)
├─ 同一型別對不同輸入型別提供不同實作 → 泛型 trait,例如 From<T>(09)
├─ 不經由實例的建立操作 → 關聯函式;先考慮 new / Default(06)
├─ 綁定於型別的固定資料 → 關聯常數(07)
├─ 依賴另一組能力、共用可覆寫行為 → Supertraits + 預設方法(08)
├─ 不會失敗的語意轉換 → From / Into(09)
│  可能驗證失敗 → TryFrom / TryInto(09)
├─ 操作具有清楚的數學/領域語意 → std::ops(10)
└─ 多個 trait 的方法名稱衝突 → 完全限定語法(11)
03 / TRADE-OFFS

把實際差異攤開比較

同一個契約,可依不同邊界選擇不同表示法
比較元素泛型 T: Traitdyn Traitenum + match
呼叫機制具體型別單態化;容易提供內聯機會透過 vtable 間接呼叫;可能被最佳化依變體選擇分支;分支內型別已知
異質容器單一 Vec<T> 的 T 固定可用 Vec<Box<dyn Trait>> 等形式Vec<Enum> 可容納不同變體
新增型別新增符合契約的實作;呼叫端重新編譯可新增實作而不增加中央 enum 變體;仍須編譯/連結修改 enum 與相關 match;集合由定義端控制
記憶體依 T 與容器決定,不由泛型自動配置&dyn 借用不因此配置;Box 對非零大小值通常配置可直接內嵌變體;大小受最大變體、對齊與表示法影響
主要代價可能增加編譯時間與二進位大小間接呼叫、可能的配置與較少最佳化資訊分支、較大變體拖高尺寸、集中修改成本
適合起點保留型別資訊、可替換的演算法核心外部擴充、統一儲存不同實作可列舉的支付方式、狀態或後端

Enum 的變體在編譯期已知,實際分支仍可在執行期選擇。它不保證較快,也不保證「最大變體 + 固定 tag」的精確大小;對齊與 niche 最佳化都可能影響 layout。容器或變體內的 String、Box 仍可能配置記憶體。型別配置規則。

外部擴充 ≠ 動態載入二進位外掛。 Trait object 能讓不同實作共用介面,但本身不提供穩定的跨動態函式庫 ABI 或外掛載入機制。

使用 dyn 前的契約檢查

  • Trait 與它的 supertraits 都必須 dyn compatible,不能要求整個 trait Self: Sized。
  • 可由物件呼叫的方法,通常以 &self、&mut self 等合法 receiver 存取;不能有型別泛型參數,也不能在 receiver 以外使用 Self。
  • 建構子、泛型方法等可加上 where Self: Sized,明確排除於物件呼叫介面之外;見範例 06。
  • 一般關聯型別可以使用,通常須在物件型別指定,例如 dyn MessageStream<Item = String>。關聯常數與泛型關聯型別(GAT)不符合此處的 dyn compatibility 規則。
  • 可派發的方法不能直接使用 async fn 或回傳 impl Trait;非同步物件介面需另外設計。本篇範例均為同步程式。

這是常見檢查項目,完整條件以 Rust Reference:Dyn compatibility 為準。

04 / DISPATCH & OWNERSHIP

先決定行為如何被持有與呼叫

執行方式:每個程式區塊都是完整的 main.rs,單獨複製一個範例到 Rust Playground,選擇 Stable / 2024 / Debug / Run 即可。無外部 crate、網路或輸入需求;每個範例都附斷言與預期輸出。範例中的付款、通知、API 均為本機模擬。

01 · 泛型與 impl Trait:保留具體型別

將演算法依賴寫成 trait bound,讓呼叫端選擇實作。參數位置的 impl Trait 是匿名型別參數;需要跨參數表達「同一型別」或額外關係時,用具名 T 更清楚。

設計需求
同一風控流程可套用不同引擎,每次呼叫的引擎型別明確。
組成元素
RiskEngine 契約、兩種具體引擎、泛型呼叫端、回傳不透明型別的工廠。
所有權/派發
&T 借用;本例 T 為具體型別,使用靜態派發。
如何擴充
新增 impl RiskEngine for NewEngine,既有流程不必增加分支。
成本與限制
單態化可能增加編譯時間與程式碼體積;不保證一定內聯。回傳 impl Trait 的各分支必須是同一個隱藏具體型別。
改選時機
需要在一個容器放入不同引擎時,評估 dyn 或 enum。

T: Trait 預設還有 Sized 約束。若改用 T: Trait + ?Sized 並傳入 dyn Trait,內部 trait 方法呼叫仍可能動態派發;不能只看函式有沒有泛型,就斷言呼叫一定是靜態。

參考:參數與回傳位置的 impl Trait。

01_generics.rs
trait RiskEngine {
    fn allows(&self, amount: u64) -> bool;
}

struct LimitEngine { limit: u64 }
struct RejectAll;

impl RiskEngine for LimitEngine {
    fn allows(&self, amount: u64) -> bool {
        amount <= self.limit
    }
}

impl RiskEngine for RejectAll {
    fn allows(&self, _amount: u64) -> bool { false }
}

fn execute<T: RiskEngine>(engine: &T, amount: u64) -> bool {
    engine.allows(amount)
}

fn execute_short(engine: &impl RiskEngine, amount: u64) -> bool {
    engine.allows(amount)
}

fn default_engine() -> impl RiskEngine {
    LimitEngine { limit: 10_000 }
}

fn main() {
    let engine = default_engine();
    assert!(execute(&engine, 5_000));
    assert!(!execute_short(&engine, 10_001));
    assert!(!execute(&RejectAll, 1));
    println!("limit: true; over limit: false; reject all: false");
}
預期輸出與驗證重點

涵蓋通過、超過上限,以及替換成另一種實作。

limit: true; over limit: false; reject all: false

02 · dyn Trait:統一持有不同實作

消費端只依賴通知契約,不需要知道每個通知渠道的具體型別。指向 trait object 的指標帶有資料位置與 vtable 資訊;&dyn Trait 與 Box<dyn Trait> 的所有權意義不同。

設計需求
同一批通知可以交給不同型別的渠道,允許新增渠道實作。
組成元素
可用於 dyn 的 NotificationChannel、Email/SMS 實作、trait object 集合。
所有權/派發
第一段用陣列借用既有物件;第二段將值移入 Box,由 Vec 擁有。兩者都是動態派發介面。
如何擴充
新增實作後放入容器,遍歷迴圈不必修改。
成本與限制
借用轉為 &dyn 本身不配置。第二段有 Vec 儲存空間及非零大小值的 Box 配置;虛擬呼叫有間接性。列印本身的成本須另外計入。
改選時機
集合小且封閉、希望集中列舉所有變體時,評估 enum;只需要單一具體型別則用泛型。

參考:Trait object 的指標與派發機制。

02_trait_objects.rs
trait NotificationChannel {
    fn send(&self, message: &str) -> usize;
}

struct Email { recipient: &'static str }
struct Sms { phone: &'static str }

impl NotificationChannel for Email {
    fn send(&self, message: &str) -> usize {
        println!("Email to {}: {message}", self.recipient);
        message.len()
    }
}

impl NotificationChannel for Sms {
    fn send(&self, message: &str) -> usize {
        println!("SMS to {}: {message}", self.phone);
        message.len()
    }
}

fn main() {
    let email = Email { recipient: "team@example.com" };
    let sms = Sms { phone: "0900-000-000" };

    // 借用兩個既有物件;陣列不需要 Vec 的配置。
    {
        let channels: [&dyn NotificationChannel; 2] = [&email, &sms];
        for channel in channels {
            assert_eq!(channel.send("maintenance"), 11);
        }
    }

    // 移動所有權到 Box,再放入同一個 Vec。
    let channels: Vec<Box<dyn NotificationChannel>> =
        vec![Box::new(email), Box::new(sms)];
    for channel in &channels {
        assert_eq!(channel.send("ready"), 5);
    }
}
預期輸出與驗證重點

同時示範借用與持有的異質容器;回傳值是訊息的位元組數。

Email to team@example.com: maintenance
SMS to 0900-000-000: maintenance
Email to team@example.com: ready
SMS to 0900-000-000: ready

03 · Enum dispatch:把封閉集合寫進型別

當支付渠道由應用程式集中管理,可以使用 enum 包裝所有候選型別,再以 match 委派。Enum 也能實作 trait,讓它繼續進入泛型 API。

設計需求
信用卡與電子錢包共用計價契約,但渠道集合由本模組決定。
組成元素
Payment、具體渠道、PaymentKind 變體、委派 match、泛型計價函式。
所有權/派發
Enum 直接持有渠道;match 依執行時的變體選擇分支,分支內呼叫具體實作。
如何擴充
新增變體並更新 exhaustive match;外部 crate 無法直接替 enum 增加變體。
成本與限制
本例渠道與 enum 不需要 heap 配置。一般情況仍要衡量分支與變體尺寸;大型變體可考慮 Box,但會引入配置。
改選時機
無法集中掌握實作集合,或頻繁新增第三方實作時,考慮 dyn。
03_enum_dispatch.rs
trait Payment {
    fn fee(&self) -> u64;
}

struct Card;
struct Wallet;

impl Payment for Card {
    fn fee(&self) -> u64 { 30 }
}
impl Payment for Wallet {
    fn fee(&self) -> u64 { 10 }
}

enum PaymentKind {
    Card(Card),
    Wallet(Wallet),
}

impl Payment for PaymentKind {
    fn fee(&self) -> u64 {
        match self {
            Self::Card(channel) => channel.fee(),
            Self::Wallet(channel) => channel.fee(),
        }
    }
}

fn total(payment: &impl Payment, amount: u64) -> Option<u64> {
    amount.checked_add(payment.fee())
}

fn main() {
    let payments = [PaymentKind::Card(Card), PaymentKind::Wallet(Wallet)];
    assert_eq!(total(&payments[0], 1_500), Some(1_530));
    assert_eq!(total(&payments[1], 800), Some(810));
    assert_eq!(total(&payments[0], u64::MAX), None);
    println!("card total: 1530; wallet total: 810");
}
預期輸出與驗證重點

檢查兩個變體的委派結果,以及金額加總溢位時的 None。

card total: 1530; wallet total: 810

04 · 生命週期界限:物件可以持有借用

物件有 Box 所有權,不代表它擁有內部參照的資料。Box<dyn Rule + 'a> 允許實作持有對外部資料的借用,且要求這些借用在 'a 期間有效。

設計需求
規則借用呼叫端的設定字串,避免為規則再複製一份字串。
組成元素
PrefixRule<'a> 持有 &'a str;工廠回傳帶 + 'a 的 trait object。
所有權/派發
呼叫端擁有 String;Box 擁有規則本體。底層字串必須在規則使用期間有效。
如何擴充
新增其他 Rule 實作;只有需要借用的實作才在型別上加入生命週期。
成本與限制
「不複製被借用字串」不等於「零配置」:String 與 Box 仍有自己的配置。生命週期由編譯器檢查,不會延長資料壽命。
改選時機
需要讓規則脫離設定的存活範圍時,讓規則持有 String 或共享擁有的資料。

在一般欄位/函式回傳型別中,當沒有其他界限可推得生命週期時,Box<dyn Trait> 預設使用 'static;表達式與參照等情境可能推導出不同界限。'static 不是永久存活要求。方法參數的 &str 也不必和規則內部的 'a 綁在一起。

參考:Trait object 預設生命週期規則。

04_borrowed_object.rs
trait Rule {
    fn matches(&self, input: &str) -> bool;
}

struct PrefixRule<'a> {
    prefix: &'a str,
}

impl Rule for PrefixRule<'_> {
    fn matches(&self, input: &str) -> bool {
        input.starts_with(self.prefix)
    }
}

fn make_rule<'a>(prefix: &'a str) -> Box<dyn Rule + 'a> {
    Box::new(PrefixRule { prefix })
}

fn main() {
    // 真正的區域 String,並非只有 'static 字串常值。
    let config = String::from("order:");
    {
        let rule = make_rule(&config);
        let input = String::from("order:42");
        assert!(rule.matches(&input));
        assert!(!rule.matches("user:42"));

        let local = PrefixRule { prefix: &config };
        let borrowed: &dyn Rule = &local;
        assert!(borrowed.matches("order:99"));
        println!("order:42 => true; user:42 => false");
    } // rule 先釋放,config 仍然有效。
    println!("config still owned: {config}");
}
預期輸出與驗證重點

使用區域 String 建立借用規則,並展示 Box 與借用 trait object 的兩種形式。

order:42 => true; user:42 => false
config still owned: order:
05 / CONTRACTS & COMPOSITION

再定義型別關係與共用契約

以下元素可和泛型、enum 或 dyn 介面組合;是否能透過物件使用,取決於具體契約是否符合 dyn compatibility。

05 · 關聯型別:讓實作決定產出型別

type Item 表達「這個實作產出什麼」。對固定的 Self 與 trait 型別參數組合,關聯型別由該實作決定;若同一型別需要支援多種輸入關係,則評估 Trait<Input>。

設計需求
消費端逐筆讀取訊息,而訊息來源決定 Item 的型別。
組成元素
MessageStream::Item、記憶體佇列、指定 Item = String 的消費函式。
所有權/派發
佇列持有 String;pop_front 移出訊息。本例使用 &mut dyn MessageStream<Item = String> 動態派發。
如何擴充
可新增不同來源;若放進同一種 trait object,Item 必須符合相同的型別約束。
成本與限制
VecDeque 與 String 有配置;pop_front 避免 Vec::remove(0) 每次搬移剩餘元素。本例不是 Kafka 客戶端,也不是非同步 Stream。
改選時機
一般同步迭代優先實作標準 Iterator;只有領域契約確實不同時才新增自訂 trait。

參考:Associated types、VecDeque::pop_front。

05_associated_types.rs
use std::collections::VecDeque;

trait MessageStream {
    type Item;
    fn next_message(&mut self) -> Option<Self::Item>;
}

struct MemoryStream {
    messages: VecDeque<String>,
}

impl MessageStream for MemoryStream {
    type Item = String;

    fn next_message(&mut self) -> Option<Self::Item> {
        self.messages.pop_front()
    }
}

fn drain(stream: &mut dyn MessageStream<Item = String>) -> Vec<String> {
    let mut received = Vec::new();
    while let Some(message) = stream.next_message() {
        received.push(message);
    }
    received
}

fn main() {
    let mut stream = MemoryStream {
        messages: VecDeque::from([
            String::from("Event-1"),
            String::from("Event-2"),
        ]),
    };
    let received = drain(&mut stream);
    assert_eq!(received, vec!["Event-1", "Event-2"]);
    assert_eq!(stream.next_message(), None);
    println!("received: {}", received.join(", "));
    println!("stream exhausted: true");
}
預期輸出與驗證重點

驗證 FIFO 順序與耗盡狀態,同時證明一般關聯型別可搭配 dyn。

received: Event-1, Event-2
stream exhausted: true

06 · 關聯函式:定義不需要 receiver 的操作

沒有 self receiver 的關聯函式適合建構等操作。只服務單一型別時,Type::new 通常足夠;合理的預設值優先用 Default。只有呼叫端需要跨型別的建立契約時,才抽象成 trait。

設計需求
泛型呼叫端能依 id 建立實體,讀取端仍能透過 dyn 取得 id。
組成元素
Entity::create 建構契約、id(&self) 物件方法、build<T> 呼叫端。
所有權/派發
建構函式回傳 Self 的所有權;create 使用具體型別呼叫,id 可透過 dyn 派發。
如何擴充
其他實體可實作相同建立契約;需要驗證時可改回傳 Result。
成本與限制
where Self: Sized 將 create 排除於 trait object 呼叫之外;不是在 &dyn Entity 上建立未知型別。
改選時機
若工廠本身須於執行時切換,設計有 &self 的工廠方法,並回傳 enum 或 Box trait object。

參考:明確不可由 trait object 派發的方法。

06_associated_functions.rs
trait Entity {
    fn create(id: u64) -> Self where Self: Sized;
    fn id(&self) -> u64;
}

struct Order { id: u64 }

impl Entity for Order {
    fn create(id: u64) -> Self { Self { id } }
    fn id(&self) -> u64 { self.id }
}

fn build<T: Entity>(id: u64) -> T {
    T::create(id)
}

fn main() {
    let order: Order = build(1001);
    let view: &dyn Entity = &order;
    assert_eq!(view.id(), 1001);

    let second = Order::create(1002);
    assert_eq!(second.id(), 1002);
    println!("order IDs: {}, {}", view.id(), second.id());
}
預期輸出與驗證重點

泛型工廠與具體型別均可建立值;加上 Sized 限制的建構子不妨礙 id 的動態呼叫。

order IDs: 1001, 1002

07 · 關聯常數:綁定型別層級的固定資料

協定版本或固定服務代碼可以是關聯常數。需要由設定檔調整的逾時、端點或重試次數,則應放在執行期設定結構中;常數不會隨物件實例改變。

設計需求
每個協定型別必須提供代碼與預設逾時,但允許呼叫端覆寫實際逾時。
組成元素
ServiceConfig 常數契約、具體服務型別、執行期 RuntimeConfig。
所有權/派發
以 T::CONST 取得型別資料,不需要實例,也不是虛擬方法呼叫。
如何擴充
新型別提供必要常數;未覆寫的常數可以沿用 trait 的預設值。
成本與限制
含關聯常數的 trait 不符合 dyn compatibility。常數不強制值在不同實作間唯一,也不保證周邊程式沒有執行成本。
改選時機
同一型別的不同物件需要不同值時,用欄位或方法;需要 dyn 時,將可查詢的資料暴露為物件方法。

參考:Trait 關聯項目與預設值。

07_associated_constants.rs
trait ServiceConfig {
    const CODE: &'static str;
    const DEFAULT_TIMEOUT_MS: u64 = 5_000;
}

struct PaymentGateway;

impl ServiceConfig for PaymentGateway {
    const CODE: &'static str = "PAYMENT_V1";
}

struct RuntimeConfig {
    timeout_ms: u64,
}

fn config_for<T: ServiceConfig>(timeout: Option<u64>) -> RuntimeConfig {
    RuntimeConfig {
        timeout_ms: timeout.unwrap_or(T::DEFAULT_TIMEOUT_MS),
    }
}

fn main() {
    let default = config_for::<PaymentGateway>(None);
    let custom = config_for::<PaymentGateway>(Some(1_200));
    assert_eq!(default.timeout_ms, 5_000);
    assert_eq!(custom.timeout_ms, 1_200);
    println!("{}: default={}ms, custom={}ms",
        PaymentGateway::CODE, default.timeout_ms, custom.timeout_ms);
}
預期輸出與驗證重點

型別常數提供預設值,實例設定可以覆寫它。

PAYMENT_V1: default=5000ms, custom=1200ms

08 · Supertraits 與預設方法:組合能力

trait Audit: Identity 表示所有 Audit 實作也必須實作 Identity。這是能力約束,不是 struct 欄位繼承。預設方法可以利用這組能力提供共用行為。

設計需求
稽核訊息必須包含操作者識別,並提供可共用的格式化方法。
組成元素
Identity 基礎能力、Audit 預設方法、AdminUser 的兩個 impl。
所有權/派發
識別資料以 &str 借用,避免每次 clone;預設方法可由具體型別或 dyn 使用。
如何擴充
新型別分別實作所需契約。只實作 Identity 不會自動得到 Audit,除非另外提供 blanket impl。
成本與限制
本例 format! 會建立 String。預設方法可被覆寫,不能用它保證不可跳過的安全檢查或稽核流程。
改選時機
若流程必須不可覆寫,把流程放在控制邊界的函式/包裝型別;只把允許替換的步驟留在 trait。

參考:Supertraits 的能力需求。

08_supertraits.rs
trait Identity {
    fn operator_id(&self) -> &str;
}

trait Audit: Identity {
    fn record(&self, action: &str) -> String {
        format!("[AUDIT] {}: {action}", self.operator_id())
    }
}

struct AdminUser { id: String }

impl Identity for AdminUser {
    fn operator_id(&self) -> &str { &self.id }
}

impl Audit for AdminUser {}

fn main() {
    let admin = AdminUser { id: String::from("ADMIN_888") };
    let direct = admin.record("review-order");
    let service: &dyn Audit = &admin;
    let dynamic = service.record("review-order");
    assert_eq!(direct, dynamic);
    assert_eq!(dynamic, "[AUDIT] ADMIN_888: review-order");
    println!("{dynamic}");
}
預期輸出與驗證重點

具體型別與 trait object 呼叫共用預設方法,結果一致。

[AUDIT] ADMIN_888: review-order
06 / API DESIGN

最後整理轉換與呼叫語意

語法應該讓失敗條件與領域含義更清楚。轉換、運算子與限定呼叫不會自動消除底層工作的成本。

09 · From / Into 與 TryFrom:分開轉換和驗證

使用 From<Source> for Target 表達不會失敗、保留語意且自然的轉換,標準函式庫會提供對應 Into。可能失敗的 DTO 驗證用 TryFrom 回傳 Result,讓 API 明確表達錯誤。

設計需求
結帳 API 可接受各種渠道輸入,同時拒絕非法金額。
組成元素
渠道到 enum 的 From、impl Into<PaymentKind> API 參數、Amount::try_from 驗證。
所有權/派發
Into 的轉換消耗輸入;本例將渠道包進 enum,結帳仍以 match 選擇渠道名稱。
如何擴充
為合理的新來源實作 From 或 TryFrom;轉換 trait 不會自動讓封閉 enum 變成開放集合。
成本與限制
包裝 unit struct 很便宜,但一般轉換可能配置、複製或解析。避免在 From 裡使用 panic 表示正常驗證失敗。
改選時機
只是借用參照而不需要轉移所有權時,考慮 &T 或 AsRef;多種語意不明的轉換改用具名方法。

參考:From 的語意要求與 Into blanket impl、TryFrom。

09_conversions.rs
struct Card;
struct Wallet;

enum PaymentKind {
    Card(Card),
    Wallet(Wallet),
}

impl From<Card> for PaymentKind {
    fn from(value: Card) -> Self { Self::Card(value) }
}
impl From<Wallet> for PaymentKind {
    fn from(value: Wallet) -> Self { Self::Wallet(value) }
}

struct Amount(u64);

impl TryFrom<i64> for Amount {
    type Error = &'static str;

    fn try_from(value: i64) -> Result<Self, Self::Error> {
        if value <= 0 {
            Err("amount must be positive")
        } else {
            // 正的 i64 一定可以無損放進 u64。
            Ok(Self(value as u64))
        }
    }
}

fn checkout(
    payment: impl Into<PaymentKind>,
    raw_amount: i64,
) -> Result<(&'static str, u64), &'static str> {
    let amount = Amount::try_from(raw_amount)?;
    let name = match payment.into() {
        PaymentKind::Card(_) => "card",
        PaymentKind::Wallet(_) => "wallet",
    };
    Ok((name, amount.0))
}

fn main() {
    let card = checkout(Card, 2_000).expect("valid amount");
    let wallet = checkout(Wallet, 1_200).expect("valid amount");
    assert_eq!(card, ("card", 2_000));
    assert_eq!(wallet, ("wallet", 1_200));
    assert_eq!(checkout(Card, -1), Err("amount must be positive"));
    assert!(checkout(Wallet, 0).is_err());
    println!("{}: {}; {}: {}", card.0, card.1, wallet.0, wallet.1);
    println!("invalid amount rejected");
}
預期輸出與驗證重點

兩個合法渠道都接受;負值與零會走明確錯誤路徑。

card: 2000; wallet: 1200
invalid amount rejected

10 · 運算子多載:語法必須符合領域語意

std::ops::Add 讓 a + b 呼叫 add。設計前先定義單位、幣別與溢位策略。以下用固定幣別的最小單位整數表示金額,避免把任意兩種幣別誤加。

設計需求
同幣別金額可以相加,且運算在 Debug 與 Release 有一致的溢位政策。
組成元素
Newtype TwdMinor、Add 的 Output、可回復的 checked_add。
所有權/派發
Add 以值接收兩個操作數;本例小型數值型別實作 Copy,原值仍可使用。
如何擴充
依語意加入 Sub、AddAssign,或另建其他幣別型別;跨幣轉換需要明確匯率。
成本與限制
本例不配置。+ 若溢位會明確 panic;對不可信輸入使用 checked_add 的 Option 分支,而不是依賴 primitive 加法的建置模式差異。
改選時機
可能失敗、單位不同或需要額外上下文的業務操作,優先採用具名方法與 Result/Option。

參考:Add 的 receiver、Rhs 與 Output。這個範例只示範整數單位的加總契約,不包含匯率、利率或四捨五入規則。

10_operator_overloading.rs
use std::ops::Add;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct TwdMinor(u64);

impl TwdMinor {
    fn checked_add(self, rhs: Self) -> Option<Self> {
        self.0.checked_add(rhs.0).map(Self)
    }
}

impl Add for TwdMinor {
    type Output = Self;

    fn add(self, rhs: Self) -> Self::Output {
        // 對 + 明訂一致政策:溢位即 panic。
        self.checked_add(rhs).expect("TwdMinor addition overflow")
    }
}

fn main() {
    let first = TwdMinor(150);
    let second = TwdMinor(350);
    let total = first + second;
    assert_eq!(total, TwdMinor(500));
    assert_eq!(first, TwdMinor(150)); // Copy 型別仍可使用原值。

    // 外部資料使用可回復的介面,這裡不會觸發 panic。
    let overflow = TwdMinor(u64::MAX).checked_add(TwdMinor(1));
    assert_eq!(overflow, None);
    println!("total: {} TWD minor units", total.0);
    println!("overflow detected: {}", overflow.is_none());
}
預期輸出與驗證重點

正常相加與溢位分支均有驗證。範例本身完整執行,不會刻意 panic。

total: 500 TWD minor units
overflow detected: true

11 · 完全限定語法:精確指出要呼叫的契約

<Type as Trait>::method(...) 明確指定 trait 的實作,常被歸入 UFCS 的討論。它解決名稱解析,不是另一種多型機制;派發方式仍取決於 receiver 的型別。

設計需求
同一 adapter 同時實作兩個具有 connect 方法的 API,並且還有自己的同名方法。
組成元素
兩個 trait、同一具體型別的多個 impl、完全限定呼叫。
所有權/派發
借用 adapter。指定具體型別時使用該實作;以 dyn 型別指定 trait 時仍是動態派發介面。
如何擴充
新增 trait 不必重新命名外部契約;在需要消歧義的呼叫位置限定來源。
成本與限制
語法本身不額外建立物件。不能因為寫了完全限定語法就宣稱所有呼叫都靜態。
改選時機
自己掌控的 API 若長期大量同名衝突,可考慮領域命名或獨立 adapter,降低讀者負擔。

同名方法不一定造成編譯錯誤:本例 adapter.connect() 會選擇 inherent 方法;若沒有該方法、又有多個可適用的 trait 方法,才需要消歧義。部分情境可簡寫成 Trait::method(&value)。

參考:同名方法與完全限定語法。

11_qualified_syntax.rs
trait LegacyApi {
    fn connect(&self) -> &'static str;
}
trait ModernApi {
    fn connect(&self) -> &'static str;
}

struct Adapter;

impl Adapter {
    fn connect(&self) -> &'static str { "inherent" }
}
impl LegacyApi for Adapter {
    fn connect(&self) -> &'static str { "legacy" }
}
impl ModernApi for Adapter {
    fn connect(&self) -> &'static str { "modern" }
}

fn main() {
    let adapter = Adapter;
    let own = adapter.connect();
    let old = <Adapter as LegacyApi>::connect(&adapter);
    let new = <Adapter as ModernApi>::connect(&adapter);
    assert_eq!((own, old, new), ("inherent", "legacy", "modern"));

    let object: &dyn ModernApi = &adapter;
    let dynamic = <dyn ModernApi as ModernApi>::connect(object);
    assert_eq!(dynamic, "modern");
    println!("{own} / {old} / {new}");
    println!("qualified dyn call: {dynamic}");
}
預期輸出與驗證重點

分別驗證 inherent、兩個 trait 實作,以及限定語法搭配 dyn 的呼叫。

inherent / legacy / modern
qualified dyn call: modern
07 / PUT IT TO WORK

把選型結果變成下一次設計的依據

一個結帳服務,可以組合多種方案

假設需求是「替換風控引擎、支援固定的支付渠道、允許新增通知渠道」,三個邊界就不必使用同一種抽象。

以下是依需求推導的設計建議,應隨實際限制調整
服務邊界建議元素理由與改選條件
外部金額輸入TryFrom → Amount(09)先驗證,讓內部流程處理有效資料;失敗直接回傳錯誤。
風控引擎T: RiskEngine(01)服務實例保留單一引擎型別;若需同一欄位切換不同型別,再採 enum 或 dyn。
支付渠道PaymentKind + match(03);From 簡化輸入(09)渠道是封閉集合;若開放第三方實作,重新評估 dyn。
通知集合Vec<Box<dyn NotificationChannel>>(02)服務持有不同渠道;若由外部管理生命週期,則可改用借用形式。
讀取訊息關聯型別(05)固定每種來源的輸出契約;是否使用 dyn 是另外的決定。

11 個主題的查用索引

從設計問題回查對應範例
想解決的問題必要元素必須記錄的限制
01 保留具體型別的可替換行為Trait bound + 泛型呼叫端單態化、型別傳播、回傳 impl Trait 的單一隱藏型別
02 擦除不同實作的型別Dyn-compatible trait + 指標 + 容器間接呼叫與配置分開評估
03 管理已知變體Enum + exhaustive match集合封閉、尺寸與分支成本
04 讓物件借用外部資料借用欄位 + 生命週期界限資料擁有者的有效範圍
05 固定實作的輸出關係type Item + 輸出約束Dyn 使用時指定相容的關聯型別
06 抽象建立操作無 receiver 的關聯函式Self: Sized 的位置與物件可呼叫介面
07 提供型別固定資料關聯常數 + 必要時的執行期設定非 dyn compatible;值不保證唯一
08 組合能力與共用行為Supertrait + 預設方法預設方法可以覆寫,不是不可繞過的流程
09 整理 API 輸入From / Into 或 TryFrom / TryInto失敗語意、所有權移動與實際轉換成本
10 提供領域運算Newtype + std::ops + Output幣別、單位、溢位與操作數消耗
11 解決名稱歧義<Type as Trait>::method名稱解析不決定靜態或動態派發

設計檢核清單

  1. 變動點:哪些行為真的需要替換?如果只是單一實作,是否用具體型別就足夠?
  2. 型別集合:由誰新增實作?是否需要在同一個欄位、回傳型別或容器保存不同具體型別?
  3. 所有權:誰建立、持有、釋放資料?是否真的需要 Box、共享所有權或跨執行緒?
  4. 契約:輸出型別、錯誤型別、變更能力與物件生命週期是否明確?需要 dyn 的方法是否符合相容規則?
  5. 成本:列出配置、複製、分支和間接呼叫。以接近實際工作量的 Release benchmark 驗證,不用這些 println! 教學範例比較效能。
  6. 演進:新增 enum 變體、增加必填 trait 方法或改變關聯型別,都可能影響既有使用者。公開 API 需另外考慮相容性。
  7. 邊界案例:除了正常路徑,也驗證空集合、拒絕輸入、溢位與資料耗盡等符合該契約的情況。
可重用的設計決策紀錄

需求:哪個模組要替換什麼行為,型別集合由誰管理?

選擇:派發方式、容器、所有權、生命週期、關聯型別與錯誤介面。

理由:選定方案滿足哪些限制;另兩個方案有哪些具體代價?

證據:功能驗證結果、代表性 benchmark、記憶體配置與程式碼大小。

重新評估條件:例如實作集合開放、需要跨執行緒、配置量超出預算,或建置時間過長。

驗證結果:2026-09-29,從本 HTML 擷取的 11 個範例,已以 rustc 1.97.1、Edition 2024 通過 Debug 與 Release 共 22 次編譯及執行;編譯警告視為錯誤,程式內斷言與預期輸出比對均通過。此為本機驗證,非 Playground 遠端執行紀錄。

08 / PRIMARY SOURCES

官方資料與後續查閱

語言規則以 Rust Reference 與標準函式庫文件為準;本文的選型建議是基於這些機制的工程判斷,並非語言對效能的保證。

Rust Trait 設計指南 · 單欄文章,無外部字型或腳本依賴。閱讀與複製可離線使用;開啟 Playground 與官方文件需要網路。回到頂端 ↑

留言

這個網誌中的熱門文章

[C#] 無法載入檔案或組件 或其相依性的其中之一。 找到的組件資訊清單定義與組件參考不符。 (發生例外狀況於 HRESULT: 0x80131040)

[VibeCoding] AI Vibecoding 的地基(下):五層架構的實作 Prompt

[Note] 公司常見的書信結尾