はじめに
Rust の match は「全てのケースを網羅していないとコンパイルエラーになる」という性質があります。初心者向けの解説ではよく「型安全性の一例」として紹介される機能ですが、実際にこれが効いてくる場面を自分の手で実装していくことで、じっくりと体感することができました。
これは Scheme インタプリタを Rust で書いていたときに、コンパイラに助けられた話です。「ああ、これは単なるコンパイラの優しさじゃなくて、バグを未然に防ぐ安全装置だったんだ」と腑に落ちた瞬間の記録です。
舞台:Scheme の「値」を Rust の enum で表現する
Scheme の値にはいくつかの種類があります。数値、文字列、真偽値、リストなど。これを Rust の enum で素直に表現できます。
#[derive(Debug, Clone)]
enum Value {
Number(f64),
Str(String),
Bool(bool),
List(Vec<Value>),
}
この Value 型の変数には、4つのうちどれか1つしか入っていないことが 型レベルで保証 されます。Python の辞書やリストに何でも詰め込むときの「これ本当に何が入ってるんだっけ?」という不安とは、もう無縁になれます。
これらの値を扱う関数は、match で場合分けすれば簡単に書けます:
fn to_string(v: &Value) -> String {
match v {
Value::Number(n) => n.to_string(),
Value::Str(s) => format!("\"{}\"", s),
Value::Bool(b) => b.to_string(),
Value::List(xs) => {
let parts: Vec<String> = xs.iter().map(to_string).collect();
format!("({})", parts.join(" "))
}
}
}
関数が増えても同じパターン。evaluate も、equal? も、display も、どれも Value の各バリアントを処理する match で書いていきます。
事件:クロージャを追加したくなった
処理系が育ってきて、関数オブジェクト(クロージャ) を値の一種として扱いたくなりました。lambda を評価すると、引数リストと本体とキャプチャした環境を持つ値が生まれる。これも Value の新しいバリアントとして足すのが自然です:
#[derive(Debug, Clone)]
enum Value {
Number(f64),
Str(String),
Bool(bool),
List(Vec<Value>),
Closure {
params: Vec<String>,
body: Box<Expr>,
env: Rc<RefCell<Env>>,
},
}
バリアントを1つ足しました。これだけの変更です。他に触った場所はありません。
さて、保存して cargo build。
コンパイルエラーの嵐(ただし、いい意味で)
出てきたのはこんなエラーでした:
error[E0004]: non-exhaustive patterns: `Closure { .. }` not covered
--> src/main.rs:45:11
|
45 | match v {
| ^ pattern `Closure { .. }` not covered
しかも、同じエラーが Value を match している全ての箇所で出ています。to_string、evaluate、equal_values、display_value、それぞれ違うファイル・違う関数で、コンパイラが全部指摘してくれる。
error[E0004]: non-exhaustive patterns: `Closure { .. }` not covered
--> src/eval.rs:112:11
error[E0004]: non-exhaustive patterns: `Closure { .. }` not covered
--> src/display.rs:23:11
error[E0004]: non-exhaustive patterns: `Closure { .. }` not covered
--> src/equal.rs:8:11
これは怒られているのではなく、手厚いガイダンスです。「Closure を足したけど、この関数、この関数、この関数で処理を書き忘れていませんか?」と、全部の現場を網羅的に教えてくれている。修正の方針が明確で、指摘された箇所を順に埋めていけば、抜け漏れなく対応できます。
もし他の言語だったら
この手のバグが、他の言語ではどう顕在化するかを考えると、Rust のありがたみが際立ちます。
Python の場合
class Value:
pass
class Number(Value): pass
class Str(Value): pass
class Closure(Value): pass # ← 新しく追加
def to_string(v):
if isinstance(v, Number):
return str(v.n)
elif isinstance(v, Str):
return f'"{v.s}"'
# Bool、List、Closure の分岐が無い → Noneが返る
Closure を渡すと、静かに None が返ってくるだけ。単体テストを網羅的に書いていなければ、リリースするまで気づかない。
TypeScript の場合
switch 文では網羅チェックされない(never 型のトリックを使えば疑似的にできるが、言語組み込みではない)。デフォルト分岐を忘れると、未定義値のまま処理が続く。
Rust の場合
コンパイルが通らない。そもそも実行できない。バグが実行時まで到達しない。
小さなコメントだけど、安全性の哲学
non-exhaustive patterns は、初めて見たときは「なんでこのエラーが出るの、面倒だな」と思うかもしれません。でも、enum でドメインを型レベルで表現して、match でそれを処理するスタイルで書くと、このエラーが「抜け漏れを指摘してくれる守護神」に変わります。
特に Scheme のような 値の種類が明確に決まっている 対象を扱うときは効きます。新しい値の種類を追加するたびに、全ての処理関数で考慮漏れがないかコンパイラが保証してくれる。リファクタリング時の安心感が、他の言語とは違う質のものになります。
まとめ
- Rust の
enum+matchは「場合分けの網羅性」を型レベルで強制する - バリアントを追加したら、それを使う全ての
matchでnon-exhaustive patternsエラーが出る - これはコンパイラからの親切なガイダンス。修正箇所を網羅的に教えてくれる
- 他の言語では静かに
Noneが返ったり、未定義の挙動になったりする類のバグが、実行前に全部検知される -
enum+matchはデータドメインを型で表現し、その処理を網羅的に書くための強力な組み合わせ
Scheme インタプリタのように 値の種類を列挙できる領域 を扱うコードでは、この性質が特に効いてきます。最初は少し窮屈に感じるかもしれませんが、育ってきたコードベースに変更を加えていくとき、match 網羅性チェックは本当に頼りになります。
参考
この記事は、私が書いた技術書『Rustで作るSchemeインタプリタ』の実装過程で感じたことから生まれました。書籍では、約1,000行の Scheme 処理系を Rust で構築する過程で、enum と match を使った値の表現、評価器の構造、eval と apply の相互再帰などを扱っています。
- 書籍: Rustで作るSchemeインタプリタ
- ソースコード: GitHub DrK-Labo/mini-scheme
- 著者 X: @DrKLaboratory