首页 / C# 入门教程 / 字符串与字符集合

C# 入门教程

字符串与字符集合

本教程共 100 篇 · 第 27 篇 · 更新于 2026-07-31 · 约 8 分钟阅读

C#C# 入门教程编程语言stringSpan

27. 字符串与字符集合

本节目标:理解 string 不可变的本质,学会用 ToCharArray 拿到可变字符,并初识 Span 的性能价值。

string 是不可变的

字符串(string)一旦创建,内部的字符序列就固定了。像 ToUpperReplaceSubstring 这类”看起来在改字符串”的方法,其实都返回了一个全新的字符串,原来的那个原封不动。

namespace CSharpDemo;

string name = "Alice";
string upper = name.ToUpper();

Console.WriteLine(name);   // Alice(原串没变)
Console.WriteLine(upper);  // ALICE(这是新串)
Note

不可变带来一个关键好处:字符串可以安全地在多个线程、多处代码间共享,谁都不必担心它被偷偷改掉。这也是为什么 string 用起来像值类型、本质上却是引用类型(reference type)。

为什么设计成不可变

如果字符串可变,会引出一连串麻烦:两个地方引用同一串,一处改了另一处跟着变;做哈希(hash)时内容一变,之前算的哈希就失效;作为字典键(key)时更是灾难。把它设成”改 = 生成新串”,就从根本上避免了这些隐患。

代价是:每次”修改”都要分配新内存,频繁改动时会有性能开销——这正是后面要讲的 StringBuilderSpan<char> 存在的理由。一句话总结:用一点性能,换来了安全和简单。

字符串驻留(interning)一谈

为了省内存,.NET 对相同的字符串字面量会做”驻留”:内容相同的常量字符串在内存里可能只存一份。这依赖的底层前提正是”不可变”——既然改不了,放心共享才安全。

Tip

普通业务代码不用关心驻留。只知道”相同字面量可能共享”即可,别依赖它做相等判断(相等请用 ==Equals,它们比的是内容)。

循环里拼接要小心

在循环里用 + 反复拼字符串,每次 += 都生成新串、丢弃旧串,既慢又产生大量垃圾。

namespace CSharpDemo;

string result = "";
for (int i = 0; i < 1000; i++)
{
    result += i;   // 每次都新建字符串,低效
}
Console.WriteLine(result.Length);
Warning

上百次、上千次循环里用 += 拼字符串,性能会明显变差,还会给垃圾回收器(GC)添负担。这种场景必须换方案,别等程序变慢才回头查。

大量拼接用 StringBuilder

StringBuilder 在一个可变的缓冲区上操作,追加时尽量不新建字符串,最后 ToString() 一次性生成结果。

namespace CSharpDemo;

var sb = new System.Text.StringBuilder();
for (int i = 0; i < 1000; i++)
{
    sb.Append(i);
}
string result = sb.ToString();
Console.WriteLine(result.Length);
Tip

经验法则:拼接次数很少(两三次)用 + 没问题;在循环里、或次数不确定时,直接用 StringBuilder。一条简单的判断:会不会”反复往里加”——会,就用它。

真要改字符:ToCharArray

想要原地修改某个字符,先用 ToCharArray 把字符串拆成 char 数组,改完再用 string 构造函数拼回去。

namespace CSharpDemo;

string name = "hello";
char[] chars = name.ToCharArray();
chars[0] = 'H';
string fixedName = new string(chars);

Console.WriteLine(fixedName);  // Hello
Note

ToCharArray 返回的是一份副本。你改的是副本,name 依旧是 “hello”。这一处再次印证:string 不可变,谁都改不动它本体,要改只能造新串。

索引取字符与 UTF-16 的坑

字符串能像数组一样用索引取单个 char,但 char 是 UTF-16 的一个”码元(code unit)“,并不一定等于一个完整字符。

namespace CSharpDemo;

string s = "码上学";
Console.WriteLine(s[0]);      // 码
Console.WriteLine(s.Length);  // 3

大部分中英文一个 char 就够,但某些字符(如部分 emoji、生僻字)要用两个 char(代理对,surrogate pair)表示。这时 s.Length 数的是码元个数,不是你眼里看到的”字形”个数。

Warning

处理可能含 emoji 或生僻字的国际化文本时,别简单按 char 遍历或切片,否则可能把一个字符从中间劈开。要正确按”字符”处理,可用 Rune 类型(.NET 提供的完整 Unicode 标量视图)逐个字素遍历,而不是按 char

性能利器:Span

Span<char> 是一段连续内存的”视图(view)“,不复制数据就能切片、读取。它特别适合解析字符串的局部,比如取前若干字符:

namespace CSharpDemo;

string text = "hello world";
ReadOnlySpan<char> span = text.AsSpan(0, 5);

Console.WriteLine(span.ToString());  // hello

AsSpan 不复制整个字符串,只是”借看”其中一段。C# 14 还进一步丰富了 Span<char> 上的隐式转换,用起来更顺手。

Note

Span<T>ref struct,约束很严:它不能装箱、不能当作异步方法的返回值、不能放进堆上的容器(如 List<Span<T>>)。它专为”方法内部、短期、高性能”的操作设计。初学阶段认识它、知道有这回事即可,不必深究。

三者怎么选

场景用什么
少量、偶发的修改直接接收方法返回的新串(如 Replace
循环里反复拼接StringBuilder
原地改个别字符ToCharArray 改完合成新串
高性能解析/切片,不复制Span<char> / ReadOnlySpan<char>
Tip

普通业务代码很少需要碰 char 数组或 Span。真正重要的认知只有两条:string 不可变,以及”循环里别用 + 拼”。掌握这两点,字符串操作基本不会踩坑。

常见坑

  1. 以为方法改了原串ReplaceToUpper 都返回新串,忘了接住返回值,原串不变。
  2. 循环里 += 拼串:性能陷阱,换 StringBuilder
  3. char 切国际化文本:emoji 会断,必要时用 Rune
  4. 误以为 Length 是字数:它数的是 UTF-16 码元,emoji 算两个。

最佳实践

  • 把字符串当成”只读”来用,需要新内容就接收方法返回的新串。
  • 拼接次数不定或很多,果断上 StringBuilder
  • 做字符串解析的高性能需求时,考虑 AsSpan 避免复制。
  • 做相等判断用 ==,别依赖驻留或引用相等。

为什么不可变对字典键很重要

字符串常用作字典(Dictionary)的键。如果字符串可变,某天改了键的内容,而字典内部按内容算的哈希就失效了,原来存的值会找不到。不可变保证了”键从创建到销毁内容恒定”,所以放心拿字符串当键。

namespace CSharpDemo;

Dictionary<string, int> scores = [];
scores["小明"] = 88;
Console.WriteLine(scores["小明"]);  // 88
Note

这里顺手用集合表达式 [] 创建空字典,和上一章呼应。字符串作键时相等性按内容比较,不按引用,所以两个不同变量只要内容相同就是同一个键。

比较字符串的小提醒

判断相等用 == 即可,它会比内容。大量比较、要忽略大小写时,用 string.Equals(a, b, StringComparison.OrdinalIgnoreCase),比手动 ToUpper 后比较更规范。

不可变不等于”不能重新赋值”

要分清两件事:string s = "a"; s = "b"; 是合法的——你把变量 s 指向了另一个字符串对象,原来的 “a” 没被改,只是 s 不再引用它。不可变指的是”字符串对象本身的内容不可改”,不是”变量不能换指向”。

Note

这点常被初学者搞混。变量是引用、字符串对象才是数据;重新赋值换的是引用,破坏不了任何已有字符串。

小结

string 不可变是为了安全和共享,代价是修改要生成新串。少量修改直接接收新串,大量拼接用 StringBuilder,原地改字符用 ToCharArray,极致性能看 Span<char>。理解了”不可变”这个根,其余技巧都顺理成章。下一章我们正式进入面向对象,看看如何用类把数据和行为打包成对象。