All Articles

Rust のエラーハンドリングと thiserror に関するメモ書き

今回は、Rust の thiserror crate の概要や利用方法などについて簡単にまとめます。

参考:thiserror - Rust

もくじ

thiserror crate とは

thiserror は、Rust の std::error::Error トレイト(異なる型に対して共通のメソッドを定義したもの)を derive マクロ(struct や enum を使用してトレイトのコードを自動的に生成するもの)で簡潔に実装するためのクレート(Rust におけるライブラリ)です。

Rust のエラーハンドリングに有効なクレートとしては anyhow がありますが、thiserror は anyhow と比較してカスタムエラー型の実装などに利点があり、主にエラーの型を明示的に公開し呼び出し元側で判別できるようにする必要があるライブラリの実装などで使用されることが一般的なようです。

参考:thiserror vs anyhow、エラーハンドリング宗教戦争 #Rust - Qiita

Rust におけるエラーハンドリング

前提として、Rust では try-catch によるエラーハンドリングは存在せず、エラーは Result<T, E> 型で表現されます。

enum Result<T, E> {
    Ok(T),   // 成功時の値
    Err(E),  // エラー時の値
}

参考:Rustはいいぞ: エラーハンドリング編

そして、Rust ではこの Result 型は「エラーが発生するかもしれない処理」の結果として使用されます。

以下の関数は、read_to_string 関数を使用する場合のエラーハンドリングを愚直に実装したものです。

Rust では、このような match によるパターンマッチングを使用して、Result 型の戻り値に基づくエラーハンドリングを行うことができます。

use std::fs;

fn main() -> Result<(), std::io::Error> {
    let content = match fs::read_to_string("test.txt") {
        Ok(text) => text,
        Err(err) => return Err(err),
    };
    println!("{}", content);
    Ok(())
}

参考:match - Rust By Example

さらに Rust では ? 演算子を使用することでこの match 式を使用する場合とほぼ同等の Result 型に対する処理を以下のように実装することができます。

use std::fs;

fn main() -> Result<(), std::io::Error> {
    let content = fs::read_to_string("test.txt")?;
    println!("{}", content);
    Ok(())
}

参考:Resultで回復可能なエラー - The Rust Programming Language 日本語版

参考:Rustの?演算子 #Rust - Qiita

また、このコードと同じような処理は以下のように実装することもできます。

use std::fs;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let message: String = fs::read_to_string("message.txt")?;
    println!("{}", message);
    Ok(())
}

上記のコードでは、Box<dyn std::error::Error> を使用してエラーを受け取ります。

Rust のコンパイル時には関数の戻り値の型が明確であることが要求されますが、ここでは dyn std::error::Error のように std::error::Error トレイトを実装する何らかの型を動的にディスパッチします。

このような場合は、ヒープに格納するデータを指す Box を使用します。

これにより、コンパイル時には型のサイズを特定できない場合でもその型を使用することができるようになり、以下のように関数内で異なる型のエラーが返される可能性がある場合でもそのエラーを受け取ることができるようになります。

use std::fs;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // io::Error が ? で Box<dyn Error> に自動変換される
    // let content = fs::read_to_string("Cargo.toml")?;
    let content = fs::read_to_string("xxx.toml")?;

    // ParseIntError も同じ Box<dyn Error> に変換できる
    let port: u16 = content.trim().parse()?;

    println!("port = {}", port);
    Ok(())
}

参考:dynを利用してトレイトを返す - Rust By Example 日本語版

参考:ヒープのデータを指すBoxを使用する - The Rust Programming Language 日本語版

エラーハンドリングに thiserror を使用する

呼び出し側でエラーハンドリングを行う場合、前述した Box<dyn std::error::Error> を使用すると手軽に複数の型を扱えるようになりますが、一方で呼び出し側ではエラーの種類を判断しにくいなどのデメリットがあります。

thiserror を使用すると、明示的なカスタムエラー型を簡単に実装することができるので、エラー型を構造的に公開し、呼び出し側でパターンマッチを可能とする必要があるようなライブラリの実装で主に使用されます。

thiserror を使用する最小サンプル

以下は、thiserror を使用したシンプルなエラーハンドリングの実装です。ここでは enum により AppError というエラーを定義しています。

先頭の #[derive(Debug, Error)]std::error::Error トレイトの自動実装のための要件として記述しています。

use thiserror::Error;

#[derive(Debug, Error)]
pub enum AppError {
    #[error("I/O エラー: {0}")]
    Io(#[from] std::io::Error),
}

fn handle_error(err: &AppError) {
    match err {
        AppError::Io(e) => {
            eprintln!("[IO] ファイル操作失敗: {}", e);
        }
    }
}

fn read_config(path: &str) -> Result<String, AppError> {
    let content = std::fs::read_to_string(path)?;
    Ok(content)
}

fn main() {

    println!("--- 1. 存在しないファイルの読み込み (#[from] による自動変換) ---");
    match read_config("nonexistent.toml") {
        Ok(content) => println!("{}", content),
        Err(e) => handle_error(&e),
    }

    println!("\n--- 2. 存在するファイルの読み込み (#[from] による自動変換) ---");
    match read_config("Cargo.toml") {
        Ok(content) => println!("{}", content),
        Err(e) => handle_error(&e),
    }

}

上記のコードでは、#[from] により std::io::Error を AppError に変換する実装を自動生成しており、? 演算子でエラー委譲が行われた際に自動的に io::Error → AppError への変換が行われます。

まとめ

thiserror を使用する実装パターンについてはそのうち追記します。