Rust 基礎講座 #5 構造体と列挙型 — match と Option、null がない言語の設計
所有権と借用という山場を越えたので、今度はデータをプログラムの形に組み立てる道具を学ぶ番です。Rust の答えは 2 つです。「これらの値はいつも一緒に動く」を表す構造体(struct)と、「この値はいくつかの姿のうちの 1 つだ」を表す列挙型(enum)です。特に列挙型と match の組み合わせは、Rust で null ポインタエラーが構造的に不可能である理由へとつながります。
構造体 — 一緒に動く値の束 #
struct User {
email: String,
name: String,
active: bool,
}
let user = User {
email: String::from("tanaka@example.com"),
name: String::from("田中太郎"),
active: true,
};
println!("{}", user.email);クラスのある言語のデータ部分と似ていますが、継承はありません。動作は impl ブロックで別に付けます。
impl User {
fn display_name(&self) -> String {
format!("{} <{}>", self.name, self.email)
}
fn deactivate(&mut self) {
self.active = false;
}
}最初の引数が、そのまま前回の借用規則です。読むだけのメソッドは &self、状態を変えるメソッドは &mut self で受け取ります。メソッドのシグネチャを見るだけでその呼び出しが値を変えるかどうかが分かり、mut で宣言していないインスタンスで deactivate を呼ぶとコンパイルエラーになります。デバッグ出力は、構造体の宣言の上に #[derive(Debug)] を付けて println!("{user:?}") で出すのが基本ですが、この derive の正体は第 8 回で扱います。
列挙型 — いくつかの姿のうちの 1 つ #
他の言語の列挙型が「名前の付いた定数の一覧」に近いとすれば、Rust の列挙型はバリアントごとに異なるデータを持てます。
enum PaymentMethod {
Cash,
Card { number: String, installments: u8 },
Transfer(String), // 銀行口座
}現金には追加情報がなく、カードには番号と分割回数が必要で、銀行振込は口座の文字列 1 つで足ります。こうしたデータを 1 つの構造体に nullable なフィールドの寄せ集めとして詰め込むと、「現金なのにカード番号がある」という矛盾した状態が可能になってしまいます。列挙型はその矛盾を型のレベルで取り除きます。不正な状態を表現すらできないようにすることが、Rust のデータ設計の核心的な習慣です。
match — すべてのケースを検査したか #
列挙型を消費する道具が match です。
fn fee(method: &PaymentMethod) -> u32 {
match method {
PaymentMethod::Cash => 0,
PaymentMethod::Card { installments, .. } if *installments > 3 => 500,
PaymentMethod::Card { .. } => 300,
PaymentMethod::Transfer(_) => 100,
}
}switch に似て見えますが、決定的な違いが 2 つあります。第一に、match は式なので各アームの値がそのまま返ります(第 2 回の「すべてが式」がここで真価を発揮します)。第二に、すべてのバリアントを処理しないとコンパイルエラーです。
error[E0004]: non-exhaustive patterns: `PaymentMethod::Transfer(_)` not coveredTransfer のアームを消すとこのエラーが出ます。後から列挙型にバリアントを追加すると、その列挙型を match しているすべてのコードがコンパイルエラーに変わります。「決済手段を 1 つ追加したのに、処理していない箇所がどこかに残っている」という種類のバグが、コンパイラの作る作業リストに変わるのです。残り全部を意味する _ アームもありますが、この安全網を切る行為なので、既定の処理が本当に正しいときだけ使います。
Option — null を代替する列挙型 #
Rust には null がありません。代わりに、標準ライブラリの列挙型が 1 つ、その役割を担います。
enum Option<T> {
Some(T),
None,
}「値がないかもしれない」が型に現れ、コンパイラがその処理を強制します。実はもう何度も出会っています。第 2 回で parse の結果を unwrap で取り出したのは、この処理(正確には次回扱う Result の処理)を飛ばす行為でした。
fn find_user(email: &str) -> Option<User> { /* ... */ }
match find_user("tanaka@example.com") {
Some(user) => println!("{}", user.display_name()),
None => println!("該当するユーザーがいません。"),
}Option<User> は User とは別の型なので、「ない可能性」を処理する前に中の値を使うことはできません。null チェックを忘れて起きる NullPointerException 系の事故が、コンパイルエラーに変わる構造です。1 つのケースだけに関心があるときは、match より if let が簡潔です。
if let Some(user) = find_user("tanaka@example.com") {
println!("{}", user.display_name());
}unwrap() は、None ならそのままパニックで落ちる近道です。例やプロトタイプでは便利ですが、実務コードで unwrap を使ってよいかの判断基準は、次回エラー処理の全体とあわせて整理します。
まとめ #
- 構造体は一緒に動く値の束で、動作は
implブロックで付けます。&selfと&mut selfの区別に借用規則がそのまま続いています。 - Rust の列挙型はバリアントごとにデータを持ちます。矛盾した状態を型のレベルで表現できなくするのが設計の核心的な習慣です。
matchは、すべてのケースの処理をコンパイラが強制する式です。バリアントの追加が、そのまま「処理漏れ箇所のコンパイルエラー一覧」になります。- null の代わりに
Option<T>があります。値がない可能性が型に現れるので、null チェック漏れというバグのクラスが消えます。 - 次回はエラー処理です。Option の兄弟である
Result、エラーを伝播する?演算子、そして panic との境界を扱います。