Rust Book 编译版 10 · 泛型、trait 与生命周期:Rust 的三大复用机制
编译自 The Rust Programming Language(2024 edition),在原文基础上做了删减与补充。© The Rust Project Developers,依据 MIT OR Apache-2.0 许可编译转载。
开始之前
「怎么少写重复代码」,每门语言都要回答。Rust 的答案是一条递进的抽象阶梯:函数用参数抽象了值,同一段代码得以处理不同的值;泛型(generic)抽象了类型,同一段代码得以处理不同的类型;trait(特征)抽象了行为,只约束「能做什么」而不管「是什么」;生命周期(lifetime)再补上一块拼图——描述引用有效性之间的关系,让借用检查器(borrow checker)能放行更多合法代码。本章把这三样一次讲完,顺带回答一个藏在背后的问题:这些抽象为什么没有运行时开销——泛型靠编译期单态化(monomorphization)展开成具体代码,另一条动态派发的路线,要等第 18 章的 trait 对象再登场。
其实你早就在用泛型了:第 6 章的 Option<T>、第 8 章的 Vec<T> 与 HashMap<K, V>、第 9 章的 Result<T, E>。本章讲的是怎么亲手定义它们。全章一条主线——largest 函数——从两份复制粘贴的代码出发,一路讲完泛型、trait 约束和生命周期标注:
重复的「值」 → 提取函数: fn largest(list: &[i32])
重复的「类型」 → 泛型: fn largest<T>(list: &[T])
重复的「行为」 → trait + 约束: fn notify(item: &impl Summary)
重复的「关系」 → 生命周期标注: fn longest<'a>(x: &'a str, y: &'a str) -> &'a str
前置知识:第 9 章错误处理——Result<T, E> 的两个类型参数正是本章的伏笔。
你会学到:
- 从「提取函数消除重复」过渡到「泛型消除类型重复」,
largest函数全章贯穿 - 定义泛型函数、结构体、枚举与方法,理解单态化为何让泛型零运行时开销
- 用 trait 定义共享行为:默认实现、
impl Trait与 trait 约束、where子句、批量实现 - 用生命周期标注描述引用之间的关系,掌握三条省略规则与
'static的真实含义
通过提取函数来消除重复
泛型用一个「能代表多种类型的占位符」替换具体类型来消除重复。在碰泛型语法之前,先看一种不需要泛型的消除重复手法:提取函数,用一个「能代表多个值的占位符」替换具体的值。学会了识别哪些重复能提取成函数,也就开始能识别哪些重复适合泛型——两种思维是一回事。
先从示例 10-1 这个在数字列表中找出最大数字的小程序开始。
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let mut largest = &number_list[0];
for number in &number_list {
if number > largest {
largest = number;
}
}
println!("The largest number is {largest}");
}
示例 10-1:在数字列表中找出最大的数字
逻辑很朴素:把第一个数字的引用存进 largest,遍历列表,遇到更大的就替换;遍历结束时 largest 指向最大值,本例是 100。现在假设任务变成「在两个不同的列表里分别找最大值」,最省事的做法是复制粘贴,如示例 10-2。
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let mut largest = &number_list[0];
for number in &number_list {
if number > largest {
largest = number;
}
}
println!("The largest number is {largest}");
let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];
let mut largest = &number_list[0];
for number in &number_list {
if number > largest {
largest = number;
}
}
println!("The largest number is {largest}");
}
示例 10-2:在两个数字列表中找出最大数字的代码
能用,但复制代码既繁琐又容易出错,想改逻辑还得记得多处同步。消除这种重复的办法,是定义一个对任何以参数形式传入的整数列表都适用的函数——一个抽象。示例 10-3 把找最大值的代码提取进 largest 函数,两处重复改成调用;将来遇到任何 i32 列表也都能复用。
fn largest(list: &[i32]) -> &i32 {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = largest(&number_list);
println!("The largest number is {result}");
let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];
let result = largest(&number_list);
println!("The largest number is {result}");
}
示例 10-3:抽象出函数后,在两个列表中查找最大数字的代码
largest 的参数 list 代表「任何可能传进来的 i32 值切片」,调用时代码就运行在传入的具体值上。从示例 10-2 到 10-3 总共三步:
- 识别出重复的代码。
- 把重复代码提取到函数体中,并在函数签名中指明输入和返回值。
- 把两处重复的代码改为调用该函数。
接下来把同样的步骤套到泛型上:函数体可以操作抽象的 list 而非具体的值,泛型则让代码操作抽象的类型。比如一个函数在 i32 切片中找最大项、另一个在 char 切片中找最大项,怎么合并成一份?往下看。
泛型数据类型
用泛型可以为函数签名、结构体等条目创建定义,再把它们用于许多不同的具体类型。本节按函数、结构体、枚举、方法的顺序展开,最后回答「泛型慢不慢」。
在函数定义中
定义使用泛型的函数时,把泛型放在函数签名中通常标注参数和返回值类型的位置——代码更灵活,调用方获得更多功能,同时避免重复。
继续 largest 的例子。示例 10-4 给出两个都在切片中查找最大值的函数,接下来合并成一个使用泛型的函数。
fn largest_i32(list: &[i32]) -> &i32 {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn largest_char(list: &[char]) -> &char {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = largest_i32(&number_list);
println!("The largest number is {result}");
let char_list = vec!['y', 'm', 'a', 'q'];
let result = largest_char(&char_list);
println!("The largest char is {result}");
}
示例 10-4:两个只有函数名和签名中类型不同的函数
两个函数体一模一样。要在单个函数里把类型参数化,先得给类型参数起个名字——就像给函数的值参数命名一样。任何标识符都行,但惯例用 T:Rust 的类型参数名很短、常常只有一个字母,类型命名用 UpperCamelCase(大驼峰式),T 是 type 的缩写,多数人的默认选择。和「值参数必须先在签名中声明、才能在函数体里使用」相同,类型参数也必须先声明——放进函数名与参数列表之间的尖括号 <> 里,像这样:
fn largest<T>(list: &[T]) -> &T {
读作「函数 largest 对某个类型 T 泛型」:参数是 T 类型值组成的切片,返回指向同类型 T 的值的引用。示例 10-5 是合并后的定义与两种调用,注意它暂时编译不过。
fn largest<T>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = largest(&number_list);
println!("The largest number is {result}");
let char_list = vec!['y', 'm', 'a', 'q'];
let result = largest(&char_list);
println!("The largest char is {result}");
}
示例 10-5:使用泛型类型参数的 largest 函数;目前还无法编译
现在编译这段代码,会得到:
cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0369]: binary operation `>` cannot be applied to type `&T`
--> src/main.rs:5:17
|
5 | if item > largest {
| ---- ^ ------- &T
| |
| &T
|
help: consider restricting type parameter `T` with trait `PartialOrd`
|
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
| ++++++++++++++++++++++
For more information about this error, try `rustc --explain E0369`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
帮助信息提到的 std::cmp::PartialOrd 是一个 trait,正是下一节的主角。眼下只需明白:largest 的函数体要比较 T 类型的值,而「比较」并非所有类型都支持——函数体并不能适用于 T 的所有可能类型。标准库提供了 std::cmp::PartialOrd trait 来支持比较(更多信息见附录 C),并且已经为 i32 和 char 实现了它。按帮助信息的建议,把 T 约束为「实现了 PartialOrd 的类型」,示例就能编译了。
在结构体定义中
同样用 <> 语法,可以让结构体的一个或多个字段使用泛型类型参数。示例 10-6 定义了可以存放任意类型坐标值的 Point<T>。
struct Point<T> {
x: T,
y: T,
}
fn main() {
let integer = Point { x: 5, y: 10 };
let float = Point { x: 1.0, y: 4.0 };
}
示例 10-6:存放 T 类型的 x 值和 y 值的 Point<T> 结构体
语法与函数中类似:结构体名后面的尖括号里声明类型参数,定义体里原本写具体类型的地方改用泛型。注意定义里只有一个 T,含义是:字段 x 和 y 都是这个相同的类型。如果实例里两个字段类型不同,如示例 10-7,代码无法通过编译。
struct Point<T> {
x: T,
y: T,
}
fn main() {
let wont_work = Point { x: 5, y: 4.0 };
}
示例 10-7:字段 x 和 y 必须是同一类型,因为它们共用同一个泛型数据类型 T。
把整数 5 赋给 x,等于告诉编译器这个实例的 T 是整数;再给 y 赋 4.0——它的类型已定为与 x 相同——于是得到类型不匹配错误:
cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0308]: mismatched types
--> src/main.rs:7:38
|
7 | let wont_work = Point { x: 5, y: 4.0 };
| ^^^ expected integer, found floating-point number
For more information about this error, try `rustc --explain E0308`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
想让 x 和 y 都是泛型、但类型可以不同,就用多个泛型类型参数。示例 10-8 把 Point 改为对 T、U 两个类型泛型。
struct Point<T, U> {
x: T,
y: U,
}
fn main() {
let both_integer = Point { x: 5, y: 10 };
let both_float = Point { x: 1.0, y: 4.0 };
let integer_and_float = Point { x: 5, y: 4.0 };
}
示例 10-8:对两个类型泛型的 Point<T, U>,使 x 和 y 可以是不同类型的值
这下所有实例都合法了。泛型参数想用多少个都行,但超过几个之后代码就难读了;发现自己需要大量泛型类型参数,多半是代码在提示你:该重构、拆成更小的部分了。
在枚举定义中
枚举同样可以在变体中持有泛型数据。回头看标准库提供的、第 6 章用过的 Option<T> 定义:
enum Option<T> {
Some(T),
None,
}
Some 持有一个 T,None 不持有——「可选值」这个抽象概念与具体类型无关,泛型让它对任何类型都适用。多个泛型类型的例子是第 9 章用过的 Result:
enum Result<T, E> {
Ok(T),
Err(E),
}
持有 T 类型值的 Ok,加上持有 E 类型值的 Err:凡是可能成功(返回 T)或失败(返回 E)的操作都能用它。示例 9-3 打开文件时,T 被填入 std::fs::File,E 被填入 std::io::Error。
当你发现多个结构体或枚举定义唯一的区别只是所持有值的类型,就可以改用泛型类型来消除重复。
在方法定义中
第 5 章在结构体和枚举上实现过方法,方法定义里同样能用泛型。示例 10-9 在 Point<T> 上实现一个名为 x 的方法。
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}
fn main() {
let p = Point { x: 5, y: 10 };
println!("p.x = {}", p.x());
}
示例 10-9:在 Point<T> 结构体上实现名为 x 的方法,返回指向 T 类型的 x 字段的引用
注意必须在 impl 后面紧接着声明 T,这样 Point 尖括号里的 T 才会被识别为泛型而不是具体类型(泛型参数名可以与结构体定义里的不同,惯例用同一个)。在这种 impl 里编写的方法,对该类型的任何实例都存在,无论泛型最终被替换成什么具体类型。
还可以反过来收窄:只在某个具体化的类型上实现方法。示例 10-10 用了具体类型 f32,impl 后面就无需声明任何类型。
impl Point<f32> {
fn distance_from_origin(&self) -> f32 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}
示例 10-10:一个只适用于泛型类型参数 T 取特定具体类型的结构体的 impl 块
含义是:只有 Point<f32> 拥有 distance_from_origin 方法,其他 Point<T> 没有——该方法计算点到原点的距离,用到了只有浮点类型才支持的数学运算。
结构体定义的泛型参数与方法签名的泛型参数也不必相同。示例 10-11 给结构体用 X1、Y1,给 mixup 方法的签名用 X2、Y2:拿 self 的 x 值和传入 Point 的 y 值,拼出一个新的 Point 实例。
struct Point<X1, Y1> {
x: X1,
y: Y1,
}
impl<X1, Y1> Point<X1, Y1> {
fn mixup<X2, Y2>(self, other: Point<X2, Y2>) -> Point<X1, Y2> {
Point {
x: self.x,
y: other.y,
}
}
}
fn main() {
let p1 = Point { x: 5, y: 10.4 };
let p2 = Point { x: "Hello", y: 'c' };
let p3 = p1.mixup(p2);
println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}
示例 10-11:一个使用了与其结构体定义不同的泛型类型的方法
main 里 p1 的 x 是 i32(值 5)、y 是 f64(值 10.4);p2 的 x 是字符串切片("Hello")、y 是 char(c)。p1.mixup(p2) 得到 p3:x 来自 p1 是 i32,y 来自 p2 是 char,打印 p3.x = 5, p3.y = c。
这个例子演示的要点是:有些泛型参数随 impl 声明(X1、Y1,配合结构体定义),有些随方法定义声明(X2、Y2,只与方法本身有关)。
泛型代码的性能
一个常见疑问:泛型类型参数有没有运行时开销?好消息是,泛型代码不会比使用具体类型的代码跑得慢。
Rust 靠编译期对泛型代码执行单态化(monomorphization)做到这一点:把泛型代码转换成特定代码,填入编译时用到的具体类型。编译器查看所有调用泛型代码的位置,为用到的每种具体类型各生成一份代码——和我们创建示例 10-5 的步骤恰好互逆。
用标准库的 Option<T> 看这一过程:
let integer = Some(5);
let float = Some(5.0);
编译器读取 Option<T> 实例用到的值,识别出两种 Option<T>——i32 和 f64——把泛型定义展开成专门针对两者的定义。单态化之后的代码与下面的类似(编译器使用的名称与我们为便于说明所用的不同):
enum Option_i32 {
Some(i32),
None,
}
enum Option_f64 {
Some(f64),
None,
}
fn main() {
let integer = Option_i32::Some(5);
let float = Option_f64::Some(5.0);
}
Option<T> 被替换成了指明具体类型的特定定义——运行时表现与手工复制每一份定义完全相同。泛型的灵活性,并不会带来运行时的额外开销。
编译注:写过 C++ 模板的读者到这里应该会心一笑:单态化就是模板实例化(template instantiation)。真正的差别在检查时机——Rust 的泛型在定义处就按 trait 约束完成类型检查,错误直接指向
fn largest那一行;C++ 模板是鸭子类型,只有实例化时才检查,错误常常从库的深处喷出一屏。把「约束」做成泛型的一等公民这件事,concepts 折腾了十几年才随 C++20(2020 年)进标准,而 trait bound(前面见过,但我没有详细介绍,下面马上就讲) 从 Rust 1.0(2015 年)起就是约束泛型的基本机制。当然单态化也有和模板实例化一样的代价:类型组合一多,编译变慢、二进制变大。
用 trait 定义共享行为
trait 定义了某个特定类型拥有、并且可以与其他类型共享的功能。可以用 trait 以抽象的方式定义共享行为,还可以用 trait 约束(trait bound)来指定某个泛型只接受具备特定行为的类型。
提示: trait 与其他语言中通常称为接口(interface)的特性相似,但也存在一些差异。
定义 trait
一个类型的行为由能在该类型上调用的方法构成;同一组方法可以在多个类型上调用,这些类型就共享同一种行为。trait 定义就是把方法签名组织在一起,描述完成某项任务所需的一组行为。
举个例子:假设有多个持有文本的结构体——NewsArticle 存放一条在特定地点发稿的新闻报道;SocialPost 内容最多 280 个字符,还带有元数据,标明它是新帖、转发还是对另一条帖子的回复。我们想做一个名为 aggregator 的媒体聚合器库 crate 来显示这些数据的摘要:每种类型都要提供摘要,通过在实例上调用 summarize 方法获取。示例 10-12 就是表达这种行为的公开 Summary trait。
pub trait Summary {
fn summarize(&self) -> String;
}
示例 10-12:由 summarize 方法提供的行为组成的 Summary trait
trait 关键字后跟 trait 名(本例 Summary);声明为 pub,依赖本 crate 的其他 crate 才能用它。花括号内声明方法签名——fn summarize(&self) -> String——描述实现该 trait 的类型应具备的行为。
签名以分号结尾而不是给出实现:实现该 trait 的每个类型都必须提供自己的方法体,编译器强制保证签名一致。trait 体中可以包含多个方法:一行一个签名,每行以分号结尾。
为类型实现 trait
定义好期望的签名,就可以在聚合器的各个类型上实现它了。示例 10-13:NewsArticle 用标题、作者、地点拼出摘要;SocialPost 的摘要定义为用户名后跟帖子全文——反正内容已被限制在 280 个字符以内。
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location)
}
}
pub struct SocialPost {
pub username: String,
pub content: String,
pub reply: bool,
pub repost: bool,
}
impl Summary for SocialPost {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
示例 10-13:在 NewsArticle 和 SocialPost 类型上实现 Summary trait
语法与实现普通方法类似,区别在于:impl 后面先写 trait 名,接着 for 关键字,再写类型名;块内放 trait 中声明的方法签名,签名后不写分号,而是用花括号填入方法体。
实现之后,crate 的使用者就可以像调用普通方法一样调用这些 trait 方法,唯一的区别是必须把 trait 和类型一起引入作用域。下面是一个二进制 crate 使用 aggregator 库 crate 的例子:
use aggregator::{SocialPost, Summary};
fn main() {
let post = SocialPost {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
reply: false,
repost: false,
};
println!("1 new post: {}", post.summarize());
}
这段代码打印 1 new post: horse_ebooks: of course, as you probably already know, people。
依赖 aggregator 的其他 crate 也可以把 Summary 引入作用域,为自己的类型实现它。但有一条限制:trait 和类型至少有一个定义在本 crate 内,才能为该类型实现该 trait。所以可以为自己的 SocialPost 实现标准库的 Display(类型是本地的),也可以为 Vec<T> 实现 Summary(trait 是本地的);但不能为 Vec<T> 实现 Display——trait 和类型都是外部的。这条限制属于一致性(coherence)属性的一部分,具体叫孤儿规则(orphan rule,得名于「父类型不在场」)。它确保别人的代码不会破坏你的代码、你的也不会破坏别人的——没有它,两个 crate 可以为同一个类型实现同一个 trait,Rust 将无从得知该用哪一个实现。
使用默认实现
有时为 trait 的部分或全部方法提供默认行为更省事:之后为具体类型实现 trait 时,可以保留或覆盖默认行为。示例 10-14 给 summarize 指定了一个默认字符串。
pub trait Summary {
fn summarize(&self) -> String {
String::from("(Read more...)")
}
}
示例 10-14:定义带 summarize 方法默认实现的 Summary trait
想直接用默认实现,只需写一个空的 impl 块:impl Summary for NewsArticle {}。此后仍可在 NewsArticle 实例上调用 summarize,像这样:
let article = NewsArticle {
headline: String::from("Penguins win the Stanley Cup Championship!"),
location: String::from("Pittsburgh, PA, USA"),
author: String::from("Iceburgh"),
content: String::from(
"The Pittsburgh Penguins once again are the best \
hockey team in the NHL.",
),
};
println!("New article available! {}", article.summarize());
打印 New article available! (Read more...)。SocialPost 的实现不需要任何改动——覆盖默认实现的语法,与实现一个没有默认值的方法是同一套。
默认实现还可以调用同一 trait 中的其他方法,哪怕那些方法没有默认实现。一个 trait 就能以少量必写代码换回大量功能。例如:必须实现的 summarize_author,加上一个调用它的、带默认实现的 summarize:
pub trait Summary {
fn summarize_author(&self) -> String;
fn summarize(&self) -> String {
format!("(Read more from {}...)", self.summarize_author())
}
}
使用这个版本的 Summary,实现时只需定义 summarize_author:
impl Summary for SocialPost {
fn summarize_author(&self) -> String {
format!("@{}", self.username)
}
}
定义好之后,就能在 SocialPost 实例上调用 summarize,它的默认实现会去调用你写的 summarize_author:
let post = SocialPost {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
reply: false,
repost: false,
};
println!("1 new post: {}", post.summarize());
打印 1 new post: (Read more from @horse_ebooks...)。注意一个限制:在某个方法的覆盖实现里,无法调用这个方法自身的默认实现。
编译注:「少量必填 + 大量默认」最壮观的实例是标准库的
Iterator:唯一必须实现的方法只有next,其余几十个方法——map、filter、sum、collect等——全是建立在next之上的默认实现。第 13 章写迭代器时你会直接享受这层福利。可以粗略类比成「实现一个比较函数,整套排序设施免费送」。
把 trait 用作参数
会定义和实现之后,来看怎么用 trait 定义能接受多种不同类型的函数。给 notify 函数的 item 参数用上 impl Trait 语法——任何实现了 Summary 的类型都能传进来:
pub fn notify(item: &impl Summary) {
println!("Breaking news! {}", item.summarize());
}
参数位置写的不是具体类型,而是 impl 关键字加 trait 名;函数体内可以对 item 调用 Summary 的任何方法。传 NewsArticle 或 SocialPost 的实例都行;传 String 或 i32 则无法编译——它们没实现 Summary。
trait 约束(Trait Bound)语法
impl Trait 适用于简单场景,它其实是 trait 约束 这种更长形式的语法糖:
pub fn notify<T: Summary>(item: &T) {
println!("Breaking news! {}", item.summarize());
}
两者等价,Trait Bound更啰嗦:约束写在泛型类型参数声明之后,用冒号引出,放在尖括号里。impl Trait 胜在简洁,trait 约束则能表达更复杂的约束。比如两个都实现了 Summary 的参数,用 impl Trait 写成这样:
pub fn notify(item1: &impl Summary, item2: &impl Summary) {
这样写允许 item1 和 item2 是不同类型(只要都实现了 Summary)。若要强制两个参数是同一类型,就必须用 trait 约束:
pub fn notify<T: Summary>(item1: &T, item2: &T) {
T 同时指定给两个参数后,传给 item1 和 item2 的值的具体类型必须相同。
编译注: 参数位置的
impl Trait和 C++20 的简写函数模板几乎一模一样:void f(std::input_iterator auto it)里被概念(concept)约束的auto参数,同样是「带约束的泛型参数」的缩写。差别在返回值位置——Rust 的-> impl Trait是不透明类型(opaque type),只承诺「实现了这个 trait」,调用方不能也不需要知道具体是什么,下一节马上看到。
用 + 语法指定多个 trait 约束
约束可以叠加。希望 notify 既调用 summarize、又用 {} 显示格式化 item,就要求它同时实现 Display 和 Summary,用 + 语法:
pub fn notify(item: &(impl Summary + Display)) {
+ 同样适用于泛型类型上的 trait 约束:
pub fn notify<T: Summary + Display>(item: &T) {
有了这两个约束,函数体就能调用 summarize 并用 {} 格式化 item 了。
用 where 子句让 trait 约束更清晰
约束太多也有坏处:每个泛型都带着自己的约束,多个泛型参数的函数,函数名与参数列表之间可能堆满约束信息,签名难读。为此 Rust 提供了另一种语法:在泛型函数签名后面用 where 子句。与其这样写:
fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {
不如使用 where 子句:
fn some_function<T, U>(t: &T, u: &U) -> i32
where
T: Display + Clone,
U: Clone + Debug,
{
函数名、参数列表和返回类型紧挨在一起,清爽多了。
返回实现了 trait 的类型
impl Trait 也能用在返回值位置,返回某个实现了 trait 的类型的值:
fn returns_summarizable() -> impl Summary {
SocialPost {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
reply: false,
repost: false,
}
}
返回类型是 impl Summary,不必点出具体类型名——本例实际返回 SocialPost,但调用方不需要知道这一点。这种「只凭所实现的 trait 指定返回类型」的能力,在闭包和迭代器的上下文中尤其有用(第 13 章《函数式语言特性:迭代器与闭包》):它们会创建只有编译器才知道、或写起来极其冗长的类型,impl Trait 让你简洁地写出「返回某个实现了 Iterator 的类型」,而不必罗列一长串类型。
不过,impl Trait 只能返回单一类型。有时返回 NewsArticle、有时返回 SocialPost 是行不通的:
fn returns_summarizable(switch: bool) -> impl Summary {
if switch {
NewsArticle {
headline: String::from(
"Penguins win the Stanley Cup Championship!",
),
location: String::from("Pittsburgh, PA, USA"),
author: String::from("Iceburgh"),
content: String::from(
"The Pittsburgh Penguins once again are the best \
hockey team in the NHL.",
),
}
} else {
SocialPost {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
reply: false,
repost: false,
}
}
}
这是编译器实现 impl Trait 的方式带来的限制。「运行时才决定返回哪种类型」的需求要靠 trait 对象解决——第 18 章《面向对象语言特性》的主角,也是单态化之外、通过动态派发使用 trait 的另一条路线。
使用 trait 约束有条件地实现方法
trait 约束还能用在带泛型类型参数的 impl 块上,只为满足约束的类型有条件地实现方法。示例 10-15 中,Pair<T> 总是拥有返回新实例的 new 函数(Self 是 impl 块所属类型的别名,本例即 Pair<T>,第 5 章讲过);而 cmp_display 只在 T 同时实现支持比较的 PartialOrd trait 和支持打印的 Display trait 时才存在。
use std::fmt::Display;
struct Pair<T> {
x: T,
y: T,
}
impl<T> Pair<T> {
fn new(x: T, y: T) -> Self {
Self { x, y }
}
}
impl<T: Display + PartialOrd> Pair<T> {
fn cmp_display(&self) {
if self.x >= self.y {
println!("The largest member is x = {}", self.x);
} else {
println!("The largest member is y = {}", self.y);
}
}
}
示例 10-15:依据 trait 约束在泛型类型上有条件地实现方法
还能更进一步:对任何实现了某个 trait 的类型,有条件地实现另一个 trait。这类「满足约束即自动获得」的实现称为批量实现(blanket implementation),标准库大量使用。例如标准库为所有实现了 Display 的类型实现了 ToString,impl 块类似:
impl<T: Display> ToString for T {
// --省略--
}
因为有这个批量实现,任何实现了 Display 的类型都能调用 to_string。整数实现了 Display,所以:
let s = 3.to_string();
批量实现会列在该 trait 文档的 "Implementors"(实现者)部分。
trait 与 trait 约束让我们既写出用泛型减少重复的代码,又向编译器声明了泛型类型应具备的行为。编译器据此检查所有具体类型都提供了正确行为:动态类型语言里调用不存在的方法要到运行时才报错,Rust 把它提前到编译期;而且编译期已确认行为,运行时检查的代码也就不必写了。性能与灵活性兼得。
用生命周期验证引用
生命周期是另一种你其实已经在使用的泛型。泛型保证类型具有我们想要的行为,生命周期保证引用在我们需要它有效的整个期间始终有效。
第 4 章《理解所有权》「引用与借用」一节有个未展开的细节:Rust 中的每一个引用都有生命周期,即该引用保持有效的那个作用域。大多数时候生命周期是隐式、由编译器推导的,就像类型大多数时候也是推导出来的一样——只有类型存在多种可能时才需要标注;同理,只有引用的生命周期可能以几种方式相关联时,才必须标注生命周期。Rust 要求用泛型生命周期参数标注这些关系,确保运行时实际使用的引用绝对有效。
生命周期标注在大多数语言里连对应的概念都没有,学起来会觉得陌生。本章不追求面面俱到,只讨论你常会遇到的生命周期语法,帮你熟悉这个概念。
悬垂引用
生命周期的主要目标是防止悬垂引用(dangling reference):一旦放任它存在,程序就会引用到本不打算引用的数据。看看示例 10-16,它包含外层和内层两个作用域。
fn main() {
let r;
{
let x = 5;
r = &x;
}
println!("r: {r}");
}
示例 10-16:尝试使用一个其值已离开作用域的引用
提示: 示例 10-16、10-17 和 10-23 在声明变量时没有赋予初值,因此变量名在外层作用域就已存在。乍一看,这似乎与「Rust 没有 null 值」相冲突。但如果试图在给变量赋值之前使用它,我们会得到一个编译期错误,这说明 Rust 确实不允许 null 值。
外层声明未赋初值的 r,内层声明 x = 5 并让 r 指向它;内层作用域结束后打印 r——无法编译,r 指向的值在使用之前就已离开作用域:
cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `x` does not live long enough
--> src/main.rs:6:13
|
5 | let x = 5;
| - binding `x` declared here
6 | r = &x;
| ^^ borrowed value does not live long enough
7 | }
| - `x` dropped here while still borrowed
8 |
9 | println!("r: {r}");
| - borrow later used here
For more information about this error, try `rustc --explain E0597`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
错误信息说 x「存活得不够久」(does not live long enough):内层作用域结束时 x 随之消失,r 却还在外层活着。放任这段代码工作的话,r 会指向一块在 x 离开作用域时已被释放的内存,之后对 r 的任何操作都得不到正确结果。Rust 靠什么判定它非法?借用检查器。
借用检查器
Rust 编译器内置了借用检查器,它通过比较作用域来判断所有借用是否有效。示例 10-17 与 10-16 相同,只是加上了展示变量生命周期的标注。
fn main() {
let r; // ---------+-- 'a
// |
{ // |
let x = 5; // -+-- 'b |
r = &x; // | |
} // -+ |
// |
println!("r: {r}"); // |
} // ---------+
示例 10-17:r 与 x 的生命周期,分别命名为 'a 和 'b
r 的生命周期是 'a,x 的是 'b;内层的 'b 块比外层的 'a 小得多——被引用的值没有引用本身活得久,程序被拒绝编译。示例 10-18 修复了这一点,不再有悬垂引用:
fn main() {
let x = 5; // ----------+-- 'b
// |
let r = &x; // --+-- 'a |
// | |
println!("r: {r}"); // | |
// --+ |
} // ----------+
示例 10-18:数据比引用活得久,因此是有效引用
这次 x 的 'b 比 r 的 'a 大:Rust 知道只要 x 有效,r 中的引用就一直有效。知道了生命周期体现在哪里、Rust 如何分析它,接下来看函数参数和返回值中的泛型生命周期。
函数中的泛型生命周期
写一个函数,返回两个字符串切片中较长的那一个:接收两个字符串切片,返回单个字符串切片。实现之后,示例 10-19 应打印 The longest string is abcd。
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest(string1.as_str(), string2);
println!("The longest string is {result}");
}
示例 10-19:调用 longest 函数求两个字符串切片中较长者的 main 函数
参数希望是字符串切片而不是 String,因为切片是引用、不必取得所有权(第 4 章「字符串切片作为参数」一节有更详细的讨论)。按示例 10-20 实现 longest,无法通过编译。
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() { x } else { y }
}
示例 10-20:返回两个字符串切片中较长者的 longest 函数实现,尚无法编译
取而代之的是这个与生命周期有关的错误:
cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0106]: missing lifetime specifier
--> src/main.rs:9:33
|
9 | fn longest(x: &str, y: &str) -> &str {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
|
9 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
| ++++ ++ ++ ++
For more information about this error, try `rustc --explain E0106`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
帮助信息表明返回类型需要一个泛型生命周期参数,因为 Rust 判断不出返回的引用指向 x 还是 y——说实话,连我们自己都不知道:if 块返回指向 x 的引用,else 块返回指向 y 的引用!定义函数时,我们既不知道将来会传入什么值(走哪个分支),也无法像示例 10-17、10-18 那样直接查看作用域;借用检查器同样无从判定,因为它不知道 x、y 的生命周期与返回值是什么关系。修复办法是添加描述引用之间关系的泛型生命周期参数,让借用检查器能够完成分析。
生命周期标注语法
生命周期标注不会改变任何引用的实际存活时长;它只描述多个引用的生命周期彼此之间的关系,而不影响生命周期本身。正如指定了泛型类型参数的函数能接受任何类型,指定了泛型生命周期参数的函数也能接受任何生命周期的引用。
语法略有特殊:名字必须以撇号(')开头,通常全小写而且很短——和泛型类型相似,第一个一般叫 'a。标注放在引用的 & 后面,与类型之间隔一个空格:
&i32 // 一个引用
&'a i32 // 一个带有显式生命周期的引用
&'a mut i32 // 一个带有显式生命周期的可变引用
编译注:「只描述关系、不改变行为」是生命周期标注最容易误解的一点:
'a不会延长也不会缩短任何引用的生命周期,它只是写给借用检查器看的约束声明。所以遇到悬垂引用时改标注是没用的——关系本身不成立,怎么标都过不了(后面「生命周期的相互关系」里返回局部变量的例子就是明证:无论怎么标注都是 E0515)。另外,生命周期检查全部发生在编译期,检查完即擦除,运行时零开销——它既不是 GC,也不是运行时借用检查。
单独一个生命周期标注没有多大意义,它的用途是告诉 Rust 多个引用的泛型生命周期参数之间如何关联。回到 longest。
函数签名中的生命周期标注
与泛型类型参数的处理方式相同:把泛型生命周期参数声明在函数名与参数列表之间的尖括号里。我们想表达的约束是「只要两个参数都有效,返回的引用就有效」——这就是参数与返回值的生命周期之间的关系。把它命名为 'a,添加到每个引用上,得到示例 10-21。
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
示例 10-21:longest 函数定义指定签名中所有引用必须拥有相同的生命周期 'a
现在签名告诉 Rust:函数接收两个至少能存活 'a 这么久的字符串切片,返回的字符串切片也至少存活 'a 这么久。实践中,这意味着返回引用的有效期与两个参数中较短的那个相同。请记住:我们并没有改变任何传入值或返回值的生命周期,只是规定借用检查器拒绝一切不满足这些约束的值。longest 不需要确切知道 x 和 y 能活多久,只需要知道存在某个可以代入 'a 的作用域,使签名得到满足。
传入具体引用时,'a 取 x 与 y 作用域相重叠的部分——即两者中较短的生命周期;返回的引用也在较短者的时长内有效。示例 10-22 验证了这一点。
fn main() {
let string1 = String::from("long string is long");
{
let string2 = String::from("xyz");
let result = longest(string1.as_str(), string2.as_str());
println!("The longest string is {result}");
}
}
示例 10-22:在两个具有不同具体生命周期的 String 值的引用上使用 longest 函数
string1 活到外层作用域结束,string2 活到内层结束,result 在内层结束前使用——借用检查器放行,打印 The longest string is long string is long。
换个例子说明「返回引用只能活较短者的时长」:把 result 的声明移出内层作用域,赋值留在 string2 所在的作用域内,println! 移到内层结束之后。示例 10-23 编译不过。
fn main() {
let string1 = String::from("long string is long");
let result;
{
let string2 = String::from("xyz");
result = longest(string1.as_str(), string2.as_str());
}
println!("The longest string is {result}");
}
示例 10-23:在 string2 离开作用域后尝试使用 result
cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `string2` does not live long enough
--> src/main.rs:6:44
|
5 | let string2 = String::from("xyz");
| ------- binding `string2` declared here
6 | result = longest(string1.as_str(), string2.as_str());
| ^^^^^^^ borrowed value does not live long enough
7 | }
| - `string2` dropped here while still borrowed
8 | println!("The longest string is {result}");
| ------ borrow later used here
For more information about this error, try `rustc --explain E0597`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
错误表明:要让 result 在 println! 处有效,string2 就必须存活到外层作用域结束——因为参数和返回值用的是同一个 'a。以人的视角看,string1 更长、result 实际会指向 string1、而 string1 在 println! 处仍然有效,似乎应该放行;但编译器不做这种分支级的事实分析,它只知道「返回引用的生命周期等于较短的那个参数」,string2 短了,就拒绝。
试着再设计一些实验:改变传入引用的值和生命周期、改变返回引用的使用方式。编译之前先预测能否通过借用检查器,再编译验证——这是培养生命周期直觉最快的方法。
生命周期的相互关系
需要以哪种方式指定生命周期参数,取决于函数在做什么。如果把 longest 改为始终返回第一个参数而不是较长的那个,y 就不需要标注了,下面的代码可以通过编译:
fn longest<'a>(x: &'a str, y: &str) -> &'a str {
x
}
y 的生命周期与 x 或返回值之间没有任何关系,所以不必指定。
从函数返回引用时,返回类型的生命周期参数需要与某个参数的生命周期参数相匹配。如果返回的引用不指向任何参数,它就只能指向函数内部创建的值——可那样它就是悬垂引用,函数结束时值就离开作用域了。看看这个无法编译的尝试:
fn longest<'a>(x: &str, y: &str) -> &'a str {
let result = String::from("really long string");
result.as_str()
}
尽管给返回类型指定了 'a,这个实现仍然无法通过编译——返回值的生命周期与参数完全无关。错误信息如下:
cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0515]: cannot return value referencing local variable `result`
--> src/main.rs:11:5
|
11 | result.as_str()
| ------^^^^^^^^^
| |
| returns a value referencing data owned by the current function
| `result` is borrowed here
For more information about this error, try `rustc --explain E0515`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
result 会在函数结束时离开作用域并被清理,返回指向它的引用怎么标注都改变不了悬垂的事实,Rust 不会让我们创建悬垂引用。这种情况下,最好的修复办法是返回拥有所有权的类型而不是引用,把清理值的职责交给调用方。
归根结底,生命周期语法的作用是把函数各个参数与返回值的生命周期关联起来;关联一旦建立,Rust 就有足够的信息放行内存安全的操作,拒绝那些会制造悬垂指针或以其他方式违反内存安全的操作。
结构体定义中的生命周期标注
到目前为止,我们定义的结构体持有的都是拥有所有权的类型。结构体也可以持有引用,但这时需要在定义中为每一个引用添加生命周期标注。示例 10-24 定义了持有字符串切片的 ImportantExcerpt。
struct ImportantExcerpt<'a> {
part: &'a str,
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().unwrap();
let i = ImportantExcerpt {
part: first_sentence,
};
}
示例 10-24:持有引用的结构体,需要一个生命周期标注
语法与泛型数据类型一样:在结构体名称后面的尖括号里声明泛型生命周期参数,供定义体使用。这个标注的含义是:ImportantExcerpt 的 part 字段的生命周期至少能达到该实例的生命周期。main 里,novel 的数据在实例创建之前就已存在,直到实例离开作用域之后才离开作用域,因此实例中的引用是有效的。
生命周期省略
你已经了解到每个引用都有生命周期、使用引用的函数或结构体需要指定生命周期参数。然而示例 4-9 中的这个函数(示例 10-25 再次给出)没有任何生命周期标注也通过了编译。
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
示例 10-25:我们在示例 4-9 中定义的函数,尽管参数和返回类型都是引用,却不需要生命周期标注就能编译
原因是历史:在 Rust 早期版本(1.0 之前),每个引用都需要显式生命周期,这段代码当时编译不过,签名得写成这样:
fn first_word<'a>(s: &'a str) -> &'a str {
在编写了大量 Rust 代码之后,Rust 团队发现程序员在特定场景下会一遍又一遍写下同样的标注——场景可预测、模式有限。于是开发者把这些模式编入编译器,让借用检查器在这些场景下自行推导生命周期,不再需要显式标注。这段历史值得了解:将来可能出现更多确定的模式被加入编译器,需要手写的标注会更少。
编入 Rust 引用分析的这些模式被称为生命周期省略规则(lifetime elision rules)。它们不是程序员要遵守的规则,而是一组编译器考察的特殊情形——符合情形就不必显式写出。省略规则也不等于完整推导:应用规则后引用的生命周期仍有歧义时,编译器不会去猜,而是报错,由你添加标注解决。
函数或方法参数上的生命周期称为输入生命周期(input lifetime),返回值上的称为输出生命周期(output lifetime)。没有显式标注时,编译器用三条规则确定引用的生命周期:第一条作用于输入生命周期,第二、三条作用于输出生命周期;走完三条仍有引用的生命周期无法确定,编译器就报错停止。规则对 fn 定义和 impl 块都适用。
第一条规则:每个作为引用的参数各分配一个生命周期参数。单参数函数得到一个:fn foo<'a>(x: &'a i32);双参数函数得到两个互相独立的:fn foo<'a, 'b>(x: &'a i32, y: &'b i32);以此类推。
第二条规则:恰好只有一个输入生命周期参数时,该生命周期赋给所有输出生命周期参数:fn foo<'a>(x: &'a i32) -> &'a i32。
第三条规则:存在多个输入生命周期参数、且其中一个是 &self 或 &mut self(因为这是方法)时,self 的生命周期赋给所有输出生命周期参数。这条规则让方法需要书写的符号少得多。
假设我们是编译器,把规则应用到示例 10-25 的 first_word 上演练一遍。签名起初没有任何关联的生命周期:
fn first_word(s: &str) -> &str {
应用第一条规则,每个参数获得自己的生命周期,按惯例叫做 'a:
fn first_word<'a>(s: &'a str) -> &str {
恰好只有一个输入生命周期,第二条规则适用,把它赋给输出生命周期:
fn first_word<'a>(s: &'a str) -> &'a str {
现在签名中所有引用都有了生命周期,编译器继续分析,无需程序员标注。
再看 longest——示例 10-20 初见它时还没有任何生命周期参数:
fn longest(x: &str, y: &str) -> &str {
应用第一条规则,两个参数获得两个独立的生命周期:
fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {
第二条规则不适用(输入生命周期不止一个);第三条也不适用(longest 是函数不是方法,没有 self)。三条走完,返回类型的生命周期仍无法确定——这正是示例 10-20 报错的原因:编译器走完了省略规则,却仍无法确定签名中所有引用的生命周期。
第三条规则实际上只作用于方法签名。接下来就在方法场景中考察生命周期,看看为什么第三条规则意味着我们很少需要为方法标注。
方法定义中的生命周期标注
为带生命周期的结构体实现方法时,使用的语法与泛型类型参数相同(对照示例 10-11)。在何处声明和使用生命周期参数,取决于它们与结构体字段相关,还是与方法参数和返回值相关。
结构体字段的生命周期名必须始终声明在 impl 关键字之后、使用在结构体名称之后,因为这些生命周期是结构体类型的一部分。impl 块内方法签名中的引用,可能与结构体字段的生命周期相绑定,也可能是独立的;此外,生命周期省略规则常常让方法签名无需标注。继续用示例 10-24 的 ImportantExcerpt 看例子。
首先是 level 方法:唯一参数是 self 的引用,返回 i32,不引用任何东西:
impl<'a> ImportantExcerpt<'a> {
fn level(&self) -> i32 {
3
}
}
impl 之后的生命周期参数声明以及它在类型名之后的使用是必需的,但由于第一条省略规则,不必为 self 的引用标注生命周期。
下面是一个适用第三条省略规则的例子:
impl<'a> ImportantExcerpt<'a> {
fn announce_and_return_part(&self, announcement: &str) -> &str {
println!("Attention please: {announcement}");
self.part
}
}
这里有两个输入生命周期:第一条规则让 &self 和 announcement 各自拥有自己的生命周期;由于其中一个参数是 &self,返回类型获得了 &self 的生命周期。至此,所有生命周期都有了着落。
'static 生命周期
还有一个要讨论的特殊生命周期是 'static,它表示受约束的引用可以存活整个程序的运行期。所有字符串字面量都拥有 'static 生命周期,可以这样标注:
let s: &'static str = "I have a static lifetime.";
这个字符串的文本直接存储在程序的二进制文件里,而二进制文件始终可用,因此所有字符串字面量的生命周期都是 'static。
错误信息里有时会建议使用 'static。动手之前先想一想:你手里的引用是否真的能活满程序的一生,以及你是否希望它这样。大多数时候,建议 'static 的错误信息源自试图创建悬垂引用,或者可用生命周期不匹配——正解是修复问题本身,而不是把 'static 当创可贴。
编译注:
'static有两副面孔,读代码时要分清。作为实际生命周期,它指数据真的活满整个程序——躺在二进制里的字符串字面量就是这种;作为约束,它只表示「至少能活这么久」,也就是不借用任何短命的数据。后者你后面一定会遇到:std::thread::spawn要求F: Send + 'static,含义是闭包要么拥有自己的数据、要么只引用'static数据,而不是「这个闭包永远存在」。顺带一提,它和 C/C++ 的static关键字(静态存储、内部链接)只是撞了名字,并无关系。
泛型类型参数、trait 约束与生命周期
最后快速看一眼在同一个函数中同时指定泛型类型参数、trait 约束和生命周期的语法:
use std::fmt::Display;
fn longest_with_an_announcement<'a, T>(
x: &'a str,
y: &'a str,
ann: T,
) -> &'a str
where
T: Display,
{
println!("Announcement! {ann}");
if x.len() > y.len() { x } else { y }
}
这是示例 10-21 的 longest,多了一个泛型参数 ann: T,由 where 子句约束为任何实现了 Display 的类型(它要用 {} 打印,所以需要这个约束)。生命周期也是泛型的一种,所以生命周期参数 'a 和类型参数 T 的声明放在同一个尖括号列表里,位于函数名之后。
小结与下一步
- 泛型消除类型重复:函数、结构体、枚举、方法都能泛型化,
largest从两份函数合成一份 - trait 消除行为重复:定义共享行为、默认实现、
impl Trait与 trait 约束、where子句、批量实现;孤儿规则守住实现的一致性 - 生命周期消除引用关系的重复描述:标注只声明关系、不改变行为,三条省略规则让你大多数时候不用写
- 单态化让这一切零运行时成本——代价在编译期:编译时间与二进制大小
- 动态派发路线(trait 对象)留给第 18 章;
impl Trait在第 13 章的闭包与迭代器里会大量出场
下一篇:第 11 章「编写自动化测试」——泛型与 trait 正好让测试代码也少写一半。