C#基础知识梳理系列七:字符串
字符串是保存文本的System.String类型对象。它跟值类型(如:Int32、Int64等)有着相似的使用方法及表达目的,但它并不是值类型。由于在编程中会大量使用字符串,所以CLR为了提高性能及开发方便,对它进行了特殊处理。这一章,我们来介绍一下字符串的驻留机制、字符串池及字符串的比较等特性。
注意,本系列所有测试代码均运行于.NET 4.0。
字符串被定义为System.String类型的对象,既然它是引用类型,那么一个未初始化的对象声明将保留为null,并且它的内存只能在堆上分配。它在内部维护的是字符Char的集合,所以它有一个属性Length来表示Char集合中元数的个数。来看一下String类型的定义:
String实际上是继承了System.Object类型,同时还实现了一系列接口,如Ienumberable、ICompareable等,所以字符串提供了对字符集合、比较等相关的操作。
尽管它是引用类型,但是编译器不允许使用new根据一个文本常量来创建一个字符串对象,而是必须使用简明的声明语法来声明及初始化,对字符串的初始化值是直接被编译进元数据的。比如如下定义一个字符串变量:
string name1 = "Jack";
IL:
.method public hidebysig specialname rtspecialname instance void .ctor() cil managed { // 代码大小 19 (0x13) .maxstack 8 IL_0000: ldarg.0 IL_0001: ldstr "Jack" IL_0006: stfld string ConsoleApp.Example07.Code_07::name1 IL_000b: ldarg.0 IL_000c: call instance void [mscorlib]System.Object::.ctor() IL_0011: nop IL_0012: ret } // end of method Code_07::.ctor
我们知道,通常对于引用类型创建对象是使用newobj指令,但上面的并没有使用该指令,只是使用ldstr指令加载了字符串“Jack”,从IL_0000 - IL_0006可以看到是直接加载“Jack”串赋给变量,这是CLR的一种特殊的构造方式。
字符串对象一旦创建,在整个进程的生命周期中是不可变的,无法对其进行加长、缩短、改变等操作,既然它不会变,所以也就不存在线程同步的问题,哪怕是皇天老儿创建的线程都无法对其进行改变。如下代码:
string str1 = "Jim"; string str2 = str1; Console.WriteLine(object.ReferenceEquals(str1, str2)); //True str1 += " China"; Console.WriteLine(object.ReferenceEquals(str1, str2)); //False
在第一次调用object.ReferenceEquals方法比较的str1和str2,它们指向的是同一个字符串对象引用,所以结果为true,而str1 += " China";的过程是重新创建了一个对象,且把新的对象引用赋给str1,此时str1与str2指向的不是同一个对象引用,所以在第二次调用object.ReferenceEquals方法时返回的是false。无论是使用+=操作符还是其他的对字符串修改的方法,都会引起重新创建字符串对象,并且复制旧的字符串到新的内存区,而不是我们常说的“对XX字符串进行修改”,如果非要说“改变”,那就是对对象引用的改变。对于str1 += " China";操作,CLR会执行以下操作:
1) 开辟新的足够大的临时存储区内存来容纳str1和” China”;
2) 复制str1串到临时区的开始处;
3) 复制” China”到临时存储区的结尾处;
4) str1丢弃对旧对象的引用;
5) 为str1再一次分配内存区;
6) 将临时存储区内的字符值复制到4)新开辟的内存区,将str1指向这个内存区的引用。
所以对字符串的连接操作会大大损伤性能。我们在下面会讲到.NET 提供的一个专门对付字符串连接的类StringBuilder。
通过前面的描述,我们已经知道字符串的内存是分配在托管堆上,且它是不可改变的,而在编程中,我们会大量使用字符串,这就会导致不停地创建字符串对象,不停地分配内存,并且很有可能不停地执行垃圾回收,如此以来会大大损伤性能,所以CLR对字符串进行了特殊的优化机制,下面我们来对这些机制及特性进行描述。
字符串驻留是CLR提供的一种提高性能的对待字符串的机制,它保证在一个进程内的某个字符串在内存中只分配一次。看以下代码:
string str1 = "abc"; string str2 = "abc"; Console.WriteLine(object.ReferenceEquals(str1, str2)); //True
明明声明了两个对象str1和str2,调用object.ReferenceEquals方法返回的是True,为什么它们指向的是同一个引用呢?这就说明了CLR的字符串驻留,相同的字符串在托管内存中只分配一次,再次声明相同的字符串对象时,会将后来一次的声明指向第一次声明所引用的对象。那么CLR 如何保证做到的呢?原来,在CLR初始化时创建一个内部的哈希表,我们知道哈希表在处理表内数据时是非常快的,这个表相当于一个字典表(Dictionary<TKey,TValue>),键就是字符串,而值是指向托管堆中该字符串对象的引用,当在声明一个字符串时,会调用对象的Intern方法,该方法接收一个string对象,它会先在哈希表中检查该字符串是否存在?如果存在,则返回这个字符串对应的对象引用;否则,将创建该字符串的副本,并将副本添加到哈希表中,最后返回对该副本对象的引用。String类还提供了一个IsInterned方法,该方法会根据字符串在哈希表中检查是否已经存在相同的串,如果存在,则返回该字符串对象的引用,否则,返回null,但它是它不会向哈希表中添加字符串。我们对上面的代码进行改造:
void TestIntern() { string str1 = "abc"; string str2 = "abc"; Console.WriteLine(object.ReferenceEquals(str1, str2)); //True str1 += str2; Console.WriteLine(str1); }
接下来通过内存分析器来看一下字符串驻留:
可以看到字符串”abc”是有驻留的。从前面的讲解中我们知道,+=操作是要重新创建对象的,但CLR对临时计算的新对象“abcabc”没有进行驻留呢。继续改造上面的代码:
string str1 = "abc"; string str2 = "abc"; Console.WriteLine(object.ReferenceEquals(str1, str2)); //True str1 += str2; str1 = string.Intern(str1); Console.WriteLine(str1);
这次我们使用了string.Intern方法,再用内存分析器看一下:
这次我们看到字符串”abcabc”是进行了驻留,这里就验证了刚才上面对驻留机制的讨论。
有一点要注意,尽管String.Intern(string)方法的字符串参数(上面代码中的str1)被垃圾回收器回收,但是CLR已将这个str1的副本添加到哈希表中,垃圾回收器是无法对哈希表引用的字符串进行回收。
字符串驻留也有发“懵”的时候,看以下代码:
void Test() { string str1 = "abc"; string str2 = new string(new char[] { 'a', 'b', 'c' }); Console.WriteLine(object.ReferenceEquals(str1, str2)); //False }
对于变量str2最终的字符串也是”abc”,可是为什么这次object.ReferenceEquals方法返回了False呢?原因是CLR会为new string(char[])创建的字符串对象会重新分配内存,不再使用字符串驻留机制对它进行处理,于是str1和str2就指向了不同的对象引用。
字符串池与字符串驻留机制是分不开的,最能体现字符串池的是在在C#编译器编译的过程中,在编译过程中,同样的字符串(比如上面代码中的”abc”)会被程序中的很多地方使用,通过第一节我们已经知道字符串的声明是直接将字符中写入元数据中,如果编译器在每个使用的地方都将该相同的字符串写入元数据中,则会大大增加元数据的体积,且也没有必要。所以编译器会只在模块的元数据中写入该字符串一次,并将引用该相同字符串的代码都修改为指向这同一个字符串对象,如此以来,就会大大减小元数据的体积。
对字符串的比较,通常有两层意义,一个是判断两个字符串对象是否具有相同的引用,另一个是判断两个字符串对象是不具有相同的“值”,我们一般的编程中经常使用后者。
(1) Unicode编码与字符
为了解决使用相同的字符集表示不同的语言,于是一群人就捣鼓出了Unicode,Unicode对每一个字符都提供了唯一的值,它不依赖于任何平台和任何区域语言。Unicode采用2个字节的编码方式,可以表示65536个字符,就是我们常说的16位Unicode编码,然而,仅中文就有85000多个字符,所以16位的Unicode不能满足语言文化的需要。当然如果采用32位的Unicode编码一定能满足,但32位编码的每个字符占4个字节,所以Unicode定制了另一个使用代理对的机制来满足各种语言文化的需要。
.NET Framework使用16位的Unicode编码,每个字符对应一个确定的Unicode码值。在第一节我们已经知道,字符串是由字符组成,所以一个字符串是由一系列的Unicode码组成。我们通常用Int32值来表示一个字符的编码值,如:’a’:97、’A’:65。如果你感兴趣,可以使用如下代码来测试看看每个数值所代表的字符是什么,当然,无对应数值,可能无法转换:
for (int i = 1; i < 10000; i++) { char temp = (char)i; Debug.WriteLine(temp + " " + i); }
计算机只能识别0/1,任何一个数字都可以用0/1对其进行编码,每个字符都是由Unicode码值来表示,所以计算机就能“识别”出所有字符,而字符是组成字符串的元素,所以计算机进而能“识别”出字符串。
字符文本与区域语言文化有很大的依赖关系。
(2) 区域语言文化
由于全世界各地的人类文化不同,也就导致的语言文化的不同,这就是区域语言文化的不同。计算机系统使用国际化标准来处理各种语言文化的差异。.NET Framework为了方便处理各种语言文化,提供了一个System.Globalization.CultureInfo类,它提供对特殊文化信息的支持,如文化名称、相关语言、国家/区域等。如en代表英文、en-CA代表加拿大、zh-CHS代表中文简体。
一个应用程序一般即要处理国际化数据,也要处理本地化数据,CultureInfo为处理这两类数据扮演了重要角色,为了完美地支持这两类数据处理,CultureInfo类提供了两个重要的属性CurrentUICulture和CurrentCulture。CurrentUICulture的值决定了如何加载窗体资源及窗体元素以什么语言来显示。CurrentCulture决定了除CurrentUICulture外的其他方面,如日期格式、数字格式、货币符号、字符串大小写及比较等。CurrentUICulture和CurrentCulture在应用的线程级设定,如果未设定,那么系统将从Windows中获取一个值来进行实初始化,这个值通常在控制面板的语言和区域中设置。
(3) 字符串的比较
我们知道,字符串是由字符组成的,而每个字符可以由一个Int32值来表示,我们当然可以比较两个Int32的值,同样也可以比较两个字符的“大小” ,进而可以比较两个字符串的“大小”,事实上字符串都是表示一系列文本内容的,它们不可能用大小来衡量,只能长度对其本身的特性进行一方面的描述,我们通常所说的比较,是对其内包含的字符对应的Int32值进行比较。在对字符或串排序的时候,这个比较很有用,另外一个,我们通常是比较两个字符串是否相等。
注意:字符串的比较,是按顺序逐个比较每个字符的Int32值。
字符串的比较通常有以下几种方式:
比较符号 ==、实例级和静态的Equals方法、CompareTo方法、String.Compare方法、String.CompareOrdinal方法
字符串与语言文化有很大的依赖关系,所以任何一个的字符串,都会直接或间接的使用某种语言文化信息。下面我们分别介绍一下每个比较方法。
a) 等于号==
如下代码:
void TestEqualto() { string str1 = "abc"; string str2 = "def"; bool chk = str1 == str2; }
我们来看一下IL:
.method private hidebysig instance void TestEqualto() cil managed { // 代码大小 22 (0x16) .maxstack 2 .locals init ([0] string str1, [1] string str2, [2] bool chk) IL_0000: nop IL_0001: ldstr "abc" IL_0006: stloc.0 IL_0007: ldstr "def" IL_000c: stloc.1 IL_000d: ldloc.0 IL_000e: ldloc.1 IL_000f: call bool [mscorlib]System.String::op_Equality(string, string) IL_0014: stloc.2 IL_0015: ret } // end of method Code_07::TestEqualto
它是调用了op_Equality方法,再来看看op_Equality方法的IL:
.method public hidebysig specialname static bool op_Equality(string a, string b) cil managed { .custom instance void System.Runtime.TargetedPatchingOptOutAttribute::.ctor(string) = ( 01 00 3B 50 65 72 66 6F 72 6D 61 6E 63 65 20 63 // ..;Performance c 72 69 74 69 63 61 6C 20 74 6F 20 69 6E 6C 69 6E // ritical to inlin 65 20 61 63 72 6F 73 73 20 4E 47 65 6E 20 69 6D // e across NGen im 61 67 65 20 62 6F 75 6E 64 61 72 69 65 73 00 00 ) // age boundaries.. // 代码大小 8 (0x8) .maxstack 8 IL_0000: ldarg.0 IL_0001: ldarg.1 IL_0002: call bool System.String::Equals(string, string) IL_0007: ret } // end of method String::op_Equality
我们可以看到op_Equality方法内部调用的是Equals方法,也就是说,只要使用==来比较两个字符串,最终调用的还是Equals方法,二者是等价的。
b) Equals
Equals有两种:实例方法和类方法,它们是普通的序号比较。来看一下两者的实现。
public bool Equals(string value) { if (this == null) { throw new NullReferenceException(); } if (value == null) { return false; } return (object.ReferenceEquals(this, value) || EqualsHelper(this, value)); } public static bool Equals(string a, string b) { return ((a == b) || (((a != null) && (b != null)) && EqualsHelper(a, b))); }
可以看到,实例方法进行了一次引用的比较,判断两个对象是否指向了同一个对象地址;静态方法是判断两个对象是否相等。对于两个方法,如果前面部分不成立,则它们会继续调用EqualsHelper方法进行逐字符比较。逐字符比较是先比较第一个字符的码值,再比较第二个字符的码值,依次类推,比较过程中以第一个字符串的长度为基准。比如:
char a = 'a'; //97 char b = 'b'; //98 char c = 'c'; //99 "ab"<"ac" "abc">"ab" "ab"<"abc" "ab"="ab" "ac">"ab"
但是,如果要执行区域敏感规则的比较,可能就不同了,比较中可能会为非字母数字的 Unicode 字符分配特殊权重,使用字词排序规则和特定区域的约定
c) CompareTo
CompareTo(string)方法是拿一个字符串对象与另一个串对象进行比较,返回一个Int32值,如果前者的码值小于后者码值,则返回-1,如果相等,则返回0,如果前者码值大于后者码值,则返回1。来看一下它的定义:
public int CompareTo(string strB) { if (strB == null) { return 1; } return CultureInfo.CurrentCulture.CompareInfo.Compare(this, strB, CompareOptions.None); }
它在内部使用了包含区域语言特性信息的Compare比较。CompareInfo.Compare方法接收一个CompareOptions枚举,各个枚举的用意可参考MSDN文档。
d) String.Compare
Compare是CompareTo的静态化版本,在内部都是对CultureInfo.CurrentCulture.CompareInfo.Compare方法进行调用。它同样返回Int32值:-1、0、1。Compare的重载版本会另外的参数,如:
public static int Compare(string strA, string strB, bool ignoreCase)
接收一个bool值ignoreCase表示是否忽略大小写。
public static int Compare(string strA, string strB, StringComparison comparisonType)
接收一个StringComparison枚举,表示要使用的区域文化信息、排序规则等。该枚举的详细可参考MSDN文档。
e) String.CompareOrdinal
CompareOrdial方法执行的是忽略区域文化信息的序号比较,不执行转换和提供国际化支持所需的系统开销,该方法可用于比较文件路径、IP、URL等字符串。很显然它的性能会比提供区域语言文化信息的Compare方法快很多。
最后,如果进行不区分大小写的比较,或是想对字符串中的大小写进行更改,建议使用 ToUpperInvariant()和ToLowerInvariant()方法,这两个方法使用了固定区域性的大小写规则,而ToUpper()方法和ToLower()方法是依赖区域语言文化信息的,所以性能会差一些。
在第2节,我们知道字符串的不可变性,如果要对字符串进行更改,则会重新创建字符串对象,这个过程会导致性能问题,CLR提供了驻留和池来提高性能,同时.NET Framework还提供了一个StringBuilder类可以高效地对字符串进行动态管理,大提高了性能,它不像我们像常说的在内部维护一个Char[]数组那么简单。在后面,我们准备专用一个章节来讨论StringBuilder。