第5章:抽象能力 —— 泛型、Trait、生命周期与特征对象
约 8 分钟 · 更新于 2026-09-01
第5章:抽象能力 —— 泛型、Trait、生命周期与特征对象
上一章用类型固定了单个工作项的合法状态,本章转向“多个类型如何共享能力”。路线是:先识别重复算法,再用泛型保留具体类型,用 Trait 描述最小行为,最后在静态分发、动态分发和借用返回之间做选择。
一、先识别变化点与稳定点
交付系统里既有 WorkItem,也有 ReleaseGate。它们字段不同,却都能提供名称与完成度。抽象的目标不是抹掉差异,而是把“算法真正依赖的部分”提炼出来。
先区分两类信息:
- 稳定点:比较完成度、生成摘要、选择进度更高者;
- 变化点:具体字段、构造方式、存储布局和业务行为。
Rust 提供四组工具分别表达这些关系:
| 工具 | 适合表达 |
|---|
| 泛型 T | 算法适用于一组具体类型,调用时类型已知 |
| Trait | 类型必须提供的最小行为契约 |
| 生命周期 'a | 输出借用与输入借用之间的有效期关系 |
| dyn Trait | 集合或边界需要容纳运行时不同的具体类型 |
后续选择抽象方式时,始终问两个问题:调用方是否需要保留具体类型信息?运行时是否必须把不同类型放进同一容器?
二、泛型:把类型变成参数
rust
fn choose_first<T>(left: T, _right: T) -> T {
left
}
let number = choose_first(10, 20);
let text = choose_first(String::from("A"), String::from("B"));
同一次调用中,两个参数和返回值必须是同一种具体 T。泛型也可用于结构体:
rust
#[derive(Debug)]
struct Pair<T> {
left: T,
right: T,
}
impl<T> Pair<T> {
fn new(left: T, right: T) -> Self {
Self { left, right }
}
}
两个字段允许不同类型时,使用多个参数:
rust
struct Entry<K, V> {
key: K,
value: V,
}
Option<T> 与 Result<T, E> 都是泛型枚举。const 泛型还能参数化编译期值:
rust
fn first<T, const N: usize>(items: &[T; N]) -> Option<&T> {
items.first()
}
泛型 T 本身没有任何已知行为。只有加约束后,才能调用相应能力:
rust
use std::fmt::Display;
impl<T: Display> Pair<T> {
fn print(&self) {
println!("left={}, right={}", self.left, self.right);
}
}
Rust 通常会为实际使用到的具体类型生成专门代码,这叫单态化。它让泛型保持静态分发和优化空间,代价主要在编译时间与二进制体积。
三、Trait:定义行为契约
给交付工作台条目定义共同能力:
rust
trait ProgressItem {
fn title(&self) -> &str;
fn progress(&self) -> u8;
fn summary(&self) -> String {
format!("{} - {}%", self.title(), self.progress())
}
}
为具体类型实现:
rust
impl ProgressItem for WorkItem {
fn title(&self) -> &str { &self.title }
fn progress(&self) -> u8 { self.progress }
}
impl ProgressItem for ReleaseGate {
fn title(&self) -> &str { &self.name }
fn progress(&self) -> u8 { self.progress }
}
summary 有默认实现,两个类型都能复用。使用 Trait 作为参数:
rust
fn print_item(item: &impl ProgressItem) {
println!("{}", item.summary());
}
它等价于常见的泛型写法:
rust
fn print_item<T: ProgressItem>(item: &T) {
println!("{}", item.summary());
}
复杂约束用 where 更清晰:
rust
use std::fmt::Debug;
fn inspect<T>(item: &T)
where
T: ProgressItem + Debug,
{
println!("{} | {item:?}", item.summary());
}
于是重复的比较算法可以统一:
rust
fn higher_progress<'a, T>(left: &'a T, right: &'a T) -> &'a T
where
T: ProgressItem,
{
if left.progress() >= right.progress() { left } else { right }
}
同一次调用的两个参数仍必须是同一具体类型,这正是静态泛型的含义。
四、返回 impl Trait 与关联类型
函数可以隐藏一种确定的返回类型:
rust
fn sample_item() -> impl ProgressItem {
WorkItem {
id: 1,
title: String::from("学习 Trait"),
progress: 30,
}
}
返回位置的 impl Trait 不是「任意实现类型」。一个函数的所有返回路径仍必须是同一种具体类型;需要运行时返回多种类型时,使用枚举或 Box<dyn Trait>。
关联类型适合「每个实现天然对应一种成员类型」:
rust
trait EmitsEvent {
type Event;
fn emit(&self) -> Self::Event;
}
#[derive(Debug)]
struct WorkItemEvent {
item_id: u64,
}
impl EmitsEvent for WorkItem {
type Event = WorkItemEvent;
fn emit(&self) -> Self::Event {
WorkItemEvent { item_id: self.id }
}
}
相比让调用方反复填写泛型参数,关联类型让每个实现只选择一种确定的 Event。
Rust 的孤儿规则通常要求 Trait 或被实现类型至少有一个定义在当前 crate。若需要为外部类型补充外部 Trait,可使用 newtype:
rust
struct DisplayProgress(u8);
impl std::fmt::Display for DisplayProgress {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}%", self.0)
}
}
五、静态分发与特征对象
泛型函数在编译期知道具体 T,属于静态分发:
rust
fn render<T: ProgressItem>(item: &T) -> String {
item.summary()
}
它适合处理同构数据,例如 Vec<WorkItem>。如果首页需要混合保存工作项与发布关卡,则使用特征对象:
rust
let items: Vec<Box<dyn ProgressItem>> = vec![
Box::new(WorkItem {
id: 1,
title: String::from("完成数据建模"),
progress: 80,
}),
Box::new(ReleaseGate {
name: String::from("Rust 基础阶段"),
progress: 60,
}),
];
for item in &items {
println!("{}", item.summary());
}
dyn ProgressItem 表示某个实现了该 Trait 的类型,但此处擦除了具体类型。它大小未知,通常放在指针后:
- &dyn ProgressItem:借用对象;
- Box<dyn ProgressItem>:拥有对象;
- Arc<dyn ProgressItem + Send + Sync>:跨线程共享对象。
特征对象通过数据指针与方法表在运行时选择实现,称为动态分发。
| 场景 | 优先方案 |
|---|
| 对一种 T 批量执行算法 | 泛型 / impl Trait |
| 集合混合多种实现 | dyn Trait |
| 变体封闭且数量少 | 枚举 |
| 插件实现未来可扩展 | dyn Trait |
不是所有 Trait 都能变成特征对象。直觉上,通过方法表调用时,签名必须能在不知道具体 Self 的情况下成立。返回裸 Self 或包含泛型方法的 Trait 往往需要调整,或给相关方法增加 where Self: Sized。
枚举与 Trait 的边界
「状态只有 Todo / Doing / Done」是封闭集合,适合枚举;「任何模块都可实现 ProgressItem」是开放能力,适合 Trait。
六、生命周期:描述引用之间的关系
下面的函数返回两个输入中的一个引用:
rust
fn longer_title<'a>(left: &'a str, right: &'a str) -> &'a str {
if left.len() >= right.len() { left } else { right }
}
'a 不会让字符串活得更久。它只声明:返回引用不会比两个输入共同有效的范围更长。
rust
let outer = String::from("长期标题");
let selected;
{
let inner = String::from("短");
selected = longer_title(outer.as_str(), inner.as_str());
println!("作用域内可用:{selected}");
}
// println!("{selected}"); // 可能借自 inner,因此不能使用
主示例中的泛型比较同样需要关系说明:
rust
fn higher_progress<'a, T>(left: &'a T, right: &'a T) -> &'a T
where
T: ProgressItem,
{
if left.progress() >= right.progress() { left } else { right }
}
如果函数总返回第一个参数,可以更精确:
rust
fn keep_left<'a, 'b, T>(left: &'a T, _right: &'b T) -> &'a T {
left
}
生命周期标注越精确,调用者受到的不必要限制越少。
七、结构体引用、消除规则与 NLL
结构体保存引用时必须说明:视图不能比原对象活得更久。
rust
#[derive(Debug)]
struct ItemView<'a> {
title: &'a str,
progress: u8,
}
impl<'a> ItemView<'a> {
fn from_item(item: &'a impl ProgressItem) -> Self {
Self {
title: item.title(),
progress: item.progress(),
}
}
}
如果对象需要独立长期保存,通常让它拥有 String 比到处传播生命周期参数更合适。
很多引用函数无需显式标注:
rust
fn trim_title(title: &str) -> &str {
title.trim()
}
impl WorkItem {
fn title(&self) -> &str {
&self.title
}
}
编译器通过生命周期消除规则推断常见关系:只有一个输入引用时,输出通常绑定到它;方法存在 &self 时,输出通常绑定到 self。
现代 Rust 的非词法生命周期(NLL)还会把借用缩短到最后一次使用:
rust
let mut title = String::from("建模");
let view = &title;
println!("{view}");
title.push_str("与抽象"); // view 已结束使用
可变引用有时会通过更短的再借用暂时转交:
rust
fn raise(progress: &mut u8) {
*progress = (*progress + 10).min(100);
}
let mut progress = 20;
let first = &mut progress;
raise(&mut *first);
raise(&mut *first);
assert_eq!(*first, 40);
&mut *first 创建短暂再借用,调用结束后原借用仍可继续使用。
八、'static 到底约束谁
字符串字面量可以得到静态引用:
rust
let name: &'static str = "工作项交付工作台";
&'static T 表示引用可在整个程序期间有效。不要通过泄漏内存把它当成普通生命周期报错的快捷修复。
T: 'static 则表示 T 不依赖短生命周期借用。拥有型值也能满足:
rust
fn spawn_job<T>(job: T)
where
T: Send + 'static,
{
std::thread::spawn(move || drop(job));
}
String 满足这里的 'static,但仍会在线程结束时正常释放。它之所以合格,是因为拥有数据,而不是「永远不释放」。
遇到生命周期错误时,先检查返回来源、所有权与线程边界,不要机械添加 'static。
九、组合示例
rust
use std::fmt::Debug;
trait ProgressItem: Debug {
fn title(&self) -> &str;
fn progress(&self) -> u8;
fn summary(&self) -> String {
format!("{} - {}%", self.title(), self.progress())
}
}
#[derive(Debug)]
struct WorkItem { id: u64, title: String, progress: u8 }
#[derive(Debug)]
struct ReleaseGate { name: String, progress: u8 }
impl ProgressItem for WorkItem {
fn title(&self) -> &str { &self.title }
fn progress(&self) -> u8 { self.progress }
}
impl ProgressItem for ReleaseGate {
fn title(&self) -> &str { &self.name }
fn progress(&self) -> u8 { self.progress }
}
fn higher_progress<'a, T: ProgressItem>(left: &'a T, right: &'a T) -> &'a T {
if left.progress() >= right.progress() { left } else { right }
}
fn render_all(items: &[Box<dyn ProgressItem>]) {
for item in items {
println!("{}", item.summary());
}
}
职责分工:具体类型保存数据,Trait 定义共同能力,泛型函数处理同构值,生命周期保护返回借用,特征对象保存异构值。
十、常见误区
- 泛型越多越抽象:只为真实变化维度引入类型参数。
- Trait 是方法大礼包:它是长期契约,应保持最小、稳定。
- impl Trait 能混合返回类型:它隐藏的是一种确定的具体类型。
- 所有接口都用 dyn Trait:静态已知时,泛型或枚举更直接。
- 生命周期能延长数据寿命:它只描述关系,不创造存活时间。
- 借用型结构体一定更高效:长期领域对象拥有数据往往更简单。
- 'static 是万能修复:它会显著收紧接口,甚至迫使调用者克隆或泄漏数据。
十一、实践练习
练习一:从重复函数提取泛型
分别为整数和浮点数写两个“返回较大值”的函数,再尝试抽成泛型。记录为什么仅有 T 还不够,以及比较操作需要什么 Trait 约束。
练习二:设计最小 Trait
为 WorkItem 与 ReleaseGate 定义 ProgressItem。只保留生成摘要真正需要的方法,不把构造、存储或无关字段塞进 Trait。
练习三:比较两种分发
分别实现 fn print<T: ProgressItem>(...) 和 fn print(&dyn ProgressItem)。创建异构集合时使用 Vec<Box<dyn ProgressItem>>,说明它换来了什么,又失去了什么。
练习四:返回借用
实现 higher_progress,返回两个输入之一。先省略生命周期观察诊断,再写出显式 'a,解释标注描述的是关系而不是延长寿命。
练习五:辨析 'static
分别构造字符串字面量、拥有型 String 与借用局部 String 的引用,判断哪些是 &'static str,哪些类型满足 T: 'static,并说明二者不是同一约束。
十二、总结
- 泛型把类型变成参数,Trait 把算法所需行为变成契约。
- 约束应保持最小:算法只要求比较,就不要额外要求显示、克隆或默认构造。
- 泛型和 impl Trait 通常使用静态分发,保留优化空间与具体类型信息。
- dyn Trait 通过统一指针形态支持异构值,代价是动态分发和部分能力限制。
- 生命周期标注描述引用之间的有效期关系,不创建数据,也不让数据活得更久。
- 生命周期消除和 NLL 会省略大量常规标注,但返回借用或结构体持有引用时仍要理解关系。
- 抽象质量不取决于参数数量,而取决于变化点是否局部、契约是否准确。
十三、下一章
接下来把类型和接口放进可维护工程:用集合管理对象、用错误类型表达失败、用 module 控制边界,再用测试与文档守住契约。
下一章进入 第6章:工程骨架。
- Rust 深度教程
- 第4章:数据建模
- 第6章:工程骨架
- Pi 第2章:三层架构 —— 抽象边界的工程对照