课程概览 · 第 10 章

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

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

泛型和 trait 都在解决“复用”,但它们负责的事情不同:泛型让同一段算法处理多种类型,trait 则把调用方需要的能力写成契约。本章关注的是边界:什么时候抽象、函数究竟依赖什么能力,以及何时值得用动态分发。

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

先记住四个默认选择

  1. 只有一个具体用例时,先写具体函数;第二个真实用例出现后再泛型化。
  2. trait 只暴露调用方真正需要的方法;读取、写入、事务等能力不要硬塞进一个大 trait。
  3. 类型在编译期已知时,优先泛型或 impl Trait;它们走静态分发。
  4. 只有实现必须在运行时切换、或集合必须容纳异构值时,才使用 dyn Trait

这套顺序避免两种常见问题:为了“通用”过早设计复杂签名,以及把 dyn Trait 误当成 Rust 默认的多态手段。

学习路线:先看类型,再看能力,最后看分发

这一章的知识点彼此相连,建议按下面的顺序理解,而不是把每个语法当作独立技巧:

  1. 泛型参数 T:哪些位置允许变化;它让一个定义适用于一组类型。
  2. trait bound:这组类型至少要具备什么能力;它让编译器可以检查泛型函数的函数体。
  3. impl:某个具体类型如何兑现这份能力契约;默认方法、关联类型和条件实现也在这里发生。
  4. 分发方式:调用时是否知道具体类型;已知用静态分发,不知道才擦除成 dyn Trait

后面的每个选择都可以回到两个问题:调用方真正需要什么能力?编译到调用点时,具体类型是否已知?

泛型:复用算法,不放宽要求

假设我们要从一个切片中找到最大元素。算法不关心元素是整数、字符串还是领域类型,但它必须知道“怎样比较两个元素”。这正是 trait bound 的作用:泛型参数表达可变部分,bound 表达不变的前提。

1
2
3
4
5
6
7
8
9
10
/// 空切片没有最大值;返回借用,不复制元素。
fn largest<T: Ord>(items: &[T]) -> Option<&T> {
items.iter().max()
}

let scores = [3, 7, 5];
assert_eq!(largest(&scores), Some(&7));

let names = ["alice", "carol", "bob"];
assert_eq!(largest(&names), Some(&"carol"));

T 不是“任意类型都行”,而是“任意实现了 Ord 的类型都行”。去掉 T: Ord 后,编译器无法确认 T 可以比较,自然也不能调用 max。因此,好的泛型签名不是最宽泛的签名,而是恰好写出算法所需能力的签名。

这里返回 Option<&T> 也有意图:

  • None 表示空切片这一正常情况;
  • &T 表示最大元素仍属于原切片,函数不克隆、不转移所有权;
  • 若调用方确实需要拥有它,再由调用方决定是否 cloned()

从具体函数到泛型函数

开始时,fn highest_priority(tasks: &[Task]) -> Option<&Task> 往往更好:它直接说明了业务含义,错误信息也最短。等到你确实还要在别的类型上复用相同流程,再提炼为 largest<T: Ord>

“先具体、后抽象”不是拒绝泛型,而是让真实用例帮助你找到正确的 bound。若函数同时要求 Clone + Debug + Serialize + Send + Sync,先问一句:它是不是同时做了排序、日志、序列化和并发投递?拆分职责通常比继续堆 bound 更清晰。

泛型不只在函数上:类型、方法与条件实现

泛型参数可以出现在结构体、枚举和方法上。标准库的 Vec<T>Option<T>Result<T, E> 都是泛型类型:容器的结构固定,里面装的值类型可变。

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
struct Point<T> {
x: T,
y: T,
}

// 对任意 T 都成立的方法,不需要额外能力。
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}

// 只为 Point<f64> 提供专属方法。
impl Point<f64> {
fn distance_from_origin(&self) -> f64 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}

fn main() {
let text = Point { x: "left", y: "right" };
assert_eq!(text.x(), &"left");

let location = Point { x: 3.0, y: 4.0 };
assert_eq!(location.distance_from_origin(), 5.0);
}

impl<T> Point<T> 表示“无论 T 是什么,这个方法都可用”;impl Point<f64> 则把能力收窄到一个具体类型。还有第三种很常见的写法:条件实现。只有当 T 也具备 Display 时,Pair<T> 才能安全地格式化两个值:

1
2
3
4
5
6
7
8
9
10
11
12
use std::fmt::Display;

struct Pair<T> {
left: T,
right: T,
}

impl<T: Display> Pair<T> {
fn show(&self) -> String {
format!("({}, {})", self.left, self.right)
}
}

这比把 T: Display 写在结构体定义上更灵活:Pair<Vec<u8>> 仍然可以存在,只是不能调用 show把 bound 放到最小需要它的方法或 impl 块上,能避免无意义地限制类型的其他用途。

bound 怎样写:参数位置、where?Sized

一个简单 bound 适合写在类型参数旁边:fn largest<T: Ord>(...)。当参数多、约束长,或约束与返回值关联紧密时,where 子句通常更容易读:

1
2
3
4
5
6
7
8
9
use std::fmt::Display;

fn format_pair<K, V>(key: &K, value: &V) -> String
where
K: Display,
V: Display,
{
format!("{key}={value}")
}

T 默认隐含 T: Sized,即编译期知道其大小。绝大多数泛型值都需要这个前提,才能按值存放或传递。str[u8]dyn Write 这类动态大小类型(DST)没有这个大小;它们必须躲在 &strBox<str>&dyn Write 等“胖指针”后面使用。

只有函数确实要接收这类借用时,才放宽默认限制:

1
2
3
4
5
6
7
8
use std::fmt::Display;

// T 可以是 Sized 类型,也可以是 str 这样的 DST;引用本身的大小仍然已知。
fn print_label<T: Display + ?Sized>(value: &T) {
println!("{value}");
}

print_label("release-2026-08"); // 这里 T 可以是 str

?Sized 的含义是“Sized 可能没有实现”,不是“任何 trait 都可选”。它是少数 API 边界的工具;日常代码不需要主动加它。

trait:把依赖缩小为能力契约

业务代码通常不应依赖某一个存储实现,而应依赖“能够读写任务”这项能力。trait 的价值不在于模仿面向对象的继承,而在于明确调用方需要什么、实现方必须保证什么。

1
2
3
4
5
6
trait TaskStore {
type Error;

fn get(&self, id: TaskId) -> Result<Option<&Task>, Self::Error>;
fn insert(&mut self, task: Task) -> Result<(), Self::Error>;
}

这份契约传达了三个重要语义:

设计表达的事实
&self / &mut self查询不修改存储;插入需要独占修改权。
Result<Option<&Task>, _>None 是“未找到”的正常结果;Err 才是 IO、损坏等失败。
type Error一个具体存储实现对应一种错误类型。

关联类型适合“一种实现配一种输出类型”的关系。例如内存存储可用 Infallible,文件存储可用 io::Error。如果改成 trait TaskStore<E>,则允许同一存储类型以多种 E 实现该 trait;这在少数场景有用,却会让所有使用处多携带一个类型参数。默认选择关联类型,直到确实需要“同一实现,多种类型参数”。

trait 要小,组合要胜过万能接口

如果某个调用方只需要读取能力,它不应该被迫实现 insert、批量写入、事务提交等方法。可以按角色拆分:TaskReaderTaskWriter,需要完整能力的地方再组合它们。这样测试替身更容易写,未来替换存储也更安全。

这就是 Rust trait 的常用姿势:它不是“某类对象的祖先”,而是可单独满足、可自由组合的能力清单。

必需方法、默认方法与 derive

trait 中没有函数体的方法是实现者必须提供的必需方法;带函数体的是默认方法。默认方法适合把由必需方法拼装出的通用流程放在 trait 一侧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
trait Summary {
fn author(&self) -> &str; // 每种类型自己回答
fn body(&self) -> &str;

// 复用流程;实现者可以继承,也可以覆盖。
fn preview(&self) -> String {
format!("{}: {}", self.author(), self.body())
}
}

struct Note {
owner: String,
text: String,
}

impl Summary for Note {
fn author(&self) -> &str { &self.owner }
fn body(&self) -> &str { &self.text }
}

Note 只实现两个必需方法,就自动拥有 preview。反过来,默认方法只能调用 trait 已经承诺的其他方法,不能假设实现类型一定有某个字段;trait 不知道 Note 的内部布局。

#[derive(...)] 则是编译器或过程宏为常见 trait 自动生成实现,常见组合如下:

1
2
#[derive(Debug, Clone, PartialEq, Eq)]
struct TaskId(u64);
  • Debug 便于日志和断言失败时查看结构;
  • Clone 表示值能显式复制,绝不等于廉价复制;
  • PartialEq 支持 ==Eq 表示相等关系满足自反性等完整规则;
  • CopyClone 更强:赋值会发生隐式按位复制,只应给小而无资源所有权的类型实现。

不是所有 trait 都能或都应当 derive。例如“怎样把任务写入数据库”属于业务行为,应该由手写 impl 明确实现;而 Debug、比较、排序等结构性行为,通常适合派生。

常用标准库 trait:统一参考

derive、比较、转换、迭代、I/O 和并发边界涉及的标准库 trait 已迁移到
Rust 常用 Trait:从 prelude 到 std 的工程选型
需要为领域类型选择 DebugCloneEqHashDefault 时,先按
该文的语义清单判断;本章继续聚焦“调用方究竟需要什么能力”以及泛型、
impl Traitdyn Trait 三种边界写法。

关联类型、泛型 trait 与超 trait

前面的 type Error 是 trait 内的关联类型。实现者在 impl 中把它确定一次,调用方就能通过 S::Error(或 <S as TaskStore>::Error)引用它。迭代器也是这个模型:每个迭代器实现固定产出一种 Item

1
2
3
4
trait Batch {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}

如果“同一个实现类型可以针对多种参数分别实现此 trait”,才用泛型 trait,例如 From<T>String 可以从 &strchar 等不同 T 转换而来。选择口诀是:实现一旦确定,输出也应确定,用关联类型;需要按输入类型形成多份实现,用 trait 泛型。

trait 还可以声明前置能力(超 trait):

1
2
3
4
5
6
7
8
9
use std::fmt::Display;

trait OutlinePrint: Display {
fn outline_print(&self) {
let text = self.to_string(); // Display 已被承诺
let line = "*".repeat(text.len() + 4);
println!("{line}\n* {text} *\n{line}");
}
}

只有已经实现 Display 的类型才能实现 OutlinePrint。这是一种“能力依赖”,不是字段继承;OutlinePrint 仍不知道实现类型的数据布局。并发边界中的 Send + Sync 也常作为这种约束出现:前者表示值可安全转移到另一线程,后者表示 &T 可安全地跨线程共享。

blanket impl 与一致性:为什么实现不能重叠

泛型 impl 能一次为一族类型提供能力,称为 blanket implementation。标准库的 ToStringDisplay 就有这种关系:实现 Display 的类型通常就能调用 .to_string()

这也解释了 Rust 对实现的严格限制:对于同一个“类型 + trait”组合,编译器必须始终能找到唯一实现。两个可能同时匹配的 impl 会产生歧义,因此 Rust 默认拒绝重叠实现;孤儿规则又防止两个外部 crate 分别为同一外部组合写出互相冲突的实现。后文的 newtype 正是把适配意图放回本 crate 的办法。

把 trait 放进 API:三种参数写法与两种返回写法

同样是“函数依赖 Write 能力”,签名表达的分发时机不同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use std::io::Write;

// 1. 显式泛型:适合多个参数需要引用同一个 T,或要在签名中复用 T。
fn copy_twice<W: Write>(out: &mut W, bytes: &[u8]) -> std::io::Result<()> {
out.write_all(bytes)?;
out.write_all(bytes)
}

// 2. 参数位置 impl Trait:上面的简写;每次调用仍会确定一个具体类型。
fn write_header(out: &mut impl Write) -> std::io::Result<()> {
out.write_all(b"Content-Type: text/plain\\r\\n")
}

// 3. trait object:调用点可传任意实现,但函数体经虚表调用。
fn write_footer(out: &mut dyn Write) -> std::io::Result<()> {
out.write_all(b"\\r\\n")
}

前两种是静态分发impl Trait 只是参数位置的简写;第三种是动态分发。单看一个函数调用,三者都能接收 FileVec<u8> 等实现了 Write 的类型。真正的差异在于:泛型能保留并复用具体类型信息,dyn 则在函数边界主动忘掉具体类型。

返回位置需要额外谨慎:-> impl Write 表示函数会返回一个隐藏但固定的具体类型,每条返回路径都必须是同一种类型;它不是“返回任何实现了 Write 的类型”。运行时分支真的要返回不同类型时,使用 trait object:

1
2
3
4
5
6
7
8
9
use std::io::{self, Write};

fn selected_output(discard: bool) -> Box<dyn Write> {
if discard {
Box::new(io::sink())
} else {
Box::new(Vec::<u8>::new())
}
}

Box 同时做了两件事:在堆上存放未知大小的具体值,并让返回值拥有它。若调用方只是在本次调用期间借用现成对象,则用 &mut dyn Write,不需要也不应该为了 dyn 额外分配堆内存。

原理:泛型为什么通常没有运行时成本

对每个实际用到的类型,编译器会生成一份专用的泛型代码,这叫单态化。例如 largest(&[i32])largest(&[&str]) 在编译后对应不同版本;调用点知道确切类型,所以可以直接调用并有机会内联。

1
2
3
4
源代码:largest<T: Ord>(items)

编译后:largest_i32(items) ── 直接调用
largest_str(items) ── 直接调用

它交换的是两种成本:运行时少了一次“这个值到底是什么类型”的查询,但类型组合很多、函数又很大时,编译时间和二进制体积会增加。对一般业务函数,这个代价通常很小;对大量实例化的热路径,才需要结合构建时间和二进制大小测量,而不是凭感觉优化。

impl Writefn f<W: Write>(writer: W) 在参数位置的含义相近:调用方选择一个具体的 W,编译器为该 W 静态分发。若函数只需要把状态写到某处,依赖 Write 就足够,不必知道它是文件、终端还是内存缓冲区。

何时使用 dyn Trait

dyn Trait 把“选哪个实现”的时机从编译期推迟到运行时。它通过数据指针和虚表指针调用方法,因此会多一次间接调用,并通常失去内联机会;若使用 Box<dyn Trait>,还会有一次堆分配。换来的能力是:变量或集合可以保存运行时决定的、不同的具体类型。

这里要把“动态分发”和“堆分配”分开看:&dyn Trait 只是借用了一个已经存在的值,通常不分配;Box<dyn Trait> 才拥有并在堆上存储该值。两者都是胖指针——除了数据地址,还带有该具体实现对应的虚表地址。虚表保存方法入口以及析构、布局等元数据,调用时据此跳转到正确实现。

问题优先选择原因
所有调用点的类型在编译期可见泛型 / impl Trait静态分发,接口也保留具体类型信息。
只有有限的几种已知实现enum分支显式,没有虚表;新增变体时编译器会提醒处理位置。
配置、插件或输入决定实现&dyn TraitBox<dyn Trait>运行时才知道具体类型,需要抹去类型差异。
需要一个所有者容纳异构实现Vec<Box<dyn Trait>>每个元素的具体类型不同,但共享同一能力契约。

例如命令行参数决定日志写入终端还是文件时,可以把最终选出的 writer 交给 &mut dyn Write;而一个函数同时接受 FileVec<u8>、测试缓冲区时,通常直接写成泛型即可。

dyn Trait 也会擦除具体类型带来的能力。例如 Box<dyn Write> 只能调用 Write(及其超 trait)承诺的方法,不能再调用 File::sync_all。因此较好的边界是:在运行时选择完成后,尽量把 trait object 限制在真正需要异构的那一层;领域内部仍使用具体类型或泛型。

对象安全:不是每个 trait 都能变成 dyn Trait

trait object 必须让编译器能够在“不知道具体类型”的情况下完成方法调用。下面这些成员不能参与 trait object 的动态调用:

1
2
3
4
5
6
trait NotDynSafe {
const VERSION: u32; // 关联常量没有运行时查找入口
fn create() -> Self; // 返回的具体 Self 未知
fn merge(&self, other: Self); // 参数中的具体 Self 也未知
fn encode<T>(&self, value: T); // 调用者可任选 T,虚表无法预先列全
}

若这些能力只给具体类型或泛型调用者使用,可以明确排除在 trait object 接口外:

1
2
3
4
5
6
7
8
9
10
11
trait Job {
fn run(&self);

fn from_config(text: &str) -> Self
where
Self: Sized;

fn encode<T>(&self, value: T)
where
Self: Sized;
}

dyn Job 仍可调用 run,但不能调用 from_configencode;编译器知道这两个方法只针对大小已知的具体 Self。如果 trait 本身声明为 trait Job: Sized,则整个 trait 都不能成为 dyn Job,因为 dyn Job 的大小未知。

按值接收 self 的方法需要细分:它并不一定让 trait 不能写成 dyn Trait,但 &dyn Trait 没有所有权,无法把底层未知大小的值 move 出来。若确实要以“消耗 trait object”的方式表达所有权转移,签名通常写成 self: Box<Self>,调用方持有 Box<dyn Trait> 时即可调用。最重要的判断仍是:方法签名有没有要求调用方知道具体 Self 或额外的泛型类型。

Clone 不能直接写成 dyn Clone,正是因为 clone(&self) -> Self 需要返回未知具体类型。常见替代方式是定义对象安全的 clone_box(&self) -> Box<dyn Trait>,或在不需要运行时异构时保留 Clone bound 与泛型。

孤儿规则与 newtype:为外部类型增加本地语义

Rust 不允许为“外部 trait + 外部类型”直接写实现,例如不能为 Vec<u8> 实现标准库的 Display。否则两个 crate 可能各自提供不同实现,依赖它们的项目将无法决定用哪一个。

需要适配时,定义一个你自己的包装类型(newtype):

1
2
3
4
5
6
7
8
9
10
11
12
use std::fmt;

struct HexBytes(Vec<u8>);

impl fmt::Display for HexBytes {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for byte in &self.0 {
write!(f, "{byte:02x}")?;
}
Ok(())
}
}

HexBytes 是本 crate 定义的类型,因此可以实现 Display;同时它把“这串字节按十六进制展示”的领域语义放进类型名,避免把任意 Vec<u8> 都误当成可打印的十六进制数据。newtype 也常用于 TaskId(u64)、货币金额、用户邮箱等场景,让编译器帮助区分表面上相同、含义却不同的数据。

不要为了少写 .0 就盲目实现 Deref。只有包装类型确实应当完全像内部类型一样使用时才考虑;大多数领域类型应保留自己的方法边界。

用 newtype 做适配,而不是泄漏底层类型

newtype 的价值不止绕过孤儿规则。它还能控制哪些能力对外可见:

1
2
3
4
5
6
7
8
9
10
11
12
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct TaskId(u64);

impl TaskId {
fn new(raw: u64) -> Option<Self> {
(raw != 0).then_some(Self(raw))
}

fn get(self) -> u64 {
self.0
}
}

这里外部代码不需要直接构造 TaskId(0);构造函数把“ID 不能为零”的规则放到了类型入口。是否把字段写成 pub,决定了这条不变量由谁维护。对金额、状态码、邮箱、分页大小这类容易混淆的基础类型,优先考虑 newtype 往往比散落的注释可靠。

常见误区与检查清单

  • 泛型越多越好:不对。先让第二个真实用例证明抽象有价值,再选择最小 bound。
  • dyn Trait 是默认多态:不对。绝大多数类型在编译期已知,泛型更直接;运行时异构才需要 dyn
  • 找不到数据就是错误:未必。用 Option 表示正常缺失,用 Result 表示操作失败;两者可以组合。
  • 一个 Repository trait 放下所有方法:会让只读实现和测试替身承受不需要的责任。按能力拆分。
  • 给外部类型补一个标准 trait 就行:孤儿规则会拒绝。使用 newtype,并借机表达本地领域语义。
  • 看到虚表就必须避免:也不对。动态分发的成本通常很小;真正要问的是“实现是否只能在运行时确定”。
  • impl Trait 能从不同分支返回不同实现:不对。返回位置的 impl Trait 仍是一个固定隐藏类型;分支异构时用 Box<dyn Trait>enum 或重构边界。
  • Box<dyn Trait> 是所有动态分发的必选项:不对。只借用时 &dyn Trait / &mut dyn Trait 不拥有值,也不额外堆分配。
  • derive(Clone) 就说明复制很便宜:不对。StringVecclone 会复制堆上数据;是否克隆应由所有权和成本共同决定。

自测

  1. largest<T: Ord> 中的 Ord 去掉后,编译器为什么不能继续工作?
  2. get 为什么适合返回 Result<Option<&Task>, E>,而不是只有 Result<&Task, E>
  3. 一个上传任务只需要读取配置,它的参数应该依赖完整 TaskStore,还是更小的读取 trait?
  4. 日志目标由命令行参数决定时,为什么 dyn Write 比泛型更自然?反过来,测试函数接收任意 writer 时为什么泛型更自然?
  5. 为什么 impl Display for Vec<u8> 不合法,而 impl Display for HexBytes 合法?
  6. impl<T: Display> Pair<T> 为什么比在 struct Pair<T: Display> 上直接加约束更宽松?
  7. -> impl Write-> Box<dyn Write> 分别要求函数的返回路径满足什么条件?
  8. 为什么给某个 trait 的泛型方法加上 where Self: Sized 后,trait object 仍可使用其余方法?