この記事は Rust Advent Calendar 2021 の 7日目の記事です。
結論
よく分からなければ、anyhow::Error
にしておくのがよさそう。
2021/12現在 Rustのエラーハンドリングのベストプラクティスというのは定まっていないようである。
なので、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つの役割がある。
- エラーのマーカー
- 表示方法の提供
- コンテクストの表現
エラーのマーカー
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<dyn Error>
にしておけば、簡単に複数の種類のエラーに対処することができる。なぜなら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<dyn Error + Send + Sync + 'static>
の強化版?として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