2.1 字符串操作
字符串在内存中是一个由字符数组组成的数据结构。字符串操作是最基础的应用。我们已经见过其中一个应用——在命令行中打印一条信息。字符串有以下几个特性。
1)它是引用类型,因此可以用null给它赋值。
2)字符串是不可变的,因此不能在代码中更改字符串里的内容。无论是增加还是删除字符,实际上是在原先的字符串基础上重新创建一个字符串实例,而老的字符串会等到没有代码引用后,由CLR的垃圾回收模块清理。
了解这几个基本特性后,接下来我们看一下字符串的常用操作。
2.1.1 格式化字符串输出
前面章节中的很多例子都使用Console.WriteLine方法输出内容。WriteLine有多种重载方法,其中一个重载方法的定义如下:
public static void WriteLine (string format, params object[] arg);
这个重载方法很典型。
- 第一个参数format是一个字符串,其指定了输出字符串的格式样本,里面通过嵌入“{0}”占位并使用arg数组的值填充,中间的0是参数在arg数组的索引号。
- 第二个参数arg是一个不定长参数数组,其长度必须和format参数中占位符的最大索引值匹配,否则会抛出InvalidOperationException异常。
Console.WriteLine方法内部实际上是调用了String.Format方法来格式化字符串,这两个方法接收的参数是一样的。format参数的格式如下。
{index[,alignment][:formatString]}
format参数说明如下。
- index:指明arg参数中用来替换格式占位符的索引,是format参数中必备的部分。如果对应的参数值为null,则使用空字符串填充。
- alignment:可选部分,是一个整数值,用来处理对齐,指明填充占位符的总长度,如果对应的arg参数中字符串长度不够,则用空格填充。正数表示右对齐,负数表示左对齐。
- formatString:可选部分,指定对应的arg参数中字符串展现形式,默认使用的是object.ToString方法输出字符串。如果指定了formatString部分,则使用IFormattable.ToString(string, IFormatProvider)重载方法,也就是说只要类型实现了IFormattable接口,都可以通过这个方法自定义格式。.NET自带的格式字符串的类型可参考表2-1。
表2-1 .NET自带的格式字符串的类型
代码清单2-1列举了几种常见类型的格式化方式,其中第9行使用DateTime类型的ToString方法得到类型的默认字符串输出格式,其余使用DateTime类型的IFormattable接口中的ToString(String,IFormatProvider)方法得到类型的不同输出格式。下面看一下清单2-1中的格式处理。
1)第13行演示了分别打印DateTime的日期和时间,第18行则演示了对齐打印的调用方法。
2)第25行使用yyyy自定义日期打印格式,指明只打印日期的年份部分。数字8表示占用8个字符,不足的部分使用空格补齐。12:N2表示结果占用12个字符,N表示打印整数和小数、组分隔符和小数分隔符,2则是精度说明符。数字123456的格式化结果是123,456.00。14:P1中的P表示打印结果是百分比,1是精度,说明只保留1位小数。
3)第29行和第31行分别演示了直接调用IFormattable接口中的ToString方法,第29行与第25行一样也采用了N2格式。123.456的打印结果是123.46,说明String.Format里的formatString部分是直接传递给IFormattable.ToString方法的,另外可以看到输出时会自动对数字执行四舍五入操作。
4)一些特殊字符在C#字符串中的转义方式与C/C++等语言类似,如第35行中的“\n”转义字符表示输出一个换行符,由于大括号“{}”在String.Format中是参数占位符的标志,因此单独输出大括号字符时需要使用额外的大括号字符来转义,如第35行中的“{{”和“}}”,输出转义字符“\”也是类似的,要使用“\\”进行输出。
5)第36行演示了C#提供的一个语法糖,但字符串的前缀是“@”字符时,可以将“\”作为普通字符串输出。
代码清单2-1 String.Format使用示例
1 // 源码位置:第2章\StringFormatDemo.cs 2 // 编译命令:csc StringFormatDemo.cs 3 using System; 4 5 class StringFormatDemo 6 { 7 static void Main() 8 { 9 var str = String.Format( 10 "当前时间:{0}, 温度是:{1}°C。", DateTime.Now, 24.5); 11 Console.WriteLine(str); 12 Console.WriteLine( 13 "当前日期:{0:d},时间:{0:t}", DateTime.Now); 14 15 int[] years = { 2013, 2014, 2015 }; 16 int[] population = { 1025632, 1105967, 1148203 }; 17 Console.WriteLine( 18 "{0,6}{1,15}", "Year", "Population"); 19 for (int i = 0; i < years.Length; i++) 20 Console.WriteLine( 21 "{0,6}{1,15:N0}", 22 years[i], population[i]); 23 24 Console.WriteLine(""); 25 Console.WriteLine("{0, -12}{1,8:yyyy}{2,12:N2}{3,14:P1}", 26 "BeiJing", DateTime.Now, 123456, 0.32d); 27 28 // 标准数字格式 29 Console.WriteLine(123.456m.ToString("N2")); 30 // 自定义数字格式 31 Console.WriteLine(123.4.ToString("00000.000")); 32 33 char c1 = '{', c2 = '}'; 34 Console.WriteLine( 35 "打印大括号用法相同:\n {{ 和 }}\n {0} 和 {1}", c1, c2); 36 var path1 = @"c:\china-pub\C#\sample-code"; 37 var path2 = "c:\\china-pub\\C#\\sample-code"; 38 Console.WriteLine( 39 "两个路径是相同的:\n{0}\n{1}", path1, path2); 40 } 41 }
2.1.2 $符号:字符串内插
String.Format方法要求在格式化模板里采用索引的方式指明参数的位置。从C# 6.0开始添加了字符串内插的语法糖,有点类似Perl、PHP甚至Bash这些脚本语言的字符串格式化方法。当字符串使用“$”作为前缀时,可以实现与String.Format同样的效果。而且formatString里的参数占位符索引可以直接用参数本身,或者使用一个合法的C#表达式来替代。
代码清单2-2中第10行演示了字符串内插的基本用法,既可以在大括号中直接使用参数输出,也可以直接使用结构体和类型的字段。第14行演示了“$”和“@”两个前缀字符可以混用。第17行演示了内插表达式的用法,只要是合法的表达式都允许,包括三目运算符等看起来比较复杂的表达式,不过要求表达式的计算结果是一个变量。
代码清单2-2 字符串内插示例
1 // 源码位置:第2章\StringInterpolationDemo.cs 2 // 编译命令:csc StringInterpolationDemo.cs 3 using System; 4 5 class StringInterpolationDemo 6 { 7 static void Main() 8 { 9 var degree = 24.5d; 10 var str = $"当前时间:{DateTime.Now}, 温度是:{degree}°C。"; 11 Console.WriteLine(str); 12 13 var lang = "C#"; 14 str = $@"c:\china-pub\{lang}\sample-code"; 15 Console.WriteLine(str); 16 17 Console.WriteLine($"使用表达式:{degree * 2 / 3}"); 18 } 19 }
2.1.3 字符串比较
C#中字符串类型是引用类型,也就意味着使用“==”操作符应该是对比两个变量的引用是否相同,而不是对比两个变量实际的值是否相同。但如果运行代码清单2-3的代码,会发现第16行和第17行的对比是按值比较的,而不是按引用比较的,这是因为String类型重载了“==”操作符,具体可以参见.NET String类型的源码[1]。
与大部分语言类似,C#字符串也支持互相比较——String.Compare方法可用于比较两个字符串的大小,当第1个字符串大于第2个字符串时,返回值大于0;当第1个字符串小于第2个字符串时,返回值小于0;当两个字符串相同时,返回值为0。
对于有大小写字母的字符,如英文,String.Compare支持忽略大小写进行字符对比,如代码清单2-3中的第19~21行演示了Compare的用法。同时,C#中的字符串是基于Unicode编码的,因此除了包含ANSI字符以外,还可以容纳全球大部分语言文化的文字,而不同文化对相同字符有着不同的比较方法和理解。
随着国内IT公司纷纷出海,国际化问题越来越受到重视。.NET框架内置了丰富的国际化支持方法。Compare方法就有一个接收CultureInfo类型参数的方法重载,这个类型参数可以根据具体的文化和区域设置来比较两个字符串。如代码清单2-3中第23~28行的三种比较,运行程序会发现第26行使用zh-CN(即中文简体文化设置)、第28行使用en-US(即美国英文的文化设置)比较相同的两个字符串,第26行比较的结果是–1,第28行比较的结果则是1。而第24行采用的是无CultureInfo参数的重载版本,采用操作系统默认的区域文化设置做比较,如果操作系统是中文版且是中文区域设置,则和英文版操作系统的运行结果不一致。
代码清单2-3 字符串比较示例
1 // 源码位置:第2章\StringCompareDemo.cs 2 // 编译命令:csc StringCompareDemo.cs 3 using System; 4 using System.Globalization; 5 6 class StringCompareDemo 7 { 8 static void Main() 9 { 10 object a = 1, b = 1; 11 // Console.WriteLine(a == 1); 12 Console.WriteLine(a == (object)1); 13 Console.WriteLine(a == b); 14 15 string c = "1", d = "1"; 16 Console.WriteLine(c == d); 17 Console.WriteLine(c == "1"); 18 19 Console.WriteLine(string.Compare("1", "2")); 20 Console.WriteLine(string.Compare("a", "A")); 21 Console.WriteLine(string.Compare("a", "A", true)); 22 23 Console.WriteLine(string.Compare( 24 "财经传讯公司", "房地产及按揭")); 25 Console.WriteLine(string.Compare( 26 "财经传讯公司", "房地产及按揭", false, new CultureInfo("zh-CN"))); 27 Console.WriteLine(string.Compare( 28 "财经传讯公司", "房地产及按揭", false, new CultureInfo("en-US"))); 29 } 30 }
2.1.4 修改字符串
与C/C++等编程语言不同的是,C#的字符串是不可修改的,即字符串在C#中是一个只读的字符数组。C#的字符串也不是以常见的“\0”字符结尾,字符串长度保存在字符串前面的位置,如图2-1所示。
图2-1 字符串的内存表现形式
在C语言中,很多初学者容易犯的错误是使用“+”操作符来连接两个字符串。.NET中字符串是对象,原本也不能使用“+”来连接字符串。其通过在String类型里重载“+”操作符来实现连接功能。字符串创建之后不可修改,因为针对字符串对象的任何修改都会导致一个新的字符串实例被创建,致使这个操作符重载经常被误用。如代码清单2-4的第8行中,每次使用“+”操作符执行连接操作后都会生成一个新的字符串对象。而C#是基于垃圾回收机制的编程语言,一方面新创建的无用对象只有等到下一次垃圾回收才能释放内存空间,另一方面内存里有太多的垃圾对象,会频繁触发垃圾回收机制,影响程序执行效率。
代码清单2-4 使用“+”号操作符连接字符串
// 源码位置:第2章\ModifyStringDemo.cs // 编译命令:csc /main:ModifyStringDemo ModifyStringDemo.cs 1 var value = string.Empty; 2 for (var i = 0; i<loops; ++i) 3 { 4 // 大于1MB就删除掉 5 if (value.Length > 1024 * 1024) 6 value = string.Empty; 7 8 value = value + i.ToString(); 9 }
对于少量的字符串连接操作,使用“+”操作符处理可以在不影响程序执行效率的同时,提高代码的可读性。但如果需要频繁执行字符串连接或者修改操作,.NET提供了一个更好的方案——StringBuilder。StringBuilder类型定义在System.Text命名空间,其内部保存了一个字符数组作为缓存,提供了类似编辑数组元素的方案来修改字符串。
代码清单2-5实现了与代码清单2-4相同的字符串连接功能。
代码清单2-5 使用StringBuilder连接字符串
// 源码位置:第2章\ModifyStringDemo.cs // 编译命令:csc /main:ModifyStringBuilderDemo ModifyStringDemo.cs 1 var sb = new StringBuilder(); 2 for (var i = 0; i<loops; ++i) 3 { 4 // 大于1MB就删除掉 5 if (sb.Length > 1024 * 1024) 6 sb.Clear(); 7 8 sb.Append(i.ToString()); 9 }
图2-2展示了以两种方法执行10000次字符串连接操作的性能对比。可以看到,StringBuilder方案的性能大大超过直接使用“+”操作符的性能。
图2-2 “+”操作符和StringBuilder连接字符串的性能对比
2.1.5 字符编码
随着互联网的蓬勃发展,不同语言文化的字符编码给程序员带来的困扰越来越少。不过,读者可能还是会碰到打开一个文本文件或者访问一个网页整屏显示“???? ?????? ??? ????”或“”字符串的情况,特别是在Linux系统打开从Windows系统复制过来的文件时,这就是字符编码出现了问题。很多编程初学者,特别是有一点C语言知识的初学者,总是倾向做出“字符 = ascii = 一个字节”或者“字符 = Unicode = 两个字节”这样的草率判断。很遗憾,这是错误的。如果抱着这种理念编程,那只能靠操作系统和编程语言本身自带的框架来拯救了。幸运的是经过多年发展,操作系统和编程语言在隐藏这些细节方面做得还不错。
在20世纪70年代,字符编码基本是ASCII编码,如表2-2所示。ASCII编码数字32~127可以表示所有的英文字母和相关的标点符号,如空格(SPACE)是数字32或十六进制的20,字母“A”对应的数字是65。数字32之前的字符都是所谓的“不可打印字符”,即控制字符,如7是一个BELL字符(会导致电脑嘟嘟响),表示这些字符只需要7位就够了。然而普通电脑的一个字节有8位,即如果一个字节只用来存储ASCII编码,那么对空间是很大的浪费。对于128~255之间的数字而言,不同的国家和机构有不同的利用方式。例如IBM-PC将这些数字作为OEM字符集,不仅支持欧洲语言里的一些重音字符,还支持一些画线字符,如“╢”“╗”等字符。在西欧某些国家的PC上,130这个数字代表“é”,在以色列的PC上,这个数字代表“”,那么从西欧某个国家发送简历(résumé)到以色列时,接收方收到的是“”。
表2-2 ASCII编码
虽然不同地区的电脑厂商都遵循ASCII编码规则,即前128个数字(0~127)都对应相同的字符,但是对后128个数字有着不同的解释,这些不同的编码系统被称为“代码页”(Code Page)。在使用不同代码页的系统上传输文件时,我们必须通过定制的编码格式转换工具将编码的文字使用位图的方式呈现出来。亚洲的情况就更复杂了,字符有成千上万个,一个字节根本没办法全部容纳。最开始采用的是DBCS编码:双字节字符集。在DBCS编码中,有的字符占用1字节,有的字符则占用2字节,这导致在字符串里向前移动非常容易,向后移动则变得非常困难。DBCS编码中不能使用s++或者s--之类的操作符在字符串里前后移动指针。在Windows系统中,我们必须使用AnsiNext和AnsiPrev这种系统级API。在前互联网时代,这不是很大的问题,因为在不同编码系统中传输文件的需求不多。互联网时代来临后,在本地下载另一个国家的网页或者传输文件成为一个非常普遍的需求,这需要统一的编码方式。因此,Unicode被发明出来。
Unicode尝试使用一个字符集来表现世界上所有的书写系统,以及如《星球大战》电影里的克林贡语言这样的虚构书写系统。很多人可能会简单地认为Unicode是一个16位的字符集,即最多只能容纳65536个字符,这是不正确的。在Unicode中,一个字符会被映射到一个码点(Code Point)。码点是一个虚构的概念。
在Unicode中,A和B、a是不同的字符,但和A、A是相同的字符。使用Times New Roman字体书写的“A”和使用Helvetica字体书写的A是相同的字符,但与小写的a是不同的字符,这些问题看起来没有任何争议。但在有些语言里则不同,如德语字符ß到底是一个字母,还是ss的另一种写法?如果单词结尾的字符形状有变化,那这个字符是否是另一个字符:在希伯来语中认为是,在阿拉伯语中则认为不是。幸运的是,Unicode委员会已经帮我们解决了这些争议。
Unicode给每个字符都映射了一个数字,如U+6C49,这个数字就被称为码点,U+说明是Unicode,其中的数字是十六进制的。文字“汉”的码点是U+6C49,英文字母A的码点是U+0041,而表情符号☺的码点是U+1F642。字符串“Hello”的码点是:
U+0048 U+0065 U+006C U+006C U+006F
但这些仅仅是码点,并没有定义应该如何存储在内存中或者如何在邮件里编码,我们可以自行决定存储方式,比如每个码点用2字节存储,那么Hello在内存里的存储格式是:
00 48 00 65 00 6C 00 6C 00 6F
当然,存储格式也可以是下面这样的:
48 00 65 00 6C 00 6C 00 6F 00
这两种存储格式分别代表不同的字节存储方式,即字节序不同。字节序有大端序(Big-endian)和小端序(Little-endian)。字节序的不同是由不同CPU架构对字节处理顺序不同产生的,例如Intel/AMD x86、Digital VAX和Digital Alpha等CPU架构支持小端序,而Motorola 680、SPARCower PC和大部分RISC架构支持大端序。为了让在不同CPU架构的电脑上处理的Unicode文件能相互理解,Unicode在文件的头部加上了所谓的BOM(Byte Order Mark,字节顺序标记)。BOM的Unicode码点是U+FEFF,作为一个“魔术数字”出现在文本的最前面。BOM在文本文件中是可选的,但一旦出现,文本处理软件会通过读取BOM的字节顺序来判定文件存储的字节序。如Windows自带的记事本软件以Unicode格式保存文件时(见图2-3),会在文件开始的地方插入不可见的BOM字节。这个字节在普通的图形化文本编辑器里是不可见的,但在Linux或者macOS系统上采用less等命令行工具查看文件时,则会看到这个字节,如图2-4所示。
图2-3 以Unicode格式保存文本文件
图2-4 在macOS终端打开Unicode格式的文件
除了字节序上的处理差异,在纯英文环境下,使用两个字节来存储一个字符看起来是一个非常浪费空间的做法,这些争论促使UTF-8编码格式的发明。在UTF-8中,0~127的码点使用单字节存储,128以上的码点才使用2~6个字节存储,这样纯英文文本文件的大小与ASCII码文本文件的大小完全一致,因此也能兼容老的文本处理软件。然而,UTF-8在2009年之后才成为主流编码格式,在此之前还有很多其他的编码格式。表2-3列出了这些编码格式的差别。
表2-3 不同编码格式对比
虽然Unicode尽量将所有的文字系统统一展现,但还是有漏网之鱼,如果打开的文件编码里有字符没有对应的Unicode码点,那么这个字符就会被显示为。定义好字符的编码格式,当在一个地区访问另一个地区的网页或者将电子邮件向不同地区分发时,需要添加额外信息帮助系统使用正确的编码来解析收到的字节流。在电子邮件系统中,通常会在邮件消息头中加上类似下面的键–值对,以便接收方正确处理。
Content-Type: text/plain; charset="UTF-8"
HTTP的处理也类似。HTTP消息头也会通过Content-Type键–值对来描述服务器端使用的字符编码。但仅仅指明Web服务器端使用的编码格式是不够的,这是因为大型网站经常有人在制作网页,不同地区的开发者使用的默认编码格式是不一致的。为了解决这个问题,HTML里的<head>标签中加入了元数据标签,指明网页在创作时使用的编码格式。
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
如果在HTML网页和HTTP消息头里都没有指明文件的编码格式,浏览器只能靠猜了。因此大部分字母语言会将非英文字母映射到128~255之间,而且人类语言中有些字母出现的频率会很高。结合这两个统计信息,早期浏览器特别是IE在猜测方面做得还不错。
.NET中的字符串使用UTF-16编码,同时其提供了丰富的Unicode支持。在定义字符串时,我们可以直接在字符串里使用UTF-16编码,如代码清单2-6中的第4行,\uD83D\uDE42是笑脸表情符号☺的UTF-16的编码格式,它的编码用2字节无法容纳,需要4字节。我们也可以直接在源码中输入或者粘贴Unicode字符,如第6行。第14行定义的GetUnicodeString演示了获取一个字符的UTF-16编码的方法——先将每个字符转换为整数再以十六进制格式打印。整数占用4字节,可以容纳大部分的Unicode字符。.NET框架的System.Text命名空间定义了Encoding类,其通过几个静态字段来获取字符串的UTF-7、UTF-8、UTF-16和UTF-32的编码格式,并以字节数组的方式返回,同时允许从编码字节数组返回对应的字符串。
代码清单2-6 C#中对Unicode的支持
1 // 代码节选,源码位置:第2章\UnicodeDemo.cs 2 static void Main() 3 { 4 var emoji = "\uD83D\uDE42"; 5 Console.WriteLine(emoji); 6 var x = "☺"; 7 Console.WriteLine(GetUnicodeString(x)); 8 Console.WriteLine("Unicode - UTF16"); 9 var bytes = Encoding.Unicode.GetBytes(x); 10 foreach (var b in bytes) Console.Write("{0:x2} ", b); 11 // ... ... 12 } 13 14 static string GetUnicodeString(string s) 15 { 16 StringBuilder sb = new StringBuilder(); 17 foreach (char c in s) 18 { 19 sb.Append("\\u"); 20 sb.Append(String.Format("{0:x4}", (int)c)); 21 } 22 return sb.ToString(); 23 }
由于.NET默认采用UTF-16编码,因此在Encoding类中Unicode字段代表UTF-16编码。图2-5分别列出了笑脸表情符号使用UTF-16、UTF-8和UTF-32等编码格式返回的数组。可以看到,采用最少4字节存储的UTF-32编码格式和Unicode的码点是基本对应的。
图2-5 笑脸表情的不同编码
[1].NET String类型源码:https://referencesource.microsoft.com/mscorlib/system/string.cs.html。