模式匹配源码级剖析:C# 如何改变与数据结构的交互方式
系列 :C# 与常用数据结构源码剖析 · 高级特性实现原理篇
阅读时间 :约 55 分钟
版本边界:主线以 C# 7---12 与 .NET 8 为例;Unity 中是否可用取决于 Editor 版本、脚本运行时与编译器,不能只看语法所属的 C# 版本。
一、不要把模式匹配理解成"更短的 if"
模式匹配的真正价值,不是少写几个括号,而是把"判断数据属于哪一种形态,并从中提取有效数据"变成一个可由编译器分析的整体。传统写法往往把类型检查、空值检查、强制转换、字段判断和分支选择拆散在多行中。模式则把这些条件合并为一个声明式规则。
对数据结构而言,这种改变尤其重要。链表节点可以被视为"空"或"非空",语法树节点可以被视为若干种派生类型,指令可以被视为带不同载荷的联合,数组或 Span<T> 可以被视为具有特定前缀、后缀和长度的序列。模式匹配为这些"数据形状"提供了统一的查询语言。
但是,"声明式"不等于"没有成本"。属性模式可能调用 getter,位置模式可能调用 Deconstruct,列表模式会读取长度并访问索引,切片模式的成本取决于被匹配类型的 Slice 或 Range 索引器。对数组切片可能产生新数组,而对 Span<T> 切片通常只是构造一个新视图。因此本文不使用"模式匹配一定零开销"这类结论,而是追踪它的语义、降级和数据访问路径。
二、从 C# 7 到 C# 12:能力是如何拼起来的
C# 7 引入类型模式、常量模式与 when 守卫,让"判断类型后立即获取强类型变量"成为基础操作。C# 8 加入 switch 表达式、属性模式与位置模式,分支开始像一张由上到下的规则表。C# 9 加入关系模式和 and、or、not 逻辑组合。C# 10 支持扩展属性模式,减少深层对象图的括号噪声。C# 11 加入列表模式和切片模式,使顺序容器也能按形状解构。C# 12 虽然没有再增加一组同等量的新模式,但一级构造函数、集合表达式等能力会与模式一起改变数据建模方式。
下面是一个由类型、属性和守卫共同组成的规则表:
static float Evaluate(Command command) => command switch
{
MoveCommand { Distance: <= 0 } => 0,
MoveCommand { Distance: > 0 and <= 10 } move => move.Distance,
AttackCommand { Target.IsAlive: true, Power: > 0 } attack
when attack.Target.IsVisible => attack.Power * 1.25f,
AttackCommand => 0,
null => throw new ArgumentNullException(nameof(command)),
_ => throw new NotSupportedException(command.GetType().Name)
};
这段代码同时做了五件事:确认派生类型,读取属性,检查数值范围,绑定强类型变量,并对不支持的命令显式失败。阅读者看到的是业务规则,编译器看到的则是一组可进行包含、可达性和穷尽性分析的测试。
三、编译器内部:从语法树到决策 DAG
"编译器把模式直接翻译成一串 if-else"只是粗略比喻,并不是准确的实现模型。Roslyn 首先绑定模式中的类型、成员、转换和变量,然后用决策有向无环图(decision DAG)表示多个分支共享的测试。DAG 中通常有三类动作:测试某个条件,从输入计算一个临时值,或将已确定的值绑定给模式变量。
假设多个分支都先检查 Order 的 Customer 不为空,再读取 Customer.Level。朴素翻译可能在每个分支重复做相同的空值测试和属性读取。决策 DAG 允许前缀测试被共享,再在后续节点分叉。降级阶段会将这张图转换成标签、条件跳转、临时变量和必要的类型测试,最终再生成 IL。
这个模型解释了两个容易被忽略的现象。第一,源码中的分支顺序具有语义,"更具体"的规则必须放在会覆盖它的宽泛规则之前,否则编译器可以判定其不可达。第二,不应依赖未由语言规范保证的属性求值次数。getter 应尽量稳定、无副作用且成本可预期;不要让一次模式匹配暗中发送网络请求或改变游戏状态。
3.1 常量、类型与 null 测试
x is null 是模式测试,不会调用用户重载的 operator ==;这对存在运算符重载的数学类型或 Unity 对象语义尤其需要注意。在 Unity 中,UnityEngine.Object 对 == null 有特殊的"原生对象已销毁"语义,而语言层的 null 模式检查的是托管引用。因此,不能机械地用 obj is null 替换游戏代码中所有 obj == null:两者可能回答不同的问题。
类型模式 value is Enemy enemy 在匹配成功后才让 enemy 成为确定赋值的变量。对引用类型而言,null 不会匹配任何非 null 类型模式。对装箱值而言,类型测试可以识别其真实值类型并执行拆箱;装箱是否发生通常由值进入 object 或接口的早期路径决定,不应把开销简单归罪于 is 关键字。
四、属性模式:在对象图上做结构查询
属性模式会通过可访问成员观察对象,并不要求对象本身是 record。扩展属性模式能将 { Position: { X: > 0, Y: > 0 } } 写成 { Position.X: > 0, Position.Y: > 0 },但它仍然是沿对象图读取数据,并不绕过封装。
static bool CanLock(Target? target) => target is
{
IsAlive: true,
Transform.Position.Y: >= -100 and <= 100,
Faction: not Faction.Friendly
};
对热路径使用属性模式时,要问三个问题。第一,getter 是返回字段,还是每次都执行查找、聚合或分配?第二,中间引用可否为 null,不匹配是预期分支还是应当暴露的数据错误?第三,匹配期间对象是否可被其他线程修改?模式只是一组读取,不自动创建一致性快照。并发容器保证单个 API 的线程安全,也不保证多个属性读取组成原子事务。
五、位置模式与 Deconstruct:便利也是 API 契约
位置模式通常通过 Deconstruct 将对象投影为若干个位置值。record 可由编译器生成相应成员,普通类型也能自己实现。
public readonly record struct GridCell(int X, int Y, CellKind Kind);
static int MovementCost(GridCell cell) => cell switch
{
(_, _, CellKind.Blocked) => int.MaxValue,
(0, 0, _) => 0,
(>= 0 and < 100, >= 0 and < 100, CellKind.Road) => 1,
_ => 3
};
对坐标、颜色、日期等稳定值对象,位置有清晰含义。对于有十几个字段、字段顺序可能演化的配置对象,位置模式反而会隐藏意图。(100, 2, 5) 不如 { Health: 100, Level: 2, Rarity: 5 } 自证。一旦公开 Deconstruct,位置顺序就变成 API 契约;随意调换会让旧调用方在不显眼的位置改变语义。
Deconstruct 也不是"编译器直接读字段"的许可。如果实现内部创建容器或执行昂贵计算,每次匹配都会承担该成本。在低延迟游戏循环中,应使用 Profiler 或微基准验证真实路径,而不是根据语法长短推测成本。
六、关系与逻辑模式:范围、组合和类型收窄
关系模式适合表达分段函数,逻辑模式则组合子条件。其绑定优先级从高到低是 not、and、or。当业务语义稍微复杂时,应使用括号,不要把正确性寄托给读者对优先级的记忆。
static string Classify(float hpRatio) => hpRatio switch
{
< 0 or > 1 => throw new ArgumentOutOfRangeException(nameof(hpRatio)),
0 => "Dead",
> 0 and <= 0.2f => "Critical",
<= 0.7f => "Wounded",
_ => "Healthy"
};
分支由上到下匹配。上例中 <= 0.7f 之所以能表示 (0.2, 0.7],是因为更小的区间已被前面的分支接住。这种写法简洁,但在规则经常重排时也容易引入错误。对策划频繁修改的数值规则,可以在每个分支中写完整边界,再配合边界值单元测试。
and 左侧还可以收窄右侧的输入类型,因此 o is byte and < 100 在类型测试成功后才进行数值比较。而 or 的两侧不能任意声明之后需要使用的不同变量,因为编译器无法保证到底哪一个被赋值。这不是语法限制的偶然,而是确定赋值规则在保护后续代码。
七、列表模式:不是 IEnumerable 的通用遍历语法
列表模式看起来像在枚举序列,实际上依赖"可计数、可按整数索引"的形状。数组、Span<T>、ReadOnlySpan<T> 与许多列表类型可以匹配,但仅实现 IEnumerable<T> 的懒惰序列并不因此自动可匹配。这个设计避免了一次表面上的判断暗中消费无限序列或执行不可预期的 I/O。
static PacketKind ParseHeader(ReadOnlySpan<byte> bytes) => bytes switch
{
[0x47, 0x41, 0x4D, 0x45, ..] => PacketKind.Game,
[0x50, 0x49, 0x4E, 0x47] => PacketKind.Ping,
[] => PacketKind.Empty,
_ => PacketKind.Unknown
};
一个没有切片的列表模式通常先检查精确长度,再访问必要的索引。含 .. 的模式允许忽略中间部分;只有将切片绑定给子模式或变量时,才需要实际获取那个切片。[.., 0] 表达"末尾为零",不需要把前缀复制出来;[.. var rest, 0] 则要根据输入类型的切片语义计算 rest。
对热路径的协议解析,ReadOnlySpan<byte> 与列表模式可以同时获得可读性和无堆分配切片。但是"无分配"必须由具体类型和降级结果验证。如果换成数组并绑定切片,就可能复制元素;如果自定义索引器内部做了查找,复杂度也不再是直觉上的常数。
八、数据结构设计:用封闭形状代替"布尔字段汤"
模式匹配最适合的建模对象,是有限且语义明确的状态集。例如一个加载结果不要同时保存 IsLoading、IsSuccess、HasError 三个可以相互矛盾的布尔值,而可以建模为三种互斥类型:
abstract record LoadState<T>;
sealed record Loading<T>(float Progress) : LoadState<T>;
sealed record Loaded<T>(T Value) : LoadState<T>;
sealed record Failed<T>(string Message, bool Retryable) : LoadState<T>;
static string ToLabel<T>(LoadState<T> state) => state switch
{
Loading<T> { Progress: < 0 or > 1 } => "invalid progress",
Loading<T> { Progress: var p } => $"loading {p:P0}",
Loaded<T> => "ready",
Failed<T> { Retryable: true, Message: var m } => $"retry: {m}",
Failed<T> { Message: var m } => $"failed: {m}",
_ => throw new UnreachableException()
};
这类结构常被称为"可辨识联合"风格,但 C# 的类继层次默认不是语言级的封闭联合。即使当前只有三个派生类,其他程序集仍可能增加新类型,取决于基类的可访问性与构造函数设计。因此生产代码应保留明确的默认分支,或用分析器和测试保证每次新增类型时都更新消费者。_ 不应总是"忽略错误",在不可能分支中抛出异常往往比返回假数据更安全。
九、字典、集合与树:模式不会改变容器复杂度
模式可以解构从字典中取出的值,但它没有提供"字典键模式"。查找仍应使用 TryGetValue,以一次哈希查找同时完成存在性判断和取值,然后再对值做模式匹配。
if (components.TryGetValue(entityId, out var component) &&
component is HealthComponent { Current: > 0, Maximum: > 0 } health)
{
RenderBar((float)health.Current / health.Maximum);
}
不要为了写成 switch 而先把字典复制成列表,也不要在循环内使用 ContainsKey 后再访问索引器,那会做两次查找。语法不会改变哈希冲突、红黑树高度或数组局部性;它只能让取出元素后的分支更集中。
对递归树结构,类型模式很自然,但深树仍然可能导致调用栈溢出。将节点写成 switch 不会把深度优先递归自动变成迭代算法。处理不可信语法树、地图节点或对话图时,仍要设置深度限制,或用显式 Stack<T> 实现迭代遍历。
十、性能边界:从"看起来很快"到可验证证据
分析模式匹配性能时,可以按以下顺序建立成本模型:
- 输入如何到达匹配点?值类型是否已经因
object或接口而装箱? - 模式读取哪些成员?getter、
Deconstruct、索引器或切片操作的真实成本是什么? - 分支分布如何?最常见的路径是否需要穿过很多测试?
- JIT 能否内联相关成员?AOT 或 IL2CPP 的结果是否相同?
- 问题是 CPU 时间、分配、代码尺寸,还是分支错误预测?不同指标需要不同工具。
对普通业务分支,模式和手写条件最终往往生成相近的代码,应优先选择可读、可证明正确的表达。对每帧处理数十万元素的热循环,要分别查看 Roslyn 生成的 IL、目标运行时的机器码与分配记录。不能用桌面 .NET 8 的 BenchmarkDotNet 结果直接代替 Unity IL2CPP 设备上的 Profiler 数据。
10.1 一个可靠的对照实验
若要比较 switch 模式与手写 if 链,必须让两者完成同样的工作,返回结果以防止无用代码被消除,并使用多种输入分布。全部输入落在第一个分支,与均匀落在十个分支,对分支预测器是两个完全不同的实验。对 getter 的调用次数有疑问时,可以先用带计数器的测试类型验证语义,但计数器本身会扰动性能,不应留在最终基准中。
十一、常见误用与修正方法
11.1 把默认分支当垃圾桶
如果 _ => 0 同时吸收了未知类型、非法数据和真正的零值结果,线上错误会被伪装成正常数据。应将 null、已知但无效的状态、以及不可达状态分别处理。游戏客户端若不宜抛异常,也至少要记录可聚合的错误事件,而不是静默返回假结果。
11.2 在模式中隐藏有副作用的属性
一个名为 Inventory.TotalWeight 的 getter 若每次都遍历整个背包,那么属性模式的简洁会隐藏 O(n) 成本。应将结果缓存到局部变量,或重新设计数据结构,让频繁查询变成可维护的派生状态。一个好的 getter 应让调用者能从名称与文档预判成本。
11.3 认为模式会自动穷尽所有类继分支
bool、枚举、可空值类型与某些有限输入能得到很强的穷尽性分析,但普通类继体系可被扩展。将基类设为 abstract 只是禁止直接实例化,不是禁止新增派生类。要将默认分支设计成可观测的失败,并在单元测试中枚举已知变体。
11.4 为追求一行代码而牺牲调试性
嵌套属性、逻辑模式、when 守卫和复杂表达式全部塞进一个 switch arm,会让断点、日志和代码审查变得困难。当一个分支需要多步计算或错误上下文时,应调用有名称的方法。模式负责选路,方法负责完成该路径的业务逻辑。
十二、Unity 落地清单
在 Unity 项目中引入较新模式语法前,首先用目标 Editor 和 CI 的实际编译器试编译,不要仅依据本机 SDK。其次在 Mono Editor 中验证功能不等于 IL2CPP 玩家构建的性能结论;要在目标设备、目标后端与发布配置下采样。再次,对 UnityEngine.Object 保留其特殊 null 语义,不做全局机械替换。最后,网络协议、存档和配置解析中的默认分支必须留下版本号、输入摘要和错误类型,以便定位版本漂移。
一个可以进入代码评审的模式分支,应能回答:输入集是否完整,分支是否互斥,边界值归属哪一支,默认分支如何暴露未知数据,成员读取是否无副作用,以及目标运行时上是否有性能证据。这六个问题比"是不是比 if 快"更接近工程现实。
十三、本篇结论
模式匹配是一套对数据形状进行测试、提取和选路的语言机制。Roslyn 使用决策 DAG 共享测试并完成可达性分析,然后降级为常规控制流。它不改变字典、树或数组的基本复杂度,也不保证零分配;实际成本来自输入形态、成员实现、切片语义、分支分布和目标运行时。
当数据被建模为有限、互斥且语义清楚的形状时,模式匹配可以让非法状态更少,让分支更容易审查,让新增类型对消费者的影响更可观测。当 getter 有副作用、默认分支吞掉错误,或开发者用语法印象代替测量时,它也可以只是更漂亮的隐患。
建议实验 :用 SharpLab 或本地 Roslyn 编译产物对比类型模式、属性模式与列表模式的 IL;再分别以数组和
ReadOnlySpan<T>绑定切片,检查分配差异。源码阅读线索 :Roslyn 仓库中搜索
DecisionDag、BoundDecisionDag、LoweredSwitch与 pattern matching 相关测试。源码名称可随版本调整,阅读时应固定 commit 或 tag。下一篇:unsafe 代码与指针。