在日常业务开发中,字符串处理是最常见的操作之一——清洗非法字符、判断格式、解析分隔字段、批量替换……然而,传统写法(Replace + Where + new string)在高频调用场景下会产生大量临时对象,给 GC 带来不必要的压力。.NET 8 引入了 SearchValues
和一批新的 Span API,让零分配字符串处理变得简单高效。本文从 SwitchData 项目的 StringExtensions.cs 出发,手把手带你把能用就行的字符串代码升级为高性能、零分配、生产级的实现。
为什么你的字符串代码慢?先看这几个反例
下面这些写法,你一定见过甚至写过:
`csharp // 反例 1:清洗非法字符——每 Replace 都创建新字符串 public static string SanitizeBad(this string input) { var sb = new StringBuilder(input.Length); foreach (char c in input) { if ((c >= 0x20 && c <= 0x7E)) sb.Append(c); } return sb.ToString(); }
// 反例 2:判断是否纯数字——LINQ 产生闭包 + 迭代器分配 public static bool IsNumberBad(this string str) { return str.All(char.IsDigit); }
// 反例 3:按多分隔符拆分——每次 Split 都分配新数组 public static string[] GetItemsBad(this string line, char[] separators) { return line.Split(separators, StringSplitOptions.RemoveEmptyEntries); } `
这些代码逻辑没错,但有两个共同问题:
- 多分配:StringBuilder、LINQ 迭代器、Split 返回值数组都是堆分配,触发 GC
- 多遍历:某些写法会对字符串进行两到三次遍历
在高频调用场景(比如日志解析、CSV 批量处理、网络响应清洗),这些微小的损耗会被放大。SwitchData 项目中每秒要处理数百条网络设备日志行,字符串处理的性能直接影响整个管道的吞吐量。
核心武器:.NET 8 的三个新玩具
SearchValues:把
O(n×k) 变成 O(1) 字符查找
SearchValues
`csharp // 预定义:程序启动时构建一次,后续全程复用 private static
readonly SearchValues
private static readonly SearchValues
关键技巧:static readonly 字段预构建,不要在方法内部 SearchValues.Create()。构建过程本身有开销,但建好之后就是极致的 O(1) 查找。
ReadOnlySpan:零切片的万能钥匙
ReadOnlySpan
传统写法:input.Substring(10, 5) 分配新字符串 Span 写法:input.AsSpan().Slice(10, 5) 零分配,仅移动指针
string.Create:在目标内存里直接构造
string.Create(length, state, spanAction)
是一个回调式工厂——它在内部预分配好目标长度的字符串内存,然后把
Span
csharp return string.Create(resultLength, input, (dest, src) => { for (int i = 0; i < src.Length; i++) dest[i] = src[i]; // 直接写进目标内存 });
实战 1:Sanitize —— 零分配字符串清洗
SwitchData 的 Sanitize 方法解决的问题是:从输入字符串中移除所有不在白名单里的字符。比如从用户输入里剔除控制字符、从网络响应里只保留可打印 ASCII。
`csharp public static string Sanitize(this string input,
SearchValues
ReadOnlySpan<char> source = input.AsSpan();
// 一次遍历:找到第一个非法字符
int firstInvalid = source.IndexOfAnyExcept(validValues);
if (firstInvalid == -1) return input; // 没有非法字符,直接返回原字符串引用
// 二次遍历:计算最终长度
int resultLength = firstInvalid;
for (int i = firstInvalid + 1; i < source.Length; i++)
if (validValues.Contains(source[i])) resultLength++;
if (resultLength == 0) return string.Empty;
// string.Create:直接在目标内存里构建
return string.Create(resultLength, (source, firstInvalid, validValues), (dest, state) =>
{
state.source[..state.firstInvalid].CopyTo(dest); // 拷贝前缀
int destIdx = state.firstInvalid;
for (int i = state.firstInvalid + 1; i < state.source.Length; i++)
if (state.validValues.Contains(state.source[i]))
dest[destIdx++] = state.source[i];
});
} `
执行流程图
`mermaid flowchart LR A[“输入: Hello\x00World\x7F”] –> B[“IndexOfAnyExcept 找第一个非法字符”] B –>|firstInvalid=5| C[“遍历计算 resultLength=10”] C –> D[“string.Create 预分配 10 字符内存”] D –> E[“拷贝前缀 Hello + 遍历拷贝合法字符 World”] E –> F[“输出: HelloWorld”]
style A fill:#f9f,stroke:#333
style F fill:#9f9,stroke:#333
`
为什么快?
| 对比项 | 传统 StringBuilder | 本实现 |
|---|---|---|
| 分配次数 | N(扩容时)+ 1(ToString) | 1(仅 string.Create) |
| 遍历次数 | 1 | 3(找非法 + 算长度 + 拷贝)但第 3 步写内存极快 |
| 无非法字符时 | 仍然重建 | 直接返回原引用(零开销) |
一行调用的便利方法
`csharp // 只保留可打印 ASCII(网络日志清洗场景) string cleaned = response.SanitizeToAscii(onlyPrintable: true);
// 只保留数字 string digits = phone.Sanitize(SearchValues.Create(“0123456789”)); `
实战 2:IsNumber / IsAscii —— 一次遍历,提前返回
格式验证是另一个高频场景。SwitchData 的写法非常简洁:
`csharp public static bool IsNumber(this string str) { if (string.IsNullOrEmpty(str)) return false; return str.AsSpan().IndexOfAnyExcept(Digits) == -1; }
public static bool IsPrintableAscii(this string str) { if (string.IsNullOrEmpty(str)) return true; return str.AsSpan().IndexOfAnyExcept(PrintableAscii) == -1; } `
关键点
- IndexOfAnyExcept(SearchValues) 是 JIT 内联的方法,编译后等价于 for 循环 + 位掩码查表
- == -1 表示所有字符都在白名单里
- 对比 str.All(char.IsDigit):后者产生迭代器 + 闭包分配,前者零分配
Benchmark 大致结果:
| 方法 | 耗时 | 分配 |
|---|---|---|
| str.All(char.IsDigit) | 2.1x | 32 B |
| str.AsSpan().IndexOfAnyExcept(Digits) == -1 | 1.0x | 0 B |
实战 3:GetItems —— ReadOnlySpan.SplitAny 解析网络响应
网络设备返回的响应往往是表格文本,按空格或 Tab 分隔。SwitchData
需要把每行拆成字段。这里用的是 .NET 8 Span
`csharp public static string[] GetItems(this string line, params char[] separators) { return GetItems(line.AsSpan(), false, separators); }
public static string[] GetItems(this ReadOnlySpan
var list = new List<string>();
foreach (var range in span.SplitAny(separators))
{
ReadOnlySpan<char> trimmed = span[range].Trim();
if (allowEmpty || trimmed.Length > 0)
list.Add(trimmed.ToString());
}
return list.ToArray();
} `
亮点
- 重载设计:string 版直接转 ReadOnlySpan
,核心逻辑只写一份 - SplitAny 返回 Range 枚举,不分配子字符串——只返回起止索引
- ToString 延迟:只有最后加到 List 时才分配字符串,中间过程零分配
- params char[] 调用方写起来优雅:line.GetItems(’ ‘,’\t’, ‘|’)
按列位置取值(定长格式场景)
还有一种常见场景:老设备返回的是固定列宽文本(比如网管后台导出的表格),需要按列位置截取:
`csharp public static string[] GetItems(this string line, int[][]
columnPosition) { int count = columnPosition.Length; string[] items =
new string[count]; ReadOnlySpan
for (int i = 0; i < count; i++)
{
int start = columnPosition[i][0] - 1; // 业务是 1-based
int length = columnPosition[i][1];
int actualLength = Math.Min(length, lineSpan.Length - start); // 防越界
items[i] = lineSpan.Slice(start, actualLength).Trim().ToString();
}
return items;
} `
实战 4:BatchReplace & CreateDictionary —— 批量替换 + KeyValue 解析
BatchReplace:循环 Contains + Replace
`csharp public static string BatchReplace(this string str, string[] oldValues, string[] newValues, bool ignoreCase = false) { if (string.IsNullOrEmpty(str)) return string.Empty; if (oldValues.Length != newValues.Length) throw new ArgumentException(“数组长度不一致”);
var comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
for (int i = 0; i < oldValues.Length; i++)
{
if (string.IsNullOrEmpty(oldValues[i])) continue;
if (str.Contains(oldValues[i], comparison))
str = str.Replace(oldValues[i], newValues[i], comparison);
}
return str;
} `
小优化:先 Contains 判断再 Replace,避免不匹配时白分配一个新字符串。
CreateDictionary:一行解析 k1=v1;k2=v2 格式
`csharp public static IReadOnlyDictionary<string, string>
CreateDictionary( this ReadOnlySpan
foreach (var range in span.Trim().SplitAny(itemSep))
{
ReadOnlySpan<char> trimmed = span[range].Trim();
if (trimmed.IsEmpty) continue;
int idx = trimmed.IndexOf(kvSep);
if (idx <= 0) continue;
ReadOnlySpan<char> key = trimmed[..idx].Trim();
ReadOnlySpan<char> value = trimmed[(idx + 1)..].Trim();
if (!key.IsEmpty) dict[key.ToString()] = value.ToString();
}
return dict;
}
// 调用:“Name=张三;Age=25;City=北京”.AsSpan().CreateDictionary(‘;’, ‘=’); `
实战 5:IsEscaped —— 反斜杠转义判断
这个小方法用来判断字符串中某位置的字符是否被反斜杠转义了。在解析 SQL、JSON、命令行参数时都有用:
csharp public static bool IsEscaped(this string text, int index) { int backslashCount = 0; for (int j = index - 1; j >= 0 && text[j] == '\\'; j--) backslashCount++; return backslashCount % 2 != 0; // 奇数个 = 被转义 }
逻辑很简单:从目标位置往前数连续的反斜杠,奇数个说明被转义,偶数个说明没被转义(两个反斜杠表示字面反斜杠,三个反斜杠加引号则引号被转义)。
性能实测:一张表看懂差距
我用 BenchmarkDotNet 在 .NET 8 Release 模式下测了几个方法。输入是一段真实的网络设备日志响应(约 200 字符,含 3 个非法控制字符):
| 方法 | 耗时(ns) | 分配(B) |
|---|---|---|
| Sanitize(SearchValues + string.Create) | 182 | 432(仅结果字符串) |
| SanitizeBad(StringBuilder) | 415 | 592 |
| IsNumber(IndexOfAnyExcept) | 14 | 0 |
| IsNumberBad(LINQ All) | 247 | 40 |
| GetItems(SplitAny + ReadOnlySpan) | 389 | 528 |
| GetItemsBad(Split + RemoveEmptyEntries) | 563 | 768 |
可见:SearchValues + ReadOnlySpan + string.Create 三件套能带来 2-15x 的性能提升,同时把分配降到最低。
设计模式提炼:静态工具类的五个好习惯
从 SwitchData 的 StringExtensions.cs 中可以提炼出几个通用设计原则:
1. 预构建 SearchValues,不要每次 Create
`csharp // 坏:每次方法调用都重建 public static bool IsNumber(this string s) => s.AsSpan().IndexOfAnyExcept(SearchValues.Create(“0123456789”)) == -1;
// 好:static readonly 字段 private static readonly
SearchValues
2. 先快速返回,再做重操作
csharp if (string.IsNullOrEmpty(input)) return input; // 快速通道 if (firstInvalid == -1) return input; // 全合法直接返回引用
3. 多态重载,string 到 ReadOnlySpan 零成本桥接
csharp public static string[] GetItems(this string line, char sep) => GetItems(line.AsSpan(), sep); // 一行委托,核心逻辑写一份
4. 用 string.Create 代替手动 StringBuilder
已知最终长度时,string.Create 只需一次分配。StringBuilder 在预估不准时会多次扩容。
5. Span 是视图,ToString 才是分配
csharp ReadOnlySpan<char> keySpan = trimmed[..idx].Trim(); // 零分配 dict[keySpan.ToString()] = ...; // 这里才分配一次
总结
| 技术 | 解决的问题 | 性能收益 |
|---|---|---|
| SearchValues |
O(1) 字符集合查找 | 替换 Contains + LINQ |
| ReadOnlySpan |
零切片、零分配 | 替换 Substring、LINQ 迭代器 |
| string.Create | 已知长度时零分配构建 | 替换 StringBuilder |
| SplitAny / IndexOfAnyExcept | Span 原生解析 | 替换 Split / 手动遍历 |
.NET 的字符串处理栈在最近几个版本迭代中发生了翻天覆地的变化——从 StringBuilder 到 Span、从 Regex 到 SearchValues,每一步都在向着零分配、低延迟的方向演进。作为业务开发者,你不需要自己实现这些底层 API,但学会用它们来重写你项目中那些性能不敏感的字符串热点,往往能在不改变业务逻辑的前提下,让整个应用的吞吐率翻倍。
下次再写字符串处理代码,先问自己三个问题:
- 我能否用 AsSpan() 跳过临时字符串?
- 我能否用 SearchValues 替换 char.IsDigit 这种单字符判断?
- 我能否用 string.Create 替代 StringBuilder?
试试看,你会发现 .NET 8 的性能比你想象的还要快。