- はじめに — nullも例外もない世界
- 列挙型は代数的データ型
- Option — nullを型に
- match — 網羅的(exhaustively)に分解する
- if letとlet else — 1つの場合だけ扱うとき
- Result — 失敗を値に
- ?演算子 — エラー伝播の簡潔さ
- unwrapとexpect — いつパニックしてよいか
- thiserrorとanyhow — 実戦的なエラー型の設計
- 手で覚える
- おわりに
- 参考資料
はじめに — nullも例外もない世界
トニー・ホーアはnull参照を発明したことを「10億ドルの間違い」と呼びました。ほとんどの主流言語は、いまだにその間違いを抱えて生きています。値があるかもしれないし、ないかもしれないのに、型はその区別ができないからです。だからNullPointerExceptionが本番で私たちを待っています。
例外にも似た問題があります。関数のシグネチャだけを見ても、その関数がどんな例外を投げるのか、そもそも投げるのかどうかも分かりません(Javaのチェック例外はこれを直そうとして、たいてい嫌われただけでした)。エラー処理が型システムの外にあるので、コンパイラは「このエラーを処理していない」と指摘できません。
Rustはこの2つをまるごと取り除きました。nullの代わりにOption、例外の代わりにResult。どちらもごく普通の列挙型(enum)で、値の「不在」と「失敗の可能性」を型に入れます。 そしてその列挙型を安全に開く道具が**パターンマッチ(pattern matching)**です。コンパイラが「この場合を処理していない」と捕まえてくれるので、忘れることが不可能になります。
列挙型は代数的データ型
他の言語のenumは、たいてい名前付き整数定数の集合です。Rustのenumははるかに強力です。各バリアント(variant)が異なる種類のデータを持てます。
enum Shape {
Circle { radius: f64 }, // 名前付きフィールド
Rectangle { w: f64, h: f64 }, // 2つのフィールド
Point, // データなし
}
Shapeの値は、円か、長方形か、点か — ちょうど3つのうち1つです。この「AかBかC」という型を、型理論では**直和型(sum type)あるいはタグ付き共用体(tagged union)と呼びます。構造体(struct)が「AでありBでありC」の直積型(product type)なら、enumはその対称です。2つを合わせて代数的データ型(algebraic data type, ADT)**と呼び、これがRustのデータモデリングの核心です。
肝は、各バリアントが自分のデータを持つ点です。Cスタイルのenumのように「種類」だけを表すのではなく、その種類にぴったり合うデータを一緒に詰めます。これが次に出てくるOptionとResultを可能にします。
Option — nullを型に
Option<T>は「T値があるか、ないか」を表す標準ライブラリの列挙型です。定義は驚くほど単純です。
enum Option<T> {
Some(T), // 値がある、その値はT
None, // 値がない
}
これがnullを置き換えます。値が不在でありうるところには、TではなくOption<T>を書きます。すると、コンパイラが強制的に「不在の場合」を処理させます。
fn find_user(id: u32) -> Option<String> {
if id == 1 {
Some(String::from("Alice"))
} else {
None
}
}
fn main() {
let user = find_user(1);
// userはOption<String> — 直接文字列としては使えない
// println!("{}", user.len()); // コンパイルエラー! Optionにlenはない
}
nullとの決定的な違いはこれです。nullはどんな参照型にもこっそり入り込めるので、「この値はnullでありうるか?」をコードから判断できません。一方Option<T>は型に明示的に現れており、中のTを取り出すには必ずNoneの場合を先に処理しなければなりません。「不在でありうる」が型に書かれていて、コンパイラがその処理を強制します。 10億ドルの間違いが型システムの中に引き込まれたのです。
match — 網羅的(exhaustively)に分解する
Optionの中の値を取り出す定石はmatchです。matchは値をパターンと照合して合致するアームを実行しますが、Rustの決定的な安全機能は、matchが**網羅的(exhaustive)**でなければならないことです。可能なすべての場合を扱わなければコンパイルされません。
fn main() {
let user = find_user(1);
match user {
Some(name) => println!("見つかった: {name}"), // Someなら中のnameを束縛
None => println!("ユーザーなし"), // Noneの場合
}
}
Some(name)のnameは、Someの中にあったStringを取り出して束縛したものです。このようにパターンが値の構造を分解しつつ、中の値を変数に取り出すことを**分配束縛(destructuring)**と呼びます。
Noneのアームを省くとこうなります。
error[E0004]: non-exhaustive patterns: `None` not covered
--> src/main.rs:4:11
|
4 | match user {
| ^^^^ pattern `None` not covered
|
= help: ensure that all possible cases are being handled
この網羅性検査が静かな超能力です。後でenumにバリアントを1つ追加すると、そのenumを扱うすべてのmatchがコンパイルエラーになります。コンパイラが「新しい場合を処理せよ」と全数調査をしてくれるのです。リファクタリングの安全網として、これは計り知れない価値があります。新しい状態を追加して、どこかで処理を忘れて本番で爆発する、ということが原理的に防げます。
すべての場合を書きたくなければ、ワイルドカード_で「それ以外すべて」を捕まえられます。ただし_に頼りすぎると網羅性の恩恵が失われるので、本当にどうでもいい残りにだけ使うのがよいです。
fn describe(n: i32) -> &'static str {
match n {
0 => "ゼロ",
1 | 2 | 3 => "小さい数", // 複数パターンを | で
4..=9 => "1桁", // 範囲パターン
_ => "その他", // それ以外すべて
}
}
if letとlet else — 1つの場合だけ扱うとき
matchがすべての場合を要求するのが過剰なときがあります。「Someのときだけ何かして、Noneなら何もしない」といった場合です。ここではif letが簡潔です。
fn main() {
let config = find_user(1);
if let Some(name) = config {
println!("設定されたユーザー: {name}"); // Someのときだけ実行
}
// Noneならそのまま進む(elseは省略可能)
}
if let Some(name) = configは「configがSomeパターンに合致すればnameを束縛してブロックを実行せよ」という意味です。matchの1つのアームだけを切り出した糖衣構文です。elseも付けられます。
逆の状況もよくあります。「値があってこそ正常で、なければここで早く抜ける」。これはlet elseが優雅です(Rust 1.65から)。
fn greet(id: u32) -> String {
let Some(name) = find_user(id) else {
return String::from("ゲスト"); // Noneならここで関数終了
};
// この下ではnameがStringで確定し、そのまま使える
format!("ようこそ、{name}さん!")
}
let elseの美点は、成功経路をインデントの外に出すことです。if letで成功を包むとコードが右へずれ続けます(矢印コード)が、let elseは失敗を先に処理して抜けるので、残りの本体が平らに保たれます。「ガード節(guard clause)」をRustの型システムと組み合わせた形です。
Result — 失敗を値に
さてエラー処理です。Result<T, E>は「成功ならT、失敗ならE」を入れる列挙型です。
enum Result<T, E> {
Ok(T), // 成功、結果はT
Err(E), // 失敗、エラーはE
}
例外との核心的な違いはこれです。例外は関数の外へ投げられてどこかで捕まるか、プログラムを殺します。一方Resultはただの返り値です。失敗の可能性が関数のシグネチャに-> Result<T, E>と書かれていて、呼び出し側はその値を受け取り、OkかErrかを処理しなければなりません。エラー処理が制御フローの隠れた脇道ではなく、目に見える普通の値の流れになります。
use std::num::ParseIntError;
fn parse(s: &str) -> Result<i64, ParseIntError> {
s.parse::<i64>() // parseはResultを返す
}
fn main() {
match parse("42") {
Ok(n) => println!("数値: {n}"),
Err(e) => println!("パース失敗: {e}"),
}
}
Rustには例外がないので、失敗しうる標準ライブラリ関数(ファイルを開く、文字列をパースする、ネットワークリクエスト)はほぼすべてResultを返します。そしてResultは#[must_use]と印付けられているので、返されたResultを無視するとコンパイラが警告します。エラーを静かに握りつぶすことが難しく設計されているのです。
?演算子 — エラー伝播の簡潔さ
Resultを毎回matchでほどくと、コードはすぐに散らかります。特に失敗しうる操作をいくつもつなげるときです。そのために?演算子があります。
?はResult(またはOption)の後ろに付けて、こう動きます。Ok(v)なら中のvを取り出して式の値として使い、Err(e)ならそのErrを現在の関数から即座にreturnする。 つまり「成功なら続行、失敗ならこのエラーを上へ投げる」を1文字で表します。
use std::fs;
use std::io;
// ?なし — 冗長
fn read_len_verbose(path: &str) -> Result<usize, io::Error> {
let contents = match fs::read_to_string(path) {
Ok(c) => c,
Err(e) => return Err(e), // 失敗を手で伝播
};
Ok(contents.len())
}
// ?あり — 同じ動作、はるかに簡潔
fn read_len(path: &str) -> Result<usize, io::Error> {
let contents = fs::read_to_string(path)?; // 失敗すれば自動でErr返却
Ok(contents.len())
}
2つの関数はまったく同じことをします。?がそのmatch後の早期リターンパターンを代わりにやってくれるだけです。複数の操作をつなぐと真価が現れます。
fn process(path: &str) -> Result<i64, Box<dyn std::error::Error>> {
let text = fs::read_to_string(path)?; // io::Errorを伝播
let first_line = text.lines().next().unwrap_or("");
let number: i64 = first_line.trim().parse()?; // ParseIntErrorを伝播
Ok(number * 2)
}
ここで?がもう1つやってくれることがあります。エラー型の変換です。read_to_stringはio::Errorを、parseはParseIntErrorを出しますが、関数の返り型はBox<dyn std::error::Error>です。?はFromトレイトを通じて、各エラーを返り型へ自動変換します。異なるエラーを1つの返り型へなめらかにまとめてくれるのです。これが次節のエラー型設計につながります。
注意: ?は返り型がResult(またはOption)である関数の中でのみ使えます。返り型が合ってこそErrをreturnできるからです。
unwrapとexpect — いつパニックしてよいか
OptionやResultから値を強制的に取り出す.unwrap()と.expect()もあります。Ok/Someなら値をくれ、Err/Noneなら**パニック(panic)**してプログラムを終了します。
let n: i64 = "42".parse().unwrap(); // Ok(42) → 42
let m: i64 = "abc".parse().unwrap(); // Err → パニック!
let k: i64 = "abc".parse()
.expect("設定値は整数でなければなりません"); // パニック + このメッセージ
unwrapは便利ですが危険です。失敗が本当に不可能なところ(たった今検証した値など)や、プロトタイプ・例・テストでは問題ありません。しかし本番コードの正常経路で濫用してはいけません。 ユーザー入力やファイル・ネットワークのように失敗しうるものにunwrapを掛けるのは、例外を捕まえず放置するのと変わりません。そういうときは?で伝播するかmatchで扱ってください。expectは少なくとも「なぜここで失敗が不可能と見たか」をメッセージに残すので、unwrapよりexpectが優れています。
まとめると、panic!(とunwrap)は回復不能なバグ状況用で、Resultは回復可能な予想された失敗用です。「ファイルがないかもしれない」は回復可能(→ Result)、「配列インデックスが負」はプログラムロジックのバグ(→ パニック)です。
thiserrorとanyhow — 実戦的なエラー型の設計
標準ライブラリだけでもできますが、実戦のプロジェクトはたいてい2つのクレートを使います。役割がはっきり分かれます。
thiserrorはライブラリ用です。 ライブラリは、呼び出し側がエラーを種類ごとに区別して処理できるよう、具体的なエラー型を公開すべきです。thiserrorはそのカスタムエラーenumをボイラープレートなしで作ってくれます。
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("設定ファイルを読めません")]
Io(#[from] std::io::Error), // #[from]でio::Errorを自動変換
#[error("不正なポート番号: {0}")]
InvalidPort(u16), // データを持つバリアント
#[error("必須キーが欠落: {key}")]
MissingKey { key: String },
}
#[derive(Error)]がstd::error::Errorトレイト実装を、#[error("...")]が人間が読むメッセージ(Display)を自動生成します。#[from]を付けたバリアントはそのエラー型からの自動変換をサポートするので、関数の中で?1つでio::ErrorがConfigError::Ioに変わります。ライブラリの利用者はこのenumをmatchして、「IOの問題か、ポートが不正か」を区別して対応できます。
anyhowはアプリケーション用です。 最終アプリケーション(CLI、サーバー)の多くの地点では、エラーを種類ごとに区別する必要はなく、「失敗した、そしてこういう文脈だった」だけ分かればよいです。anyhow::Errorはどんなエラーでも入れる単一の型なので、さまざまなエラーを?で自由に混ぜて伝播できます。
use anyhow::{Context, Result};
fn load_config(path: &str) -> Result<String> { // anyhow::Result
let text = std::fs::read_to_string(path)
.with_context(|| format!("設定ファイルを開けませんでした: {path}"))?; // 文脈を追加
let port_line = text.lines().next()
.context("ファイルが空です")?;
Ok(port_line.to_string())
}
anyhowの強みは.context()です。エラーに「何をしようとして失敗したか」の文脈を層状に積んでくれるので、最終的なエラーメッセージが「ファイルなし」のような低レベルのものではなく、「設定ファイルを開けませんでした: /etc/app.conf → ファイルなし」のような追跡可能な連鎖になります。
まとめると: ライブラリを作るならthiserrorで具体的なエラー型を公開し、アプリケーションを作るならanyhowで文脈を付けて手軽に伝播しましょう。この2つのクレートの組み合わせが、今日のRustエラー処理の事実上の標準です。
手で覚える
matchの網羅性や?の伝播の挙動は、自分でコンパイルしてエラーに出会ってこそ確実に理解できます。Rust学習ラボでOption・Resultの分解と?演算子をさまざまなシナリオで実験すると、「コンパイラが自分の代わりに場合を漏らさないよう見張ってくれる」という感覚が手に馴染みます。
おわりに
Rustのエラー処理の哲学は一文にまとまります。失敗は値だ。 nullと例外という2つの隠れた脇口を閉じ、「不在」と「失敗」をOption・Resultという普通の値として型に入れます。その値を開く鍵がパターンマッチで、コンパイラの網羅性検査が「この場合を忘れた」を代わりに捕まえてくれます。
その代償として、最初はコードが少し冗長に見えるかもしれません。しかし?演算子がその冗長さの大半を取り払い、残るのはどこで何が失敗しうるかがコードに正直に現れたプログラムです。本番の午前3時にNullPointerExceptionのスタックトレースを眺める代わりに、コンパイル時に「この場合を処理していません」と聞く方を選ぶ — それがこの設計の取引です。
参考資料
- The Rust Programming Language, 第6章 "Enums and Pattern Matching": https://doc.rust-lang.org/book/ch06-00-enums.html
- The Rust Programming Language, 第9章 "Error Handling": https://doc.rust-lang.org/book/ch09-00-error-handling.html
- thiserror クレート: https://docs.rs/thiserror/
- anyhow クレート: https://docs.rs/anyhow/
- Rust by Example — Error handling: https://doc.rust-lang.org/rust-by-example/error.html
현재 단락 (1/153)
トニー・ホーアはnull参照を発明したことを「10億ドルの間違い」と呼びました。ほとんどの主流言語は、いまだにその間違いを抱えて生きています。値があるかもしれないし、ないかもしれないのに、型はその区別...