字符串不就是一串字符吗,能有多少花样?实际上,字符串作为引用类型,却常常表现出值类型的"错觉",在拼接、比较、内存开销等方面藏着不少坑。这篇就来聊聊 C# 中的 string,从本质到常用骚操作,帮你把字符串用得明明白白。
- 字符串的本质:它到底是值类型还是引用类型?
- 不可变性与字符串驻留:为什么修改字符串会创建新对象?
- 常用操作与最佳实践:拼接、比较、格式化怎么选?
- 性能陷阱与优化 :什么时候该用
StringBuilder? - 字符串与 Span :新一代高性能字符串处理方式
一、字符串的本质
1.1 什么是字符串
在 C# 中,string 是 System.String 的别名,表示一个 不可变的字符序列。它虽然是引用类型,但设计上模仿了值类型的语义(如比较相等性时比较内容而非引用)。
csharp
string greeting = "Hello, World!";
Console.WriteLine(greeting.Length); // 13
代码解析:
- string :定义一个字符串变量,本质是
System.String类的实例。 - "Hello, World!" :字符串字面量,编译器会将其放入字符串驻留池。
- Length :字符串的长度属性,表示字符数(注意
string的索引是基于Char的,对于包含代理项的 Unicode 字符,一个"字符"可能占两个Char)。
1.2 字符串是引用类型,但行为像值类型
字符串属于引用类型,分配在堆上,变量存储的是引用。但它重写了 Equals 方法,让比较时比较内容。此外,字符串的不可变性使得它经常被当作值类型来使用。
csharp
string a = "hello";
string b = "hello";
Console.WriteLine(object.ReferenceEquals(a, b)); // True(因为字符串驻留)
Console.WriteLine(a == b); // True
核心知识点 :字符串比较时,
== 运算符被重载为比较内容,而ReferenceEquals 比较引用。由于 字符串驻留 机制,相同字面量的字符串常常指向同一块内存。
二、字符串的不可变性
2.1 为什么字符串不可变?
字符串一旦创建,其内部的字符数组就不能被修改。任何修改操作(如 ToUpper、Replace、拼接)都会生成一个新的字符串对象,而原字符串保持不变。
csharp
string original = "hello";
string modified = original.ToUpper();
Console.WriteLine(original); // "hello"
Console.WriteLine(modified); // "HELLO"
Console.WriteLine(object.ReferenceEquals(original, modified)); // False
划重点: 不可变性保证了字符串是线程安全的,多个线程可以共享同一个字符串实例,无需担心数据被意外修改。
2.2 字符串驻留(String Interning)
公共语言运行时(CLR)维护一个字符串驻留池,对于编译时确定的字面量字符串,会自动驻留。运行时可手动调用 string.Intern 将字符串放入池中。
csharp
string s1 = "Hello";
string s2 = "Hello";
string s3 = new string("Hello".ToCharArray());
Console.WriteLine(ReferenceEquals(s1, s2)); // True(编译时驻留)
Console.WriteLine(ReferenceEquals(s1, s3)); // False(s3是动态创建的字符串)
s3 = string.Intern(s3);
Console.WriteLine(ReferenceEquals(s1, s3)); // True(手动驻留后相同)
常见坑: 大量动态拼接的字符串如果不手动驻留,会占用过多内存。适时使用 string.Intern 可以减少重复字符串的内存开销,但过度使用反而会增加驻留池的压力。
三、字符串常用操作
3.1 拼接
| 方式 | 特点 | 适用场景 |
|---|---|---|
+ 运算符 |
简单直接,每次拼接创建新对象 | 少量拼接(< 5次) |
string.Concat |
内部使用 StringBuilder 优化?不,直接拼接 |
明确知道拼接个数时 |
string.Format |
格式字符串,可读性好 | 带格式化的拼接 |
$ 字符串插值 |
语法糖,编译后等同 string.Format |
C# 6.0 起推荐 |
| StringBuilder | 可变缓冲区,频繁修改性能高 | 循环中大量拼接 |
csharp
// 少量拼接,用插值
var msg = $"Hello, {name}! You have {count} messages.";
// 循环拼接,用 StringBuilder
var sb = new StringBuilder();
foreach (var item in items)
{
sb.Append(item).Append(", ");
}
string result = sb.ToString().TrimEnd(',', ' ');
3.2 比较
推荐使用 string.Equals 或静态方法,并指定 StringComparison 选项。
csharp
string a = "hello";
string b = "HELLO";
bool ignoreCase = a.Equals(b, StringComparison.OrdinalIgnoreCase);
bool exact = string.Compare(a, b, StringComparison.Ordinal) == 0;
划重点: 在.NET Core/.NET 5+ 中,默认的 == 比较采用 序数比较 (Ordinal),行为与 StringComparison.Ordinal 一致。但在 .NET Framework 中默认使用当前区域性进行语言比较,可能导致意外的排序结果。始终显式指定比较模式,以避免跨平台差异。
3.3 格式化
string.Format 和字符串插值支持复合格式,包括对齐和格式化字符。
csharp
double price = 123.456;
Console.WriteLine($"Price: {price,10:C2}"); // 右对齐,宽度10,货币格式
// 输出:Price: ¥123.46
四、性能陷阱与优化
4.1 循环中的字符串拼接
每次用 + 拼接都会创建新字符串,在循环中会引发严重的性能和内存问题。
csharp
// 错误做法
string s = "";
for (int i = 0; i < 100000; i++)
{
s += i.ToString(); // 每次循环都分配新字符串
}
// 正确做法
var sb = new StringBuilder();
for (int i = 0; i < 100000; i++)
{
sb.Append(i);
}
string result = sb.ToString();
【提示】 少量拼接(比如 3-5 次)直接用插值或
+,编译器会优化成string.Concat,性能不差。但一旦进了循环,务必用StringBuilder,否则垃圾回收(GC)会教你做人。
4.2 字符串的截取与内存
Substring 方法在 .NET Framework 中返回的字符串与原字符串共享内部的字符数组,可能导致大对象被长期引用。但在 **.NET Core / .NET 5+ ** 中,Substring 每次都会创建新的字符数组,不再共享内存。
csharp
string filePath = @"C:\Users\Alice\Documents\report.docx";
// .NET Core 中,Substring 会分配新内存
string fileName = filePath.Substring(filePath.LastIndexOf('\\') + 1);
4.3 使用 Span<char> 和 ReadOnlySpan<char> 处理字符串
当需要在不分配堆内存的情况下对字符串进行切片、解析时,Span<T> 是神器。
csharp
ReadOnlySpan<char> span = "Hello, World".AsSpan();
ReadOnlySpan<char> slice = span.Slice(0, 5); // 指向 "Hello",无堆内存分配
Console.WriteLine(slice.ToString()); // 但调用 ToString 还是会在堆上分配字符串
划重点: Span<T> 可以避免大量临时字符串的分配,尤其在解析配置文件、日志行等场景下性能飞跃。但它只能在栈上使用,不能作为类的字段。
五、常用字符串方法一览
| 方法 | 功能 | 是否修改原字符串 |
|---|---|---|
Length |
获取字符数 | 只读属性 |
ToUpper()/ToLower() |
大小写转换 | 返回新字符串 |
Trim() |
移除首尾空白 | 返回新字符串 |
Split(char[]) |
分割字符串 | 返回字符串数组 |
Substring(int, int) |
提取子字符串 | 返回新字符串 |
Replace(string, string) |
替换子串 | 返回新字符串 |
Contains(string) |
判断是否包含子串 | 只读取 |
IndexOf(string) |
查找子串位置 | 只读取 |
常见坑: Replace 和 Trim 等返回新字符串,如果忘了接收返回值,原字符串不会改变。
csharp
string text = " hello ";
text.Trim(); // 错误!原字符串不变
string trimmed = text.Trim(); // 正确,用 trimmed 接收新值
六、字符串与编码
C# 的 string 内部使用 UTF-16 编码存储,即每个字符至少占 2 个字节。当与外部二进制数据交互时,需要使用 Encoding 类进行转换。
csharp
string text = "Hello, 世界";
byte[] utf8Bytes = Encoding.UTF8.GetBytes(text);
string decoded = Encoding.UTF8.GetString(utf8Bytes);
性能注意: Encoding.GetBytes 和 GetString 涉及内存分配,对于高吞吐场景,可以使用 Encoding.GetBytes 的重载,将数据写入 Span<byte> 缓冲区。
七、总结
字符串虽然简单,但用不好就会成为性能瓶颈。记住几个核心原则:
- 不可变性是双刃剑:保证了线程安全,但拼接会生成大量临时对象。
- 少量拼接用插值,循环拼接用
StringBuilder。 - 比较字符串时务必指定
StringComparison,避免跨平台行为差异。 - 在 .NET Core / .NET 5+ 中,
Substring 不再共享内存,可以放心使用。 - 善用
Span<T> 实现零分配的字符串切片和解析。
最后:
字符串是门大学问,搞清楚了不可变性和驻留机制,能帮你避开大部分坑。下次遇到字符串拼接变慢,别犹豫,换上 StringBuilder 再说。