# 模式匹配

Source: https://codewiki.com/zh/csharp/pattern-matching/

> - **what**: 模式匹配（pattern matching）把类型检查、数据形状检查和变量提取写成一个模式，可用于 `is`、`switch` 语句和 `switch` 表达式。
> - **trap**: `switch` 按文本顺序选择第一个匹配且守卫为真的分支；过宽模式会遮住后续意图，而类型模式不会匹配 `null`。
> - **fix**: 先写窄而具体的模式，再写一般情况，并明确处理 `null`、未知类型、枚举未定义值和空列表等边界。

## 是什么，为什么存在

模式匹配（pattern matching）用一个模式测试输入，并可在成功时提取后续代码需要的值。它不只比较一个常量，还能检查运行时类型、对象属性、解构位置、数值关系和序列形状。第一屏应记住的规则是：模式描述「哪些值属于这个分支」，匹配成功后才允许使用其中声明的变量。

没有模式匹配时，处理 `object` 或多种消息类型通常要先用 `is` 检查，再强制转换，然后继续写属性条件。声明模式把这些动作合在一起，例如 `value is Order order` 会同时检查类型并绑定 `order`。编译器知道变量只在匹配成功的控制流中可用，因此后续成员访问保持静态类型安全。

模式可以出现在三种主要结构中。`is` 表达式回答一个布尔问题；`switch` 语句让匹配分支执行语句；`switch` 表达式让匹配分支产生一个值。需要从同一输入计算结果时，`switch` 表达式通常最紧凑，但它仍然是有顺序、有失败路径的分支结构。

你会在解析边界对象、按记录形状路由消息、表达状态转换和检查短命令序列时遇到它。模式适合结构已经由静态类型表达的输入，不会代替外部数据验证。JSON、HTTP 或数据库数据仍要先经过解析、范围检查和授权，之后模式才能安全地组织已取得的值。

模式匹配也不等于多态。若每个派生类型本来就拥有自己的行为，虚方法或接口往往比到处重复类型 `switch` 更能维护封装。模式更适合调用方确实拥有分类规则，或者多个值必须一起决定结果的地方。

## 工作原理

### 输入、模式与结果

每次匹配都有一个输入值和一个模式。常量模式比较特定值；声明模式与类型模式检查运行时类型；关系模式使用 `<`、`<=`、`>`、`>=`；逻辑模式用 `not`、`and`、`or` 组合其他模式。`var` 模式总会匹配并绑定输入，弃元模式 `_` 在 `switch` 表达式中承担不需要绑定的兜底分支。

类型模式只有在输入非 `null` 且运行时类型与目标类型模式兼容时才成功。`value is string text` 因此同时建立两个事实：`value` 是 `string`，而且 `text` 非 `null`。若要检查空值，应写常量模式 `null`；若要检查非空值，可写 `is not null`。

模式变量遵守确定赋值分析。`if (value is string text)` 的真分支可以读取 `text`，假分支不能假定它已绑定。把匹配结果保存成独立布尔变量或写入复杂表达式时，变量可用范围可能比视觉上预期的更窄；编译器诊断比猜测作用域可靠。

### 递归模式读取数据形状

属性模式（property pattern）把可读属性或字段的值交给嵌套模式。`order is { Total: > 0m, Customer: not null }` 要求输入非空，而且两个成员分别满足关系模式与非空模式。扩展属性模式可把 `{ Address: { City: "Paris" } }` 写成 `{ Address.City: "Paris" }`，两者都会在中间接收者为空时匹配失败。

位置模式先按元组元素匹配，或者调用输入类型上可用的 `Deconstruct` 方法，再把产生的值交给子模式。位置顺序属于契约的一部分，所以 `(var width, var height)` 的含义来自元组位置或 `Deconstruct` 参数顺序，而不是变量名称。位置不直观时，属性模式通常更容易审查。

列表模式按位置检查序列元素。它要求输入类型既可计数又可索引，而不是接受任意 `IEnumerable`。`[first, second]` 只匹配恰好两个元素；`[first, .., last]` 允许中间有零个或多个元素；带子模式的切片还要求类型可以切片。

### 分支按顺序选择

`switch` 表达式从上到下检查分支，返回第一个模式匹配且可选守卫为真的分支结果。分支守卫（case guard）写在 `when` 后面，适合模式语法无法表达的布尔条件，例如调用领域方法。守卫为假时，匹配继续检查后续分支。

顺序因此表达优先级。`{ Total: >= 1000m }` 必须放在 `{ Total: > 0m }` 之前，否则较宽的正数分支已经覆盖大额订单。编译器会把能够静态证明永远到不了的后续模式报告为错误，但无法替你证明任意 `when` 方法之间的业务关系。

逻辑模式的结合优先级依次是 `not`、`and`、`or`。`value is not null and Order` 按此规则组合，而涉及多个运算符时加括号更便于审查。相同结合优先级的子模式检查顺序未定义，失败的匹配也可能跳过其余子模式，因此带副作用的属性 getter 或 `Deconstruct` 会让代码难以推理。

### 常用模式的边界

| 模式 | 示例 | 关键边界 |
| --- | --- | --- |
| 常量 | `value is 0` | `null` 也是常量模式 |
| 声明 | `value is Order order` | 不匹配 `null` |
| 属性 | `{ Total: > 0m }` | 输入和嵌套接收者必须满足非空路径 |
| 位置 | `(0, var y)` | 依赖元组位置或 `Deconstruct` 顺序 |
| 关系 | `>= 0 and <= 100` | 常量必须可转换到输入类型 |
| 逻辑 | `not (null or "")` | 优先级是 `not`、`and`、`or` |
| 列表 | `["run", var file]` | 需要计数与索引，不是一般枚举 |
| 弃元 | `_` | 在 `switch` 表达式中匹配所有剩余输入 |

## 示例

下面四个示例从单值分类开始，再组合属性、守卫、位置与列表模式。当前环境没有 .NET SDK 或其他 C# 编译器，因此每个代码块都明确标记为未执行，输出块不伪造结果。

### 同时检查类型与属性

消息分类器从 `object?` 边界开始。窄分支先区分有效与无效订单，随后处理非空字符串、`null` 和未知类型。

<!-- quick -->

```csharp
// file: MessageRouter.cs
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;

object?[] messages =
[
    new OrderPlaced("A-17", 125m),
    new OrderPlaced("A-18", 0m),
    "heartbeat",
    null
];

foreach (object? message in messages)
    Console.WriteLine(Describe(message));

static string Describe(object? message) => message switch
{
    OrderPlaced { Total: > 0m } order => $"order {order.Id}: {order.Total:C}",
    OrderPlaced => "invalid order",
    string { Length: > 0 } text => $"signal: {text}",
    null => "missing message",
    _ => "unsupported message"
};

public sealed record OrderPlaced(string Id, decimal Total);
```

```text
# not executed here: the .NET SDK and C# compilers are unavailable
```


<!-- /quick -->

`OrderPlaced { Total: > 0m } order` 先验证类型和属性，再把完整对象绑定到 `order`。第二个 `OrderPlaced` 分支接住零金额与负金额；若把它放在第一项，具体分支会被完全遮住并产生编译错误。

最后的 `_` 让方法对任何 `object?` 都有结果。这里选择返回诊断文字；真实边界也可以抛出领域异常或返回结果类型，但该失败契约必须明确，不能让未知消息静默进入正常路径。

### 用守卫补充领域条件

价格区间能直接写成关系模式，工作日判断则由方法完成。只有属性模式已经匹配后，对应的 `when` 才会执行。

```csharp
// file: ShippingLane.cs
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;

Shipment[] shipments =
[
    new("FR", 8m, true),
    new("FR", 8m, false),
    new("US", 25m, true)
];

DateTime acceptedAt = new(2026, 9, 4);
foreach (Shipment shipment in shipments)
    Console.WriteLine(SelectLane(shipment, acceptedAt));

static string SelectLane(Shipment shipment, DateTime acceptedAt) => shipment switch
{
    { WeightKg: <= 0m } => "reject",
    { Country: "FR", Express: true, WeightKg: <= 20m }
        when IsBusinessDay(acceptedAt) => "express-fr",
    { Country: "FR", WeightKg: <= 20m } => "standard-fr",
    { WeightKg: > 20m and <= 30m } => "freight",
    _ => "manual-review"
};

static bool IsBusinessDay(DateTime value) =>
    value.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday);

public sealed record Shipment(string Country, decimal WeightKg, bool Express);
```

```text
# not executed here: the .NET SDK and C# compilers are unavailable
```

将 `IsBusinessDay` 放进守卫，保留了方法调用这个无法由模式本身表达的条件。若只是检查 `WeightKg > 20m && WeightKg <= 30m`，关系与逻辑模式已经足够，不需要额外声明变量再写 `when`。

第一个分支先拒绝非正重量，避免后续一般分支给无效数据分配运输通道。守卫方法应保持无副作用；未来调整分支顺序或增加覆盖测试时，调用次数不应改变系统状态。

### 用位置模式表达状态转换

元组模式适合由两个输入共同决定结果。命令的属性模式嵌在第二个位置里，金额校验因此与当前状态处于同一张转换表。

```csharp
// file: OrderTransitions.cs
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;

OrderState state = OrderState.Created;
state = Next(state, new Pay(42m));
Console.WriteLine(state);
state = Next(state, new Ship("ZX-9"));
Console.WriteLine(state);

static OrderState Next(OrderState state, OrderCommand command) =>
    (state, command) switch
    {
        (OrderState.Created, Pay { Amount: > 0m }) => OrderState.Paid,
        (OrderState.Paid, Ship { TrackingId.Length: > 0 }) => OrderState.Shipped,
        (OrderState.Created or OrderState.Paid, Cancel) => OrderState.Cancelled,
        (OrderState.Shipped or OrderState.Cancelled, _) => state,
        _ => throw new InvalidOperationException(
            $"Invalid transition: {state} + {command.GetType().Name}")
    };

enum OrderState { Created, Paid, Shipped, Cancelled }
abstract record OrderCommand;
sealed record Pay(decimal Amount) : OrderCommand;
sealed record Ship(string TrackingId) : OrderCommand;
sealed record Cancel : OrderCommand;
```

```text
# not executed here: the .NET SDK and C# compilers are unavailable
```

元组的两个位置先后代表状态与命令类型，嵌套属性继续检查命令内容。若调用方可能传入 `null` 命令，方法签名应写成 `OrderCommand?` 并添加明确分支，而不是依赖最后一项在错误消息中解引用失败。

终态分支故意保持原状态，其他非法转换则抛出异常。两种行为代表不同业务契约；不能为了让 `switch` 看似穷尽，就用一个返回原状态的 `_` 吞掉所有未知组合。

### 用列表模式解析短命令

列表模式适合元素数量有限、位置具有含义的输入。切片 `.. var tags` 捕获剩余数组，守卫再保证至少有一个标签。

```csharp
// file: CommandParser.cs
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;

string[][] commands =
[
    [],
    ["ship", "A-17"],
    ["ship", "A-18", "--priority"],
    ["tag", "A-19", "fragile", "gift"]
];

foreach (string[] command in commands)
    Console.WriteLine(Parse(command));

static string Parse(string[] args) => args switch
{
    [] => "help",
    ["ship", var orderId] => $"ship {orderId}",
    ["ship", var orderId, "--priority"] => $"priority {orderId}",
    ["tag", var orderId, .. var tags] when tags.Length > 0 =>
        $"tag {orderId}: {string.Join(',', tags)}",
    [var verb, ..] => $"unknown command: {verb}"
};
```

```text
# not executed here: the .NET SDK and C# compilers are unavailable
```

`["ship", var orderId]` 不会匹配三元素命令，所以优先级分支仍可到达。最后一个模式要求至少有一个元素；空数组已经由第一项处理，因此这里没有遗漏输入。

该方法接收数组，而不是 `IEnumerable<string>`。若输入来自惰性流，应先决定是否允许物化及最大长度；列表模式不是在无限序列中搜索前缀的工具。

## 陷阱

### 把宽模式放在窄模式前

> **陷阱:** `switch` 选择文本顺序中的第一个成功分支。宽模式放在前面会改变业务优先级；若编译器能证明后续分支完全不可达，还会直接报错。

**修复方法：** 先按集合包含关系排列分支：常量与窄范围在前，一般类型和宽范围在后，最后才是 `null` 或 `_` 等明确兜底。对带 `when` 的分支另外写边界测试，因为编译器无法推断任意领域谓词的关系。

### 用错 `and` 与 `or`

> **陷阱:** `age is >= 18 or < 65` 对几乎所有整数都为真，因为每个值至少满足其中一边。生成代码经常把布尔区间 `age >= 18 && age < 65` 错译成这个模式。

**修复方法：** 连续区间使用 `>= 18 and < 65`，分离集合才使用 `or`。为下界前一项、两个边界和上界后一项列真值表，不要只测试区间内部的正常值。

### 把穷尽性警告当成证明

> **陷阱:** 没有匹配分支的 `switch` 表达式会在运行时抛出异常。编译器通常会对非穷尽表达式发出警告，但列表模式没有对应的完整覆盖警告，枚举变量也可能包含未定义的底层数值。

**修复方法：** 根据契约决定最后一项。输入边界允许未知值时，用 `_` 返回明确错误结果或抛出自己的异常；封闭领域希望新增情况触发审查时，可以保留警告并在构建中把它提升为错误，同时仍测试 `null` 与强制转换出的枚举值。

### 把属性模式当作无副作用验证器

> **陷阱:** 属性模式会读取成员，子模式的检查顺序不应成为业务依赖。getter 若执行 I/O、推进游标或修改状态，匹配结果和调用次数就难以预测，异常也会从 getter 直接传播。

**修复方法：** 让用于模式的属性保持便宜、稳定且无副作用。外部输入先解析为普通数据对象，再匹配它的形状；昂贵或可能失败的计算先执行一次并保存为局部值，然后针对该值分支。

### 假设列表模式适用于任何序列

> **陷阱:** `IEnumerable` 只承诺枚举，不满足列表模式需要的计数和索引协议。列表模式也检查固定位置与长度，不会像 `Contains` 或 LINQ 查询那样搜索任意位置。

**修复方法：** 对数组、字符串、跨度或明确提供计数与索引的自定义类型使用列表模式。一般流数据使用单遍枚举；确实需要形状匹配时，先设置大小上限，再物化成适合的容器。

### 用兜底分支吞掉领域错误

> **陷阱:** `_ => currentState` 能让状态机在语法上覆盖所有输入，也会把新命令、拼写错误和非法转换静默解释成「没有变化」。这种兜底会让新增类型绕过本应发生的审查。

**修复方法：** 区分预期的无操作与真正未知的输入，为前者写命名明确的分支，为后者返回失败结果或抛出领域异常。新增派生类型或枚举成员时，测试应证明它不会落进成功兜底。

<!-- deep -->

## 分支覆盖与运行时边界

### 涵盖关系与守卫

若模式 `P` 能匹配的每个值都已经由前面的无守卫模式集合覆盖，`P` 就被前者涵盖，后续分支无法到达。C# 会把这种情况报告为编译错误。常量分支在类型分支之后、窄范围在宽范围之后，都是常见例子。

守卫会改变静态判断的能力。前一分支带有 `when` 时，即使模式本身很宽，守卫仍可能为假，所以后续相同模式可能有机会运行。业务上互斥的两个方法调用对编译器只是任意布尔表达式，仍需要测试证明顺序和覆盖关系。

`switch` 语句和 `switch` 表达式都使用模式，但结果契约不同。表达式必须从选中的分支产生一个可转换到共同结果类型的值；语句分支则执行控制流。不要仅为了换成表达式而把原本有多个副作用的分支塞进辅助方法，先确认计算结果确实是核心契约。

### 穷尽性与失败方式

模式集合若能为每个可能输入找到适用分支，才是穷尽的。`_` 在 `switch` 表达式中匹配所有剩余值，因此是最直接的语法兜底。`var remaining` 也能匹配全部输入，并在错误消息确实需要原值时完成绑定。

非穷尽 `switch` 表达式遇到未匹配值时，在现代 .NET 上会抛出 `System.Runtime.CompilerServices.SwitchExpressionException`。编译器通常会发出警告，但警告不是运行时保护，也不保证未来新增值获得正确业务行为。构建是否把这类警告当作错误，应由项目策略明确规定。

枚举尤其需要区分「已声明成员」与「该底层整数类型的所有可能值」。反序列化、强制转换和版本不一致都可能产生未定义数值。若 `_` 返回正常结果，调用方可能永远看不到协议已经漂移；若没有 `_`，则要接受并测试运行时失败契约。

列表模式目前不会因为未覆盖所有可能序列形状而给出穷尽性警告。即使已经写了 `[]`、`[var one]` 与 `[var first, var second]`，长度为三的数组仍可能在运行时无分支可选。面向任意长度输入时，应写切片或兜底模式。

### 空值与递归失败

声明、类型、属性和位置模式都不会把 `null` 当作成功的非空对象匹配。`input is { } value` 可用空属性模式同时检查非空并绑定值，但 `is not null` 往往更直接。`var value` 则连 `null` 也匹配，所以不能把它当作非空证明。

嵌套属性路径中任一接收者为空，对应属性模式会匹配失败，而不是抛出空引用异常。`{ Customer.Address.City: "Paris" }` 因此隐含了 `Customer` 与 `Address` 都非空的路径要求。这个便利不等于输入已经通过完整验证；未在模式中出现的字段仍可能无效。

模式变量的静态类型由模式决定。以 `object?` 为输入时，`string text` 分支中的 `text` 是非空 `string`，而最后的 `var other` 仍是 `object?`。评审生成代码时，应检查后续 API 接收的是收窄后的变量，还是仍在使用原始宽类型输入。

### 属性读取与解构调用

属性模式匹配成功需要读取相应字段或属性，并把取得的值交给子模式。规范不保证各子模式按某个固定顺序检查，失败后也不要求继续测试其余子模式。依赖 getter 调用顺序、调用次数或副作用会把正确性绑在不受保证的行为上。

位置模式对元组直接读取对应元素；对可解构类型则选择并调用合适的 `Deconstruct` 方法。方法的 `out` 参数数量必须与位置数量一致，参数顺序决定子模式接收哪个值。给 `Deconstruct` 加副作用同样会让一次看似纯粹的分类改变对象或外部状态。

属性模式比位置模式多写成员名，却能抵抗位置含义不清。记录的两个字段类型相同，或者构造顺序与读者直觉不同的时候，`{ Start: 0, End: var end }` 通常比 `(0, var end)` 更安全。位置模式适合稳定、众所周知的坐标或状态对。

### 列表模式的结构协议

列表模式的兼容类型必须可计数且可索引。编译器使用可访问的 `Length` 或 `Count`，并通过 `Index` 索引器或单个 `int` 参数的索引器取得元素。只有 `IEnumerable` 的类型无法满足这些静态要求，因为枚举协议不提供随机位置与长度。

不带子模式的 `..` 只影响长度和其他索引的位置，不需要真的构造中间切片。`.. var middle` 必须产生并绑定切片，因此输入还要支持 `Range` 索引器或合适的 `Slice` 方法；数组和字符串有语言规定的处理方式。只想忽略中间内容时，不要无意义地捕获它。

模式 `[head, .., tail]` 至少需要两个元素，且头尾分别检查。它不是搜索操作：`["error", ..]` 只检查首元素，`[.., "error"]` 只检查末元素。要在任意位置查找，应使用集合 API 或 LINQ，并单独考虑枚举次数。

自定义类型可以通过相应成员参与列表模式，但这也把成员语义纳入语言结构。`Count` 必须稳定反映可索引元素数量，索引器必须与它一致。虚假长度、昂贵索引或访问时改变集合都会让普通模式产生意外行为。

### 模式不是数据规范化

模式根据当前值分类，不会替你执行文本修剪、大小写折叠、单位换算或区域性解析。字符串常量模式区分大小写，因此 `"vip"` 不会自动匹配 `"VIP"`。边界需要哪种规范化，就应在进入匹配前明确完成一次。

守卫可以调用比较器或领域谓词，但这会把策略藏在分支条件里。多个分支需要同一规范化结果时，先保存成局部值再匹配，既避免重复计算，也让所有分支共享同一规则。不要让各分支分别选择不同的区域性或字符串比较方式。

模式也不会建立未写出的跨字段不变量。`{ Start: >= 0, End: >= 0 }` 不能证明 `Start <= End`；这种关系可以放进守卫或先由构造函数验证。模式负责清楚地消费不变量，数据类型与边界验证负责建立不变量。

若原始值还要用于审计或错误消息，应把规范化结果存入新变量，不要覆盖输入。这样既能让模式使用统一表示，也能保留边界实际收到的内容。

### 测试模式集合

模式测试应覆盖分支边界，而不只是每个分支一个快乐路径。关系范围至少测试每个边界及其相邻值；类型分类至少测试基类、派生类、未知实现与 `null`；列表分类至少测试少一个、恰好满足和多一个元素。

分支顺序测试需要选择同时满足多个模式的输入。例如，大额 VIP 订单也满足一般正金额分支，只有这种重叠输入才能证明具体分支确实优先。若输入一次只满足一个模式，交换分支后测试仍会通过。

守卫测试应控制外部时间、区域性和服务结果。示例把日期作为参数传入，而不是在守卫里读取 `DateTime.Now`，因此周末与工作日都能确定地重现。复杂守卫若需要多个依赖，应先计算一个有名称的领域事实，再让匹配结构消费它。

对非穷尽设计，测试还要触发失败路径。给枚举强制转换一个未定义数值，传入未知派生类型，并构造未覆盖长度的数组。成功断言之外，还要检查异常类型或错误结果，确保失败不会被 `_` 悄悄转成正常值。

<!-- /deep -->

[检查点: csharp/pattern-matching](https://codewiki.com/zh/csharp/pattern-matching/#checkpoint)

## 延伸阅读

- [Microsoft Learn：C# 模式匹配概览](https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/pattern-matching)
- [Microsoft Learn：C# 模式参考](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/patterns)
- [Microsoft Learn：`switch` 表达式](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/switch-expression)
- [C# 语言规范：模式](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/language-specification/patterns)
