Rust学习笔记之泛型、trait 与生命周期
每天早上,不是被尿憋醒,而是真的被梦想叫醒
大家好,我是柒八九。
今天,我们继续Rust学习笔记的探索。我们来谈谈关于Rust学习笔记之泛型、trait 与生命周期的相关知识点。
如果,想了解该系列的文章,可以参考我们已经发布的文章。如下是往期文章。
文章list
- Rust学习笔记之Rust环境配置和入门指南
- Rust学习笔记之基础概念
- Rust学习笔记之所有权
- Rust学习笔记之结构体
- Rust学习笔记之枚举和匹配模式
- Rust学习笔记之包、Crate和模块
- Rust学习笔记之集合
- Rust学习笔记之错误处理
你能所学到的知识点
- 泛型大补汤 推荐阅读指数 ⭐️⭐️⭐️⭐️
- 泛型数据类型 推荐阅读指数 ⭐️⭐️⭐️⭐️⭐️
- trait:定义共享的行为 推荐阅读指数 ⭐️⭐️⭐️⭐️⭐️
- 生命周期与引用有效性 推荐阅读指数 ⭐️⭐️⭐️⭐️⭐️
好了,天不早了,干点正事哇。
泛型大补汤
{泛型|generics}是一种编程语言特性,它允许在编写代码时使用抽象类型,而不是具体的类型。这使得代码更加灵活和可重用,因为它可以适用于多种不同的数据类型。
我们可以表达泛型的属性,比如他们的行为或如何与其他泛型相关联,而不需要在编写和编译代码时知道他们在这里实际上代表什么。
下面展示各种OOP
编程语言中,定义泛型的方式
C++:
template<typename T>
void swap(T& a, T& b) {
T temp = a;
a = b;
b = temp;
}
JAVA:
public class GenericClass<T> {
private T data;
public GenericClass(T data) {
this.data = data;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
}
TS:
function identity<T>(arg: T): T {
return arg;
}
各自优缺点
C++
的泛型表达使用了模板,可以在编译时进行类型检查,提高了代码的安全性和效率。但是模板的语法较为复杂,需要掌握一定的模板元编程技巧。JAVA
的泛型表达使用了泛型类和泛型方法,可以在运行时进行类型检查,提高了代码的灵活性和可读性。但是泛型类和泛型方法的语法较为繁琐,需要掌握一定的泛型编程技巧。TS
的泛型表达使用了类型变量,可以在编译时进行类型检查,提高了代码的安全性和可读性。但是类型变量的语法较为简单,可能会导致类型推断不准确。
Rust
Rust
使用处理trait
,这是一个定义泛型行为的方法。trait
可以与泛型结合来将泛型限制为拥有特定行为的类型,而不是任意类型。
{生命周期|lifetimes},它是一类允许我们向编译器提供引用如何相互关联的泛型。Rust
的生命周期功能允许在很多场景下借用值的同时仍然使编译器能够检查这些引用的有效性。
泛型数据类型
可以使用泛型为函数签名或结构体等项创建定义,这样它们就可以用于多种不同的具体数据类型。
在函数定义中使用泛型
当使用泛型定义函数时,本来在函数签名中指定参数和返回值的类型的地方,会改用泛型来表示。采用这种技术,使得代码适应性更强,从而为函数的调用者提供更多的功能,同时也避免了代码的重复。
如下展示了两个函数,它们的功能都是寻找 slice
中最大值。
fn largest_i32(list: &[i32]) -> i32 {
let mut largest = list[0];
for &item in list.iter() {
if item > largest {
largest = item;
}
}
largest
}
fn largest_char(list: &[char]) -> char {
let mut largest = list[0];
for &item in list.iter() {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = largest_i32(&number_list);
println!("最大的数为{}", result);
assert_eq!(result, 100);
let char_list = vec!['y', 'm', 'a', 'q'];
let result = largest_char(&char_list);
println!("最大的字符为{}", result);
assert_eq!(result, 'y');
}
largest_i32
函数,它用来寻找slice
中最大的i32
。largest_char
函数寻找slice
中最大的char
。
因为两者函数体的代码一致,我们可以定义一个函数,再引进泛型参数来消除这种重复。
为了参数化新函数中的这些类型,我们也需要为类型参数取个名字,道理和给函数的形参起名一样。任何标识符都可以作为类型参数的名字。这里选用 T
,因为传统上来说,Rust
的参数名字都比较短,通常就只有一个字母,同时,Rust
类型名的命名规范是{骆驼命名法|CamelCase}。T
作为 “type”
的缩写是大部分 Rust
开发者的首选。
如果要在函数体中使用参数,就必须在函数签名中声明它的名字,好让编译器知道这个名字指代的是什么。同理,当在函数签名中使用一个类型参数时,必须在使用它之前就声明它。
为了定义泛型版本的 largest
函数,类型参数声明位于函数名称与参数列表中间的尖括号 <> 中像这样(熟悉TS
的小伙伴,是不是有种似曾相识的感觉)
fn largest<T>(list: &[T]) -> T {}
函数 largest
有泛型类型 T
。它有个参数 list
,其类型是元素为 T
的 slice
。largest
函数的返回值类型也是 T
。
largest
函数在它的签名中使用了泛型,统一了两个实现。
fn largest<T>(list: &[T]) -> T {
let mut largest = list[0];
for &item in list.iter() {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = largest(&number_list);
println!("最大的数为{}", result);
let char_list = vec!['y', 'm', 'a', 'q'];
let result = largest(&char_list);
println!("最大的字符为{}", result);
}
如果现在就编译这个代码,会出现如下错误:
error[E0369]: binary operation `>` cannot be applied to type `T`
--> src/main.rs:5:12
|
5 | if item > largest {
| ^^^^^^^^^^^^^^
|
= note: an implementation of `std::cmp::PartialOrd` might be missing for `T`
注释中提到了 std::cmp::PartialOrd
,这是一个 trait
。这个错误表明 largest
的函数体不能适用于 T 的所有可能的类型。因为在函数体需要比较 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 };
}
其语法类似于函数定义中使用泛型。
- 首先,必须在结构体名称后面的尖括号中声明泛型参数的名称。
- 接着在结构体定义中可以指定具体数据类型的位置使用泛型类型。
注意 Point<T>
的定义中只使用了一个泛型类型,这个定义表明结构体 Point<T>
对于一些类型 T
是泛型的,而且字段 x 和 y 都是 相同类型的,无论它具体是何类型。
struct Point<T> {
x: T,
y: T,
}
fn main() {
let wont_work = Point { x: 5, y: 4.0 };
}
上面的代码是不能通过编译的。当把整型值 5 赋值给 x
时,就告诉了编译器这个 Point<T>
实例中的泛型 T 是整型的。接着指定 y
为 4.0
,它被定义为与 x 相同类型,就会得到一个像这样的类型不匹配错误。
如果想要定义一个 x
和 y
可以有不同类型且仍然是泛型的 Point
结构体,我们可以使用多个泛型类型参数。
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 };
}
枚举定义中的泛型
和结构体类似,枚举也可以在成员中存放泛型数据类型。
enum Option<T> {
Some(T),
None,
}
Option<T>
是一个拥有泛型 T
的枚举,它有两个成员:
Some
,它存放了一个类型T
的值- 不存在任何值的
None
通过 Option<T>
枚举可以表达有一个可能的值的抽象概念,同时因为 Option<T>
是泛型的,无论这个可能的值是什么类型都可以使用这个抽象。
枚举也可以拥有多个泛型类型。
enum Result<T, E> {
Ok(T),
Err(E),
}
Result
枚举有两个泛型类型,T
和 E
。Result
有两个成员:
Ok
,它存放一个类型T
的值Err
则存放一个类型E
的值。
这个定义使得 Result
枚举能很方便的表达任何可能成功(返回 T
类型的值)也可能失败(返回 E
类型的值)的操作。
方法定义中的泛型
在为结构体和枚举实现方法时,一样也可以用泛型。
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());
}
这里在 Point<T>
上定义了一个叫做 x
的方法来返回字段 x
中数据的引用:
注意必须在 impl
后面声明 T
,这样就可以在 Point<T>
上实现的方法中使用它了。在 impl
之后声明泛型 T
,这样 Rust
就知道 Point
的尖括号中的类型是泛型而不是具体类型。
泛型代码的性能
Rust
通过在编译时进行泛型代码的 {单态化|monomorphization}来保证效率。单态化是一个通过填充编译时使用的具体类型,将通用代码转换为特定代码的过程。
编译器所做的工作正好与我们创建泛型函数的步骤相反。编译器寻找所有泛型代码被调用的位置并使用泛型代码针对具体类型生成代码。
看看一个使用标准库中 Option
枚举的例子
let integer = Some(5);
let float = Some(5.0);
当 Rust
编译这些代码的时候,它会进行单态化。编译器会读取传递给 Option<T>
的值并发现有两种 Option<T>
:一个对应 i32
另一个对应 f64
。为此,它会将泛型定义 Option<T>
展开为 Option_i32
和 Option_f64
,接着将泛型定义替换为这两个具体的定义。
编译器生成的单态化版本的代码看起来像这样,并包含将泛型 Option<T>
替换为编译器创建的具体定义后的用例代码:
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);
}
可以使用泛型来编写不重复的代码,而
Rust
将会为每一个实例编译其特定类型的代码。这意味着在使用泛型时没有运行时开销;当代码运行,它的执行效率就跟好像手写每个具体定义的重复代码一样
trait:定义共享的行为
trait
告诉 Rust
编译器某个特定类型拥有可能与其他类型共享的功能。可以通过 trait
以一种抽象的方式定义共享的行为。可以使用 trait bounds
指定泛型是任何拥有特定行为的类型。
trait
类似于其他语言中常被称为 {接口|interfaces}的功能
定义 trait
一个类型的行为由其可供调用的方法构成。如果可以对不同类型调用相同的方法的话,这些类型就可以共享相同的行为了。
trait
定义是一种将方法签名组合起来的方法,目的是定义一个实现某些目的所必需的行为的集合。
创建一个多媒体聚合库用来显示可能储存在 NewsArticle
或 Tweet
实例中的数据的总结。每一个结构体都需要的行为是他们是能够被总结的,这样的话就可以调用实例的 summarize
方法来请求总结。
文件名: src/lib.rs
pub trait Summary {
fn summarize(&self) -> String;
}
使用
trait
关键字来声明一个trait
,后面是trait
的名字。在大括号中声明描述实现这个trait
的类型所需要的行为的方法签名
在上面例子中是 fn summarize(&self) -> String
。
在方法签名后跟分号,而不是在大括号中提供其实现。接着每一个实现这个 trait
的类型都需要提供其自定义行为的方法体,编译器也会确保任何实现 Summary
trait
的类型都拥有与这个签名的定义完全一致的 summarize
方法。
trait
体中可以有多个方法:一行一个方法签名且都以分号结尾。
为类型实现 trait
定义了 Summary
trait
,接着就可以在多媒体聚合库中需要拥有这个行为的类型上实现它了
文件名: src/lib.rs
pub trait Summary {
fn summarize(&self) -> String;
}
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 Tweet {
pub username: String,
pub content: String,
pub reply: bool,
pub retweet: bool,
}
impl Summary for Tweet {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
在类型上实现 trait
类似于实现与 trait
无关的方法。区别在于 impl
关键字之后,我们提供需要实现 trait
的名称,接着是 for
和需要实现 trait
的类型的名称。在 impl
块中,使用 trait
定义中的方法签名,不过不再后跟分号,而是需要在大括号中编写函数体来为特定类型实现 trait
方法所拥有的行为。
一旦实现了 trait
,我们就可以用与 NewsArticle
和 Tweet
实例的非 trait
方法一样的方式调用 trait
方法了:
let tweet = Tweet {
username: String::from("北宸南蓁"),
content: String::from("Rust 学习笔记"),
reply: false,
retweet: false,
};
println!("内容为: {}", tweet.summarize());
实现 trait
时需要注意的一个限制是,只有当 trait
或者要实现 trait
的类型位于 crate
的本地作用域时,才能为该类型实现 trait
。
默认实现
有时为 trait
中的某些或全部方法提供默认的行为,而不是在每个类型的每个实现中都定义自己的行为是很有用的。这样当为某个特定类型实现 trait
时,可以选择保留或重载每个方法的默认行为。
pub trait Summary {
fn summarize(&self) -> String {
String::from("(Read more...)")
}
}
如果想要对 NewsArticle
实例使用这个默认实现,而不是定义一个自己的实现,则可以通过 impl Summary for NewsArticle {}
指定一个空的 impl
块。
虽然我们不再直接为 NewsArticle
定义 summarize
方法了,但是我们提供了一个默认实现并且指定 NewsArticle
实现 Summary
trait
。因此,我们仍然可以对 NewsArticle
实例调用 summarize
方法。
默认实现允许调用相同 trait
中的其他方法,哪怕这些方法没有默认实现。如此,trait
可以提供很多有用的功能而只需要实现指定一小部分内容。
pub trait Summary {
fn summarize_author(&self) -> String;
fn summarize(&self) -> String {
format!("(Read more from {}...)", self.summarize_author())
}
}
为了使用这个版本的 Summary
,只需在实现 trait
时定义 summarize_author
即可。
impl Summary for Tweet {
fn summarize_author(&self) -> String {
format!("@{}", self.username)
}
}
一旦定义了 summarize_author
,我们就可以对 Tweet
结构体的实例调用 summarize
了,而 summarize
的默认实现会调用我们提供的 summarize_author
定义。
trait 作为参数
在上面的例子中为 NewsArticle
和 Tweet
类型实现了 Summary
trait
。我们可以定义一个函数 notify
来调用其参数 item
上的 summarize
方法,该参数是实现了 Summary
trait
的某种类型。为此可以使用 impl
Trait
语法,像这样
pub fn notify(item: impl Summary) {
println!("{}", item.summarize());
}
对于 item
参数,我们指定了 impl
关键字和 trait
名称,而不是具体的类型。该参数支持任何实现了指定 trait
的类型。在 notify
函数体中,可以调用任何来自 Summary
trait
的方法,比如 summarize
。
Trait Bound 语法
impl Trait
语法适用于直观的例子,它实际上是一种较长形式语法的语法糖。我们称为 trait bound
。
pub fn notify<T: Summary>(item: T) {
println!("{}", item.summarize());
}
trait bound
与泛型参数声明在一起,位于尖括号中的冒号后面。
impl Trait
很方便,适用于短小的例子。trait bound
则适用于更复杂的场景。
例如,可以获取两个实现了 Summary
的参数。使用 impl Trait
的语法看起来像这样:
pub fn notify(item1: impl Summary, item2: impl Summary) {}
这适用于 item1
和 item2
允许是不同类型的情况(只要它们都实现了 Summary
)。不过如果你希望强制它们都是相同类型呢?这只有在使用 trait bound
时才有可能:
pub fn notify<T: Summary>(item1: T, item2: T) {
泛型 T
被指定为 item1
和 item2
的参数限制,如此传递给参数 item1
和 item2
值的具体类型必须一致。
通过 + 指定多个 trait bound
如果 notify
需要显示 item
的格式化形式,同时也要使用 summarize
方法,那么 item
就需要同时实现两个不同的 trait:Display
和 Summary
。这可以通过 + 语法实现:
pub fn notify(item: impl Summary + Display) {
+ 语法
也适用于泛型的 trait bound
:
pub fn notify<T: Summary + Display>(item: T) {
通过 where 简化 trait bound
使用过多的 trait bound
也有缺点。每个泛型有其自己的 trait bound
,所以有多个泛型参数的函数在名称和参数列表之间会有很长的 trait bound
信息,这使得函数签名难以阅读。为此,Rust
有另一个在函数签名之后的 where
从句中指定 trait bound
的语法。所以除了这么写:
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 {
Tweet {
username: String::from("北宸南蓁"),
content: String::from("Rust 学习笔记"),
reply: false,
retweet: false,
}
}
通过使用 impl Summary
作为返回值类型,我们指定了 returns_summarizable
函数返回某个实现了 Summary trait
的类型,但是不确定其具体的类型。
使用 trait bounds 来修复 largest 函数
在 largest
函数体中我们想要使用大于运算符(>
)比较两个 T
类型的值。这个运算符被定义为标准库中 trait std::cmp::PartialOrd
的一个默认方法。所以需要在 T
的 trait bound
中指定 PartialOrd
,这样 largest
函数可以用于任何可以比较大小的类型的 slice。因为 PartialOrd
位于 prelude
中所以并不需要手动将其引入作用域。将 largest 的签名修改为如下:
fn largest<T: PartialOrd>(list: &[T]) -> T {
但是如果编译代码的话,会出现一些不同的错误:
错误的核心是 cannot move out of type [T], a non-copy slice
,对于非泛型版本的 largest
函数,我们只尝试了寻找最大的 i32
和 char
。
在前面章节中介绍过,像 i32
和 char
这样的类型是已知大小的并可以储存在栈上,所以他们实现了 Copy trait
。当我们将 largest
函数改成使用泛型后,现在 list
参数的类型就有可能是没有实现 Copy trait
的。这意味着我们可能不能将 list[0]
的值移动到 largest
变量中,这导致了上面的错误。
为了只对实现了 Copy
的类型调用这些代码,可以在 T
的 trait bounds
中增加 Copy
!
下面代码中展示了一个可以编译的泛型版本的 largest
函数的完整代码,只要传递给 largest
的 slice
值的类型实现了 PartialOrd
和 Copy
这两个 trait
,例如 i32 和 char:
fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
let mut largest = list[0];
for &item in list.iter() {
if item > largest {
largest = item;
}
}
largest
}
生命周期与引用有效性
Rust
中的每一个引用都有其{生命周期|lifetime},也就是引用保持有效的作用域。
大部分时候生命周期是隐含并可以推断的,正如大部分时候类型也是可以推断的一样。类似于当因为有多种可能类型的时候必须注明类型,也会出现引用的生命周期以一些不同方式相关联的情况,所以 Rust
需要我们使用泛型生命周期参数来注明他们的关系,这样就能确保运行时实际使用的引用绝对是有效的。
生命周期避免了悬垂引用
生命周期的主要目标是避免悬垂引用,它会导致程序引用了非预期引用的数据。
存在如下代码:
{
let r;
{
let x = 5;
r = &x;
}
println!("r: {}", r);
}
外部作用域声明了一个没有初值的变量 r
,而内部作用域声明了一个初值为 5 的变量 x
。在内部作用域中,我们尝试将 r
的值设置为一个 x
的引用。接着在内部作用域结束后,尝试打印出 r
的值。这段代码不能编译因为 r
引用的值在尝试使用之前就离开了作用域。
变量 x
并没有 “存在的足够久”。其原因是 x
在到达内部作用域结束时就离开了作用域。不过 r
在外部作用域仍是有效的;作用域越大我们就说它 “存在的越久”。如果 Rust
允许这段代码工作,r
将会引用在 x
离开作用域时被释放的内存,这时尝试对 r
做任何操作都不能正常工作。那么 Rust
是如何决定这段代码是不被允许的呢?这得益于借用检查器。
借用检查器
Rust 编译器有一个{借用检查器|borrow checker},它比较作用域来确保所有的借用都是有效的。
{
let r; // ---------+-- 'a
// |
{ // |
let x = 5; // -+-- 'b |
r = &x; // | |
} // -+ |
// |
println!("r: {}", r); // |
} // ---------+
这里将 r
的生命周期标记为 'a
并将 x
的生命周期标记为 'b
。如你所见,内部的 'b
块要比外部的生命周期 'a
小得多。在编译时,Rust
比较这两个生命周期的大小,并发现 r
拥有生命周期 'a
,不过它引用了一个拥有生命周期 'b
的对象。程序被拒绝编译,因为生命周期 'b
比生命周期 'a
要小:被引用的对象比它的引用者存在的时间更短。
我们再做一个并没有产生悬垂引用且可以正确编译的例子:
{
let x = 5; // ----------+-- 'b
// |
let r = &x; // --+-- 'a |
// | |
println!("r: {}", r); // | |
// --+ |
} // ----------+
这里 x
拥有生命周期 'b
,比 'a
要大。这就意味着 r
可以引用 x
:Rust
知道 r
中的引用在 x
有效的时候也总是有效的。
函数中的泛型生命周期
编写一个函数,返回两个字符串 slice
中较长的那一个。这个函数获取两个字符串 slice
并返回一个字符串 slice
。
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest(string1.as_str(), string2);
println!("最长的字符串为{}", result);
}
这个函数获取作为引用的字符串 slice
,因为我们不希望 longest
函数获取参数的所有权。我们期望该函数接受 String
的 slice
(参数 string1
的类型)和字符串字面量(包含于参数 string2
)
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
它并不能编译:相应地会出现关于有关生命周期的错误。
error[E0106]: missing lifetime specifier
--> src/main.rs:1:33
|
1 | fn longest(x: &str, y: &str) -> &str {
| ^ expected lifetime parameter
提示文本揭示了返回值需要一个泛型生命周期参数,因为 Rust
并不知道将要返回的引用是指向 x
或 y
。
为了修复这个错误,我们将增加泛型生命周期参数来定义引用间的关系以便借用检查器可以进行分析。
生命周期标注语法
生命周期标注并不改变任何引用的生命周期的长短。与当函数签名中指定了泛型类型参数后就可以接受任何类型一样,当指定了泛型生命周期后函数也能接受任何生命周期的引用。生命周期标注描述了多个引用生命周期相互的关系,而不影响其生命周期。
生命周期标注有着一个不太常见的语法:生命周期参数名称必须以撇号('
)开头,其名称通常全是小写,类似于泛型其名称非常短。'a
是大多数人默认使用的名称。生命周期参数标注位于引用的 & 之后,并有一个空格来将引用类型与生命周期标注分隔开。
&i32 // 引用
&'a i32 // 带有显式生命周期的引用
&'a mut i32 // 带有显式生命周期的可变引用
我们有一个没有生命周期参数的 i32
的引用,一个有叫做 'a
的生命周期参数的 i32
的引用,和一个生命周期也是 'a
的 i32
的可变引用。
单个生命周期标注本身没有多少意义,因为生命周期标注告诉
Rust
多个引用的泛型生命周期参数如何相互联系的。
例如如果函数有一个生命周期 'a
的 i32
的引用的参数 first
。还有另一个同样是生命周期 'a
的 i32
的引用的参数 second
。这两个生命周期标注意味着引用 first
和 second
必须与这泛型生命周期存在得一样久。
函数签名中的生命周期标注
就像泛型类型参数,泛型生命周期参数需要声明在函数名和参数列表间的尖括号中。这里我们想要告诉 Rust
关于参数中的引用和返回值之间的限制是他们都必须拥有相同的生命周期。
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
现在函数签名
表明对于某些生命周期 a
,函数会获取两个参数,他们都是与生命周期 'a
存在的一样长的字符串 slice。函数会返回一个同样也与生命周期 'a
存在的一样长的字符串 slice
。它的实际含义是 longest
函数返回的引用的生命周期与传入该函数的引用的生命周期的较小者一致。这就是我们告诉 Rust
需要其保证的约束条件。
记住通过在函数签名中指定生命周期参数时,我们并没有改变任何传入值或返回值的生命周期,而是指出任何不满足这个约束条件的值都将被借用检查器拒绝。
当在函数中使用生命周期标注时,这些标注出现在函数签名中,而不存在于函数体中的任何代码中。这是因为 Rust
能够分析函数中代码而不需要任何协助,不过当函数引用或被函数之外的代码引用时,让 Rust
自身分析出参数或返回值的生命周期几乎是不可能的。这些生命周期在每次函数被调用时都可能不同。这也就是为什么我们需要手动标记生命周期。
当具体的引用被传递给 longest
时,被 'a
所替代的具体生命周期是 x 的作用域与 y 的作用域相重叠的那一部分。换一种说法就是泛型生命周期 'a 的具体生命周期等同于 x
和 y
的生命周期中较小的那一个。因为我们用相同的生命周期参数 'a
标注了返回的引用值,所以返回的引用值就能保证在 x
和 y
中较短的那个生命周期结束之前保持有效。
深入理解生命周期
指定生命周期参数的正确方式依赖函数实现的具体功能。
fn longest<'a>(x: &'a str, y: &str) -> &'a str {
x
}
这个例子中,我们为参数 x
和返回值指定了生命周期参数 'a
,不过没有为参数 y
指定,因为 y
的生命周期与参数 x
和返回值的生命周期没有任何关系。
当从函数返回一个引用,返回值的生命周期参数需要与一个参数的生命周期参数相匹配。如果返回的引用 没有 指向任何一个参数,那么唯一的可能就是它指向一个函数内部创建的值,它将会是一个悬垂引用,因为它将会在函数结束时离开作用域。
fn longest<'a>(x: &str, y: &str) -> &'a str {
let result = String::from("really long string");
result.as_str()
}
即便我们为返回值指定了生命周期参数 a
,这个实现却编译失败了,因为返回值的生命周期与参数完全没有关联。
出现的问题是 result
在 longest
函数的结尾将离开作用域并被清理,而我们尝试从函数返回一个 result
的引用。无法指定生命周期参数来改变悬垂引用,而且 Rust
也不允许我们创建一个悬垂引用。在这种情况,最好的解决方案是返回一个有所有权的数据类型而不是一个引用,这样函数调用者就需要负责清理这个值了。
综上,生命周期语法是用于将函数的多个参数与其返回值的生命周期进行关联的。一旦他们形成了某种关联,
Rust
就有了足够的信息来允许内存安全的操作并阻止会产生悬垂指针亦或是违反内存安全的行为。
结构体定义中的生命周期标注
将定义包含引用的结构体,不过这需要为结构体定义中的每一个引用添加生命周期标注。
struct ImportantExcerpt<'a> {
part: &'a str,
}
fn main() {
let novel = String::from("前端.柒八九");
let first_sentence = novel.split('.')
.next()
.expect("没有包含'.'");
let i = ImportantExcerpt { part: first_sentence };
}
结构体有一个字段,part
,它存放了一个字符串 slice
,这是一个引用。类似于泛型参数类型,必须在结构体名称后面的尖括号中声明泛型生命周期参数,以便在结构体定义中使用生命周期参数。这个标注意味着 ImportantExcerpt
的实例不能比其 part
字段中的引用存在的更久。
main
函数创建了一个 ImportantExcerpt
的实例,它存放了变量 novel
所拥有的 String
的第一个句子的引用。novel
的数据在 ImportantExcerpt
实例创建之前就存在。另外,直到 ImportantExcerpt
离开作用域之后 novel
都不会离开作用域,所以 ImportantExcerpt
实例中的引用是有效的。
生命周期省略(Lifetime Elision)
我们已经知道了每一个引用都有一个生命周期,而且我们需要为那些使用了引用的函数或结构体指定生命周期。
有一个函数,如下所示,它没有生命周期标注却能编译成功:
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[..]
}
这个函数没有生命周期标注却能编译是由于一些历史原因:在早期版本(pre-1.0
)的 Rust
中,这的确是不能编译的。每一个引用都必须有明确的生命周期。那时的函数签名将会写成这样:
fn first_word<'a>(s: &'a str) -> &'a str {
在编写了很多 Rust
代码后,Rust
团队发现在特定情况下 Rust
开发者们总是重复地编写一模一样的生命周期标注。这些场景是可预测的并且遵循几个明确的模式。接着 Rust
团队就把这些模式编码进了 Rust 编译器中,如此借用检查器在这些情况下就能推断出生命周期而不再强制开发者显式的增加标注。
被编码进 Rust
引用分析的模式被称为{生命周期省略规则|lifetime elision rules}。
省略规则并不提供完整的推断:如果 Rust
在明确遵守这些规则的前提下变量的生命周期仍然是模棱两可的话,它不会猜测剩余引用的生命周期应该是什么。在这种情况,编译器会给出一个错误,这可以通过增加对应引用之间相联系的生命周期标注来解决。
- 函数或方法的参数的生命周期被称为 {输入生命周期|input lifetimes}
- 而返回值的生命周期被称为 {输出生命周期|output lifetimes}。
编译器采用三条规则来判断引用何时不需要明确的标注。第一条规则适用于输入生命周期,后两条规则适用于输出生命周期。如果编译器检查完这三条规则后仍然存在没有计算出生命周期的引用,编译器将会停止并生成错误。这些规则适用于
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
,说明是个对象的方法(method), 那么所有输出生命周期参数被赋予 self 的生命周期。第三条规则使得方法更容易读写,因为只需更少的符号。
方法定义中的生命周期标注
声明和使用生命周期参数的位置依赖于生命周期参数是否同结构体字段或方法参数和返回值相关。
(实现方法时)结构体字段的生命周期必须总是在 impl
关键字之后声明并在结构体名称之后被使用,因为这些生命周期是结构体类型的一部分。
impl
块里的方法签名中,引用可能与结构体字段中的引用相关联,也可能是独立的。另外,生命周期省略规则也经常让我们无需在方法签名中使用生命周期标注。
impl<'a> ImportantExcerpt<'a> {
fn announce_and_return_part(&self, announcement: &str) -> &str {
println!("{}", announcement);
self.part
}
}
这里有两个输入生命周期,所以 Rust
应用第一条生命周期省略规则并给予 &self
和 announcement
他们各自的生命周期。接着,因为其中一个参数是 &self
,返回值类型被赋予了 &self
的生命周期,这样所有的生命周期都被计算出来了。
静态生命周期
有一种特殊的生命周期值得讨论:'static
,其生命周期能够存活于整个程序期间****。所有的字符串字面量都拥有 'static
生命周期。
let s: &'static str = "前端柒八九";
这个字符串的文本被直接储存在程序的二进制文件中而这个文件总是可用的。因此所有的字符串字面量都是 'static
的。
总结
泛型类型参数意味着代码可以适用于不同的类型。trait
和 trait bounds
保证了即使类型是泛型的,这些类型也会拥有所需要的行为。由生命周期标注所指定的引用生命周期之间的关系保证了这些灵活多变的代码不会出现悬垂引用。
后记
分享是一种态度。
参考资料:《Rust权威指南》
全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。
转载自:https://juejin.cn/post/7212942861518716965