9.3 特徵繫結(Trait Bounds)與派生(derive)#
特徵繫結的三種寫法#
特徵繫結(trait bound)是「這個泛型參數必須滿足哪些條件」的宣告。Rust 提供三種寫法,它們在語意上完全等價,差別只在於可讀性與適用場景。
第一種是內聯界定,把界定直接寫在尖括號裡:
use std::fmt::Debug;
fn print_all<T: Debug>(items: &[T]) {
for item in items {
println!("{item:?}");
}
}
fn main() {
print_all(&[1, 2, 3]);
print_all(&["a", "b"]);
}
第二種是 where 子句,把界定移到簽章之後:
use std::fmt::Debug;
fn print_all<T>(items: &[T])
where
T: Debug,
{
for item in items {
println!("{item:?}");
}
}
fn main() {
print_all(&[1, 2, 3]);
print_all(&["a", "b"]);
}
第三種是多重界定,用 + 連接多個特徵:
use std::fmt::{Debug, Display};
fn report<T: Debug + Display>(value: &T) {
println!("{value} / {value:?}");
}
fn main() {
report(&42);
report(&"hi");
}
這三種寫法可以混合使用,也可以在特徵名後加上括號來帶入生命週期或關聯型別的界定:
use std::fmt::Debug;
fn describe<T>(items: &[T])
where
T: Debug + Clone + Send + 'static,
{
for item in items {
let copy = item.clone();
println!("{copy:?}");
}
}
fn main() {
describe(&[String::from("a"), String::from("b")]);
}
實務上的選擇原則很直覺:單一或兩個簡短的界定用內聯形式,超過兩個、或需要關聯型別界定時用 where。where 的可讀性優勢在簽章很長時特別明顯。
為什麼 Rust 能支援多個特徵#
這個問題的答案直接關係到 Rust 的整體設計哲學。
Java 的類別只能單一繼承,但可以實作多個介面。這個限制並非任意,而是因為 Java 的類別繼承攜帶了「is-a」的語意——子類別必須是父類別的特例,而特例只能有一個父親。介面因為只是行為契約,沒有限制數量。
Rust 沒有繼承,也就不需要「is-a」的語意。它只有特徵,而特徵純粹是行為契約,沒有任何語意上的理由限制一個型別能實作幾個。因此 Rust 的型別可以同時實作任意多個特徵:
use std::fmt::{Debug, Display};
struct Report {
title: String,
}
impl Display for Report {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "報告:{}", self.title)
}
}
impl Debug for Report {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Report({})", self.title)
}
}
fn main() {
let r = Report { title: "季度".to_string() };
println!("{r}");
println!("{r:?}");
}
這個能力帶來的實際效果是行為的組合而非型別的階層。傳統物件導向設計中,「一個型別同時具有可比較、可序列化、可格式化三種行為」往往意味著要建立複雜的繼承樹或委派模式;在 Rust 中,只要三個 impl 區塊並排寫在一起即可。
另一個好處是沒有菱形繼承問題。在 C++ 中,若 D 同時繼承 B 與 C,而兩者都繼承自 A,則 A 的方法在 D 上會產生歧義。Rust 的特徵沒有這種結構性問題,因為特徵之間不存在「祖先—後代」的關係。
值得補充的是,Rust 的特徵之間可以建立「父特徵」關係(supertrait),例如 trait Read: Write,表示實作 Read 的型別必然也實作 Write。這是明確宣告的依賴,與 C++ 的隱性多重繼承不同,不會產生歧義。
派生巨集#
#[derive(...)] 讓編譯器自動產生特徵實作。最常用的一組是:
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
struct User {
id: u32,
name: String,
active: bool,
}
fn main() {
let a = User {
id: 1,
name: "ccc".to_string(),
active: true,
};
let b = a.clone();
println!("{a:?}");
println!("{}", a == b);
}
除了標準庫的這些,serde 提供了序列化的派生:
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Config {
port: u16,
host: String,
verbose: bool,
}
fn main() {
let cfg = Config {
port: 8080,
host: "127.0.0.1".to_string(),
verbose: false,
};
let json = serde_json::to_string(&cfg).unwrap();
println!("{json}");
}
常用的派生巨集可以整理成這張表:
| 巨集 | 產生的特徵 | 行為 | 前提條件 |
|---|---|---|---|
Debug | fmt::Debug | 產生 {:?} 輸出 | 所有欄位都可 Debug |
Clone | Clone | 產生深層複製 | 所有欄位都可 Clone |
Copy | Copy | 複製時位元複製 | 所有欄位都可 Copy,且型別無 Drop |
PartialEq | PartialEq | 產生 == 與 != | 所有欄位都可 PartialEq |
Eq | Eq | 標示「全等」 | 所有欄位都可 Eq |
PartialOrd | PartialOrd | 產生 < > 等比較 | 所有欄位都可 PartialOrd |
Ord | Ord | 產生全序比較 | 所有欄位都可 Ord |
Hash | Hash | 產生雜湊值 | 所有欄位都可 Hash |
Default | Default | 產生預設值 | 所有欄位都可 Default |
Serialize | serde::Serialize | 序列化為 JSON 等格式 | 來自 serde crate |
派生是編譯期的程式碼生成#
派生巨集本質上是編譯期的程式碼生成。#[derive(Clone)] 展開後大致等於:
#[derive(Debug)]
struct User {
id: u32,
name: String,
active: bool,
}
impl Clone for User {
fn clone(&self) -> Self {
User {
id: self.id,
active: self.active,
name: self.name.clone(),
}
}
}
fn main() {
let u = User { id: 1, name: "ccc".to_string(), active: true };
let v = u.clone();
println!("{v:?}");
}
這個展開是由編譯器的內建巨集(built-in macro)完成的,不需要外部 crate。serde 的 Serialize 與 Deserialize 則是由程序巨集(procedural macro)完成的——它們是真正的 Rust 程式,在編譯期執行並產生 token 串,再交給編譯器繼續處理。
無論哪種機制,結果都相同:派生的結果與手寫的等價,但少寫了幾十行重複樣板程式碼。
這個「等價性」是派生最重要的保證。由於派生是機械式的規則應用,它不會有人為疏漏。實務上唯一需要留意的,是派生所實作的語意是否正確。
派生對欄位的要求#
派生巨集有個關鍵特性:它要求所有欄位都實作對應的特徵。這是機械式生成的必然結果,因為派生的程式碼基本上是「對每個欄位呼叫對應的方法」。
#[derive(Clone)]
struct HoldsNonClone {
id: u32,
text: String,
}
fn main() {
let a = HoldsNonClone { id: 1, text: "x".to_string() };
let b = a.clone();
println!("{} {}", b.id, b.text);
}
這段程式碼會編譯失敗,因為 Box<dyn Fn()> 不實作 Clone,而 Clone 的派生要求 id 與 callback 都可 Clone。錯誤訊息會明確指出是 callback 欄位的問題。
這個限制看似嚴格,實則是一種保護。它保證了派生的 Clone 一定是深層複製,不會出現「複製了容器卻共享內部指標」的淺層陷阱。
Copy 的要求更嚴格:除了所有欄位都可 Copy 之外,型別本身不能實作 Drop。原因是 Copy 語意上表示「這個值複製後,原始值可以安全地繼續存在且兩者互不影響」;而 Drop 的存在表示型別有釋放資源的責任,此時複製會導致重複釋放,因此 Rust 直接禁止這種組合。
use std::rc::Rc;
#[derive(Copy, Clone)]
struct Point {
x: i32,
y: i32,
}
#[derive(Clone)]
struct Wrapper {
// Rc<i32> 可 Clone 不可 Copy
count: Rc<i32>,
}
fn main() {
let p = Point { x: 1, y: 2 };
let q = p;
let w = Wrapper { count: Rc::new(3) };
let w2 = w.clone();
println!("{} {} {}", q.x, w.count, w2.count);
}
Copy 與 Clone 為什麼要分開#
Copy 與 Clone 分開派生不是冗餘,而是刻意的設計。
Clone 的語意是「可以建立一個等價的副本」,這對任何型別都合理。Copy 的語意強得多:「複製之後,位元層級的複製就是完整的複製,不需要任何額外動作,且原值仍然有效」。Copy 的存在意味著型別在位元複製之後不會有任何資源管理上的問題。
這個區別在有堆積指標的型別上非常明顯。String 實作 Clone 但不實作 Copy,因為複製一個 String 必須配置新的堆積記憶體並複製內容。若 String 是 Copy,位元複製會讓兩個值指向同一塊記憶體,其中一個離開作用域時釋放記憶體,另一個就會被釋放後使用——這是典型的 use-after-free。
Rc<T> 的情況類似,它的引用計數使得「複製值」必須同時增加計數,因此它只能 Clone 不能 Copy。
實務上的規則是:只有純粹由值組成的型別(全部欄位都是 Copy 或原始型別)才應該派生 Copy。一旦型別含有 String、Vec、Box、Rc 或任何智慧指標,就應該停在 Clone。
另一個常見的陷阱是派生 Copy 與手寫 Drop 衝突:
#[derive(Copy, Clone)]
struct Resource {
id: u32,
}
fn main() {
let r = Resource { id: 7 };
let s = r;
println!("{} {}", r.id, s.id);
}
若再為 Resource 加上 impl Drop,編譯器會直接拒絕這個組合,錯誤訊息會說明 Copy 與 Drop 無法共存於同一個型別。
Rust 在編譯期就禁止這種組合,避免了資源被重複釋放的隱患。
常見的特徵實作#
除了派生之外,最常見的特徵實作是 Display 與 std::error::Error。
use std::error::Error;
use std::fmt;
#[derive(Debug)]
pub struct ParseError {
pub field: String,
pub raw: String,
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "欄位 {} 的值 {:?} 無法解析", self.field, self.raw)
}
}
impl Error for ParseError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
None
}
}
fn parse_field(field: &str, raw: &str) -> Result<u32, ParseError> {
raw.parse::<u32>().map_err(|_| ParseError {
field: field.to_string(),
raw: raw.to_string(),
})
}
fn main() {
match parse_field("port", "abc") {
Ok(v) => println!("{v}"),
Err(e) => println!("{e}"),
}
}
這個範例涵蓋了幾個要點。Display 實作面向使用者,所以用自然語言描述問題;Debug 透過派生取得,面向開發者,包含完整的結構資訊。Error 實作讓這個型別可以被 ? 轉換(只要有對應的 From 實作),並且在第 8.3 節的錯誤鏈中扮演角色。
Error 實作只需要 Debug 與 Display 作為前提條件,這正是 Error: Debug + Display 這個父特徵界定的作用。實作者因此無法忘記實作 Display——編譯器會提醒你。
孤兒規則的實務影響#
孤兒規則在實務中的影響,主要出現在你想為既有的外部型別擴充功能時。
假設你想讓 Vec<String> 能夠直接被 serde 序列化,但 serde 只為 Vec<T> 實作了序列化,而 Vec<T> 對 T 有額外要求。這個時候你可以用新類型模式繞過:
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct StringList(Vec<String>);
fn main() {
let list = StringList(vec!["a".to_string(), "b".to_string()]);
println!("{list:?}");
}
這個包裝型別可以自由實作 Serialize,因為 StringList 是本地型別。它同時保留了與 Vec<String> 之間的轉換能力。
另一個常見的應用是為外部型別實作自己的特徵。假設你定義了一個 Describe 特徵,然後想讓 Duration 也能實作它:
use std::time::Duration;
trait Describe {
fn describe(&self) -> String;
}
impl Describe for Duration {
fn describe(&self) -> String {
format!("{} 秒", self.as_secs())
}
}
fn main() {
println!("{}", Duration::from_secs(90).describe());
}
這是允許的,因為 Describe 是本地特徵。對照之下,若 Describe 來自另一個 crate,而你想為 Duration(外部型別)實作它,就會被孤兒規則拒絕。
這個規則帶來的實務原則很簡單:當你想為外部型別加行為時,讓特徵保持本地。若需要用到外部特徵,就改用新類型模式。
泛型參數的預設值#
特徵界定還可以帶上預設型別參數,這在容器 crate 中非常常見:
use std::fmt::Debug;
trait Storage {
type Item;
fn put(&mut self, item: Self::Item);
fn take(&mut self) -> Option<Self::Item>;
}
struct LogBuffer<T = u32> {
items: Vec<T>,
}
impl<T> Storage for LogBuffer<T> {
type Item = T;
fn put(&mut self, item: T) {
self.items.push(item);
}
fn take(&mut self) -> Option<T> {
self.items.pop()
}
}
fn main() {
let mut buf: LogBuffer = LogBuffer { items: Vec::new() };
buf.put(42);
println!("{:?}", buf.take());
}
LogBuffer<T = u32> 中的 = u32 是預設型別參數。當使用時不指定 T(如 LogBuffer 而不是 LogBuffer<String>),編譯器會採用預設值。這個機制在 Rust 2021 之前比較常用,之後因為 impl Trait 與型別別名(type alias)的發展而較少使用。
這裡要特別注意,預設型別參數不能用在函式簽章中。fn foo<T = u32>(...) 是非法的,只有結構體與列舉可以宣告預設值。此外,預設型別參數在方法的呼叫處不會被套用——你寫 LogBuffer::<T>::new() 時必須顯式指定。
界定錯誤的診斷#
泛型最令人挫折的經驗之一,是編譯器錯誤訊息常常很冗長。它之所以如此,是因為當編譯器無法證明某個特徵成立時,它必須列出所有可能的實例化組合來說明哪一個不成立。
use std::fmt::Debug;
fn show<T: Clone + Debug>(value: T) {
println!("{:?}", value.clone());
}
fn main() {
show(42);
show(String::from("hi"));
}
這段程式碼本身沒問題,但若把 Clone 換成一個只對部分型別實作的自訂特徵,錯誤訊息會變成一大段帶有 note: required by a bound in ... 的堆疊。
改善診斷品質的實務技巧有幾個。第一是讓界定盡量精簡:每多一個特徵界定,錯誤訊息中需要考慮的組合就多一份。第二是用 where 子句把界定集中,讓錯誤訊息更容易閱讀。第三是先寫具體的函式測試,確認型別真的滿足了所有需求,再抽成泛型。
Clippy 也提供了相關的 lint,例如 trait_duplication_in_bounds 會提醒你在多處重複相同的界定。
特徵與泛型的組合慣例#
實務上,泛型與特徵很少單獨出現,它們通常以固定的組合被使用。認識這些組合能讓你讀懂標準庫的簽章。
最常見的是 T: Trait + ?Sized。?Sized 是鬆弛的 Sized 界定,允許 T 是不可Sized的型別(例如 str 或 [T])。這是必要的,因為泛型參數預設隱含 Sized,而特徵物件 dyn Trait 恰好是不可 Sized 的。若沒有 ?Sized,你就無法寫出接受 &dyn Trait 的泛型函式:
use std::fmt::Debug;
fn print_debug<T: Debug + ?Sized>(value: &T) {
println!("{value:?}");
}
fn main() {
let s: &str = "hello";
print_debug(s);
print_debug(&42);
}
第二個常見組合是關聯型別的投影。當特徵有 type Item,而函式需要同時處理多種實作者時,可以用 <T as Trait>::Item 的完全限定寫法來指稱關聯型別:
use std::fmt::Debug;
trait Container {
type Item;
fn first(&self) -> Option<&Self::Item>;
}
fn show_first<C>(c: &C)
where
C: Container,
C::Item: Debug,
{
if let Some(item) = c.first() {
println!("{item:?}");
}
}
struct Bag<T> {
items: Vec<T>,
}
impl<T> Container for Bag<T> {
type Item = T;
fn first(&self) -> Option<&T> {
self.items.first()
}
}
fn main() {
show_first(&Bag { items: vec![1, 2, 3] });
show_first(&Bag { items: vec!["a", "b"] });
}
第三個組合是透過關聯型別統一不同的介面。標準庫的 IntoIterator 就是最好的例子——Vec、HashMap、String、甚至整數都能呼叫 .into_iter(),因為它們都實作了 IntoIterator,而它的 Item 關聯型別描述了迭代出來的元素型別。透過這個設計,泛型演算法只需寫一份。
這三個組合共同說明了一件事:特徵的關聯型別是 Rust 抽象系統中最靈活的部分,它讓「介面」不只是方法集合,而是帶著型別資訊的方法集合。這正是 Rust 能夠在不依賴繼承階層的情況下,建立起類似 Java 泛型與 C++ 模板之表達力的原因。
派生的進階用法#
除了標準庫提供的這些,serde 還支援自訂序列化。當型別的欄位名稱與序列化後的欄位名稱不同,或是需要跳過某些欄位時,可以搭配 #[serde(rename)] 與 #[serde(skip)]:
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Config {
#[serde(rename = "listen_port")]
port: u16,
#[serde(skip)]
cache: Option<Vec<u8>>,
}
fn main() {
let cfg = Config {
port: 8080,
cache: None,
};
println!("{cfg:?}");
}
這裡的 rename 把 Rust 欄位名 port 對應到序列化後的 listen_port,而 skip 則讓 cache 不參與序列化。這類客製化是 derive 之外的補充,但大多數情況下預設行為已經足夠。
另一個常見的進階用法是為泛型型別派生。只要所有泛型參數都滿足對應的特徵要求,派生巨集就能自動為泛型結構體產生正確的實作:
use std::fmt::Debug;
#[derive(Debug, Clone, PartialEq)]
struct Wrapper<T> {
value: T,
}
fn main() {
let a = Wrapper { value: 1 };
let b = a.clone();
println!("{:?}", a == b);
}
這裡的 #[derive(Clone)] 對 Wrapper<T> 產生的實作,其要求是 T: Clone。當你寫 Wrapper<String> 時,String 確實實作 Clone,所以編譯通過;而 Wrapper<某個不可 Clone 的型別> 就會在編譯期被擋下。派生的泛型要求是由編譯器自動推導的,不需要手動撰寫任何額外程式碼。
這是派生宏最優雅的特性之一:它把「所有欄位都要滿足要求」這條規則自動推廣到了所有型別層級。你不需要為 Wrapper<T>、Wrapper<Wrapper<T>> 或任何巢狀組合重複撰寫實作。
為什麼 Rust 能組合行為而 Java 不能#
回到本章開頭的問題:既然 Rust 沒有繼承,為什麼它能組合出比 Java 更靈活的行為?
答案在於兩種機制解決的是不同的問題。Java 的繼承解決的是「程式碼重用」——子類別複用父類別的實作。Rust 用泛型與特徵預設方法解決同一個問題,但方式不同:預設方法提供重用,impl Trait for T 提供泛化,而多重實作提供組合。
Java 的多重介面實作並非組合,而是「同時符合多個契約」。Rust 的多重特徵實作則是「同時擁有多組方法」。差別在於 Java 若兩個介面有同名方法,必須手動消歧;而 Rust 的特徵之間若方法名衝突,編譯器會要求明確的限定語法,衝突因此變得可見而非隱藏。
另一個差異是為外部型別實作。Java 中你想讓 java.util.List 支援某個自訂介面,只能包裝或改用繼承自它的類別;Rust 中你可以在自己的 crate 裡定義 trait Summarizable,然後直接 impl Summarizable for Vec<T>——因為特徵是本地的。這就是 Rust 能在不修改型別定義的情況下擴充既有型別的原因。
這三點合起來,讓 Rust 的抽象能力不依賴任何階層結構:沒有父類別、沒有子類別、沒有菱形繼承,只有彼此獨立的行為契約。這也解釋了為什麼 Rust 的 Vec<T>、String、Box<T> 這些型別能自由地實作 Display、Debug、Clone、From 等多種特徵,而彼此之間毫無干擾。
靜態派發與 vtable 的取捨#
這個標題值得單獨說明,因為它在實務中的影響比想像中大。
一個型別可以同時有靜態派發的呼叫與 vtable 的呼叫,兩者並存不悖:
use std::fmt::{Debug, Display};
trait Greet {
fn greet(&self) -> String;
}
struct Robot;
impl Greet for Robot {
fn greet(&self) -> String {
String::from("嗶嗶")
}
}
impl Display for Robot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "機器人")
}
}
impl Debug for Robot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Robot")
}
}
fn static_greet<T: Greet>(x: &T) -> String {
x.greet()
}
fn dynamic_greet(x: &dyn Greet) -> String {
x.greet()
}
fn main() {
let r = Robot;
println!("{}", static_greet(&r));
println!("{}", dynamic_greet(&r));
}
static_greet 呼叫的是靜態派發:編譯器知道 T = Robot,直接把 Robot::greet 內聯進去。dynamic_greet 呼叫的是動態派發:透過 vtable 查表後跳轉。
這裡的取捨有三層。效能層面:靜態派發零開銷,動態派發多一次間接跳轉。程式體積層面:靜態派發為每個型別產生一份程式碼,動態派發只有一份 vtable。表達力層面:只有動態派發能讓不同型別共存於同一個容器。
實務上的建議是預設用靜態派發,只在確實需要異質容器時才付動態派發的代價。這個判斷在設計 API 時特別重要:一個公開的函式若接受 impl Trait,呼叫端可以使用泛型或 trait object 兩種方式,函式本身則因為單態化而對每個呼叫者最佳化。
反過來,一個接受 Box<dyn Trait> 的函式則永遠是動態派發,無論呼叫端傳入什麼。若這個函式在-hot path 上被大量呼叫,這就是一個值得注意的效能陷阱。
派生、界定與靜態多態的整體關係#
把本章到目前為止的概念串起來,可以畫出這張圖:
flowchart TD
A["型別 MyStruct"] --> B["derive 產生基本特徵實作"]
A --> C["手寫特徵實作 Display / Error 等"]
B --> D["MyStruct 自動擁有 Clone / Debug 等能力"]
D --> E["泛型函式 T: Debug 可以接受 MyStruct"]
E --> F["單態化:產生 MyStruct 專屬版本"]
F --> G["零執行期開銷"]
C --> H["特徵物件 Box<dyn Trait>"]
H --> I["vtable 間接呼叫,有執行期開銷"]
H --> J["可與其他型別放入同一集合"]這張圖說明了 Rust 抽象體系的完整鏈路:derive 提供預設的特徵實作,泛型加特徵界定提供靜態的多型性,trait object 提供動態的多型性。三者共享同一套特徵定義,這是 Rust 抽象系統的一致性所在。
引出第 10 章#
本章到這裡,Rust 的型別系統與特徵機制已經相當完整。你可以用泛型寫出對型別完全泛化的演算法,用特徵表達行為契約,用派生自動產生大量樣板程式碼。
但有一個問題尚未解決:所有權與生命週期的靜態限制。
泛型與特徵都在編譯期運作,這意味著它們能表達的關係是靜態的、固定的。但實務中經常需要執行期決定的關係。例如一個節點需要持有多個同類型的子節點,而這些子節點的數量在執行期才知道;或是一個物件需要被多個擁有者共享,而共享關係在執行期才建立。
第 5 章的所有權規則之所以嚴格,正是因為 Rust 用靜態的方式保證記憶體安全。要突破這個限制,就必須引入執行期的引用計數或執行期的借用檢查。
這正是第 10 章智慧指標的主題。Box<T> 提供堆積配置與擁有權轉移,Rc<T> 提供共享所有權與執行期計數,RefCell<T> 提供內部可變性與執行期借用檢查。這三者讓 Rust 的抽象能力從「編譯期確定」延伸到「執行期彈性」,同時不犧牲記憶體安全——只是把部分檢查的時機從編譯期挪到了執行期。
換句話說,第 9 章讓你能泛化你的程式碼,第 10 章讓你能組合你的程式碼。兩者結合,才是 Rust 所謂零成本抽象的完整拼圖。