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を確認するテストを書きます。

rusterror-handlingthiserroranyhow