字符串
字符串是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#中没有对应的线程安全版本,多线程场景下需要自行加锁。