Loading

Rust 程序设计语言(8)

前言

本章介绍 rust 的错误处理

错误处理分类

只要是人写的代码就有可能出现 bug, rust 会尽可能的在编译时就将错误抛出, 目的是让你的代码更加健壮, 避免在运行时出现问题.
rust 将错误分为两种, 可恢复的(recoverable)不可恢复的(unrecoverable) 错误, 如果是可恢复的错误, 例如文件未找到, rust只会向用户报告错误. 而针对不可恢复的错误, 例如数组下标超限等, 程序会立即停止.

使用 panic! 处理不可恢复错误

panic!

rust自带了宏panic!, 当执行这个宏时, rust会打印出一个错误信息, 展开并清理栈数据, 然后程序会退出.
默认情况下, 当出现 panic 时, 程序会默认展开(unwinding), Rust 会回溯栈并清理数据, 这个过程可能需要很多工作, 你也可以设置关闭 rust 的展开功能, 在程序结束后由操作系统进行清理, 这通常会减小最后生成的二进制文件, 你可以通过在 Cargo.toml 添加配置来设置在 release 模式直接终止程序

[profile.release]
panic = 'abort'

简单的调用一下 panic!

fn main() {
    panic!("panic")
}

运行可以看到错误输出

➜  try git:(master) ✗ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/try`
thread 'main' panicked at 'panic', src/main.rs:3:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

可以看到已经输出了错误信息, 提示我们错误在 src/main.rs 的第三行第5个字符
而在真正的项目中, 如果我们调用其他包出现错误, 很可能这里的错误会指向我们调用的包内的代码, 此时我们可以调用 backtrace 来获取错误上下文信息

panic! 的 backtrace

我们修改代码, 使 panic! 由其他代码触发而不是自己调用

fn main() {
    let v = vec![1, 2, 3];
    v[99];  // 下标 99
}

在这里, 我们建立v只有三个元素, 但是在下方获取下标为99的元素, 在rust 中, 如果访问了无效索引, 会导致 panic, 如果是在 C 语言中, 会获取到对应数据结构中这个元素内存中的位置的值, 也可能会访问到不属于这个数据结构的数据, 这被称之为内存泄露, 可能会导致安全漏洞问题, 因此Rust 将这个操作进行了捕捉和错误处理. 例如:

➜  try git:(master) ✗ cargo run
   Compiling try v0.1.0 (/Work/Code/Rust/student/try)
    Finished dev [unoptimized + debuginfo] target(s) in 0.34s
     Running `target/debug/try`
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:3:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

错误输出提示我们, 错误在 src/main.rs:3:5, 并告诉我们错误时尝试获取索引99的数据但是总长度为3.
另外也告诉我们, 可以设置 RUST_BACKTRACE=1 来获取backtrace 信息, 我们在运行命令中指定RUST_BACKTRACE=1 运行, 命令变成了 RUST_BACKTRACE=1 cargo run

➜  try git:(master) ✗ RUST_BACKTRACE=1 cargo run
   Compiling try v0.1.0 (/Work/Code/Rust/student/try)
    Finished dev [unoptimized + debuginfo] target(s) in 1.38s
     Running `target/debug/try`
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:3:5
stack backtrace:
   0: rust_begin_unwind
             at /rustc/69f9c33d71c871fc16ac445211281c6e7a340943/library/std/src/panicking.rs:575:5
   1: core::panicking::panic_fmt
             at /rustc/69f9c33d71c871fc16ac445211281c6e7a340943/library/core/src/panicking.rs:65:14
   2: core::panicking::panic_bounds_check
             at /rustc/69f9c33d71c871fc16ac445211281c6e7a340943/library/core/src/panicking.rs:151:5
   3: <usize as core::slice::index::SliceIndex<[T]>>::index
             at /rustc/69f9c33d71c871fc16ac445211281c6e7a340943/library/core/src/slice/index.rs:259:10
   4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
             at /rustc/69f9c33d71c871fc16ac445211281c6e7a340943/library/core/src/slice/index.rs:18:9
   5: <alloc::vec::Vec<T,A> as core::ops::index::Index<I>>::index
             at /rustc/69f9c33d71c871fc16ac445211281c6e7a340943/library/alloc/src/vec/mod.rs:2736:9
   6: try::main
             at ./src/main.rs:3:5
   7: core::ops::function::FnOnce::call_once
             at /rustc/69f9c33d71c871fc16ac445211281c6e7a340943/library/core/src/ops/function.rs:251:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

此时就会有 backtrace 信息打印出来, 可以看到错误上下文, 当程序开启 debug 模式时, 默认在 panic 时会打印 backtrace 信息, 而当不使用 --release 参数运行cargo buildcargo run 时 debug 会默认启用.
backtrace 信息中输出了错误的上下文信息, 可以看到, 错误是在 ./src/main.rs:3:5 开始触发的, 如果你想要对错误进行排查, 需要从你的调用代码开始查看和排查问题.

使用 Result 处理可以恢复的错误

举个例子, 当我们在代码中希望打开某一个文件, 当文件不存在时, 我们应该进行其他处理, 例如重试或者更换文件, 而不是直接将程序停止, 因为这是很可能出现的错误.
在之前的章节中, 我们提到过, Result 枚举成员有两个

enum Result<T, E> {
    Ok(T),
    Err(E),
}

其中, TE 是泛型类型参数, 之后会学习泛型的知识, 现在, 我们可以认为, T 是成功时Ok 成员中的数据类型, E 代表错误时Err成员中的数据类型, 因此我们可以通过枚举来判断调用是否成功
下面的代码我们尝试打开一个文件

use std::fs::File;

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

我们如何知道File::open 的返回值是什么类型呢? 如果你使用 Vscode 并且安装了相关模块, 他会自己提示, 或者你可以查看文档, 同时, 如果你定义了错误的类型作为返回值接收, 在编译和运行时也会有错误信息, 例如 let f:u32 =File::open("a.txt");
则会报错

➜  try git:(master) ✗ cargo run             
   Compiling try v0.1.0 (/Work/Code/Rust/student/try)
error[E0308]: mismatched types
 --> src/main.rs:4:16
  |
4 |     let f:u32 =File::open("a.txt");
  |           ---  ^^^^^^^^^^^^^^^^^^^ expected `u32`, found enum `Result`
  |           |
  |           expected due to this
  |
  = note: expected type `u32`
             found enum `Result<File, std::io::Error>`

For more information about this error, try `rustc --explain E0308`.
error: could not compile `try` due to previous error

错误会告诉你, 返回值应该是 Result<File, std::io::Error>
这就是典型的返回结构, Result<File, std::io::Error>, File则是调用成功后返回的文件句柄, Error 则是错误信息, 我们可以通过枚举来进行分支处理(match 表达式)

use std::fs::File;
   
fn main() {
   let f =File::open("a.txt");
   let _f = match f {
	   Ok(file) => {file},
	   Err(error) => panic!("open file error: {:?}", error),
   };
   print!("OK");
}

需要注意的是Result枚举和成员也是默认导入到 prelude 中的, 所以无需通过 Result:: 来进行手动导入
这里, 我们对 f 进行枚举, 当 open 调用成功时, 进行Ok 中逻辑, 将 file 返回给 f, 当错误时, 调用Err 进行 panic 抛出.当我们本地没有 a.txt 时. 运行会报错

➜  try git:(master) ✗ cargo run
      Compiling try v0.1.0 (/Work/Code/Rust/student/try)
       Finished dev [unoptimized + debuginfo] target(s) in 0.30s
        Running `target/debug/try`
   thread 'main' panicked at 'open file error: Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:7:23
   note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

错误很好的告诉了我们没有这个文件

匹配不同的错误

我们还是希望对错误进行分别处理, 当没有文件时, 我们希望能自己创建这个文件, 返回句柄, 而因为其他原因失败, 触发panic, 我们就需要借用 ErrorKind 来判断具体的错误并进行处理

use std::fs::File;
use std::io::ErrorKind;

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

    let f = match f {
        Ok(file) => file,  // success
        Err(error) => match error.kind() {  // kind 返回错误
            // 是 NotFound 错误, 文件不存在
            ErrorKind::NotFound => match File::create("hello.txt") {  // 创建文件, 枚举创建是否成功
                Ok(fc) => fc,  // 返回文件句柄
                Err(e) => panic!("Problem creating the file: {:?}", e),  // 创建文件失败, panic
            },
            other_error => {
                // 其他错误
                panic!("Problem opening the file: {:?}", other_error)
            }
        },
    };
}

io::ErrorKind是标准库中的枚举, 包含了io 操作各种可能错误, 例如文件找不到, 就对应了 ErrorKind::NotFound
我们这里使用了3次 match, 可以看到, 代码嵌套比较难懂, 之后我们会学习闭包, 可以讲这种代码使用闭包进行简化, 例如

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let f = File::open("a.txt").unwrap_or_else(|error|{
        if error.kind()== ErrorKind::NotFound{
            File::create("a.txt").unwrap_or_else(|error|{
                panic!("{:?}", error);
            })
        }else{
            panic!("{:?}", error)
        }
    });
}

这样代码就变得简单了, 我们之后会学习闭包和unwrap_or_else 的用法

失败时 panic 的快捷方式

如果我们想要在返回错误时直接进行panic 抛出, 并打印错误信息, 有两个简写unwrapexpect
对于 unwrap, 如果调用成功, 则会返回Ok中的值, 如果错误则会为我们调用panic!

use std::fs::File;

fn main() {
    let f = File::open("a.txt").unwrap();
}

在错误时

    Finished dev [unoptimized + debuginfo] target(s) in 0.28s
     Running `target/debug/try`
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:4:33
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

expect 的区别是, 当错误时, 开发者可以指定携带一些自定义的错误信息, 方便我们定位错误

use std::fs::File;

fn main() {
    let f = File::open("a.txt").expect("open file error");
}

错误时

    Finished dev [unoptimized + debuginfo] target(s) in 0.21s
     Running `target/debug/try`
thread 'main' panicked at 'open file error: Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:4:33
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

可以看到, 其中的 open file error 是我们自定义的错误信息, 这样可以帮助我们更快的定位问题

传播错误

在我们自己编写函数时, 往往也需要把可能出现的错误返回给调用者, 让调用者知道函数内部发生了问题, 并进行处理, 这被称为 传播错误
作为被调用者, 我们很难明白调用者调用我们的意图, 所以, 将错误传播出去, 而不是我们内部触发 panic 或者其他操作是正确的处理方式

use std::{fs::File, io::{Read, self}};

fn read_file() -> Result<String, io::Error> {  // 返回字符串和io::Error
    let f = File::open("a.txt");  // 打开文件
    let mut f = match f {
        Ok(file) => file,  // 成功返回给 f
        Err(e) => return Err(e),  // 错误直接将函数退出并返回错误
    };
    let mut s= String::new();  // 新建字符串 s
    match f.read_to_string(&mut s) {  // read_to_string 将文件内容读取到 s 中
        Ok(_) => Ok(s),  // 成功返回 s, 因为代码块到最后了同时无变量接收, 所以这里直接感受结束了, 正确响应需要包一个 Ok, 符合枚举
        Err(e) => Err(e),  // 错误返回, Err 包含
    }
}

需要注意的是, 最后函数的返回需要使用 Ok 或者 Err 包含, 使其符合Result 结构

传播错误简写方式

传播错误是我们经常使用的开发方式, 所以 Rust 内置了 ? 运算符帮助我们简化传播错误的代码, 例如:

use std::{fs::File, io::{Read, self}};

fn read_file() -> Result<String, io::Error> {  // 返回字符串和io::Error
    let mut f = File::open("a.txt")?;  // 如果Ok 赋值给 f, 如果Err 直接 return Err(e)
    let mut s = String::new();
    f.read_to_string(& mut s)?;  // 如果 Ok 往下走, 如果Err 直接 return Err(e)
    Ok(s)  // 返回 Ok(s), 因为是最后一行代码所以无需写 return
}

? 将错误值传递给了标准库From, 其将错误值包装为指定的错误类型, 在这里是 io::Error 下面的错误类型, ?在我们只需要将错误返回而不是加以处理时非常的有用. 前提是错误类型实现了from 函数, 内置的宏都已经实现了这个函数, 因此可以直接调用.
?同样可以支持链式调用, 帮助我们进一步简化代码

use std::{fs::File, io::{Read, self}};

fn read_file() -> Result<String, io::Error> {  // 返回字符串和io::Error
    let mut s = String::new();
    File::open("a.txt")?.read_to_string(& mut s)?;  // 链式调用, 正确时才会进行链的下一节调用, 错误时直接返回, 不执行后的节
    Ok(s)  // 返回 Ok(s), 因为是最后一行代码所以无需写 return
}

fn main() {
    read_file().expect("read error");
}

链式调用可以帮助我们更进一步简化代码
同时, 针对这个简单的函数, 我们可以直接调用 fs::read_to_string 宏, rust 内置了一些方便的宏帮助我们进行简单的操作, 比如 fs::read_to_string 就是将文件内容读取并返回, 当出现错误时同样的是Result, 可以直接将错误传播, 当然, 大部分情况, 函数内部的逻辑不会这么简单 🧑

use std::{fs::{self}, io::{self}};

fn read_file() -> Result<String, io::Error> {  // 返回字符串和io::Error
    fs::read_to_string("a.txt")
}

fn main() {
    read_file().expect("read error");
}

哪里适合使用 ? 运算符

需要记住, ? 运算符支持在调用两种函数或者宏时使用, 一个是返回Result类型, 一个是返回Option<T>, 当Result类型时, ?会在Err时返回Err的值
而当Option<T>时, ?会在为None时返回None, 例如:

// 返回第一行的最后一个字符
fn last_char_of_first_line(text: &str) -> Option<char>{
    // 链式调用
    // lines() 返回每行数据的迭代器
    // next() 直接获取第一行的字符串, Option<str>, 当为空时返回 none
    // ? 如果 Option<T> 为none, 直接返回 none 
    // 如果有值, 转换成字符串 .chars
    // .last 也返回了 Option<char>, 因为是代码块最后了, 所以直接返回, 就算为 none 也符合要求, 需要注意类型要对的上
    text.lines().next()?.chars().last()
}

因此, 如果函数的返回是 Option<T>Result, 并且想要在调用失败时直接将错误或者 none 返回, 就可以使用 ? 来将代码简化.

在 main 中使用 ? 运算符

上面说过, ?的前提是函数必须返回Option<T> 或者Result 类型, 那么main函数默认是没有返回值的, 我们想要在 main 中使用? , 就必须这样写:

use std::error::Error;
use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
    let f = File::open("hello.txt")?;

    Ok(())
}

我们可以手动指定返回值为 Result, Result<(), Box<dyn Error>>, 其中, ()代表空的Ok, 返回时可以使用Ok(()) 返回成功, 此时程序以0结束
Box<dyn Error>> 类型是一个 trait 对象, 后续会进行讲解, 现在, 你可以认为Box<dyn Error>> 代表可以是任何错误类型, 这样就兼容了所有的Err 返回, 此时程序以负数结束.

什么时候应该使用 panic! 报错

之前说过, panic! 会导致程序结束, 所以调用panic!应该是慎重的. 而且作为被调用者, 尤其是函数内部, 直接panic! 很有可能不是调用者预期的处理方式, 因此默认情况下, 函数返回Result是最合适的, 当然也有例外

这段代码是 示例/demo/原型/测试 代码建议使用 panic!

panic会导致代码停止, 同时携带可以expect让读者更好的排查问题, 同时读者应该知道这是测试代码, 读者不应该期待测试代码的稳定性

错误处理原则

当错误会导致程序运行出现有害的结果时建议使用panic!, 例如:

  • 发生了非预期的行为, 比如用户输入了错误格式的数据
  • 之后的代码在发生错误后无法继续正常运行, 比如程序启动时读取配置文件错误
  • 没有可行的措施来处理这个错误, 这个后续会用例子说明
  • 作为被调用者, 如果调用者传递了一个没有意义的值给你, 或者你是调用者, 调用的功能返回了一个你无法修复的无效状态

创建自定义类型来进行约束

在之前的猜数字游戏中, 我们要求用户输入一个1到100之间的数字, 而假如将这个封装为一个函数, 我们如何告诉调用者, 需要传入1到100之间的数字呢?
首先想到的是, 我们通过自己判断来进行处理

fn test(guess: i32){
    // 判断数字符合要求
    if guess > 100 {
        // 不符合
        // 处理....
    }
    // 符合, 下一步...
}

因为默认的数据类型中, 并没有一个可以满足我们的 大于0小于101的需求, 所以, 我们需要通过每次判断参数来处理, 其实我们还可以通过自定义数据类型, 来从参数类型方面进行约束.


pub struct Guess {
    value: i32,  // 成员 value, 代表真正的值
}

impl Guess {
    pub fn new(value: i32) -> Guess {  // new 新建一个guess
        if value < 1 || value > 100 {  // 判断是否符合要求
            panic!("Guess value must be between 1 and 100, got {}.", value);
        }
        Guess { value }  // 返回这个 guess
    }

    pub fn value(&self) -> i32 {  // 获取 value, &self 代表自己
        self.value  // 返回 value
    }
}
fn test(guess: Guess){
    println!("{}", guess.value())  // 获取值
}

fn main() {
    let g = Guess::new(100);  // 正常
    // let g1 = Guess::new(1000);  // 会panic, 因为被拦截
    test(g);
}

我们自定义了一个结构体Guess, 他有一个字段value, 里面保存值
接着, 实现Guess 的关联函数new 接收i32 并判断是否符合我们的自定义类型需求, 没有通过则触发panic! , 通知我们的开发者这是一个需要修改的 bug, 至少不应该让程序崩溃. 这个方法返回 Guess 类型的值.
然后, 实现了一个value方法(注意和字段 value 不是一个东西), 接收一个参数&self, 没有任何其他参数, 这种方法有时候被称为getter, 目的是返回对应字段的数据, 因为Guess的字段value是私有的, 只能通过公开的value 方法来获取值. 这样做的目的是为了防止调用者修改字段 value 的值, 假如说调用者通过 new 生成了一个Guess 值, 再自己修改 value 为大于100的数字, 再调用我们的 test 方法, 就会导致我们的程序发生错误, 因此, 使用设置私有变量再通过公开方法来获取值的方法来保证安全和代码运行正常是有必要的.
因此, 在 test 函数中, 我们无需再对参数进行检查了.

总结

Rust 的错误处理功能被设计为帮助你编写更加健壮的代码, panic! 代表程序发生了无法处理的错误, Result则让我们可以处理可恢复的错误

posted @ 2023-01-05 20:58  ChnMig  阅读(154)  评论(0编辑  收藏  举报