资讯动态

C# 9.0模式匹配与nint:原理、用法与避坑指南

发布时间:2026/10/14 18:20:10 来源:尧图企业网站定制
某次 Code Review 时一位同事把一段“四个 if 叠两层 switch”的订单状态判断代码扔给我问我能不能简化。我那天正好在处理 nint 相关的互操作问题就顺手用 C# 9.0 的 Pattern matching 把那坨逻辑重写成了十几行的 switch 表达式看得他直说“这语法糖真甜”。也就是从那次之后我才对这个版本有了实感关系模式、逻辑模式、not 模式补全了模式匹配的最后一公里而低调的 nint 则在 32/64 位互操作和底层计算里解决了以前必须绕路才能解决的问题。这篇就从实际项目视角把这两个新特性的原理、写法和踩坑记录一起捋一遍适合刚接触 C# 9.0 想提升代码表达力的朋友也适合在旧代码里纠结要不要升级语法的团队参考。1. 模式匹配这块C# 9.0 到底补了哪三张拼图1.1 一张表看懂 C# 7 到 C# 9 的模式匹配演进其实模式匹配不是从 C# 9.0 才有的。C# 7 带来了is类型模式和常量模式C# 8.0 又加了 switch 表达式、属性模式、元组模式和位置模式但那时候的 pattern 还是“零散招式”——比如你知道可以用obj is int i来解包一个整数也知道可以在 switch 表达式里写{ Length: 1 }这种属性模式可一旦遇到“整数大于 0 且小于 100”这种组合条件你还是得回到 if 语句里继续嵌套。原因很简单没有关系模式你写不出 0没有逻辑模式你写不出and和or没有括号模式组合优先级也没法保证。C# 9.0 补齐的正是这最后三块拼图。关系模式让数值比较可以出现在模式里逻辑模式让多个模式可以按and、or、not来组合括号模式则让复杂的组合表达式依然能被人类轻松读懂。从这里开始模式匹配才真正从“语法糖拼盘”变成了一门可以完整表达业务规则的“小语言”。我用一张表把演变过程整理出来方便对照模式类型引入版本一句话说明示例类型模式C# 7用is判断并解包类型obj is string s常量模式C# 7匹配固定常量obj is 1属性模式C# 8按对象属性匹配{ Length: 0 }元组模式C# 8按元组各项匹配(true, 10)位置模式C# 8按 Deconstruct 匹配p is Point(0, 0)关系模式C# 9用 比较 0 and 100逻辑模式C# 9用and/or/not组合not null括号模式C# 9改变模式优先级(a or b) and c这几种模式都不是为了炫技。关系模式解决的是“条件判断写不到模式里”的尴尬逻辑模式解决的是“多个模式怎么串起来”的刚需括号模式则是在逻辑模式多了以后被迫补上的表达工具。三者合在一起你才能写出那种“读起来像业务规则原文”的代码。1.2 关系模式把数值分段变成一门语言关系模式支持四种比较符号、、、。它和常量模式的边界很清晰等值判断用常量模式is 1或is ok大小判断用关系模式。为什么没有和!的关系模式因为这两个判断完全可以用常量模式和not模式替代加进去反而造成语法冗余。使用关系模式有一个隐含要求类型需要支持比较语义底层实际调用的是IComparableT.CompareTo。int、long、double、DateTime、string 这些常见类型都天然满足。这里有两个容易忽略的细节先看第一个。关系模式不只是给 int 用的写日期区间判断时非常舒服string GetTimeOfDay(DateTime now) now switch { { Hour: 6 } 深夜, { Hour: 6 and 12 } 上午, { Hour: 12 and 18 } 下午, _ 晚上 };这个写法里属性模式{ Hour: ... }负责把对象的Hour属性解出来关系模式负责判断数值区间and负责把两个区间条件串起来。放在 C# 8 你只能写now.Hour 6 ? ... : now.Hour 6 now.Hour 12 ? ...哪一个更接近业务语言一眼就能看出来。再看第二个。关系模式有类型推断但并不是无限推断。如果模式左边是object类型想写obj is 0是不行的因为编译器不知道用什么类型比较。这时候需要先用类型模式锁定类型obj is int and 0。先匹配类型再比较大小顺序不能反。1.3 逻辑模式与括号当模式学会讲“与或非”有了关系模式组合就是自然而然的下一步。C# 9.0 提供了三个逻辑操作符and与、or或、not非。优先级顺序需要背一下not 最高其次 and最后 or。如果不确定就加括号编译器和人的阅读习惯都欢迎括号。几个典型组合// 非空判断 if (obj is not null) { } // 数字落在 [1, 10] 或 [20, 30] bool InRange(int v) v is 1 and 10 or 20 and 30;第二个例子的优先级刚好是我们要的( 1 and 10) or ( 20 and 30)。但如果想让“大于 10 且5 到 15”就必须写括号否则解析结果完全不同。这就是括号模式存在的意义。然后这里藏着一个非常重要的语义限制or 模式里不能绑定变量。比如obj is int i or string s直接编译失败因为 or 模式的两个分支都各自带变量绑定一旦匹配成功编译器不知道结果里哪个变量是确定的。而and模式可以绑定如obj is int i and 0因为整个模式匹配成功后变量i必然来自唯一的那个 int 分支。这个限制看着像语法规定其实是为了保证语义没有歧义。1.4 从 is 到 switch 表达式一个例子看懂玩法如果想直观感受这三张拼图怎么一起工作看这个public static string Describe(object value) value switch { null 空值, int and 0 负整数, int and 0 零, int and 0 and 100 正小整数, int and 100 大整数, string { Length: 0 } 空字符串, string { Length: 0 } 非空字符串, _ 其他类型 };花几秒钟盯一下这个例子信息量其实很大int and 0类型模式 逻辑模式 关系模式一次搞定“是 int 且为负数”int and 0常量模式匹配整数 0string { Length: 0 }类型模式 属性模式 关系模式把“非空字符串”的判断收敛成一个分支。这种表达密度在 C# 9.0 之前是做不到的。它把“类型判断 属性取值 数值比较 逻辑组合”全部收敛到了 switch 表达式的单分支里没有临时变量没有嵌套 if没有魔法数字。2. 用订单折扣场景走一遍从 if 堆砌到表达式式写法每次讲模式匹配我都习惯拿一个真实项目里的老代码当例子因为只有对比过“以前怎么写”和“现在怎么写”语法糖的价值才不是空谈。2.1 老代码长什么样痛点在哪里假设某个电商系统里有一块计算订单折扣的逻辑。需求本身是普通用户满 500 打 9 折新用户满 100 打 95 折VIP 用户满 1000 打 8 折其他情况不打折。这是很常规的规则听起来不复杂。但沉淀了一段时间后代码长成了这样public static decimal CalculateDiscount(Order order) { if (order.Amount 0) throw new ArgumentException(订单金额不能为负); decimal discount 0m; if (order.IsVip order.Amount 1000) { discount order.Amount * 0.2m; } else if (order.Amount 500) { discount order.Amount * 0.1m; } else if (order.IsNewUser order.Amount 100) { discount order.Amount * 0.05m; } return discount; }这段代码的问题不在“能不能跑”而在“看清规则的时候很难受”。所有业务规则都被压进了 if/else 的线性结构里一眼扫过去你很难直接说出“VIP 满 1000”和“普通 满 500”的层级关系。更麻烦的是后续需求想加一个“VIP 新用户额外减 5 元”你就得在现有分支里找一个既不破坏老用户、又不影响 VIP 大额订单的插入点。规则越多这个插入点越难找最后代码往往被改成六七个 if 嵌套在一起的混沌状态。2.2 第一轮改造switch 表达式 属性模式 关系模式用 C# 9.0 的模式匹配重写不需要修改Order的任何定义不需要额外抽象只是把判断逻辑换一种组织方式public static decimal CalculateDiscount(Order order) { if (order.Amount 0) throw new ArgumentException(订单金额不能为负); return order switch { { IsVip: true, Amount: 1000 } order.Amount * 0.2m, { Amount: 500 } order.Amount * 0.1m, { IsNewUser: true, Amount: 100 } order.Amount * 0.05m, _ 0m }; }第一眼看到这个版本你会觉得它“像一张规则表”。每一行都独立描述一种优惠条件命中就返回对应折扣。分支之间不需要考虑嵌套层级增加新规则只需要在表里插入一行而不是在 if 里拆墙。属性模式负责把order.IsVip、order.Amount解出来关系模式负责金额区间的判断switch 表达式负责把第一个匹配的分支作为结果返回。这里有一个顺序上的讲究。分支必须按“从特殊到一般”排列VIP 满 1000 是最特殊的必须排在普通满 500 之前普通满 500 又必须在“新用户满 100”之前因为Amount 500也同时满足Amount 100如果先把新用户分支放前面普通用户满 500 就会错误地命中新客 95 折。模式匹配的检查顺序和 if/else 一样是“从上到下、先到先得”但换成表格形式以后顺序错误更容易被 Code Review 的人一眼看出来。2.3 第二轮改造元组模式 or/and 表达多组合规则如果规则更复杂一点要求按用户等级和金额两个维度同时决定折扣幅度元组模式会更合适。下面这个例子把结果分成三档铂金、黄金、白银public static string GetDiscountLevel(Order order) (order.IsVip, order.Amount) switch { (true, 1000) 铂金, (true, 500) or (false, 2000) 黄金, (_, 100) 白银, _ 普通 };这个写法的精妙之处在于(order.IsVip, order.Amount)先构造一个二元组然后用元组模式逐个匹配元组里的元素第一行(true, 1000)是“VIP 且满 1000”第二行(true, 500) or (false, 2000)把“VIP 满 500”和“非 VIP 满 2000”两条独立规则合并到一个分支里or在这里真正起到了规则并集的作用第三行(_, 100)里的_是弃元表示“不管是不是 VIP只要满 100 就到白银”。可以看到一旦把条件拆成元组维度规则之间的排列组合就变得非常直观。原来需要用、||、括号、多层 if 才能表达的逻辑现在全部变成了模式表达式里的组成部分。2.4 改造过程中必须守住的约束模式匹配虽然好写但不是没有约束。第一个要注意的是 switch 表达式要求所有路径必须返回一个值所以_分支不能省略否则编译器直接报错“并非所有代码路径都返回了值”。这和传统 switch 语句不同传统 switch 可以只命中部分 case 然后继续往下执行而 switch 表达式没有 fall-through。第二个约束是模式里不能带副作用。你可以在分支右侧调用方法、抛异常但不能在模式匹配的过程中修改变量状态。所以前面看到的所有代码分支右侧都是计算表达式不会出现赋值行为。这其实是好事它强制你写出更函数式的逻辑。第三个约束是结果语义switch 表达式返回的是“第一个匹配分支的值”不会像 switch 语句那样执行完一个 case 再 fall through 到下一个。所以分支顺序就是业务优先级多分支规则下这一点必须牢记。前面说的“VIP 新用户额外减 5 元”需求新增分支必须放在普通折扣之前否则永远轮不到它。3. nint 在底层交互里比 IntPtr 好用在哪模式匹配讲得差不多来聊 nint。这个特性在标题里和 Pattern matching 并列但网上讨论热度一直不高。我猜原因很简单日常 Web 开发几乎用不到它只有接触到底层交互、内存操作、跨平台接口的时候你才突然发现“以前怎么写都别扭”的代码换上 nint 之后顺了很多。3.1 nint 的本质原生尺寸整数nint是“native integer”的缩写同系列的nuint是它的无符号版本。它的核心特点是一个词随平台。进程是 32 位的nint 就是 32 位整数进程是 64 位的nint 就是 64 位整数。从运行时角度看nint映射到System.IntPtrnuint映射到System.UIntPtr。但 nint 和直接用IntPtr的体验完全不同。IntPtr虽然叫“指针”本质上是一个结构体你不能直接对它做算术运算。想加一个偏移量得写IntPtr.Add(basePtr, offset)想取整数值得调ToInt32()或ToInt64()。而 nint 被编译器当作真正的整数类型对待加减乘除、位运算、比较运算符全部原生支持还能参与编译期常量计算。看个直观对比// IntPtr 的老写法 IntPtr a new IntPtr(10); IntPtr b new IntPtr(20); IntPtr sum IntPtr.Add(a, b.ToInt32()); // nint 的新写法 nint a2 10; nint b2 20; nint sum2 a2 b2;第二种写法不需要任何额外方法读起来和普通整数运算没区别。更重要的是nint 的运算结果类型还是 nint不会在每一步都退化成 int 或 long这在大地址运算场景里非常关键。3.2 从 IL 层面看 nint为什么能随平台自适应如果你反编译过用 nint 写出来的程序集会在元数据里看到native int这样的类型记号。它不是被硬编码成 int 或 long而是在运行时 JIT 阶段才绑定到当前平台的位数。换句话说同一段 IL 在 32 位运行时上被编译成 4 字节整数指令在 64 位运行时上被编译成 8 字节整数指令。这带来一个实际好处对于某些调用原生 API 的场景你不需要针对 x86 和 x64 分别写IntPtr和long两套互操作代码一个 nint 就能把两边的返回类型统一接住。这比自己在项目里用条件编译复制好几份代码要干净得多。3.3 类型转换规则一定要记住nint 的类型转换规则是很多人一开始都会搞混的点我整理了一个常用转换表转换方向是否隐式说明int → nint隐式int 肯定在 nint 范围内不会溢出uint → nuint隐式uint 肯定在 nuint 范围内不会溢出nint → long隐式64 位下同位宽32 位下必然能放下nuint → ulong隐式语义同上long → nint显式在 32 位进程下可能溢出nint → int显式在 64 位进程下可能溢出ulong → nuint显式在 32 位进程下可能溢出为什么 int 到 nint 可以隐式因为 nint 的最小宽度是 32 位int 的值一定能放下。为什么 nint 到 long 可以隐式因为 long 的最小宽度是 64 位而 nint 最大也就 64 位。反过来就都要显式转换编译器不给任何信任。这套规则本质上和 C 语言里intptr_t的转换逻辑一脉相承你只要把 nint 想象成“等于当前指针宽度的整数”就不容易搞混。3.4 实战场景互操作句柄与指针偏移nint 出镜率最高的地方是 P/Invoke 和 unsafe 代码。举两个我项目里真实遇到的场景。第一个场景是接收原生函数返回的句柄。某底层库的一个函数返回设备句柄句柄本质是一个地址在 32 位下是 4 字节在 64 位下是 8 字节。以前我得根据平台写两个重载签名一个返回IntPtr另一个返回long现在直接定义一个 nint 返回值就能通吃。第二个场景是 unsafe 指针偏移。C# 的指针算术要求偏移量是整数类型int 也行但遇到超过int.MaxValue的偏移就得用 long。在 x64 下处理 GB 级缓冲区时long 可以可它读起来没有“和指针同宽”的感觉。换成 nint 以后下面这种写法就非常自然unsafe { byte[] buffer new byte[10 * 1024 * 1024]; fixed (byte* p buffer) { nint offset 4 * 1024 * 1024; // 4MB 偏移 byte* target p offset; // 处理 target 附近的数据 } }p offset能编译是因为 nint 被识别为合法的指针运算偏移量。放在以前你只能看着IntPtr干瞪眼然后被迫转成 int 或者 long 再计算。这种地方看起来都是小事但底层代码天天处理这些小事攒多了就会变成真正的效率瓶颈和心智负担。3.5 nuint 与原生无符号类型的关系与 nint 对应的 nuint 主要用于需要无符号原生整数的场景。比如做内存分配时某个 API 返回的 size 参数可能是uintptr_t用 nuint 正好与之对应。跨语言封装时nint/nuint 和 C/C 的intptr_t/uintptr_t形成天然对应这也是互操作场景里它们会越来越重要的原因。4. 那些不读文档绝对会踩的坑模式边界与 nint 溢出的案例分析到这一步原理和正常用法都讲完了。接下来是文章里我认为最有价值的部分——踩坑记录。每个坑我都尽量复现出当时的问题上下文方便你对照排查。4.1 关系模式碰上浮点数NaN 是个幽灵关系模式对数值类型来说最大的隐藏陷阱是double.NaN。在 IEEE 754 标准下NaN 和任何数值比较都是 false等于它自己也是 false。这不只是数学背景知识它会直接改变你 switch 表达式的行为。double value double.NaN; string result value switch { 0 负, 0 非负, _ 未知 };猜猜这个代码返回什么实际上是“未知”。因为 NaN 既不满足 0也不满足 0最后落到_分支。如果你没给_分支这段代码就会直接抛出异常而不是返回某个你觉得“应该正确”的结果。所以在处理可能为 NaN 的数据时要么在进入 switch 前显式判断double.IsNaN(value)要么把 NaN 也当作一个模式分支考虑进去。类似的还有无穷值。double.PositiveInfinity满足 0double.NegativeInfinity满足 0这些不会太意外但要留意边界是还是因为浮点边界判断在极值附近非常容易写错。4.2 or 模式不能带变量是语法规则也是语义限制我在前面已经提过这个限制但这里要展开讲一下为什么它一定会报错。看这段代码if (obj is int i or string s) { // i 和 s 到底该用哪个 }如果 obj 是 int 5模式匹配成功i绑定到 5但s没有值如果 obj 是 abcs绑定到 abc但i没有值。编译器无法在 if 块内确定“哪个变量一定被赋值了”所以直接禁止这种写法。解决办法是把变量绑定挪到 or 模式之外或者拆分成两个分支处理。这个坑特别容易遇到因为从直觉上你会觉得“两个分支我都写了变量总有一个能用”但编译器不这么想。模式绑定要求“一旦模式匹配成功变量必然有值”这个强保证是它的核心语义or 模式破坏了这个保证。4.3 分支顺序与无效分支的隐藏问题模式匹配没有编译器级别的“分支可达性分析”所以你写出来的无用分支不会被警告。比如int number 50; string result number switch { 0 and 100 范围内, 50 正好五十, // 这个分支永远不会执行但编译器不会提醒你 _ 其他 };第二个分支50永远够不到因为第一个分支 0 and 100已经把它包含了。代码编译通过运行结果也“正确”但如果你靠这个分支来输出特定提示就会变成隐蔽的 bug。这类问题在 if/else 时代同样存在但模式匹配的表达密度更高一旦分支多起来漏看的可能性反而更大。建议在关键分支上加注释或者用静态分析工具做一次覆盖检查。另一个相关的问题是 switch 表达式的穷尽性。编译器只强制要求“必须有_分支或确认所有可能都被覆盖”但不会验证你的业务分支是否覆盖了所有预期情况。枚举类型有覆盖检查自定义类型就得靠开发者自己把握。4.4 nint 的溢出行为与 checked/uncheckednint 参与算术运算时默认遵循 C# 的 unchecked 语义——溢出不会抛异常只会悄悄绕回。看这个例子nint value nint.MaxValue; nint result value 1; // 不会抛异常结果变成 nint.MinValue在 64 位进程下nint.MaxValue是 long.MaxValue加 1 绕回 long.MinValue。如果你的代码依赖这个值做内存地址计算就会直接算出错误地址。解决办法有两个要么在做关键运算时包checked块checked { nint next current 1; }要么在逻辑层面提前判断是否可能越界。底层互操作代码里我更推荐后者因为 checked 抛出的 OverflowException 在异步 IO 场景里不好处理不如在计算前就校验。4.5 32/64 位切换时的 nint 陷阱nint 最大的优势是自适应平台但这也是最大的风险来源。如果在 64 位环境写了一年代码某天突然部署到 32 位进程nint 的宽度会从 8 字节变成 4 字节。代码里凡是拿 nint 当“大整数”存储的都会遇到精度问题。最典型的是用 nint 存“全局累计序号”。假设服务运行在 64 位时序号涨到 50 亿这时它还能正常存储换到 32 位进程后nint 只有 32 位50 亿直接溢出成了负数。严格来说用 nint 存业务序号本身就是错误的——它应该只用于和原生尺寸绑定的场景例如句柄、内存偏移、P/Invoke 参数。凡是与业务数字有关的逻辑老老实实用 int/long明确不要用 nint。4.6 模式匹配和 nint 组合时的类型推断问题最后补一个混合场景的坑。当你对 object 用模式匹配提取 nint 时需要显式写全类型object raw (nint)100; if (raw is nint value) { Console.WriteLine(value); }这段代码看起来正常但要注意raw在运行时实际盒装的是什么。由于 nint 映射到IntPtrraw is nint value等于raw is IntPtr value。如果你在 64 位平台把 long 值塞进 object再试图用is nint匹配是不会命中的——因为它们底层类型不同。跨位数平台测试时这种“看起来等价实际上不等价”的坑最磨人。遇到类似问题时先用value.GetType()打印出装箱真实类型再决定模式该怎么写。5. 一点个人经验什么时候该用、什么时候别硬用最后分享几条我在实际项目里总结出来的原则算是对这两大特性的使用边界做一次收敛。先说模式匹配。它适合的条件很清晰规则多、组合条件多、判断逻辑频繁变化。电商折扣、风险控制、消息路由、对象格式校验这些场景用 switch 表达式 模式匹配代码会越改越清爽。但它不适合“条件分支里还要穿插复杂命令逻辑”的代码——比如某个分支里既要改 A 状态又要发通知还要记录审计日志。这种强烈依赖副作用的流程强行套模式匹配只会把副作用藏进表达式里反而更难维护。最好的做法是让模式匹配负责“分类”分类之后的分支动作仍然用传统语句写清楚。再说 nint。它最合适的用途就是和指针宽度、原生类型相关的场景包括 P/Invoke、句柄传递、内存偏移、与 C/C 的 native 代码互操作。如果你只是业务计算里需要一个“比较灵活”的整数那还是用 int 或 long不要为了新特性硬上 nint。我见过有人把订单金额字段定义成 nint理由是“以后可能变大”这完全用错了对象金额这类业务数字不但需要固定位宽还需要 decimal 这样的精确语义。最后分享一个小技巧在团队代码库里引入这两个特性时不用一步到位全项目推广。我一般是先挑一个模块用模式匹配重写两处最容易看懂的条件判断再在一个互操作工具类里引入 nint让同事通过实际 diff 感受差异。语法糖这种东西光讲效果有限本地跑一遍前后对比比任何文档都有说服力。我给同事讲的时候常打的比喻是模式匹配是“分类器”负责把数据分门别类nint 是“量具”负责在底层世界里按指针宽度量尺寸。分清这两者的用途写代码时就不容易用错。就分享到这儿。如果你正打算升级 C# 9.0或者已经在用但被这两个特性卡过欢迎在评论区聊一聊你的具体场景说不定某个坑我这次也漏掉了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑