课程概览 · 第 10 章
上一篇:模式匹配:用穷尽分支处理业务状态
下一篇:集合:从访问模式选择 Vec、Map、Set 与队列
同一个“找出最大元素”的算法,你可能会先为整数写一遍,再为任务标题写一遍,最后发现两段代码几乎相同。泛型解决“同一算法处理多种类型”,trait 解决“不同类型提供同一种能力”;本章的重点不是用尽 trait 技巧,而是让抽象只暴露调用方真正需要的行为,并在静态分发与动态分发之间做出有依据的选择。
图:先定义调用方需要的能力;编译期类型已知时优先泛型,需要运行时异构时再选 trait object。学习目标与默认选择
学完本章你应当能够:
- 从一个具体实现出发,判断何时值得泛型化;
- 用 trait bound(
<T: Ord>)和关联类型(type Error;)表达算法与存储的真实要求; - 解释单态化、静态分发与动态分发的成本差异;
- 用 newtype 适配器绕开孤儿规则,而不污染公共 API。
默认选择:先写具体类型的具体函数;第二个真实用例出现时再引入泛型。默认用泛型(静态分发);只有运行时才能确定实现时才用 dyn Trait。
从具体实现开始
过早抽象比重复更贵。先为任务优先级写一个具体的“取最大”函数,让它可测试、可读:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| #[derive(Debug, PartialEq)] struct Task { priority: i64, title: String, }
fn highest_priority(tasks: &[Task]) -> Option<&Task> { tasks.iter().max_by_key(|task| task.priority) }
fn main() { let tasks = vec![ Task { priority: 2, title: String::from("write docs") }, Task { priority: 5, title: String::from("fix build") }, Task { priority: 3, title: String::from("review pr") }, ]; let top = highest_priority(&tasks).expect("non-empty task list"); assert_eq!(top.title, "fix build");
assert!(highest_priority(&[]).is_none()); println!("top task: {}", top.title); }
|
当第二个用例出现——比如还需要在 Vec<i64> 里取最大值——再把“取最大”提炼成泛型函数,并让它返回借用而不是克隆:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
fn largest<T: Ord>(items: &[T]) -> Option<&T> { items.iter().max() }
fn main() { let priorities = [3, 7, 5]; assert_eq!(largest(&priorities), Some(&7));
let titles = [String::from("alpha"), String::from("beta")]; assert_eq!(largest(&titles).map(String::as_str), Some("beta"));
let empty: [i64; 0] = []; assert_eq!(largest(&empty), None); println!("largest ok"); }
|
T: Ord 是 trait bound:它把这个函数的要求(可完全排序)写进签名,调用方在编译期证明自己满足要求。写成 fn largest(tasks: &[Task]) 只能服务一种类型;写成 fn largest<T>(items: &[T]) 则连比较都做不了——没有 bound,T 上没有任何可用方法。
用 trait 定义能力边界
第 6–8 章的任务模型需要持久化。与其绑定某个具体存储,不如定义一个最小能力边界,让内存实现、文件实现、测试用的假实现都满足它:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65
| #[derive(Debug, Clone, PartialEq)] struct TaskId(u64); #[derive(Debug, Clone, PartialEq)] #[allow(dead_code)] enum TaskState { Todo, InProgress { started_at: u64 }, Done { finished_at: u64 }, Cancelled { reason: String }, }
#[derive(Debug, Clone, PartialEq)] struct Task { id: TaskId, title: String, state: TaskState, labels: Vec<String>, }
trait TaskStore { type Error;
fn get(&self, id: TaskId) -> Result<Option<&Task>, Self::Error>; fn insert(&mut self, task: Task) -> Result<(), Self::Error>; }
#[derive(Default)] struct InMemoryStore { tasks: Vec<Task>, }
impl TaskStore for InMemoryStore { type Error = std::convert::Infallible;
fn get(&self, id: TaskId) -> Result<Option<&Task>, Self::Error> { Ok(self.tasks.iter().find(|task| task.id == id)) }
fn insert(&mut self, task: Task) -> Result<(), Self::Error> { self.tasks.push(task); Ok(()) } }
fn main() { let mut store = InMemoryStore::default(); let task = Task { id: TaskId(1), title: String::from("ship release"), state: TaskState::InProgress { started_at: 100 }, labels: vec![String::from("ops")], }; store.insert(task).expect("in-memory insert cannot fail");
let found = store.get(TaskId(1)).expect("in-memory get cannot fail"); assert_eq!(found.expect("task was just inserted").title, "ship release");
let missing = store.get(TaskId(99)).expect("in-memory get cannot fail"); assert!(missing.is_none()); println!("TaskStore boundary ok"); }
|
两个设计决策值得注意:
- 关联类型
type Error 适合“一个实现对应一种输出/错误类型”。如果同一个类型需要以多种错误类型实现同一 trait,才考虑把 Error 提升为泛型 trait 参数——那会让每个调用点的类型都变复杂。 Option 与 Result 分工:get 返回 Result<Option<&Task>, _>,因为“任务不存在”是正常缺失(第 13 章会系统展开),而“存储损坏/IO 失败”才是可恢复失败。
公共 trait 应小:把读取、写入、批量、事务拆成不同能力,避免一个 trait 强迫每种实现承担不需要的方法(接口隔离)。
单态化:泛型的零成本来源
Rust 为实际使用到的每个具体 T 生成一份专门代码,这称为单态化(monomorphization)。largest(&priorities) 和 largest(&titles) 编译后是两个不同的函数,调用点是直接调用,可以内联、没有虚表查询。代价是可能增加二进制体积和编译时间。
trait bound 在编译期限制可调用的方法集合,不等于运行时继承体系:没有基类、没有运行时类型信息隐式传递。只有使用 dyn Trait 时,值才通过(数据指针, 虚表)对进行动态分发。
派生(#[derive(Debug, Clone, PartialEq)])是编译器替你写的 impl, blanket impl(如标准库“T: Ord 则 &T: Ord”)是标准库替所有满足条件的类型写的 impl——两者都只在用到时才生成或查找,不需要你预先理解全部规则。
静态分发:impl Write 与泛型输出函数
把“输出到某处”抽象成泛型函数。类型在编译期确定,调用是静态分发:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| use std::fmt; use std::io::{self, Write};
fn write_status<W: Write>(writer: &mut W, task_id: u64, status: &str) -> io::Result<()> { writeln!(writer, "task {task_id}: {status}") }
struct LogSink { buf: Vec<u8>, }
impl Write for LogSink { fn write(&mut self, buf: &[u8]) -> io::Result<usize> { self.buf.extend_from_slice(buf); Ok(buf.len()) } fn flush(&mut self) -> io::Result<()> { Ok(()) } }
impl fmt::Display for LogSink { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", String::from_utf8_lossy(&self.buf)) } }
fn main() -> io::Result<()> { let mut sink = LogSink { buf: Vec::new() }; write_status(&mut sink, 7, "started")?; write_status(&mut sink, 7, "done")?; assert_eq!(sink.to_string(), "task 7: started\ntask 7: done\n");
let mut buf: Vec<u8> = Vec::new(); write_status(&mut buf, 8, "cancelled")?; assert_eq!(buf, b"task 8: cancelled\n".to_vec()); println!("static dispatch ok"); Ok(()) }
|
同一个 write_status 在编译期生成了两个版本:一个面向 LogSink,一个面向 Vec<u8>。这就是 impl Trait/泛型的成本模型:零运行时开销,换取代码体积与编译时间。
动态分发:&mut dyn Write 与运行时选择
当目标在运行时才能确定–比如由命令行参数决定日志写到哪里–静态分发就不够了,需要 trait object:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| use std::fs::File; use std::io::{self, BufWriter, IsTerminal, Write};
fn write_status(writer: &mut dyn Write, task_id: u64, status: &str) -> io::Result<()> { writeln!(writer, "task {task_id}: {status}") }
fn main() -> io::Result<()> { let stderr = io::stderr(); let mut buf: Vec<u8> = Vec::new();
let target: &mut dyn Write = if stderr.is_terminal() { &mut stderr.lock() } else { &mut buf }; write_status(target, 42, "running")?;
if !stderr.is_terminal() { assert_eq!(buf, b"task 42: running\n".to_vec()); }
let file = File::create("/tmp/rust-course-dyn-write.log")?; let mut file_writer = BufWriter::new(file); write_status(&mut file_writer, 42, "flushed")?; file_writer.flush()?; println!("dynamic dispatch ok"); Ok(()) }
|
&mut dyn Write 是一个胖指针:数据指针 + 虚表指针。每次方法调用经虚表间接跳转,通常还会阻止内联。它的价值是运行时可换实现;它的约束是 object safety(对象安全):trait 的方法不能返回 Self、不能有泛型参数、不能使用 Self 作为按值参数(否则编译器无法为“未知大小类型”构造虚表)。Write 满足这些约束,而 Clone(返回 Self)不满足——这就是为什么没有 dyn Clone。
| 选择 | 适合场景 | 代价 |
|---|
泛型 / impl Trait | 类型在编译期确定 | 静态分发;可能增大代码体积与编译时间 |
&dyn Trait / Box<dyn Trait> | 运行时选择实现、异构集合 | 虚表间接调用,通常阻止内联,Box 额外堆分配 |
| 枚举 | 已知有限几种实现 | 无间接调用,但加变体要改代码 |
默认顺序:具体类型 → 枚举 → 泛型 → dyn Trait。不要把 dyn Trait 当默认多态。
孤儿规则与 newtype 适配器
不能为“外部 trait + 外部类型”的组合直接实现 trait(孤儿规则)。这条规则保证不同 crate 不会为同一组合给出冲突实现。需要适配时,用自己的 newtype 包装外部类型:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| use std::fmt; use std::hash::{Hash, Hasher};
struct ExternalTaskRef(u64);
struct TaskRef(ExternalTaskRef);
impl PartialEq for TaskRef { fn eq(&self, other: &Self) -> bool { (self.0).0 == (other.0).0 } } impl Eq for TaskRef {}
impl Hash for TaskRef { fn hash<H: Hasher>(&self, state: &mut H) { (self.0).0.hash(state); } }
impl fmt::Display for TaskRef { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "task-{}", (self.0).0) } }
fn main() { let a = TaskRef(ExternalTaskRef(1)); let b = TaskRef(ExternalTaskRef(2)); assert_eq!(a.to_string(), "task-1"); assert!(!a.eq(&b)); let mut set = std::collections::HashSet::new(); set.insert(a); set.insert(b); set.insert(TaskRef(ExternalTaskRef(1))); assert_eq!(set.len(), 2); println!("newtype adapter ok"); }
|
newtype 的额外收益:它是一个领域类型(“任务引用”),不是裸的 u64,编译器能防止你把它误传给别的整数参数(第 7 章的 TaskId 同理)。
边界与失败场景
- 泛型过多:函数签名出现三四个 bound 时,通常说明它做了太多事,应拆分。
- 关联类型 vs 泛型参数:把
type Error 改成 trait TaskStore<E> 会让每个存储类型可以“以多种错误类型实现”,代价是所有泛型代码都要多携带一个参数。没有多实现需求时用关联类型。 dyn Trait 的对象安全约束:dyn Clone、dyn Sized 不存在;给 trait 加泛型方法会立刻破坏其所有 dyn 用法。- 单态化体积:为几十种类型实例化大型泛型函数会显著拖慢编译并增大二进制;公共 API 用泛型,内部热路径的具体化版本要节制。
为什么可行
trait bound 让编译器在调用点检查“类型是否具备算法所需的能力”,单态化把这次检查的成果固化成专用机器码,于是抽象不引入运行时成本。dyn Trait 则把“选择哪个实现”推迟到运行时,用虚表间接调用换取灵活性。两套机制都建立在同一件事上:能力(trait)与类型分离,能力要求写进签名。孤儿规则则保证这种全局能力匹配不会因为两个 crate 各自的实现而冲突——newtype 把“外部类型”翻译成“本 crate 类型”,冲突在语言层面就不可能发生。
常见误区
- 为了“通用”提前泛型化:只有一个调用方时,具体函数更短、更快编译、报错更直白。等第二个用例出现再抽象,bound 也会更准确。
- 把
dyn Trait 当默认多态:运行时才知道实现的场景确实存在,但大多数多态在编译期就已确定;虚表成本虽小,却是白付的。 - 实现外部 trait 给外部类型:如
impl fmt::Display for Vec<u8>(Vec 和 Display 都是别人的)——直接被拒绝;用 newtype。 - trait 方法返回
Self 却想用 dyn:对象安全不满足,编译失败;把方法改为返回具体类型或加 where Self: Sized 限定。 - 关联类型和泛型参数混着用而不自知:
Iterator 的 Item 是关联类型(每个迭代器只有一种元素),Add<Rhs> 的 Rhs 是泛型参数(同一个类型可以加不同右值)——分清哪种表达“一一对应”,哪种表达“多对多”。
自测
fn largest<T: Ord>(items: &[T]) -> Option<&T> 为什么必须写 T: Ord,去掉 bound 会发生什么?
方向:没有 bound 时 T 上没有 max/比较方法可用;bound 把“可完全排序”变成签名的一部分,调用方在编译期证明满足。TaskStore 的 type Error 为什么用关联类型而不是 trait TaskStore<E>?
方向:一个实现只对应一种错误类型时关联类型让签名更简单;只有同一类型需要多种错误类型实现同一 trait 时才用泛型参数。- 什么场景必须用
&mut dyn Write 而不是泛型 W: Write?
方向:目标实现在运行时才能确定(CLI 参数、运行期条件、异构集合);此时静态分发无法表达“编译期未知”。 dyn Clone 为什么不存在?
方向:Clone::clone 返回 Self,虚表无法为“编译期未知大小的类型”按值返回;对象安全规则会拒绝。- 想给
Vec<u8> 实现 Display,编译器为什么拒绝?怎么解决?
方向:孤儿规则禁止为外部 trait + 外部类型写 impl;用 newtype 包装 Vec<u8> 再实现。