Rustでのエラーハンドリング(thiserrorとanyhow)
1. 概要
RustはResult<T, E>を通じてエラー処理を明示的に行うことを強制します。このアプローチは安全ですが、手動でエラー型を書くのは冗長です。各ケースごとにDisplay、Error、Fromを実装する必要があります。
この記事では、これを解決するための最も一般的な2つのcrateを紹介します。thiserrorは構造化されたエラー型の定義に役立ちます。anyhowはすべてのエラーを1つの型に集約し、コンテキストを追加するのに役立ちます。この記事を読み終わったあと、どこでどのcrateを使うべきか、それらをどのように組み合わせるか、そしてエラーをテストする方法を理解できるようになるでしょう。
2. 手動でエラーを書く際の問題点
ファイルから注文の数量を読み込むとします。2つのエラーが発生する可能性があります:I/Oエラーとパースエラーです。ライブラリを使わない場合、次のように書く必要があります:
use std::fmt;
#[derive(Debug)]
pub enum OrderError {
Io(std::io::Error),
Parse(std::num::ParseIntError),
}
impl fmt::Display for OrderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
OrderError::Io(_) => write!(f, "注文ファイルを読み込めません"),
OrderError::Parse(_) => write!(f, "注文データの形式が不正です"),
}
}
}
impl std::error::Error for OrderError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
OrderError::Io(e) => Some(e),
OrderError::Parse(e) => Some(e),
}
}
}
impl From<std::io::Error> for OrderError {
fn from(e: std::io::Error) -> Self { OrderError::Io(e) }
}
impl From<std::num::ParseIntError> for OrderError {
fn from(e: std::num::ParseIntError) -> Self { OrderError::Parse(e) }
}
わずか2つのvariantのために、約30行のボイラープレートが必要です。variantを追加するたびに、4つの場所を修正する必要があります。これがまさにthiserrorが行ってくれることです。
別の近道はBox<dyn std::error::Error>を使うことです。これは簡潔ですが、型情報が失われ、コンテキストを追加できません。anyhowはこの近道の改良版です。
3. インストール
Cargo.tomlに2つの依存関係を追加します:
[dependencies]
thiserror = "2"
anyhow = "1"
この2つのcrateは独立しています。ライブラリは通常thiserrorだけが必要です。バイナリはanyhowを使い、時々両方を使います。
4. thiserror:ライブラリ用の構造化されたエラー型
4.1. エラーenumの定義
thiserrorでは、第2節のコードは次のように短縮されます:
use thiserror::Error;
#[derive(Debug, Error)]
pub enum OrderError {
#[error("注文 #{0} が見つかりません")]
NotFound(u64),
#[error("無効な数量: {quantity} (0より大きい必要があります)")]
InvalidQuantity { quantity: i32 },
#[error("注文ファイルを読み込めません")]
Io(#[from] std::io::Error),
#[error("注文データの形式が不正です")]
Parse(#[from] std::num::ParseIntError),
}
各部分は独自の役割を持っています:
#[derive(Error)]はstd::error::Errorの実装を生成します。#[error("...")]はDisplayを生成します。{0}はtupleバリアントの最初のフィールドを参照します。{quantity}は名前付きフィールドを参照します。#[from]はimpl From<std::io::Error> for OrderErrorを生成します。これにより、?演算子が自動的にエラーを変換します。#[from]が付いたフィールドは自動的にsource()になります。
次に、このエラー型を使う関数を書きます:
use std::fs;
pub fn parse_quantity(raw: &str) -> Result<i32, OrderError> {
let quantity: i32 = raw.trim().parse()?;
if quantity <= 0 {
return Err(OrderError::InvalidQuantity { quantity });
}
Ok(quantity)
}
pub fn load_quantity(path: &str) -> Result<i32, OrderError> {
let content = fs::read_to_string(path)?;
parse_quantity(&content)
}
fs::read_to_stringはio::Errorを返します。?演算子はFromを呼び出して、それをOrderError::Ioに変換します。同様に、ParseIntErrorはOrderError::Parseに変換されます。呼び出し元は各variantに対してマッチして、それぞれ処理できます。
4.2. #[source]による原因チェーンの走査
時々、基本的なエラーは保持したいが、Fromで自動的に変換したくない場合があります。その場合、#[from]の代わりに#[source]を使います:
#[derive(Debug, Error)]
pub enum InventoryError {
#[error("製品 {sku} の在庫を更新できません")]
UpdateFailed {
sku: String,
#[source]
cause: std::io::Error,
},
}
このバリアントは追加フィールドskuを持っているため、Fromは自動的に生成されません。この場合、#[source]はcauseが根本原因であることを宣言するだけです。原因チェーンを走査するには、次のようにします:
use std::error::Error as _;
fn print_chain(err: &dyn std::error::Error) {
eprintln!("エラー: {err}");
let mut current = err.source();
while let Some(cause) = current {
eprintln!(" 原因: {cause}");
current = cause.source();
}
}
“Permission denied”エラーをラップするUpdateFailedでprint_chainを呼び出すと、次が表示されます:
エラー: 製品 SKU-42 の在庫を更新できません
原因: Permission denied (os error 13)
4.3. #[error(transparent)]
transparentはDisplayとsource()を内部エラーに直接転送します。バリアントが単なるラッパーで追加情報がない場合に使用します:
#[derive(Debug, Error)]
pub enum PaymentError {
#[error("カードが拒否されました")]
CardDeclined,
#[error(transparent)]
Other(#[from] anyhow::Error),
}
Otherバリアントは「その他のエラー」として機能します。呼び出し元が処理する必要がある特定のエラーは独自のバリアントを持ちます。その他すべてのエラーはOtherに集約されます。
5. anyhow:アプリケーション全体用の単一エラー型
アプリケーション層では、各エラー型に対してmatchすることはほとんどありません。通常、エラーをログしたり、ユーザーに表示したりするだけで、何が起きたかを知るためのコンテキスト情報が必要です。anyhow::Errorがこの正確なニーズに役立ちます。
5.1. anyhow::Resultとコンテキスト
use anyhow::{Context, Result};
fn read_port(path: &str) -> Result<u16> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("設定ファイル {path} を読み込めません"))?;
let port: u16 = raw
.trim()
.parse()
.context("portは0–65535の範囲内の整数である必要があります")?;
Ok(port)
}
fn main() -> Result<()> {
let port = read_port("app.conf")?;
println!("サーバーはポート {port} で実行中です");
Ok(())
}
このコードは以下のように機能します:
anyhow::Result<T>はResult<T, anyhow::Error>のエイリアスです。std::error::Error + Send + Sync + 'staticを実装するすべての型は?を通じてanyhow::Errorに変換されます。.context(...)は元のエラーを新しいメッセージでラップし、元のエラーは根本原因として保持されます。.with_context(|| ...)はクロージャを受け取ります。クロージャはエラーが発生したときのみ実行されるため、正常系で不要なformat!呼び出しを避けられます。
ファイルが存在しないためにmainがErrを返す場合、プログラムは以下を出力します:
Error: 設定ファイル app.conf を読み込めません
Caused by:
No such file or directory (os error 2)
anyhow::Errorをフォーマットする方法は3つあります:
{}は最も外側のメッセージのみを出力します。{:#}は1行でチェーン全体を出力し、:で区切ります。{:?}は上記のような複数行形式で出力します。
ログ出力では{:#}を使うのが最も便利です。
ContextはOptionでも機能します。これはNoneを意味のあるエラーに変える簡潔な方法です:
use anyhow::{Context, Result};
use std::collections::HashMap;
fn find_email(customers: &HashMap<u64, String>, id: u64) -> Result<&String> {
customers.get(&id).with_context(|| format!("顧客 #{id} がいません"))
}
5.2. bail!、ensure!、anyhow!マクロ
これら3つのマクロは、型を定義せずにアドホックなエラーを作成するのに役立ちます:
use anyhow::{anyhow, bail, ensure, Result};
fn apply_discount(total: u64, percent: u8) -> Result<u64> {
ensure!(percent <= 100, "無効な割引率: {percent}");
if total == 0 {
bail!("0円の注文に割引を適用できません");
}
total
.checked_sub(total * percent as u64 / 100)
.ok_or_else(|| anyhow!("割引計算でオーバーフロー"))
}
ensure!(cond, ...)は条件が偽の場合、エラーを返します。bail!(...)はreturn Err(anyhow!(...))と同等です。anyhow!(...)はanyhow::Error値を作成しますが、returnしません。
例えば、apply_discount(500_000, 120)は無効な割引率: 120エラーを返します。
5.3. 型分類が必要な場合のダウンキャスト
anyhow::Errorは元の型を保持しています。ダウンキャストして特殊なケースを処理できます:
fn handle(err: &anyhow::Error) {
match err.downcast_ref::<OrderError>() {
Some(OrderError::NotFound(id)) => println!("注文#{id}の404ページを表示"),
Some(other) => println!("注文エラー: {other}"),
None => println!("システムエラー: {err:#}"),
}
}
.context(...)でラップされたエラーでもdowncast_refは機能します。ただし、頻繁にダウンキャストしている場合、anyhowの代わりにthiserrorenumを使う方がよい兆候です。
6. 組み合わせ:下層ではthiserror、上層ではanyhow
シンプルで効果的なルール:
- ライブラリまたはドメインモジュールでは
thiserrorを使用します。呼び出し元は正確なエラー内容を知る必要があり、アクションを決定します。 - バイナリ、CLI、最上層のハンドラーでは
anyhowを使用します。ここではエラーを主に報告しているだけです。
use anyhow::{Context, Result};
fn process_order_file(path: &str) -> Result<()> {
let quantity = load_quantity(path)
.with_context(|| format!("注文ファイル {path} の処理に失敗しました"))?;
println!("注文に {quantity} 個の製品があります");
Ok(())
}
load_quantity関数は構造化されたOrderErrorを返します。上層は処理中のファイルに関するコンテキストを追加します。-3を含むファイルで実行すると、次が表示されます:
Error: 注文ファイル orders/1001.txt の処理に失敗しました
Caused by:
無効な数量: -3 (0より大きい必要があります)
7. エラー動作のテスト
構造化されたエラー型により、テストがより正確になります。文字列比較をあいまいに行う代わりに、正しいvariantを確認できます:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn negative_quantity_returns_invalid_quantity() {
let err = parse_quantity("-3").unwrap_err();
assert!(matches!(err, OrderError::InvalidQuantity { quantity: -3 }));
assert_eq!(err.to_string(), "無効な数量: -3 (0より大きい必要があります)");
}
#[test]
fn non_numeric_input_returns_parse_error() {
let err = parse_quantity("abc").unwrap_err();
assert!(matches!(err, OrderError::Parse(_)));
}
#[test]
fn missing_config_keeps_io_error_as_root_cause() {
let err = read_port("/no/such/file.conf").unwrap_err();
assert_eq!(
err.to_string(),
"設定ファイル /no/such/file.conf を読み込めません"
);
assert!(err.root_cause().downcast_ref::<std::io::Error>().is_some());
}
}
テストを実行するには:
cargo test
running 3 tests
test tests::negative_quantity_returns_invalid_quantity ... ok
test tests::non_numeric_input_returns_parse_error ... ok
test tests::missing_config_keeps_io_error_as_root_cause ... ok
test result: ok. 3 passed; 0 failed
3番目のテストはroot_cause()がチェーンの最も奥のエラーに到達することを示しています。これにより、コンテキストが根本原因を失わないことを確認できます。
8. よくあるエラーと不適切な使用例
注意:
#[error]で根本原因のメッセージを繰り返さないでください。#[error("io error: {0}")] Io(#[from] io::Error)と書くと、{:?}や{:#}でチェーン全体を出力するときに、メッセージが2回現れます。2つの方法のいずれかを選択してください:{0}を埋め込んでsourceを宣言しないか、sourceを宣言してメッセージを簡潔にしておきます。
注意: ライブラリのpublic APIで
anyhow::Errorを使用しないでください。呼び出し元はエラーに対してmatchできず、ダウンキャストを強いられます。代わりにthiserrorenumを公開してください。
他にも出会う可能性のあるエラーがあります:
- 同じソース型に対する2つのvariantの
#[from]。 例えば、2つのvariantが両方ともio::Errorをラップしていると、重複する2つのimpl Fromが生成され、コンパイルエラーが発生します。少なくとも1つのvariantに対して#[source]とmap_errを使用してください。 #[from]の乱用。 すべてのio::ErrorがOrderError::Ioになると、どの操作が失敗したかに関する情報が失われます。UpdateFailed { sku, cause }のように、フィールドコンテキストを持つvariantはしばしばより有用です。anyhow::Errorがstd::error::Errorを実装していないことを忘れる。 これはdyn Errorに逆参照しますが、traitを実装していません。E: std::error::Errorを必要とする場所に渡すことはできません。必要な場合は、#[error(transparent)]variantでラップしてください。.context(format!(...))を呼び出す。 文字列はエラーがなくてもすぐにフォーマットされます。.with_context(|| ...)を使用してください。- 非常にパフォーマンス重視なコードまたは
no_std環境。anyhow::Errorはヒープに割り当てられます。ホットパスまたは組み込み環境では、コンパクトなenumがより良い選択肢です。
バックトレースが必要な場合は、実行時に環境変数RUST_BACKTRACE=1を設定してください。anyhowはバックトレースをエラーとともに出力します。
9. 結論
thiserrorとanyhowは異なる2つのニーズに対応しています。thiserrorはエラー型定義時のボイラープレートを排除し、ライブラリとドメイン層に適しており、呼び出し元がエラーを分類する必要があります。anyhowはすべてのエラーを1つの型に集約し、段階ごとにコンテキストを追加するのに対応でき、バイナリと最上層に適しています。
両方を組み合わせるべきです:下層では構造化されたエラーを定義し、上層ではコンテキストをラップします。最後に、メッセージは簡潔に保ち、根本原因を繰り返さず、文字列比較ではなく正しいvariantを確認するテストを書きます。