RustのEnumとパターンマッチング
1. 概要
多くの言語では、enumはそれぞれ名前を持つ定数のリストに過ぎません。Rustでは、enumの各バリアント(variant)が独自のデータを持つことができます。matchと組み合わせると、コンパイラはすべてのケースを処理することを強制します。
この記事は、enumの定義方法からmatch、guard、binding @を経由して、Option、Result、if let、let else、matches!まで扱います。読み終わる頃には、ビジネスロジックの状態をenumでモデル化し、一般的なエラーを避けることができるようになるでしょう。
この記事はRust 1.65以上(let elseが必要)を使用しており、外部ライブラリは不要です。
2. データを持つenumの定義
例えば、注文の状態を説明する必要があるとします:
#[derive(Debug, Clone, PartialEq)]
enum OrderStatus {
Pending,
Paid(u64),
Shipped { carrier: String, tracking: String },
Cancelled { reason: String },
}
fn main() {
let statuses = vec![
OrderStatus::Pending,
OrderStatus::Paid(250_000),
OrderStatus::Shipped {
carrier: "GHN".to_string(),
tracking: "GHN123".to_string(),
},
OrderStatus::Cancelled { reason: "在庫切れ".to_string() },
];
for s in &statuses {
println!("{:?}", s);
}
}
このenumは3種類のバリアントを持ちます:
Pendingはunit variantで、データを持ちません。Paid(u64)はtuple variantで、位置で指定された金額を持ちます。Shipped { .. }とCancelled { .. }はstruct variantで、名前のついたフィールドを持ちます。
#[derive(Debug, Clone, PartialEq)]は、{:?}で値を印字したり、clone()でコピーしたり、==で比較することを可能にします。比較は後でテストを書くときに必要になります。
Pending
Paid(250000)
Shipped { carrier: "GHN", tracking: "GHN123" }
Cancelled { reason: "在庫切れ" }
重要なポイント:Pendingな注文が「誤って」追跡番号を持つことはできません。データは必要なバリアントにだけ存在します。多くのフィールドを持つOptionでstructを使う場合とは異なり、この保証があります。
3. 基本的なmatchと完全性(exhaustiveness)
matchは値を各パターンと上から下へ照合します。最初に一致したブランチが実行されます:
fn describe(status: &OrderStatus) -> String {
match status {
OrderStatus::Pending => "支払い待ち中".to_string(),
OrderStatus::Paid(amount) => format!("支払い済み {amount} 円"),
OrderStatus::Shipped { carrier, tracking } => {
format!("{}で配送中、追跡番号:{}", carrier, tracking)
}
OrderStatus::Cancelled { reason } => format!("キャンセル: {reason}"),
}
}
注意すべき点:
statusは&OrderStatus型です。default binding modesにより、通常の値のようにパターンを書けます。amount、carrier、trackingなどの変数は参照(&u64、&String)になり、データが移動することはありません。matchは式です。すべてのブランチは同じ型を返す必要があり、ここではStringです。
ここでCancelledブランチを削除してみてください。コンパイラはビルドを拒否します:
error[E0004]: non-exhaustive patterns: `&OrderStatus::Cancelled { .. }` not covered
--> src/main.rs:12:11
|
12 | match status {
| ^^^^^^ pattern `&OrderStatus::Cancelled { .. }` not covered
これがmatchの最大の価値です。新しいバリアントを追加すると、それを処理していないmatchはすべてエラーになります。どこを修正すればいいか手動で探す必要がありません。
4. 高度なパターン:tuple、|、guard、range、@
パターンはenumだけに限りません。複数の条件を同時にチェックするためにtupleにマッチさせることができます:
enum CustomerTier {
Regular,
Silver,
Gold,
}
fn discount(tier: &CustomerTier, total: u64) -> u64 {
match (tier, total) {
(CustomerTier::Gold, _) => total / 10,
(CustomerTier::Silver, t @ 1_000_000..) => t / 20,
(CustomerTier::Silver | CustomerTier::Regular, t) if t >= 2_000_000 => 50_000,
_ => 0,
}
}
fn main() {
println!("{}", discount(&CustomerTier::Gold, 300_000));
println!("{}", discount(&CustomerTier::Silver, 1_200_000));
println!("{}", discount(&CustomerTier::Regular, 2_500_000));
println!("{}", discount(&CustomerTier::Regular, 100_000));
}
各ブランチは以下のように動作します:
_はすべての値に一致し、変数をバインドしません。t @ 1_000_000..はtotalが100万以上のrangeにあることをチェックし、同時にその値をtにバインドします。CustomerTier::Silver | CustomerTier::Regularは|を使って複数のパターンを1つのブランチにまとめます。if t >= 2_000_000はmatch guardです。これはパターンが一致した後に評価される追加条件です。_ => 0はデフォルトブランチです。u64には無限の値があるため、このブランチは必須です。
30000
60000
50000
0
structバリアントの場合、..を使って気にしないフィールドを省略できます:
fn shipping_fee(status: &OrderStatus) -> u64 {
match status {
OrderStatus::Shipped { carrier, .. } if carrier == "GHN" => 22_000,
OrderStatus::Shipped { .. } => 30_000,
_ => 0,
}
}
注意: コンパイラは完全性をチェックするときにguardを使用しません。
Shipped { .. } if ...ブランチだけを書いてguardなしのShippedブランチがない場合でも、すべてのケースをカバーするために別のブランチを追加する必要があります。
5. OptionとResultもまたenumに過ぎない
標準ライブラリはOption<T>(Some(T)とNoneを含む)とResult<T, E>(Ok(T)とErr(E)を含む)を定義しています。これらはenumなので、上記のツールを使ってまったく同じように処理します:
fn find_customer(id: u32) -> Option<&'static str> {
match id {
1 => Some("太郎"),
2 => Some("花子"),
_ => None,
}
}
fn parse_quantity(input: &str) -> Result<u32, String> {
match input.trim().parse::<u32>() {
Ok(0) => Err("数量は0より大きい必要があります".to_string()),
Ok(n) => Ok(n),
Err(e) => Err(format!("無効です: {e}")),
}
}
fn main() {
println!("{:?}", find_customer(2));
println!("{:?}", find_customer(9));
println!("{:?}", parse_quantity(" 3 "));
println!("{:?}", parse_quantity("0"));
println!("{:?}", parse_quantity("abc"));
}
Ok(0)はネストされたパターンです。結果がOkであり、かつ内部の値が0である場合にのみ一致します。Ok(0)がOk(n)の前にあるため、0は一般的なブランチに到達する前に処理されます。
Some("花子")
None
Ok(3)
Err("数量は0より大きい必要があります")
Err("無効です: invalid digit found in string")
Rustにはnullがありません。「存在しないかもしれない」値はすべてOptionを通す必要があり、コンパイラはNoneケースを処理することを強制します。
6. 簡潔な構文:if let、let else、while let、matches!
1つのバリアントだけに関心がある場合、完全なmatchは冗長になります。Rustはこのケース用にいくつかの短い構文を提供しています。
6.1. if let
fn tracking_code(status: &OrderStatus) -> Option<&str> {
if let OrderStatus::Shipped { tracking, .. } = status {
Some(tracking.as_str())
} else {
None
}
}
if letはパターンが一致すると、ブロックを実行します。else部分はオプションです。この構文はすべてのケースを処理することを強制しないため、他のバリアントが本当に重要でない場合のみ使用してください。
6.2. let else
let elseは「早期リターン」パターンに適しています:一致すればデータを取得し、そうでなければすぐに終了します。
fn charge(status: &OrderStatus) -> Result<u64, String> {
let OrderStatus::Paid(amount) = status else {
return Err("注文はまだ支払われていません".to_string());
};
Ok(*amount)
}
let行の後、amount変数は追加のネストなしで関数の残りで使用できます。elseブロックは必須で、return、break、continue、またはpanic!を使って現在のフローから抜ける必要があります。
6.3. while letとmatches!
fn main() {
let mut queue = vec![
OrderStatus::Paid(100_000),
OrderStatus::Pending,
OrderStatus::Paid(400_000),
];
let paid = queue
.iter()
.filter(|s| matches!(s, OrderStatus::Paid(_)))
.count();
println!("支払い済み注文数: {paid}");
while let Some(status) = queue.pop() {
println!("処理中: {status:?}");
}
}
matches!(値, パターン)はboolを返します。このマクロはfilterやassert!で非常に便利です。while let Some(status) = queue.pop()はpop()がNoneを返すまでループします。
支払い済み注文数: 2
処理中: Paid(400000)
処理中: Pending
処理中: Paid(100000)
7. enumにメソッドを付与:状態遷移のモデル化
enumも構造体と同じくimplを持ちます。便利な使い方は有効な状態遷移を表現することです:
impl OrderStatus {
fn ship(self, carrier: &str, tracking: &str) -> Result<OrderStatus, String> {
match self {
OrderStatus::Paid(_) => Ok(OrderStatus::Shipped {
carrier: carrier.to_string(),
tracking: tracking.to_string(),
}),
other => Err(format!("状態 {:?} の注文は配送できません", other)),
}
}
fn cancel(self, reason: &str) -> Result<OrderStatus, String> {
match self {
OrderStatus::Pending | OrderStatus::Paid(_) => Ok(OrderStatus::Cancelled {
reason: reason.to_string(),
}),
other => Err(format!("状態 {:?} の注文はキャンセルできません", other)),
}
}
fn is_final(&self) -> bool {
matches!(self, OrderStatus::Cancelled { .. })
}
}
いくつかのポイント:
shipとcancelはselfを値として受け取るため、古い状態は消費されます。状態遷移後に古い状態変数を続けて使うことはできません。other => ...ブランチはすべての残りの値をother変数にバインドし、エラーメッセージに含めます。is_finalは読み取り専用なので、&selfを受け取ります。
8. 主な動作のテスト記述
Rustには組み込みのテストフレームワークがあります。#[cfg(test)]モジュール内に同じファイルにテストを配置できます:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paid_order_can_be_shipped() {
let result = OrderStatus::Paid(250_000).ship("GHN", "GHN123");
assert_eq!(
result,
Ok(OrderStatus::Shipped {
carrier: "GHN".to_string(),
tracking: "GHN123".to_string(),
})
);
}
#[test]
fn pending_order_cannot_be_shipped() {
let result = OrderStatus::Pending.ship("GHN", "X");
assert!(result.is_err());
assert!(result.unwrap_err().contains("Pending"));
}
#[test]
fn cancelled_order_is_final() {
let status = OrderStatus::Pending.cancel("顧客が変更").unwrap();
assert!(status.is_final());
assert!(matches!(status, OrderStatus::Cancelled { ref reason } if reason == "顧客が変更"));
}
}
enumがPartialEqとDebugを導出しているため、assert_eq!は使えます。テストが失敗すると、Rustは期待値と実際の値の両方を表示します。最後のテストでは、ref reasonはフィールドを参照としてバインドするため、statusはmatches!内で移動しません。
cargo test
running 3 tests
test tests::cancelled_order_is_final ... ok
test tests::paid_order_can_be_shipped ... ok
test tests::pending_order_cannot_be_shipped ... ok
test result: ok. 3 passed; 0 failed; 0 ignored
9. 一般的なエラーと非推奨な用途
_の過度な使用で完全性を失う。 自分が管理するenumに対して_ => ...を書くと、後で新しいバリアントを追加したときにコンパイラは沈黙します。新しいバリアントはデフォルトブランチに落ちるため、通常はあなたが望むことではありません。ビジネスロジック用のenumについては、各バリアントを明示的にリストアップしてください。
参照からデータを移動させる。 match *status { OrderStatus::Cancelled { reason } => ... }と書くと、エラーになります:
error[E0507]: cannot move out of `status` as enum variant `Cancelled` which is behind a shared reference
3つの修正方法があります:status(参照)に直接マッチさせるか、ref reasonを使うか、本当にデータを所有する必要があればクローンします。
一般的なブランチを特定のブランチの前に配置する。 Ok(n)がOk(0)の前に来ると、Ok(0)ブランチは決して実行されません。コンパイラはunreachable patternを警告しますがエラーではないため、見落としやすいです。
バリアントが大きすぎる。 enumのサイズは最大のバリアントのサイズにタグを加えたものです。1つのバリアントが大きな配列を含むと、enumのすべての値がその分のメモリを消費します。Box<...>でそのバリアントをラップしてください。lintのclippy::large_enum_variantがこれを指摘します。
注意: ライブラリを書いていて、後で新しいバリアントを追加してもユーザーのコードを壊さない場合は、enumに
#[non_exhaustive]を付与してください。すると、他のcrateのコードは強制的に_ブランチを持つ必要があります。
enumを使わない場合? enumは定義と制御の対象となる閉じたケースセットに適しています(例:注文状態やエラータイプ)。ケースセットが開かれている場合、例えばライブラリユーザーが独自の支払い方法を追加する必要がある場合は、trait(Box<dyn PaymentMethod>またはgeneric)を使用してください。バリアントの追加には元のenumの変更が必要ですが、traitは外部から拡張できます。
10. 結論
Rustのenumにより、各ケースは正確に必要なデータを持つことができます。matchはコンパイル時にすべてのケースを処理することを強制します。以下を見てきました:
- Unit、tuple、struct variant。
- binding、
|、guard、range、@、..を使ったmatch。 - 通常のenumとしての
OptionとResult。 if let、let else、while let、matches!などの簡潔な構文。- テスト付きで状態遷移をモデル化するためのenum上のメソッド。
覚えやすいルール:自分が所有するenumについては、_を使わずに各バリアントを明示的にリストアップしてください。そうすれば、新しいバリアントを追加するたびに、修正が必要なすべての場所をコンパイラが指摘してくれます。