☰
C# 9.0核心特性:模式匹配与nint原生整数实战指南
2026/10/14 18:19:11 网站建设 项目流程

C# 9.0 是 2020 年 11 月跟着 .NET 5 一起正式发布的。我身边不少朋友第一反应是“哦,又一个大版本”,但真正上手之后会发现,这一代最值得玩味的不是那些宏大特性,而是两个看起来不大的东西:Pattern matching 的补全,以及 nint 这个原生大小整数。一个是把条件判断从“堆 if 链”变成“说人话”,一个是把平台相关的整数类型从“能用”变成“好用”。

如果你平时写过一长串 if-else、switch 里塞满 case,或者碰过 Win32/COM 句柄、写过跨平台性能敏感代码,那这两个特性跟你的关系最大。这篇文章我不打算罗列特性清单,而是直接拆解这两个东西的底层逻辑、使用姿势和实际项目里的避坑经验。尤其会回答几个你八成会关心的问题:关系模式和 is not null 到底好在哪?nint 和 IntPtr 到底是什么关系?为什么业务代码里我建议你少碰 nint?

1. 为什么这两个特性值得你花时间

1.1 模式匹配:从“到处 if”到“说出规则”

模式匹配这个概念,说穿了就是把三件事合并成一句话:检查对象长得像什么、从里面取出关键值、按照条件走不同分支。C# 7.0 第一次引入了 is 模式和 switch 模式,当时只能做类型判断和简单的常量匹配;C# 8.0 加了属性模式和 switch 表达式,能把一个对象的结构拆开来看;到了 C# 9.0,终于把关系运算符和逻辑运算符引进了模式语言里。

于是很多以前要写七八行 if 的代码,现在能压成一行。比如最典型的年龄判断:

// 老写法 if (age >= 18 && age < 60) { Console.WriteLine("成年人"); } // C# 9.0 写法 if (age is >= 18 and < 60) { Console.WriteLine("成年人"); }

看着只是把 && 换成了 and,但意义完全不同。后者不是一个“布尔表达式”,而是一个模式,它可以直接塞进 switch 表达式、可以跟其他模式组合、可以让编译器帮你穷尽检查。分支一多,差距就出来了,后面我会用具体案例说明。

1.2 nint:被低估的“原生大小整数”

nint 在 C# 9.0 里是作为“原生大小整数类型”出现的,它在 32 位进程里等于 32 位,在 64 位进程里等于 64 位。听起来简单,背后解决的是一个长期痛点:过去我们要处理平台相关的整数,只能用 IntPtr 这种“指针大小整数”。

IntPtr 的问题在于它是一个结构体,不是一等公民。你想让两个句柄做减法、想判断一个返回值是不是 -1、想把它当数组索引用,都得先手动转成 int 或 long,写起来像穿着雨靴跑步。nint 则是编译器一等支持的类型:能直接做算术、能比较、能当常量、能被模式匹配。更重要的是,它是 System.IntPtr 在运行时层面的同一类型,所以在互操作场景里不会多出额外的封送开销。

我个人的看法是:nint 不适合作为业务领域的通用整数,但它非常适合三类地方——互操作句柄、平台相关的内存/偏移计算、以及需要在极大数据规模下使用索引的底层库代码。这一点后面展开讲。

2. Pattern matching 新玩法逐个拆解

2.1 关系模式:用比较运算符做分支

C# 9.0 允许<、>、<=、>=直接出现在模式里,这就是关系模式。它最常见的用法是划定一个数值区间,并且跟逻辑模式组合:

static string GetLevel(int score) => score switch { >= 90 => "优秀", >= 75 => "良好", >= 60 => "及格", _ => "需要补考" };

注意这里没有&&,没有score >= 60 && score < 75,模式匹配的语义是自上而下匹配,第一个符合的规则生效,所以每个分支只需要写一个边界即可。这比 if 链清晰得多,也避免了边界条件写错。

关系模式适用于所有实现了比较运算的类型,数字、字符、枚举、DateTime 都可以:

// 判断一个字符落在哪个区间 char c = 'm'; if (c is >= 'a' and <= 'z') { Console.WriteLine("小写字母"); }

这里有个细节很多人第一次会踩:is >= 90这种写法,模式里的表达式必须是一个常量,不能是变量。你不能写score is >= minScore and <= maxScore,编译器会拒绝。如果你需要跟变量比较,老老实实用score >= minScore && score <= maxScore。

2.2 逻辑模式:and、or、not 三个关键字

逻辑模式是这一代模式匹配真正“觉醒”的部分。它把布尔运算下沉到了模式语言里,允许你在一处同时表达多个条件。

最常见、也最值得立刻用进现有代码的,是is not null:

if (obj is not null) { // 以前的 obj != null 写法被替代 }

为什么说它比!= null好?因为!=是运算符,你可以在类上重载它,一旦重载,比较的语义就不一定是“引用不等”。而is not null走的是模式匹配的规则,编译器按照“空引用判定”来处理,不管你的类型有没有重载!=,结果都是可靠的。这对那些自我感觉良好、重载过==和!=的 DTO 尤其重要,它能防止你被“看起来对”的判断坑到。

or和and可以组合出很直观的区间:

// 判断一个数是否是合法端口号 if (port is > 0 and <= 65535) { // 合法 } // 判断一个字符是否是元音 if (ch is 'a' or 'e' or 'i' or 'o' or 'u') { Console.WriteLine("是元音"); }

注意优先级:not的优先级最高,然后是and,最后是or。所以a is not null and > 0不会被解析成a is (not null and > 0),而是(a is not null) && (a is > 0)。如果逻辑复杂,该加括号就加括号,别赌自己和别人都记得优先级。

另外,or模式在 switch case 里特别省事。以前你要合并多个 case,得写case 1: case 2:,现在一个分支搞定:

string result = code switch { 1 or 2 or 3 => "输入错误", 4 or 5 => "权限不足", _ => "未知错误" };

2.3 扩展属性模式:不用再叠罗汉

C# 8.0 的属性模式允许你这么写:user is { Address: { City: "上海" } },结构一深,括号叠括号。C# 9.0 引入了扩展属性模式,可以直接把属性访问链写进模式里:

// C# 8.0 if (user is { Address: { City: "上海" } }) { } // C# 9.0 if (user is { Address.City: "上海" }) { }

这个特性对那种“对象套对象”的校验场景极其友好。比如判断订单的收货地址是不是海外:

if (order is { Customer.Address.Country: not "CN" }) { // 海外订单,走另一个流程 }

有人会担心:如果 user 是 null,或者 Address 是 null,这样写会不会抛 NullReferenceException?不会。模式匹配在求值属性链时,任何一级引用为 null,都会让整个模式不匹配,而不是抛异常。这一点非常安全,也是我愿意在复杂嵌套对象上大量使用它的原因。

2.4 模式匹配与 switch 表达式组合,才是终极形态

关系模式、逻辑模式、属性模式单个看都只是语法糖,真正威力大的是它们互相嵌套,然后配合 switch 表达式一次完成整个分支。举个实际点的例子,根据角色和状态计算订单折扣:

decimal GetDiscount(Order order) => order switch { { Role: "VIP", Total: >= 10000 } => 0.8m, { Role: "VIP" } => 0.9m, { Role: "Normal", Total: >= 5000 } => 0.95m, { Role: "Normal" } => 1.0m, _ => throw new InvalidOperationException("未知角色") };

这种代码的可读性,比起一串 if 嵌套,完全不是一个体验。每个分支就是一个“规则行”,从上往下看,规则一目了然。而且以后要加规则,只需要加一行,不用去翻大括号。

使用 switch 表达式时还有一个重要原则:永远保留一个_兜底分支。因为关系模式、逻辑模式混合使用时,编译器不总是能证明你的分支已经穷尽所有情况,留着_既是给编译器交代,也是给未来加需求留后路。

3. nint/nuint 原生大小整数实战解析

3.1 nint 到底是什么,和 int、long、IntPtr 什么关系

先用一个问题引入:你在写一个跨平台库,要跟外部系统交换“地址大小”或“句柄”,应该用什么类型?用 int,32 位上没问题,64 位上可能截断;用 long,32 位上浪费一倍内存和寄存器带宽。你需要的,是一个“跟着进程位数走”的整数类型,这就是 nint。

nint 的运行时类型实际上就是 System.IntPtr。你可以把 nint 理解为 IntPtr 的“编译器友好外壳”:

维度intlongIntPtrnint
位数固定 32 位固定 64 位跟随平台跟随平台
算术运算支持支持需先转换直接支持
作为常量支持支持不支持(旧版)支持
模式匹配支持支持类型判断直接支持
P/Invoke 默认封送intlongIntPtrIntPtr 语义

从表格能看出来,nint 的出现就是要取代“把 IntPtr 转来转去”的日常操作。你在代码里写nint和在运行时看到IntPtr,是同一回事,所以互操作边界上传入传出时,封送程序完全按 IntPtr 处理,不会多一次转换。

nuint 是 nint 的无符号版本,用于表示无符号的平台大小整数。用法和注意点跟 nint 对称,需要无符号场景时选它即可。

3.2 nint 的运算、转换与常量

nint 的声明和普通整数几乎没区别:

nint code = -1; nuint size = 0; // 参与算术 nint offset = 100; offset += 200; nint next = offset * 2;

它直接支持+、-、*、/、%、位运算、比较运算,这些操作在编译器层面会被优化成对应平台位宽的原生整数运算,不会像 IntPtr 那样每个操作都先调方法。

常量场景要注意一个区别。你可以这样写:

const nint MaxRetryCount = 3;

只要这个值在当前编译目标平台上放得下,就能作为常量正常使用。但如果你写了一个很大的数值,比如const nint BigValue = 5000000000;,它在 64 位平台上没问题,拿到 32 位平台上编译会直接报错,因为这超出了 32 位 nint 的表示范围。这就是平台相关类型在编译期给你上的一课。

转换规则很简单:int 可以隐式转换到 nint,nint 隐式转换到 long(前提是目标平台位数不超过 long,实际都如此);反方向 nint 转 int 是显式强转,可能丢精度。跨平台序列化时要特别小心,下面我会展开讲。

3.3 典型场景一:互操作句柄处理

做 Win32 或 COM 互操作时,一个很常见的模式是外部函数返回一个 “句柄”,无效时返回 -1 或 0。以前你得先拿到 IntPtr,再转成 int 判断,或者用IntPtr.Size自己按平台分支。现在 nint 让这件事变得非常直接:

// 模拟外部函数返回句柄 nint handle = OpenDevice("device", 0); if (handle is -1 or 0) { throw new InvalidOperationException("打开设备失败"); }

注意handle is -1 or 0这种写法,nint 搭配模式匹配相当顺滑,编译器能正确推导。如果你用 IntPtr,写handle.ToInt64() == -1之类的代码,读起来就差远了。

我还习惯在互操作层用 nint 做“固定大小”的状态打包。比如在 64 位进程里,把一个指针和一个状态位打包到一个 nint 里,用位运算快速读取:

nint packed = (nint)ptr | statusFlag; nint apiResult = CallNative(packed);

这对性能敏感而且依赖平台位宽的底层模块非常实用。

3.4 典型场景二:平台相关的内存与边界计算

nint 的第二个大本营是内存/缓冲区相关的代码。假设你写一个自定义缓冲池,需要根据平台位数决定最大容量,同时要快速判断一个写入偏移是否越界:

nint bufferSize = GetBufferSize(); nint writeOffset = GetWriteOffset(); if (writeOffset is >= 0 and < bufferSize) { // 安全写入 } else { throw new ArgumentOutOfRangeException(nameof(writeOffset)); }

关键在于:bufferSize 来自本机环境,用 nint 保存能确保在 32 位和 64 位上都能完整表达,同时避免在 32 位平台上使用 long 带来的性能浪费。边界判断用关系模式写,代码意图一眼就懂。

这里有个实用技巧:我一般在内存管理类里写工具方法,用 nint 做内部计算,但对外只暴露 long 或 int。这样内部可以安心匹配平台位数,外部 API 的语义保持稳定。

3.5 典型场景三:性能敏感的大循环索引

写大型数组、位图、音频采样缓冲这类需要高性能遍历的底层代码时,如果数据规模可能超过 20 亿(int.MaxValue),你自然想用 long,但在 32 位进程中,long 运算会拆成两个 32 位寄存器操作,速度上吃亏。用 nint 则既能跟上平台位数,又不会做多余的高位运算:

nint totalSamples = GetTotalSamples(); for (nint i = 0; i < totalSamples; i++) { // 处理每个采样点 }

这种用法在音频/视频处理、大量采样数据的批处理库里很常见。普通业务开发用不上,但一旦用上,效果立竿见影。

3.6 nint 的坑,提前说清楚

先泼一盆冷水:nint 不是让你在业务代码里到处用的类型。我自己踩过的坑,以及帮别人 review 时看到的坑,大概有这么几类。

第一,DTO 和序列化别用 nint。同样是nint,在 32 位环境和 64 位环境里得到的字符串不一样、占用空间不一样,一旦数据从 64 位平台序列化到 32 位平台,可能直接溢出或者解析失败。跨进程、跨机器、跨架构传输数据时,一定要用固定宽度的 long 或 int。

第二,默认不检查溢出。nint 的算术运算在默认上下文里是 unchecked 的,也就是说溢出时不会抛异常,直接截断。如果你在做地址运算,一次溢出可能就是一次内存破坏。需要安全的时候,请主动用checked块包住关键计算。

第三,泛型里使用要小心。C# 9 里 nint 在泛型上下文的支持有限,你没法像List<int>那样把 nint 直接当泛型实参使用(后续版本有改进,但为了迁就老目标框架,我仍然建议用 IntPtr 或者 long 做泛型载体)。

第四,跟 IntPtr 互转要心里有数。虽然两者运行时是同一个类型,但是当你写IntPtr value = someNint;或者说把 nint 传给只接受 IntPtr 的接口时,编译器有时会提示需要转换,因为语言层面的身份并不完全一致。遇到这种提示别奇怪,强转一下就好。

4. 实战案例:把两个特性合起来用

4.1 案例一:业务校验规则重写

下面是我在模拟项目里真实做过的重构。需求是根据用户的会员等级、订单金额、以及是否是新用户,决定优惠方案。老代码是这样的:

if (order.Amount >= 1000) { if (user.Level == "VIP" && !user.IsNew) return 0.8m; if (user.Level == "VIP") return 0.85m; return 0.95m; } if (order.Amount >= 500) { if (user.Level == "VIP") return 0.9m; return 0.97m; } return 1.0m;

用 C# 9.0 重写后:

return order switch { { Amount: >= 1000, User: { Level: "VIP", IsNew: false } } => 0.8m, { Amount: >= 1000, User.Level: "VIP" } => 0.85m, { Amount: >= 1000 } => 0.95m, { Amount: >= 500, User.Level: "VIP" } => 0.9m, { Amount: >= 500 } => 0.97m, _ => 1.0m };

每一行就是一个独立的业务规则,优先级从上往下,后续加规则只需要加一行。模式匹配嵌套属性的能力在这里体现得淋漓尽致:金额判断用关系模式,属性取值用扩展属性模式,条件的优先级靠代码顺序表达。肉眼可见地好维护。

4.2 案例二:互操作句柄与状态判断

再来看一个把 nint 和模式匹配结合得更紧密的示例。假设你封装了一个底层库,函数返回的结果同时包含“是否成功”和“操作类型”的信息,用 nint 打包返回:

nint result = DoNativeOperation(); string message = result switch { < 0 => $"调用失败,错误码 {result}", 0 or 200 => "成功", > 200 and <= 999 => "业务异常", _ => "未知状态" };

这里result是 nint,但又用了关系模式和逻辑模式来分支。如果你是 64 位程序,这个 nint 有机会承载更大的错误码范围;如果是 32 位程序,也能保证跟平台的 api 语义一致。互操作代码最怕平台差异,这样写至少能消除因为 int 宽度不够导致的隐性 bug。

4.3 案例三:环形缓冲区索引推进

最后一个案例是个典型的底层数据结构:环形缓冲区。用 nint 维护读写索引,同时用模式匹配检查异常状态:

nint head = 0, tail = 0, capacity = 1024; // 写入一个元素 void Write(byte b) { if (IsFull()) { throw new InvalidOperationException("缓冲区已满"); } buffer[tail] = b; tail = (tail + 1) % capacity; } bool IsFull() => (tail + 1) % capacity == head; // 判断数据长度 nint Count() => tail >= head ? tail - head : capacity - head + tail;

这里的头尾索引非常适合用 nint:容量来自初始化时传入的平台相关值,索引推进要用取模运算,计算数据长度又要做大小比较。用了 nint 后,整个环形缓冲区的位宽天然跟平台一致,不会因为索引类型偏窄而限制容量,也不会因为偏宽而在 32 位平台上白白损失性能。

如果你想检查环形缓冲区的“半满”状态,还可以直接写:

if (Count() is > 0 and < capacity / 2) { // 未过半 }

模式匹配对 nint 同样生效,两种新特性的组合在底层代码里意外地协调。

5. 常见问题与排查技巧实录

5.1 Pattern matching 常见翻车点

第一个最常见的翻车是优先级记混。not高于and高于or,我是吃过亏的。比如我写if (x is not null and > 0),一开始以为它表达的是“x 不是 null,并且 x 大于 0”,确实如此,因为not null整体是一个模式,and > 0是另一个模式。但如果你写if (x is not (null or 0)),这里的括号就不能省,否则语义完全变了。

第二个是 switch 表达式里忘记写_。当你的模式包含< 0 or > 100这种区间时,编译器不一定能断定你已经覆盖所有整数,不写_会直接编译失败。我建议统一养成写_的习惯,毕竟未来加一个分支的时候,兜底分支能兜住意外情况。

第三个坑是可变值参与模式匹配的不可变性。前面提过,关系模式右边必须是常量。很多人想用变量当边界,写成:

if (score is >= passLine and <= fullMark)

这段代码编译不过。你需要换个思路:要么把变量转换成常量表达式参与的模式,要么干脆退回普通比较。这不是 bug,是模式匹配的设定,早点接受比较好。

第四个是属性模式下的变量捕获。or模式的两个分支不能声明同名变量,比如is (> 0 or < -10) var v这种组合很容易报“变量范围冲突”。遇到复杂情况,我宁可拆成两个 if,或者在 switch 表达式里用递归模式,别硬写。

第五个坑是关于可空值类型。对int?做is > 5时,模式匹配先判断有没有值,再比较。如果值是 null,is > 5会返回 false,不是异常。这个行为符合直觉,但如果你用传统x.HasValue && x.Value > 5习惯了,可能还会愣一下。没关系,这种写法其实是更安全的。

5.2 nint 常见问题实录

我在实际项目里见过最典型的 nint 翻车,是在一个平台无关的公共类库里定义了 nint 类型字段,结果被 32 位进程引用后,反序列化的数据直接错位。查了半天发现是 64 位进程写出的数据,nint 占 8 字节,32 位进程读入时按 4 字节解析。所以再强调一次:nint 只能活在进程内部,别出边界。

第二个问题是nint和IntPtr在重载决议时的混淆。某些 API 同时有接收 nint 和接收 long 的重载,写调用点时要小心到底调了哪个。特别是在老库和 P/Invoke 签名混用的时候,编译器不见得按你直觉选重载。遇到这种问题,明确强转:

DoSomething((nint)result);

第三个问题是 32 位平台上的溢出静默。我以前写过一个地址累加器,在 x64 上跑得好好的,拿到 x86 测试机上数据就错乱,就是因为 nint 在 32 位平台上一累加就溢出了,还没有任何异常。排查这种问题最快的方法是给关键计算包上checked块,让溢出瞬间暴露出来:

nint next = checked(head + count);

第四个问题跟调试有关。你会在调试器里看到 nint 显示成 IntPtr 的样子,有时还会带个十六进制前缀。这是正常的,因为它运行时就是 IntPtr。别为了让它“看起来像整数”专门转成 int 去看,那样反而看不到完整值。

5.3 排查技巧速查表

现象可能原因处理方式
is >= x编译失败,x 是变量关系模式右侧必须是常量改用传统比较,或把变量提升为局部常量
switch 表达式提示不穷尽区间模式让编译器无法验证覆盖增加_兜底分支
or分支里变量冲突模式分支变量范围冲突拆分判断或改用嵌套模式
32/64 位切换后 nint 值异常nint 宽度的平台差异跨平台传输改用 long,内部计算包 checked
P/Invoke 参数类型不匹配nint 与 IntPtr 语言层面身份差异强转为所需类型后再传参
泛型类型参数用了 nint 报错C# 9 时期泛型约束不完善改用 IntPtr / long 作为泛型参数

6. 写在最后的一点经验分享

我在项目里推广 C# 9.0 这两个特性时,遵循的原则是“先易后难、边界隔离”。最容易先做的是把所有!= null替换成is not null,这一步风险低、收益直观,还能让代码在后续模式匹配重构时更顺滑。第二步是把多分支的 if-else 链改写成 switch 表达式,优先挑那些分支里逻辑不重的场景练手。最后才在互操作和底层内存管理代码里引入 nint。

关于 nint,我最后的建议是:在公共 API 上尽量别露它,内部用 nint 计算,对外暴露长整型或者明确宽度的类型。这样即使未来换平台,也不会让调用方一头雾水。如果你在一个类库里维护跨平台逻辑,一定要在你的持续集成流水线里同时跑 x86 和 x64 两套测试,nint 相关的边界问题几乎只在某个具体平台上出现,少跑一套都等于在埋雷。

这两个特性看起来不大,但确确实实改善了我日常写代码的体感。模式匹配让“规则”在代码里变得可见、可读、可维护,nint 让平台相关的整数操作从“别扭”变成了“顺手”。如果你还没在真实代码里试过,建议找个周末拿个老模块重构一下,那种“原来可以这么写”的感觉,只有亲自试过才知道。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询