Result
與可復原的錯誤
大多數的錯誤沒有嚴重到需要讓整個程式停止執行。有時候當函式失敗時,你是可以輕易理解並作出反應的。舉例來說,如果你嘗試開啟一個檔案,但該動作卻因為沒有該檔案而失敗的話,你可能會想要建立檔案,而不是終止程序。
回憶一下第二章的「使用 Result
型別可能的錯誤」提到 Result
列舉的定義有兩個變體 Ok
和 Err
,如以下所示:
#![allow(unused)] fn main() { enum Result<T, E> { Ok(T), Err(E), } }
T
和 E
是泛型型別參數,我們會在第十章深入討論泛型。你現在需要知道的是 T
代表我們在成功時會在 Ok
變體回傳的型別,而 E
則代表失敗時在 Err
變體會回傳的錯誤型別。因為 Result
有這些泛型型別參數,我們可以將 Result
型別和它的函式用在許多不同場合,讓成功與失敗時回傳的型別不相同。
讓我們呼叫一個可能會失敗的函式並回傳 Result
型別。在範例 9-3 我們嘗試開啟一個檔案。
檔案名稱:src/main.rs
use std::fs::File; fn main() { let greeting_file_result = File::open("hello.txt"); }
File::open
的回傳型別為 Result<T, E>
。泛型參數 T
在此已經被 File::open
指明成功時會用到的型別 std::fs::File
,也就是檔案的控制代碼(handle)。用於錯誤時的 E
型別則是 std::io::Error
。這樣的回傳型別代表 File::open
的呼叫在成功時會回傳我們可以讀寫的檔案控制代碼,但該函式呼叫也可能失敗。舉例來說,該檔案可能會不存在,或者我們沒有檔案的存取權限。File::open
需要有某種方式能告訴我們它的結果是成功或失敗,並回傳檔案控制代碼或是錯誤資訊。這樣的資訊正是 Result
列舉想表達的。
如果 File::open
成功的話,變數 greeting_file_result
的數值就會獲得包含檔案控制代碼的 Ok
實例。如果失敗的話,greeting_file_result
的值就會是包含為何產生該錯誤的資訊的 Err
實例。
我們需要讓範例 9-3 的程式碼依據 File::open
回傳不同的結果採取不同的動作。範例 9-4 展示了其中一種處理 Result
的方式,我們使用第六章提到的 match
表達式。
檔案名稱:src/main.rs
use std::fs::File; fn main() { let greeting_file_result = File::open("hello.txt"); let greeting_file = match greeting_file_result { Ok(file) => file, Err(error) => panic!("開啟檔案時發生問題:{:?}", error), }; }
和 Option
列舉一樣,Result
列舉與其變體都會透過 prelude 引入作用域,所以我們不需要指明 Result::
,可以直接在 match
的分支中使用 Ok
和 Err
變體。
當結果是 Ok
時,這裡的程式碼就會回傳 Ok
變體中內部的 file
,然後我們就可以將檔案控制代碼賦值給變數 greeting_file
。在 match
之後,我們就可以使用檔案控制代碼來讀寫。
match
的另一個分支則負責處理我們從 File::open
中取得的 Err
數值。在此範例中,我們選擇呼叫 panic!
巨集。如果檔案 hello.txt 不存在我們當前的目錄的話,我們就會執行此程式碼,接著就會看到來自 panic!
巨集的輸出結果:
$ cargo run
Compiling error-handling v0.1.0 (file:///projects/error-handling)
Finished dev [unoptimized + debuginfo] target(s) in 0.73s
Running `target/debug/error-handling`
thread 'main' panicked at '開啟檔案時發生問題:Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:8:23
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
如往常一樣,此輸出告訴我們哪裡出錯了。
配對不同種的錯誤
範例 9-4 的程式碼不管 File::open
為何失敗都會呼叫 panic!
。不過我們想要依據不同的錯誤原因採取不同的動作,如果 File::open
是因為檔案不存在的話,我們想要建立檔案並回傳新檔案的控制代碼。如果 File::open
是因為其他原因失敗的話,像是我們沒有開啟檔案的權限,我們仍然要像範例 9-4 這樣呼叫 panic!
。對此我們可以加上 match
表達式,如範例 9-5 所示。
檔案名稱:src/main.rs
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let greeting_file_result = File::open("hello.txt");
let greeting_file = match greeting_file_result {
Ok(file) => file,
Err(error) => match error.kind() {
ErrorKind::NotFound => match File::create("hello.txt") {
Ok(fc) => fc,
Err(e) => panic!("建立檔案時發生問題:{:?}", e),
},
other_error => {
panic!("開啟檔案時發生問題:{:?}", other_error);
}
},
};
}
File::open
在 Err
變體的回傳型別為 io::Error
,這是標準函式庫提供的結構體。此結構體有個 kind
方法讓我們可以取得 io::ErrorKind
數值。標準函式庫提供的列舉 io::ErrorKind
有從 io
運算可能發生的各種錯誤。我們想處理的變體是 ErrorKind::NotFound
,這指的是我們嘗試開啟的檔案還不存在。所以我們對 greeting_file_result
配對並在用 error.kind()
繼續配對下去。
我們從內部配對檢查 error.kind()
的回傳值是否是 ErrorKind
列舉中的 NotFound
變體。如果是的話,我們就嘗試使用 File::create
建立檔案。不過 File::create
也可能會失敗,所以我們需要第二個內部 match
表達式來處理。如果檔案無法建立的話,我們就會印出不同的錯誤訊息。第二個分支的外部 match
分支保持不變,如果程式遇到其他錯誤的話就會恐慌。
除了使用
match
配對Result<T, E>
以外的方式我們用的
match
的確有點多!match
表達式雖然很實用,不過它的行為非常基本。在第十三章你會學到閉包(closure),Result<T, E>
型別有很多接收閉包的方法。使用這些方法可以讓你的程式碼更簡潔。舉例來說,以下是另一種能寫出與範例 9-5 邏輯相同的程式碼,這次則是使用到閉包與
unwrap_or_else
方法:use std::fs::File; use std::io::ErrorKind; fn main() { let greeting_file = File::open("hello.txt").unwrap_or_else(|error| { if error.kind() == ErrorKind::NotFound { File::create("hello.txt").unwrap_or_else(|error| { panic!("建立檔案時發生問題:{:?}", error); }) } else { panic!("開啟檔案時發生問題:{:?}", error); } }); }
雖然此程式碼的行為和範例 9-5 一樣,但他沒有包含任何
match
表達式而且更易閱讀。當你讀完第十三章後,別忘了回來看看此範例,並查閱標準函式庫中的unwrap_or_else
方法。除此方法以外,還有更多方法可以來解決處理錯誤時龐大的match
表達式。
錯誤發生時產生恐慌的捷徑:unwrap
與 expect
雖然 match
已經足以勝任指派的任務了,但它還是有點冗長,而且可能無法正確傳遞錯誤的嚴重性。Result<T, E>
型別有非常多的輔助方法來執行不同的特定任務。unwrap
就和我們在範例 9-4 所寫的 match
表達式一樣,擁有類似效果的捷徑方法。如果 Result
的值是 Ok
變體,unwrap
會回傳 Ok
裡面的值;如果 Result
是 Err
變體的話,unwrap
會呼叫 panic!
巨集。以下是使用 unwrap
的方式:
檔案名稱:src/main.rs
use std::fs::File; fn main() { let greeting_file = File::open("hello.txt").unwrap(); }
如果我們沒有 hello.txt 這個檔案並執行此程式碼的話,我們會看到從 unwrap
方法所呼叫的 panic!
回傳訊息:
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:49
還有另一個方法 expect
和 unwrap
類似,不過能讓我們選擇 panic!
回傳的錯誤訊息。使用 expect
而非 unwrap
並提供完善的錯誤訊息可以表明你的意圖,讓追蹤恐慌的源頭更容易。expect
的語法看起來就像這樣:
檔案名稱:src/main.rs
use std::fs::File; fn main() { let greeting_file = File::open("hello.txt") .expect("hello.txt 應該要存在此專案中"); }
我們使用 expect
的方式和 unwrap
一樣,不是回傳檔案控制代碼就是呼叫 panic!
巨集。使用 expect
呼叫 panic!
時的錯誤訊息會是我們傳遞給 expect
的參數,而不是像 unwrap
使用 panic!
預設的訊息。訊息看起來就會像這樣:
thread 'main' panicked at 'hello.txt 應該要存在此專案中: Os {
code: 2, kind: NotFound, message: "No such file or directory" }',
src/main.rs:5:10
在正式環境等級的程式碼,大多數 Rustaceans 會選擇 expect
而不是 unwrap
,這樣能在出錯時提供更多資訊,告訴我們為何預期該動作永遠成功。這樣一來就算你的假設證明錯誤,你都能夠在除錯時有足夠的資訊來理解。
傳播錯誤
當函式實作呼叫的程式碼可能會失敗時,與其直接在該函式本身處理錯誤,你可以回傳錯誤給呼叫此程式的程式碼,由它們決定如何處理。這稱之為傳播(propagating)錯誤並讓呼叫者可以有更多的控制權,因為比起你程式碼當下的內容,回傳的錯誤可能提供更多資訊與邏輯以利處理。
舉例來說,範例 9-6 展示了一個從檔案讀取使用者名稱的函式。如果檔案不存在或無法讀取的話,此函式會回傳該錯誤給呼叫該函式的程式碼。
檔案名稱:src/main.rs
#![allow(unused)] fn main() { use std::fs::File; use std::io::{self, Read}; fn read_username_from_file() -> Result<String, io::Error> { let username_file_result = File::open("hello.txt"); let mut username_file = match username_file_result { Ok(file) => file, Err(e) => return Err(e), }; let mut username = String::new(); match username_file.read_to_string(&mut username) { Ok(_) => Ok(username), Err(e) => Err(e), } } }
此函式還能再更簡化,但我們要先繼續手動處理來進一步探討錯誤處理,最後我們會展示最精簡的方式。讓我們先看看此函式的回傳型別 Result<String, io::Error>
。這代表此函式回傳的型別為 Result<T, E>
,其中泛型型別 T
已經指明為實際型別 String
,而泛型型別 E
則指明為實際型別 io::Error
。
如果函式正確無誤的話,程式碼會呼叫此函式並收到擁有 String
的 Ok
數值。如果程式遇到任何問題的話,呼叫此函式的程式碼就會獲得擁有包含相關問題發生資訊的 io::Error
實例的 Err
數值。我們選擇 io::Error
作為函式的回傳值是因為它正是 File::open
函式和 read_to_string
方法失敗時的回傳的錯誤型別。
函式本體從呼叫 File::open
開始,然後我們使用 match
回傳 Result
數值,就和範例 9-4 的 match
類似。如果 File::open
成功的話,變數 file
中的檔案控制代碼賦值給可變變數 username_file
並讓函式繼續執行下去。但在 Err
的情形時,與其呼叫 panic!
,我們使用 return
關鍵字來讓函式提早回傳,並將 File::open
的錯誤值,也就是模式中的變數 e
,作為此函式的錯誤值回傳給呼叫的程式碼。
所以如果我們在 username_file
有拿到檔案控制代碼的話,接著函式就會在變數 username
建立新的 String
並對檔案控制代碼 username_file
呼叫 read_to_string
方法來讀取檔案內容至 username
。read_to_string
也會回傳 Result
因為它也可能失敗,就算 File::open
是執行成功的。所以我們需要另一個 match
來處理該 Result
,如果 read_to_string
成功的話,我們的函式就是成功的,然後在 Ok
回傳 username
中該檔案的使用者名稱。如果 read_to_string
失敗的話,我們就像處理 File::open
的 match
一樣回傳錯誤值。不過我們不需要顯式寫出 return
,因為這是函式中的最後一個表達式。
呼叫此程式碼的程式就會需要處理包含使用者名稱的 Ok
數值以及包含 io::Error
的 Err
數值。這交給呼叫的程式碼來決定如何處理這些數值。舉例來說,如果呼叫此程式碼而獲得錯誤的話,它可能選擇呼叫 panic!
讓程式崩潰,或者使用預設的使用者名稱從檔案以外的地方尋找該使用者。所以我們傳播所有成功或錯誤的資訊給呼叫者,讓它們能妥善處理。
這樣傳播錯誤的模式是非常常見的,所以 Rust 提供了 ?
來簡化流程。
傳播錯誤的捷徑:?
運算子
範例 9-7 是另一個 read_username_from_file
的實作,擁有和範例 9-6 一樣的效果,不過這次使用了 ?
運算子。
檔案名稱:src/main.rs
#![allow(unused)] fn main() { use std::fs::File; use std::io::{self, Read}; fn read_username_from_file() -> Result<String, io::Error> { let mut username_file = File::open("hello.txt")?; let mut username = String::new(); username_file.read_to_string(&mut username)?; Ok(username) } }
定義在 Result
數值後的 ?
運作方式幾乎與範例 9-6 的 match
表達式處理 Result
的方式一樣。如果 Result
的數值是 Ok
的話,Ok
內的數值就會從此表達式回傳,然後程式就會繼續執行。如果數值是 Err
的話,Err
就會使用 return
關鍵字作為整個函式的回傳值回傳,讓錯誤數值可以傳遞給呼叫者的程式碼。
不過範例 9-6 的 match
表達式做的事和 ?
運算子做的事還是有不同的地方:?
運算子呼叫所使用的錯誤數值會傳遞到 from
函式中,這是定義在標準函式庫的 From
特徵中,用來將數值從一種型別轉換另一種型別。當 ?
運算子呼叫 from
函式時,接收到的錯誤型別會轉換成目前函式回傳值的錯誤型別。這在當函式要回傳一個錯誤型別來代表所有函式可能的失敗是很有用的,即使可能會失敗的原因有很多種。
舉例來說,我們可以將範例 9-7 的函式 read_username_from_file
改成回傳一個我們自訂的錯誤型別叫做 OurError
。如果我們有定義 impl From<io::Error> for OurError
能從 io::Error
建立一個 OurError
實例的話,那麼 read_username_from_file
本體中的 ?
運算就會呼叫 from
然後轉換錯誤型別,不必在函式那多加任何程式碼。
在範例 9-7 中,在 File::open
的結尾中 ?
回傳 Ok
中的數值給變數 username_file
。如果有錯誤發生時,?
運算子會提早回傳整個函式並將 Err
的數值傳給呼叫的程式碼。同理也適用在呼叫 read_to_string
結尾的 ?
。
?
運算子可以消除大量樣板程式碼並讓函式實作更簡單。我們還可以再進一步將方法直接串接到 ?
後來簡化程式碼,如範例 9-8 所示。
檔案名稱:src/main.rs
#![allow(unused)] fn main() { use std::fs::File; use std::io::{self, Read}; fn read_username_from_file() -> Result<String, io::Error> { let mut username = String::new(); File::open("hello.txt")?.read_to_string(&mut username)?; Ok(username) } }
我們將建立新 String
的變數 username
移到函式的開頭,這部分沒有任何改變。再來與建立變數 username_file
的地方不同的是,我們直接將 read_to_string
串接到 File::open("hello.txt")?
的結果後方。我們在 read_to_string
呼叫的結尾還是有 ?
,然後我們還是在 File::open
和 read_to_string
成功沒有失敗時,回傳包含 username
的 Ok
數值。函式達成的效果仍然與範例 9-6 與 9-7 相同。這只是一個比較不同但慣用的寫法。
說到此函式不同的寫法,範例 9-9 展示了使用 fs::read_to_string
更短的寫法。
檔案名稱:src/main.rs
#![allow(unused)] fn main() { use std::fs; use std::io; fn read_username_from_file() -> Result<String, io::Error> { fs::read_to_string("hello.txt") } }
讀取檔案至字串中算是個常見動作,所以標準函式庫提供了一個方便的函式 fs::read_to_string
來開啟檔案、建立新的 String
、讀取檔案內容、將內容放入該 String
並回傳它。不過使用 fs::read_to_string
就沒有機會讓我們來解釋所有的錯誤處理,所以我們一開始才用比較長的寫法。
?
運算子可以用在哪裡?
?
運算子只能用在有函式的回傳值相容於 ?
使用的值才行。這是因為 ?
運算子會在函式中提早回傳數值,就像我們在範例 9-6 那樣用 match
表達式提早回傳一樣。在範例 9-6 中,match
使用的是 Result
數值,函式的回傳值必須是 Result
才能相容於此 return
。
讓我們看看在範例 9-10 的 main
函式中的回傳值要是不相容於我們用在 ?
的型別,如果我們使用 ?
運算子會發生什麼事:
use std::fs::File;
fn main() {
let greeting_file = File::open("hello.txt")?;
}
檔案名稱:src/main.rs
此程式碼會開啟檔案,所以可能會失敗。?
運算子會拿到 File::open
回傳的 Result
數值,但是此 main
函式的回傳值為()
而非 Result
。當我們編譯此程式碼時,我們會得到以下錯誤訊息:
$ cargo run
Compiling error-handling v0.1.0 (file:///projects/error-handling)
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
--> src/main.rs:4:48
|
3 | fn main() {
| --------- this function should return `Result` or `Option` to accept `?`
4 | let greeting_file = File::open("hello.txt")?;
| ^ cannot use the `?` operator in a function that returns `()`
|
= help: the trait `FromResidual<Result<Infallible, std::io::Error>>` is not implemented for `()`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `error-handling` due to previous error
此錯誤告訴我們只能在回傳型別為 Result
或 Option
或其他有實作 FromResidual
的型別的函式才能使用 ?
運算子。
要修正此錯誤的話,你有兩種選擇。其中一種是如果你沒有任何限制的話,你可以將函式回傳值變更成與 ?
運算子相容的型別。另一種則是依照可能的情境使用 match
或 Result<T, E>
其中一種方法來處理 Result<T, E>
。
錯誤訊息還提到了 ?
也能用在 Option<T>
的數值。就像 ?
能用在 Result
一樣,你只能在有回傳 Option
的函式中,對 Option
的值使用 ?
。在 Option<T>
呼叫 ?
的行為與在 Result<T, E>
上呼叫類似:如果數值為 None
,None
就會在函式該處被提早回傳;如果數值為 Some
,Some
中的值就會是表達式的結果數值,且程式會繼續執行下去。以下範例 9-11 的函式會尋找給予文字的第一行中最後一個字元:
fn last_char_of_first_line(text: &str) -> Option<char> { text.lines().next()?.chars().last() } fn main() { assert_eq!( last_char_of_first_line("Hello, world\nHow are you today?"), Some('d') ); assert_eq!(last_char_of_first_line(""), None); assert_eq!(last_char_of_first_line("\nhi"), None); }
此函式會回傳 Option<char>
,因為它可能會在此真的找到字元,或者可能根本沒有半個字存在。此程式碼接受引數 text
字串切片,並呼叫它的 lines
方法,這會回傳一個遍歷字串每一行的疊代器。因為此函式想要的是第一行,它對疊代器只呼叫 next
來取得疊代器的第一個數值。如果 text
是空字串的話,這裡 next
的呼叫就會回傳 None
。我們這裡就可以使用 ?
來中斷 last_char_of_first_line
並回傳 None
。如果 text
不是空字串的話,next
會用 Some
數值來回傳 text
的第一行字串切片。
?
會取出字串切片,然後我們可以對字串切片呼叫 chars
來取得它的疊代器。我們在意的是第一行的最後一個字元,所以我們呼叫 last
來取得疊代器的最後一個值。這也是個 Option
因為第一行可能是個空字串。如果 text
開頭就換行,但在下一行有字元的話,它可能就會是 "\nhi"
。不過如果第一行真的有最後一個字元的話,它就會回傳 Some
變體。在這過程中的 ?
運算子讓我們能簡潔地表達此邏輯,並讓我們能只用一行就能實作出來。如果我們對 Option
無法使用 ?
運算子的話,我們使用更多方法呼叫或 match
表達式才能實作此邏輯。
請注意你可以在有回傳 Result
的函式對 Result
的值使用 ?
運算子,你可以在有回傳 Option
的函式對 Option
的值使用 ?
運算子,但你無法混合使用。?
運算子無法自動轉換 Result
與 Option
之間的值。在這種狀況下會需要顯式表達,Result
的話有提供 ok
方法,Option
的話有提供 ok_or
方法。
目前為止,所有我們使用過的 main
函式都是回傳 ()
。main
是個特別的函式,因為它是可執行程式的進入點與出口點,而要讓程式可預期執行的話,它的回傳型別就得要有些限制。
幸運的是 main
也可以回傳 Result<(), E>
。範例 9-12 取自範例 9-10,不過我們更改 main
的回傳型別為Result<(), Box<dyn Error>>
,並在結尾的回傳數值加上 Ok(())
。這樣的程式碼是能編譯的:
use std::error::Error;
use std::fs::File;
fn main() -> Result<(), Box<dyn Error>> {
let greeting_file = File::open("hello.txt")?;
Ok(())
}
Box<dyn Error>
型別使用了特徵物件(trait object)我們會在第十七章的「允許不同型別數值的特徵物件」討論到。現在你可以將 Box<dyn Error>
視為它是「任何種類的錯誤」。在有 Box<dyn Error>
錯誤型別的 main
函式中的 Result
使用 ?
是允許的,因為現在 Err
數值可以被提早回傳。盡管此 main
函式本來只會回傳錯誤型別 std::io::Error
,但有了 Box<dyn Error>
的話,此簽名就能允許其他錯誤型別加入 main
本體中。
當 main
函式回傳 Result<(), E>
時,如果 main
回傳 Ok(())
的話,執行檔就會用 0
退出;如果 main
回傳 Err
數值的話,就會用非零數值退出。用 C 語言寫的執行檔在退出時會回傳整數:程式成功退出的話會回傳整數 0
,而程式退出錯誤的話則會回傳不是 0
的其他整數。而 Rust 執行檔也遵循相容這項規則。
main
函式可以回傳任何有實作 std::process::Termination
特徵的型別,該特徵包含了一個函式 report
來回傳 ExitCode
。你可以查閱標準函式庫技術文件來了解如何對你的型別實作 Termination
特徵。
現在我們已經討論了呼叫 panic!
與回傳 Result
的細節。現在讓我們回到何時該使用何種辦法的主題上吧。