match という構文はいまや多くの言語にあります。ただし同じ名前でも能力はまちまちで、単なる switch の言い換えに留まるものから、分岐の漏れをコンパイラが検出するものまで幅があります。
違いを測る物差しとして、網羅性検査の有無がもっとも重要です。すべてのケースを書いたかどうかを機械が確かめてくれるか、という点です。
4 段階で捉える
| レベル | できること | 代表 |
|---|---|---|
| 1. 値の照合 | 定数との一致で分岐する | PHP の match |
| 2. 型の絞り込み | 型で分岐し、その枝で型が確定する | TypeScript |
| 3. 構造の分解 | 入れ子のデータを分解して束縛する | Python |
| 4. 直和型と網羅性 | 3 に加え、漏れをコンパイラが検出する | Rust |
上のレベルは下を含みます。そして 3 と 4 の間に、実務上いちばん大きな段差があります。
レベル1: 値の照合
PHP の match は式であり、厳密比較で分岐します。
$label = match ($status) {
UserStatus::Active => '利用中',
UserStatus::Suspended => '停止中',
};
switch との違いは 3 点です。式なので値を返す、break が要らない、比較が === 相当になる。地味ですが、switch の代表的なバグ(break 忘れと緩い比較)が構造的に消えます。
enum のケースを書き漏らすと UnhandledMatchError になりますが、これは実行時です。その分岐を通るまで気づきません。
レベル2: 型の絞り込み
TypeScript には match 構文はありませんが、判別可能なユニオン型で同等のことができます。
type Command =
| { kind: "create"; name: string }
| { kind: "move"; x: number; y: number }
| { kind: "quit" };
function run(command: Command): string {
switch (command.kind) {
case "create":
return command.name; // ここでは create 型に確定している
case "move":
return `${command.x},${command.y}`;
case "quit":
return "bye";
}
}
各分岐の中で型が自動的に絞り込まれます。さらに never を使うと、網羅漏れをコンパイル時に検出できます。
function assertNever(value: never): never {
throw new Error(`unhandled: ${value}`);
}
// default 節で assertNever(command) を呼ぶと、
// ケースを足し忘れたときにコンパイルエラーになる
言語機能として自動ではなく、この定型句を自分で書く必要があるのが特徴です。仕組みは用意されているが、使うかどうかは書き手に委ねられています。
レベル3: 構造の分解
Python の match は値の一致ではなく、構造の照合を行います。
def handle(message: object) -> str:
match message:
case {"type": "user", "id": int(user_id)}:
return f"user:{user_id}"
case ["move", int(x), int(y)]:
return f"move:{x},{y}"
case None:
return "empty"
case _:
return "unknown"
辞書・リスト・クラスを分解しながら、同時に型を確認して変数へ束縛できます。ガード条件も書けます。
match point:
case Point(x, y) if x > 0 and y > 0:
return "first"
表現力は高い一方で、Python は標準では網羅性を保証しません。case _ を書き忘れ、どのパターンにも当たらなければ match 文は何もせずに次へ進みます。エラーにすらなりません。
もう 1 つの落とし穴が、捕捉パターンと定数パターンの取り違えです。
match status:
case ACTIVE: # 定数比較のつもりが、すべてを ACTIVE に束縛してしまう
...
裸の名前は「その値と比較する」ではなく「この名前に束縛する」と解釈されます。結果として最初の枝が常に成立します。定数と比較したいなら Status.ACTIVE のようにドット付きで書きます。
レベル4: 直和型と網羅性
Rust では、直和型と match が組みになっています。
enum Command {
Create { name: String },
Move(i32, i32),
Quit,
}
fn run(command: Command) -> String {
match command {
Command::Create { name } => name,
Command::Move(x, y) => format!("{x},{y}"),
Command::Quit => String::from("bye"),
}
}
ここで Command::Quit の枝を消すとコンパイルが通りません。さらに後から enum にケースを追加すると、そのケースを処理していない match がすべてコンパイルエラーになります。
これが実務でいちばん効きます。状態を 1 つ増やしたとき、対応が必要な箇所を人間が探すのではなく、コンパイラが全部列挙してくれる。パターンマッチの価値は構文の簡潔さではなく、この網羅性検査にあります。
何を確認すべきか
新しい言語のパターンマッチを見るときは、構文より先に次を確かめると性質がつかめます。
- 網羅性を検査するか。するのはコンパイル時か実行時か
- ガード条件を書けるか
- 入れ子の構造を分解できるか
- 分解しながら変数へ束縛できるか
- 分岐の中で型が絞り込まれるか
- 到達しないパターンを警告するか
まとめ
- パターンマッチは値の照合・型の絞り込み・構造の分解・網羅性検査の 4 段階で捉えると差が見えます。
- PHP の
matchは式であり厳密比較を行うため、switchの典型的なバグは消えます。ただし漏れの検出は実行時です。 - TypeScript は判別可能なユニオン型と
neverで網羅性を検査できますが、定型句を自分で書く必要があります。 - Python の
matchは構造分解が強力な反面、網羅性は保証されません。裸の名前は比較ではなく束縛になる点が最大の罠です。 - Rust は網羅性がコンパイル時に保証されます。enum にケースを足したとき、対応漏れを機械が全部指摘してくれることがこの機能の本体です。