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については、_を使わずに各バリアントを明示的にリストアップしてください。そうすれば、新しいバリアントを追加するたびに、修正が必要なすべての場所をコンパイラが指摘してくれます。

rustenumpattern matchingmatchoption