hooqアドベントカレンダーではRustのエラーハンドリング・ロギング関連の情報をまとめてきました!
Rustのエラーハンドリングの仕組みはシンプルです。パニックせず適切にハンドリングする場合はResult<T, E>を利用するのが定石です。成功時の T 型にも失敗時の E 型にも 特に制約はなく (伏線)、かなり自由に使うことが可能です。 anyhow や thiserror 、 eyre 、 tracing ...などなど、エラーに関わるクレートはたくさんありますが、別にこれらを使うことは必須ではなく、 Result<usize, String> とか、 Result<u32, u32> とか Result<(), ()> などなど好きな型を Result 型として利用して良いわけです。
...自由なんですけど、エラーというのは基本的に「標準(エラー)出力する」ものです。それを象徴する使い方の一つに、「 main 関数の返り値を Result 型にする」というものがあります。
fn main() -> Result<(), String> {
Err("Something wrong.".to_string())
}
Compiling playground v0.0.1 (/playground)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.93s
Running `target/debug/playground`
Error: "Something wrong."
Playground: https://play.rust-lang.org/?version=stable&mode=debug&edition=2024&gist=7e951d5f4e84bd6ced86fdf635c4960c
ランタイムエラーがうまく出力されました。
ここで、 E を自分で用意した型をエラー型にしたいとします!
struct MyError;
fn main() -> Result<(), MyError> {
Err(MyError)
}
Playground: https://play.rust-lang.org/?version=stable&mode=debug&edition=2024&gist=feb90c894fd2a75cc74810de04d34847
しかしこちらはDebug トレイトの実装が求められ、コンパイルエラーとなります(ややこしいw)。
Compiling playground v0.0.1 (/playground)
error[E0277]: `MyError` doesn't implement `Debug`
--> src/main.rs:3:14
|
3 | fn main() -> Result<(), MyError> {
| ^^^^^^^^^^^^^^^^^^^ the trait `Debug` is not implemented for `MyError`
|
= note: add `#[derive(Debug)]` to `MyError` or manually `impl Debug for MyError`
= note: required for `Result<(), MyError>` to implement `Termination`
help: consider annotating `MyError` with `#[derive(Debug)]`
|
1 + #[derive(Debug)]
2 | struct MyError;
|
For more information about this error, try `rustc --explain E0277`.
error: could not compile `playground` (bin "playground") due to 1 previous error
この使い方を始めとして、まともに運用するには Result<T, E> の E に最低限Debug トレイトが実装されていてほしいわけです。
std::error::Error トレイト
Debug トレイトをその「実用的なエラー型に求められる必要最低限のトレイト」としても良いのですが、最低限として一般的に用いられるもう少し適切なトレイトがあります。それがstd::error::Error トレイトです!
pub trait Error: Debug + Display {
// Provided methods
fn source(&self) -> Option<&(dyn Error + 'static)> { ... }
fn provide<'a>(&'a self, request: &mut Request<'a>) { ... }
// deprecated なものは省略
}
std::error::Error で最も注目する点は Debug + Display というトレイト境界 です。実装可能なメソッドが2つありますがこれらはデフォルト実装を含んでいるため実装は必須ではありません。
このトレイトの実装は「エラー内容は文字列で表せること」を要求しており、言い換えると std::error::Error を実装している型は以下2つが保証されています。
- 先ほど確認したようにデバッグ出力ができる
-
ToString::to_stringで文字列化できる
「Rustのエラーは文字列化機能が必要/存在する」ということだけ覚えておけばとりあえずおkというわけです。本記事の残りではオーバーライド可能な2つのメソッドについて見ていきますが、こちらは「最低限」という意味では気にしなくてもよさそうです。
sourceメソッド
fn source(&self) -> Option<&(dyn Error + 'static)> {
// ...
}
「このエラーの元となったエラー」を表示するためのメソッドです。列挙型のカスタムエラーなどでは実装すると捗りそうなメソッドですね。デフォルトでは None を返します。
#![allow(unused)]
#[derive(Debug)]
enum MyError {
Io(std::io::Error),
Req(reqwest::Error),
}
impl std::fmt::Display for MyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
let s = match self {
MyError::Io(e) => format!("io error: {}", e),
MyError::Req(e) => format!("reqwest error: {}", e),
};
write!(f, "{}", s)
}
}
impl std::error::Error for MyError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(match self {
MyError::Io(e) => e,
MyError::Req(e) => e,
})
}
}
fn main() {
use std::io::{Error, ErrorKind};
use std::error::Error as _;
let my_error = MyError::Io(Error::new(ErrorKind::Other, "something wrong"));
println!("{:?}", my_error.source());
}
Playground: https://play.rust-lang.org/?version=stable&mode=debug&edition=2024&gist=94e59dccbb983dd8c81cea73b944e9ee
こう書いたは良いのですが、基本的に手動実装しません。筆者も今回初めて試しました。後述のderiveマクロthiserror::Errorを使うのが普通でしょう。
provideメソッド
fn provide<'a>(&'a self, request: &mut Request<'a>) {
// ...
}
今回記事を書き始めたのは実はこのメソッドについて研究したかったからでした。
そもそも現在nightlyでのみ提供されているらしく、利用には #![feature(error_generic_member_access)] が必要となるようです。
機能説明としては「任意の型の値または参照を登録しておき、トレイトオブジェクト &dyn Error にされた後からでも取り出せるようにする」というもののようです。任意の型をキーにして出し入れはよく見るパターン1なのですがパターン名がわからない...ちなみにこの仕組みの実装自体は多分そこまで大変ではない2です。
登録は今回紹介する provide メソッド内にてstd::error::Requestという構造体の可変参照の provide_ref や provide_value を呼び出して引数に渡すことで行います。
一方引き出すのは std::error::request_ref や std::error::request_value にトレイトオブジェクト参照を渡すことで行えるようですね。
#![feature(error_generic_member_access)]
use std::{
error::{Request, request_value},
fmt::{Display, Formatter, Result as FmtResult},
};
#[derive(Debug)]
struct MyError;
impl Display for MyError {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
write!(f, "MyError occurred")
}
}
impl std::error::Error for MyError {
fn provide<'a>(&'a self, request: &mut Request<'a>) {
// 何かしらの値を事前登録!
request.provide_value::<usize>(334);
}
}
fn main() {
let err = MyError;
let err_ref = &err as &dyn std::error::Error;
// 型をキーにして取り出し
if let Some(value) = request_value::<usize>(err_ref) {
println!("Provided value: {}", value);
} else {
println!("No value provided");
}
}
$ cargo run -q
Provided value: 334
で「なんでこんな仕組みを用意しているんだ?」という話なのですが、どうやら将来的にstd::backtrace::Backtraceを始めとしたバックトレース系の何かを、エラーをラップする上位の型に引き回していきたいからのようですね。他にも用途あるんだろうか...?
#![feature(error_generic_member_access)]
#![allow(unused)]
use std::{
backtrace::Backtrace,
error::{Request, request_ref},
fmt::{Display, Formatter, Result as FmtResult},
};
macro_rules! impl_display {
($t:ty) => {
impl Display for $t {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
write!(f, "{:?}", self)
}
}
};
}
macro_rules! impl_error {
($t:ty) => {
impl std::error::Error for $t {
fn provide<'a>(&'a self, request: &mut Request<'a>) {
match request_ref::<Backtrace>(&*self.0) {
Some(bt) => {
request.provide_ref::<Backtrace>(bt);
}
None => {}
}
}
}
};
}
fn func1() -> impl std::error::Error {
#[derive(Debug)]
struct Func1Error(std::backtrace::Backtrace);
impl_display! {Func1Error}
impl std::error::Error for Func1Error {
fn provide<'a>(&'a self, request: &mut Request<'a>) {
request.provide_ref::<Backtrace>(&self.0);
}
}
Func1Error(Backtrace::capture())
}
fn func2() -> impl std::error::Error {
#[derive(Debug)]
struct Func2Error(Box<dyn std::error::Error>);
impl_display! {Func2Error}
impl_error! {Func2Error}
Func2Error(Box::new(func1()))
}
fn func3() -> impl std::error::Error {
#[derive(Debug)]
struct Func3Error(Box<dyn std::error::Error>);
impl_display! {Func3Error}
impl_error! {Func3Error}
Func3Error(Box::new(func2()))
}
fn main() {
let err = func3();
if let Some(bt) = request_ref::<Backtrace>(&err) {
println!("Backtrace found:\n{}", bt);
} else {
println!("No backtrace found.");
}
}
$ RUST_BACKTRACE=1 cargo run -q
Backtrace found:
0: error_provide_backtrace_pg::func1
at ./src/main.rs:47:16
1: error_provide_backtrace_pg::func2
at ./src/main.rs:57:25
2: error_provide_backtrace_pg::func3
at ./src/main.rs:67:25
3: error_provide_backtrace_pg::main
at ./src/main.rs:71:15
4: ...
今回筆者の理解のためにこのフォワーディングを手書きしましたが、stable入りした後はこちらも source 同様thiserror::Errorで自動実装するのが当たり前になりそうです。
deriveマクロthiserror::Error
source や provide は thiserror::Error deriveマクロを利用して実装すると良いという話をしました。その他 std::error::Error 実装のために Debug トレイトや Display トレイトを実装する必要があるという話をしました。
詳細は次回改めて話しますが、総括して乱暴に言えば 本記事の内容を自動的に実装してくれるのがthiserror::Errorマクロ です!つまりthiserrorクレート自体を理解するには std::error::Error を理解するのが手っ取り早かったというわけですね。
本記事最後に示したソースコードでもマクロを利用しましたが、こんなのを毎回書いていたら日が暮れてしまいますものね...それを簡略化してくれるのでthiserrorは偉大です。
まとめ・所感
hooqアドベントカレンダー 20日目の記事でした!
hooqはメソッドを ? 演算子にフックする属性マクロです。本アドカレではhooqの使い方を始め、hooqマクロを作成するにあたり得た知識、Rustのエラーハンドリング・エラーロギング周りの話をまとめています。
後半の provide にて前回扱ったバックトレースに言及しつつ、次回記事への伏線(thiserror)を張りました!というわけで、次回に続きます。
ここまで読んでいただきありがとうございました!
-
ちょっと違うかもですが型でディスパッチという点では
axum::extract::Stateやtauri::Stateとかと共通している仕組みに見えますね ↩
