この記事は Rust Advent Calendar 2021 の 7日目の記事です。
結論
よく分からなければ、anyhow::Errorにしておくのがよさそう。
なので、Rustのエラーハンドリングがこれからどうなっていくのかは注視する必要がある。
Rustのエラーハンドリングのキホン
Rustを勉強していくうちに、エラーハンドリングはどうやらResult<T, E>というものを使うようだということが分かる。Result<T, E>を返す関数内では?オペレーターが使え
下記のコードが
use std::fs::OpenOptions;
use std::io;
use std::io::prelude::*;
use std::path::Path;
fn write(filename: impl AsRef<Path>) -> Result<(), io::Error> {
let mut file = match OpenOptions::new().write(true).open(filename) {
Ok(file) => file,
Err(e) => return Err(From::from(e)), // このコードでFrom::fromは不要ですが、?の動作のdesugarを表現するために書いています。
};
match file.write_all(b"Hello, world!") {
Ok(ok) => ok,
Err(e) => return Err(From::from(e)),
};
Ok(())
}
?オペレーターを使って、以下のように書き換えることができる。
use std::fs::OpenOptions;
use std::io;
use std::io::prelude::*;
use std::path::Path;
fn write(filename: impl AsRef<Path>) -> Result<(), io::Error> {
let mut file = OpenOptions::new().write(true).open(filename)?;
file.write_all(b"Hello, world!")?;
Ok(())
}
かなりシンプルにエラー伝搬が行えていることが分かるだろう。これはRustの嬉しい点の1つだ。
さらに値がエラーの場合、もし発生したエラーと戻り値のエラーが異なっていても、発生したエラーが戻り値のエラーに対してFromトレイトを実装していれば、エラーを戻り値のエラー(この場合はio::Error)に変換してくれる。
すばらしい。。「例外なんていらんかったんや?」と思うわけだけが、以下のような疑問が浮かんでくる。
-
- 階層を持つエラーはどうやってコンテキストを保つのか
- ある関数で複数の種類のエラーが発生する場合、Eはなににしたらよいのか
まず最初の疑問から見ていく。この疑問に答えるためには、まずErrorトレイトについて説明する必要がある。
コンテキストの保持
Errorトレイト
RustにはErrorというトレイトがある。以下のようなものだ。
pub trait Error: Debug + Display {
fn source(&self) => Option<&(dyn Error + 'static)> {
None
}
}
私は最初勘違いしていたのだが、Result<T, E>は別にEがErrorトレイトを実装することを要求してない。別にStringでも()でもなんでもいいのである。
ではこのErrorトレイトはなんのために存在しているのだろうか。
Errorトレイトの役割
Errorトレイトには3つの役割がある。
1. エラーのマーカー
2. 表示方法の提供
3. コンテクストの表現
エラーのマーカー
Errorトレイトを実装している型は、エラーを表現するものであることを表現できるということだ。
表示方法
ErrorトレイトはDebugとDisplayの実装を要求している。従ってErrorトレイトを実装する構造体は例えばprintln!(“{}”, e);やprintln!(“{:?}”, e);とすればエラーを表示できる。
コンテクストの表現
Errorトレイトはsourceメソッドを持っている。エラーがそのソースを持っていた場合sorceトレイトをオーバーライドする。Errorトレイトのドキュメントから使用例を引用する。
use std::error::Error;
use std::fmt;
#[derive(Debug)]
struct SuperError {
side: SuperErrorSideKick,
}
impl fmt::Display for SuperError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "SuperError is here!")
}
}
impl Error for SuperError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&self.side)
}
}
#[derive(Debug)]
struct SuperErrorSideKick;
impl fmt::Display for SuperErrorSideKick {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "SuperErrorSideKick is here!")
}
}
impl Error for SuperErrorSideKick {}
fn get_super_error() -> Result<(), SuperError> {
Err(SuperError { side: SuperErrorSideKick })
}
fn main() {
match get_super_error() {
Err(e) => {
println!("Error: {}", e);
println!("Caused by: {}", e.source().unwrap());
}
_ => println!("No error"),
}
}
Error: SuperError is here!
Caused by: SuperErrorSideKick is here!
e.source()を呼ぶことでSuperErrorの原因であるSuperSideErrorを取得できていることが分かるだろう。
また、以下のように再帰的にsourceを呼べばエラーのコンテクストをプリントできる。
// 【2021/12/29】let e -> let mut eに修正 @nobkzさんありがとうございます。
let mut e = e.source();
while let Some(cause) = e {
println!("Caused by: {}", cause);
e = cause.source();
}
ただし、これはあまりにもミスするのが簡単なため、Rust公式としてもっと簡単にコンテクストをプリントできるようにしようとする動きがあるようである。
また、eyreというクレートを使うと簡単にコンテクストを出力できるようである。
関数内で複数の種類のエラーが発生しうる場合
次に、2つめの疑問である関数で複数の種類のエラーが発生する場合どうしたらいいのかについて見ていく。
これには2種類の手法がある。Enumを使う方法とBoxを使う方法だ。
Enum
1つ目の手法はEnumを使う方法である。例としてsqlx::Errorの抜粋を示す。
pub enum Error {
/// Error occurred while parsing a connection string.
Configuration(BoxDynError),
/// Error returned from the database.
Database(Box<dyn DatabaseError>),
/// Error communicating with the database backend.
Io(io::Error),
Tls(BoxDynError),
///.....省略
}
https://github.com/launchbadge/sqlx/blob/master/sqlx-core/src/error.rs から改変・抜粋して引用
このようにエラーを定義して、Enumの構成要素それぞれに対してFromトレイトを実装すれば、?オペレーターを使って、複数のエラーを扱うことができる。
impl From<io::Error> for Error {
fn from(e: io::Error) -> Self {
Self::Io(e)
}
}
さらにこのErrorをエラーにするためにはErrorトレイトの実装をしなければならない。
Debugはderiveマクロで自動導出できるが、Displayは手で実装しなければならない。
またエラーのソースを持っているのでsource関数もオーバーライドしたほうがいいだろう。
まとめると以下のような作業が必要である。
-
- 各構成要素にFromトレイトの実装
Errorトレイトの実装(source関数の実装とDisplayトレイトの実装とDebugの導出)
これは結構面倒な作業なので、マクロでこのようなボイラープレートを生成してくれるthiserrorというクレートが存在する。
メリデメは以下のようになるだろう。
メリット
- パターンマッチングでその構成要素を取得できること。
デメリット
-
- 実装が面倒なこと。
- 新しいエラーを返したい場合に、Enumを拡張しなければならないこと。
Box
もう一つの方法はBoxを使うことだ。Result<T, E>のEをBoxにしておけば、簡単に複数の種類のエラーに対処することができる。なぜならBoxには以下のような実装がされているからである。
impl<'a, E: Error + 'a> From<E> for Box<dyn Error + 'a>
impl<'a, E: Error + Send + Sync + 'a> From<E> for Box<dyn Error + Send + Sync + 'a>
つまり、Errorトレイトを実装した型はそれをBox化した型に変換できるということである。
また、Send、Sync、’static境界は必須ではないが、可能ならつけておいた方がいい。
なぜなら、SendとSyncをつけないとマルチスレッドプログラミングで用いることが難しくなるし、’staticをつけていないとdowncast_refでBox化されている具体的な値を得ることができなくなるからである。
Boxの強化版?としてanyhowというクレートがある。
また、anyhowのフォークで、カスタマイズされたエラー報告機能がついたeyreというクレートもあるようである。
メリデメは以下のようになるだろう。
メリット
- 実装が楽なこと。
デメリット
-
- 詳細な値の取得が難しいこと。
downcast_refという関数で詳細の値を得ることは可能だが、この関数を多用するようなら、Enumでエラーを構成したほうが良さそうだ。
まとめ Result<T, E>のEは何にするのがよいか
エラーの値が単一なら好きにしたらいいだろう。ただし、Box化されたエラーに変換できるようにErrorトレイトは実装した方がよい。
複数の種類のエラーが返される場合はEnum(thiserror)を使うかBox(anyhow, eyre)を使うかの選択肢がある。
エラーの詳細の値を扱いたい場合はEnum、ただエラーを報告したいだけのときはBoxを使うというのが判断基準だろうか。
アプリケーションのコードでは、ただエラーを報告したいということが多いのでBox(anyhow, eyre)を使うのが安定なことが多く、逆にライブラリの場合は、なるべく柔軟性を維持するためEnum(thiserror)使うことが多いようである。
結論として、ライブラリを書く人よりアプリケーションを書く人の方が多いことと、ライブラリを書く人はRustについて詳しい人が多いということを考えると、悩んだらanyhowを使うのが安定の場合が多いということになるだろう。
参考文献
-
- What the Error Handling Project Group is Working Towards https://blog.rust-lang.org/inside-rust/2021/07/01/What-the-error-handling-project-group-is-working-towards.html
RustConf 2020 – Error handling Isn’t All About Errors by Jane Lusby https://www.youtube.com/watch?v=rAF8mLI0naQ
Jon Gjengset, Rust for Rustaceans, no strech press https://nostarch.com/rust-rustaceans
Error Handling In Rust – A Deep Dive https://www.lpalmieri.com/posts/error-handling-rust/
Rust エラー処理2020 https://cha-shu00.hatenablog.com/entry/2020/12/08/060000