C#和.NET中的字符串
System.String类型(C#中简写为string)是.NET中最重要的类型之一,不幸的是人们对它有很多误解。本文试图讲解该类型的一些基础知识。
什么是字符串
字符串大致上说就是一个字符序列,每个字符都是U+0000到U+FFFF范围内的Unicode字符(后面会详细介绍)。字符串类型string(我之后都会用C#简写,而不是放System.String)有以下特点:
- 是引用类型
这是一个常见的误区,认为字符串是一种值类型。这是因为它的不可变性(见下一点)使得它的行为有点像值类型。实际上,它就像一个普通的引用类型。关于值类型和引用类型之间的差异,可以参阅我关于参数传递和内存的文章。 - 不可变性
你永远无法真正改变一个字符串的内容,至少是在不使用反射的安全代码中。正因如此,你经常会在最后改变了字符串变量的值。例如,代码s = s.Replace ("foo", "bar");并没有改变原始引用的字符串的内容——它只是将s的值设置为了一个新字符串,这个字符串是旧字符串的副本,只是“foo”被“bar”替换了。 - 重载
==操作符
当使用==运算符比较两个字符串时,会调用Equals方法,该方法会检查字符串内容是否相等,而不是引用本身。例如,"hello".Substring(0,4)=="hell",虽然运算符两边的引用不同,但判断返回为True(它们都包含相同的字符序列,但引用的是两个不同的字符串对象)。请注意,只有在编译时运算符的两边都是字符串表达式时,运算符重载才会起作用——运算符不是多态应用的。对于编辑器而言,如果运算符的某一边的类型是对象(object),那么==运算符正常执行,只是简单判定引用是否相等。
驻留
.NET有一个“驻留池”的概念,基本上就是一组字符串,它能确保每次引用同一个字符串字面量时,都能得到同一个字符串的引用。这可能与语言有关,但至少在C#和VB.NET中肯定是这样的,如果有某种语言不是如此,那还挺让人惊讶,因为IL是很容易做到这一点的(可能比不驻留字符串字面量更简单)。除了自动驻留字面量外,还可以使用Intern方法手动驻留字符串,并使用IsInterned方法检查池中是否已经存在有相同字符的驻留字符串。这个方法(IsInterned)会返回一个字符串而不是直观认为的布尔值——如果池中有相等的字符串,则返回该字符串的引用。否则,将返回null。同样的,Intern方法会返回一个对驻留字符串的引用——无论是你传入的字符串已在池中,或是创建了新的驻留字符串,还是池中已存在相同字符串,都是如此。
字面量
字面量是将字符串硬编码到C#程序中的方式。在C#中,有两种类型的字符串字面量——常规字符串字面量和转义字符串字面量。常规字符串字面量与许多其他语言(如Java和C)中的相似。都以"开头和结尾,而有些字符(特别是"本身,\,回车(CR)和换行(LF))需要“转义”才能在字符串中表示。转义字符串字面量几乎允许包含任何内容,并以第一个"结束,而不是两个。即使是回车和换行也可以出现在字面量中!要想在字符串中显示",你需要写""。转义字符串字面量通过在开头引号前添加@来进行区分。下面举一些例子,来说明这两种文字类型,以及它们表示的是什么:
| 常规字符串字面量 | 转移字符串字面量 | 结果字符串 |
|---|---|---|
"Hello" |
@"Hello" |
Hello |
"Backslash: \\" |
@"Backslash: \" |
Backslash: \ |
"Quote: \"" |
@"Quote: """ |
Quote: " |
"CRLF:\r\nPost CRLF" |
@"CRLF:Post CRLF" |
CRLF: Post CRLF |
请注意,只是为了编译器才区分不同类型的字符量。一旦字符串被编译后,就不存在常规字符串字面量和转义字符串字面量的区别了。
完整的转义序列如下:
\'——单引号,用于字符\"——双引号,用于字符串\\——反斜杠\0——Unicode字符0\a——警报(字符7)\b——退格(字符8)\f——送表(字符12)\n——新的一行(字符10)\r——回车(字符13)\t——水平制表符(字符9)\v——垂直制表符(字符11)\uxxxx——带有十六进制值xxxx的字符的Unicode转义序列\xn[n][n][n]——带有十六进制值nnnn的字符的Unicode转义序列(可变长度版本的\uxxxx)\Uxxxxxxxx——十六进制值为xxxxxxx的字符的Unicode转义序列(用于生成代理字符)
根据我的经验,其中的\a、\f、\v、\x和\U很少使用。
字符串和调试器
很多人在调试器中检查字符串时遇到了问题,无论是 VS .NET 2002还是 VS .NET 2003。讽刺的是,这些问题往往是由于调试器为了提供帮助而产生的,要么将字符串显示为带有反斜杠转义字符的常规字符串字面量,要么将其显示为带有前导@的转义字符串字面量。这就导致了很多问题,怎么去掉@呢,尽管事实上它本来就不存在——只是调试器的显示方式而已。另外,某些版本的VS .NET会在第一个空字符时停止显示字符串的内容,并错误地评估其Length属性,自己计算值,而不是询问管理代码。同样,它就会认为该字符串在第一个空字符处就结束了。
鉴于这一点引起的混乱,至少在你觉得有点奇怪时,最好尝试用不同的方法调试一下。我建议使用像下面这样的方法,它将以一种安全的方式将字符串的内容打印到控制台。可以根据你正在开发的应用程序的类型,把这些信息写到日志文件中,写到调试或跟踪监听器中,或者在消息框中弹出。
另外,作为一种交互式检查文本的方式,你可以使用一个我写的简单的Unicode探测器(原文 译文)——只需输入文本,就可以看到字符、UTF-16编码和UTF-8字节。
1 | static readonly string[] LowNames = |
内存使用
至少在现在的实现中,字符串占用了20+(n/2)*4个字节(n/2向下取整),n是字符串中的字符个数。字符串类型很特别,因为对象本身的大小是不同的。另一个能做到这一点的类(就我所知)是数组。本质上,字符串在内存中就是一个字符数组,加上数组的长度和字符串的长度(以字符为单位)。数组长度和字符长度并不总是相同,因为字符串可以使用mscorlib.dll进行“超额分配”,以使其构建更加容易(例如,StringBuilder就是这么做的)。虽然字符串对外界来说是不可变的,但mscorlib中的代码却可以改变其内容,所以StringBuilder会创建一个大于当前内容的内部字符数组,然后对字符串进行追加,直到字符数组不够大时,会再创建一个新的字符串,使其拥有更大的数组。字符串长度成员在其顶位还包含一个标志,用于说明字符串是否包含任何非ASCII字符。这样在某些情况下可以进行额外的优化。
虽然就API而言,字符串并不是以null作为终止(null-terminated)(个人理解:末尾没有“\0”),但字符数组确是以null终结(个人理解:末尾是“\0”),因此这意味着它可以直接传递给非管理函数,而不涉及任何复制调用,假定传递过程(inter-op)指定字符串应当以Unicode形式进行装配(marshalled)。
编码
(如果你不了解字符编码和Unicode,请先看我的相关文章(原文 译文)。)
正如文章开头所说,字符串总是采用Unicode编码。“大五码字符串”或“UTF-8编码字符串”是一种错误的概念(就.NET而言),这通常表明对编码或者.NET处理字符串的方式缺乏了解。理解这一点是非常重要的——如果把一个字符串当作一个非Unicode编码的有效文本来处理,几乎总会造成错误。
现在,Unicode编码字符集(Unicode的缺陷之一是一个名词会用于各种事物,包括一个编码字符集和一个字符编码方案)包含了65536多个字符。这意味着char(System.Char)不可能涵盖所有字符。这就导致了代用符的使用,U+FFFF以上的字符在字符串中被表示为两个字符。本质上,string使用UTF-16字符编码形式。大多数开发者很可能不需要知道太多,但至少值得了解一下。
文化与国际化的怪事
Unicode的一些怪异性导致了字符串和字符处理的怪异性。许多字符串方法都具有文化敏感性——换句话说,它们的作用取决于当前线程的文化。例如,你认为"i".toUpper()返回的是什么?大多数人会说 “I”,但在土耳其语中,正确答案是 “İ” (Unicode U+0130,“拉丁文大写字母I,上面有点”)。要执行对文化不敏感的大小写变化,可以使用CultureInfo.InvariantCulture,将其传递给String.ToUpper的带有CultureInfo的重载。
在比较、排序和查找子串的索引时,还有更多的奇怪之处。它们中有的涉及文化特有性,有的则不涉及。例如,在所有文化中(据我所知),当使用CompareTo或Compare时,"lassen "和 “la\u00dfen”(其中的 "sharp S "或eszett是Unicode-escaped字符)被认为是相等的,但使用Equals时又不相等。IndexOf对eszett和"ss"的处理是一样的,除非使用CompareInfo.IndexOf并制定CompareOptions.Ordinal作为要使用的选项。
其他一些unicode字符对于普通的IndexOf根本不可见。 有人在C#新闻组中问到,为什么一个搜索/替换方法会进入一个无限循环。内容是循环使用Replace将所有双空格替换为单空格,并通过IndexOf检查是否已经完成,这样多个空格就会折叠成一个空格。不幸的是,由于原始字符串中两个空格之间有一个 "奇怪 "的字符,导致了该错误。IndexOf匹配了双空格,忽略了额外的字符,但Replace却没有。我不知道真实数据中到底是哪个字符,但可以用U+200C这个零宽度的非连接字符(不用管它到底是什么意思!)轻松再现。将其放在你要搜索的文本中间,IndexOf会忽略它,但Replace却不会。与之前的情况同样,为了使两个方法的行为一致,你可以使用CompareInfo.IndexOf传入CompareOptions.Ordinal。我猜测,有很多代码会在这样的 "尴尬 "数据上失效。(我也无法保证我的所有代码都对此免疫。)
微软有一些关于字符串处理的建议——可以追溯到2005年,但它们仍然非常值得阅读。
总结
对于这样一个核心类型,字符串(以及一般的文本数据)比你最初预期的要复杂得多。尽管对于多文化背景下的比较和变化的一些细微之处目前你还无法理解,但了解这里列出的基础知识是很重要的。特别是,能够通过打印(logging)真实字符串数据,来诊断编码错误的造成的数据丢失,是至关重要的。
本文是对Strings in C# and .NET这篇文章的翻译,作者是著有C# in Depth的大神Jon Skeet。非文章原文或本人对某段文字理解,会以斜体 个人理解:xxx 进行标注。本人翻译能力有限,强烈建议大家去看原版!