コンテンツにスキップ

Rustのanyhowとthiserror

Resultと独自のエラー型のページでは、エラー型を手で書きました。列挙型を定義し、impl Display を書き、? が使えるように impl From も書いたかもしれません。学ぶうえでは、それが正しいやり方です。でも実際のプロジェクトでは、同じ決まりきったコードを何度も何度も書くことになります。

その決まりきったコードをなくしてくれるのが、同じ作者による2つの小さなクレートです。2つはそれぞれ別の問題を解決します。

  • thiserror はエラー型を定義するのを助けてくれます。列挙型は自分で書きますが、Display、Error、From は #[derive] が書いてくれます。
  • anyhow はエラーを上へ渡していくのを助けてくれます。どんなエラーでも入れられる1つのエラー型と、「設定ファイルの読み込み中に」のようなメッセージを簡単に付け足す方法を用意してくれます。

ライブラリとアプリケーション

Section titled “ライブラリとアプリケーション”

どちらを選ぶかは、誰がそのエラーを処理するかで決まります。

ライブラリは、ほかの人が呼び出すコードです。呼び出す側は、失敗の種類ごとに違う対応をしたいかもしれません。タイムアウトなら再試行し、入力が不正ならフォームにエラーを表示する、といった具合です。ですから、ライブラリは呼び出す側が match できる、正確な列挙型を返すべきです。それが thiserror です。

アプリケーション(あなたの main.rs)は、たいていエラーの終着点です。ほとんどのエラーは最終的に人間向けのメッセージになり、場合によっては終了コードになります。それを match することはめったになく、? で簡単に上へ渡せて、読みやすいことのほうが大事です。それが anyhow です。

目安としては、ほかの人が呼び出すコードには thiserror、プログラムのいちばん上には anyhow です。このページの最後で見るように、両方を使うプロジェクトもたくさんあります。

Terminal window
cargo add thiserror anyhow
[dependencies]
anyhow = "1"
thiserror = "2"

Cargo.toml が初めての人は、Cargoとクレートのページを見てください。どちらのクレートも Rust Playground で使えるので、このページの「Try it Yourself」ボタンはすべて動きます。

thiserror:Errorをderiveしてメッセージを書く

Section titled “thiserror:Errorをderiveしてメッセージを書く”

列挙型に #[derive(Debug, Error)] を付け、各バリアントに #[error("...")] でメッセージを書きます。このメッセージが、そのバリアントの Display のテキストになります。メッセージの中では、{0} は1つ目のタプルフィールドを、{balance} は名前付きフィールドを表します。

use thiserror::Error;
#[derive(Debug, Error)]
enum BankError {
#[error("口座 `{0}` が見つかりません")]
NotFound(String),
#[error("残高不足です:残高は{balance}、引き出そうとした額は{amount}")]
InsufficientFunds { balance: u32, amount: u32 },
#[error("金額は0より大きくなければいけません")]
ZeroAmount,
}
fn main() {
let errors = vec![
BankError::NotFound(String::from("alice")),
BankError::InsufficientFunds { balance: 50, amount: 80 },
BankError::ZeroAmount,
];
for e in &errors {
println!("{e}");
}
println!("{:?}", errors[1]);
}

実行結果:

口座 `alice` が見つかりません
残高不足です:残高は50、引き出そうとした額は80
金額は0より大きくなければいけません
InsufficientFunds { balance: 50, amount: 80 }
▶ 実際に試してみよう

これは独自のエラー型のページの列挙型とまったく同じですが、impl Display のブロックがまるごと消えています。実行時に何かが隠れているわけではありません。thiserror は、あなたが手で書くはずだったコードを書いてくれるだけです。呼び出す側からは、ふつうの列挙型に見えます。

エラーには原因があることがよくあります。「ポートが不正です」というエラーは、「文字列に不正な数字が含まれていた」から起きた、という具合です。標準の Error トレイトには、これを表すための .source() メソッドがあります。thiserror はそれも埋めてくれます。

#[from] は2つのことをします。そのフィールドを原因(source)として登録し、さらに impl From を書いてくれます。そのため、? が中のエラーを自動的に自分のバリアントへ変換してくれるのです。

use std::error::Error; // .source() メソッドを使えるようにする
use std::num::ParseIntError;
#[derive(Debug, thiserror::Error)]
enum ConfigError {
#[error("行 {0:?} に `=` がありません")]
NoEquals(String),
#[error("ポートが正しい数値ではありません")]
BadPort(#[from] ParseIntError),
}
fn parse_port(line: &str) -> Result<u16, ConfigError> {
let (_, value) = line
.split_once('=')
.ok_or_else(|| ConfigError::NoEquals(line.to_string()))?;
let port = value.trim().parse::<u16>()?; // ParseIntError が ConfigError::BadPort になる
Ok(port)
}
fn main() {
for line in ["port = 8080", "port 8080", "port = eighty"] {
match parse_port(line) {
Ok(port) => println!("OK: {port}"),
Err(e) => {
println!("エラー: {e}");
if let Some(cause) = e.source() {
println!(" 原因: {cause}");
}
}
}
}
}

実行結果:

OK: 8080
エラー: 行 "port 8080" に `=` がありません
エラー: ポートが正しい数値ではありません
原因: invalid digit found in string
▶ 実際に試してみよう

最後の行の invalid digit found in string は、標準ライブラリの ParseIntError が出す英語のメッセージで、「文字列に不正な数字が含まれています」という意味です。

原因は持たせたいけれど、自動の From はいらないこともあります。たとえば、行番号のような追加のデータを付けたい場合です。そのときは、フィールドに #[source] を付け、.map_err() でエラーを自分で組み立てます(source という名前のフィールドは、それだけで自動的に原因として扱われます)。また、thiserror は列挙型だけでなく構造体にも使えます。

use std::error::Error;
use std::num::ParseIntError;
#[derive(Debug, thiserror::Error)]
#[error("{line}行目に数値がありません")]
struct LineError {
line: usize,
#[source]
cause: ParseIntError,
}
fn sum_lines(text: &str) -> Result<i32, LineError> {
let mut total = 0;
for (i, row) in text.lines().enumerate() {
let n: i32 = row
.trim()
.parse()
.map_err(|cause| LineError { line: i + 1, cause })?;
total += n;
}
Ok(total)
}
fn main() {
println!("合計: {}", sum_lines("10\n20\n30").unwrap());
let err = sum_lines("10\ntwenty\n30").unwrap_err();
println!("{err}");
println!("原因: {}", err.source().unwrap());
}

実行結果:

合計: 60
2行目に数値がありません
原因: invalid digit found in string
▶ 実際に試してみよう

anyhow:何にでも使える1つのResult型

Section titled “anyhow:何にでも使える1つのResult型”

anyhow::Result<T> は Result<T, anyhow::Error> の短縮形です。anyhow::Error には、標準の Error トレイトを実装している型なら、どんなエラーでも入れられます。そのため、1つの関数の中で何種類ものエラーが起きても、そのすべてに ? が使えます。

use anyhow::Result;
// ParseIntError と ParseFloatError の2種類のエラー型がある。
// anyhow::Result なら、どちらも `?` で受け取れる。
fn total_price(quantity: &str, price: &str) -> Result<f64> {
let quantity: u32 = quantity.trim().parse()?;
let price: f64 = price.trim().parse()?;
Ok(quantity as f64 * price)
}
fn main() {
for (quantity, price) in [("3", "2.50"), ("three", "2.50"), ("3", "cheap")] {
match total_price(quantity, price) {
Ok(total) => println!("合計: {total}"),
Err(e) => println!("エラー: {e}"),
}
}
}

実行結果:

合計: 7.5
エラー: invalid digit found in string
エラー: invalid float literal
▶ 実際に試してみよう

anyhow がなければ、2つのバリアントを持つ自分の列挙型か、Box<dyn Error> が必要になるところです。anyhow は、より使いやすい Box<dyn Error> のようなものだと考えてください。ただし、問題があることに気づいたでしょうか。「invalid digit found in string」(不正な数字が含まれています)というメッセージでは、どの値が間違っていたのかがわかりません。そこで使うのがコンテキスト(context)です。

anyhow:コンテキストを付けて、チェーンを表示する

Section titled “anyhow:コンテキストを付けて、チェーンを表示する”

Context トレイトをインポートすると、すべての Result に2つの新しいメソッドが使えるようになります。

  • .context("メッセージ") は、決まったメッセージでエラーを包みます。
  • .with_context(|| format!(...)) はクロージャを受け取ります。本当にエラーが起きたときにだけメッセージを組み立てるので、メッセージに format! が必要なときはこちらを使いましょう。

コンテキストを1層付けるたびに、エラーチェーン(error chain)に1つずつ輪が加わります。エラーをどう表示するかで、チェーンのどこまでが見えるかが変わります。

use anyhow::{Context, Result};
fn parse_port(text: &str) -> Result<u16> {
let port: u16 = text
.parse()
.with_context(|| format!("`{text}` は正しいポートではありません"))?;
Ok(port)
}
fn load_settings(port_text: &str) -> Result<u16> {
let port = parse_port(port_text).context("設定を読み込めませんでした")?;
Ok(port)
}
fn main() {
let err = load_settings("99999").unwrap_err();
println!("{err}"); // いちばん外側のメッセージだけ
println!("{err:#}"); // チェーン全体を1行で
for (i, cause) in err.chain().enumerate() {
println!("{i}: {cause}");
}
}

実行結果:

設定を読み込めませんでした
設定を読み込めませんでした: `99999` は正しいポートではありません: number too large to fit in target type
0: 設定を読み込めませんでした
1: `99999` は正しいポートではありません
2: number too large to fit in target type
▶ 実際に試してみよう

number too large to fit in target type は、「数が大きすぎて目的の型に収まりません」という標準ライブラリのメッセージです(u16 の最大値は65535です)。

3つ目の書式は {:?} です。これは複数行のレポートを表示します。main がエラーを返したときに表示されるのも、この形です。

println!("{err:?}");
設定を読み込めませんでした
Caused by:
0: `99999` は正しいポートではありません
1: number too large to fit in target type

Caused by: は「原因:」という意味で、anyhow が付ける見出しです。

環境変数 RUST_BACKTRACE=1 が設定されていると、{:?} はエラーが作られた場所を示すスタックバックトレースも追加で表示します。環境変数とプロセスのページも見てください。

実際によくある使い方は、ファイルの読み込みです。OS が出すメッセージ(「No such file or directory」)には、どのファイルなのかが書かれていないからです。

let text = std::fs::read_to_string(path)
.with_context(|| format!("設定ファイル {path} を読み込めませんでした"))?;

元になるエラーがない場合もあります。何かがおかしいと、自分で判断するだけのときです。anyhow には、そのための3つのマクロがあります。

  • bail!("...") は、その場ですぐにエラーを返します。return Err(anyhow!("...")) の短縮形です。
  • ensure!(条件, "...") は、条件が false ならエラーを返します。
  • anyhow!("...") は、エラーの値を作るだけで、返しはしません。

3つとも、format! と同じ書き方のメッセージを受け取ります。

use anyhow::{anyhow, bail, ensure, Result};
fn withdraw(balance: u32, amount: u32) -> Result<u32> {
ensure!(amount > 0, "金額は0より大きくなければいけません");
if amount > balance {
bail!("残高不足です:残高は{balance}、引き出そうとした額は{amount}");
}
Ok(balance - amount)
}
fn find_user(id: u32) -> Result<&'static str> {
match id {
1 => Ok("Ferris"),
_ => Err(anyhow!("ID {id} のユーザーはいません")),
}
}
fn main() {
println!("{:?}", withdraw(100, 30).ok());
for (balance, amount) in [(100, 0), (100, 250)] {
if let Err(e) = withdraw(balance, amount) {
println!("エラー: {e}");
}
}
println!("{}", find_user(1).unwrap());
println!("エラー: {}", find_user(7).unwrap_err());
}

実行結果:

Some(70)
エラー: 金額は0より大きくなければいけません
エラー: 残高不足です:残高は100、引き出そうとした額は250
Ferris
エラー: ID 7 のユーザーはいません
▶ 実際に試してみよう

anyhow がいちばんよく使われるのはここです。main が anyhow::Result<()> を返すようにして、あちこちで ? を使います。エラーが main まで届くと、Rust はそれを {:?} で標準エラー出力(stderr)に表示し、終了コード 1 で終了します。

use anyhow::{Context, Result};
fn main() -> Result<()> {
let text = "forty-two";
let n: i32 = text
.parse()
.with_context(|| format!("{text:?} を数値として読み取れませんでした"))?;
println!("{n}");
Ok(())
}
▶ 実際に試してみよう

標準出力(stdout)には何も表示されません。標準エラー出力には次のように表示されます。

Error: "forty-two" を数値として読み取れませんでした
Caused by:
invalid digit found in string

実際のプロジェクトでよく見る形を紹介します。inventory モジュールはライブラリの役で、thiserror で作った正確な InventoryError を返します。アプリケーションのコードは anyhow を使ってコンテキストを付け、本当に気になるところでだけエラーの中身を確かめます。downcast_ref は anyhow::Error に「実は中身はこの型だったりする?」と尋ねるメソッドで、コンテキストを付けたあとでも使えます。

// ----- 「ライブラリ」側:thiserror で正確なエラーを返す -----
mod inventory {
use thiserror::Error;
#[derive(Debug, Error)]
pub enum InventoryError {
#[error("`{0}` という商品はありません")]
UnknownItem(String),
#[error("残りは{available}個なので、{wanted}個は取り出せません")]
NotEnough { available: u32, wanted: u32 },
}
pub struct Store {
pub apples: u32,
}
impl Store {
pub fn take(&mut self, item: &str, wanted: u32) -> Result<(), InventoryError> {
if item != "りんご" {
return Err(InventoryError::UnknownItem(item.to_string()));
}
if wanted > self.apples {
return Err(InventoryError::NotEnough { available: self.apples, wanted });
}
self.apples -= wanted;
Ok(())
}
}
}
// ----- 「アプリケーション」側:anyhow とコンテキスト -----
use anyhow::{Context, Result};
use inventory::{InventoryError, Store};
fn process_order(store: &mut Store, id: u32, item: &str, amount: u32) -> Result<()> {
store
.take(item, amount)
.with_context(|| format!("注文 #{id} は失敗しました"))?;
println!("注文 #{id} 完了");
Ok(())
}
fn main() {
let mut store = Store { apples: 5 };
let orders = [(1, "りんご", 2), (2, "すもも", 1), (3, "りんご", 9)];
for (id, item, amount) in orders {
if let Err(e) = process_order(&mut store, id, item, amount) {
println!("エラー: {e:#}");
// 元の thiserror の型を取り出して中身を確かめることもできる
if let Some(InventoryError::NotEnough { available, .. }) =
e.downcast_ref::<InventoryError>()
{
println!(" ヒント: {available}個以下で注文してみてください");
}
}
}
println!("残りのりんご: {}", store.apples);
}

実行結果:

注文 #1 完了
エラー: 注文 #2 は失敗しました: `すもも` という商品はありません
エラー: 注文 #3 は失敗しました: 残りは3個なので、9個は取り出せません
ヒント: 3個以下で注文してみてください
残りのりんご: 3
▶ 実際に試してみよう

Context のインポートを忘れる。.context() は anyhow::Context トレイトのメソッドです。トレイトのメソッドは、そのトレイトがスコープに入っているときにしか使えません。

use anyhow::Result;
fn parse(text: &str) -> Result<i32> {
let n: i32 = text.parse().context("数値ではありません")?;
Ok(n)
}
error[E0599]: no method named `context` found for enum `Result<T, E>` in the current scope
--> src/main.rs:4:31
|
4 | let n: i32 = text.parse().context("数値ではありません")?;
| ^^^^^^^
|
= help: items from traits can only be used if the trait is in scope
help: trait `Context` which provides `context` is implemented but not in scope; perhaps you want to import it
|
1 + use anyhow::Context;
|

このエラーは「現在のスコープで、Result に context というメソッドが見つかりません」という意味です。help の部分で、Context トレイトをインポートすればいいと教えてくれています。

直し方:use anyhow::{Context, Result}; と書きます。

原因のメッセージを、自分のメッセージの中でもくり返してしまう。#[from] を付けると、中のエラーはすでに原因(source)になっています。そこへさらにメッセージに {0} も入れると、完全なレポートでは同じ内容が2回表示されます。

#[derive(Debug, thiserror::Error)]
enum AppError {
#[error("ポートが不正です: {0}")] // 原因のテキストを含めている……
BadPort(#[from] ParseIntError), // ……しかもこれが原因(source)でもある
}

これを anyhow 経由で {:#} で表示すると、ポートが不正です: invalid digit found in string: invalid digit found in string となってしまいます。直し方は2つです。#[error("ポートが不正です")] と書いて原因の表示はチェーンに任せるか、{0} を残してフィールドを原因として登録しないかです。どちらか一方を選びましょう。

{} で表示して原因が消えてしまう。anyhow::Error を println!("{e}") で表示すると、「設定を読み込めませんでした」のような、いちばん外側のメッセージしか見えません。人間に向けてエラーを表示するときは、{:#}(1行)か {:?}(完全なレポート)を使いましょう。

ライブラリの公開 API で anyhow を使う。ライブラリが anyhow::Result を返すと、呼び出す側はエラーを match できません。downcast_ref で型を推測するしかなくなってしまいます。公開する関数には thiserror の列挙型を返させ、anyhow はアプリケーション、テスト、サンプルコードで使いましょう。

  • thiserror はエラーを定義するためのものです。#[derive(Error)] と、バリアントごとの #[error("...")] メッセージが、手書きの Display と Error の実装の代わりになります。
  • #[from] を付けると、? が中のエラーを自分のバリアントに変換してくれて、それを原因として記録します。#[source] は変換なしで原因だけを記録します。
  • anyhow はエラーを上へ渡すためのものです。anyhow::Result<T> は、? でどんなエラーでも受け取れます。
  • .context() と .with_context() は、エラーチェーンに人間が読めるメッセージを1層追加します。チェーンは {:#}(1行)か {:?}(レポート)で表示します。
  • bail! はその場でエラーを返し、ensure! は条件が false ならエラーを返し、anyhow! はエラーを作ります。
  • よくある構成は、ライブラリのコードに thiserror、main に anyhow です。

理解度チェック

1. ライブラリを書いていて、呼び出す側はタイムアウトと不正な入力とで違う対応をする必要があります。いちばん合っているクレートはどれですか?

2. バリアントのフィールドに付けた #[from] は何をしますか?

3. anyhow::Error を、チェーン全体まで含めて1行で表示する書式はどれですか?

4. .context(...) ではなく .with_context(|| ...) を使うべきなのはどんなときですか?

5. amount が 0 のとき、ensure!(amount > 0, "金額は正の数でなければいけません") はどうなりますか?

スコア: 0 / 5