1. 程式人生 > >簡談 Rust 中的錯誤處理

簡談 Rust 中的錯誤處理

在學習 Rust 的過程中,錯誤處理是一個必需要邁過的坎。主要原因是所有的標準庫都以統一的方式處理錯誤,我們就來談一談 Rust 中是如何處理錯誤的吧。

Rust Book 對 rust 中的錯誤處理有詳細的講解,本文對其中一些選擇背後的原因進行了思考和總結。強烈建議先看原文。

#返回錯誤與異常處理

名正則言順,我們先說說什麼是“錯誤”,什麼是“異常”:

  • 錯誤:執行時發生的不尋常的、 超出預期 的行為,這些問題只能通過修改程式來解決。例如記憶體不足。
  • 異常 :執行時發生的不規則的、 意料之內 的行為。例如嘗試讀取“讀保護”的檔案。

可以看到,“錯誤”與“異常”的區別是“意料之內”還是“之外”。因此,本文中所說的“錯誤”其實都指的是異常(這也是 Java 中既存在異常 Exception 又存在 Error 的原因)。

在 C 語言中,錯誤處理的機制是十分簡陋的,例如 Linux 的系統呼叫如果出錯,會將錯誤記錄在一個全域性變數 errno 中,errno 是一個整型值,作業系統事先約定好不同值代表不同含義。

到了 C++/Java/Python 語言則採用了異常處理機制,當函式錯誤時,可以丟擲預定義或自定義的異常,語言本身提供了捕獲這個異常/錯誤的語法(即 try ... catch ...

異常處理相比於返回錯誤的好處是分離了接收和處理錯誤的程式碼。如果只用 C 語言的方式,則函式的返回值需要有一部分用於表示錯誤。例如 read 函式 在出錯時返回 -1;正確時返回 0 或以上,而函式的呼叫者必須自己區分正確也錯誤的情形。還有一些更壞的情況,例如一個除法函式,它返回的任何值理論上都可能是“正確值”。那麼當發生除 0 錯誤時,它應該返回什麼值來表示錯誤呢?

在寫作本文時,我也倍受困擾,“返回錯誤”的方式明明一無是處,為什麼 Rust 還要選擇這種方式呢? 這篇文章 中提出的觀點是:Rust 是一門相對底層的語言,因此在某些情況下,異常處理所需要的額外效能開銷是不可接受的。或許這就是 Rust 不包含異常的原因吧。

#Option

首先要注意到 Rust 中是沒有 null 的概念的,我們無法像其它語言(如 C++/java)一樣建立一個變數,並賦值為 null 來代表變數當前沒有內容。在 Rust 中,做不到!

於是 Rust 自定義了一個結構體來表示可能為空的情形,這應該是向 Haskell 的 Maybe 借鑑的吧。結構體長這樣:

pub enum
Option
<T> {
None, Some(T),}

這樣,當你想表示 null 時就可以用 None 代替。而其它的賦值則可以用 Some(...) 完成。帶來的問題是:如何訪問 Some(...) 裡的內容呢?Rust 的答案是 pattern matching:

match opt {    Some(value) => println!("value = {}", value),    None => println!("Got None"),}

而由於 match 會保證我們列出了所有可能的 pattern,即不允許只處理 Some 而不處理 None,因此保證了程式設計師必定處理了值為 null 的情形。就說機不機智。

不過事實是程式設計師都懶啊,如果我明確知道不可能出現為 null 的情況,還需要寫一堆的 match,著實鬧心,於是 rust 又為我們開了小灶,提供了 unwrap 函式:

impl<T> Option<T> {    fn unwrap(self) -> T {        match self {            Option::Some(val) => val,            Option::None =>              panic!("called `Option::unwrap()` on a `None` value"),        }    }}

注意這裡的 panic!,它的作用是輸出錯誤的資訊並退出程式(嚴格地說並不一定退出程式,rust 1.9 添加了 catch_unwind 支援)。所以可以通過呼叫 option.unwrap() 來獲取 option 中包裹的值。言下之意就是:你說不可能出現 null 是吧,我且相信你,但如果出了問題我就不管了。

當然,使用 Option 的過程中還有其它一些問題,例如,程式設計師知道可能出現 None 的情況,當出現時使用一個預設的值。這種情況 rust 提供了函式 unwrap_or(default) 來方便書寫。再例如兩個函式都返回 Option,我們想將一個函式的輸出作為另一個函式的輸入,此時可以使用 and_then 來減少手寫 match 的次數。

還有一些其它的情況可以參考 官方文件

#Result: Option 加強版

Option 可以用來表示 null 的情形,這解決了前文提到的一個問題,如果除法函式發生了除 0 操作,返回什麼值來表示發生錯誤了?有了 Option 我們可以返回 None

但如果可能發生多個錯誤呢?這時,Option 可以認為只能表示發生一個錯誤的情形。於是 Rust 提出了另一個結構,用於包裹真正的結果:

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

其實就是表示了兩種可能,如果沒有錯誤,則返回 Ok(..),反之返回 Err(..)。而由於 Err 可以帶引數,所以即使發生了多個錯誤也能正常表示。甚至,我們可以將 Option 定義為:

type Option<T> = Result<T, ()>;

它和上節中的 Option 在作用上是等價的。另一方面,我們也看到,其實 rust 處理錯誤就是返回不同的結構體,某些表示正確,某些表示錯誤,我們甚至可以拋開這些結構,直接用 tuple 來表示:

type Result<T, E> = (T, E);

這樣的話,是不是和 Go 語言又很相似了呢?所以這裡要強調的是,返回錯誤的重點在於“返回”,也就是說,錯誤也是“正常值”的一種。

我們馬上又要回到了 Option 的老路了,但這之前,我們發現 Err(E) 中,E 可以是任意型別,也就是說我們可以將錯誤指定為任意型別。我們先指定為 i32 來模仿 C 中的 errno

fn read(...) -> Result<usize, i32> {    if size >= 0 {        return Ok(size);    } else {        return Err(errno);    }}

而如果呼叫者對發生的錯誤感興趣,則可以繼續用 pattern matching 來解構:

match read(...) {    Ok(size) => ...    Err(1) => ... file not found ...    Err(2) => ... is directory ...    ...}

當然,像 Option 一樣,如果程式設計師對發生的錯誤不感興趣,rust 也提供了 unwrap 方法來避免手寫 match

要注意的是,無論是 Option 還是 Result,它們更像是一種約定,而不是機制。假設你是 API 的提供者,你當然也可以按你自己喜歡的方式返回錯誤。而關於 OptionResult,重要的是標準庫的所有函式都遵守這樣的約定,也因此對它們的支援相比你自定義的型別要豐富,這也是我們最好遵守這種約定的主要原因。

#錯誤傳遞

上面說了半天,其實依舊沒有提及如何表示“錯誤”本身。無論是 Option 還是 Result 其實都只是“包裹”錯誤的容器罷了。那麼什麼才是“錯誤”呢?

上節其實提到了,在 Result 中,“錯誤”其實可以是任意型別。但下文我們會提到, rust 定義了一個 trait: Error。而之所以需要這個定義,是因為我們在錯誤傳遞上遇到了問題。

想像一下,當你呼叫某個函式時,你不在乎它們會產生什麼錯誤,無論錯誤是什麼,你只想把它們往外丟,就像異常處理裡的 throw 一樣。考慮 下面例子

use std::fs::File;use std::io::Read;use std::path::Path;fn file_double<P: AsRef<Path>>(file_path: P) -> i32 {    let mut file = File::open(file_path).unwrap(); // error 1    let mut contents = String::new();    file.read_to_string(&mut contents).unwrap(); // error 2    let n: i32 = contents.trim().parse().unwrap(); // error 3    2 * n}fn main() {    let doubled = file_double("foobar");    println!("{}", doubled);}

第一個遇到的問題就是:呼叫的函式會返回不同型別的錯誤,如果我們要丟擲錯誤,要將它們定義成什麼型別?眉頭一皺,計上心頭。定義成 String 不就行了?於是我們將程式碼改寫成:

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, String> {    let mut file = match File::open(file_path) {        Ok(file) => file,        Err(err) => return Err(err.to_string()),    };    let mut contents = String::new();    if let Err(err) = file.read_to_string(&mut contents) {        return Err(err.to_string());    }    let n: i32 = match contents.trim().parse() {        Ok(n) => n,        Err(err) => return Err(err.to_string()),    };    Ok(2 * n)}

可以看到,我們手工地將各種錯誤通過 err.to_string() 轉成 String 型別並返回。回想一下我們的初衷,就是在 file_double 中我們不想處理呼叫子函式時產生的任何錯誤,我們認為應該讓呼叫者處理,可由於返回值要統一,因此我們把它轉換成 String 型別後再返回。

第二個問題是:我們手寫了許多的 match 語句來解構返回值,浪費時間,降低程式碼的可讀性,這個問題可以通過寫一個巨集來解決。

#try! 巨集

為了解決上節的第二個問題,我們定義了一個巨集,命名為 try!,如下:

macro_rules! try {    ($e:expr) => (match $e {        Ok(val) => val,        Err(err) => return Err(err),    });}

有了它,上節的程式碼就可以寫成:

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, String> {    let mut file = try!(File::open(file_path).map_err(|e| e.to_string()));    let mut contents = String::new();    try!(file.read_to_string(&mut contents).map_err(|e| e.to_string()));    let n = try!(contents.trim().parse::<i32>().map_err(|e| e.to_string()));    Ok(2 * n)}

其中的 .map_err(|e| e.to_string()) 做的是將 err 轉成 String 型別。可以看到,程式碼一下簡短了許多。然而我們寫了許多 .map_err(..) 來轉換型別也著實醜陋,下面就來解決這個問題。

#Error Trait

把錯誤轉換成 String 返回有一個不足,就是我們失去了錯誤原本的型別資訊,不利於函式的呼叫者再針對錯誤的型別做不同的處理。於是 Rust 為我們定了一個統一的型別來表示錯誤:

use std::fmt::{Debug, Display};trait Error: Debug + Display {  /// A short description of the error.  fn description(&self) -> &str;  /// The lower level cause of this error, if any.  fn cause(&self) -> Option<&Error> { None }}

如果所有的錯誤全都實現了 Error trait,則我們很容易就能建立自己的錯誤型別,目的則是統一函式裡會發生的錯誤,繼續上節的例子,我們首先定義自己的型別:

use std::io;use std::num;// We derive `Debug` because all types should probably derive `Debug`.// This gives us a reasonable human readable description of `CliError` values.#[derive(Debug)]enum CliError {    Io(io::Error),    Parse(num::ParseIntError),}
  • File::open(file_path) 會返回 io::Error 型別,通過 CliError::Io 可以轉換成 CliError
  • file.read_to_stringFile::open 類似,也返回 io::Error 的錯誤。
  • String::parse 則返回的是 num::ParseIntError 型別,能通過 CliError::Parse 轉換成 CliError 型別。

當然,為了保證與其它型別的相容性,我們也需要為 CliError 實現 Error triat:

use std::error;use std::fmt;impl fmt::Display for CliError {    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {        match *self {            // Both underlying errors already impl `Display`, so we defer to            // their implementations.            CliError::Io(ref err) => write!(f, "IO error: {}", err),            CliError::Parse(ref err) => write!(f, "Parse error: {}", err),        }    }}impl error::Error for CliError {    fn description(&self) -> &str {        // Both underlying errors already impl `Error`, so we defer to their        // implementations.        match *self {            CliError::Io(ref err) => err.description(),            CliError::Parse(ref err) => err.description(),        }    }    fn cause(&self) -> Option<&error::Error> {        match *self {            // N.B. Both of these implicitly cast `err` from their concrete            // types (either `&io::Error` or `&num::ParseIntError`)            // to a trait object `&Error`. This works because both error types            // implement `Error`.            CliError::Io(ref err) => Some(err),            CliError::Parse(ref err) => Some(err),        }    }}

可見,只要每個錯誤型別都實現了 Error trait,則很容易通過建立新的自定義型別來統一錯誤型別。

#From trait

Error trait 雖然統一了錯誤型別,但我們依舊要寫一堆 .map_err(...) 來轉換型別,有沒有什麼更好的方法呢?rust 定義了一個通用的 triat 用於轉換型別:

trait From<T> {    fn from(T) -> Self;}

再次重申,有點型別於 Java 中的 interfacetrait 只是一種“約定”,而約定之所以有用,是因為 rust 的標準庫都遵守了這個約定。如 From 要求型別實現從其它型別的轉換函式,例如你可以做下面的操作:

let string: String = From::from("foo");let bytes: Vec<u8> = From::from("foo");let cow: ::std::borrow::Cow<str> = From::from("foo");

這是因為標準庫中的 String 型別已經實現了 From<&str>,另外幾個也類似。

那麼為什麼上節中我們自定義的錯誤型別要實現 Error trait 呢?其中一個重要原因是標準庫已經為 Box<Error> 實現了 From trait:

impl<'a, E: Error + 'a> From<E> for Box<Error + 'a>

也因此我們可以用 From::from 來進行錯誤型別間的轉換如下:

// We have to jump through some hoops to actually get error values.let io_err: io::Error = io::Error::last_os_error();let parse_err: num::ParseIntError = "not a number".parse::<i32>().unwrap_err();// OK, here are the conversions.let err1: Box<Error> = From::from(io_err);let err2: Box<Error> = From::from(parse_err);

因此,有了 ErrorFrom 兩個 trait 及標準庫對兩個 trait 的實現,try! 巨集的真正實現方式就進化了:

macro_rules! try {    ($e:expr) => (match $e {        Ok(val) => val,        Err(err) => return Err(::std::convert::From::from(err)),    });}

有了這兩個工具,我們就可以:

  1. 不定義自己的型別,而直接使用 Box<Error> 來統一錯誤型別。
  2. try! 巨集來傳遞錯誤。
fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, Box<Error>> {    let mut file = try!(File::open(file_path));    let mut contents = String::new();    try!(file.read_to_string(&mut contents));    let n = try!(contents.trim().parse::<i32>());    Ok(2 * n)}

完美!並且,在 rust 1.13 中加入了 ? 操作符,用來替代 try! 因此可以這麼寫:

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, Box<Error>> {    let mut file = File::open(file_path)?;    let mut contents = String::new();    file.read_to_string(&mut contents)?;    let n = contents.trim().parse::<i32>()?;    Ok(2 * n)}

#統一自定義錯誤型別

最後一個大問題是自定義錯誤型別。有了 From trait 之後,我們可以輕易地將任意實現了 Error trait 的錯誤轉換成 Box<Error>,但如果我們要返回的不是 Box<Error> 而是自定義錯誤,那要怎麼辦呢?答案也很簡單,為可能出現的錯誤實現 From trait。

上幾節的例子中,可能出現的錯誤為 io::Errornum::ParseIntError,因此我們需要為 CliError 實現 From<io::Error>From<num::ParseIntError>。如下:

use std::io;use std::num;impl From<io::Error> for CliError {    fn from(err: io::Error) -> CliError {        CliError::Io(err)    }}impl From<num::ParseIntError> for CliError {    fn from(err: num::ParseIntError) -> CliError {        CliError::Parse(err)    }}

有了上述的實現,我們就可以寫出如下程式碼:

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, CliError> {    let mut file = try!(File::open(file_path));    let mut contents = String::new();    try!(file.read_to_string(&mut contents));    let n: i32 = try!(contents.trim().parse());    Ok(2 * n)}

終於搞定了!

#如何處理錯誤?

綜上,在 rust 語言中,處理錯誤有幾種方式:

對於函式的作者而言,返回值可以是:

  1. 正常的值,即 i32, String 等等,表明該函式不可能發生錯誤。
  2. 返回 Option 表示函式可能會失敗。
  3. 不自定義錯誤。返回 Result<..., Box<Error>>
  4. 返回自定義錯誤,如上例中的 Result<i32, CliError>

而當函式 A 呼叫的子函式 B 返回錯誤時,有幾種處理的方式:

  1. 不處理錯誤。即呼叫 unwrap 來獲取返回資料。
  2. 在函式 A 內部處理。即通過 match 語句或 unwrap_or 等函式來處理返回值可能包含錯誤的情況。
  3. 當函式 A 返回值為 ResultB 的返回值也為 Result 時,可以通過 try!(B()) 來獲得 B 的返回值。而若返回值為 Err 時,try! 會自動退出函式 A 並將錯誤進行處理後返回。

最後,當函式的作用決定自定義錯誤型別(如 CliError)時,需要做幾項操作:

  1. 實現 Error trait。即實現 descriptioncause 函式,來提供錯誤的內容。
  2. 為可能發生的錯誤實現 From trait。如上文中 CliError 實現了 From<io::Error>From<num::ParseIntError>

上述兩項工作完成後就可以放心地使用 try! 來獲取子函式返回值的內容了。

#小結

本文首先區別介紹了“返回錯誤”和“異常處理”的區別。Rust 選擇了“返回錯誤”的道路,本文也因此介紹了它面臨了幾個問題:

  1. 如何表示返回值有錯誤?Rust 提供了 OptionResult 這兩個“容器”來滿足不同需求。
  2. 呼叫不同子函式可能返回不同錯誤型別,於是使用 Error trait 來統一型別。
  3. 解構返回值需要寫大量 match 語句,Rust 引入巨集 try! 來減少工作量。
  4. 不同錯誤型別間的轉換需要寫很多程式碼,Rust 引入 From trait 來減少程式設計師的輸入。

最後,若使用者需要自定義錯誤型別,它需要同時實現 ErrorFrom 兩個 trait.

與其它語言對比,rust 的錯誤處理是相當地複雜。其中的重要原因是它更像是一種高層的約定,而非語言層面的機制,換句話說,你用其它的語言也能實現類似的功能。

由於我寫過的 rust 程式都不大,並且沒有寫過庫,因此對這套錯誤處理方式的優點並不是特別“感同深受”,也許它更適合大型程式的開發吧。

#Reference