原文地址:Jon Skeet:Strings in C# and .NET
System.String 类型(在C#语言中对应的别名是string)是.NET最重要的类型之一,不幸的是在它身上存在了太多的误解。这篇文章将试图去解决关于该类型的部分基础错误认知。
字符串是什么?
一个字符串实际上是一个字符序列。每一个字符都是范围介于U+0000至U+FFFF的Unicode字符(稍后给出更详细的说明)。string类型(后文中我将使用C#中的string别名统一来指代System.String类型)拥有以下特征:
它是一个引用类型
开发者中存在一个普遍的误解就是string类型是值类型。这常常是因为string的不变性使得其行为类似于值类型(见下一点)。实际上,它更多地表现为一个普通的引用类型。请查看我的参数传递和内存二文,以参阅关于值类型和引用类型之间差异的更多细节。它是不可变的
你永远不可能改变字符串的内容,如果你使用不借助反射机制的安全代码的话。也正是因此,最终您通常只会更改字符串变量的值。例如,代码s = s.Replace(“foo”,“bar”);
不会更改s原来引用的字符串的内容——它只是将s的值设置到一个新字符串中,这个新字符串是旧字符串的副本,在这个新字符串中,“foo”将被替换为“bar”。它可以包含空字符
C语言程序员习惯于使用'\0',nul或者null字符来作为字符串字符序列的结尾。(我将使用“null”,因为它是Unicode代码图表中的详细信息;不要将它与C#中的null关键字混为一谈——char是值类型,所以它不能是一个空引用)在.NET中,字符串中可以包含空字符,就字符串本身具有的方法而言,这没有任何问题。然而,其他的类型(比如说许多Windows窗体)可能会认为字符串以第一个null字符作为结束标志——如果你的字符串表现为似乎会被奇数截断,可能就是出现了这种情况。它重载了“==”操作符
当==操作符用于比较两个字符串时,Equals方法将被调用,该方法检查两个字符串内容的相等性,而不是引用本身。例如,即使操作符的两侧引用不同(指的是两个不同的字符串对象,它们都包含相同的字符序列),"hello".Substring(0,4)=="hell"
也将返回true。需要注意的是,如果操作符的两侧在编译时都是字符串表达式——操作符重载将仅在此处运行而不会以多态运行。如果操作的任意一边是object类型,则将应用正常的==操作符,并且简单的引用相等性将被测试。
字符串常量池(字符串驻留)
.NET有一个“字符串常量池”的概念。该常量池基本表现为一个字符串集合,但它确保每次引用具有相同值的字符串时,都会引用相同的字符串。这可能是在语言层面提供的,在C#和VB.NET中确实都是如此。所以如果看到有一种语言并不适用此规则(译者注:在.NET平台上),我将会非常惊讶,因为IL使其变得非常容易(实现此规则比不实现此规则更容易)。除了自动驻留的规则外,您还可以使用对应的Intern方法手动实现字符串驻留的功能,也可以使用IsInterned方法检查池中是否已经存在具有相同字符序列的内部字符串。这个方法返回一个字符串引用而不是一个布尔值,这稍微有些不直观——如果池中有相等的字符串,则返回对该字符串的引用,否则返回null。类似像Intern方法也会返回一个对驻留字符串的引用——例如暂存了“str”,则返回系统对其的引用;否则返回对值为“str”的字符串的新引用。
译者注,System.String的Intern和IsInterned方法将会在 .NET Core 2.0 版本释出。
字面值(Literals)
译者注:找不到合适的词语来解释Literals,所以取其英语翻译本意。
Literals就是你如何将字符串硬编码到C#程序中的方式。C#中有两种类型的字符串字面值方式——常规字符串字面值和逐字字符串字面值。常规字符串字面值与许多其他语言(例如Java和C)类似,它们以"
作为开始和结尾,并且各种字符(特别是"
本身,\
,以及回车(CR)和换行符(LF))需要转义成为在字符串中的表示。逐字字符串字面值允许字符串内部的几乎任何字符,并且在第一个字符"
处不会结束(如果不成对实现)。即使回车和换行符也可以出现在字符串中!如果要获得一个"
字符,你需要写""
。逐字字符串字面值方式通过在字符串开头之前引用@
与常规字符串字面值方式进行区分。
译者注:这一段相对绕口,简而言之,Literals就是C#表示字符串的两种方式,以下给出示例解读。
/*
常规字符串字面值
*/
Console.WriteLine("This string contains a newline\nand a tab\tand an escaped backslash\\");
/*
逐字字符串字面值
*/
Console.WriteLine(@"This string displays as is. No newlines\n, tabs\t or backslash-escapes\\.");
/*
逐字字符串字面值,本句将打印 " 字符
*/
Console.WriteLine(@"""");
常规 | 逐字 | 结果 |
---|---|---|
"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(可变长度版本的\uxxxx)的字符的Unicode转义序列
- \Uxxxxxxxx - 具有十六进制值xxxxxxxx的字符的Unicode转义序列(用于生成代理)
其中,\a,\f,\v,\x和\U很少出现在我的代码中。
字符串和调试器
许多人在调试器中检查字符串时会遇到一些问题,无论是使用VS.NET 2002还是VS.NET 2003。讽刺的是,这些问题通常是由调试器自身试图帮助解析字符串的行为引起的:将字符串显示为带有反斜杠转义字符的常规字符串字面值,或将其显示为带有@
的完整字符串字面值。这导致了许多问题,比如说怎么才可以删除@
,尽管事实上@
不是真的在那里——这只是调试器的显示方式。而且VS.NET的某些版本会在第一个空字符处停止显示字符串的内容,并且不能正确地评估其Length属性,它只是计算值本身,而不是询问托管代码。再次重申,调试器会考虑字符串在第一个空字符处就结束。
考虑到这一点造成的混乱,我认为最好在调试时以不同的方式检查字符串,至少在你觉得奇怪的事情正在发生的情况下应该这样做。我建议使用下面的DisplayString方法,它以安全的方式将字符串内容打印到控制台。根据你正在开发的应用程序,你可能需要将此信息写入至日志文件,调试窗口或跟踪侦听器中,或者在消息框中弹出。
或者,作为检查文本的一种交互方式,你可以使用我的 Unicode Explorer 小应用——只需要输入文本,就可以查看对应的字符,UTF-16代码单元和UTF-8字节。
static readonly string[] LowNames =
{
"NUL", "SOH", "STX", "ETX", "EOT", "ENQ", "ACK", "BEL",
"BS", "HT", "LF", "VT", "FF", "CR", "SO", "SI",
"DLE", "DC1", "DC2", "DC3", "DC4", "NAK", "SYN", "ETB",
"CAN", "EM", "SUB", "ESC", "FS", "GS", "RS", "US"
};
public static void DisplayString (string text)
{
Console.WriteLine ("String length: {0}", text.Length);
foreach (char c in text)
{
if (c < 32)
{
Console.WriteLine ("<{0}> U+{1:x4}", LowNames[c], (int)c);
}
else if (c > 127)
{
Console.WriteLine ("(Possibly non-printable) U+{0:x4}", (int)c);
}
else
{
Console.WriteLine ("{0} U+{1:x4}", c, (int)c);
}
}
}
内存使用情况
至少在当前的.NET实现中,字符串对象占用了20+(n/2)*4个字节(对n/2向下取整),其中n是字符串中的字符数。string类型是特殊的(译者注:指资源占用不固定),因为其对象本身的大小不同。据我所知,相似行为的其他类型只有数组。本质上来说,字符串是内存中的一个字符数组,(译者注:注意段首公式,20字节为默认分配资源)计算资源占用时需要加上数组的长度和字符串的长度(以字符为单位)。字符数组的长度并不总是与字符长度相同,因为字符串可以在mscorlib.dll中“过度分配”,以使其更容易构建。(例如StringBuilder就是这样做的)。虽然字符串对外界是不可变的,但mscorlib中的代码可以改变其内容,StringBuilder可以创建一个比当前文本内容要求更长的内部字符数组,再附加到该字符串,直到字符数组长度不再能应对需求,在那之后StringBuilder将再创建一个包含更大数组的新字符串(译者注:简单来说,就是动态扩容,.NET大部分集合类都具有该特性)。字符串长度属性还在其顶部位包含一个标志,以说明该字符串是否包含任何非ASCII字符。这在某些情况下允许额外的优化。
尽管字符串对于COM API而言不是空终止的,但是字符数组是以空终止的,这意味着它可以直接传递给非托管函数,而不会涉及任何复制操作,假设inter-op指定字符串应该编码为Unicode形式。
编码
如果你不了解字符编码和Unicode,请先阅读我关于该主题的文章。
如文章开头所述,字符串始终是Unicode编码格式。“Big-5字符串”或“UTF-8编码中的字符串”的说法是错误的(就.NET而言),(提出上述观点的人)通常表示为对编码格式或.NET处理字符串的方式缺乏了解。理解这一点非常重要——就像如果想在非Unicode编码中表示一些有效的文本以处理一个字符串,这几乎总是错的。
现在,Unicode编码字符集(Unicode的一个缺点是一个术语用于各种事物,包括编码字符集和字符编码方案)包含超过65536个字符。这意味着单个char(System.Char)不能覆盖每个字符。这导致在使用代码时,在U+FFFF以上的字符在字符串中表示为两个字符。本质上,string使用UTF-16字符编码形式。大多数开发人员可能不需要了解关于这一点的更多信息,但至少要注意这一点。
译者注1:C#中,单个char占有两个字节,表示1个Unicode字符,其MaxValue值为65535,所以Jon Skeet才说单个char已经不能覆盖每个字符了。至于为什么string使用UTF-16字符编码形式,请参阅Why does .net uses the UTF16 encoding for string , but uses utf8 as default for saving files?
译者注2:Unicode和UTF-8总是会让一些人感到疑惑,推荐阅读这两篇文章——廖雪峰:字符串和编码和阮一峰:字符编码笔记:ASCII,Unicode和UTF-8。
文化与国际化的遗产
Unicode的一些奇怪特性导致字符串和字符处理中的怪异。许多字符串方法是文化性敏感的——换句话说,它们的作用取决于当前线程的文化。例如,你期望"i".toUpper()方法返回什么呢?大多数人会说"I",但在土耳其语中,正确答案是 "İ"(UnicodeU+0130,比如在Latin单词的大写形式中,我会使用İ代替I)。要执行不依赖区域性的更改,你可以使用CultureInfo.InvariantCulture,并传递到String.ToUpper的重载方法中,这需要CultureInfo类型。
当比较,排序和查找子字符串的索引时,还有其他的怪异之处。其中一些是文化特定的,有些不是。例如,在所有文化中(据我看到的),在使用CompareTo 或Compare而不是使用Equals时,"lassen"和"la\u00dfen"被认为是相等的。IndexOf 会将\u00df视为相同的ss,除非您使用CompareInfo.IndexOf 并指定CompareOptions.Ordinal为使用选项。
对于正常的IndexOf,其他的一些Unicode字符似乎是完全不可见的。有人在C#新闻组询问为什么搜索/替换方法会进入无限循环。它被重复使用Replace方法,用一个空格替换所有的双重空格,并检查是否已经完成使用IndexOf,以便多个空格折叠到一个空格。不幸的是,由于两个空格间的原始字符串中的“奇怪”字符,转换将失败。IndexOf匹配双重空格,忽略额外的角色,但Replace并没有。我不知道真实数据中的确切字符,但是可以使用U+200C来轻松复制,这是一个零宽度的非连接器字符(无论什么意思,正好!)。IndexOf把其中一个放在您正在搜索的文本的中间,并将忽略它,但Replace不会。再次重申,为了使两个方法的行为相同,您可以使用CompareInfo.IndexOf方法并传入CompareOptions.Ordinal(译者注:目前应该调用CompareInfo.GetCompareInfo().IndexOf方法)。我的猜测是,因为这样的“尴尬”数据,将导致很多的代码的运行失败(我暂时也不会声称我的所有代码都是免疫的)。
微软有一些关于字符串处理的建议——它们可以追溯到2005年,但仍然值得一读。
结论
对于这样的核心类型,字符串(和普通的文本数据)比你最初期望的更复杂。了解这里列出的基础知识很重要,即使现在有一些关于比较的细节和多元文化背景下的包装知识让开发者觉得难以捉摸。(这种情况下)特别得,能够通过记录真实字符串数据来诊断数据丢失的编码错误便显得至关重要。