课程概览 · 第 10 章

上一篇:模式匹配:用穷尽分支处理业务状态

下一篇:集合:从访问模式选择 Vec、Map、Set 与队列

同一个“找出最大元素”的算法,你可能会先为整数写一遍,再为任务标题写一遍,最后发现两段代码几乎相同。泛型解决“同一算法处理多种类型”,trait 解决“不同类型提供同一种能力”;本章的重点不是用尽 trait 技巧,而是让抽象只暴露调用方真正需要的行为,并在静态分发与动态分发之间做出有依据的选择。

trait 最小能力边界、泛型单态化静态分发、dyn Trait 动态分发和 newtype 适配器的关系
图:先定义调用方需要的能力;编译期类型已知时优先泛型,需要运行时异构时再选 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,
}

/// 返回优先级最高的任务;列表为空时返回 None。
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
/// 返回切片中最大的元素(借用);空切片返回 None。
/// T: Ord 是这个算法的真实要求:元素必须可完全排序。
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")];
// 比较 String 本身(字典序),而不是比较长度。
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)] // 完整状态机见第 8 章;本例只用 InProgress。
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;

/// 找不到不是错误:None 表示正常缺失。
fn get(&self, id: TaskId) -> Result<Option<&Task>, Self::Error>;
fn insert(&mut self, task: Task) -> Result<(), Self::Error>;
}

/// 内存实现:Error 类型是 Never(不会失败),但边界仍然保留。
#[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 参数——那会让每个调用点的类型都变复杂。
  • OptionResult 分工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};

/// 把任务状态写成一行日志。W 在编译期确定,静态分发。
fn write_status<W: Write>(writer: &mut W, task_id: u64, status: &str) -> io::Result<()> {
writeln!(writer, "task {task_id}: {status}")
}

/// 一个满足 Write 的自定义 sink,验证泛型函数不绑定 std 类型。
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<()> {
// 用法一:内存 sink(自定义类型)。
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");

// 用法二:标准库 sink(Vec<u8> 实现了 Write)。
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};

/// 动态分发:writer 的具体类型在运行时才确定。
fn write_status(writer: &mut dyn Write, task_id: u64, status: &str) -> io::Result<()> {
writeln!(writer, "task {task_id}: {status}")
}

fn main() -> io::Result<()> {
// 根据运行期条件选择实现:终端 or 内存缓冲。
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")?;

// 非终端路径上数据进了 buf(终端路径上输出到 stderr)。
if !stderr.is_terminal() {
assert_eq!(buf, b"task 42: running\n".to_vec());
}

// 另一个动态分发来源:集合里放不同的 writer。
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};

/// 外部 crate 的“任务编号”类型(此处用 u64 模拟外部类型,
/// 假设它只提供构造和读取,没有实现任何 std trait)。
struct ExternalTaskRef(u64);

/// newtype 适配器:包装外部类型,绕开孤儿规则,
/// 同时给外部类型一个有领域含义的名字。
struct TaskRef(ExternalTaskRef);

// 手工实现需要的 trait,全部委托给内部的 u64。
// 这些 impl 写在我们自己的 crate 里,不违反孤儿规则。
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))); // 与 a 相同,去重。
assert_eq!(set.len(), 2);
println!("newtype adapter ok");
}

newtype 的额外收益:它是一个领域类型(“任务引用”),不是裸的 u64,编译器能防止你把它误传给别的整数参数(第 7 章的 TaskId 同理)。

边界与失败场景

  • 泛型过多:函数签名出现三四个 bound 时,通常说明它做了太多事,应拆分。
  • 关联类型 vs 泛型参数:把 type Error 改成 trait TaskStore<E> 会让每个存储类型可以“以多种错误类型实现”,代价是所有泛型代码都要多携带一个参数。没有多实现需求时用关联类型。
  • dyn Trait 的对象安全约束dyn Clonedyn Sized 不存在;给 trait 加泛型方法会立刻破坏其所有 dyn 用法。
  • 单态化体积:为几十种类型实例化大型泛型函数会显著拖慢编译并增大二进制;公共 API 用泛型,内部热路径的具体化版本要节制。

为什么可行

trait bound 让编译器在调用点检查“类型是否具备算法所需的能力”,单态化把这次检查的成果固化成专用机器码,于是抽象不引入运行时成本。dyn Trait 则把“选择哪个实现”推迟到运行时,用虚表间接调用换取灵活性。两套机制都建立在同一件事上:能力(trait)与类型分离,能力要求写进签名。孤儿规则则保证这种全局能力匹配不会因为两个 crate 各自的实现而冲突——newtype 把“外部类型”翻译成“本 crate 类型”,冲突在语言层面就不可能发生。

常见误区

  • 为了“通用”提前泛型化:只有一个调用方时,具体函数更短、更快编译、报错更直白。等第二个用例出现再抽象,bound 也会更准确。
  • dyn Trait 当默认多态:运行时才知道实现的场景确实存在,但大多数多态在编译期就已确定;虚表成本虽小,却是白付的。
  • 实现外部 trait 给外部类型:如 impl fmt::Display for Vec<u8>VecDisplay 都是别人的)——直接被拒绝;用 newtype。
  • trait 方法返回 Self 却想用 dyn:对象安全不满足,编译失败;把方法改为返回具体类型或加 where Self: Sized 限定。
  • 关联类型和泛型参数混着用而不自知IteratorItem 是关联类型(每个迭代器只有一种元素),Add<Rhs>Rhs 是泛型参数(同一个类型可以加不同右值)——分清哪种表达“一一对应”,哪种表达“多对多”。

自测

  1. fn largest<T: Ord>(items: &[T]) -> Option<&T> 为什么必须写 T: Ord,去掉 bound 会发生什么?
    方向:没有 bound 时 T 上没有 max/比较方法可用;bound 把“可完全排序”变成签名的一部分,调用方在编译期证明满足。
  2. TaskStoretype Error 为什么用关联类型而不是 trait TaskStore<E>
    方向:一个实现只对应一种错误类型时关联类型让签名更简单;只有同一类型需要多种错误类型实现同一 trait 时才用泛型参数。
  3. 什么场景必须用 &mut dyn Write 而不是泛型 W: Write
    方向:目标实现在运行时才能确定(CLI 参数、运行期条件、异构集合);此时静态分发无法表达“编译期未知”。
  4. dyn Clone 为什么不存在?
    方向:Clone::clone 返回 Self,虚表无法为“编译期未知大小的类型”按值返回;对象安全规则会拒绝。
  5. 想给 Vec<u8> 实现 Display,编译器为什么拒绝?怎么解决?
    方向:孤儿规则禁止为外部 trait + 外部类型写 impl;用 newtype 包装 Vec<u8> 再实现。