Agent X-Ray
RuntimeNotesAbout
Notes/代码工程/Rust 深度教材/第5章

第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 定义共同能力,泛型函数处理同构值,生命周期保护返回借用,特征对象保存异构值。


十、常见误区

  1. 泛型越多越抽象:只为真实变化维度引入类型参数。
  2. Trait 是方法大礼包:它是长期契约,应保持最小、稳定。
  3. impl Trait 能混合返回类型:它隐藏的是一种确定的具体类型。
  4. 所有接口都用 dyn Trait:静态已知时,泛型或枚举更直接。
  5. 生命周期能延长数据寿命:它只描述关系,不创造存活时间。
  6. 借用型结构体一定更高效:长期领域对象拥有数据往往更简单。
  7. 'static 是万能修复:它会显著收紧接口,甚至迫使调用者克隆或泄漏数据。

十一、实践练习

练习一:从重复函数提取泛型

分别为整数和浮点数写两个“返回较大值”的函数,再尝试抽成泛型。记录为什么仅有 T 还不够,以及比较操作需要什么 Trait 约束。

练习二:设计最小 Trait

WorkItemReleaseGate 定义 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章:三层架构 —— 抽象边界的工程对照

本章目录
一、先识别变化点与稳定点二、泛型:把类型变成参数三、Trait:定义行为契约四、返回 impl Trait 与关联类型五、静态分发与特征对象六、生命周期:描述引用之间的关系七、结构体引用、消除规则与 NLL八、'static 到底约束谁九、组合示例十、常见误区十一、实践练习十二、总结十三、下一章Related Documents
苏ICP备2025204887号-2