Rust 程序設計語言(8)

title: Rust程序設計語言(8)              
date: 2023-01-03            
updated: 2023-01-05         
comments: true              
toc: true                   
excerpt: Rust錯誤處理
tags:                       
- Rust
categories:                 
- 編程

前言

本章介紹 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則讓我們可以處理可恢復的錯誤

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章