C# 基础数据类型:字符串类型

字符串不就是一串字符吗,能有多少花样?实际上,字符串作为引用类型,却常常表现出值类型的"错觉",在拼接、比较、内存开销等方面藏着不少坑。这篇就来聊聊 C# 中的 string,从本质到常用骚操作,帮你把字符串用得明明白白。

  1. 字符串的本质:它到底是值类型还是引用类型?
  2. 不可变性与字符串驻留:为什么修改字符串会创建新对象?
  3. 常用操作与最佳实践:拼接、比较、格式化怎么选?
  4. 性能陷阱与优化 :什么时候该用 StringBuilder
  5. 字符串与 Span :新一代高性能字符串处理方式

一、字符串的本质

1.1 什么是字符串

在 C# 中,string​ 是 System.String​ 的别名,表示一个 不可变的字符序列。它虽然是引用类型,但设计上模仿了值类型的语义(如比较相等性时比较内容而非引用)。

csharp 复制代码
string greeting = "Hello, World!";
Console.WriteLine(greeting.Length); // 13

代码解析:

  1. string :定义一个字符串变量,本质是 System.String 类的实例。
  2. "Hello, World!" :字符串字面量,编译器会将其放入字符串驻留池。
  3. 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 再说。

相关推荐
BingoGo14 小时前
TrueAsync Server 为 PHP 带来了原生的高性能 HTTP 服务器
后端·php
GISer_Jing14 小时前
现代分布式系统架构全链路解析
后端·架构
我是一颗柠檬14 小时前
【MySQL全面教学】MySQL聚合函数与分组Day5(2026年)
数据库·后端·mysql·database
星栈独行14 小时前
别让 API 跳去登录页:我在 Axum 里做了认证失败双通道
前端·后端·rust·开源·github·个人开发
JaguarJack14 小时前
TrueAsync Server 为 PHP 带来了原生的高性能 HTTP 服务器
后端·php
字节高级特工15 小时前
Redis事务:简单但实用的打包执行
数据库·redis·后端·缓存
极客小云15 小时前
【用 Go 写一个统一的 LLM Token 统计库:tokencalc 的设计与实现】
开发语言·后端·golang
Vect__15 小时前
C++转go的之路:变量声明、iota、函数、切片、init、defer
开发语言·后端·golang
fengxin_rou15 小时前
【SpringBoot+Elasticsearch 内容搜索系统实战】:架构设计与全流程实现
spring boot·后端·elasticsearch