string.Compare这个 API 签名短、返回值简单,很多人扫一眼文档就上手了。我见过不少代码把它当成 C 语言strcmp的等价物,写if (string.Compare(a, b) == 1)判断"a 大于 b",写string.Compare(a, b) == 0判断两个字符串相等,本地跑得挺顺,一上生产换了服务器区域设置、换了一批带连字符和重音符号的数据,排序结果就开始飘。更麻烦的是这类问题不报错、不抛异常,只是悄悄把顺序搞错,等你从业务侧听到"列表里的名字排得莫名其妙",再回头查,很难定位到这一行调用。
这篇就把string.Compare从头到尾拆一遍:返回值到底保证了什么、三种比较语义(序数 / 区域性 / 不变文化)有什么本质区别、那一堆重载里index和length参数到底怎么算、区域性比较在哪些真实数据上会翻车、性能上什么时候必须换掉它、以及六类典型业务场景各自该选哪个 API。内容偏实战,适合已经写过 C# 但对字符串比较只有模糊印象的读者,也适合想把这套知识系统梳理一遍的老手。
1. 返回值只有符号有意义:一个被无数人写错的契约
1.1Compare(a, b) == 1这种写法迟早翻车
官方文档对返回值的描述只有三句话:小于零表示strA排在strB前面,等于零表示两者在排序顺序中位置相同,大于零表示strA排在strB后面。注意它说的是"大于零"和"小于零",不是"等于 1"和"等于 -1"。这个措辞不是文档写得啰嗦,而是实现确实可能返回其他值。
序数比较的底层实现通常返回第一个不同字符的码位差。取两个单字符字符串测试一下就能看到:
Console.WriteLine(string.CompareOrdinal("a", "c")); // -2,'a'(97) - 'c'(99) Console.WriteLine(string.CompareOrdinal("c", "a")); // 2 Console.WriteLine(string.CompareOrdinal("ab", "abc")); // -1,长度差在"a"和"c"这一组里,返回的是 -2 而不是 -1。如果代码里写if (string.CompareOrdinal(a, b) == -1),这个分支永远不会命中。文化敏感比较的返回值更没规律,内部走的是排序权重表,返回什么取决于实现细节,不同运行时版本之间都可能变。
正确姿势只有一种:只判断符号,不判断具体数值。
int result = string.Compare(a, b, StringComparison.Ordinal); if (result < 0) { /* a 排在 b 前 */ } else if (result > 0) { /* a 排在 b 后 */ } else { /* 排序位置相同 */ }如果你确实需要"三态"的值来做IComparer<string>的返回值,直接return result;就行,IComparer<T>的契约同样只要求符号正确,不需要归一化成 -1/0/1。硬归一化反而多一次分支,没有收益。
1.2null与空串在比较里的位置
string.Compare是静态方法,对null有明确定义:null被视为排序顺序中的最小值。string.Compare(null, null)返回 0,string.Compare(null, "")返回负数,string.Compare("", null)返回正数,string.Compare("", "a")返回负数。也就是说排序顺序是null < "" < "a"。
这个特性在排序场景里很有用——你可以直接把可能为null的字段丢进去排,不用预处理。但如果你的业务语义是"null和空串等价",那就得自己先归一化,别指望Compare帮你抹平。
顺带提一个容易混淆的点:实例方法CompareTo和静态方法Compare在null上的表现不完全一样。"a".CompareTo(null)返回 1,没问题;但如果实例本身是null再去调CompareTo,直接NullReferenceException,因为它是实例方法。所以处理可能为null的字符串时,静态的string.Compare更稳妥。
1.3 返回 0 不等于"两个字符串逐字节相同"
这是文化敏感比较带来的隐蔽差异。string.Compare(a, b)返回 0,只说明在当前的排序规则下两者位置相同,不代表它们的字符序列一样。
举个具体例子,Unicode 里带重音的é有两种表示:单个码位U+00E9,或者e后面跟组合重音符U+0301。在不变文化比较下,这两串会被规范化后认为排序位置相同:
string composed = "\u00E9"; // é,单码位 string decomposed = "e\u0301"; // e + 组合重音符 Console.WriteLine(string.Compare(composed, decomposed, StringComparison.InvariantCulture)); // 0 Console.WriteLine(string.CompareOrdinal(composed, decomposed)); // 非 0 Console.WriteLine(composed == decomposed); // False于是就会出现这种诡异情况:字典里用文化比较判等,两把"相等"的 key 却能同时存在;数据库里存的是组合形式,代码里用单码位形式去查,Compare说相等但==说不等。所以判等永远不要用Compare(...) == 0,老老实实用string.Equals(a, b, StringComparison.Ordinal)或==运算符(==就是序数语义)。Compare的设计目的是排序,不是判等,这两个用途在语义上根本不同。
2. 序数、区域性、不变文化:三种语义决定了结果
2.1 序数比较:纯粹按码位逐字对比
序数比较(Ordinal)完全不管语言规则,就是把两个字符串的 UTF-16 码位一个一个比下去,第一个不同就出结果,全都相同再比长度。"Z"的码位是 90,"a"是 97,所以序数比较下"Z" < "a"——大写字母整体排在小写字母前面,跟人类直觉相反。
它的价值在于确定性。同样的输入,在 Windows、Linux、容器、不同 .NET 版本上结果完全一致,不受系统区域设置影响,不受 ICU 版本影响,不受运行时开关影响。所以凡是"程序自己定义"的字符串——枚举名、配置键、HTTP 头、文件扩展名、数据库列名、协议字段、JSON 属性名、缓存 key——都应该用序数比较。
序数比较的实现也最简单,先比引用(同一个字符串实例直接返回 0,字符串驻留场景下这一步就能短路),再比长度和字符,没有查表、没有规范化、没有内存分配。这是所有比较方式里最快的。
写法上有两种等价形式:
string.Compare(a, b, StringComparison.Ordinal); string.CompareOrdinal(a, b);CompareOrdinal是独立方法,不接受StringComparison参数,永远序数。两者在结果符号上完全一致,选哪个看代码风格,一般推荐前者,因为参数里显式写着Ordinal,读代码的人一眼能看出语义。
2.2 区域性比较:回答的是"人类怎么读"
区域性比较(CurrentCulture / 具体 CultureInfo)走的是语言学排序规则,会考虑字母顺序、重音、大小写、宽度、假名类型等一堆因素。德语里ä排在a附近,瑞典语里ä是独立字母排在z后面,同样是ä,两种文化的排序位置完全不同。
它解决的问题是"给用户看的列表怎么排才符合他的阅读习惯"。联系人列表、商品目录、城市名下拉框,这些场景必须用区域性比较,用序数排出来的结果在用户眼里就是乱序。
但代价是:结果依赖Thread.CurrentThread.CurrentCulture。在 ASP.NET 里,当前文化可能被请求头、路由参数、中间件改掉,同一段排序代码在不同请求里可能给出不同顺序。更糟的是部署环境——开发机是中文区域,服务器是英文区域,同一份数据排出来的顺序就不一样,而代码逻辑看着完全没问题。
所以用区域性比较时,尽量显式传入CultureInfo,不要依赖隐式当前文化:
var culture = new CultureInfo("de-DE"); int result = string.Compare(a, b, false, culture);这样至少行为可复现,出了问题能定位。
2.3 InvariantCulture 到底是不是"安全区"
不变文化(InvariantCulture)是个折中选项。它有一套固定的、基于英语习惯的排序规则,不随系统区域设置变化,因此在跨机器场景下比当前文化稳定。
但它不是序数比较。它仍然会做重音处理、符号权重计算、规范化,只是规则固定下来了。所以:
- 需要跨机器可复现、又要接近人类阅读顺序 → 用
InvariantCulture - 需要绝对确定、与语言无关 → 用
Ordinal
很多人把InvariantCulture当成"安全的默认值"到处用,这在排序展示场景没问题,但用在标识符比较上就是过度设计——它比序数比较慢,还会把"file"和"file"(连字 fi)这类视觉相似但码位不同的串判成相近,对 key 匹配是有害的。
3. 重载家族的成员与index/length参数的真实含义
3.1 十个重载,日常只用三个
string.Compare一共有十来个重载,参数组合是ignoreCase、CultureInfo、StringComparison、CompareOptions、index/length的笛卡尔积。实际项目里 95% 的调用集中在三个:
| 重载 | 适用场景 | 说明 |
|---|---|---|
Compare(a, b, StringComparison) | 绝大多数场景 | 语义最明确,首选 |
Compare(a, b, bool ignoreCase, CultureInfo) | 需要显式指定文化 | 老代码常见,功能等价 |
Compare(a, b, CultureInfo, CompareOptions) | 需要IgnoreSymbols等高级选项 | 灵活性最高 |
其余带indexA、indexB、length的重载是给"只比较子串"用的,日常很少碰,但一旦用错就是边界异常。
3.2length是比较的最大字符数,不是结束索引
这是最容易搞错的地方。string.Compare(strA, indexA, strB, indexB, length)的语义是:从strA的indexA位置开始、从strB的indexB位置开始,最多比较length个字符。它不是"比较到第 length 个位置"。
string a = "prefix-ABCDEF"; string b = "prefix-ABCDXY"; // 从索引 7 开始,比较 4 个字符:"ABCD" vs "ABCD",返回 0 int r = string.Compare(a, 7, b, 7, 4); // 从索引 7 开始,比较 6 个字符:"ABCDEF" vs "ABCDXY" r = string.Compare(a, 7, b, 7, 6); // 负数边界规则有两条值得记住:
indexA + length超过strA.Length是允许的,会自然截断到字符串末尾,不抛异常。indexA、indexB、length任一为负数,或者indexA > strA.Length,抛ArgumentOutOfRangeException。
第二条里有个隐含坑:如果strA是null,它的"长度"按 0 算,此时indexA只能是 0,length也只能是 0,否则就抛异常。也就是说子串版本对null的容忍度远低于普通版本,传参前最好自己判一下。
3.3 一个典型误用:拿子串比较做前缀判断
有人图省事,用string.Compare(a, 0, b, 0, b.Length) == 0来判断a是否以b开头。这段代码能跑,但有两个问题:一是b为null或空串时行为要单独处理,二是语义绕,读代码的人要停下来想两秒。
更直接的是a.StartsWith(b, StringComparison.Ordinal),意图清晰,性能也更好(内部有长度预检和向量化优化)。同理,判断后缀用EndsWith,中间包含用Contains,都有对应的StringComparison重载。
Compare的子串能力真正有用的场景是"比较两段结构化文本的某一段",比如解析定长记录时比较两个字段区间的取值,这时候它比先Substring再比较更省一次分配。
4. 区域性敏感比较的坑:土耳其 i、连字符和跨平台漂移
4.1 土耳其语里的i能让IgnoreCase彻底失效
土耳其语有四个 I 形字母:I、ı(无点小写)、İ(有点大写)、i。规则是i的大写是İ而不是I,I的小写是ı而不是i。
于是这段代码的行为就很反直觉:
var tr = new CultureInfo("tr-TR"); Console.WriteLine(string.Compare("file", "FILE", true, tr)); // 非 0 Console.WriteLine(string.Compare("file", "FILE", StringComparison.OrdinalIgnoreCase)); // 0在tr-TR文化下做忽略大小写比较,"file"和"FILE"被判为不相等。原因是大写转换时i变成了İ,跟I对不上。
这个坑的杀伤力在于它是环境相关的。一套多语言系统,某个用户把界面语言切到土耳其语,当前线程文化跟着变,然后所有依赖隐式当前文化的IgnoreCase比较同时失效——配置键匹配不上、命令解析失败、登录名校验拒绝合法用户。排查的时候你会觉得"代码没动过啊"。
规避办法很直接:凡是忽略大小写的标识符比较,一律显式用OrdinalIgnoreCase。
// 不要这样 if (string.Compare(cmd, "start", true) == 0) { } // 应该这样 if (string.Equals(cmd, "start", StringComparison.OrdinalIgnoreCase)) { } // 排序场景 Array.Sort(items, StringComparer.OrdinalIgnoreCase);OrdinalIgnoreCase的实现是对每个字符做固定的大小写折叠(内部用一张大小写映射表),不查语言规则,所以不受当前文化影响,也不会有土耳其问题。唯一需要注意的是它对非 ASCII 字符的处理比较粗略,比如ß不会被视作等于ss——对标识符场景来说这恰恰是想要的行为。
4.2 连字符、撇号在排序表里权重极低
在区域性排序里,-、'、.这类标点的权重非常低,很多时候接近于"可忽略"。所以"co-op"、"coop"、"co op"在文化比较下排序位置非常接近,甚至可能 collate 相等。而序数比较里-是 0x2D,远小于所有字母,顺序完全不同。
var words = new[] { "coop", "co-op", "cope", "co op" }; Array.Sort(words); // 文化比较(取决于当前文化) // 大致结果:co-op, co op, coop, cope Array.Sort(words, StringComparer.Ordinal); // 结果:co op, co-op, coop, cope (' '(0x20) < '-'(0x2D) < 'o')如果业务上要求"标点必须参与排序、不能忽略",那就得用CompareOptions.StringSort:
int r = string.Compare(a, b, CultureInfo.InvariantCulture, CompareOptions.StringSort);StringSort会让连字符和撇号按码位参与排序,而不是被折叠掉。这个选项在排文件名、排代码符号列表时特别有用,因为用户期望a-b和ab是分开的两条。
还有一个相关选项是CompareOptions.IgnoreSymbols,作用相反,会忽略所有符号。用它的场景一般是搜索匹配——用户输入"c++"想搜到"c",这种模糊语义就需要显式忽略符号。但别在排序里用它,会让本来不同的条目排到一起,顺序变得不可预测。
4.3 Windows 和 Linux 上的排序表不是同一张
这是近几年容器化部署后才被广泛踩到的坑。.NET 在 Windows 上长期使用系统自带的 NLS 排序表,在 Linux/macOS 上使用 ICU(International Components for Unicode)。两套实现的排序规则在大部分常见字符上一致,但在边缘字符上会给出不同结果——特别是那些新加入 Unicode 的字符、组合字符序列、某些小语种的扩展字母。
更隐蔽的是 ICU 本身有版本差异。基础镜像里的 ICU 版本不同,同一个字符串的排序键就可能不同。你在本地 Docker 里测出来的排序,到了 CI 环境的另一个基础镜像上就可能变。
还有一种情况是全球化不变模式。容器镜像为了减小体积,有时候会设置环境变量DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1,这时候整个运行时退化为不变文化模式,所有区域性比较的行为简化为接近序数的规则。表现出来的现象就是"本地跑没问题,容器里排序全乱"。这个开关一般在项目文件或部署配置里,代码里完全看不出来,排查时容易漏。
结论就一句话:排序结果如果需要跨环境一致,就用Ordinal或OrdinalIgnoreCase;如果必须用区域性排序,就把CultureInfo写死,并在部署环境里锁定 ICU 版本或至少在 CI 里加一条排序结果的快照测试。
5. 性能账本:哪些地方必须把string.Compare换掉
5.1 序数和文化比较的开销差一个数量级
序数比较是逐字符的整数比较,CPU 层面非常友好,现代运行时还能用 SIMD 指令做批量比对,几千万次比较也就几十毫秒。文化比较完全不是这个量级:它要查排序权重表、处理重音和大小写折叠、可能还要做 Unicode 规范化(把组合字符拆开或合并),中间涉及多次查表和潜在的内存分配。
粗略的量级参考:在同样的短字符串上,文化敏感比较比序数比较慢十几倍到几十倍,字符串越长、越复杂,差距越明显。如果在一百万条数据上跑OrderBy(x => x.Name)用默认的Comparer<string>.Default(内部走当前文化),跟用StringComparer.Ordinal相比,耗时能差出好几秒。
这个差异在列表展示场景通常感受不到,因为数据量小、只排一次。但在下面这些地方就是灾难:
- 高频调用的字典查找或
HashSet去重 - 大批量数据的内存内连接(LINQ
Join、GroupBy) - 日志聚合、指标统计里按 key 分组
- 递归算法里做路径或状态去重
这些场景的共性是比较次数与数据量是超线性关系,单次比较的成本被放大几十万倍。
5.2 排序、字典和去重各自该用哪个StringComparer
StringComparer系列是这几个场景的正解,它把比较语义封装成一个可复用的对象,能直接传给Dictionary、HashSet、Array.Sort、OrderBy。
| 用途 | 推荐 | 理由 |
|---|---|---|
| 字典 / 去重的 key | StringComparer.Ordinal | 哈希与判等语义一致,最快 |
| 忽略大小写的 key | StringComparer.OrdinalIgnoreCase | 不受当前文化影响 |
| 面向用户排序 | StringComparer.Create(culture, false) | 显式文化,可复现 |
| 忽略大小写的用户排序 | StringComparer.Create(culture, true) | 同上,带忽略大小写 |
| 跨文化稳定的排序 | StringComparer.InvariantCulture | 规则固定 |
特别提醒一点:Dictionary<string, T>的默认比较器是序数的(EqualityComparer<string>.Default走==),但SortedDictionary<string, T>和SortedList<string, T>的默认比较器是文化敏感的(Comparer<string>.Default走CompareTo)。这个不一致会导致同样一批 key,放进Dictionary是 10 条,放进SortedDictionary可能变成 9 条——因为两把"文化上相等但序数不等"的 key 被合并了。给有序容器显式传StringComparer.Ordinal能规避。
5.3 反复排序时用排序键摊薄成本
如果同一批字符串要在多处反复排序(比如先按名称排一遍,再按分组排一遍),可以先用CompareInfo.GetSortKey把每个字符串编译成一个排序键,之后直接比较字节数组:
var compareInfo = CultureInfo.InvariantCulture.CompareInfo; var keys = items.ToDictionary( x => x, x => compareInfo.GetSortKey(x.Name)); var sorted = items.OrderBy(x => keys[x], SortKeyComparer.Instance).ToList();GetSortKey的代价相当于做一次完整的权重分析,但做一次之后,后续每次比较就退化成字节数组的memcmp,速度接近序数比较。排序次数越多,摊薄效应越明显。这个技巧在需要按多个不同字段排序同一批大数据的报表场景里效果很好,代价是多占一份内存存排序键。
反过来,只排一次的场景别用这招,GetSortKey本身的开销加上内存分配可能比直接排还慢。
6. 场景对照:六类典型需求各自该用哪个 API
6.1 决策表
把前面的结论收成一张表,遇到具体问题直接对号入座:
| 场景 | 用什么 | 不要用什么 |
|---|---|---|
| 判等(标识符、key、枚举名) | string.Equals(a, b, StringComparison.Ordinal) | Compare == 0 |
| 忽略大小写判等 | string.Equals(a, b, StringComparison.OrdinalIgnoreCase) | ToLower() == |
| 字典 / 哈希集合 key | StringComparer.Ordinal | 默认比较器在有序容器里 |
| 面向用户的名称排序 | StringComparer.Create(culture, false) | 无参Array.Sort |
| 文件名 / 路径排序 | StringComparer.Ordinal | 文化敏感排序 |
| 前缀 / 后缀判断 | StartsWith/EndsWith+StringComparison | Compare子串版 |
| 版本号大小比较 | Version类或拆分段比较 | string.Compare |
| 密码 / 令牌比对 | CryptographicOperations.FixedTimeEquals | 任何字符串比较 |
ToLower() ==这个写法单独说一下。它除了有土耳其问题,还会分配一个新字符串,在循环里调用就是给垃圾回收添堵。OrdinalIgnoreCase是零分配的,行为还稳定。
6.2 版本号和数字字符串:字符串比较天生做不了这件事
"1.10"和"1.9"谁大?数值上是 1.10 更大,但字符串比较会逐字符走:'1'相同,'.'相同,然后'1'和'9'比,'1' < '9',得出"1.10" < "1.9"。这跟业务语义完全相反。
同样的坑出现在所有"看起来是数字但存成字符串"的地方:订单号、楼层号、章节号、IP 段。只要需要按数值大小排序,就必须先转成数值类型。
固定的版本号格式(major.minor.patch)直接用System.Version:
var v1 = new Version("1.10.0"); var v2 = new Version("1.9.0"); Console.WriteLine(v1 > v2); // True不固定段数的(比如"1.2.3.4.5"或带预发布标签的语义化版本),Version处理不了,得自己拆成整数数组逐段比较,或者用专门的语义化版本解析库。核心原则是:数字段的比较必须走数值比较,字符段才走字符串比较。
还有一种变体是"自然排序"——文件名里的file2.txt要排在file10.txt前面。这时候需要的是把字符串切成"数字段"和"文本段",数字段按数值比、文本段按序数比,所以既要int.CompareTo也要string.CompareOrdinal,两者交替使用。Windows 资源管理器的文件名排序就是这套规则。
6.3 安全相关:比较耗时本身会泄露信息
最后提一个容易被忽略的角度。普通字符串比较在发现第一个不同字符时就返回,所以比较两个长度相同、前缀相同的字符串时,前缀越接近耗时越长。对于密码、API 令牌、HMAC 签名这类秘密值,攻击者可以通过大量请求测量耗时,逐字节猜出正确值——这叫时序攻击。
防范方式是用恒定时间比较:
bool ok = CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(input), Encoding.UTF8.GetBytes(expected));这个方法的实现会完整遍历两个数组的所有字节,不管在第几位发现不同,耗时都一样,从而堵住时间侧信道。string.Compare、==、Equals全都不具备这个特性,在安全敏感场景下都不该出现。
我在实际项目里遇到过一个相关的细节:某次把校验逻辑从string.Compare改成OrdinalIgnoreCase之后,功能测试全过,但压力测试里 CPU 占用反而上去了。查下来是因为改完之后顺手把校验函数放进了热路径的循环里,原来文化比较路径上有缓存(排序表是静态的),而新路径每次都完整走一遍字符折叠,加上字符串长度普遍偏长,单次开销反而增加。最后的处理是把校验结果按 key 缓存起来,只在 key 首次出现时比一次。这件事提醒我,性能优化不能只看单次比较的绝对耗时,还得看调用频率和数据特征,最好用真实数据跑一遍 profile 再决定。