enumとパターンマッチング

17 分で読了
このページの目次

enumは複数の可能な形態のいずれか1つを表し、各バリアントは異なるデータを保持できます。状態番号と一連のnullableフィールドを持つことと比較して、enumは「どのフィールドがその状態で存在するか」を直接型に組み込むことができます。

Disconnected接続パラメータなしConnectingaddress: StringConnectedpeer: String, session: u64異なる状態のデータを対応するバリアントに格納する各値は1つのバリアントのみを保持します。型は無意味なフィールドの組み合わせを除外しますが、状態遷移ルールは依然として実装する必要があります。

状態とデータの共同定義

enum Connection {
    Disconnected,
    Connecting { address: String },
    Connected { peer: String, session: u64 },
}
fn describe(state: &Connection) -> String {
    match state {
        Connection::Disconnected => "offline".to_owned(),
        Connection::Connecting { address } => format!("connecting to {address}"),
        Connection::Connected { peer, session } => format!("{peer}, session {session}"),
    }
}
fn main() {
    let states = [
        Connection::Disconnected,
        Connection::Connecting { address: "localhost".into() },
        Connection::Connected { peer: "localhost".into(), session: 7 },
    ];
    assert_eq!(describe(&states[0]), "offline");
    assert!(describe(&states[1]).contains("connecting"));
    assert!(describe(&states[2]).contains("session 7"));
}

これにより、Disconnectedが意図せず未クリーンアップのsessionフィールドを保持することがなくなります。ただし、enum自体はプログラムが任意の状態から別の状態に遷移することを禁止するものではありません。遷移を制限するには、フィールド、メソッド、コンストラクタのエントリポイントをカプセル化する必要があります。

「タグ付き共用体」は有用なセマンティックモデルですが、物理的な記憶域に独立したタグが存在する保証はありません。実際のサイズは最大ペイロード、アライメント、nicheの影響を受けるため、機械的に加算することはできず、Stringなどの所有権を持つフィールドのサイズを無視してはいけません。

Optionのセットを先に書くのではなく、フィールドの組み合わせから型を導出する

status: u8、address: Option<String>、session: Option<u64> を用いて3つの状態を表現する場合、フィールドの組み合わせにおいて「接続済みだがsessionがない」「オフラインなのにsessionが残っている」といった状況が発生する可能性があります。enumは各形態で許可されるデータを直接バリアントに収めるため、呼び出し側が繰り返し確認を行う条件を減らすことができます。

これはしばしば和型(union type)と呼ばれます:値は複数の候補のいずれか1つに属します。structはフィールドの組み合わせをまとめるため、積型(product type)と呼ばれることが一般的です。有限集合の単純な例では、2つのboolは2×2=4通りの組み合わせを持ちます。Pending | Finished(bool) は1+2=3通りの形態を持ちます。ここで計算しているのはセマンティックな値の取り得る範囲であり、レイアウトのバイト数ではありません。

モデリングの選択表現に適しているもの追加の制約
Option通常存在するか、欠落しているか欠落の理由は通常細分化されない
Result<T, E>成功結果または報告可能なエラー失敗の類別はEの設計による
カスタムenum各状態が異なるペイロードを持つ遷移、リソース所有権、不変条件
複数の独立フィールド条件が本当に独立して組み合わせ可能合法な組み合わせを人為的に禁止しない

存在させたくない組み合わせはどれか

表はペイロードの有無を数えます。3 状態と 2 個の省略可能フィールドの 4 通りで 12 種類ですが、必要なのは 3 種類だけです。組み合わせを選び、enum が余分な形を表せなくする仕組みを確認します。数はサイズでも文字列の全値を含む総数でもありません。

図を準備しています
存在させたくない組み合わせはどれか

独立したフィールドでは 12 通り、この enum では 3 種類のペイロード構造だけを許します。

状態遷移:古い値を消費し、失敗時に誰が所有するかを明確にする

DrafttextQueuedtext + ticketDonereceiptSubmitComplete状態の形態はenumで表現され、許可される遷移は関数によってチェックされるテキストは空ではない。完了イベントのticketは現在のタスクと一致しなければならない無効な遷移は元の状態と理由を返す。フィールドの組み合わせが合法だからといって、任意の状態遷移が合法とは限らない。

以下の遷移関数はJobを値で受け取ります。成功時は古い状態が消費され、データが新しい状態にムーブされます。失敗時は元の状態とエラーを返し、呼び出し側は編集を続けたり記録したりでき、失敗パスのためにStringをコピーする必要はありません。完了イベントはticketを伴うため、古いタスクの完了イベントが現在のタスクを誤って進行させることを防ぎます。

#[derive(Debug, PartialEq)]
enum Job {
    Draft { text: String },
    Queued { text: String, ticket: u64 },
    Done { receipt: String },
}
enum Event {
    Submit { ticket: u64 },
    Complete { ticket: u64, receipt: String },
}
fn advance(job: Job, event: Event) -> Result<Job, (Job, &'static str)> {
    match (job, event) {
        (Job::Draft { text }, Event::Submit { ticket }) if !text.trim().is_empty() => {
            Ok(Job::Queued { text, ticket })
        }
        (Job::Queued { ticket, .. }, Event::Complete { ticket: received, receipt })
            if ticket == received => Ok(Job::Done { receipt }),
        (previous, _) => Err((previous, "invalid transition")),
    }
}
fn main() {
    let draft = Job::Draft { text: "report".into() };
    let queued = advance(draft, Event::Submit { ticket: 7 }).unwrap();
    let (queued, reason) = advance(queued, Event::Complete {
        ticket: 6, receipt: "stale".into(),
    }).unwrap_err();
    assert_eq!(reason, "invalid transition");
    assert!(matches!(&queued, Job::Queued { ticket: 7, .. }));
    let done = advance(queued, Event::Complete {
        ticket: 7, receipt: "stored".into(),
    }).unwrap();
    assert_eq!(done, Job::Done { receipt: "stored".into() });
    let empty = Job::Draft { text: String::new() };
    assert!(advance(empty, Event::Submit { ticket: 8 }).is_err());
}

ここで使用されているガードは、分岐が選択されるかどうかを決定する際に条件をチェックします。条件が失敗しても、後続のマッチングブランチに委ねることができます。完了に成功するとtextは不要になるため、消費された状態とともに古いテキストは解放されます。もしビジネスロジックで全文の監査が必要であれば、それを新しい状態または外部の記録に保存する必要があります。enumが自動的に保持してくれることを期待してはいけません。

この関数は列挙されたローカルな遷移ルールを保証しますが、外部タスクの実行、ticketのグローバル一意性の検証、結果の永続化は行いません。Jobとそのバリアントのコンストラクタがすべて公開されている場合、他のコードは関数を迂回して直接Doneを構築できる可能性があります。外部からの強制エントリポイントにはモジュールカプセル化が必要です。Draftに対して完了操作をコンパイル時に禁止したい場合は、さらに型付きステート(typestate)インターフェースを使用できますが、その代償として、ランタイムでの状態集合と異種コンテナの処理がより複雑になります。

matchは値の取り得る形態に対して網羅性をチェックする

ガードのない網羅的なmatchは、新しいバリアントが追加された際に分岐の追加を促します。すでに_を使用しているmatchはコンパイルを継続できるため、すべてのマッチング箇所が新しいバリアントによってエラーになるとは限りません。公開されているnon_exhaustive enumは、下流が将来の拡張のためにマッチング方法を予約することを要求します。

matchはソースコードの順序に従ってマッチングブランチを探します。ガードは、パターンが成功した後に追加でチェックされるブール条件です。コンパイラは、任意のガードのロジックを完全な網羅性の証明として扱うことは一般的ではありません。match

fn classify(input: Option<i32>) -> &'static str {
    match input {
        Some(value @ 1..=5) if value % 2 == 1 => "small odd",
        Some(_) => "other number",
        None => "missing",
    }
}
fn main() {
    assert_eq!(classify(Some(3)), "small odd");
    assert_eq!(classify(Some(2)), "other number");
    assert_eq!(classify(None), "missing");
}

@は、マッチした値を保存しつつサブパターンもチェックします。if letは1つの形態だけを処理する場合に適しており、while letは同じ種類の結果を繰り返し取得する場合に適しています。残りのケースを無視することはインターフェースの意図であるべきであり、構文が短いからといってエラー処理が完了したと見なしてはいけません。

網羅性はカバレッジの証明であり、ビジネスロジックの正当性の証明ではない

Some(x) if x > 0 と Some(x) if x <= 0 はすべてのi32をカバーしているように見えますが、コンパイラは任意のガードの補完関係を普遍証明するものではなく、ガードのない対応するカバーリングブランチが必要です。_ => {} はコンパイルを通過させますが、新しく追加された重要な状態を静かに無視してしまう可能性があります。

or-patternの各ブランチは、同じ名前、互換性のある型、およびバインドパターンを保持する必要があります。例えば、Some(1 | 2) は値のみをテストしますが、Ok(value) | Err(value) はvalueの型が両側で一致していることも要求します。@ はサブパターンのチェックと同時に値をバインドし、任意のサイズのデータのコピーを作成するわけではありません。

fn payload_len(value: Result<&str, &str>) -> usize {
    match value {
        Ok(text) | Err(text) => text.len(),
    }
}
fn command(input: &[u8]) -> Option<(&[u8], u8)> {
    match input {
        [0x7e, payload @ .., checksum] => Some((payload, *checksum)),
        _ => None,
    }
}
fn main() {
    assert_eq!(payload_len(Err("bad")), 3);
    assert_eq!(command(&[0x7e, 1, 2, 3]), Some((&[1, 2][..], 3)));
    assert_eq!(command(&[0x7e, 3]), Some((&[][..], 3)));
    assert_eq!(command(&[0x7e]), None);
}

スライスパターンの例は形状と開始マーカーのみをチェックしており、checksumを本当に検証しているわけではありません。パターンは構造を記述するのが得意ですが、ビジネスロジックの関係性は明示的に計算する必要があります。通常のletは反駁不可能なパターンを要求します。マッチする可能性があるSome(x)は、match、if let、またはlet-elseに適しています。

パターンバインドはムーブする場合もあれば、借用する場合もある

fn main() {
    let item = Some(String::from("hello"));
    match &item {
        Some(text) => assert_eq!(text.len(), 5), // text: &String
        None => unreachable!(),
    }
    let Some(text) = item else { return; }; // ここでStringがムーブされる
    assert_eq!(text, "hello");
}

&item に対してマッチする場合、マッチングのエルゴノミクスルールにより、内側のバインドはフィールドを借用します。item に対して非Copyフィールドを値でデストラクチャリングすると、所有権が転送されます。let-elseではelseブランチが分岐(return、break、またはpanicなど)することを要求し、それによって後続のパスでパターンが成功したことを前提にすることができます。

パターン内の_はバインドを作成しません。_nameは変数を作成し、値をムーブして所有することができます。Rust 2024では、一部の暗黙的な借用パターン内でref/mutを明示的に書き加える組み合わせに対してより厳しいルールがあり、&value と value のどちらにマッチするかを明確に選択する方が通常は可読性が高くなります。パターン

マッチング対象の式とバインドの型を一緒に見る

入力と書き方textの型/動作後続で元のコンテナが保持されるか
match item, Some(text)Stringが値でムーブされるムーブされた部分は通常使用できない
match &item, Some(text)&String保持される。借用中は共有ルールに従う
match &mut item, Some(text)&mut String保持される。借用を通じてフィールドの修正を許可
match item.as_deref()Option<&str> の内側の &str保持される。文字列の所有権は転送されない
item.take()Optionの元の値を取り出す元の位置はNoneになる

mut text はバインドを再代入可能にしますが、「&mut Stringを借用した」とは等価ではありません。ref mut text が、対応する値パターンコンテキスト内で可変借用を要求します。Rust 2024では、一部のデフォルト借用パターン内でref/mutを書き加える組み合わせが制限されており、&item または &mut item にマッチすることを明示的に選択し、入力の階層を明確にすることが優先されます。Rust 2024 マッチングルール

fn main() {
    let mut item = Some(String::from("hello"));
    if let Some(text) = &mut item {
        text.push('!');
    }
    assert_eq!(item.as_deref(), Some("hello!"));
    let taken = item.take().unwrap();
    assert_eq!(taken, "hello!");
    assert!(item.is_none());

    let ignored = Some(String::from("still here"));
    if let Some(_) = ignored {
        assert_eq!(ignored.as_deref(), Some("still here"));
    }
    // Some(_) はバインドせずStringをムーブしない。Some(_text) はそのフィールドをバインドする。
    assert!(ignored.is_some());
}

&mut Option<String> から直接Stringを取り出して未初期化の穴を残すことはできません。take() は有効なNoneを挿入することでこの問題を解決します。他の型に対してはmem::replaceを検討できますが、代替値はビジネスロジックで許可された状態であるべきであり、借用エラーを回避するために意味のない状態をでっち上げるべきではありません。Option::take

部分ムーブにおいても、「値全体がまだ使用できるか」と「ムーブされていないフィールドがまだ使用できるか」を区別する必要があります。カスタムDropを持たないstructからStringフィールドをムーブした場合、他のムーブされていないフィールドにアクセスできる可能性があります。struct全体への借用や、完全な値を必要とするメソッドの呼び出しは異なります。カスタムDropを含む型には、フィールドを直接ムーブできないという制限もあります。

OptionとResultは一般的な列挙プロトコルである

通常の参照はnullになり得ませんが、Rustの裸のポインタはnull値を許可するため、「Rustにはnullがない」というのは言い過ぎです。Optionは、正常な安全なインターフェースにおいて欠落を表現し、呼び出し側はunwrapを選択し、Noneの場合にpanicさせることができます。型は自動的に回復策を選択してくれるわけではありません。

fn safe_div(a: i32, b: i32) -> Option<i32> { a.checked_div(b) }
fn main() {
    assert_eq!(safe_div(10, 2), Some(5));
    assert_eq!(safe_div(10, 0), None);
    assert_eq!(safe_div(i32::MIN, -1), None);
}

b == 0のみをチェックすると、最小有符号整数を-1で割った際のオーバーフローを見逃します。呼び出し側が2つの失敗を区別する必要がある場合は、すべての理由をNoneに押し込めるのではなく、類別を持つResultを返すべきです。

Resultは報告可能な失敗を関数の戻り値タイプに組み込みますが、内部にpanicがないことや外部副作用がないことを保証するものではありません。エラー伝播と組み合わせ方については、ResultとOption を参照してください。

公開enumの進化とwireデコード

#[non_exhaustive] は、定義したcrateの外側での使用を主に制約します。crate内では、現在定義されているバリアントに対して完全なマッチングを行うことができます。下流は未知のバリアントブランチを予約する必要がありますが、これはランタイムで任意の整数を合法なenumに変換する許可ではありません。non_exhaustive

wireの状態コードが将来の追加を許可する場合、未知の数値を明確な Unknown(u8) として保存できます。これはRustのenumにおける非法なdiscriminantとは全く異なります。Unknownはすでに定義された合法なバリアントであり、ログ、転送、または後続の処理のために元のコードを保持します。

#[derive(Debug, PartialEq)]
enum Status { Ready, Busy, Unknown(u8) }
impl From<u8> for Status {
    fn from(code: u8) -> Self {
        match code {
            1 => Self::Ready,
            2 => Self::Busy,
            other => Self::Unknown(other),
        }
    }
}
fn main() {
    assert_eq!(Status::from(1), Status::Ready);
    assert_eq!(Status::from(2), Status::Busy);
    assert_eq!(Status::from(255), Status::Unknown(255));
}

このインターフェースは未知のコードを保持する選択をしていますが、理解して実行する必要がある操作は拒否する必要があります。未知の値を許可しない入力に対しては、TryFromを実装してエラーを返すことができます。transmuteを使用して任意のバイトを有限バリアントenumとして解釈しないでください。レイアウトサイズが同じだからといって、discriminantが有効である証明にはなりません。

enumとmatchの組み合わせは、形態のチェック、フィールドへのアクセス、所有権の転送を1カ所にまとめます。使用する際は、以下の順で確認してください:どの状態に異なるデータが必要か、どの遷移が許可されているか、ブランチが重要なケースを明示的にカバーしているか、バインドはムーブか借用か、未知の入力が合法な型にどう組み込まれるか。これにより、構文の迅速な特定が可能になり、モデルとビジネスロジックの一貫性を保つことができます。