C# 中的字符串是不可变的:s += "x" 并不会延长 s,而是创建一个包含旧字符和新字符的新字符串,旧字符串就成了垃圾。做一次无所谓。但在执行几千次的循环中,每次迭代都要复制目前已构建的全部内容,所以总工作量会随长度的平方增长。
StringBuilder 位于 System.Text 中,是一个可变的缓冲区。Append 把内容写进末尾的空余空间,偶尔扩大缓冲区,最后由 ToString() 生成一个字符串。
输出:
Order #1042, total 59.97
Status: shipped
**********
51
为什么在循环中拼接很慢
下面用两种方式构建同样的文本。耗时因机器而异,所以下面的数字只是一个例子;重要的是差距,而且循环越大,差距越大:
示例输出:
string += 482 ms, length 188890
StringBuilder 1 ms, length 188890
追加 20,000 次时,+= 版本总共要复制将近 20 亿个字符。StringBuilder 对每个字符大约只复制两次,一次是追加时,另一次是在 ToString() 中,所以它的耗时一直在 1 毫秒左右。
Append、AppendLine 和 AppendFormat
Append 为每种内置类型都提供了重载,并且返回同一个构建器,所以可以链式调用。AppendLine 在参数后面添加一个换行符。AppendFormat 像 string.Format 一样接受复合格式字符串:
输出:
RECEIPT
-------
Espresso 2.40
Croissant 3.10
Orange juice 4.50
TOTAL 10.00
追加插值字符串(sb.Append($"..."))没有问题,读起来也清楚。在 .NET 6 及更高版本中,编译器甚至会把各个部分直接写入构建器,而不创建中间字符串。
AppendLine 使用 Environment.NewLine:在 Linux 和 macOS 上是 \n,在 Windows 上是 \r\n。当文件格式要求特定的换行符时,请自己追加。
Insert、Remove、Replace 和索引器
与字符串不同,StringBuilder 可以就地修改:
输出:
Hello, World
Hello, Maya
Maya
maya
maya!
[] length 0
把 Length 设为更小的值会截断内容;这是去掉末尾分隔符最省事的方法。Clear() 把长度设为 0,但保留已分配的缓冲区,所以一个构建器可以在外层循环的多次迭代中重复使用。
对于刚追加的内容,索引器很快;但在非常大的构建器中随机访问可能会很慢,因为内容存储在相互链接的块中。如果需要读取字符,请调用一次 ToString(),然后对字符串进行索引。
末尾分隔符问题
一个经典用法是构建逗号分隔的列表。在每一项后面都追加分隔符,结尾就会多出一个:
输出:
csharp, dotnet, linq
csharp, dotnet, linq
当各项已经在一个集合中时,string.Join 更简短,速度也不会更慢。把 StringBuilder 留给形状取决于逻辑的文本:条件性的段落、嵌套循环、混合的格式。
容量
StringBuilder 一开始有 16 个字符的空间。空间用完时,它会链接一个新的存储块,大小与它已存储的全部内容相同,每块最多 8,000 个字符,所以它在较小时容量成倍增长,之后以每次 8,000 个字符的幅度增长。增长时从不复制已经存储的文本。如果你大致知道结果有多长,就把容量传给构造函数,这样构建器就不需要增长:
输出:
12000
10000
True
True
预先设置大小是一种优化,而不是必需的。增长会分配一个新块,但不会移动已有的文本,所以除非你在热点路径上构建大量大字符串,否则默认值就足够了。
什么时候不该用 StringBuilder
StringBuilder 并不是每个 + 的更快替代品。编译器已经把单个表达式中的 a + b + c + d 变成了一次 string.Concat 调用,它会计算最终长度并把每一段只复制一次,这和构建器做的工作一样,代码却更少。以下情况请直接用字符串:
- 片段数量少且固定:
$"{first} {last} ({age})"。 - 片段在一个集合中,并且需要分隔符:
string.Join(", ", items)。 - 片段不需要分隔符:
string.Concat(parts)。 - 你只在循环之外拼接几次。
当你在循环中追加、追加的次数事先不确定,或者在构建过程中编辑文本(插入、替换、删除)时,请使用 StringBuilder。来回转换(在循环里调用 sb.ToString())会抵消它的好处;请只在最后调用一次。
流是另一种选择:要生成一个大文件,就边处理边把各行写入 StreamWriter,而不是在内存中构建全部内容。见 文件。
常见问题
C# 中的 StringBuilder 是什么?
System.Text.StringBuilder 是一个可变的字符缓冲区,用来分多步构建字符串。Append 把内容追加到缓冲区末尾,而不是每次都创建新字符串,最后由 ToString() 一次性生成最终的字符串。它是在循环中构建文本的标准工具。
什么时候应该用 StringBuilder 而不是字符串拼接?
当你在循环中追加,或者追加的次数事先不确定时,比如根据一个集合生成报表、CSV 文件或 HTML。如果只是固定的几段(a + b + c,或一个插值字符串),直接拼接同样快,也更易读,因为编译器已经把它合并成了一次 string.Concat 调用。
如何删除 StringBuilder 的最后一个字符?
减小它的 Length:if (sb.Length > 0) sb.Length--; 不需要复制就能去掉最后一个字符。sb.Remove(sb.Length - 1, 1) 效果相同。这是去掉末尾逗号的常用方法,不过 string.Join 从一开始就不会产生多余的分隔符。
StringBuilder 是线程安全的吗?
不是。多个线程同时对同一个 StringBuilder 调用 Append,可能会破坏其内容或抛出异常。请给每个线程一个自己的构建器,再合并结果,或者用 lock 保护共享的那一个。
C# 中的 AppendLine 会添加什么?
文本后面跟一个 Environment.NewLine,它在 Windows 上是 \r\n,在 Linux 和 macOS 上是 \n。如果输出必须使用特定的换行符,比如文件格式要求的 \n 或 HTTP 头要求的 \r\n,请改用 Append("\n") 显式追加。