Rustでthiserrorクレートを使ったエラー処理

2022.11.29

Introduction

最近Rustを使い始めているのですが、
要所要所で既存のプログラミング言語との違いに遭遇して躓いてます。

エラー処理についても、
Java/JavaScriptなどの例外を使ったエラー処理とは違い、
Rustでは基本的にResult型を用いてエラー処理を行います。

これに慣れないと、あまり良くないと知りつつも
とりあえずunwrapしたり、

fn hoge() -> Result<String,Error> {
 ・・・
}

let result = hoge.unwrap();

とりあえずErrがmatchしたらpanicしてました。

fn hoge() -> Result<String,Error> {
 ・・・
}

match hoge {
  Ok(result) => ・・・
    Err(err) => panic!("for now panic!!"); 
}

プロトタイプや使い捨てコードならこれでいいかもしれませんが、
品質の良いコードを書こうとするならエラー処理もしっかり書く必要があるので、
Rustにおけるエラー処理の基本について確認し、
最後にthiserrorクレートを使ってエラー処理を書いてみます。

Environment

  • MacBook Pro (13-inch, M1, 2020)
  • OS : MacOS 12.4
  • Rust : 1.65.0

サンプルプログラムはcargo newで適当につくっておきます。

Error Handling In Rust

さきほども言ったとおり、Rustには例外がありません。
Rustでは「処理の結果、またはそのとき発生したエラー」を表す型として
↓のstd::result::Result型が用意されています。

enum Result<T, E> {
    Ok(T),
    Err(E),
}

通常はこのResult型を使い、プログラム外部要因によって発生した問題を解決します。
発生したエラーが回復不能な場合、実行を中止するpanicマクロを使います。
panicは「発生してはいけないエラー(配列範囲外のアクセスが発生したとか)」を
処理するために使う最終手段的なヤツです。

panicが発生すると、スタックが巻き戻されてスレッドが終了します。 
それがメインスレッドだった場合、プログラムのプロセス自体が終了します。
(panicが発生=Javaでランタイム例外が発生と同じようなもの、らしい)

panicはスレッド単位で発生するので、
複数スレッドを動かしていた場合、他のスレッドはそのまま動かせます。

panicをハンドリング

panicが発生しても、std::panic::catch_unwindを使えば
catchして処理を継続させることができます。

use std::panic;

fn main() {

    let result = panic::catch_unwind(|| {
        panic!("catch the panic!");
    });
    
    println!("Err? -> {:?}",result.is_err());
}

panic::catch_unwind内でpanicを発生させています。
普通ならメインスレッドなのでそのままプログラムが終了しますが、
この場合は処理を継続しています。

% cargo run
     Running `target/debug/main`
thread 'main' panicked at 'catch the panic!', src/bin/main.rs:11:9

Err? -> true

これは、C/C++から呼ばれる可能性のあるRustプログラムを書くときに必要になるとのことです。

ちなみに、panicが発生したら処理継続を許さず、
すぐプログラムを終了させたい場合、
オプションを使って制御することができます。

ここではCargo.tomlにオプションを追加し、
panic発生時にabortさせるようにしています。

[profile.dev]
panic = 'abort'

・・・

panic発生時、すぐにプログラムが終了しています。

% cargo run
     Running `target/debug/main`
thread 'main' panicked at 'catch the panic!', src/bin/main.rs:11:9
[1]    33395 abort      cargo run

エラーの伝搬

よく使う処理の1つに、「?演算子(question mark operator)」を使った処理があります。
?演算子は、それを呼び出す関数自体がResultを返さなければいけません。
(Resultを返す関数内だけで使える)


[追記]
?演算子はstd::ops::Tryトレイトを実装していれば
利用できる機能であり、現在のstable版だと
Optionでも使用可能です。
※nightly版であればユーザ定義の型でも使える


?演算子はErrをうけとったとき、関数の戻り値をErrにしてくれます。

下記関数では、文字列から数値の変換に成功したらその数値、
失敗したらreturn Errします。

pub fn to_num(str: &str) -> Result<i32, std::num::ParseIntError> {
    let num = str.parse::<i32>()?;
    Ok(num)
}

こうやって書いているのと同じ。

pub fn to_num(str: &str) -> Result<i32, std::num::ParseIntError> {
  match str.parse::<i32>(){
    Ok(n) => Ok(n),
    Err(err) => Err(err),
  }
}

Try Error handling

では、実際にエラー処理を書いてみます。
例として、ファイル名をうけとってオープンする関数を定義します。
この関数ではファイルオープンが成功すればOk(File)、
存在しないファイル名をうけとった場合、
ファイルがあっても権限がない場合はErr(std::io::Error)を返します。

パターンマッチを使って素直に書いてみます。

use std::fs::File;
use std::io::{Error, ErrorKind, Read};

pub fn open_file(file_name: &str) -> Result<File, Error> {
    let file = match File::open(file_name) {
        Ok(f) => {
            println!("[info] file open ok.");
            f
        },
        Err(error) if error.kind() == ErrorKind::NotFound => {
            println!("[error] File::open failure : NotFound.");
            return Err(Error::new(ErrorKind::NotFound, error));
        },
        Err(error) if error.kind() == ErrorKind::PermissionDenied => {
            println!("[error] File::open failure : Permission Error");
            return Err(Error::new(ErrorKind::PermissionDenied,error));
        },
        Err(error) => {
            panic!("other error!");
        }
    };

    Ok(file)
}

matchガードを使ってErrorKindでエラー種類を特定しています。
Errにマッチしたら、std::io::Errorを作ってreturnします。

main関数ではopen_file関数を呼び出し、
中身をreadしてます。

fn main() {

    match open_file("hello.txt") {
        Ok(mut file) => {
            let mut s = String::new();
            file.read_to_string(&mut s);
            println!("file body : {}", s);
        }
        Err(err) => {
            println!("Error : {}", err);
        }
    }
}

存在しないファイルを指定して実行してみます。
ErrorKind::NotFoundにマッチしてErrが返されます。

% cargo run

[error] File::open failure : NotFound.
Error : No such file or directory (os error 2)

main関数でerrを表示するとき、
{:?}を使うとfmt::Debugの実装が使われて
もう少し詳細なエラー情報が見れます。

Custom { kind: NotFound, error: Os { code: 2, kind: NotFound, message: "No such file or directory" } }

Custom Error

次はここここなどを参考に、独自のエラー型を定義してみます。

独自エラーを定義するためには、
fmt::Displayを定義したり、
std::error::Errorトレイトを実装する必要があります。

ここでは独自エラー「MyIOError」を定義してみます。

use std::error::Error;
use std::fs::File;
use std::io::{ErrorKind, Read};
use std::fmt;


type Result<T> = std::result::Result<T, MyIOError>;

#[derive(Debug)]
pub enum MyIOError {
    MyFileNotFoundError(std::io::Error),
    MyPermissionError,
}

Type AliasをつかってResultのテンプレを定義しておきます。
そして自前のエラー型、MyIOErrorをenumで定義します。
(単一のエラーならstructを使ってもよさそう)
ここではenumを使い、ファイルが見つからない場合と
権限エラーの2種類のエラーを定義します。

次にDiplayトレイトの実装。
エラーメッセージをFormatterに書き込みます。

impl fmt::Display for MyIOError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match *self {
            MyIOError::MyFileNotFoundError(..) => write!(f, "[fmt:Display] File Not Found"),
            MyIOError::MyPermissionError => write!(f, "[fmt:Display]  Permission Error"),
        }
    }
}

エラーの原因を取得するためにsource関数を実装。

impl Error for MyIOError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match *self {
            MyIOError::MyFileNotFoundError(ref e) => Some(e),
            MyIOError::MyPermissionError => None,
        }
    }
}

open_fileも少し書き換えます。
正常時はmapでメッセージを出力してそのまま継続、
エラー時はmap_errで独自エラーオブジェクトを返します。

pub fn open_file(file_name: &str) -> Result<File> { //Result<File,MyIOError>
    File::open(file_name)
        .map(|f| {
            println!("[info] file open ok.");
            f
        })
        .map_err(|err| {
            if err.kind() == ErrorKind::NotFound {
                println!("[error] File::open failule : NotFound.");
                MyIOError::MyFileNotFoundError(err)
            } else if err.kind() == ErrorKind::PermissionDenied {
                println!("[error] File::open failule : Permission Error");
                MyIOError::MyPermissionError
            } else {
                panic!("other error!");
            }
        })
}

main関数での処理です。
Err時にsource結果があればそれを表示します。

fn main() {
    match open_file("hello.txt") {
        Ok(mut file) => {
            let mut s = String::new();
            file.read_to_string(&mut s);
            println!("{}", s);
        },
        Err(err) => {
            println!("Error : {}", err);
            if let Some(source) = err.source() {
                println!("Caused by: {}", source);
            }
        }
    }
}

存在しないファイルを指定して実行してみるとこんな感じです。

% cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.28s

[error] File::open failule : NotFound.
Error : [fmt:Display] File Not Found
Caused by: No such file or directory (os error 2)

ちなみに「?演算子」を使いたい場合は
Fromトレイトを下記のように実装します。

impl From<std::io::Error> for MyIOError {
    fn from(err: std::io::Error) -> MyIOError {
        if err.kind() == ErrorKind::NotFound {
            println!("From std::io::Error to MyFileNotFoundError.");
            MyIOError::MyFileNotFoundError(err)
        } else if err.kind() == ErrorKind::PermissionDenied {
            println!("From std::io::Error to MyPermissionError.");
            MyIOError::MyPermissionError
        } else {
            panic!("other error!");
        }
    }
}

pub fn open_file(file_name: &str) -> Result<File> {
     let f = File::open(file_name)?;
     Ok(f)
}

thiserror crate

さきほどのように、カスタムエラー型が少ないならこの程度で問題ないですが、
ある程度の規模になるとエラー型もふえてけっこう面倒になります。
そこでthiserrorクレートを使います。
このライブラリはstd::error::Errorトレイトを
実装するためのマクロを提供してくれます。

まずはCargo.tomlに依存ライブラリを追加します。

[dependencies]
thiserror = "1.0.37"

そして、必要ライブラリのimportと
thiserror::Errorを使ったエラー定義。

use std::fs::File;
use std::io::{ErrorKind, Read};
use std::error::Error;
use thiserror::Error;

type Result<T> = std::result::Result<T, Box<dyn Error + Send + Sync + 'static>>;

#[derive(Error, Debug)]
pub enum MyIOError {
    #[error("[thiserror] File Not Found.")]
    MyFileNotFoundError(#[from] std::io::Error),

    #[error("[thiserror] Permission Error.")]
    MyPermissionError,
}

エラーはBoxにしろとあったので変更してみた。


[追記]
ここでBoxを使っているのは、各種エラーを
まとめてシンプルに記述したいため。
リンク先にあるように、実行時まで型が不明なので
用途に応じて使い分けましょう。


そして、MyIOErrorに対してthiserror::Errorマクロを指定しています。
各エラーに対して#[error(xxx)]を使い、Display機能を実現しています。 
ちなみに、#[error("{hoge}")]みたいに書くと動的なメッセージを出力することもできます。
また、#[from]を指定することでFromトレイトとsource()の実装もやってくれます。

次にopen_fileの定義です。
エラー時にintoで型を変換するように記述しています。

pub fn open_file(file_name: &str) -> Result<File> {
    File::open(file_name)
        .map(|f| {
            println!("[info] file open ok.");
            f
        })
        .map_err(|err| {
            if err.kind() == ErrorKind::NotFound {
                println!("[error] File::open failule : NotFound.");
                MyIOError::MyFileNotFoundError(err).into()
            } else if err.kind() == ErrorKind::PermissionDenied {
                println!("[error] File::open failule : Permission Error.");
                MyIOError::MyPermissionError.into()
            } else {
                panic!("other error!");
            }
        })
}

main関数は特に変わってないので省略。
実行してみると、以下のようになります。

% cargo run

[error] File::open failule : NotFound.
Error : [thiserror]File Not Found.
Caused by: No such file or directory (os error 2)

記述量がだいぶ減りましたが、問題なく動いてます。

[Appendix] +anyhow

thiserrorと同じ作者が開発しているanyhowは、
エラーに情報を追加する関数や便利なマクロを提供してくれるcrateです。
このcrateもよく使用されるのでthiserrorといっしょに使ってみます。

Cargo.tomlに依存ライブラリを追加します。

[dependencies]
anyhow = "1.0.66"
thiserror = "1.0.37"

コードはこんな感じ。
anyhow::Resultを使ってるので
std::result::ResultのType Aliasもなくなりました。

use std::fs::File;
use std::io::{ErrorKind, Read};

use thiserror::Error;
use anyhow::{self};

#[derive(Error, Debug)]
pub enum MyIOError {
    #[error("[thiserror] File Not Found.")]
    MyFileNotFoundError(#[from] std::io::Error),

    #[error("[thiserror] Permission Error.")]
    MyPermissionError,
}

pub fn open_file(file_name: &str) -> anyhow::Result<File> {
    File::open(file_name)
        .map(|f| {
            println!("[info] file open ok.");
            f
        })
        .map_err(|err| {
            if err.kind() == ErrorKind::NotFound {
                println!("[error] File::open failule : NotFound.");
                MyIOError::MyFileNotFoundError(err).into()
            } else if err.kind() == ErrorKind::PermissionDenied {
                println!("[error] File::open failule : Permission Error");
                //MyIOError::PermissionDenied.into()
                anyhow::anyhow!("こういうふうにも書ける")
            } else {
                panic!("other error!");
            }
        })
}

fn main() -> anyhow::Result<()> {

    match open_file("hello.txt") {
        Ok(mut file) => {
            let mut s = String::new();
            file.read_to_string(&mut s);
            println!("{}", s);
        }
        Err(err) => {
            println!("main : {}", err);
            if let Some(source) = err.source() {
                println!("Caused by: {}", source);
            }
        }
    }
    Ok(())
}

Summary

今回はRustのエラー処理について、
カスタムエラーを定義したりthiserrorをつかったりしてみました。

Rustにおけるエラー処理は、the Error Handling Project Groupの存在でもわかるように、
非常に重要かつ奥が深いトピックです。
なので、(今後も変化していく)正しいエラー処理を理解していきたいところです。

References