泛型与 trait:复用行为,同时保持接口清晰
课程概览 · 第 10 章
泛型让同一段算法处理多种类型,trait 把算法所需能力写成契约。抽象并不放宽类型检查:泛型函数必须仅依赖签名承诺的能力。本章先从具体用例找到最小约束,再区分编译期确定类型与运行时选择实现。
前置知识与学习目标
需要函数、借用、结构体、枚举与 Result,不要求先学后续集合实现。学完后应能:
- 从具体函数提炼泛型、bound、
where与?Sized。 - 用必需方法、默认方法和关联类型表达最小 trait 契约。
- 区分泛型、参数与返回位置的
impl Trait、dyn Trait。 - 判断 dyn 兼容性,解释分发成本及 newtype 的一致性边界。
所有源码仅依赖标准库、Edition 2024。独立示例各自是完整 .rs 文件;片段注明定义依赖与插入位置。不需要复制整个课程的任务模型。
从具体函数到泛型与 bound
只有一个具体需求时,highest_priority(tasks: &[Task]) 通常比预先设计万能接口清楚。第二个真实用例需要相同算法时,再让元素类型变化,用 bound 保留算法必须依赖的能力。
独立示例(标准库;完整文件 largest.rs):借用返回最大元素,空切片正常缺失。
1 | |
T 不是任意类型,而是任意实现 Ord 的类型。Option 表达空输入,引用说明结果仍属于输入、不复制元素;若需要拥有副本,由调用者决定克隆。去掉 Ord 后函数体无法证明元素可比较,会在使用 max 的地方被拒绝。
泛型也可以出现在类型、方法和 impl 上。把 bound 放到最小需要它的方法或 impl,比限制整个类型更灵活。
独立示例(标准库;完整文件 point.rs):通用方法与具体类型专属方法。
1 | |
简单约束可放在类型参数旁,较长的关系使用 where 更易读。T 默认隐含 Sized;str、切片和 trait object 是动态大小类型,通常通过引用或拥有指针访问。?Sized 放宽这个默认约束,不是“所有 trait 都可以任选”。
独立示例(标准库;完整文件 bounds.rs):where 与接受 str 的 ?Sized 接口。
1 | |
&T 本身大小已知,即使 T 未实现 Sized。不要为所有接口机械添加 Clone + Debug + Send + Sync;先检查是否把不相关职责塞在同一个函数里。
trait:契约、默认方法与关联类型
trait 表达调用者所需行为,不是字段继承。以下存储契约将“没找到”与“失败”区分,且由实现固定其错误类型。查询借用任务,插入获取所有权;实际保存方式不属于 trait 的字段合同。
代码片段(标准库;独立模块级定义,放入 store_contract.rs;包含本块使用的最小 TaskId、Task,无 main 与存储实现):
1 | |
type Error 表示每种实现选择一种错误类型:内存实现可以用 Infallible,文件实现可以用 io::Error。若写成 trait TaskStore<E>,则同一类型可以针对不同 E 分别实现;只有这种多份实现确有意义时才考虑泛型 trait。使用 trait object 必须明确关联类型,例如 dyn TaskStore<Error = E>,不能把它省略成一份未确定错误类型的对象接口。
若调用者只需要查询,拆出更小的读取 trait,比要求所有实现同时支持写入、事务更合适。契约也无法自动保证“插入后必能查到”等语义;这些行为仍需实现与测试。
独立示例(标准库;完整文件 summary.rs):实现必需方法,复用默认流程。
1 | |
默认方法可由实现覆盖,但只能依赖 trait 承诺的能力,不能假定具体实现一定有 owner 字段。derive 生成常见的结构性实现,而业务 I/O 行为仍应由明确的 impl 完成。关于比较、转换、I/O 等能力,可补充阅读标准库模块地图与常用 trait,本章的接口推理不依赖该文。
常见派生中,Debug 面向调试,Clone 是显式克隆,PartialEq 支持部分相等关系而 Eq 要求完整相等关系。Copy 还承诺隐式复制,不能拥有需要自身析构的资源;优先用于小值是成本建议,不是语言大小限制。派生实现按字段工作,不会验证业务输入,第 7 章的校验构造器仍有独立职责。
泛型与 impl Trait 参数、返回
显式泛型适合在多个位置复用同一类型参数;参数位置 impl Trait 适合只需表达一次的能力约束;两者均由调用者选择具体类型。dyn Trait 则在接口边界擦除具体类型,通过动态分发调用方法。
独立示例(标准库;完整文件 writers.rs):三种 writer 参数及精确 CRLF 字节断言。
1 | |
源码中的 \r\n 是回车与换行的转义序列;如果写成 \\r\\n,实际写出的就变成反斜杠和字母。本例的断言区分这两种内容。
返回位置的 -> impl Write 隐藏一个固定的具体类型,各返回路径必须兼容这个类型;它不是“本次随便返回某个实现”。运行时分支需要不同实现时可使用枚举或 trait object。
独立示例(标准库;完整文件 selected_output.rs):返回拥有的异构输出目标。
1 | |
成功运行仅说明两条 writer 路径都接受了字节,不声称可从擦除后的接口读回内容。若只借用现有 writer,用 &mut dyn Write,不必为了动态分发创建 Box。
dyn-compatible:旧称对象安全
动态兼容性规则要求接口可在不知道具体 Self 时调用。方法有自身类型参数、返回具体 Self、在普通参数位置使用 Self 等情况通常不能动态调用。对这些方法添加 where Self: Sized 可以将它们排除出动态接口;不是把所有不兼容成员都用这一办法修复。
独立示例(标准库;完整文件 dyn_job.rs):动态调用 run,具体类型调用构造与泛型编码。
1 | |
故意失败:编译错误(标准库;沿用 dyn_job.rs 的 import、trait 和实现,只用此 main 替换入口):排除的方法不能经对象调用。
1 | |
失败来自 encode 的 Self: Sized 条件。若 trait 本身声明为 trait Job: Sized,整个 trait 就不能作为 dyn Job。
关联常量会使整个 trait 不兼容,不能像方法那样用 Self: Sized 把常量单独排除。下面的错误发生在构造对象接口时,不是在调用某个常量时。
故意失败:编译错误(标准库;完整文件 associated_const.rs):
1 | |
按值 self 与 self: Box<Self> 也要区分:普通按值方法不一定使整个 trait 不兼容,但无法从借用的 trait object 中移动出未知大小的底层值。拥有 Box<dyn Trait> 并需要消费对象时,可以使用后者。
独立示例(标准库;完整文件 boxed_receiver.rs):消费拥有的 trait object。
1 | |
Clone 的 clone(&self) -> Self 需要具体类型,而且 trait 有 Sized 前提,因此不能直接使用 dyn Clone。需要异构克隆时可以设计返回 Box<dyn Trait> 的专门接口;不需要异构时保留泛型与 Clone bound 更直接。
选型成本、newtype 与一致性规则
| 条件 | 优先选择 | 边界 |
|---|---|---|
| 类型在编译期已知 | 泛型、参数 impl Trait | 保留具体类型与静态分发 |
| 有限的已知实现集合 | 枚举 | 新变体需要复核匹配 |
| 运行时选择异构实现 | &dyn Trait、Box<dyn Trait> | 只暴露 trait 承诺的能力 |
| 需要拥有异构集合 | Vec<Box<dyn Trait>> | 同时考虑拥有与分配成本 |
泛型通常通过单态化生成各具体类型的专用代码,可直接调用并有机会内联,代价可能是代码体积和编译时间。dyn 的典型方法调用多一层间接分发,不必然有可观察的性能劣势;应测量实际路径。借用的 trait object 不要求堆分配;Box 提供拥有的堆存储,零大小类型还可能无需实际分配。
擦除类型也擦除专属能力:dyn Write 不能调用 File::sync_all。因此把动态接口限制在真正需要运行时选择的一层,不要为抽象而把领域内部全部改成 dyn。
孤儿规则限制为外部 trait 与外部类型的组合直接补实现,例如不能在本 crate 为 Vec<u8> 实现标准库 Display。本地 newtype 既提供合法实现位置,也声明新的表示语义。
独立示例(标准库;完整文件 hex_bytes.rs):保留十六进制适配器。
1 | |
一致性还要求实现不重叠。blanket impl 为一族满足 bound 的类型提供实现;再写一份可能重叠的实现会被拒绝。标准库为 Display 类型提供 ToString 就是常见的 blanket impl,因此通常实现 Display 即可,不必再手写 ToString。完整孤儿规则还有泛型覆盖顺序等细节,“本地类型总能解决一切重叠”也不是保证。
深入阅读
条件实现保持类型本身宽松
独立示例(标准库;完整文件 pair.rs):不限制 Pair 存在,只限制格式化方法。
1 | |
Pair<Vec<u8>> 能存在,但不能调用要求 Display 的 show。若把 bound 放在结构体定义上,连存放这些向量也被限制了。
关联类型与超 trait
代码片段(标准库;放入 batch_contract.rs 的模块级;无入口、无其他类型依赖):一个实现固定一种产出类型。
1 | |
若一个类型需要针对多种输入分别实现相同 trait,则用 trait 泛型,标准库 From<T> 就是这个方向。超 trait 表达另一项必需能力,不继承字段。
独立示例(标准库;完整文件 outline.rs):OutlinePrint 的默认实现依赖 Display。
1 | |
这个边框例只按 ASCII 字节长度演示流程,不是 Unicode 终端宽度算法。trait object 常见实现包含数据指针和虚表元数据,这有助于理解动态调用,但默认表示与具体虚表布局不是可用于 FFI 的稳定 ABI。
自测
largest为什么需要Ord??Sized又放宽了什么?TaskStore::get为什么返回Result<Option<&Task>, _>?对象类型为什么要写Error = E?- 参数和返回位置的
impl Trait分别由谁决定具体类型?不同返回路径可否是不同类型? Self: Sized方法与关联常量对 dyn 兼容性的影响有何区别?怎样消费拥有的对象?- 为什么
HexBytes能实现Display,而本 crate 不能直接给Vec<u8>实现它?
参考答案
- 函数体需要元素可比较;
?Sized取消泛型参数默认的Sized要求,通常经引用接收 DST。 - 区分正常缺失和失败,并借用已有任务;trait object 必须固定关联错误类型。
- 参数由调用者选择,返回由实现决定一个隐藏具体类型;返回路径必须兼容同一隐藏类型。
- 条件可将方法排除出动态接口,关联常量使整个 trait 不兼容;可用
self: Box<Self>消费拥有的对象。 - newtype 是本地类型,满足实现的本地性要求;
Vec与Display都来自外部,直接组合违反孤儿规则。






