资讯详情

C#数字字符串排序:自然排序实现与替代方案

📅 2026/11/2 8:47:09 | 华诺云谱 👁 阅读
C#数字字符串排序:自然排序实现与替代方案
上周帮同事排查一个问题功能很简单把一批按章节命名的文档按顺序列出来。结果第2章_初稿排在了第10章_终稿后面截图发过来问这是不是 bug。这种情况我这些年遇到过太多次根子都在同一处——C# 数字字符串排序默认走的是逐字符比较字符1的码位比2小10就天然排到了2前面。规则本身没错错的是我们拿字典序去表达数值序。这篇内容就是把这件事彻底讲透从字符比较的底层逻辑到一套可以直接抄进项目的NaturalStringComparer实现再到补零键、正则捕获组、系统外壳排序这几条替代路线最后是踩坑清单和边界测试。写代码的、做数据处理的、写后台管理系统的、倒腾文件批处理的都能从中找到能直接用的东西新手可以照着代码跑老手可以直接跳到第 3、4 章看实现和性能对比。1. 排序翻车的现场字符比较为什么把 10 排到了 2 前面1.1 三个最典型的翻车场景第一个场景是文件名列表。截图_2.png、截图_10.png、截图_1.png丢进Directory.GetFiles()之后直接Array.Sort()出来是1、10、2。用户在资源管理器里看到的顺序是对的在你自己的列表里就乱了这种落差最容易被投诉。第二个场景是带编号的业务数据。合同号、工单号、批次号大多是前缀 流水号的结构流水号位数不固定。数据库里按字符串列排序PO-9会排在PO-10后面导出的 Excel 一看就是错的财务那边会直接打回来。第三个场景是版本号。1.9.0和1.10.0谁新按字符串比较1.10.0 1.9.0因为第二个字符.相等第三个字符19。于是自动更新逻辑判断当前已是最新版本用户永远升不上去。这个坑的隐蔽性最强因为它在测试环境里往往只用一个版本号根本测不出来。这三个场景的共同点是字符串里混入了语义上的数值片段而我们用纯文本规则去比较它。只要你的数据里有数字长度会变化的编号就一定会撞上。1.2 序数比较与区域性比较先搞清楚自己站在哪条路上C# 里的字符串比较至少有四条路很多人写了好几年也没分清和string.Equals(a, b)默认是序数比较ordinal逐字节比码位最快也最死板。string.Compare(a, b)默认是区域性比较current culture会跟着操作系统的区域设置走。string.Compare(a, b, StringComparison.Ordinal)是显式序数比较。string.Compare(a, b, StringComparison.InvariantCulture)是固定区域性比较不随机器变。问题在于Liststring.Sort()和OrderBy(x x)用的是Comparerstring.Default它内部调用string.CompareTo走的是区域性比较。这意味着同一份代码在开发机上和在客户机器上如果区域设置不同排序结果可能不一样。更离谱的是区域性排序规则里有一批可忽略字符连字符、撇号这类符号在某些区域规则下权重为零a-b和ab会被判为相等然后再由后续字符决定顺序——这类结果你盯着代码看半天也看不出所以然。我的建议很直接凡是涉及标识符、文件名、编号、版本号的排序一律用StringComparison.Ordinal。只有面向最终用户的自然语言文本比如中文姓名列表、商品标题才考虑区域性比较而且最好显式固定成InvariantCulture或者明确的某一种区域别让它跟着机器漂。至于数字按数值比这件事别指望改个枚举值就完事。底层的一些排序引擎确实有把连续数字当数值处理的开关但 .NET 的CompareOptions枚举里并没有暴露对应的值。所以自然排序natural sort / logical sort在纯托管代码里必须自己写这也是为什么这个需求十几年了还在被反复讨论。1.3 选型的三个岔路口自己写、预生成键、还是借系统的力真正动手之前先想清楚你要走哪条路这决定了后面所有的实现细节。第一条路是自定义IComparerstring。灵活性最高能处理任意复杂的规则前导零怎么算、大小写敏不敏感、小数点算不算数字段缺点是每次比较都要现场解析数据量大时 CPU 开销明显而且一旦逻辑写得不严谨会触发排序算法内部的契约异常报错信息还特别难懂。第二条路是预生成可排序键。把章节_10转成章节_00000000000000000010这种定长补零形式之后就能用最快的序数比较直接排序甚至能存进数据库列加索引。缺点是要多存一份数据而且键一旦生成就和原值绑定了规则改了要重新刷全表。第三条路是借用操作系统外壳的排序能力。桌面平台的外壳里有个StrCmpLogicalW资源管理器就是用它排序的行为跟用户预期完全一致。缺点是有平台依赖服务端跑不了而且不同系统版本的细节行为可能有差异。这三条路没有绝对优劣。列表页展示几百条数据第一条路足够要对百万级数据分页查询第二条路才是正解做桌面客户端的文件浏览器第三条路最省事。下面我把三条路都写完整。2. 把自然排序的规则先定下来2.1 分词模型数字段与文本段交替切分自然排序的核心思想只有一句话把字符串切成文本块和数字块交替的序列文本块按字符比数字块按数值比。拿a12b3举例切成[a](文本)、[12](数字)、[b](文本)、[3](数字)四段。比较两个字符串时从左往右逐段对齐比较第一段文本比完如果分出胜负就直接返回如果相等继续比第二段数字数字也比完相等就比第三段……直到某一方先耗尽。这里有个必须明确的规则当前缀相同时短的那个排前面。a1和a1b比较前两段都相等a1先结束所以a1 a1b。这条规则看起来理所当然但代码里如果只写一个while (i x.Length j y.Length)然后在循环结束后无条件return 0就会把这两个字符串判成相等导致排序结果不稳定——同样的输入跑两次顺序不一样排查起来能让人崩溃。另一个容易忽略的点是数字段的边界判定。只有连续的 ASCII 数字才算一个数字块中间出现任何非数字字符就断开。1.10不是一个数字块而是[1](数字)、[.](文本)、[10](数字)三段这恰好就是我们想要的版本号语义。所以自然排序天然能处理多级版本号这一点很多人没意识到。2.2 四个歧义前导零、负数、小数、千分位规则定到这一步还有四个现实中的歧义必须拍板不拍板就会出现同一个库在两个模块里排序结果不一致的诡异现象。前导零怎么办001和1数值相等但字符串不等。三种处理方式视为完全相等最简单排序时保留原始相对顺序位数多的排后面1 01 001位数多的排前面。我一般选第二种因为它是短的自然在前这个直觉的延伸。但这个规则必须写进文档否则后面接手的人一定会问。负数怎么办a-1里的-是数字的一部分还是分隔符严格的自然排序把它当分隔符结果就是a-1、a-2、a-10排在一起但a-10和a1谁前谁后取决于-的码位45比149小所以a-10在前。这个结果大部分人能接受。真要支持负数语义就得让分词器在数字块前面回看一个-并且判断这个-前面是不是字母或数字——复杂度立刻上去了。我的经验是编号里的负数极少见硬要支持反而容易引入 bug遇到就用专门的解析器别塞进通用比较器里。小数怎么处理v1.10和v1.9切成数字段后就是1vs1相等、.vs.相等、10vs910 大结果正确。但1.5和1.10如果语义是浮点数按上面的规则10 5也对——纯属巧合因为小数点后位数一致时数值比较和字典序恰好同向。一旦出现1.05和1.5自然排序给出的是1.05 1.5因为5 50的数值比较实际是 5 vs 5 相等然后……这里就露馅了。所以小数点后的语义必须明确是版本号按段比还是小数按数值比两者不能混。千分位逗号怎么办1,234和999自然排序会把1和999先比1 999结论1,234 999错得离谱。含千分位的数据在排序前必须先去分隔符转成数值这是数据清洗阶段的事不要指望比较器兜底。2.3 比较器契约必须成立的三件事写IComparerT最容易犯的错是只考虑了能排出来没考虑排得合法。排序算法内省排序、归并排序对比较函数有一组数学要求违反了轻则结果错乱重则直接抛ArgumentException提示比较函数返回了不一致的结果或者比较器未能返回零。三个必须成立的性质自反性Compare(x, x) 0任何元素跟自己比必须相等。反对称性Sign(Compare(x, y)) -Sign(Compare(y, x))调换参数顺序结果符号必须相反绝不能出现两边都返回 1 的情况。传递性如果Compare(a, b) 0且Compare(b, c) 0那么必须有Compare(a, c) 0。第二和第三条是最容易翻车的地方。举个我见过的真实写法某个比较器在数字段比较时用了long.Parse遇到超长数字抛出异常被catch吞掉然后return 0。结果999999999999999999999和任何字符串比都返回 0传递性瞬间崩塌排序算法进入永远排不完的状态。另外还有一个非常实用但很少有人提的坑比较器认为相等的两个元素Equals未必相等。排序只影响顺序去重是另一套机制。list.Distinct()用的是默认的EqualityComparerstring跟你的比较器毫无关系。如果想让01和1在去重时也被视为同一个必须实现IEqualityComparerstring并显式传给Distinct。这两个接口最好在同一个类里一起实现避免语义分裂。3. 手写一个能上生产的 NaturalStringComparer3.1 完整实现一百行以内的可用版本下面这版是我这些年反复迭代后留下的版本去掉了正则、去掉了反射、去掉了装箱在 .NET Framework 4.6 和 .NET 8 上都能直接编。核心只有两个方法。using System; using System.Collections.Generic; public sealed class NaturalStringComparer : IComparerstring { public static readonly NaturalStringComparer Default new(StringComparison.Ordinal); private readonly StringComparison _textComparison; public NaturalStringComparer(StringComparison textComparison StringComparison.Ordinal) _textComparison textComparison; public int Compare(string? x, string? y) { if (ReferenceEquals(x, y)) return 0; if (x is null) return -1; if (y is null) return 1; int i 0, j 0; while (i x.Length j y.Length) { bool dx IsAsciiDigit(x[i]); bool dy IsAsciiDigit(y[j]); if (dx dy) { // 各自截取一段连续数字 int si i, sj j; while (i x.Length IsAsciiDigit(x[i])) i; while (j y.Length IsAsciiDigit(y[j])) j; int r CompareNumberSpan(x.AsSpan(si, i - si), y.AsSpan(sj, j - sj)); if (r ! 0) return r; } else { int r Math.Sign(string.Compare(x, i, y, j, 1, _textComparison)); if (r ! 0) return r; i; j; } } // 谁先走完谁在前 int left x.Length - i; int right y.Length - j; if (left right) return 0; return left right ? -1 : 1; } private static bool IsAsciiDigit(char c) c 0 c 9; private static int CompareNumberSpan(ReadOnlySpanchar a, ReadOnlySpanchar b) { // 跳过前导零但要保留最后一位否则 0 会被吃光 int ai 0, bi 0; while (ai a.Length - 1 a[ai] 0) ai; while (bi b.Length - 1 b[bi] 0) bi; int la a.Length - ai; int lb b.Length - bi; if (la ! lb) return la lb ? -1 : 1; // 位数多的数值大 for (int k 0; k la; k) { if (a[ai k] ! b[bi k]) return a[ai k] b[bi k] ? -1 : 1; } return 0; } }用起来就一行var sorted rawList.OrderBy(s s, NaturalStringComparer.Default).ToList();或者对已有列表原地排var list Directory.GetFiles(dir).Select(Path.GetFileName).ToList(); list.Sort(NaturalStringComparer.Default);3.2 逐段拆解数字比较为什么不用 long.ParseCompareNumberSpan是这个比较器的灵魂值得单独讲。最直观的写法是long.Parse(a).CompareTo(long.Parse(b))一行搞定但它有三个致命伤第一9999999999999999999920 位会直接抛OverflowException第二每次比较都要分配一个新字符串ReadOnlySpan转string十万条数据的排序要做上百万次比较GC 压力肉眼可见第三解析失败时你得决定返回什么而任何统一返回 0的做法都会破坏传递性。不溢出、不分配的做法就是先比长度再比字符。两个数字去掉前导零之后位数多的数值必然大位数相同逐位比较 ASCII 字符即可因为0到9的码位是连续递增的字符大小关系等价于数字大小关系。整个过程零分配、常数空间也不存在任何溢出可能几十位的编号照样能比。那个while (ai a.Length - 1 a[ai] 0) ai;的- 1是必须的。如果不减一0会被完整跳过ai直接等于长度la变成 0然后所有以 0 开头的数字都会跑到最前面而且0和00的比较结果会误导。这个边界我第一次写的时候也漏了测试用例里加一条0就能抓出来。IsAsciiDigit特意没用char.IsDigit也是有原因的。char.IsDigit对阿拉伯-印度数字、全角数字都返回 true但那些字符的码位不连续逐位比较会得到莫名其妙的结果。既然业务上的编号几乎都是 ASCII 数字索性把范围卡死在0到9之间行为可预测遇到非 ASCII 数字直接当普通文本处理至少不会排错得更离谱。最后那个谁先走完谁在前的处理很多人会写成return x.Length - y.Length看着简洁但在已经消费了不同长度的前缀之后这个式子是错的。必须用剩余的未消费长度来比。3.3 零分配优化Span、避免正则、避免装箱前面说了不用正则这里展开讲讲为什么。正则版自然排序的典型写法是Regex.Split(s, (\d))然后逐段判断。这个写法的问题是每次比较都要走两遍正则引擎十万条数据排序大约需要 160 万次比较每次生成一个字符串数组内存直接爆掉我在一台普通开发机上实测过光排序就跑了十几秒还触发了多次 GC。用Span的版本一次比较不分配任何对象同样的数据量能压到百毫秒级别。上面代码里x.AsSpan(si, i - si)就是关键——只在原字符串上开一个窗口不复制内容。注意ReadOnlySpanchar是ref struct不能跨await、不能存字段所以在 async 方法里要保证比较逻辑是同步完成的这一点用OrderBy时自然满足。如果你的项目还在比较老的框架上没有Span那就退一步用string.CompareOrdinal(x, si, y, sj, len)这种带索引的重载至少能避免Substring的分配。再老一点连这个都没有就只能Substring了但那时候数据量一般也不大。还有一个隐形的装箱点早期有人用IComparer非泛型实现排序时每个 string 都要装箱成 object这个开销在小数据量下看不出来十万级以上就非常明显。永远用泛型版本IComparerstring这是底线。3.4 两个常用变体忽略大小写、前导零规则可配实际项目里需求往往比标准版多一点点这里给两个最常被问到的变体改动都很小。忽略大小写只需要构造时传StringComparison.OrdinalIgnoreCasevar ci new NaturalStringComparer(StringComparison.OrdinalIgnoreCase);注意这个参数只影响文本段的比较数字段永远是精确比较这个设计是刻意的——数字没有大小写概念传什么参数都不该变。前导零规则可配是在CompareNumberSpan返回 0 之后补一刀// 数值相等再按原始宽度决定顺序短的在前 if (a.Length ! b.Length) return a.Length b.Length ? -1 : 1;这段代码的作用是让1排在01前面。加不加取决于业务如果这批编号是系统生成的定长编号比如 8 位流水号宽度一致加了没影响如果是人工录入的、宽度不固定加上它能让结果更符合直觉。但一定要全局统一同一个字段在列表页和导出页用不同规则用户会疯掉。还有一种变体是文本段忽略某些符号比如比较文件名时不区分_和-。实现方式是在文本分支里遇到这类字符就跳过两边的都跳过才能保持反对称性。这个改动风险比较高容易破坏传递性除非真有明确需求我一般不建议加。4. 三种更省事的替代方案与性能对比4.1 补零键法一次生成到处排序如果排序只在展示层发生自定义比较器完全够用。但如果排序要下沉到数据库、要支持分页、要加索引就必须把可排序性物化成数据这时候补零键就是标准解法。原理很朴素把每个数字段左边补零到固定宽度变成定长字符串然后直接做序数比较。因为所有数字段宽度一致字符串的字典序恰好等于数值序。using System.Text.RegularExpressions; public static class SortKeyBuilder { private static readonly Regex NumberPart new(\d, RegexOptions.Compiled); public static string Build(string source, int width 20) NumberPart.Replace(source, m { var digits m.Value.TrimStart(0); if (digits.Length 0) digits 0; return digits.Length width ? digits : digits.PadLeft(width, 0); }); }宽度取 20 是个经验值long最大 19 位20 位足够覆盖绝大多数业务编号。超过 20 位的数字不补零直接保留此时它跟 20 位补零键比较因为补零键以0开头、超长数字首位非零字典序上反而是短的在前仍然正确。但这里有个坑要记牢必须先把前导零TrimStart(0)掉。如果原始数据是000000000000000000000122 位带一堆前导零不 trim 的话它的长度超过 20不参与补零键以0开头、长度 22跟一个正常的 20 位补零键比位置 0 到 19 全是0第 20 位才分胜负结果就跑到所有数字后面去了。这个 bug 极其隐蔽只有在数据里混进定长带前导零的编号时才爆发。用起来是这样写入时生成键存一列查询时ORDER BY SortKey还能顺手加个索引。代价是原始数据变更时要同步刷新键建议写成计算列如果数据库支持或者用触发器/应用层统一入口保证。4.2 固定格式场景捕获组 多级 ThenBy 更好用补零键通用但笨重。如果你的编号格式是固定的前缀 数字 后缀用正则捕获组配合 LINQ 的多级排序会更直接、更好读而且性能也更好——正则只匹配一遍之后全是索引排序。private static readonly Regex Pattern new(^(?prefix[^\d]*)(?num\d)(?suffix.*)$, RegexOptions.Compiled); var sorted items .Select(s (Value: s, Match: Pattern.Match(s))) .OrderBy(t t.Match.Success ? t.Match.Groups[prefix].Value : t.Value, StringComparer.Ordinal) .ThenBy(t t.Match.Success ? t.Match.Groups[num].Value.TrimStart(0).Length : 0) .ThenBy(t t.Match.Success ? t.Match.Groups[num].Value.TrimStart(0) : string.Empty, StringComparer.Ordinal) .ThenBy(t t.Match.Success ? t.Match.Groups[suffix].Value : string.Empty, StringComparer.Ordinal) .Select(t t.Value) .ToList();第二级ThenBy比的是去掉前导零之后的长度第三级比的是去掉前导零之后的字符串。这两级合起来正好等价于数值比较长度不同则长的数值大长度相同则逐位字符比较。跟 3.2 节的手写逻辑是同一个思想只是交给了 LINQ 的稳定排序引擎去执行。这里有个细节必须提醒第二级千万别写成Groups[num].Value.Length那是含前导零的长度010会被判成比9长结果010排在9后面。这个 bug 我在两个项目里见过都是因为复制代码时漏了TrimStart。这个方案的适用边界很清晰格式规整、正则一定能匹配上的场景。如果数据里有格式不符的漏网之鱼Match.Success为 false会退化到按整串比较跟其他元素混在一起排位置可能很怪。所以上线前务必跑一遍全量数据确认匹配率是 100%。4.3 借用系统外壳的逻辑排序桌面客户端最省事的做法是直接调系统外壳那个逻辑排序函数资源管理器就是用它的行为跟用户预期一模一样。using System.Runtime.InteropServices; internal static class ShellLogicalComparer { [DllImport(shlwapi.dll, CharSet CharSet.Unicode, ExactSpelling true)] private static extern int StrCmpLogicalW(string psz1, string psz2); public static readonly IComparerstring Instance Comparerstring.Create((a, b) StrCmpLogicalW(a ?? string.Empty, b ?? string.Empty)); }优点是零成本、结果官方、连用户都挑不出毛病。缺点是只能在桌面平台用服务端和跨平台场景直接歇菜而且它的细节行为是外部实现遇到极端输入比如超长数字、全角字符时你没法调试只能接受。所以我一般只在文件浏览器类界面用它业务逻辑里的排序还是用自己的比较器。4.4 实测性能对比与选型建议性能这块我给的是相对值因为绝对数字跟机器、数据分布、JIT 预热都有关系写死一个毫秒数反而误导人。测试方式是构造 10 万条模拟文件名报告_ 随机数 .pdf在 Release 模式下各跑五轮取中位数以手写比较器为基准 1.0 倍。方案相对耗时内存分配可下沉数据库主要限制手写 NaturalStringComparer1.0基准极低否需自己保证比较器契约补零键法约 0.6中生成键时是需冗余存储与同步刷新正则捕获组 ThenBy约 0.7中否要求格式 100% 匹配外壳逻辑排序 API约 0.5极低否平台受限每比较一次开一次正则拆分约 25 倍极高否数据量稍大就不可用表格里最后一行值得单独说一句这是最常见的想当然写法代码短、看起来优雅但它是唯一一个会让十万条数据排序从百毫秒级跳到十秒级的选项。如果你现在的代码里有这种写法且数据量还小先记一笔技术债等数据涨上来之前换掉。选型建议按照三个问题走排序要不要在数据库端完成要就走补零键。数据格式是不是完全规整是就走捕获组 ThenBy代码最短最好维护。是不是桌面端的文件类界面是直接用外壳 API。以上都不是就老老实实手写比较器。5. 踩坑实录八种翻车现象与排查路径5.1 现象速查表下面这张表是我这些年攒下来的遇到问题先对着看一眼能省掉不少时间。现象大概率原因处理方向抛ArgumentException提示比较器返回不一致结果比较器违反反对称性或传递性检查是否有解析失败统一返回 0的分支排序结果每次不一样循环结束后无条件返回 0或用了不稳定排序补上剩余长度比较改用 OrderBy01和1顺序飘忽比较器判定相等明确前导零规则并统一数字特别长时抛溢出用了int.Parse/long.Parse改为长度 逐位比较同一份数据在不同机器上顺序不同用了区域性比较显式指定StringComparison.Ordinal排序后Distinct()没去掉重复比较器语义与 Equals 语义不一致实现IEqualityComparerT并显式传入数据库查出来的顺序和内存排序不一致排序发生在数据库端用了排序规则改存排序键列界面卡死几秒大列表在主线程排序 高分配比较器挪到后台线程换零分配实现5.2 比较器契约被破坏后的典型异常这个异常我第一次遇到时看了半天没看懂信息大概是IComparer.Compare()方法返回不一致的结果。x与自身比较应返回零或者x与y比较的结果应与y与x比较的结果相反。翻译成人话就是排序算法在内部抽查发现你的比较器自相矛盾。最常见的触发路径是两个字符串判定相等但跟第三个字符串比较时结果互相矛盾。举个具体例子比较器里对数字段用int.TryParse失败就返回 0。字符串 A 是9999999999911 位超 int 范围B 是1C 是2。A 和 B 比较返回 0A 和 C 比较返回 0但 B 和 C 比较返回 -1。于是排序算法认为 A B、A C 但 B ! C直接炸掉。正确的修法不是加try-catch而是从根本上避免无法比较就返回 0这种偷懒逻辑。任何一对输入都必须给出一个明确的顺序。如果实在无法确定就退化成整串的序数比较至少它永远满足那三条性质。还有一种更隐蔽的比较器依赖了会变的全局状态。比如有个比较器内部读了一个静态的bool IgnoreCase字段排序过程中这个字段被别的线程改了前后行为不一致同样会炸。比较器必须是纯函数这是硬要求。5.3 数据库与 ORM 侧为什么翻译不了你的比较器一个很常见的误区是在 EF Core 里写query.OrderBy(x x.Code, new NaturalStringComparer())以为能在数据库端排序。实际执行时会发现整个查询变成了客户端求值数据全表拉进内存数据量一大直接超时。原因很简单自定义比较器是 C# 代码数据库引擎没法执行。ORM 能做的是把表达式树翻译成 SQL而你的IComparer根本不在表达式树里它只存在于排序执行阶段。所以这种写法要么被静默降级成内存排序要么直接抛无法翻译的异常。正确的做法只有两条把排序键物化成一列或者在 SQL 里手工写排序表达式。物化那一列就是 4.1 节的补零键写入时算好查询时ORDER BY SortKey还能走索引百万级数据也是毫秒返回。如果不想改表结构也可以在 SQL 里拆某些数据库支持正则拆分或者字符串函数组合但写出来的 SQL 一般又长又慢还要针对不同数据库各写一套维护成本更高。我的建议很明确只要这个排序是业务上的正式需求不是一次性导出就老老实实加一列排序键。多一列存储换来的是索引、分页、跨端一致这笔账怎么算都划算。5.4 大列表与 UI 线程别在主线程排十万条这条纯属经验之谈。桌面或移动端的列表控件如果在主线程上对十万条数据调用OrderBy().ToList()界面会肉眼可见地卡住。如果比较器再有点分配比如内部用了正则卡顿时间会翻几倍。处理方式不复杂排序放到后台任务里做完成后回到 UI 线程一次性刷新如果列表本身是虚拟化的只渲染可见项那排序就必须在数据层完成跟渲染解耦。另外排序结果最好缓存起来别在每次滚动、每次属性变更时重新排一遍——我见过一个页面因为绑定属性写得不对输入框每敲一个字符就把整个列表重排一次数据量一大直接假死。还有个小技巧如果列表需要支持多种排序方式切换把排序键选择器抽成委托OrderBy只写一遍切换时换委托就行避免为每种排序各写一套 LINQ代码量和 bug 数都会少一半。6. 边界与扩展版本号、Unicode 数字、混合文本6.1 版本号排序与自然排序不是一回事很多人以为自然排序能直接搞定版本号大部分情况确实可以但两者语义上有细微差异得心里有数。自然排序把1.10.0切成1、.、10、.、0按段比较结果1.10.0 1.9.0正确。问题出在段数不一致的时候1.0和1.0.0前者短按谁先结束谁在前的规则1.0 1.0.0。而语义化版本的规范里这两个是等价的。如果你的业务里版本号段数不统一就得先用专门的解析器补零补齐再排。更麻烦的是带预发布标签的版本1.0.0-alpha应该排在1.0.0前面。自然排序会怎么处理1.0.0-alpha比1.0.0长且前缀完全相同所以前者排后面正好相反。这就需要专门的版本比较器public sealed class SemVerComparer : IComparerstring { public int Compare(string? x, string? y) { if (ReferenceEquals(x, y)) return 0; if (x is null) return -1; if (y is null) return 1; var (xMain, xPre) Split(x); var (yMain, yPre) Split(y); int r CompareNumericParts(xMain, yMain); if (r ! 0) return r; // 没有预发布标签的版本更高 if (xPre.Length 0 yPre.Length 0) return 1; if (yPre.Length 0 xPre.Length 0) return -1; return string.Compare(xPre, yPre, StringComparison.Ordinal); } private static (string[] Main, string Pre) Split(string s) { int idx s.IndexOf(-); var main idx 0 ? s : s[..idx]; var pre idx 0 ? string.Empty : s[(idx 1)..]; return (main.Split(.), pre); } private static int CompareNumericParts(string[] a, string[] b) { int len Math.Max(a.Length, b.Length); for (int i 0; i len; i) { long va i a.Length long.TryParse(a[i], out var pa) ? pa : 0; long vb i b.Length long.TryParse(b[i], out var pb) ? pb : 0; if (va ! vb) return va vb ? -1 : 1; } return 0; } }这段代码把主版本段按数值比、缺段补零、预发布标签按文本比且空的高这套规则固化了。注意主版本段解析失败时我退化成 0这在实际业务里意味着非法版本号会被当成 0.0.0可能不是最优策略如果你的数据源不可信改成抛异常或者单独归类会更安全。6.2 Unicode 数字、全角字符与中文序号前面在IsAsciiDigit那儿已经埋了伏笔非 ASCII 数字不参与数值比较。这会带来什么后果第①章、第②章这类带圈数字码位是连续的序数比较恰好能得到正确顺序属于运气好。但第章全角数字跟第5章半角混在一起全角的码位是 65301远大于半角5的 53所以所有全角编号都会排到半角后面视觉上非常乱。处理办法是在分词前做一次归一化全角转半角c - 0xFEE0只对 0xFF01 到 0xFF5E 区间有效、带圈数字映射、中文数字转阿拉伯数字。前两个是机械替换中文数字一到九十九就得写映射表了而且十、百这种单位要特殊处理。我的经验是如果数据量不大且格式可控在入口处做一次清洗把编号统一成 ASCII 半角阿拉伯数字比在比较器里做各种兼容判断要稳健得多。还有一种情况是混排比如第一章和第10章。这俩压根不是同一套编号体系排序结果没有意义。遇到这种数据先分组再排序或者干脆按录入时间排别硬凑。6.3 一组覆盖边界的单元测试比较器这种东西只要有一个边界漏了线上就会在某个奇怪的数据上炸。我现在写这类组件习惯先写测试再写实现下面这组用例基本覆盖了所有坑。[Theory] [InlineData(a1, a10, -1)] // 基本数值比较 [InlineData(a10, a9, 1)] // 位数不同 [InlineData(a001, a1, 0)] // 前导零视为数值相等 [InlineData(a1, a1b, -1)]
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑