[Rust] Trait 設計模式元素拆解與組合規則
先決定哪些部分需要變動、由誰持有資料,再選擇派發方式與介面。把 11 個常用主題放回各自的設計位置,讓這份文章成為下一次開發時能直接查用的指南。
適合已熟悉 struct、enum、借用與基本 trait 語法的讀者。內容查核:2026-09-29。
先分開三個設計維度
Trait 定義可被實作的契約。泛型、trait object、關聯型別與轉換 trait 解決的是不同問題;它們可以組合,不是 11 個互斥選項。若只有一種實作,也沒有替換或測試隔離需求,先使用具體型別與 inherent impl 即可。
① 行為如何選擇?
T: Trait 保留具體型別;dyn Trait 擦除具體型別;enum + match 在已知變體間選擇行為。
② 資料由誰持有?
T 持有值、&T 借用值、Box<T> 持有配置於堆積的值。所有權與派發方式分開決定。
③ 介面承諾什麼?
用關聯型別描述輸出、用 supertraits 表達能力需求,再用建構、轉換與運算子設計呼叫語意。
機制依據:泛型與單態化、Trait objects。
先選派發,再選所有權
執行時才知道要使用哪個值,不等於一定要使用 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)
把實際差異攤開比較
| 比較元素 | 泛型 T: Trait | dyn Trait | enum + match |
|---|---|---|---|
| 呼叫機制 | 具體型別單態化;容易提供內聯機會 | 透過 vtable 間接呼叫;可能被最佳化 | 依變體選擇分支;分支內型別已知 |
| 異質容器 | 單一 Vec<T> 的 T 固定 | 可用 Vec<Box<dyn Trait>> 等形式 | Vec<Enum> 可容納不同變體 |
| 新增型別 | 新增符合契約的實作;呼叫端重新編譯 | 可新增實作而不增加中央 enum 變體;仍須編譯/連結 | 修改 enum 與相關 match;集合由定義端控制 |
| 記憶體 | 依 T 與容器決定,不由泛型自動配置 | &dyn 借用不因此配置;Box 對非零大小值通常配置 | 可直接內嵌變體;大小受最大變體、對齊與表示法影響 |
| 主要代價 | 可能增加編譯時間與二進位大小 | 間接呼叫、可能的配置與較少最佳化資訊 | 分支、較大變體拖高尺寸、集中修改成本 |
| 適合起點 | 保留型別資訊、可替換的演算法核心 | 外部擴充、統一儲存不同實作 | 可列舉的支付方式、狀態或後端 |
Enum 的變體在編譯期已知,實際分支仍可在執行期選擇。它不保證較快,也不保證「最大變體 + 固定 tag」的精確大小;對齊與 niche 最佳化都可能影響 layout。容器或變體內的 String、Box 仍可能配置記憶體。型別配置規則。
使用 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 為準。
先決定行為如何被持有與呼叫
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 方法呼叫仍可能動態派發;不能只看函式有沒有泛型,就斷言呼叫一定是靜態。
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 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。
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 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:
再定義型別關係與共用契約
以下元素可和泛型、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。
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 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 = ℴ
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 關聯項目與預設值。
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。
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
最後整理轉換與呼叫語意
語法應該讓失敗條件與領域含義更清楚。轉換、運算子與限定呼叫不會自動消除底層工作的成本。
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;多種語意不明的轉換改用具名方法。
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。這個範例只示範整數單位的加總契約,不包含匯率、利率或四捨五入規則。
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)。
參考:同名方法與完全限定語法。
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
把選型結果變成下一次設計的依據
一個結帳服務,可以組合多種方案
假設需求是「替換風控引擎、支援固定的支付渠道、允許新增通知渠道」,三個邊界就不必使用同一種抽象。
| 服務邊界 | 建議元素 | 理由與改選條件 |
|---|---|---|
| 外部金額輸入 | 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 | 名稱解析不決定靜態或動態派發 |
設計檢核清單
- 變動點:哪些行為真的需要替換?如果只是單一實作,是否用具體型別就足夠?
- 型別集合:由誰新增實作?是否需要在同一個欄位、回傳型別或容器保存不同具體型別?
- 所有權:誰建立、持有、釋放資料?是否真的需要 Box、共享所有權或跨執行緒?
- 契約:輸出型別、錯誤型別、變更能力與物件生命週期是否明確?需要 dyn 的方法是否符合相容規則?
- 成本:列出配置、複製、分支和間接呼叫。以接近實際工作量的 Release benchmark 驗證,不用這些 println! 教學範例比較效能。
- 演進:新增 enum 變體、增加必填 trait 方法或改變關聯型別,都可能影響既有使用者。公開 API 需另外考慮相容性。
- 邊界案例:除了正常路徑,也驗證空集合、拒絕輸入、溢位與資料耗盡等符合該契約的情況。
可重用的設計決策紀錄
需求:哪個模組要替換什麼行為,型別集合由誰管理?
選擇:派發方式、容器、所有權、生命週期、關聯型別與錯誤介面。
理由:選定方案滿足哪些限制;另兩個方案有哪些具體代價?
證據:功能驗證結果、代表性 benchmark、記憶體配置與程式碼大小。
重新評估條件:例如實作集合開放、需要跨執行緒、配置量超出預算,或建置時間過長。
驗證結果:2026-09-29,從本 HTML 擷取的 11 個範例,已以 rustc 1.97.1、Edition 2024 通過 Debug 與 Release 共 22 次編譯及執行;編譯警告視為錯誤,程式內斷言與預期輸出比對均通過。此為本機驗證,非 Playground 遠端執行紀錄。
官方資料與後續查閱
語言規則以 Rust Reference 與標準函式庫文件為準;本文的選型建議是基於這些機制的工程判斷,並非語言對效能的保證。
留言
張貼留言