1. 🔤 选择合适的字符串构造方式

string 是不可变类型。每次修改都会产生新的字符串,并复制需要保留的内容。优化字符串构造的重点不是统一改用某个 API,而是根据数据规模和输出方式选择合适的方法。

场景推荐写法
少量、固定数量的值字符串插值或 +
使用分隔符连接已有集合string.Join
循环中逐步追加大量内容StringBuilder
已知最终长度并需要直接填充string.Create
结果写入已有缓冲区TryFormat

1.1 少量拼接优先使用插值

string message = $"用户 {userId} 的角色是 {role}";

现代 C# 编译器和 .NET 运行时能够高效处理常见的字符串插值。只拼接几个值时,为此创建 StringBuilder 通常不会更快,反而增加代码和对象创建。

1.2 集合连接优先使用 string.Join

string csv = string.Join(",", values);

string.Join 已经处理了分隔符位置和最终字符串构造。不要为了连接已有集合,手动编写循环并在每轮处理“是否为第一个元素”。

1.3 循环追加使用 StringBuilder

循环中执行 result += value 会反复创建并复制字符串。输出由许多片段逐步组成时,可以改用 StringBuilder

var builder = new StringBuilder(capacity: 256);

foreach (var item in items)
{
    builder.Append(item.Name);
    builder.AppendLine();
}

string result = builder.ToString();

合理的初始容量能减少内部缓冲区扩容,但不应随意指定一个远大于常见输出的数字。StringBuilder 也不是零分配:它需要内部缓冲区,ToString() 还会创建最终字符串。

1.4 string.Create 与 TryFormat

需要指定格式提供程序并直接生成最终字符串时,可以使用 string.Create

string message = string.Create(
    CultureInfo.InvariantCulture,
    $"订单 {orderId},金额 {amount:F2}");

如果下游能够直接消费字符缓冲区,使用 TryFormat 可以避免创建独立字符串:

Span<char> buffer = stackalloc char[32];

if (amount.TryFormat(
        buffer,
        out int written,
        "F2",
        CultureInfo.InvariantCulture))
{
    Console.WriteLine(buffer[..written]);
}

只有调用链能继续接收 ReadOnlySpan<char> 时,TryFormat 才能真正避免最终字符串分配。如果紧接着调用 ToString(),仍然会创建字符串。

2. ⚖️ 比较字符串时避免额外分配

2.1 不要先转换大小写

下面的写法会创建两个临时字符串,而且比较规则依赖当前区域性:

bool equals = left.ToLower() == right.ToLower();

内部标识符、协议字段和文件扩展名通常应直接指定序号比较规则:

bool equals = left.Equals(
    right,
    StringComparison.OrdinalIgnoreCase);

大小写敏感时使用 Ordinal,忽略大小写时使用 OrdinalIgnoreCase。只有面向用户的自然语言文本,才应根据业务选择 CurrentCultureInvariantCulture 等区域性规则。

2.2 字典使用 StringComparer

字符串作为字典键时,不要在每次读写前调用 ToLower()

var users = new Dictionary<string, User>(
    StringComparer.OrdinalIgnoreCase);

比较器会统一控制相等判断和哈希计算,避免规范化字符串产生的分配,也不会出现“比较时忽略大小写,但哈希规则不一致”的问题。

2.3 单字符操作使用 char 重载

bool hasSeparator = text.Contains(':');
int commaIndex = text.IndexOf(',');
bool isRooted = path.StartsWith('/');

搜索单个字符时使用 char 重载,语义更明确,也能让运行时走针对单字符的实现。代码分析规则 CA1847 也会建议用 Contains(char) 替代只包含一个字符的 Contains(string)

3. 🔎 减少重复扫描和中间字符串

3.1 避免不必要的 Split 与 Substring

Split 会创建结果数组和多个子字符串,Substring 也会创建新字符串。热点解析代码可以先定位分隔符,再通过 Span 直接解析:

ReadOnlySpan<char> text = "user:10086";
int separator = text.IndexOf(':');

bool success = int.TryParse(
    text[(separator + 1)..],
    out int userId);

Span 适合解析和切片,但不应为了“零分配”强行进入所有普通业务代码。需要跨异步边界或长期保存数据时,还要改用 Memory<T> 或明确创建字符串。

3.2 避免连续 Replace 与 Trim

连续调用 ReplaceTrimToLower 等方法,会多次扫描输入并产生中间字符串。位于热点路径且输入较大时,可以考虑一次扫描完成过滤、大小写判断和输出。

但自定义单次扫描通常会增加代码复杂度。输入很短、调用频率低时,保留清晰的内置 API 更合理;是否值得合并,应同时测量耗时和分配量。

3.3 使用 SearchValues 复用查找集合

SearchValues<T> 适合反复使用同一组字符或字节进行搜索:

private static readonly SearchValues<char> SlugCharacters =
    SearchValues.Create(
        "abcdefghijklmnopqrstuvwxyz0123456789-_");

static bool IsValidSlug(ReadOnlySpan<char> value) =>
    !value.IsEmpty && value.IndexOfAnyExcept(SlugCharacters) < 0;

SearchValues 应创建一次并重复使用。如果每次调用都重新创建,初始化成本可能抵消搜索收益。只搜索一个字符时使用 IndexOf;只执行一次且字符很少时,直接使用相应的 IndexOfAny 重载通常更清晰。

4. 🧩 正则表达式只用于真正需要的场景

固定前缀、后缀、字符查找和简单分隔符解析,优先使用 StartsWithEndsWithIndexOfSearchValues。这些 API 的意图更直接,也省去了正则解析和匹配状态机的成本。

同一个正则会反复使用时,可以通过 GeneratedRegex 在编译期生成实现:

public static partial class Validators
{
    [GeneratedRegex("^[a-z0-9_-]+$", RegexOptions.CultureInvariant)]
    public static partial Regex SlugRegex();
}

面对外部输入时,还要关注灾难性回溯。模式兼容时可以考虑 RegexOptions.NonBacktracking;否则至少设置合理的超时时间。不要仅为了追求速度而改变正则语义,必须使用真实输入验证结果。

5. ⚡ UTF-8 数据尽量不要先转成 string

.NET 字符串使用 UTF-16,而网络协议、JSON 和许多文件格式使用 UTF-8。如果数据原本就是 UTF-8,并且下游 API 也支持字节输入,在中间转换成 string 会增加解码、编码和内存分配。

固定的 ASCII 或 UTF-8 内容可以直接使用 UTF-8 字面量:

ReadOnlySpan<byte> authorizationPrefix = "AUTH "u8;

JSON 需要直接得到 UTF-8 数据时,可以使用:

byte[] json = JsonSerializer.SerializeToUtf8Bytes(value);

高吞吐协议处理还可以使用 Utf8JsonReaderUtf8.TryWrite 以及接受 ReadOnlySpan<byte> 的解析 API。关键原则是让数据尽可能以原有编码在调用链中传递,避免 byte[] → string → byte[] 的往返转换。

6. 🧱 避免为了消费结果再创建完整字符串

6.1 使用 StringBuilder.GetChunks

如果下游能够分块接收字符,可以直接枚举 StringBuilder 的内部块,而不是先调用 ToString()

foreach (ReadOnlyMemory<char> chunk in builder.GetChunks())
{
    writer.Write(chunk.Span);
}

枚举期间不能继续修改 StringBuilder。如果调用方最终必须得到 string,直接调用 ToString() 更清晰。

6.2 复用 CompositeFormat

动态复合格式字符串会被高频重复使用时,可以缓存解析结果:

private static readonly CompositeFormat OrderFormat =
    CompositeFormat.Parse("订单 {0},金额 {1:F2}");

string message = string.Format(
    CultureInfo.InvariantCulture,
    OrderFormat,
    orderId,
    amount);

普通的编译期字符串插值不需要这样处理。CompositeFormat 主要适合格式模板来自配置、资源或其他运行时数据,并且同一个模板会重复使用的场景。

6.3 高性能日志避免提前构造字符串

日志级别关闭时,提前执行插值或格式化属于无效工作。使用结构化日志参数、LoggerMessage[LoggerMessage] 源生成器,可以把格式化推迟到确认需要写日志之后。

不要在调用日志 API 前手动执行 string.FormatToString() 或复杂插值。如果某个参数计算成本较高,还应先判断对应日志级别是否启用。

7. ⚠️ 谨慎使用驻留、缓存和对象池

7.1 string.Intern 不是通用去重方案

string.Intern 适合数量有限、重复率很高且生命周期明确的值。对用户输入、请求参数或其他无界数据使用驻留,会让大量字符串长期占用内存。

7.2 字符串缓存必须有边界

缓存格式化结果可以减少重复计算,但缓存键持续增长时,内存成本可能高于重新生成字符串。需要同时设计容量、过期策略和并发行为。

7.3 不要默认池化 StringBuilder

池化可以减少高频大对象构造,但也会带来重置、线程安全和大缓冲区长期保留等问题。只有基准测试确认 StringBuilder 分配是实际瓶颈时,才值得引入对象池。

8. ✅ 选择顺序

优化字符串代码时,可以按以下顺序判断:

  1. 是否已有语义直接的内置 API,例如插值、JoinIndexOfStartsWith
  2. 是否因为大小写转换、切片、拆分或编码转换产生了不必要的字符串。
  3. 是否在循环中重复追加、扫描或解析相同格式。
  4. 调用链能否直接消费 Span、UTF-8 字节或 StringBuilder 分块。
  5. 优化后的代码是否使用真实数据证明耗时或分配有所下降。

字符串优化的收益通常来自少分配、少复制、少扫描,而不是把所有代码改成更底层的写法。低频业务路径优先保证可读性,热点路径再根据基准结果逐步替换。