《Rust编程实战》系列第38篇
在上一篇文章中,我们学习了Rust生命周期基础,知道生命周期
Lifetime的核心作用是保证:引用在被使用时,它指向的数据一定仍然有效。
例如下面的代码无法通过编译:
ini
fn main() {
let reference;
{
let text = String::from("Rust");
reference = &text;
}
println!("{}", reference);
}
因为text已经被销毁,reference如果继续使用就会成为悬空引用。
上一篇主要解决"生命周期是什么",本篇进一步解决:
生命周期标注到底怎么写?多个生命周期之间是什么关系?结构体、方法、泛型和Trait中应该如何使用生命周期?
本文将介绍:
-
生命周期完整语法
-
'a真正表达什么 -
多个生命周期参数
-
输入与输出生命周期关系
-
生命周期约束
-
结构体中的生命周期
-
impl中的生命周期 -
方法生命周期省略
-
生命周期与泛型
-
生命周期Bound
-
'static深入理解 -
返回引用的设计原则
-
生命周期最佳实践
生命周期标注基本语法
生命周期参数通常使用单引号开头:
css
'a
'b
'input
最常见的是:
css
'a
函数中声明生命周期:
rust
fn print<'a>(text: &'a str) {
println!("{}", text);
}
完整结构可以拆成:
rust
fn 函数名<'a>(
参数: &'a 类型
)
这里的:
css
<'a>
声明生命周期参数。
而:
rust
&'a str
表示这个引用与生命周期'a关联。
生命周期参数和泛型参数很像:
php
fn example<T>(value: T)
泛型T描述"类型是什么",生命周期'a描述"引用之间的有效范围关系"。
生命周期标注不是指定具体寿命
这是理解生命周期最重要的一点。
下面的代码:
rust
fn print<'a>(text: &'a str) {
println!("{}", text);
}
并不是说:
vbnet
text必须存活'a这么长时间
因为'a并不是10秒、1分钟或者某个固定作用域。
它只是一个约束符号,由编译器根据实际调用位置计算。
可以理解为:
对于某一个生命周期'a,只要调用期间text至少在'a范围内有效,就满足要求。
生命周期真正描述的是关系
生命周期最大的作用不是描述单个引用,而是建立多个引用之间的关系。
例如:
rust
fn first<'a>(text: &'a str) -> &'a str {
&text[..1]
}
这里:
rust
text: &'a str
和:
rust
-> &'a str
使用同一个'a。
它表达的是:
返回值引用来自text,所以返回引用不能比text有效得更久。
生命周期标注的重点就是这种"输入引用和输出引用之间的关系"。
多个输入引用
经典例子:
rust
fn longest<'a>(
first: &'a str,
second: &'a str,
) -> &'a str {
if first.len() >= second.len() {
first
} else {
second
}
}
这里两个参数和返回值都使用:
css
'a
意味着返回值可能来自first,也可能来自second。
因此返回引用必须受到两个输入引用共同有效范围的限制。
可以简单理解为:
sql
first有效范围 ───────────────
second有效范围 ────────
返回值有效范围 ────────
返回值不能超过较短的有效范围。
生命周期不会要求两个参数实际活得一样久
例如:
ini
let first = String::from("Rust");
{
let second = String::from("JavaScript");
let result = longest(&first, &second);
println!("{}", result);
}
first明显比second活得更久。
但仍然可以传给:
rust
fn longest<'a>(
first: &'a str,
second: &'a str,
) -> &'a str
编译器会选择两者重叠的有效范围作为'a。
所以:
相同生命周期参数表示需要满足共同约束,不代表变量实际寿命完全相同。
多个生命周期参数
如果两个引用之间没有必要绑定到同一个生命周期,可以分别定义:
rust
fn print_two<'a, 'b>(
first: &'a str,
second: &'b str,
) {
println!("{}", first);
println!("{}", second);
}
这里:
css
'a
'b
互相独立。
由于函数没有返回引用,也不需要建立特殊关系,因此两个参数可以拥有不同生命周期。
返回值只来自其中一个参数
看下面的函数:
rust
fn choose_first<'a, 'b>(
first: &'a str,
second: &'b str,
) -> &'a str {
println!("{}", second);
first
}
虽然函数有两个引用参数,但返回值一定来自:
sql
first
因此返回生命周期只需要与:
css
'a
关联。
不需要错误地写成:
rust
fn choose_first<'a>(
first: &'a str,
second: &'a str,
) -> &'a str
后者会人为增加不必要的生命周期约束。
原则是:
生命周期标注应该尽量精确,只建立真正存在的数据借用关系。
生命周期与作用域嵌套
考虑:
rust
fn first<'a>(value: &'a str) -> &'a str {
value
}
fn main() {
let result;
{
let text = String::from("Rust");
result = first(&text);
println!("{}", result);
}
}
这是安全的,因为result只在text仍然存在时使用。
如果改成:
ini
fn main() {
let result;
{
let text = String::from("Rust");
result = first(&text);
}
println!("{}", result);
}
就会报错。
即使函数标注了:
css
'a
也不能让text延长生命周期。
生命周期约束关系
生命周期之间还可以表达"至少和另一个生命周期一样长"的约束。
语法:
rust
'a: 'b
可以理解为:
'a至少要和'b一样长。例如:
rust
fn example<'a, 'b>(
value: &'a str,
) -> &'b str
where
'a: 'b,
{
value
}
这里要求:
css
'a的有效范围 >= 'b的有效范围
这样才能安全地把&'a str作为&'b str返回。
这类显式约束在基础业务代码中并不常见,但在复杂泛型、Trait和库代码中会遇到。
结构体中的生命周期
如果结构体内部保存引用,就必须保证引用的数据不会先于结构体失效。
例如:
rust
struct User<'a> {
name: &'a str,
}
这里表达:
User中的name借用了外部字符串,所以User实例不能比name指向的数据活得更久。
使用:
ini
fn main() {
let name = String::from("Tom");
let user = User {
name: &name,
};
println!("{}", user.name);
}
这是安全的。
错误的结构体引用
例如:
ini
let user;
{
let name = String::from("Tom");
user = User {
name: &name,
};
}
println!("{}", user.name);
这无法通过编译。
因为:
name先销毁
↓
user.name仍然指向name
↓
形成悬空引用
生命周期系统会提前阻止它。
impl中的生命周期
带生命周期参数的结构体实现方法时,需要写:
rust
struct User<'a> {
name: &'a str,
}
impl<'a> User<'a> {
fn name(&self) -> &str {
self.name
}
}
这里:
rust
impl<'a>
声明实现中使用生命周期参数。
css
User<'a>
表示为带生命周期'a的User类型实现方法。
方法中的生命周期省略
上面的:
rust
fn name(&self) -> &str {
self.name
}
并没有显式写:
css
'a
因为Rust有生命周期省略规则。
完整理解可以近似写成:
rust
fn name<'b>(&'b self) -> &'b str {
self.name
}
对于方法而言,只要存在:
lua
&self
或:
rust
&mut self
Rust通常会优先认为输出引用与self的借用生命周期相关。
因此很多方法不需要手写生命周期。
生命周期省略三条核心规则
Rust生命周期省略规则可以简单记住三条。
第一条:每个输入引用参数拥有自己的生命周期。
例如:
rust
fn foo(x: &str, y: &str)
内部可理解为:
rust
fn foo<'a, 'b>(
x: &'a str,
y: &'b str,
)
第二条:如果只有一个输入生命周期,那么它会赋给所有输出引用。
例如:
rust
fn first(text: &str) -> &str
可以推断。
第三条:如果是方法,并且参数包含&self或&mut self,输出生命周期通常绑定到self。
例如:
rust
fn name(&self) -> &str
因此可以省略。
如果这些规则仍然无法确定输出引用来自哪里,就必须手动标注。
生命周期与泛型
生命周期可以和类型泛型同时使用:
rust
struct Wrapper<'a, T> {
value: &'a T,
}
这里:
css
'a
描述引用生命周期。
r
T
描述数据类型。
使用:
ini
fn main() {
let number = 100;
let wrapper = Wrapper {
value: &number,
};
println!("{}", wrapper.value);
}
也可以写函数:
rust
fn get_value<'a, T>(
wrapper: &'a Wrapper<'a, T>,
) -> &'a T {
wrapper.value
}
生命周期与Trait Bound
泛型中还可以写生命周期Bound:
vbnet
T: 'a
表示:
类型T中包含的引用数据必须至少在'a期间有效。
例如:
rust
struct Container<'a, T>
where
T: 'a,
{
value: &'a T,
}
在现代Rust中,很多简单场景的生命周期约束可以由编译器自动推断,因此不一定需要显式写出。
但在阅读复杂Rust库源码时经常会看到:
vbnet
T: 'a
或者:
vbnet
T: 'static
T:'static是什么意思
例如:
csharp
fn run<T>(value: T)
where
T: 'static,
{
}
这里容易误解。
T: 'static并不一定表示变量value本身永远存在。
它主要表示:
T内部不能包含生命周期短于
'static的借用引用。例如:
rust
String
Vec<i32>
i32
这些拥有自己数据、不依赖临时外部引用的类型通常可以满足'static约束。
因此'static经常出现在线程、异步任务等场景中。
&'static T和T:'static的区别
这两个概念不要混淆。
rust
&'static str
表示:
这是一个整个程序运行期间都有效的引用。
例如:
ini
let name: &'static str = "Rust";
而:
vbnet
T: 'static
表示:
类型T不包含短生命周期借用。
例如:
arduino
String
本身通常满足'static约束,但一个局部String变量仍然会在作用域结束时被销毁。
返回引用还是返回所有权
生命周期问题很多时候其实是API设计问题。
例如错误代码:
rust
fn create_name<'a>() -> &'a str {
let name = String::from("Tom");
&name
}
不要尝试用更复杂生命周期修复。
正确设计是:
rust
fn create_name() -> String {
String::from("Tom")
}
因为函数创建了新数据,那么最自然的方式就是:
把数据所有权返回给调用者。
只有返回值确实借用自输入参数时,才应该返回引用。
返回引用的正确场景
例如从字符串中查找一个单词:
scss
fn first_word(text: &str) -> &str {
text.split_whitespace()
.next()
.unwrap_or("")
}
返回值直接来自:
arduino
text
所以返回引用是合理的。
再例如:
rust
struct User {
name: String,
}
impl User {
fn name(&self) -> &str {
&self.name
}
}
返回的是结构体内部字段引用,也非常合理。
零拷贝与生命周期
生命周期在高性能Rust代码中的一个重要应用是:
rust
Zero Copy
零拷贝
例如解析:
ini
name=Tom&age=20
不一定需要把每个字段都复制成新的String。
可以直接返回原字符串的切片:
rust
fn get_key_value<'a>(
input: &'a str,
) -> Option<(&'a str, &'a str)> {
input.split_once('=')
}
fn main() {
let input = String::from("name=Tom");
let (key, value) =
get_key_value(&input).unwrap();
println!("{} = {}", key, value);
}
这里:
vbnet
key
value
都只是借用input中的数据,没有创建新的字符串。
这就是生命周期和性能优化结合的典型场景。
结构体零拷贝解析
例如:
rust
#[derive(Debug)]
struct Header<'a> {
key: &'a str,
value: &'a str,
}
fn parse_header(
input: &str,
) -> Option<Header<'_>> {
let (key, value) = input.split_once(':')?;
Some(Header {
key: key.trim(),
value: value.trim(),
})
}
fn main() {
let line = String::from(
"Content-Type: application/json"
);
let header =
parse_header(&line).unwrap();
println!("{:?}", header);
}
Header没有复制:
bash
Content-Type
application/json
而是直接引用line中的字符数据。
优点:
-
减少内存分配
-
减少字符串复制
-
提升解析性能
缺点是
Header不能比line活得更久。
生命周期与可变引用
可变引用也需要遵守生命周期规则:
rust
fn update<'a>(
text: &'a mut String,
) -> &'a mut String {
text.push('!');
text
}
表示返回的可变引用来自输入的可变引用。
调用:
rust
fn main() {
let mut text = String::from("Rust");
let result = update(&mut text);
result.push('?');
println!("{}", result);
}
在result仍然有效时,对原text的其他访问会受到借用规则限制。
生命周期与Trait
Trait方法中也可以使用生命周期:
rust
trait Name {
fn name(&self) -> &str;
}
struct User {
name: String,
}
impl Name for User {
fn name(&self) -> &str {
&self.name
}
}
这种情况可以使用生命周期省略。
复杂情况也可以显式声明:
rust
trait Select {
fn select<'a>(
&self,
value: &'a str,
) -> &'a str;
}
这说明返回值生命周期与参数value相关,而不是与self相关。
常见生命周期错误
返回局部变量引用
错误:
rust
fn create() -> &str {
let text = String::from("Rust");
&text
}
应该返回:
arduino
String
给所有引用都写同一个'a
不推荐:
rust
fn print<'a>(
first: &'a str,
second: &'a str,
)
如果它们没有生命周期关系,完全可以省略:
python
fn print(
first: &str,
second: &str,
)
生命周期越长越好
错误理解:
rust
把所有东西都写成'static最安全
实际上生命周期约束越长,API可能越难使用。
应该使用满足需求的最小约束。
为解决报错大量Clone
有时生命周期报错后直接:
php
value.clone()
确实能够绕过借用问题,但可能产生不必要复制。
应该先分析引用之间真正的所有权关系。
结构体过度使用引用
例如所有字段都设计为:
rust
struct User<'a> {
name: &'a str,
email: &'a str,
}
会让生命周期参数传播到大量业务代码。
如果User本身应该拥有这些数据,直接使用:
arduino
struct User {
name: String,
email: String,
}
通常更合理。
生命周期最佳实践
能省略就不要显式写
推荐:
rust
fn name(&self) -> &str
而不是无意义地写:
rust
fn name<'a>(&'a self) -> &'a str
生命周期只表达必要关系
如果返回值只来自第一个参数,就只绑定第一个参数。
新创建的数据返回所有权
例如:
rust
fn create() -> String
不要返回局部变量引用。
临时视图可以使用引用
例如:
rust
struct Parsed<'a> {
value: &'a str,
}
特别适合解析器、切片和零拷贝场景。
业务实体优先拥有数据
数据库实体、配置对象、用户对象等通常使用String、Vec等拥有型数据更加容易维护。
不要为了生命周期复杂而恐惧引用
生命周期并不是额外的运行时机制,它主要是编译阶段规则。程序运行时不会因为'a本身产生额外开销。
本章小结
Rust生命周期的核心不是"变量活多久",而是描述引用之间必须满足的有效范围关系。
本文学习了:
-
'a是生命周期参数 -
生命周期标注描述关系,而不是延长数据寿命
-
相同
'a不意味着两个变量实际寿命完全相同 -
多个引用可以使用不同的生命周期参数
-
'a: 'b表示生命周期之间的约束 -
结构体保存引用需要生命周期参数
-
impl可以声明生命周期 -
方法中大量生命周期可以通过省略规则自动推断
-
生命周期可以和泛型、Trait一起使用
-
T: 'static和&'static T含义不同 -
返回新数据时通常应该返回所有权
-
返回输入数据的一部分时可以返回引用
-
生命周期可以实现零拷贝解析
-
生命周期本身不会产生运行时性能开销
可以记住:
生命周期不是管理内存的工具,而是证明引用安全的规则。
所有权决定谁拥有数据,借用决定谁访问数据,生命周期描述这些引用能够安全存在多久。
真正优秀的生命周期代码不是写了大量
'a,而是用最少的生命周期约束准确表达数据之间的关系。
下一篇预告
下一篇我们将通过大量真实代码进一步掌握生命周期:
Rust 生命周期案例详解:从编译错误到真实业务场景
内容包括:
-
返回字符串切片案例
-
多参数引用选择案例
-
结构体引用字段案例
-
配置解析案例
-
HTTP Header零拷贝解析
-
文本解析器案例
-
生命周期与Vec案例
-
生命周期与HashMap案例
-
生命周期与方法返回值
-
生命周期与可变引用案例
-
常见生命周期报错逐步修改
-
什么时候应该放弃引用改用String