8.3 如此特殊:大话String
本节将介绍以下内容:
String类型解析
字符串恒定与字符串驻留
StringBuilder应用与对比
8.3.1 引言
String类型很特殊,算是.NET大家庭中少有的异类,它是如此的与众不同,使我们无法忽视它的存在。本节就是这样一篇关于String类型及其特殊性讨论的话题,通过逐层解析来解密System.String类型。
那么,String究竟特殊在哪里?
l 创建特殊性:String对象不以newobj指令创建,而是ldstr指令创建。在实现机制上,CLR给了特殊照顾来优化其性能。
l String类型是.NET中不变模式的经典应用,在CLR内部由特定的控制器来专门处理String对象。
l 应用上,String类型表现为值类型语义;内存上,String类型实现为引用类型,存储在托管堆中。
l 两次创建内容相同的String对象可以指向相同的内存地址。
l String类型被实现为密封类,不可在子类中继承。
l String类型是跨应用程序域的,可以在不同的应用程序域中访问同一String对象。
然而,将String类型认清看透并非易事,根据上面的特殊问题,我们给出具体的答案,为String类型的各个难点解惑,最后再给出应用的常见方法和典型操作。
8.3.2 字符串创建
string类型是C#基元类型,对应于FCL中的System.String类型,是.NET中使用最频繁,应用最广泛的基本类型之一。其创建与实例化过程非常简单,在操作方式上类似与其他基元类型int、char等,例如:
string mystr = "Hello";
分析IL可知,CLR使用ldstr指令从元数据中获取文本常量来加载字符串,而以典型的new方式来创建:
String mystr2 = new String("Hello");
会导致编译错误。因为System.String只提供了数个接受Char*、Char[]类型的构造函数,例如:
Char[] cs = {'a', 'b', 'c'};
String strArr = new String(cs);
在.NET中很少使用构造器方式来创建string对象,更多的方式还是以加载字符常量的方式来完成,关于String类型的创建,我们在3.4节“经典指令解析之实例创建”中已有详细的本质分析,详细情况请参阅。
8.3.3 字符串恒定性
字符串恒定性(Immutability),是指字符串一经创建,就不可改变。这是String对象最为重要的特性之一,是CLR高度集成String以提高其性能的考虑。具体而言,字符串一旦创建,就会在托管堆上分配一块连续的内存空间,我们对其的任何改变都不会影响到原String对象,而是重新创建出新的String对象,例如:
public static void Main()
{
string str = "This is a test about immutablitity of string type.";
Console.WriteLine(str.Insert(0, "Hi, ").Substring(19).ToUpper());
Console.WriteLine(str);
}
在上例中,我们对str对象完成一系列的修改:增加、取子串和大写格式改变等操作,从结果输出上来看str依然保持原来的值不变。而Insert、Substring和ToUpper方法都会创建出新的临时字符串,而这些新对象不被其他代码所引用,因此成为下次垃圾回收的目标,从而造成了性能上的损失。
之所以特殊化处理String具有恒定性的特点,源于CLR对其的处理机制:String类型是不变模式在.NET中的典型应用,String对象从应用角度体现了值类型语义,而从内存角度实现为引用类型存储,位于托管堆。
对象恒定性,为程序设计带来了极大的好处,主要包括为:
l 保证对String对象的任意操作不会改变原字符串。
l 恒定性还意味着操作字符串不会出现线程同步问题。
l 恒定性一定程度上,成就了字符串驻留。
对象恒定性,还意味着String类型必须为密封类,例如String类型的定义为:
public sealed class String : IComparable, ICloneable, IConvertible, Icomparable <string>, IEnumerable<char>, IEnumerable, IEquatable<string>
如果可以在子类中继承String类型,则必然有可能破坏CLR对String类型的特殊处理机制,也会破坏String类型的恒定性。
8.3.4 字符串驻留
关于字符串驻留,我们以一个简单的示例开始:
class StringInterning
{
public static void Main()
{
string strA = "abcdef";
string strB = "abcdef";
Console.WriteLine(ReferenceEquals(strA, strB));
string strC = "abc";
string strD = strC + "def";
Console.WriteLine(ReferenceEquals(strA, strD));
strD = String.Intern(strD);
Console.WriteLine(ReferenceEquals(strA, strD));
}
}
//执行结果:
//True
//False
//True
上述示例,会给我们三个意外,也是关于执行结果的意外:首先,strA和strB为两个不同的String对象,按照一般的分析两次创建的不同对象,CLR将为其在托管堆分配不同的内存块,而ReferenceEquals方法用于判断两个引用是否指向同一对象实例,从结果来看strA和strB显然指向了同一内存地址;其次,strD和strA在内容上也是一样的,然而其ReferenceEquals方法返回的结果为False,显然strA和strD并没有指向相同的内存块;最后,以静态方法Intern操作strD后,二者又指向了相同的对象,ReferenceEquals方法又返回True。
要想解释以上疑惑,只有请字符串驻留(String Interning)登场了。下面我们通过对字符串驻留技术的分析,来一步一步解开上述示例的种种疑惑。
缘起
String类型区别于其他类型的最大特点是其恒定性。对字符串的任何操作,包括字符串比较,字符串链接,字符串格式化等会创建新的字符串,从而伴随着性能与内存的双重损耗。而String类型本身又是.NET中使用最频繁、应用最广泛的基本类型,因此CLR有必要有针对性的对其性能问题,采取特殊的解决办法。
事实上,CLR以字符串驻留机制来解决这一问题:对于相同的字符串,CLR不会为其分别分配内存空间,而是共享同一内存。因此,有两个问题显得尤为重要:
l 一方面,CLR必须提供特殊的处理结构,来维护对相同字符串共享内存的机制。
l 另一方面,CLR必须通过查找来添加新构造的字符串对象到其特定结构中。
的确如此,CLR内部维护了一个哈希表(Hash Table)来管理其创建的大部分string对象。其中,Key为string本身,而Value为分配给对应的string的内存地址。我们以一个简单的图例(图8-1)来说明这一问题。

图8-1 string的内存概况
细节
我们一步一步分析上述示例的执行过程,然后才能从总体上对字符串驻留机制有所了解。
string strA = "abcdef";
CLR初始化时,会创建一个空哈希表,当JIT编译方法时,会首先在哈希表中查找每一个字符串常量,显然第一次它不会找到任何“abcdef”常量,因此会在托管堆中创建一个新的string对象strA,并在哈希表中创建一个Key-Value对,将“abcdef”串赋给Key,而将strA对象的引用赋给Value,也就是说Value内保持了指向“abcdef”字符串在托管堆中的引用地址。这样就完成了第一次字符串的创建过程。
string strB = "abcdef";
程序接着运行,JIT根据“abcdef”在哈希表中逐个查找,结果找到了该字符串,所以JIT不会执行任何操作,只是把找到的Key-Value对的Value值赋给strB对象。由此可知,strA和strB具有相同的内存引用,所以ReferenceEquals方法当然返回true。
string strC = "abc";
string strD = strC + "def";
接着,JIT以类似的过程来向哈希表中添加了“abc”字符串,并将引用返回给strC对象;但是strD对象的创建过程又有所区别,因为strD是动态生成的字符串,这样的字符串是不会被添加到哈希表中维护的,因此以ReferenceEquals来比较strA和strD会返回false。
对于动态生成的字符串,因为没有添加到CLR内部维护的哈希表而使字符串驻留机制失效。但是,当我们需要高效的比较两个字符串是否相等时,可以手工启用字符串驻留机制,这就是调用String类型的两个静态方法,它们是:
public static string Intern(string str);
public static string IsInterned(string str);
二者的处理机制都是在哈希表中查找是否存在str参数字符串,如果找到就返回已存在的String对象的引用,否则Intern方法将该str字符串添加到哈希表中,并返回引用;而IsInterned方法则不会向哈希表中添加字符串,而只是返回null。例如,
strD = String.Intern(strD);
Console.WriteLine(ReferenceEquals(strA, strD));
我们就很容易解释上述代码的执行结果了。
补充
综上所述,当一个引用字符串的方法被编译时,所有的字符串常量都会被以这种方式添加到该哈希表中,但是动态生成的字符串并未执行字符串驻留机制。值得注意的是,下面的代码执行结果又会有所不同:
public static void Main()
{
string strA = "abcdef";
string strC = "abc";
string strD = strC + "def";
Console.WriteLine(ReferenceEquals(strA, strD));
string strE = "abc" + "def";
Console.WriteLine(ReferenceEquals(strA, strE));
}
由结果可知,strA和strD指向不同的对象;而strA与strE指向相同的对象。我们将上述代码翻译为IL代码:
IL_0001: ldstr "abcdef"
IL_0006: stloc.0
IL_0007: ldstr "abc"
IL_000c: stloc.1
IL_000d: ldloc.1
IL_000e: ldstr "def"
IL_0013: call string [mscorlib]System.String::Concat(string,
string)
……部分省略……
IL_0026: ldstr "abcdef"
IL_002b: stloc.3
由IL分析可知,动态生成字符串时,CLR调用了System::Concat来执行字符串链接;而直接赋值strE = “abc” + “def”的操作,编译器会自动将其连接为一个文本常量加载,因此会添加到内部哈希表中,这也是为什么最后strA和strE指向同一对象的原因了。
最后,需要特别指出的是:字符串驻留是进程级的,可以跨应用程序域(AppDomain)而存在。垃圾回收不能释放哈希表中引用的字符串对象,只有进程结束这些对象才会被释放。因此,String类型的特殊性还表现在同一个字符串对象可以在不同的应用程序域中被访问,从而突破了AppDomain的隔离机制,其原因还是源于字符串的恒定性,因为是不可变的,所以根本没有必要再隔离。
8.3.5 字符串操作典籍
本节从几个相对孤立的角度来描述String类型,包括了不同操作、常用方法和典型问题几个方面。
1.字符串类型与其他基元类型的转换
String类型可以与其他基本类型直接进行转换,在此以System.Double类型与System.String类型的转换为例,来简要说明二者转换的几个简单的方法及其区别。
Double类型转换为String类型:
Double num = 123.456;
string str = num.ToString();
Double类型覆写了ToString方法用于返回对象的值。
String类型转换为Double类型,有多种方法可供选择:
string str = "123.456";
Double num= 0.0;
num = Double.Parse(str);
Double.TryParse(str, out num);
num = Convert.ToDouble(str);
这三种方法的区别主要是对异常的处理机制上:如果转换失败,则Parse方法总会抛出异常,主要包括ArgumentNullException、OverflowException、FormatException等;TryParse则不会抛出任何异常,而返回false标志解析失败;Convert方法在str为null时不会抛出异常,而是返回0。
其他的基元类型,例如Int32、Char、Byte、Boolean、Single等均提供了上述方法实现与String类型进行一定程度的转换,同时对于特定的格式化转换可以参考上述方法的各个重载版本,限于篇幅,此不赘述。
2.转义字符和字面字符串
l 使用转义字符来实现特定格式字符串
对于在C++等语言中熟悉的转义字符串,在.NET中同样适用,例如C#语言提供了相应的实现版本:
string strName = "Name:\n\t\"小雨\"";
上述示例实现了回车和Tab空格操作,并为“小雨”添加了双引号。
l 在文件和目录路径、数据库连接字符串和正则表达式中广泛应用的字面字符串(verbatim string),为C#提供了声明字符串的特殊方式,用于将引号之间的所有字符视为字符串的一部分,例如:
string strPath = @"C:\Program Files \Mynet.exe";
上述代码,完全等效于:
string strPath = "C:\\Program Files \\Mynet.exe";
而以下代码则导致被提示“无法识别的转义序列”的编译错误:
string strPath = "C:\Program Files \Mynet.exe";
显然,以@实现的字面字符串更具可读性,克服了转义字符串带来的阅读障碍。
3.关于string和System.String
string与System.String常常使很多初学者感到困惑。实际上,string和System.String编译为IL代码时,会生成完全相同的代码。那么关于string和System.String我们应该了解的是其概念上的细微差别。
l string为C#语言的基元类型,类似于int、char和long等其他C#基元类型,基元类型简化了语言代码,带来简便的可读性,不同高级语言对同一基元类型的标识符可能有所不同。
l System.String是框架类库(FCL)的基本类型,string和System.String有直接的映射关系。
l 从IL角度来看,string和System.String之间没有任何不同。同样的情况,还存在于其他的基元类型,例如:int和System.Int32,long和System.Int64,float和System.Single,以及object和System.Object等。
4.String类型参数的传递问题
有一个足以引起关注的问题是,String类型作为参数传递时,以按值传递和按引用传递时所表现的不同:
class StringArgument
{
public static void Main()
{
string strA = "String A";
string strB = "String B";
//参数为String类型的按值传递(strA)和按引用传递(strB)
ChangeString(strA, ref strB);
Console.WriteLine(strA);
Console.WriteLine(strB);
}
private static void ChangeString(string stra, ref string strb)
{
stra = "Changing String A";
strb = "Changing String B";
}
}
//执行结果
//String A
//Changing String B
String作为典型的引用类型,其作为参数传递也代表了典型的引用类型按值传递和按引用传递的区别,可以小结为:
l 默认情况为按值传递,strA参数所示,传递strA的值,也就是指向“String A”的引用;
l ref标识了按引用传递,strB参数所示,传递的是原引用的引用,也就是传递一个到strB本身的引用,这区别于到“String B”的引用这个概念,二者不是相同的概念。
因此,默认情况下,string类型也是按值传递的,只是这个“值”是指向字符串实例的引用而已,关于参数传递的详细描述请参考4.3节“参数之惑---传递的艺术”。
5.其他常用方法
表8-1对System.String的常用方法做以简单说明,而不以示例展开,这些方法广泛的应用在平常的字符串处理操作中,因此有必要做以说明。
表8-1 System.String类型的常用方法
|
常用方法 |
方法说明 |
|
ToString |
ToString方法是System.Object提供的虚方法,用于返回对象的字符串表达形式,可以获取格式化或者带有语言文化信息的实例信息 |
|
SubString |
用于获取子字符串,FCL提供了两个重载版本,可以指定起始位置和长度 |
|
Split |
返回包含此实例中由指定Char或者String元素隔开的子字符串的 String 数组 |
|
StartsWith、EndsWith |
StartsWith用于判断字符串是否以指定内容开始;而EndsWith用于判断字符串是否以指定内容结尾 |
|
ToUpper、ToLower |
ToUpper用于返回实例的大写版本;而ToLower用于返回实例的小写版本 |
|
IndexOf、LastIndexOf |
IndexOf用于返回匹配项的第一个的索引位置;LastIndexOf用于返回匹配项的最后一个索引位置 |
|
Insert、Remove |
Insert用于向指定位置插入指定的字符串;Remove用于从实例中删除指定个数的字符串 |
|
Trim、TrimStart、TrimEnd |
Trim方法用于从实例开始和末尾位置,移除指定字符的所有匹配项;TrimStart用于从实例开始位置,移除指定字符的所有匹配项;TrimEnd用于从实例结束位置,移除指定字符的所有匹配项 |
|
Copy、CopyTo |
Copy为静态方法,CopyTo为实例方法,都是用于拷贝实例内容给新的String对象。其中CopyTo方法可以指定起始位置,拷贝个数等信息 |
|
Compare、CompareOrdinal、CompareTo |
Compare为静态方法,用于返回两个字符串间的排序情况,并且允许指定语言文化信息;CompareOrdinal为静态方法,按照字符串中的码值比较字符集,并返回比较结果,为0表示结果相等,为负表示第一个字符串小,为正表示第一个字符串大;而CompareTo是实例方法,用于返回两个字符串的排序,不允许指定语言文化信息,因为该方法总是使用当前线程相关联的语言文化信息 |
|
Concat、Join |
均为静态方法。Concat用于连接一个或者多个字符串;Join用于以指定分隔符来串联String数组的各个元素,并返回新的String实例 |
|
Format |
静态方法。用于格式化String对象为指定的格式或语言文化信息 |
8.3.6 补充的礼物:StringBuilder
String对象是恒定不变的,而System.Text.StringBuilder对象表示的字符串是可变的。StringBuilder是.NET提供的动态创建String对象的高效方式,以克服String对象恒定性带来的性能影响,克服了对String对象进行多次修改带来的创建大量String对象的问题。因此,我们首先将二者的执行性能做以简单的比较:
public static void Main()
{
#region 性能比较
Stopwatch sw = Stopwatch.StartNew();
//String性能测试
string str = "";
for (int i = 0; i < 10000; i++)
str += i.ToString();
sw.Stop();
Console.WriteLine(sw.ElapsedMilliseconds);
//StringBuilder性能测试
sw.Reset();
sw.Start();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++)
sb.Append(i.ToString());
sw.Stop();
Console.WriteLine(sw.ElapsedMilliseconds);
#endregion
}
//执行结果
//422
//3
创建同样的字符串过程,执行结果有百倍之多的性能差别,而且这种差别会随着累加次数的增加而增加。因此,基于性能的考虑,我们应该尽可能使用StringBuilder来动态创建字符串,然后以ToString方法将其转换为String对象应用。StringBuilder内部有一个指向Char数值的字段,StringBuilder正是通过操作该字符数组而实现高效的处理机制。
1.构造StringBuilder
StringBuilder对象的实例化没有什么特殊可言,与其他对象实例化一样,典型的构造方式为:
StringBuilder sb = new StringBuilder("Hello, word.", 20);
其中,第二个参数表示容量,也就是StringBuilder所维护的字符数组的长度,默认为16,可以设定其为合适的长度来避免不必要的垃圾回收;还有一个概念为最大容量,表示字符串所能容纳字符的最大个数,默认为Int32.MaxValue,对象创建时一经设定就不可更改;字符串长度表示当前StringBuilder对象的字符数组长度,可以使用Length属性来获取和设定当前的StringBuilder长度。
2.StringBuilder的常用方法
(1)ToString方法
返回一个StringBuilder中字符数组字段的String,因为不必拷贝字符数组,所以执行效率很高,是最常用的方法之一。不过,值得注意的是,在调用了StringBuilder的ToString方法之后,都会导致StringBuilder重新分配和创建新的字符数组,因为ToString方法返回的String必须是恒定的。
(2)Append/AppendFormat方法
用于将文本或者对象字符串添加到当前StringBuilder字符数组中,例如:
StringBuilder sbs = new StringBuilder("Hello, ");
sbs.Append("Word.");
Console.WriteLine(sbs);
//执行结果
//Hello, Word.
而AppendFormat方法进一步实现了IFormattable接口,可接受IFormatProvider类型参数来实现可格式化的字符串信息,例如:
StringBuilder formatStr = new StringBuilder("The price is ");
formatStr.AppendFormat("{0:C}", 22);
formatStr.AppendFormat("\r\nThe Date is {0:D}", DateTime.Now.Date);
Console.WriteLine(formatStr);
(3)Insert方法
用于将文本或字符串对象添加到指定位置,例如:
StringBuilder mysb = new StringBuilder("My name XiaoWang");
mysb.Insert(8, "is ");
Console.WriteLine(mysb);
//执行结果
//My name is XiaoWang
(4)Replace方法
Replace方法是一种重要的字符串操作方法,用来将字符串数组中的一个字符或字符串替换为另外一个字符或字符串,例如:
StringBuilder sb = new StringBuilder("I love game.");
sb.Replace("game", ".NET");
Console.WriteLine(sb);
//执行结果
//I love .NET.
限于篇幅,我们不再列举其他方法,例如Remove、Equals、AppendLine等,留于读者自己来探索StringBuilder带来的快捷操作。
3.再论性能
StringBuilder有诸多的好处,是否可以代替String呢?基于这个问题我们有如下的对比性分析:
l String是恒定的;而StringBuilder是可变的。
l 对于简单的字符串连接操作,在性能上StringBuilder不一定总是优于String。因为StringBuilder对象的创建代价较大,在字符串连接目标较少的情况下,过度滥用StringBuilder会导致性能的浪费而非节约。只有大量的或者无法预知次数的字符串操作,才考虑以StringBuilder来实现。事实上,本节开始的示例如果将连接次数设置为一百次以内,就根本看不出二者的性能差别。
l String类型的“+”连接操作,实际上是重载操作符“+”调用String.Concat来操作,而编译器则会优化这种连接操作的处理,编译器根据其传入参数的个数,一次性分配相应的内存,并依次拷入相应的字符串。
l StringBuilder在使用上,最好指定合适的容量值,否则由于默认容量不足而频繁的进行内存分配操作,是不妥的实现方法。
l 通常情况下,进行简单字符串连接时,应该优先考虑使用String.Concat和String.Join等操作来完成字符串的连接,但是应该留意String.Concat可能存在的装箱操作。
8.3.7 结论
最后,回答为什么特殊?
String类型是所有系统中使用最频繁的类型,以致于CLR必须考虑为其实现特定的实现方式,例如System.Object基类就提供了ToString虚方法,一切.NET类型都可以使用ToString方法来获取对象的字符串表达。因此,String类型紧密地集成于CLR,CLR可以直接访问String类型的内存布局,以一系列解决方案来优化其执行。






