Rust 从 0 到 1 (生命周期、返回值、错误处理)

生命周期是 Rust 特有的,在 Rust 中函数和变量有其作用域,在一个函数中可以嵌套作用域,如:

1
2
3
4
5
6
fn main(){
let a = 0; // a
{
let b = 2; // b
} // end b
} // end a

对于生命周期其实就是引用的有效作用域,大多数情况下无需手动声明生命周期,编辑器可以进行自动推导,当然也有无法推导的情况

悬垂指针和生命周期

生命周期的主要作用是避免悬垂引用,它会导致程序引用了不该引用的数据,如:

1
2
3
4
5
6
7
8
9
10
{
let r;

{
let x = 5;
r = &x;
}

println!("r: {}", r);
}

在这里首先是 let r 没有初始化,在其次是 r 引用了内部作用域的 x 变量,但是 x 变量在内部作用域结束时已经释放掉了,最后 r 会引用一个无效的 x,导致 r 变成了一个悬垂指针。

借用检查

为了保证所有权和借用的正确性,Rust 会使用借用检查器(Borrow checker)来检查程序的借用正确性。如:

1
2
3
4
5
6
7
8
9
10
{
let r; // ---------+-- 'a
// |
{ // |
let x = 5; // -+-- 'b |
r = &x; // | |
} // -+ |
// |
println!("r: {}", r); // |
}

在这里,r的生命周期为'a, x 的生命周期为 'b ,从图示上可以看出生命周期 'b'a 小的多。在编译期 Rust 会检查变量的生命周期,上面的代码中 r 的生命周期引用了一个小的多生命周期 x,这个时候编译器就认为程序存在风险,则会抛出异常。

想通过编译就需要让生命周期 'b'a 大,也就是 x 变量要比 r 活得久,对代码进行如下修改:

1
2
3
4
5
6
7
8
{
let x = 5; // ----------+-- 'b
// |
let r = &x; // --+-- 'a |
// | |
println!("r: {}", r); // | |
// --+ |
}

现在 x 的生命周期比 r 的生命周期大,因此 rx 的引用是安全的。

生命周期标注语法

在 Rust 中以 '开头,名称为一个单独的小写字母,大部分多数使用'a 来作为生命周期的名称, 如果是引用类型的参数,那么生命周期会位于引用符号 & 之后,并用一个空格来将生命周期和引用参数分隔开。如:

1
2
3
&i32        // 一个引用
&'a i32 // 具有显式生命周期的引用
&'a mut i32 // 具有显式生命周期的可变引用

生命周期的标注主要作用是告诉编译器多个引用之间的关系,没有什么其他的意义。

需要注意的是:生命周期标注并不会改变任何引用的实际作用域

函数签名中的生命周期标注

需要比较两个字符串切片中返回较长的字符串切片:

1
2
3
4
5
6
7
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}

注意:

  • 使用生命周期参数需要先声明 <'a>
  • x,y 和返回值至少活的和 'a 一样久

longest 函数签名表明对于生命周期 'a,其两个参数和函数具有相同的生命周期,返回值也具有相同的生命周期。但是实际上,返回值的生命周期适合参数中的生命周期中的最小值一致的。

总结:在通过函数签名指定生命周期参数时,我们并没有改变传入引用或者返回引用的真实生命周期,而是告诉编译器当不满足此约束条件时,就拒绝编译通过。

来看个例子:

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

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}

首先 string1 的生命周期是跟随 main 函数一起结束的,而 string2是跟随内部作用域一起结束的。由于 'a 是两者中的最小作用域,也就是说 'a 的生命周期是等于 string2 的生命周期的,由于函数返回值的生命周期也是 'a 因此函数返回值的生命周期也等于 string2 的生命周期。

如果函数如下:

1
2
3
4
5
6
7
8
9
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);
}

在编译时就会出现一个错误: string2 does not live long enough

在上面的代码中 string2 的生命周期是 'a, 但实际上 string2在内部作用域结束后就释放了,因此它的生命周期是小于 'a的,在编译时就会报错。

使用生命周期的方式取决于函数的功能,如果现在有一个函数它永远只返回第一个参数,那么它的生命周期的标注如下:

1
2
3
fn longest<'a>(x: &'a str, y: &str) -> &'a str {
x
}

在这里 y 并没有被使用,因此 y 的生命周期和返回值的生命周期没有关系,所以我们可以不用标注 y 的生命周期。

函数的返回值如果是一个引用类型,那么它的生命周期只会来源于:

  • 函数参数的生命周期
  • 函数体中某个新建引用的生命周期(悬垂引用)

结构体中的生命周期

之前的结构都是使用非引用类型字段上,当有了生命周期之后在结构体中使用引用也是可能的:只要为结构体中的每一个引用标注生命周期即可。如:

1
2
3
4
5
6
7
8
9
10
11
struct ImportantExcerpt<'a> {
part: &'a str,
}

fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().expect("Could not find a '.'");
let i = ImportantExcerpt {
part: first_sentence,
};
}

结构体 ImportantExcerpt 中有一个引用类型的字段 part,因此需要标注上生命周期。需要注意的是:结构体 ImportantExcerpt 所引用的字符串 str 生命周期需要大于等于该结构体的生命周期。若是将代码改成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#[derive(Debug)]
struct ImportantExcerpt<'a> {
part: &'a str,
}

fn main() {
let i;
{
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().expect("Could not find a '.'");
i = ImportantExcerpt {
part: first_sentence,
};
}
println!("{:?}",i);
}

则无法通过编译因为结构体所引用的字符串的生命周期小于结构体的生命周期。

生命周期消除

对于 Rust 编译器来说,每一个引用类型都有一个生命周期。但是为了简化用户的使用,运用了生命周期消除大法。在了解消除规则之前需要注意以下几个问题:

  • 消除规则不是万能的,若编译器不能确定某件事是正确时,会直接判为不正确,那么你还是需要手动标注生命周期
  • 函数或者方法中,参数的生命周期被称为 输入生命周期,返回值的生命周期被称为 输出生命周期

三条消除规则

  1. 每一个引用参数都会获得独自的生命周期

    1
    2
    3
    4
    // 一个引用参数就有一个生命周期标注
    fn foo<'a>(x: &'a i32)
    // 两个引用参数就有两个生命周期标注
    fn foo<'a, 'b>(x: &'a i32, y: &'b i32)
  2. 若只有一个输入生命周期(函数参数中只有一个引用类型),那么该生命周期会被赋给所有的输出生命周期,也就是所有返回值的生命周期都等于该输入生命周期

    1
    2
    3
    fn foo(x: &i32) -> &i32
    // x 的生命周期会被自动赋给返回值,那么该函数等同于
    fn foo<'a>(x: &'a i32) -> &'a i32
  3. 若存在多个输入生命周期,且其中一个是 &self 或 &mut self,则 &self 的生命周期被赋给所有的输出生命周期

方法中的生命周期

为具有生命周期的结构体实现方法时,使用方法如下:

1
2
3
4
5
6
7
8
9
struct ImportantExcerpt<'a> {
part: &'a str,
}

impl<'a> ImportantExcerpt<'a> {
fn level(&self) -> i32 {
3
}
}

该语法跟范型参数语法相似。但是有几点需要注意:

  • impl 中必须使用结构体的完整名称,包括 <'a>,因为生命周期标注也是结构体类型的一部分
  • 方法签名中,往往不需要标注生命周期,得益于生命周期消除的第一和第三规则

第三规则应用的场景例子:

1
2
3
4
5
6
impl<'a> ImportantExcerpt<'a> {
fn announce_and_return_part(&self, announcement: &str) -> &str {
println!("Attention please: {}", announcement);
self.part
}
}

根据三条消除规则,该函数等效于

1
2
3
4
5
6
impl<'a: 'b, 'b> ImportantExcerpt<'a> {
fn announce_and_return_part<'b>(&'a self, announcement: &'b str) -> &'a str {
println!("Attention please: {}", announcement);
self.part
}
}

在这里需要注意的是:

  • 'a: 'b,是生命周期约束语法,跟泛型约束非常相似,用于说明 'a 必须比 'b 活得久
  • 可以把 ‘a 和 ‘b 都在同一个地方声明(如上),或者分开声明但通过 where ‘a: ‘b 约束生命周期关系,如:
    1
    2
    3
    4
    5
    6
    7
    8
    9
     impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part<'b>(&'a self, announcement: &'b str) -> &'b str
    where
    'a: 'b,
    {
    println!("Attention please: {}", announcement);
    self.part
    }
    }

静态生命周期

在 Rust 中有一个非常特殊的生命周期,那就是 ‘static,拥有该生命周期的引用可以和整个程序的生命周期一样,如字符串字面量和特征对象。

1
let s: &'static str = "Hello,World!";

错误处理

在 Rust 中错误处理主要分为两类:

  • 可恢复错误,通常用于从系统全局角度来看可以接受的错误,例如处理用户的访问、操作等错误,这些错误只会影响某个用户自身的操作进程,而不会对系统的全局稳定性产生影响
  • 不可恢复错误,刚好相反,该错误通常是全局性或者系统性的错误,例如数组越界访问,系统启动时发生了影响启动流程的错误等等,这些错误的影响往往对于系统来说是致命的

在 Rust 中使用 Result<T, E> 用于可恢复错误,panic! 用于不可恢复错误.

panic! 于不可恢复错误

触发 panic! 主要有两种方式

被动触发

如数组访问越界:

1
2
3
4
5
fn main() {
let v = vec![1, 2, 3];

v[99];
}

主动调用

在某些特殊情况下需要主动抛出错误,如在读取文件的过程中读取文件失败。Rust 为我们提供了 panic! 宏,当调用执行该宏时,程序会打印出一个错误信息,展开报错点往前的函数调用堆栈,最后退出程序。如:

1
2
3
fn main() {
panic!("crash and burn");
}

panic 的两种种植方式

当程序出现 panic 时,会提供两种方式来终止流程:栈展开直接终止

默认的方式就是 栈展开,Rust 会回溯栈上数据和函数调用,好处是可以给出充分的报错信息和栈调用信息,便于事后的问题复盘。
直接终止,不清理数据就直接退出程序,善后工作交与操作系统来负责。

如果 panic 发生在主线程中程序终止推出,如果发生在子线程中则该线程终止,但不会影响主线程。因此尽量不要在主线程中做太多任务,将任务将给子线程去做。

panic 使用的主要场景如下:

  • 示例、原型、测试
  • 可能导致全局有害状态时

有害状态大概分为几类:

  • 非预期的错误
  • 后续代码的运行会受到显著影响
  • 内存安全的问题

可恢复错误 Result

Result<T, E> 是一个枚举类型,它的定义如下:

1
2
3
4
enum Result<T, E> {
Ok(T),
Err(E),
}

泛型参数 T 代表成功时存入的正确值的类型,存放方式是 Ok(T)
泛型参数 E 代表错误时存入的错误值,存放方式是 Err(E)

Result 的使用方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
use std::fs::File;

fn main() {
let f = File::open("hello.txt");

let f = match f {
Ok(file) => file,
Err(error) => {
panic!("Problem opening the file: {:?}", error)
},
};
}

,如果是成功,则将 Ok(file) 中存放的的文件句柄 file 赋值给 f,如果失败,则将 Err(error) 中存放的错误信息 error 使用 panic 抛出来,进而结束程序。

对返回的错误进行处理

我们可以对部分错误进行处理,而不是所有的错误都直接崩溃退出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use std::fs::File;
use std::io::ErrorKind;

fn main() {
let f = File::open("hello.txt");

let f = match f {
Ok(file) => file,
Err(error) => match error.kind() {
ErrorKind::NotFound => match File::create("hello.txt") {
Ok(fc) => fc,
Err(e) => panic!("Problem creating the file: {:?}", e),
},
other_error => panic!("Problem opening the file: {:?}", other_error),
},
};
}

在这里如果是文件不存在错误 ErrorKind::NotFound,就创建文件,File::create 同样返回 Result 再通过 match 对错误进行匹配,如果创建失败则 panic。其他的错误直接 panic

unwrap 和 expect

首先是 unwrap,在遇到错误时会直接 panic,效果如下:

而 expect 会打印自定义的错误信息。

错误传播

在实际应用中,会把错误层层上传然后交给调用链上游函数进行处理,错误传播将非常常见。如函数从文件中读取用户名,然后将结果进行返回:

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::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
// 打开文件,f是`Result<文件句柄,io::Error>`
let f = File::open("hello.txt");

let mut f = match f {
// 打开文件成功,将file句柄赋值给f
Ok(file) => file,
// 打开文件失败,将错误返回(向上传播)
Err(e) => return Err(e),
};
// 创建动态字符串s
let mut s = String::new();
// 从f文件句柄读取数据并写入s中
match f.read_to_string(&mut s) {
// 读取成功,返回Ok封装的字符串
Ok(_) => Ok(s),
// 将错误向上传播
Err(e) => Err(e),
}
}

该函数返回一个 Result<String, io::Error> 类型,当读取用户名成功时,返回 Ok(String),失败时,返回 Err(io:Error); File::openf.read_to_string 返回的 Result<T, E> 中的 E 就是 io::Error

上面的写法过于麻烦,好在 Rust 提供 ? 可以简化上面的代码:

1
2
3
4
5
6
7
8
9
10
use std::fs::File;
use std::io;
use std::io::Read;

fn read_username_from_file() -> Result<String, io::Error> {
let mut f = File::open("hello.txt")?;
let mut s = String::new();
f.read_to_string(&mut s)?;
Ok(s)
}

其实 ? 就是一个宏,它的作用跟上面的 match 几乎一模一样。? 有一个比 match 好用的特性那就是自动类型转换,得益于标准库中定义的 From 特征,该特征有一个方法 from,用于把一个类型转成另外一个类型,? 可以自动调用该方法,然后进行隐式类型转换。因此只要函数返回的错误 ReturnError 实现了 From<OtherError> 特征,那么 ? 就会自动把 OtherError 转换为 ReturnError

这种转换意味着你可以用一个大而全的 ReturnError 来覆盖所有错误类型,只需要为各种子错误类型实现这种转换即可。如:

1
2
3
4
5
6
7
8
9
10
11
use std::fs::File;
use std::io;
use std::io::Read;

fn read_username_from_file() -> Result<String, io::Error> {
let mut s = String::new();

File::open("hello.txt")?.read_to_string(&mut s)?;

Ok(s)
}

? 不仅返回 Result 的 Err还可以返回 OptionNone,如:

1
2
3
4
fn first(arr: &[i32]) -> Option<&i32> {
let v = arr.get(0)?;
Some(v)
}

arr.get 返回一个 Option<&i32> 类型,因为 ? 的使用,如果 get 的结果是 None,则直接返回 None,如果是 Some(&i32),则把里面的值赋给 v。

参考链接

https://course.rs/basic/result-error/intro.html