型別系統的深層力量
Rust 的型別系統遠不只是「宣告變數型別」這麼簡單。透過關聯型別、運算子重載、Newtype pattern 等機制,你可以讓型別系統成為你的設計工具——在編譯期就捕捉邏輯錯誤,同時保持程式碼的表達力。
本章涵蓋的主題:
| 主題 | 核心概念 |
|---|---|
| 關聯型別 | 讓 trait 的實作者決定一個「搭配的型別」 |
| 運算子重載 | 透過實作 std::ops traits 自訂 +、-、* 等運算子 |
| Newtype pattern | 用 tuple struct 包裝現有型別,提供型別安全 |
| 型別別名 | 用 type 為複雜型別取簡短名稱 |
| Never 型別 | ! 代表永遠不會完成的計算 |
| DST 與 Sized | 動態大小型別與編譯期大小保證 |
關聯型別 vs 泛型
關聯型別(Associated Types)
關聯型別讓 trait 的實作者指定一個「搭配的型別」,而不是由使用者(呼叫端)決定。最經典的例子是 Iterator trait:
trait Iterator {
type Item; // 關聯型別:每個實作只能有一種 Item
fn next(&mut self) -> Option<Self::Item>;
}自訂一個使用關聯型別的 trait:
trait Container {
type Item;
fn first(&self) -> Option<&Self::Item>;
fn last(&self) -> Option<&Self::Item>;
}
struct IntBucket {
values: Vec<i32>,
}
impl Container for IntBucket {
type Item = i32; // 實作時決定 Item 是 i32
fn first(&self) -> Option<&i32> {
self.values.first()
}
fn last(&self) -> Option<&i32> {
self.values.last()
}
}泛型 trait 的對比
泛型 trait 讓同一個型別可以為不同的型別參數分別實作:
trait GenericContainer<T> {
fn first(&self) -> Option<&T>;
}
struct MultiStore {
ints: Vec<i32>,
texts: Vec<String>,
}
// 同一個 struct 可以實作多次!
impl GenericContainer<i32> for MultiStore {
fn first(&self) -> Option<&i32> { self.ints.first() }
}
impl GenericContainer<String> for MultiStore {
fn first(&self) -> Option<&String> { self.texts.first() }
}如何選擇?
| 選擇 | 使用時機 | 範例 |
|---|---|---|
| 關聯型別 | 每個型別只有一種合理的實作 | Iterator::Item、Add::Output |
| 泛型參數 | 同一個型別需要多種實作 | From<T>、Into<T>、AsRef<T> |
簡單來說:關聯型別是「一對一」的關係,泛型是「一對多」的關係。
運算子重載
Rust 允許你透過實作 std::ops 模組中的 trait 來重載運算子。每個運算子對應一個 trait:
| 運算子 | Trait | 方法 |
|---|---|---|
a + b | Add<Rhs> | add(self, rhs: Rhs) -> Self::Output |
a - b | Sub<Rhs> | sub(self, rhs: Rhs) -> Self::Output |
a * b | Mul<Rhs> | mul(self, rhs: Rhs) -> Self::Output |
a / b | Div<Rhs> | div(self, rhs: Rhs) -> Self::Output |
-a | Neg | neg(self) -> Self::Output |
a == b | PartialEq | eq(&self, other: &Rhs) -> bool |
注意這些 trait 的 Output 就是關聯型別——運算子重載和關聯型別是緊密結合的。
實作範例:二維向量
use std::ops::{Add, Mul, Neg};
#[derive(Debug, Clone, Copy, PartialEq)]
struct Vec2 {
x: f64,
y: f64,
}
impl Vec2 {
fn new(x: f64, y: f64) -> Self {
Self { x, y }
}
}
// Vec2 + Vec2
impl Add for Vec2 {
type Output = Self;
fn add(self, rhs: Self) -> Self::Output {
Self::new(self.x + rhs.x, self.y + rhs.y)
}
}
// Vec2 * f64(純量乘法)
impl Mul<f64> for Vec2 {
type Output = Self;
fn mul(self, scalar: f64) -> Self::Output {
Self::new(self.x * scalar, self.y * scalar)
}
}
// -Vec2
impl Neg for Vec2 {
type Output = Self;
fn neg(self) -> Self::Output {
Self::new(-self.x, -self.y)
}
}
let v1 = Vec2::new(3.0, 4.0);
let v2 = Vec2::new(1.0, 2.0);
println!("{:?}", v1 + v2); // Vec2 { x: 4.0, y: 6.0 }
println!("{:?}", v1 * 2.0); // Vec2 { x: 6.0, y: 8.0 }
println!("{:?}", -v1); // Vec2 { x: -3.0, y: -4.0 }跨語言比較:運算子重載
| 語言 | 支援方式 | 限制 |
|---|---|---|
| Rust | 實作 std::ops traits | 只能重載已定義的運算子,不能發明新運算子 |
| C++ | operator+ 等函式 | 幾乎無限制,可能被濫用 |
| Python | __add__、__mul__ 等魔術方法 | 靈活但無型別保證 |
| Java | 不支援 | 只有 String 的 + 是語言內建的 |
| Go | 不支援 | 設計哲學上反對運算子重載 |
Newtype Pattern
Newtype pattern 是用零成本的 tuple struct 包裝現有型別,讓型別系統區分語意不同但底層相同的值。
基本用法:型別安全的單位
#[derive(Debug, Clone, Copy, PartialEq)]
struct Meters(f64);
#[derive(Debug, Clone, Copy, PartialEq)]
struct Seconds(f64);
#[derive(Debug, Clone, Copy, PartialEq)]
struct MetersPerSecond(f64);
fn calculate_speed(distance: Meters, time: Seconds) -> MetersPerSecond {
MetersPerSecond(distance.0 / time.0)
}
let d = Meters(100.0);
let t = Seconds(9.58);
let speed = calculate_speed(d, t);
// 如果不小心把參數傳反:
// let wrong = calculate_speed(t, d); // ❌ 編譯錯誤!型別不匹配不用 Newtype 的話,distance 和 time 都是 f64,傳反了編譯器不會報錯——這是很常見的 bug 來源。
繞過孤兒規則(Orphan Rule)
Rust 的孤兒規則規定:你只能為「自己的型別」實作「外部的 trait」,或為「外部的型別」實作「自己的 trait」。不能為「外部的型別」實作「外部的 trait」。
// ❌ 不行!Vec 和 Display 都不是我們定義的
// impl fmt::Display for Vec<String> { ... }
// ✅ 用 Newtype 包裝就可以了!
struct Wrapper(Vec<String>);
impl fmt::Display for Wrapper {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[{}]", self.0.join(", "))
}
}Wrapper 是我們自己的型別,所以可以為它實作任何 trait。
Newtype 的優缺點
| 優點 | 缺點 |
|---|---|
| 零執行期成本(編譯器會優化掉包裝) | 需要手動委託內部型別的方法 |
| 編譯期型別安全 | 增加程式碼量 |
| 可以為外部型別加上自訂行為 | 需要存取內部值時要用 .0 或方法 |
可以用
Dereftrait 自動委託方法呼叫,但要小心不要濫用——只在「新型別語意上是底層型別」時使用。
型別別名
type 關鍵字可以為現有型別建立別名,不會建立新型別——只是一個較短的名稱。
簡化複雜型別
// 沒有別名時,每次都要寫完整型別
fn takes_fn(f: Box<dyn Fn() + Send + 'static>) { f(); }
fn returns_fn() -> Box<dyn Fn() + Send + 'static> {
Box::new(|| println!("hello"))
}
// 用型別別名簡化
type Thunk = Box<dyn Fn() + Send + 'static>;
fn takes_fn(f: Thunk) { f(); }
fn returns_fn() -> Thunk { Box::new(|| println!("hello")) }標準函式庫的用法
std::io::Result<T> 就是型別別名的經典範例:
// 標準函式庫中的定義
type Result<T> = std::result::Result<T, std::io::Error>;
// 讓 io 函式簽名更簡潔
fn read_file() -> std::io::Result<String> { ... }
// 等同於
fn read_file() -> Result<String, std::io::Error> { ... }型別別名 vs Newtype
| 特性 | 型別別名(type) | Newtype |
|---|---|---|
| 建立新型別 | 否(只是別名) | 是 |
| 可互換使用 | 是 | 否 |
| 型別安全 | 無額外保證 | 編譯期區分 |
| 適用場景 | 簡化長型別 | 語意區分、繞過孤兒規則 |
Never 型別(!)
! 是 Rust 的 never 型別,代表一個永遠不會產生值的計算。它是所有型別的子型別(bottom type),可以被強制轉型為任何型別。
哪些表達式回傳 !?
| 表達式 | 說明 |
|---|---|
panic!("...") | 程式終止,不會回傳 |
loop { } | 無窮迴圈,永不結束 |
continue | 跳到下一次迭代,不產生值 |
std::process::exit() | 直接結束程序 |
unreachable!() | 標示不可達的程式碼 |
為什麼需要 !?
match 的每個分支必須回傳相同型別。! 可以匹配任何型別,所以 panic! 和 continue 可以出現在任何分支:
// panic! 回傳 !,可以匹配 i32
let x: i32 = match some_option {
Some(v) => v, // i32
None => panic!(), // ! → 可以當作 i32
};
// continue 回傳 !,可以匹配 i32
let numbers = vec![Some(1), None, Some(3)];
let mut sum = 0;
for n in &numbers {
let val: i32 = match n {
Some(v) => *v, // i32
None => continue, // ! → 可以當作 i32
};
sum += val;
}DST 與 Sized trait
動態大小型別(Dynamically Sized Types)
大多數型別在編譯期就知道確切大小(如 i32 是 4 bytes、bool 是 1 byte),但有些型別的大小要到執行期才知道:
| DST | 說明 | 使用方式 |
|---|---|---|
str | 字串切片的內容 | &str、Box<str> |
[T] | 切片的內容 | &[T]、Box<[T]> |
dyn Trait | trait 物件 | &dyn Trait、Box<dyn Trait> |
DST 不能直接使用,必須放在參考(&)或智慧指標(Box)後面。
Sized trait
Sized 是一個標記 trait(marker trait),表示「編譯期知道大小」。Rust 的所有泛型參數預設帶有 T: Sized 約束:
// 以下兩者等價
fn foo<T>(x: T) { }
fn foo<T: Sized>(x: T) { }如果你想讓泛型函式也能接受 DST,需要用 ?Sized 放鬆約束:
// ?Sized 表示「T 可能不是 Sized」
fn print_ref<T: std::fmt::Display + ?Sized>(val: &T) {
println!("{val}");
}
print_ref("hello"); // &str — str 不是 Sized
print_ref(&42); // &i32 — i32 是 Sized
print_ref(&String::from("hi")); // &String — String 是 Sized標準函式庫中很多接受
&T的函式都使用了?Sized,這讓它們既能接受&String也能接受&str。
跨語言比較:型別大小
| 概念 | Rust | C/C++ | Java | Go |
|---|---|---|---|---|
| 編譯期大小保證 | Sized trait | 部分(VLA 例外) | 全部(物件透過參考) | 全部(interface 透過指標) |
| 動態大小型別 | DST(str、[T]、dyn Trait) | VLA、不完整型別 | 不適用 | 不適用 |
| 使用 DST | 必須透過 & 或 Box | 指標 | 自動(GC 管理) | 自動 |
| 放鬆大小限制 | ?Sized | 無對應機制 | 不適用 | 不適用 |
練習
完整範例
本章所有範例的完整程式碼:
cargo run -p ch17_advanced_types動手練習
完成 code/ch17_advanced_types/examples/exercise_1.rs 中的練習,填入 todo!() 處的程式碼。
cargo run -p ch17_advanced_types --example exercise_1練習內容:
- 基礎:為自訂型別
Point實作Addtrait(兩點相加) - 基礎:Newtype pattern —
Meters包裝f64,實作單位轉換 - 中級:為
Vector3實作多個運算子(Add+Sub+Mul) - 中級:關聯型別 trait — 實作等差數列的
Sequencetrait - 中級:型別別名 — 用
TransformFn簡化函式組合 - 進階:Newtype 為
Vec<i32>實作Display(繞過孤兒規則)