课程概览 · 第 10 章

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

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

泛型让同一段算法处理多种类型,trait 把算法所需能力写成契约。抽象并不放宽类型检查:泛型函数必须仅依赖签名承诺的能力。本章先从具体用例找到最小约束,再区分编译期确定类型与运行时选择实现。

trait 最小能力合同、泛型静态分发、dyn 动态分发与 newtype 适配
图:先定义能力边界;类型已知时优先静态分发,运行时异构时再引入 dyn。

前置知识与学习目标

需要函数、借用、结构体、枚举与 Result,不要求先学后续集合实现。学完后应能:

  • 从具体函数提炼泛型、bound、where 与 ?Sized。
  • 用必需方法、默认方法和关联类型表达最小 trait 契约。
  • 区分泛型、参数与返回位置的 impl Trait、dyn Trait。
  • 判断 dyn 兼容性,解释分发成本及 newtype 的一致性边界。

所有源码仅依赖标准库、Edition 2024。独立示例各自是完整 .rs 文件;片段注明定义依赖与插入位置。不需要复制整个课程的任务模型。

从具体函数到泛型与 bound

只有一个具体需求时,highest_priority(tasks: &[Task]) 通常比预先设计万能接口清楚。第二个真实用例需要相同算法时,再让元素类型变化,用 bound 保留算法必须依赖的能力。

独立示例(标准库;完整文件 largest.rs):借用返回最大元素,空切片正常缺失。

1
2
3
4
5
6
7
8
9
fn largest<T: Ord>(items: &[T]) -> Option<&T> {
items.iter().max()
}

fn main() {
assert_eq!(largest(&[3, 7, 5]), Some(&7));
assert_eq!(largest(&["alice", "carol", "bob"]), Some(&"carol"));
assert_eq!(largest::<i32>(&[]), None);
}

T 不是任意类型,而是任意实现 Ord 的类型。Option 表达空输入,引用说明结果仍属于输入、不复制元素;若需要拥有副本,由调用者决定克隆。去掉 Ord 后函数体无法证明元素可比较,会在使用 max 的地方被拒绝。

泛型也可以出现在类型、方法和 impl 上。把 bound 放到最小需要它的方法或 impl,比限制整个类型更灵活。

独立示例(标准库;完整文件 point.rs):通用方法与具体类型专属方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct Point<T> { x: T, y: T }

impl<T> Point<T> {
fn x(&self) -> &T { &self.x }
}

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);
}

简单约束可放在类型参数旁,较长的关系使用 where 更易读。T 默认隐含 Sized;str、切片和 trait object 是动态大小类型,通常通过引用或拥有指针访问。?Sized 放宽这个默认约束,不是“所有 trait 都可以任选”。

独立示例(标准库;完整文件 bounds.rs):where 与接受 str 的 ?Sized 接口。

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

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

fn print_label<T: Display + ?Sized>(value: &T) {
println!("{value}");
}

fn main() {
assert_eq!(format_pair(&"limit", &10), "limit=10");
print_label::<str>("release-2026-08");
}

&T 本身大小已知,即使 T 未实现 Sized。不要为所有接口机械添加 Clone + Debug + Send + Sync;先检查是否把不相关职责塞在同一个函数里。

trait:契约、默认方法与关联类型

trait 表达调用者所需行为,不是字段继承。以下存储契约将“没找到”与“失败”区分,且由实现固定其错误类型。查询借用任务,插入获取所有权;实际保存方式不属于 trait 的字段合同。

代码片段(标准库;独立模块级定义,放入 store_contract.rs;包含本块使用的最小 TaskId、Task,无 main 与存储实现):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#[derive(Clone, Copy, PartialEq, Eq)]
struct TaskId(u64);
struct Task { id: TaskId, title: String }

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

fn lookup<S: TaskStore>(store: &S, id: TaskId)
-> Result<Option<&Task>, S::Error>
{
store.get(id)
}

fn lookup_dyn<E>(store: &dyn TaskStore<Error = E>, id: TaskId)
-> Result<Option<&Task>, E>
{
store.get(id)
}

type Error 表示每种实现选择一种错误类型:内存实现可以用 Infallible,文件实现可以用 io::Error。若写成 trait TaskStore<E>,则同一类型可以针对不同 E 分别实现;只有这种多份实现确有意义时才考虑泛型 trait。使用 trait object 必须明确关联类型,例如 dyn TaskStore<Error = E>,不能把它省略成一份未确定错误类型的对象接口。

若调用者只需要查询,拆出更小的读取 trait,比要求所有实现同时支持写入、事务更合适。契约也无法自动保证“插入后必能查到”等语义;这些行为仍需实现与测试。

独立示例(标准库;完整文件 summary.rs):实现必需方法,复用默认流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 }
}

fn main() {
let note = Note {
owner: String::from("alice"),
text: String::from("release ready"),
};
assert_eq!(note.preview(), "alice: release ready");
}

默认方法可由实现覆盖,但只能依赖 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
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
use std::io::Write;

fn copy_twice<W: Write>(out: &mut W, bytes: &[u8])
-> std::io::Result<()>
{
out.write_all(bytes)?;
out.write_all(bytes)
}

fn write_header(out: &mut impl Write) -> std::io::Result<()> {
out.write_all(b"Content-Type: text/plain\r\n")
}

fn write_footer(out: &mut dyn Write) -> std::io::Result<()> {
out.write_all(b"\r\n")
}

fn main() -> std::io::Result<()> {
let mut out = Vec::new();
write_header(&mut out)?;
write_footer(&mut out)?;
assert_eq!(out, b"Content-Type: text/plain\r\n\r\n");
assert!(out.contains(&0x0d));
assert!(out.contains(&0x0a));
assert!(!out.contains(&b'\\'));
let mut twice = Vec::new();
copy_twice(&mut twice, b"task")?;
assert_eq!(twice, b"tasktask");
Ok(())
}

源码中的 \r\n 是回车与换行的转义序列;如果写成 \\r\\n,实际写出的就变成反斜杠和字母。本例的断言区分这两种内容。

返回位置的 -> impl Write 隐藏一个固定的具体类型,各返回路径必须兼容这个类型;它不是“本次随便返回某个实现”。运行时分支需要不同实现时可使用枚举或 trait object。

独立示例(标准库;完整文件 selected_output.rs):返回拥有的异构输出目标。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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())
}
}

fn main() -> io::Result<()> {
for discard in [false, true] {
selected_output(discard).write_all(b"task")?;
}
Ok(())
}

成功运行仅说明两条 writer 路径都接受了字节,不声称可从擦除后的接口读回内容。若只借用现有 writer,用 &mut dyn Write,不必为了动态分发创建 Box。

dyn-compatible:旧称对象安全

动态兼容性规则要求接口可在不知道具体 Self 时调用。方法有自身类型参数、返回具体 Self、在普通参数位置使用 Self 等情况通常不能动态调用。对这些方法添加 where Self: Sized 可以将它们排除出动态接口;不是把所有不兼容成员都用这一办法修复。

独立示例(标准库;完整文件 dyn_job.rs):动态调用 run,具体类型调用构造与泛型编码。

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
use std::fmt::Debug;

trait Job {
fn run(&self) -> &str;
fn from_config(text: &str) -> Self where Self: Sized;
fn encode<T: Debug>(&self, value: T) -> String where Self: Sized;
}

struct PrintJob { title: String }

impl Job for PrintJob {
fn run(&self) -> &str { &self.title }
fn from_config(text: &str) -> Self {
Self { title: text.to_string() }
}
fn encode<T: Debug>(&self, value: T) -> String {
format!("{}: {value:?}", self.title)
}
}

fn main() {
let job = PrintJob::from_config("release");
assert_eq!(job.encode(7), "release: 7");
let object: &dyn Job = &job;
assert_eq!(object.run(), "release");
}

故意失败:编译错误(标准库;沿用 dyn_job.rs 的 import、trait 和实现,只用此 main 替换入口):排除的方法不能经对象调用。

1
2
3
4
5
fn main() {
let job = PrintJob::from_config("release");
let object: &dyn Job = &job;
println!("{}", object.encode(7));
}

失败来自 encode 的 Self: Sized 条件。若 trait 本身声明为 trait Job: Sized,整个 trait 就不能作为 dyn Job。

关联常量会使整个 trait 不兼容,不能像方法那样用 Self: Sized 把常量单独排除。下面的错误发生在构造对象接口时,不是在调用某个常量时。

故意失败:编译错误(标准库;完整文件 associated_const.rs):

1
2
3
4
5
6
7
8
trait Versioned {
const VERSION: u32;
fn run(&self);
}

fn inspect(_: &dyn Versioned) {}

fn main() {}

按值 self 与 self: Box<Self> 也要区分:普通按值方法不一定使整个 trait 不兼容,但无法从借用的 trait object 中移动出未知大小的底层值。拥有 Box<dyn Trait> 并需要消费对象时,可以使用后者。

独立示例(标准库;完整文件 boxed_receiver.rs):消费拥有的 trait object。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
trait Finish {
fn finish(self: Box<Self>) -> String;
}

struct Title(String);

impl Finish for Title {
fn finish(self: Box<Self>) -> String { self.0 }
}

fn main() {
let value: Box<dyn Finish> = Box::new(Title(String::from("done")));
assert_eq!(value.finish(), "done");
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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(())
}
}

fn main() {
assert_eq!(HexBytes(vec![0, 15, 255]).to_string(), "000fff");
}

一致性还要求实现不重叠。blanket impl 为一族满足 bound 的类型提供实现;再写一份可能重叠的实现会被拒绝。标准库为 Display 类型提供 ToString 就是常见的 blanket impl,因此通常实现 Display 即可,不必再手写 ToString。完整孤儿规则还有泛型覆盖顺序等细节,“本地类型总能解决一切重叠”也不是保证。

深入阅读

条件实现保持类型本身宽松

独立示例(标准库;完整文件 pair.rs):不限制 Pair 存在,只限制格式化方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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)
}
}

fn main() {
let numbers = Pair { left: 1, right: 2 };
assert_eq!(numbers.show(), "(1, 2)");
let vectors = Pair { left: vec![1], right: vec![2] };
assert_eq!(vectors.left.len() + vectors.right.len(), 2);
}

Pair<Vec<u8>> 能存在,但不能调用要求 Display 的 show。若把 bound 放在结构体定义上,连存放这些向量也被限制了。

关联类型与超 trait

代码片段(标准库;放入 batch_contract.rs 的模块级;无入口、无其他类型依赖):一个实现固定一种产出类型。

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

若一个类型需要针对多种输入分别实现相同 trait,则用 trait 泛型,标准库 From<T> 就是这个方向。超 trait 表达另一项必需能力,不继承字段。

独立示例(标准库;完整文件 outline.rs):OutlinePrint 的默认实现依赖 Display。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use std::fmt::{self, Display};

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

struct Label(&'static str);

impl Display for Label {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.0)
}
}

impl OutlinePrint for Label {}

fn main() {
Label("task").outline_print();
}

这个边框例只按 ASCII 字节长度演示流程,不是 Unicode 终端宽度算法。trait object 常见实现包含数据指针和虚表元数据,这有助于理解动态调用,但默认表示与具体虚表布局不是可用于 FFI 的稳定 ABI。

自测

  1. largest 为什么需要 Ord??Sized 又放宽了什么?
  2. TaskStore::get 为什么返回 Result<Option<&Task>, _>?对象类型为什么要写 Error = E?
  3. 参数和返回位置的 impl Trait 分别由谁决定具体类型?不同返回路径可否是不同类型?
  4. Self: Sized 方法与关联常量对 dyn 兼容性的影响有何区别?怎样消费拥有的对象?
  5. 为什么 HexBytes 能实现 Display,而本 crate 不能直接给 Vec<u8> 实现它?

参考答案

  1. 函数体需要元素可比较;?Sized 取消泛型参数默认的 Sized 要求,通常经引用接收 DST。
  2. 区分正常缺失和失败,并借用已有任务;trait object 必须固定关联错误类型。
  3. 参数由调用者选择,返回由实现决定一个隐藏具体类型;返回路径必须兼容同一隐藏类型。
  4. 条件可将方法排除出动态接口,关联常量使整个 trait 不兼容;可用 self: Box<Self> 消费拥有的对象。
  5. newtype 是本地类型,满足实现的本地性要求;Vec 与 Display 都来自外部,直接组合违反孤儿规则。