重温CLR(十) 字符、字符串和文本处理
本章将介绍.net中处理字符和字符串的机制
字符
在.NET Framewole中,字符总是表示成16位Unicode代码值,这简化了国际化应用程序的开发。
每个字符都表示成System.Char结构(一个值类型) 的一个实例。System.Char类型提供了两个公共只读常量字段:MinValue(定义成"\0")和MaxValue(定义成'\uffff')。
为char实例调用静态GetUnicodeCategory方法,这个方法返回的是System.Globalization.UnicodeCategory枚举类型的一个值。这个值支出该字符是控制字符、货币符号、小写符号、大写符号、标点符号、数学符号,还是其他字符(Unicode定义的字符)。
为了简化开发,Char类型还提供了几个静态方法,比如IsDigit,IsLotter,IsUpper,IsLower,isNumber等。其中大多数方法都在内部调用了GetUnicodeCategory,并简单的返回true和false。注意,所有这些方法要么获取单个字符作为参数,要么获取一个string以及目标字符在这个string中的索引作为参数。
另外可调用ToLowerInvariant和ToUpperInvariant,以忽略语言文化(culture)的方式,将一个字符转换成小写和大写形式。作为另一种替代方案,ToLower和ToUpper方法将字符转换成小写和大写形式,但是转换时要使用与调用线程关联的静态CurrenCulture属性来获得。ToLower和ToUpper之所以需要语言文化信息,是因为字母大小写的转换是依赖于语言文化的。不同的语言文化,大小写的形式也不尽相同(土耳其的大写字母I上面会多一个点,是U+0130)。
除了这些静态方法,char类型还有自己的实例方法。其中,equals方法在两个char实例代表同一个16位Unicode码位的前提下返回true。CompareTo方法返回两个char实例的忽略语言文化的比较结果。
可以使用三种技术实现各种数值类型与Char实例的相互转换:
1) 转型(强制类型转换)
要将一个Char转换成为一个数值(比如Int32),最简单的方法就是转型。在三种技术中,效率也是最高的,因为编译器会生成中间语言(IL)指令来执行转型,不必调用任何方法。此外,有的语言(比如c#)允许指定转换时使用checked还是unchecked代码。
2) 使用Convert类型
System.Convert类型提供了几个静态方法来实现Char和数值类型的相互转型。所以这些方法都是以checked方式进行转换,所以一旦发现转型会造成数据丢失,就会抛出OverflowException异常。
3) 使用IConvertible接口
Char类型和FCL的所有数值类型都实现的ICOnvertible接口。该接口定义了像ToUInt16和ToChar这些的方法。这种技术效率最差,因为在值类型上调用一个接口方法,要求对实例进行装箱—— Char和所有数值类型都是值类型。如果某个类型不能转换,或者转换造成数据的丢失,IConvertible的方法会抛出System.InvalidCastException异常。
下面演示这三种方法的调用:
public static void Main() { Char c; Int32 n; // 使用C#转型技术实现,强制类型转换 c = (Char)65; Console.WriteLine(c); // 显示 "A" n = (Int32)c; Console.WriteLine(n); // 显示 "65" c = unchecked((Char)(65536 + 65)); Console.WriteLine(c); // 显示 "A" // 使用Convert进行转换 c = Convert.ToChar(65); Console.WriteLine(c); // 显示 "A" n = Convert.ToInt32(c); Console.WriteLine(n); // Displays "65" // 显示Convert的范围检查 try { c = Convert.ToChar(70000); // 对 16-bits 来说过大 Console.WriteLine(c); // 不知心 } catch (OverflowException) { Console.WriteLine("Can't convert 70000 to a Char."); } // 使用IConvertible进行转换 c = ((IConvertible)65).ToChar(null); Console.WriteLine(c); // 显示 "A" n = ((IConvertible)c).ToInt32(null); Console.WriteLine(n); // 显示 "65" }
system.string 类型
在任何应用程序中,system.string都是用得最多的类型之一。一个string代表一个不可变(immutable)的顺序字符集。string类型直接派生自object,所以是引用类型。因此,string对象总是存在于堆上,永远不会跑到线程栈。
String类型还实现了几个接口,IComparable、ICloneable等。
构造字符串
许多编程语言(包括C#)都将String视为一个基元类型——也就是说,编译器允许在源代码中直接表示文本常量字符型。编译器将这些文本常量字符串放到模块的元数据中,并在运行时加载和引用它们。
在C#中,不能使用new操作符从字面值字符串构造一个String对象
public static void Main(string[] args) { String s =new String("Hi"); //错误 Console.WriteLine(s); }
相反,必须使用简化的语法表示:
class Program { private static void Main(string[] args) { String s = "Hi"; Console.WriteLine(s); } }
编译上述代码,并检查它的IL,会看到一下内容:
.method private hidebysig static void Main(string[] args) cil managed { .entrypoint .maxstack 1 .locals init ( [0] string str) L_0000: nop L_0001: ldstr "Hi" L_0006: stloc.0 L_0007: ldloc.0 L_0008: call void [mscorlib]System.Console::WriteLine(string) L_000d: nop L_000e: ret }
IL指令的newobj用于构造一个对象的新实例。然而,上述IL代码并没有出现newobj之类,只有一个特殊的ldstr(即load string)指令,它用从元数据获得的一个文本常量字符串构造一个String对象。这证明CLR事实是用一种特殊的方式构造文本常量String对象。
C#提供了一些特殊的语法来帮助开发人员在源代码中输入文本常量字符串。对于换行符、回车符和退格这样的特殊字符,C#采用了C/C++的转义机制:
//包含回车符和换行符的字符串 String s ="Hi\r\nthere"
但是,一般不建议这么做。因为在不同的平台解释是不同的,推荐使用System.Environment中定义的NewLine属性。NewLine属性是依赖于平台的,他会一句底层平台返回恰当的字符串。
可以使用c#的+操作符将几个字符串连接成一个。
string s=”HI”+” ”+”there”;
在上述diamante中,由于所有字符串都是字面值,所以c#编译器能在编译时连接它们,最终将一个字符串放到模块的元数据中。对于非字面值字符串使用+操作符,连接则在运行时进行。运行时连接不要使用+操作符,因为这样会在堆上创建多个字符串对象,而堆是需要垃圾回收的,从而影响性能。相反,应尽量使用System.Text.StringBuilder类型。
最后,C#还提供了一种特殊的字符串声明方式(@"xxx")。采用这种方式,引号之间的所有字符都会被视为字符串的一部分。这种特殊声明称为"逐字字符串",通常用于指定文件或目录的路径,或者配合正则表达式使用。
字符串是不可变的
string对象最重要的一点就是不可变(immutable)。也就是说,字符串一经创建就不能更改,不能变长、变短或修改其中任何字符。使字符串不可变有下面几点好处:
1.它允许在字符串上执行任何操作,而不实际的更改字符串。
2.在操作或访问字符串时不会发生线程同步问题。
3.CLR可通过一个String对象共享多个完全一致的String内容。这样能减少系统中的字符串属性,从而节省内存,这就是"字符串留用"技术的目的。
考虑到性能方面的原因,String类型和CLR是紧密集成的。具体的说,CLR知道String类型中定义的字段是如何布局的,会直接访问这些字段。但是,为了获得这种性能和直接访问的好处,String只能是密封类。换言之,不能把它作为自己类型的基类。如果允许string作为基类来定义自己的类型,就能添加自己的字段,而这回破坏clr对于string类型的各种预设。
比较字符串
判断字符串相等性或对字符串进行排序时,强烈建议调用下面列出的方法之一:
bool Equals (string value, StringComparison comparisonType) static bool Equals (string a, string b, StringComparison comparisonType) static int Compare (string strA, string strB, StringComparison comparisonType) static int Compare (String strA, String strB, bool ignoreCase, CultureInfo culture) static int Compare (string strA, string strB, CultureInfo culture, CompareOptions options) static int Compare (string strA, int indexA, string strB, int indexB, int length, StringComparison comparisonType) static int Compare (string strA, int indexA, string strB, int indexB, int length, CultureInfo culture, CompareOptions options) static int Compare (String strA, int indexA, String strB, int indexB, int length, bool ignoreCase, CultureInfo culture)
排序时应该总是执行区分大小写的比较。原因是假如只是大小写不同的两个字符串被视为相等,那么每次对它们进行排序许,它们都可能按照不同的顺序排列,从而造成用户的迷惑。
上述代码中的comparisonType参数要求获取由System.StringComparison枚举类型定义的某个值。这个枚举类型是这样定义的:
public enum StringComparison { //使用区域敏感排序规则和当前区域比较字符串。 CurrentCulture, //使用区域敏感排序规则、当前区域来比较字符串,同时忽略被比较字符串的大小写。 CurrentCultureIgnoreCase, //使用区域敏感排序规则和固定区域比较字符串。 InvariantCulture, //使用区域敏感排序规则、固定区域来比较字符串,同时忽略被比较字符串的大小写。 InvariantCultureIgnoreCase, //使用序号排序规则比较字符串。 Ordinal, //使用序号排序规则并忽略被比较字符串的大小写,对字符串进行比较。 OrdinalIgnoreCase }
另外,前面有两个方法要求传递一个CompareOptions参数。这个参数要获取有CompareOptions枚举类型定义的一个值:
public enum CompareOptions { None = 0, //指示字符串比较必须忽略大小写。 IgnoreCase = 1, //指示字符串比较必须忽略不占空间的组合字符,比如音调符号。 IgnoreNonSpace = 2, //指示字符串比较必须忽略符号,如空白字符、标点符号、货币符号、百分号、数学符号、“&”符等等 IgnoreSymbols = 4, //指示字符串比较必须忽略 Kana 类型 IgnoreKanaType = 8, //指示字符串比较必须忽略字符宽度 IgnoreWidth = 16, //指示字符串比较必须使用字符串排序算法。 StringSort = 0x20000000, //指示必须使用字符串的连续 Unicode UTF-16 编码值进行字符串比较(使用代码单元进行代码单元比较),这样可以提高比较速度,但不能区分区域性 Ordinal = 0x40000000, //字符串比较必须忽略大小写,然后执行序号比较。 OrdinalIgnoreCase = 0x10000000 }
接受一个CompareOptions实参的方法要求你必须显式传递一个语言文化。如果传递了Ordinal或OrdinalIgnoreCase 标志,这些Comoare方法会忽略指定的语言文化。
许多程序都将字符串用于内部编程目的,比如路径名、文件名、URL、注册表项/值等等。这些字符串通常只在程序内部使用,不会向用户显示。出于编程目的而比较字符串时,应该总是使用StringComparison.Ordinal或者CompareOptions.OrdinalIgnoreCase。这是字符串比较时最快的一种方式,因为在执行比较时,不需要考虑语言文化信息。
另一方面,如果想以一种语言文化正确的方式来比较字符串(通常显示给用户),应该使用StringComparison.CurrentCulture或者StringComparison.CurrentCultureIgnoreCase。
提示:StringComparison.InvariantCulture和StringComparison.InvariantCultureIgnoreCase平时最好不要用。虽然这两个值能保证比较是语言文化的正确性,但用它们比较用于内部编程目的的字符串,花费的事件要比执行一次序号比较长的多。
提示:执行序号比较之前,如果(想更改字符串中的字符的大小写,应该使用String的ToUpperInvariant和ToLowerInvariant方法。对字符串进行正规化时,强烈建议使用ToUpperInvariant方法,而不要使用ToLowerInvariant方法,应为Microsoft对执行大写比较的代码进行了优化。事实上,执行不需要区分大小写的比较之前,FCL会自动将字符串正规化为大写形式。之所以不用ToUpper和ToLower方法,是因为它们对语言文化敏感。
字符串留用
检查字符串的相等性是许多应用程序的常见操作——这个任务可能验证损害性能。执行序号(ordinal)相等性检查时,CLR快速测试两个字符串是否包含相同数量的字符。如果是否定,字符串肯定不相等;如果肯定,字符串可能相等。然后,CLR必须比较每个单独的字符才能确定。值得注意的是,在执行需要注意语言文化的比较是,CLR始终都要比较所有单独的字符,因为两个字符串即使长度不同,也可能是相等的。
除此之外,如果在内存中复制同一个字符串的多个实例,会造成内存的浪费,因为字符串是"不可变"的。如果只在内存中保留字符串的一个实例,那么将显著提高内存的利用率。需要引用字符串的所有变量只需指向单独一个字符串对象。
如果引用程序经常对字符串进行区分大小写、序号式比较,或者事先知道许多字符串对象都有相同的值,就可以利用CLR的"字符串留用"机制来显著提高性能。CLR初始化时会创建一个内部哈希表。在这个表中,键(key)是字符串,而值(value)是对托管堆中String对象的引用。哈希表最开始是空的,String类提供了两个方法,便于你访问这个内部哈希表:
//检索系统对指定 System.String 的引用 public static string Intern(string str) //检索对指定 System.String 的引用 public static string IsInterned(string str)
第一个方法Intern获取一个String,获得它的哈希码,并在内部哈希表中检查是否有匹配的。如果存在一个完全相同的字符串,就返回对这个字符串已经存在的String对象的一个引用。如果不存在,就创建字符串的副本,将副本添加到内部哈希表中,并返回对这个副本的一个引用。如果应用程序不再保持对string对象的引用,垃圾回收器就可释放那个字符串的内存。注意垃圾回收器不能释放内部哈希表引用的字符串,因为哈希表正在容纳对它们的引用。除非卸载appdomain或进程终止,否则内部哈希表引用的string对象不能被释放。
和Intern方法一样,IsInterned方法也获取一个String,并在内部哈希表中查找它。如果哈希表中有一个匹配的字符串,IIsInterned就返回对这个留用的字符串对象的一个引用。然而,如果哈希表中没有一个相匹配的字符串,IsInterned会返回null;它不会将字符串添加到哈希表中。
程序集加载时,CLR默认会留用程序集的元数据中描述的所有文本常量(literal)字符串。Microsoft知道可能因为额外的哈希表查找会造成性能显著下降,所以现在是可以禁用这个"特性"的。即使指定了CLR不留用那个程序集中的字符串,但是CLR也可能选择对字符串进行留用,但不应该依赖于CLR的这种"自主"行为。事实上,除非自己显式调用String的Intern方法,否则永远都不要以"字符串已留用"为前提来写自己的代码。以下代码演示了字符串留用:
String s1 = "Hello"; String s2 = "Hello"; Console.WriteLine(Object.ReferenceEquals(s1, s2));// 'False' s1 = String.Intern(s1); s2 = String.Intern(s2); Console.WriteLine(Object.ReferenceEquals(s1, s2));// 'True'
在对ReferenceEquals方法的第一个调用中,s1s2中的"Hello"字符串对象的引用是不同的,所以应该显示False。然而,如果在CLR的4.5版本上运行,会发现显示True。因为这个版本的CLR选择了忽略C#编译器插入的特性和标志。程序集集加载到appDomain中时,clr对字面量(literal)字符串进行留用,结果是s1和s2引用堆中的同一个hello字符串。如前所述,你的代码永远不要依赖这个行为,因为未来版本clr有可能进行变更。
字符串池
编译源代码时,编译器必须处理每个字面值字符串,并在托管模块的元数据上嵌入。铜一个字符串在源代码中多次出现,把它们都嵌入元数据会使生成的文件无谓增大。
为了解决这个问题,许多编译器(包括C#编译器)都只在模块的元数据中将字面值字符串写入一次。引用该字符串的所有代码都会被修改,以引用元数据中的同一个字符串。编译器这种将单个字符串的多个实例合并为一个实例的做法,可以显著减少模块大小。c/c++编译器多年来一直采用这个技术。
检查字符串中的字符和文本元素
虽然字符串比较对于排序和测试相等性很有用,但有时只是想检查一下字符串中的字符。string类型为此提供了几个属性和方法,包括Length,chars,getEnumerator,Contains,IndexOf等。
其他字符串操作
还可以利用string类型提供的一些方法来赋值整个字符串或者它的一部分,比如clone,copy,substring等。
除了这些方法,string还提供了多个用于处理字符串的静态方法和实例方法,比如insert,remove,padleft,replace,split,join,toUpper,trim,concat,format等,使用所有这些方法时都请牢记一点,它们返回的都是新的字符串对象。
高效率构造字符串
由于String类型是一个不可变的字符串,所以FCL提供了另一个名为System.Text.StringBuilder的类型,可利用它高效率得对字符串和字符进行动态代理,最后基于处理结果创建一个String。可将StringBuilder想象成创建string对象的特殊构造器。你的方法一般应获取string参数而非StringBuilder参数。
从逻辑上说,StringBuilder对象包含一个字段,该字段引用了有Char结构构成的一个数组。可利用StringBuilder的各个成员来操作这个字符数组,高效率的缩短或更改字符串中的字符。如果字符串变大,超过已分配的字符数组的大小,StringBuilder会自动分配一个新的、更大的数组,复制字符,并开始使用新数组。前一个数组会被垃圾回收。
用StringBuilder对象构造好字符串后,调用StringBuilder的tostring方法即可将StringBuilder的字符串数组转换成String。这样会在堆上新建String对象。
和string类不同,clr不觉得StringBuilder类有什么特别。此外,大多数语言(包括c#)都不将StringBuilder类视为基元类型。
使用StringBuilder的方法要注意,大多数方法返回的都是对同一个StringBuilder对象的引用,所以几个操作能连接到一起完成。
获取对象的字符串表示:ToString
在.NET Framework中可以调用ToString方法来和获取任何对象的字符串表示。System.Object定义了一个public、virtual、无参的ToString方法,所以在任何类型的一个实例上都能调用该方法。在语义上,ToString返回代表对象当前值的一个字符串,而且这个字符串应该根据调用线程当前的语言文化进行格式化。
System.Object实现的ToString只是返回对象所属类型的全名。这个值用处不大,但对许多不能提供有意义的字符串的类型来说,这也是一个合理的默认值。例如,一个FileStream或HashTable对象的字符串表示应该是什么呢?
任何类型要想提供合理的方式获取对象当前值的字符串表示,就应该重写ToString方法。FCL内建的许多核心类型(byte,int32,Uint64,Double等)都重写了ToString,能返回复合语言文化的字符串。
将多个对象格式化成一个字符串
可以使用string.Format方法,对于stringBuilder可以使用AppendFormat。
解析字符串来获取对象:parse
能解析一个字符串的任何类型都提供了一个名为Parse的public static方法。该方法获取一个String对象,并返回类型的一个实例。从某种意义上说,Parse扮演了一个工厂的角色。在FCL中,所有数值类型,DateTime、TimeSpan以及一些其他类型均提供了Parse方法。
编码:字符和字节的相互转换
win32开发人员经常要写代码将unicode字符和字符串转换成“多字节字符集”(multi-Byte Character Set,MBCS)格式。我个人就经常写这样的代码,这个过程很繁琐,还容易出错。在clr中,所有字符都表示成16位Uncode码值,而所有字符串都由16位Unicode码值构成,这简化了运行时的字符和字符串处理。
但偶尔想要将字符串保存到文件中,或者通过网络传输。如果字符串中的大多数字符都是英语用户的,那么保存或传输一系列16位值,效率就显得不那么理想,因为写入的半数字节都只有零构成。相反,更有效的做法是将16位值编码成压缩的字节数组,以后再将字节数组解码回16位值的数组。
用system.IO.BinaryWriter或者system.IO.StreamWriter类型将字符串发送给文件或网络流时,通常要进行编码。对应地,system.IO.BinaryReader或者system.IO.StreamReader类型从文件或网络流中读取字符串时,通常要进行解码。不显式指定一种编码方案,所有这些类型都默认使用UTF-8。
FCL提供了一些类型来简化字符编码和解码。两种最常用的编码方案是UTF-16和UTF-8。
UTF-16将每个16位字符编码成2个字节。不会对字符产生任何影响,也不发生压缩---性能非常出色。UTF-16编码也称为Unicode编码。
UTF-8将部分字符编码成1个字节,部分编码成2个字节,部分编码成3个字节或4个字节。值在0x0080之下的字符压缩成1个字节,适合表示美国使用字符。0x00800~0x07FF的字符转换成2个字节,适合欧洲和中东语言。0x0800以及之上的字符转换成3个字节,适合东亚语言。最后代理项对表示成4个字节。0x0080编码方法非常流行,但如果要编码的许多字符都具有0x0800或者之上的值,效率不如UTF-16
ASCII编码方案将16位字符编码从ASCII字符:也就是说,值小于0x0080的16位字符被转换成单字节。值超过0x007F的任何字符都不能被转换,否则字符的值会丢失。
字符和字节流的编码和解码
假定现在要通过system.net.Sockets.NetworkStream对象来读取一个UTF-16编码字符串。字节流通常以数据块(data chunk)的形式传输。换言之,可能先从流中读取5个字节,在读取7个字节。UTF-16的每个字符都由两个字节构成。所以,调用Encoding的GetString方法并传递第一个5字节数组,返回的字符串只包含2个字符。再次调用GetString并传递接着的7个字节,将返回只包含3个字节的字符串。显然,这样会存储错误的值。
之所以会造成数据损坏,是由于所有Encoding派生都不维护多个方法调用之间的状态。要编码或解码以数据块形式传输的字符/字节,必须进行一些额外的工作来维护方法调用之间的状态,从而防止丢失数据。
字节块解码首先要获取一个Encoding派生对象引用,在调用其GetDecoder方法。Decoder的所有派生类都提供了两个重要方法:getChars和getCharCount。显然,这些方法的作用是对字节数组进行编码,调用其中一个方法时,它会尽可能多地解码字节数组。假如字节数组包含的字节不足以完成一个字符,剩余的字节会保存到Decoder对象内部。下次调用其中一个方法时,Decoder对象会利用之前剩余的字节再加上传给它的新字节数组。这样一来,就可以确保对数据块进行正确编码。从流中读取字节时Decoder对象的作用很大。
从Encoding派生的类型可用于无状态编码和解码。而从decoder派生的类型只能用于解码。
编码转码实例
string str = "在下坂本,有何贵干@@"; Char[] chars = str.ToCharArray(); Debug.Log("String="+new string(chars)); //获得Encoder实例; Encoder encoder = Encoding.UTF8.GetEncoder(); //计算字符序列化需要的字节数组长度; byte[] bytes = new byte[encoder.GetByteCount(chars,0,chars.Length,true)]; //通过GetBytes转为字节序列; encoder.GetBytes(chars, 0, chars.Length, bytes, 0, true); Debug.Log(BitConverter.ToString(bytes)); Debug.Log("Encoding.UTF8.GetString=" + Encoding.UTF8.GetString(bytes)); //获得Decoder实例; Decoder decoder = Encoding.UTF8.GetDecoder(); //计算字节数组对应的字符数组长度; int charSize = decoder.GetCharCount(bytes, 0, bytes.Length); Char[] chs = new char[charSize]; //进行字符转换; int charLength = decoder.GetChars(bytes, 0, bytes.Length, chs, 0); Debug.Log("Decoder Bytes to String =" + new string(chs));