Rust エラー処理2020
このエントリは,Rust 3 Advent Calendar 2020の8日目の記事です.
はじめに
Rustを書いている時にアプリケーション固有のエラー型を定義したい場合があります.この辺のベストプラクティスは今まで何度か変化*1しており,今年の9月にエラーハンドリングのプロジェクトグループが発足*2したことからも分かるとおり,今後も変化していく可能性が濃厚です.
この記事では,エラー処理まわりに関する基本的な内容と,現時点でのベストプラクティスとされているanyhowとthiserrorを用いたエラー処理について紹介します.
エラー処理の基本
Result<T, E>
Rustには例外がなく,失敗するかもしれない関数は戻り値としてstd::result::Resultを返すことで呼び出し元にエラーを伝えます.std::result::Resultはenumとして定義されています.
pub enum Result<T, E> {
Ok(T),
Err(E),
}
戻り値の型を指定することで,独自に定義したエラー型を返すことができます.次の例だと,MyErrorは独自に定義したenumであり,hoge()はResult<(), MyError>型を返しています.
fn hoge() -> Result<(), MyError> {
// ...
return Err(MyError::HogeError(1));
}
enum MyError {
HogeError(u8),
FooError(String),
}
一方で,このような書き方をするとhoge()はResult<(), MyError>しか返すことができないため,複数種類のエラーを呼び出し元に返そうとした場合にコンパイルエラーとなります.
例えば次のような場合です.
fn hoge(filename: &str) -> Result<(), MyError> {
let f = File::open(filename)?; // エラーの場合Err(std::io::Error)が返る
// ...
return Err(MyError::HogeError(1)); // Err(MyError)が返る
}
コンパイラはこのコードを拒絶します.
error[E0277]: `?` couldn't convert the error to `MyError`
--> src/main.rs:16:33
|
15 | fn hoge(filename: &str) -> Result<(), MyError> {
| ------------------- expected `MyError` because of this
16 | let f = File::open(filename)?;
| ^ the trait `std::convert::From<std::io::Error>` is not implemented for `MyError`
|
= note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` trait
= note: required by `std::convert::From::from`
コードの実行パスごとに返る型が一致していないためエラーになります.(エラーメッセージでFromがどうこう言っているのは後ほど触れます.)
よって,戻り値にはトレイトを使い,関数内の全ての実行パスにおいて返されるエラー型がそのトレイトを実装するように変更します.このようにすることで,独自に実装したエラー型も含む複数種類のエラー型を1つの関数から返すことが可能になります.
Errorトレイト
標準ライブラリにstd::error::Errorトレイトが用意されているので,それを利用します.stdで定義されているエラー型(std::io::Errorやstd::num::ParseIntError)はこのトレイトを実装しているので,独自エラー型に対しても同様に実装します.
std::error::Errorの定義は次のようになっています.
pub trait Error: Debug + Display {
fn source(&self) -> Option<&(dyn Error + 'static)> { ... }
fn backtrace(&self) -> Option<&Backtrace> { ... }
fn description(&self) -> &str { ... }
fn cause(&self) -> Option<&dyn Error> { ... }
}
これらの4つのメソッドは全てデフォルト実装が存在するので,独自エラー型での実装は必須ではありません.さらに,backtraceはnightlyのみでの利用,descriptionとcauseは非推奨となっているので,stableチャネルで利用できるのは実質sourceメソッドのみとなっています.
トレイト境界を満たすためにDebugとDisplayは実装が必要です.Debugはderiveできるのでそれを利用します.
#[derive(Debug)]
enum MyError {
HogeError(u8),
FooError(String),
}
impl Display for MyError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
use self::MyError::*;
match self {
HogeError(i) => write!(f, "HogeError: {}", i),
FooError(s) => write!(f, "FooError: {}", s),
}
}
}
impl Error for MyError {}
あとは関数の戻り値の型をResult<(), Box<dyn Error>>とする*3ことで,MyErrorをエラー型として返すことができます.(追記も参照)
fn hoge(filename: &str) -> Result<(), Box<dyn Error>> {
let f = File::open(filename)?; // std::io::Errorを返す
// ...
Err(MyError::FooError("foo".to_string()))?; // MyErrorを返す
// ...
Ok(())
}
?オペレータ
ここで?オペレータについて補足しておきます.
let a = val?;
このように書いた場合,次のコードと等価です.
let a = match val {
Ok(v) => v,
Err(e) => return Err(From::from(e))
}
オペランドがErrの時はErr(From::from(e))をリターンします.Fromトレイトのメソッドが呼ばれていますが,どの型に実装したfromが呼ばれるかは関数の戻り値に記述した型を元に推論されます.先ほどのhoge()の例で言うと,次の2つのコードは等価になります*4.
Err(MyError::FooError("foo".to_string()))?;
return Err(Box::<dyn Error>::from(MyError::FooError("foo".to_string())));
ベストプラクティスを支えるクレート
先ほどのエラー処理には
- エラー発生時のコンテキストの情報が乏しい
- ボイラープレートを記述するのがめんどくさい
といった弱点があります.1つ目に関しては次のコードに対して実行結果を表示させてみます.
fn main() -> Result<(), Box<dyn Error>> {
hoge("./hoge.txt")?;
Ok(())
}
fn hoge(filename: &str) -> Result<(), Box<dyn Error>> {
let f = File::open(filename)?; // std::io::Errorを返す
// ...
Err(MyError::FooError("foo".to_string()))?; // MyErrorを返す
// ...
Ok(())
}
// MyError, Display, Errorの実装は同じ
./hoge.txtが存在しない時は次のように出力されます.
Os { code: 2, kind: NotFound, message: "No such file or directory" }
あっさりとしています.どのファイルが見つからなかったのかみたいな情報を付与したさがあります.
弱点の2つ目に関しても,独自エラー型に対するDisplayやErrorの実装は退屈であまり面白くはありません.
これらの弱点はそれぞれanyhowとthiserrorクレートで手軽に解決することができます.
anyhow
https://github.com/dtolnay/anyhow
anyhowはエラーにコンテキスト情報を含める便利メソッドや便利マクロ,エラー型などを提供するクレートです. anyhowを使ったコードは次のようになります.
use anyhow::{Context, Result};
fn hoge(filename: &str) -> Result<()> {
let f = File::open(filename).context(format!("failed to open file: {}", filename))?;
// ...
Err(MyError::FooError("foo".to_string()))?;
// ...
Ok(())
}
戻り値がanyhow::Result<()>になりました.この型はstd::result::Result<(), anyhow::Error>のエイリアスになっています.anyhow::ErrorはBox<dyn std::error::Error>とほぼ同じような役割を持つため,戻り値の型をこのように変更しても関数内部を変更しないといけないケースは多くありません.
hoge()の1行目ではcontext()を使うことでエラーにコンテキスト情報を含めています.前節と同様に実行してみます.
Finished dev [unoptimized + debuginfo] target(s) in 0.71s
Running `target/debug/my-error`
failed to open file: ./hoge.tx
Caused by:
No such file or directory (os error 2)
コンテキスト情報が含まれ,ややリッチなエラーメッセージが表示されるようになりました.contextと似たようなメソッドとしてwith_contextというのも用意されており,こちらはクロージャを渡すことでエラー発生時のみ文字列生成を行うことが可能です.エラーが起きなかった場合はformat!のコストを払う必要が無いということですね.
let f = File::open(filename).with_context(|| format!("failed to open file: {}", filename))?;
anyhowにはそのほかにもanyhow!やbail!などの便利マクロが備わっています.
thiserror
https://github.com/dtolnay/thiserror
thiserrorはstd::error::Errorのためのderiveマクロを提供します.anyhowと作者が同じで,それと組み合わせて使うことも可能です.
thiserrorを使ったコードは次のようになります.
use anyhow::{Context, Result};
use std::fs::File;
use thiserror::Error;
#[derive(Debug, Error)]
enum MyError {
#[error("HogeError: {0}")]
HogeError(u8),
#[error("FooError: {0}")]
FooError(String),
}
fn hoge(filename: &str) -> Result<()> {
let f = File::open(filename).context(format!("failed to open file: {}", filename))?;
Err(MyError::FooError("foo".to_string()))?;
Ok(())
}
// Display, Errorの実装は不要になる
MyErrorに対してderiveマクロのthiserror::Errorを使っています.また各エラー値に対しては,#[error(...)]を使うことでDisplayと同等の機能を実装しています.ここでは簡単なフォーマット記法を利用することが可能で,次のような変換規則があります.
| マクロ | 展開 |
|---|---|
| #[error("{var}")] | write!("{}", self.var) |
| #[error("{0}")] | write!("{}", self.0) |
| #[error("{var:?}")] | write!("{:?}", self.var) |
| #[error("{0:?}")] | write!("{:?}", self.0) |
エラー値は#[error(transparent)]と#[from]を用いることで他のエラー型にDisplay相当の機能を委譲することもできます.
#[derive(Debug, Error)]
enum MyError {
#[error("HogeError: {0}")]
HogeError(u8),
#[error("FooError: {0}")]
FooError(String),
#[error(transparent)]
Other(#[from] anyhow::Error), // エラーのバリアントを追加
}
fn hoge(filename: &str) -> Result<()> {
let f = File::open(filename).context(format!("failed to open file: {}", filename))?;
Err(MyError::FooError("foo".to_string()))?;
Err(MyError::Other(anyhow::anyhow!("Other err")))?; // anyhow::Errorをラップしている
Ok(())
}
failureクレートについて
少し前まではanyhowやthiserrorと同様のことを実現するためにfailureクレートが利用されていました.当時はstd::error::Errorトレイトの使い勝手があまり良くなかったため,独自にFailトレイトを導入し,関数の戻り値の型にはResult<T, failure::Error>を指定する,といったことが行われていました.
現在ではfailureクレートの知見を元にstd::error::Errorトレイトに改善が加えられたため,本クレートは凍結状態となっています.failure公式はanyhowとthiserrorの使用を推奨しています.
まとめ
- 関数からエラーを返すとき,サードパーティークレートを使わないなら原則として戻り値の型に
Result<T, Box<dyn Error>>を指定する(追記も参照) - anyhowでエラー情報をリッチに,扱いやすくできる
- thiserrorで独自エラー型の定義が楽になる
- failureクレートは次の世代に希望と知見を託して消えていった.現時点では非推奨なので気をつけよう
追記
@lo48576さんからご指摘をいただきました.戻り値の型はResult<T, Box<dyn Error + Send + Sync + 'static>>とした方がベターとのことです.
このポストで使ってきたhoge()を例にとるとこのようになります.
fn hoge() -> Result<(), Box<dyn Error + Send + Sync + 'static>> {
let f = File::open("./hoge.txt")?;
Err(MyError::FooError("foo".to_string()))?;
Ok(())
}
まずSendとSyncに関してですが,これらのトレイト境界を付与するとそれぞれ「他のスレッドに送信しても安全」,「他のスレッドと共有しても安全」という制約が付きます.マルチスレッドを用いたプログラミングをしている際に必要になります.例えば次のような呼び出しをした場合,Sendが無い場合コンパイルエラーになります.
thread::spawn(|| hoge());
SyncはSendと共に実装されることが多いです.
'staticトレイト境界に関してはRustの2種類の 'static | 俺とお前とlaysakuraで詳しく解説してくださっています.これを付与しておくと,isやdowncast_refといったメソッド*5が利用できるようになり,呼び出し元で柔軟なエラーハンドリングが可能になります.
例えば次のようなコードです.
if let Err(error) = hoge() {
match error.downcast_ref::<MyError>().unwrap() {
MyError::HogeError(u) => println!("retry...: {}", u),
MyError::FooError(s) => println!("notify slack: {}", s),
}
}
@lo48576さん,ありがとうございました.
*1:https://qiita.com/legokichi/items/d4819f7d464c0d2ce2b8
*2:https://blog.rust-lang.org/inside-rust/2020/09/18/error-handling-wg-announcement.html
*3:Boxに入れないと,コンパイル時にサイズが決定できないのでコンパイルできません
*4:ドキュメントには,Box
*5:https://doc.rust-lang.org/stable/std/error/trait.Error.html#method.is