模式匹配(pattern matching)把类型检查、数据形状检查和变量提取写成一个模式,可用于 is、switch 语句和 switch 表达式。
switch 按文本顺序选择第一个匹配且守卫为真的分支;过宽模式会遮住后续意图,而类型模式不会匹配 null。
先写窄而具体的模式,再写一般情况,并明确处理 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<T>。[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 和未知类型。
// # 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);# not executed here: the .NET SDK and C# compilers are unavailableOrderPlaced { Total: > 0m } order 先验证类型和属性,再把完整对象绑定到 order。第二个 OrderPlaced 分支接住零金额与负金额;若把它放在第一项,具体分支会被完全遮住并产生编译错误。
最后的 _ 让方法对任何 object? 都有结果。这里选择返回诊断文字;真实边界也可以抛出领域异常或返回结果类型,但该失败契约必须明确,不能让未知消息静默进入正常路径。
用守卫补充领域条件
价格区间能直接写成关系模式,工作日判断则由方法完成。只有属性模式已经匹配后,对应的 when 才会执行。
// # 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);# not executed here: the .NET SDK and C# compilers are unavailable将 IsBusinessDay 放进守卫,保留了方法调用这个无法由模式本身表达的条件。若只是检查 WeightKg > 20m && WeightKg <= 30m,关系与逻辑模式已经足够,不需要额外声明变量再写 when。
第一个分支先拒绝非正重量,避免后续一般分支给无效数据分配运输通道。守卫方法应保持无副作用;未来调整分支顺序或增加覆盖测试时,调用次数不应改变系统状态。
用位置模式表达状态转换
元组模式适合由两个输入共同决定结果。命令的属性模式嵌在第二个位置里,金额校验因此与当前状态处于同一张转换表。
// # 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;# not executed here: the .NET SDK and C# compilers are unavailable元组的两个位置先后代表状态与命令类型,嵌套属性继续检查命令内容。若调用方可能传入 null 命令,方法签名应写成 OrderCommand? 并添加明确分支,而不是依赖最后一项在错误消息中解引用失败。
终态分支故意保持原状态,其他非法转换则抛出异常。两种行为代表不同业务契约;不能为了让 switch 看似穷尽,就用一个返回原状态的 _ 吞掉所有未知组合。
用列表模式解析短命令
列表模式适合元素数量有限、位置具有含义的输入。切片 .. var tags 捕获剩余数组,守卫再保证至少有一个标签。
// # 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}"
};# not executed here: the .NET SDK and C# compilers are unavailable["ship", var orderId] 不会匹配三元素命令,所以优先级分支仍可到达。最后一个模式要求至少有一个元素;空数组已经由第一项处理,因此这里没有遗漏输入。
该方法接收数组,而不是 IEnumerable<string>。若输入来自惰性流,应先决定是否允许物化及最大长度;列表模式不是在无限序列中搜索前缀的工具。
陷阱
把宽模式放在窄模式前
修复方法: 先按集合包含关系排列分支:常量与窄范围在前,一般类型和宽范围在后,最后才是 null 或 _ 等明确兜底。对带 when 的分支另外写边界测试,因为编译器无法推断任意领域谓词的关系。
用错 and 与 or
修复方法: 连续区间使用 >= 18 and < 65,分离集合才使用 or。为下界前一项、两个边界和上界后一项列真值表,不要只测试区间内部的正常值。
把穷尽性警告当成证明
修复方法: 根据契约决定最后一项。输入边界允许未知值时,用 _ 返回明确错误结果或抛出自己的异常;封闭领域希望新增情况触发审查时,可以保留警告并在构建中把它提升为错误,同时仍测试 null 与强制转换出的枚举值。
把属性模式当作无副作用验证器
修复方法: 让用于模式的属性保持便宜、稳定且无副作用。外部输入先解析为普通数据对象,再匹配它的形状;昂贵或可能失败的计算先执行一次并保存为局部值,然后针对该值分支。
假设列表模式适用于任何序列
修复方法: 对数组、字符串、跨度或明确提供计数与索引的自定义类型使用列表模式。一般流数据使用单遍枚举;确实需要形状匹配时,先设置大小上限,再物化成适合的容器。
用兜底分支吞掉领域错误
修复方法: 区分预期的无操作与真正未知的输入,为前者写命名明确的分支,为后者返回失败结果或抛出领域异常。新增派生类型或枚举成员时,测试应证明它不会落进成功兜底。
分支覆盖与运行时边界
涵盖关系与守卫
若模式 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<T> 的类型无法满足这些静态要求,因为枚举协议不提供随机位置与长度。
不带子模式的 .. 只影响长度和其他索引的位置,不需要真的构造中间切片。.. var middle 必须产生并绑定切片,因此输入还要支持 Range 索引器或合适的 Slice 方法;数组和字符串有语言规定的处理方式。只想忽略中间内容时,不要无意义地捕获它。
模式 [head, .., tail] 至少需要两个元素,且头尾分别检查。它不是搜索操作:["error", ..] 只检查首元素,[.., "error"] 只检查末元素。要在任意位置查找,应使用集合 API 或 LINQ,并单独考虑枚举次数。
自定义类型可以通过相应成员参与列表模式,但这也把成员语义纳入语言结构。Count 必须稳定反映可索引元素数量,索引器必须与它一致。虚假长度、昂贵索引或访问时改变集合都会让普通模式产生意外行为。
模式不是数据规范化
模式根据当前值分类,不会替你执行文本修剪、大小写折叠、单位换算或区域性解析。字符串常量模式区分大小写,因此 "vip" 不会自动匹配 "VIP"。边界需要哪种规范化,就应在进入匹配前明确完成一次。
守卫可以调用比较器或领域谓词,但这会把策略藏在分支条件里。多个分支需要同一规范化结果时,先保存成局部值再匹配,既避免重复计算,也让所有分支共享同一规则。不要让各分支分别选择不同的区域性或字符串比较方式。
模式也不会建立未写出的跨字段不变量。{ Start: >= 0, End: >= 0 } 不能证明 Start <= End;这种关系可以放进守卫或先由构造函数验证。模式负责清楚地消费不变量,数据类型与边界验证负责建立不变量。
若原始值还要用于审计或错误消息,应把规范化结果存入新变量,不要覆盖输入。这样既能让模式使用统一表示,也能保留边界实际收到的内容。
测试模式集合
模式测试应覆盖分支边界,而不只是每个分支一个快乐路径。关系范围至少测试每个边界及其相邻值;类型分类至少测试基类、派生类、未知实现与 null;列表分类至少测试少一个、恰好满足和多一个元素。
分支顺序测试需要选择同时满足多个模式的输入。例如,大额 VIP 订单也满足一般正金额分支,只有这种重叠输入才能证明具体分支确实优先。若输入一次只满足一个模式,交换分支后测试仍会通过。
守卫测试应控制外部时间、区域性和服务结果。示例把日期作为参数传入,而不是在守卫里读取 DateTime.Now,因此周末与工作日都能确定地重现。复杂守卫若需要多个依赖,应先计算一个有名称的领域事实,再让匹配结构消费它。
对非穷尽设计,测试还要触发失败路径。给枚举强制转换一个未定义数值,传入未知派生类型,并构造未覆盖长度的数组。成功断言之外,还要检查异常类型或错误结果,确保失败不会被 _ 悄悄转成正常值。
5个问题 · 2 道输出预测题 · 1 道找错题