C# 9.0 新特性实战:Pattern matching 与 nint 原生整数详解

发布时间:2026/10/14 18:19:09
C# 9.0 新特性实战:Pattern matching 与 nint 原生整数详解
C# 9.0 的 Pattern matching 和 nint 两大特性很多人觉得一个是“语法糖开大会”一个是“看不懂的冷门货”。但实际用下来这两个东西一个负责让代码“会说话”另一个负责在性能和底层交互上“抄近道”都值得花点时间玩透。这篇文章我按实际项目里的使用思路把这两个特性的设计逻辑、上手姿势、避坑清单完整拆一遍看完你就能直接写进自己的代码里。1. 整体认知为什么这两个特性值得单独拎出来聊1.1 C# 9.0 的定位和这次的主角C# 9.0 是 2020 年底随 .NET 5 一起发布的版本这个版本的意义在于“统一”和“收敛”。它没有像 C# 8.0 那样一口气端出十几个大特性而是把重心放在了“让已有特性变得更好用”上。Pattern matching 是典型代表——C# 7.0 引入了is模式C# 8.0 加了 switch 表达式和属性模式到了 9.0 才真正把“模式匹配”补成一个完整的体系支持关系模式、逻辑模式、not模式再加上简化的嵌套属性写法这时候你写出来的判断逻辑才开始有“声明式”的味道。nint 则完全是另一个维度的东西它解决的是“原生整数类型缺失”的问题。以前你要跟底层代码打交道或者需要“当前进程位宽下最合适的整数类型”只能靠IntPtr这个结构体绕来绕去算术运算、类型转换都要写一堆辅助方法。C# 9.0 把nint做成第一等公民直接用关键字声明运算方式跟int、long完全一致等于语言层面替你把“平台相关的整数”这个老大难问题解决了。1.2 对实际项目的价值Pattern matching 的收益立竿见影。你原来写“判断一个对象是不是某种类型并提取属性”要三四行还要处理强制转换和判空现在一行is模式全搞定。而且逻辑模式支持的and、or、not组合能把原来复杂的if嵌套链压扁成可读性极高的表达式这在状态机、命令分发、策略选择这类场景里尤其舒服。nint 的价值更偏向底层和框架层。举个例子你在做某种跨平台协议解析或者写需要和 C/C 库互操作的封装你希望“当前进程是 32 位就用 4 字节的整数是 64 位就用 8 字节的整数”以前要用Marshal.SizeOf(typeof(IntPtr))去做运行时判断或者直接定义成long然后在 32 位机器上忍受多余的 4 字节。有了nint这种“平台自适应”的整数声明就变成了一行代码。适合读这篇文章的人我觉得有两类一类是日常写业务代码、想提升代码表达力、让逻辑更少 bug 的开发者另一类是写库、写底层封装、需要跟本机代码打交道的同学。前者重点看 Pattern matching 的玩法后者重点看 nint 的原理和注意事项。当然两个都看效果最好因为它们组合起来能解决一类很有意思的问题——比如“根据环境信息做策略路由”这种既需要复杂判断又需要考虑平台特性的场景。2. Pattern matching 新玩法从“能匹配”到“会表达”2.1 关系模式与逻辑模式把 if 链改写成声明式判断C# 9.0 引入的关系模式relational pattern允许你在is表达式里直接使用、、、比较。注意这里没有单独的相等判断因为在模式里本来就不需要——类型模式加{}属性模式已经能覆盖相等性判断的场景了相等判断直接用普通反而更直观。关系模式单独用场景有限真正的杀手锏是和逻辑模式logical pattern组合。逻辑模式提供and、or、not三个关键字用来组合子模式。实际写起来是这样的static string ClassifyTemperature(double t) t switch { 0 冰点以下, 0 and 15 冷, 15 and 25 舒适, 25 and 35 热, 35 炎热, double.NaN 无效值 };我在实际项目里第一次用这个特性时最大的感受是“意图透明”。以前同样的逻辑用if-else if-else链写或者用三元表达式拼接读完还得在脑子里模拟一遍执行路径。现在这种写法switch表达式的每个分支本身就是一条完整的判断声明代码的“形状”和“业务规则”一一对应。尤其当你接手别人的代码这种表达方式能省下大量理解成本。and和or的优先级问题需要注意and的优先级高于or。也就是说is true and false or false等价于is (true and false) or false而不是true and (false or false)。如果混合使用且逻辑层级复杂强烈建议用括号模式parenthesized pattern把意图写清楚不要靠记忆优先级。2.2 not 模式与类型模式改进非空判断和类型检查的优雅写法not模式是 C# 9.0 里我觉得“用一次就回不去”的特性。最经典的是非空判断// 以前 if (obj ! null) { ... } // 现在 if (obj is not null) { ... }可能有人觉得这就是换了个写法没什么本质区别。但在我自己写的代码里is not null有一个隐藏优势它可以出现在表达式和模式匹配的“任意嵌套位置”。比如在 switch 表达式里想区分“空对象”、“某种特定类型”、“其他情况”以前要写好几个分支外加when过滤现在可以直接写成static string Describe(object? o) o switch { null 空对象, string s when s.Length 0 $非空字符串: {s}, string 空字符串, int n $整数: {n}, _ 未知类型 };类型模式的另一个改进是简化了嵌套属性访问。C# 8.0 的属性模式要写o is Address { City: 北京 }C# 9.0 允许直接写成o is { Address.City: 北京 }省掉一层类型判断。这个简化在多层嵌套的数据结构比如解析 JSON、处理配置对象里非常有用。我处理某个模拟项目的配置对象时以前要写一长串类型判断加逐层?.访问现在一条模式直接把“类型”和“属性路径”绑定在一起代码量减少大概 40%而且意图是一眼可见的。2.3 模式匹配核心细节优先级、括号模式和常见坑模式匹配虽然写着爽但有三个细节必须刻在脑子里否则会踩坑。第一个是前面提过的优先级问题。and高于ornot的优先级也需要注意。混用的时候准则很简单涉及两个以上逻辑组合符一律加括号。不要信任自己的记忆也不要信任同事的记忆人的短期记忆在处理逻辑优先级时极不可靠。第二个是关系模式不能和直接组合。is 0 and 10可以但is 0 or 100这种写法在关系模式里是不允许的。相等性判断要么用类型模式加属性模式要么在when子句里做。我第一次写的时候就犯过这个错误编译器直接报错当时还以为是版本特性没开全后来查文档才发现关系模式压根没有设计相等操作。第三个是模式匹配对“值的可变性”不敏感但对“判空”敏感。在is表达式里如果左边是被匹配的对象右边写模式编译器通常会做比较好的空值处理。但注意属性模式匹配的是“非空对象”如果你在模式里写了{ Address.City: 北京 }而o本身是null这个模式匹配会失败返回 false不会抛异常。这是一个很贴心的设计但反过来也意味着如果你需要在对象为null时执行特定逻辑必须显式加一个null分支不能用“默认分支”去猜。我见过不少同学以为_默认分支能捕获null确实能但意图就模糊了。写null ...和写_ ...表达的意义完全不同后者等于告诉读代码的人“这里我不关心具体值”而前者明确说“这里处理的是空引用”。我实际使用中还有一个心得模式匹配写出来的代码编译器生成的 IL 可能跟你手写的判断逻辑不完全一致但这不影响正确性。关键是在代码审查时Reviewer 能更快理解意图这才是这个特性最大的价值。3. nint 和 nuint原生整数类型的玩法和底层逻辑3.1 nint 到底是什么跟 IntPtr、int、long 的关系nint 的全称是 native-sized integer也就是“原生大小的整数”。简单说在 32 位进程里它是 32 位整数在 64 位进程里它是 64 位整数。它的运行时类型实际映射到System.IntPtr但在 C# 语言层面它被当作一个“原生整数类型”来对待可以参与算术运算、比较、位运算直接和int、long互转不需要像以前用IntPtr那样手动调用.ToInt32()或者.ToInt64()。对应地还有nuint即无符号版本映射到System.UIntPtr。很多人会问那直接声明成long不就行了答案是在 64 位环境里确实差不多但 nint 的语义更准确。比如某个 API 的入参规定是“平台原生的指针宽度的整数”你如果声明成long在 32 位进程里就浪费了 4 字节在对接 C/C 互操作时还可能因为类型宽度不匹配导致内存布局错位。用nint代码天生就是“跟着进程位宽走”的。我在某个模拟项目里做过一个内存池设计需要用“当前平台地址宽度”来计算对齐。当时就是用nint定义对齐基数、索引偏移等变量代码在 32 位和 64 位两种进程下分别测试都能正确工作而不用写任何#if条件编译或运行时分支。3.2 nint 的底层原理和性能特性nint 的底层是IntPtr但 C# 编译器对它做了特殊处理把它伪装成一个“真正的整数类型”。具体来说编译器允许你对 nint 执行、-、*、/、、|、、等所有整数运算并且会自动在 IL 里换算成IntPtr的运算实际上编译器会把 nint 表达式提升为本地整数运算再根据需要截断或扩展。所以从性能角度看声明为 nint 进行算术运算的成本和声明为 int/long 基本一致不会因为包装成结构体而产生装箱或额外的对象分配。这里有个非常重要的性能细节如果你在 x64 环境下把变量声明为int每次想把它当一个 64 位整数用的时候都需要做符号扩展而且如果频繁涉及地址计算还容易不小心溢出。用 nint 则完全没有这个问题因为它的原生位宽就是当前平台的指针位宽。在 x86 环境下反过来声明为long的变量在做地址相关计算时会被拆成两条指令处理而 nint 就是一条指令能省一点开销。这在极高频的底层循环里是有实际意义的。当然nint 也不是完全没有代价。因为它的大小是“运行期确定”的所以它不能出现在某些要求编译期确定大小的上下文里。比如你定义一个结构体里面有一个 nint 字段那么这个结构体的实际大小在不同进程位宽下是不同的——这既是好事适配平台也是风险序列化时要小心。3.3 nint 的核心使用场景与实操要点我在实际项目中用过 nint 的场景主要有三个。第一个是互操作结构体定义。比如你要在 C# 里定义一个和 C 结构体对应的托管结构体里面某个字段在 C 代码里声明为intptr_t或size_t那在 C# 里直接用nint或nuint对应是最精确的。以前用IntPtr也可以但定义出来的结构体在代码里用起来非常别扭字段之间的算术运算要写一堆.ToInt64()。换成 nint 后结构体里的字段可以直接参与表达式运算代码接近 C 的书写习惯。第二个是地址偏移和缓冲区操作。例如某种自定义的内存管理模块需要根据元素的索引和单位大小计算内存地址偏移量。自然的选择就是nint offset index * elementSize;因为这里的 offset 本质上是“地址空间中的字节偏移”它的语义就是“当前平台的地址大小”。如果用long写在 32 位环境里虽然也能跑但类型系统的表达力就弱了——读者无法一眼看出这个变量的本质是“地址偏移”而非“业务大数值”。第三个是“我不知道这个值在目标环境下会多大但我知道它必须匹配指针大小”的场景。比如从某个二进制文件头里读一个“平台相关整数值”你要把它转换成 nint 后再参与运算。这种场景下nint 的“自适应”特性直接省掉了一层运行时位宽判断。实操要点有三条转换规则要记清楚。nint 可以隐式转换到 long因为 long 一定能表示 nint 的所有值。但 int 到 nint 的转换是隐式的还是显式的答案是在常量上下文中可以隐式转换因为编译期知道常量值不会溢出在非常量上下文中需要显式转换因为 nint 在 64 位环境里能表示 int 的所有值但反过来 nint 在 32 位环境里是 int32理论上 size 相同但编译器为了安全性还是要求显式。这个规则很多人记混我建议直接遵守“非常量转换一律显式”省得在不同位宽下踩隐蔽 bug。序列化和持久化要格外小心。同样的 nint 值在 32 位和 64 位进程里“字节宽度”不同。如果你把 nint 直接写进文件流或者套接字流在不同位宽的目标主机上读取会出错。我自己处理模拟项目 X 的跨进程通信协议时凡是涉及平台位宽的字段要么固定用 long 序列化要么在协议头里显式记录“字段宽度”绝不在线上协议里直接裸传 nint。泛型里目前不好直接用 nintC# 9.0 时期泛型约束里不能把 nint 当作类型参数因为所有原生限制。如果你有“泛型算法但参数可能是 nint”的需求建议用 long 位宽适配的方式代替。3.4 nint 和 Pattern matching 的组合一个“环境自适应”的小案例既然这篇文章的主题是“两大新特性玩法”那我不妨把两个特性组合起来展示一个能体现“平台自适应 声明式判断”的小例子。假设你在写一个跨平台的诊断工具需要根据当前进程位宽和传入的某个“内存区域描述对象”做不同处理record MemoryRegion(nint Start, nint Size, string Name, bool IsReadOnly); static string AnalyzeRegion(MemoryRegion region) region switch { { Size: 1024 } 小内存块可直接内联复制, { Size: 1024 and 1024 * 1024, IsReadOnly: true } 中等只读块使用内存映射, { Size: 1024 * 1024, IsReadOnly: false } 大型可写块需考虑零拷贝, { Start: nint v } when v nint.Zero 起始地址为0可能未初始化, _ 常规区域 };这段代码里Size和Start都是 nint它们参与的 1024是关系模式and、when是逻辑模式和条件表达式。整个过程既展示了 nint 可以无缝参与模式比较也展示了 Pattern matching 对“多条件分派”的压缩能力。如果不用这两个特性写得是一段几十行的 if-else。而且注意nint v这种“类型模式提取值”的用法nint 和 int、long 一样是可以作为类型模式出现的所以它跟 Pattern matching 天然契合。从性能角度看这样的匹配链在编译后会生成高效的跳转或比较序列不会因为你写了“漂亮代码”而产生额外成本。这也是我特别愿意在生产代码里用组合特性的原因——可读性的提升不等于运行效率的下降在 C# 这套编译器优化下两者可以兼得。4. 实操过程从零写一个用足新特性的完整示例4.1 示例背景一个模拟命令行文本处理工具为了把前面讲的东西串起来我从零写一个小工具。假设我们要做一个“文本片段分发器”输入是一组文本记录每条记录有类型、长度、数据指针和可选标签。工具根据记录类型和长度做不同的处理这里面既有复杂的条件判断非常适合 Pattern matching又有地址偏移计算非常适合 nint。先定义记录结构体public enum RecordKind { Ascii, Utf8, Utf16, Binary } public unsafe struct TextRecord { public RecordKind Kind; public nint DataPointer; public int Length; public string? Tag; }注意我用了nint DataPointer这是一个原生指针宽度的整数在互操作层面它对应 C 语言里的void*或uintptr_t。在托管代码里我们不用真的解引用它那是unsafe的领域但可以用它来计算偏移、比较大小、传给底层 API。4.2 用 Pattern matching 实现分发逻辑核心的分发方法用 switch 表达式 模式匹配来实现public static string Dispatch(TextRecord record) record switch { { Kind: RecordKind.Ascii, Length: 0 and 128 } ASCII短记录直接内联处理, { Kind: RecordKind.Ascii, Length: 128 } ASCII长记录转入批量处理队列, { Kind: RecordKind.Utf8 or RecordKind.Utf16 } 文本编码记录统一走解码通道, { Kind: RecordKind.Binary, Length: 0, Tag: not null } 带标签的二进制块优先索引, { Kind: RecordKind.Binary, Length: 0 } 无标签二进制块按原始顺序处理, { Length: 0 } 空记录跳过, _ 无法识别的记录 };这段代码我特别满意的地方是每个分支都在说“业务规则”而不是“技术判断”。原来用 if 写的话可能是这样的if (record.Kind RecordKind.Ascii record.Length 0 record.Length 128) { ... } else if (record.Kind RecordKind.Ascii record.Length 128) { ... } ...两种写法功能完全一样但维护成本天差地别。Pattern matching 版本里你想增加一种“带标签且小于 64 字节的二进制块”的直接内联策略加一个分支就行{ Kind: RecordKind.Binary, Length: 0 and 64, Tag: not null } 小标签二进制块直接内联而且新分支加在哪里不影响已经存在的分支逻辑。这就是声明式编程的底气。4.3 用 nint 处理缓冲区和偏移计算下一步是用 nint 计算每个记录的“目标写入位置”。假设我们有一个缓冲区byte[] buffer我们要把所有记录顺序写入并返回每个记录的起始偏移量。因为缓冲区大小可能超过 2GB在某些极端场景下用int做偏移量会溢出用long又显得语义不精准。用nint是最贴切的——它代表“本机内存地址空间的大小”天然支持大缓冲区。public static nint WriteRecords(TextRecord[] records, ref byte[] buffer) { nint cursor 0; foreach (var record in records) { // 计算每条记录所需的字节数 nint recordSize record.Kind switch { RecordKind.Ascii or RecordKind.Utf8 record.Length, RecordKind.Utf16 record.Length * 2, RecordKind.Binary record.Length, _ throw new InvalidOperationException(未知记录类型) }; // 检查剩余空间 if (cursor recordSize buffer.Length) { throw new OutOfMemoryException(缓冲区空间不足); } // 记录当前偏移量 recordOffsets.Add(cursor); cursor recordSize; } return cursor; }这里cursor是 nintrecord.Length是 intnint int 会自动提升为 nint 运算。cursor recordSize buffer.Length这一步编译器也能正确比较 nint 和 int。整个过程自然得像在用普通整数完全没有以前IntPtr必须.ToInt64()的割裂感。4.4 参数选择和调试过程中的实测记录我实际操作这个示例时遇到过几个值得记下来的问题。第一个是 nint 在 switch 表达式里作为分支值时的坑。比如你写record.Size类型是 nint作为 switch 的输入想用关系模式 1024做分支是可以的。但如果你想把一个 nint 和一个常量直接做case 0:匹配编译器会报错“无法将 nint 转换为 int”。因为 nint 不算是内置数值类型中的“常量可匹配”类型你需要写case 0:的时候其实编译器无法在编译期确认 nint 的位宽所以这种语法不支持。解决方案是把case 0:改成case 0L:或者用is 0配合 when 子句。第二个是 nint 变量作为集合索引时的边界问题。如果你用buffer[(int)cursor]必须自己确保cursor int.MaxValue因为数组索引是 int 类型。在 32 位进程中nint 范围本身不会超过 int.MaxValue没问题但在 64 位进程中nint 可以远大于 int.MaxValue直接强转成 int 会溢出。所以我在例子里有意写成byte流式复制或者用Spanbyte加上 long 索引来规避这个坑。实际进生产环境时“大量数据 64 位进程 数组索引”的组合一定要在代码里加显式检查。第三个是关于 checked 上下文。nint 的溢出行为遵循当前上下文是否为checked。我自己习惯在涉及地址计算的代码块里打开 checked因为这能第一时间发现偏移计算的溢出而不是等到数据写错位置了才追查。在 64 位进程里nint 的溢出非常隐蔽一旦你计算的偏移超过实际分配的内存后续写入就是野指针操作这在托管代码里虽然不会直接崩溃但会导致数据错乱。5. 常见问题与排查技巧实录5.1 模式匹配相关典型问题速查表我整理了几个在实际编码中高频出现的问题以及对应的解法思路问题描述原因解决方案is 0 and 10编译报错关系模式里and两侧都必须是模式 0和 10本身没问题但要注意优先级检查是否漏了括号混用or时务必加括号属性模式里访问不到深层属性C# 9.0 之前需要用类型模式包裹添加了{ Address.City: 北京 }这种简化写法后直接写属性路径case 0:对 nint 报错编译器无法在编译期判断 nint 的常量模式改成case long v when v 0:或使用 when 子句switch 表达式_分支匹配到 null 导致逻辑错误_是通配所有值包括 null显式添加null 分支嵌套属性模式里子属性为 null属性模式不抛异常但匹配结果可能和你预期不同在关键路径上用is { A.B: not null }显式加判空模式5.2 nint 相关的交叉位宽陷阱nint 最大的风险来自“交叉位宽环境”也就是同一个程序或同一份数据在 32 位和 64 位进程之间交互。这类问题通常在跨进程通信、文件持久化、远程调用场景中出现。我举一个自己踩过的例子。某个模拟项目里我把一段内存区域的大小用 nint 变量传给了另一个进程通过某种消息队列发送端在 64 位环境下运行nint 是 8 字节接收端恰好跑在 32 位环境下按 4 字节去解析消息结果后面的字段全部错位。这个问题排查了整整两天——因为单独看每个进程都是正常的最后才发现是 nint 的宽度差异导致的。从此之后我定了一条规矩任何跨进程或跨机器的协议字段一律固定使用明确宽度的整数类型int、long、short 等nint 只允许出现在“进程内部的内存计算”中。这条规矩帮我避开了很多隐蔽 bug。5.3 编译版本和运行时兼容性注意C# 9.0 的特性需要 .NET 5 或更高版本的运行时支持。你在写代码的时候可以放心用但如果你的项目还在跑 .NET Core 3.1 或者 .NET Framework 4.8编译器可能会允许你写因为语法层面的支持取决于编译器版本但运行时可能没有对应的 BCL 类型尤其是 nint 的某些底层运算从而出现“编译通过但运行时报错”的情况。我的建议是引入 nint 之前先确认目标框架版本。如果是库项目最低目标框架建议设为 .NET 5 或 .NET Standard 2.1 的某种兼容方案实际上 .NET Standard 2.1 并没有完整支持 nint 的语言特性所以稳妥做法是直接要求 .NET 5。Pattern matching 在设计上是纯编译器特性的语法糖对运行时要求相对宽松旧框架也能跑编译器会生成传统 IL但 nint 因为涉及IntPtr的特殊处理对运行时更敏感。6. 我自己的实践心得和扩展建议在实际项目里用这两大特性一段时间之后我最大的感受是Pattern matching 是“降低认知负担”的工具nint 是“消除类型摩擦”的工具两者配合代码的表达力和底层适配性都能上一个台阶。一个值得注意的细节是Pattern matching 写起来越漂亮越要警惕“过度声明式”的倾向。有些场景比如非常复杂的规则引擎其实不适合用模式匹配硬套因为模式分支多了之后分支之间的优先级关系又会变成一种新的复杂度。我自己一般把握的度是模式分支数量控制在 6 个以内超过这个数就考虑改用查询表或者策略类否则代码的可读性会重新下降。nint 这边我额外建议你多关注它的“文档价值”。以前你看一个long字段不知道它是普通数值还是地址现在看nint一眼就知道这个东西跟内存布局或平台指针有关。这种“类型即文档”的好处在团队协作时特别值钱。我的习惯是凡是和缓冲区、指针、地址、平台相关的整数一律优先用 nint/nuint让类型自己说话。最后分享一个我在实际操作中总结的小技巧如果你在一个较大的代码库中逐步引入这两个特性建议先从“纯函数式的判断逻辑”入手。把那些没有副作用的if-else链改写成 switch 表达式加模式匹配风险最低收益最直观代码量锐减而且不需要动任何数据结构。等你熟悉了 Pattern matching 的感觉再开始处理 nint 相关的互操作代码。这样一步步来比一次性全量重构要稳得多。这个组合还能继续往下扩展。比如把模式匹配和记录类型record配合可以写出像函数式语言那样的“解构式”逻辑把 nint 和新的指针操作结合可以在高性能场景里进一步减少 GC 压力和内存分配。往后 C# 每出一个新版本回头再看这些基础特性你会发现当初设计它们时的考虑比表面上看到的要深远得多。我自己是越用越顺手也希望这篇文章能帮你少走几步弯路。