字符串

字符串是C#中使用最频繁的数据类型之一,几乎所有程序都离不开字符串的处理。C#中字符串对应的类型是string,它是System.String的别名,就像int是System.Int32的别名一样,这在数据类型章节中我们已经简单介绍过。这篇笔记我们系统梳理字符串的定义方式、不可变性、常用操作、比较与格式化,以及用于高性能拼接的StringBuilder。

字符串的不可变性

string是引用类型,但它的内容是不可变的(immutable)。这意味着一旦字符串对象创建完成,其中的字符就再也不能被修改。字符串类型上所有看起来像“修改”的方法,如Replace()、ToUpper()等,实际上都不会改变原字符串,而是返回一个全新的字符串对象。

string s = "hello";
string t = s.ToUpper();
Console.WriteLine(s); // hello,原字符串并没有被改变
Console.WriteLine(t); // HELLO,得到的是一个新字符串

由于字符串不可变,我们也不能通过下标去修改其中的某个字符,下面这种写法是无法通过编译的。

string s = "hello";
s[0] = 'H'; // 编译报错,字符串的索引器是只读的

不可变性带来两个好处:一是字符串可以安全地被多个地方共享引用,不必担心被意外改动;二是编译器会对字符串字面量进行驻留(interning),相同的字面量在内存中只保存一份。当然,代价则是每次“修改”字符串都要产生新对象,这也导致了在循环中大量拼接字符串性能较差的问题,这种情况应当改用后面介绍的StringBuilder。

字符串字面量

普通字符串

最常见的写法是用双引号包裹,内部可以使用转义序列。

string a = "hello";
string b = "第一行\n第二行";   // \n 换行
string c = "制表\t符";         // \t 制表符
string d = "他说:\"你好\"";    // \" 表示一个双引号
string e = "路径反斜杠\\";      // \\ 表示一个反斜杠

常用的转义序列包括\n(换行)、\r(回车)、\t(制表符)、\\(反斜杠)、\"(双引号)以及\uXXXX(Unicode字符)。

逐字字符串(@"")

如果字符串中大量出现反斜杠(例如Windows路径、正则表达式),逐个转义会非常麻烦。此时可以使用@前缀声明逐字字符串(verbatim string),其中的反斜杠不再作为转义字符,而是按字面量处理;如果确实需要表示双引号,则用两个连续的双引号""。

string path = @"C:\Users\Tom\Documents\file.txt";
string pattern = @"\d{3}-\d{4}";
string quote = @"He said ""hi""";

原始字符串字面量(C# 11)

C# 11(.NET 7)进一步引入了原始字符串字面量语法,它使用三个及以上的双引号"""包裹,适合书写包含大量引号和反斜杠的文本,也支持多行内容。

string raw = """
    This is a "raw" string.
    Path: C:\temp
    """;

字符串插值

把变量拼接进字符串时,最常用的是字符串插值语法,即在字符串前加$,并在{}中书写表达式。

string name = "Tom";
int age = 18;
Console.WriteLine($"I am {name}, {age} years old.");

{}中不仅可以放变量,还可以放任意表达式,甚至指定格式化格式(格式说明符详见后文)。

double price = 12.5;
Console.WriteLine($"总价:{price * 2:C}"); // 依据当前区域设置格式化为货币

插值字符串也可以和逐字字符串组合使用,$@"..."和@$"..."两种写法等价,适合拼接路径。

string path = $@"C:\Users\{name}\file.txt";

在C# 6.0的插值语法出现之前,开发者通常使用静态方法string.Format()配合占位符{0}、{1}来完成同样的工作,如今它仍然常见于一些老代码和日志框架中,我们了解即可,现在已经没必要这样写了。

string s = string.Format("{0} is {1}", "Tom", 18);

常用成员

字符串类型提供了大量实用的属性和方法,下面整理其中常见的部分。

成员 说明
Length 字符串中字符的个数
this[int index] 索引器,读取指定位置的字符(只读)
Substring(start) / Substring(start, length) 截取子串
IndexOf / LastIndexOf 查找子串首次 / 末次出现的位置,找不到返回-1
Contains 是否包含指定子串
StartsWith / EndsWith 是否以指定子串开头 / 结尾
Replace 替换子串
Trim / TrimStart / TrimEnd 去除两端 / 开头 / 结尾的空白字符
ToUpper / ToLower 转换为大写 / 小写
Split 按分隔符拆分为字符串数组
Insert / Remove 在指定位置插入 / 移除字符
PadLeft / PadRight 在左 / 右侧填充字符到指定长度
ToCharArray 转换为字符数组
string.Join 静态方法,用分隔符连接字符串数组
string.IsNullOrEmpty / string.IsNullOrWhiteSpace 静态方法,判断是否为null或空 / 空白
string s = "hello world";
Console.WriteLine(s.Length);              // 11
Console.WriteLine(s[0]);                  // h
Console.WriteLine(s.Substring(6));        // world
Console.WriteLine(s.IndexOf("world"));    // 6
Console.WriteLine(s.Contains("wor"));     // True
Console.WriteLine(s.Replace("world", "C#")); // hello C#
Console.WriteLine("  hi  ".Trim());       // hi

结合Split和Join可以方便地处理分隔符文本。

string csv = "apple,banana,pear";
string[] parts = csv.Split(',');
string joined = string.Join(" | ", parts);
Console.WriteLine(joined); // apple | banana | pear

再次强调,上面这些方法都不会改变原字符串,而是返回新字符串。此外,判断字符串是否为空时推荐使用string.IsNullOrEmpty()或string.IsNullOrWhiteSpace(),它们可以安全地处理null,避免空引用异常。

string? s = null;
if (string.IsNullOrEmpty(s)) { /* ... */ }
if (string.IsNullOrWhiteSpace(s)) { /* ... */ }

字符串比较

string类型重载了==运算符,因此对两个字符串使用==和Equals()比较的都是字符串内容而非引用地址,这一点和普通引用类型不同。

string a = "hello";
string b = "hel" + "lo";
Console.WriteLine(a == b);            // True,比较内容
Console.WriteLine(a.Equals(b));       // True
Console.WriteLine(object.ReferenceEquals(a, b)); // 取决于字面量驻留,不要依赖

如果需要忽略大小写,或需要指定区域文化相关(culture-aware)的比较规则,应当使用带StringComparison参数的重载。

Console.WriteLine(string.Equals("ABC", "abc", StringComparison.OrdinalIgnoreCase)); // True

string.Compare()和实例方法CompareTo()则返回一个整数表示大小关系:小于为负数、等于为0、大于为正数,它常用于排序。

int r = string.Compare("apple", "banana"); // 负数

注:日常判断相等用==即可,只有在需要忽略大小写、指定排序规则或进行文化敏感比较时,才需要显式使用StringComparison。

字符串格式化

格式化可以控制数字、日期等如何转换成字符串,插值字符串和string.Format()都支持在占位符中通过冒号指定格式说明符。

Console.WriteLine($"{1234567.891:N2}"); // 1,234,567.89   千分位、保留两位小数
Console.WriteLine($"{0.876:P1}");       // 87.6%          百分比
Console.WriteLine($"{255:X}");          // FF             十六进制
Console.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss}");

常用的数值格式说明符有N(千分位)、C(货币)、P(百分比)、F(定点小数)、D(整数位数)、X(十六进制)等;日期则常用yyyy、MM、dd、HH、mm、ss等占位。任何实现了IFormattable接口的类型都可以接受格式说明符,我们也可以调用对象的ToString(格式)方法手动格式化。

与字符、数值、编码的转换

字符串和字符数组之间可以互相转换,也可以直接使用foreach遍历其中的每个字符。

string s = "abc";
foreach (char c in s)
{
    Console.WriteLine(c);
}
char[] chars = s.ToCharArray();
string back = new string(chars);

字符串和数值之间的转换在数据类型章节已经介绍过,主要使用Parse()、Convert类和TryParse()方法。

int i = int.Parse("123");
if (int.TryParse("123", out int k)) { /* ... */ }
string numStr = 123.ToString();

字符串和字节数组之间的转换则依赖编码,例如UTF-8编码,这在IO章节读写文件时会用到。

byte[] bytes = System.Text.Encoding.UTF8.GetBytes("你好");
string text = System.Text.Encoding.UTF8.GetString(bytes);

StringBuilder

前面我们一直在强调字符串不可变,每次拼接都会产生新的字符串对象,如果在循环中进行大量拼接,中间会产生大量临时对象,既浪费内存又拖慢性能。这种情况我们不应直接拼接字符串,而应当使用StringBuilder。StringBuilder内部维护一个可变的字符缓冲区,追加操作直接在缓冲区上进行,避免了反复创建字符串。

using System.Text;

StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++)
{
    sb.Append(i).Append(',');
}
string result = sb.ToString();

StringBuilder的常用成员包括Append()、AppendLine()、AppendFormat()、Insert()、Remove()、Replace()、Clear()、Length属性、索引器以及最终用于取出结果的ToString()。其中Append()等方法会返回StringBuilder自身,因此可以像上面那样链式调用。

不过也要注意,StringBuilder并非用得越多越好。对于少量、固定次数的拼接(例如拼两三个字符串),直接使用+或字符串插值更简洁,编译器也会进行优化;只有在循环拼接或大量拼接的场景下,StringBuilder的性能优势才明显。

注:Java中同样有StringBuilder(以及线程安全的StringBuffer),C#的StringBuilder与Java的StringBuilder作用类似。C#中没有对应的线程安全版本,多线程场景下需要自行加锁。

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。