Result と Option: Rust型のエラー処理

1. 概要

Rust には Java や C# のような例外がありません。代わりに Rust は二つのデータ型 Option<T> と Result<T, E> を使用して、「値が存在しない可能性がある」と「失敗する可能性がある」を表現します。この記事は、これら二つの型を正しく使う方法、? 演算子を使ったスマートなエラー伝播、そして thiserror でカスタムエラーを定義する方法を理解するのに役立ちます。この記事を読み終えた後、あなたは try/catch に頼らず、明確にエラーを処理する Rust コードを書けるようになります。

2. なぜ例外を使わないのか?

Java では、メソッドはいつでも RuntimeException をスローする可能性があり、シグネチャには事前に明示されません。Javadoc またはコードを読んで、どのような例外をスローする可能性があるのかを確認する必要があります。

Rust は関数の戻り値の型で失敗の可能性を明示することを強制します:

fn divide(a: i32, b: i32) -> Result<i32, String> {
    if b == 0 {
        return Err("ゼロで除算".to_string());
    }
    Ok(a / b)
}

シグネチャ Result<i32, String> を見るだけで、この関数が失敗する可能性があることがすぐに分かります。コンパイラは Ok と Err の両方のブランチを処理することを強制し、Java で try/catch を忘れるようにうっかり失敗を無視することはできません。

注意: Rust にはまだ panic! があり、これは回復不可能なエラー(プログラミングバグ、不変条件の違反)に使用されます。Result は予測可能で処理可能なエラー用です。

3. Option: 値が存在しない可能性

Option<T> は null の代わりになります。Some(T) と None の二つのバリアントを持ちます。

fn find_customer(id: u32) -> Option<String> {
    let customers = vec![(1, "Alice"), (2, "Bob")];
    customers.iter()
        .find(|(cid, _)| *cid == id)
        .map(|(_, name)| name.to_string())
}

fn main() {
    match find_customer(2) {
        Some(name) => println!("見つかった: {name}"),
        None => println!("このお客さんはいません"),
    }
}

find は Option<&(u32, &str)> を返し、map は Some の中の値を変換し、None の場合はスキップします。結果は:

見つかった: Bob

3.1. よく使うメソッド

let maybe_age: Option<u32> = Some(25);

let age = maybe_age.unwrap_or(18);          // 25、または None の場合は 18
let doubled = maybe_age.map(|a| a * 2);     // Some(50)
let result: Result<u32, &str> = maybe_age.ok_or("年齢が不足しています"); // Ok(25)
  • unwrap_or(default): 値を取得するか、デフォルト値を返す、絶対にパニックしない。
  • map(f): 内部の値があれば変換し、None ならそのままにする。
  • ok_or(err): Option<T> を Result<T, E> に変換する — 次のステップで ? 演算子を使うときに便利。

注意: Option/Result に対して直接 .unwrap() を呼び出すことを避けてください。None/Err に出会うとすぐにパニックします。これは制御できない NullPointerException をスローするのと同じです。テストの中か、値が 100% 存在することが確実な場合のみ使用します。

4. Result<T, E>: 成功またはエラー

Result<T, E> は Ok(T) で成功、Err(E) で失敗という二つのバリアントを持ちます。

use std::num::ParseIntError;

fn parse_quantity(input: &str) -> Result<u32, ParseIntError> {
    input.trim().parse::<u32>()
}

fn main() {
    match parse_quantity("  42 ") {
        Ok(qty) => println!("数量: {qty}"),
        Err(e) => println!("パースエラー: {e}"),
    }
}

parse::<u32>() は Result<u32, ParseIntError> を返します。match により両方のブランチを処理する必要があります — 何かのブランチがなければ、コンパイラは翻訳を拒否します。

数量: 42

4.1. エラー伝播用の ? 演算子

すべての場所で match を書くのは冗長です。? 演算子は Err に出会うと直ちにエラーを伝播させます。自動的な throw のようなものです:

use std::num::ParseIntError;

struct Order {
    quantity: u32,
    price: u32,
}

fn parse_order(qty_str: &str, price_str: &str) -> Result<Order, ParseIntError> {
    let quantity = qty_str.trim().parse::<u32>()?;
    let price = price_str.trim().parse::<u32>()?;
    Ok(Order { quantity, price })
}

fn main() {
    match parse_order("3", "15000") {
        Ok(order) => println!("合計金額: {}", order.quantity * order.price),
        Err(e) => println!("データが無効です: {e}"),
    }
}

行 qty_str.trim().parse::<u32>()? の意味は: parse が Err を返す場合、関数 parse_order はすぐに その Err を呼び出し元に返します。手動で match を書く必要はありません。Ok の場合、内側の値は「開かれ」て quantity に割り当てられます。

合計金額: 45000

注意: ? 演算子は、現在の関数のエラー型と呼び出された関数のエラー型が互換性がある場合にのみ使用できます(または From の実装で変換できます)。そうでない場合、コンパイラはビルド時にすぐにタイプミスマッチエラーを報告します。

5. Option と Result の組み合わせ

これら二つの型はしばしば混在します。例えば、在庫内で製品を見つけてから、在庫が充分かどうかを確認します:

struct Product {
    name: String,
    stock: u32,
}

fn find_product(name: &str, products: &[Product]) -> Option<&Product> {
    products.iter().find(|p| p.name == name)
}

fn check_stock(name: &str, requested: u32, products: &[Product]) -> Result<(), String> {
    let product = find_product(name, products)
        .ok_or_else(|| format!("製品が見つかりません: {name}"))?;

    if product.stock < requested {
        return Err(format!("残り {} 個のみ", product.stock));
    }
    Ok(())
}

ok_or_else は Option<&Product> を Result<&Product, String> に変換し、エラーメッセージを付けます。そして ? は None の場合に伝播させます。このような書き方はネストした match を何段階も避けられます。

6. thiserror でカスタムエラーを定義

String をエラー型として使うのは小さな例では便利ですが、本番プロジェクトでは構造化された enum エラーが必要です。crate thiserror はこれを簡潔にしてくれます:

<!-- Cargo.toml に相当、dependencies セクション -->
# Cargo.toml
[dependencies]
thiserror = "1.0"
use thiserror::Error;

#[derive(Error, Debug)]
enum OrderError {
    #[error("製品が存在しません: {0}")]
    ProductNotFound(String),

    #[error("在庫が不足しています、残り {available} 個")]
    InsufficientStock { available: u32 },
}

fn validate_order(stock: Option<u32>, requested: u32) -> Result<(), OrderError> {
    let available = stock.ok_or_else(|| OrderError::ProductNotFound("ノートパソコン".into()))?;
    if available < requested {
        return Err(OrderError::InsufficientStock { available });
    }
    Ok(())
}

#[derive(Error, Debug)] は自動的に std::error::Error トレイトの実装を生成します。#[error("...")] は各バリアント用のメッセージを定義し、{0}(位置指定)や {available}(フィールド名)などのプレースホルダをサポートします。エラーを出力するときの結果:

Error: InsufficientStock { available: 2 }

{:?} の代わりに {} を使ってエラーを出力する場合、親切なメッセージが得られます: 在庫が不足しています、残り 2 個.

7. anyhow はアプリケーション用、thiserror はライブラリ用

バイナリ/アプリケーション(他の人が使うライブラリではない)を書く場合、通常はエラーのタイプを詳細に区別する必要はありません — 伝播して出力するだけで十分です。crate anyhow はこのシーンに最適です:

[dependencies]
anyhow = "1.0"
use anyhow::{Context, Result};

fn load_config(path: &str) -> Result<String> {
    std::fs::read_to_string(path)
        .with_context(|| format!("設定ファイルが読み込めません: {path}"))
}

fn main() -> Result<()> {
    let config = load_config("app.toml")?;
    println!("設定: {config}");
    Ok(())
}

anyhow::Result<T> は Result<T, anyhow::Error> の別名で、Error トレイトを実装している任意のエラーを含むことができます。with_context は元のエラーに文脈情報を追加します。カスタム enum を定義することなく、デバッグしやすくなります。

  • ライブラリ (use される crate): thiserror を使用し、呼び出し元が各ケースを match して処理できるようにする具体的なエラー enum を返します。
  • アプリケーション (最終的なバイナリ): anyhow を使用し、伝播してログするだけで、誰も詳細な match を必要としません。

8. エラー動作のテスト

Rust には統合テストフレームワークが組み込まれており、基本的なテストのために外部ライブラリを追加する必要はありません。

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn returns_ok_when_stock_sufficient() {
        let result = validate_order(Some(10), 5);
        assert!(result.is_ok());
    }

    #[test]
    fn returns_err_when_stock_insufficient() {
        let result = validate_order(Some(2), 5);
        assert!(matches!(
            result,
            Err(OrderError::InsufficientStock { available: 2 })
        ));
    }

    #[test]
    fn returns_err_when_product_not_found() {
        let result = validate_order(None, 5);
        assert!(matches!(result, Err(OrderError::ProductNotFound(_))));
    }
}

#[test] はテスト関数をマークし、cargo test で実行します。matches! は値がパターンに一致しているかを確認するマクロです — enum にフィールドがある場合、assert_eq! より便利です。全体の error type に PartialEq を実装する必要がないからです。

running 3 tests
test tests::returns_ok_when_stock_sufficient ... ok
test tests::returns_err_when_stock_insufficient ... ok
test tests::returns_err_when_product_not_found ... ok

9. よくある間違い

  • .unwrap() の過度な使用: コードは開発中は正常に実行されますが、本番環境で予期しない入力に遭遇するとパニックします。代わりに ?、match、または明確な処理を伴う unwrap_or_else を使用します。
  • 公開ライブラリでエラー型として String を使う: 呼び出し元はエラーの種類で match できず、文字列を比較することしかできません — メッセージを変更する際に壊れやすくなります。String ではなく thiserror を持つ enum を使用してください。
  • anyhow::Error をライブラリの公開 API に混ぜる: 具体的なエラー型の情報が隠れてしまい、呼び出し元は手動で downcast する必要があります。anyhow は最終的なアプリケーション層でのみ使用すべきです。
  • #[derive(Debug)] の忘却: ? 演算子と多くのテストマクロは error type が Debug を実装することを要求します。それがないと、コンパイラはトレイトバウンドが満たされていないというエラーを報告します。

注意: Java から来た場合で、checked exception を「シミュレート」したいなら、マクロで try/catch の真似をしようとしないでください。代わりに、エラーは値(value)であり、他のデータと同じように返されて伝播されるという思考方法に慣れてください。

10. まとめ

Option<T> と Result<T, E> は、「存在しない」と「失敗」の処理をタイプシステムの一部にし、実行時ではなくコンパイル時にコンパイラによってチェックされます。? 演算子は明示的に保ちながらコードを短くするのに役立ちます。小さなプロジェクトでは String か anyhow で十分です。共有ライブラリでは、呼び出し元が各ケースを安全に処理できるように、thiserror で明確なエラー enum に投資してください。

rusterror-handlingoptionresultthiserror