在本书的第一部分,我们一直在使用字符串,并说明C#中string关键字的映射实际上指向.NET 基类System.String。System.String是一个功能非常强大且用途非常广泛的基类,但它不是.NET中唯一与字符串相关的类。本章首先复习一下System.String的特性,再介绍如何使用其他的.NET类来处理字符串,特别是System.Text 和 System.Text.Regular Expressions命名空间中的类。本章主要介绍下述内容:
创建字符串:如果多次修改一个字符串,例如,在显示字符串或将其传递给其他方法或应用程序前,创建一个较长的字符串,String类就会变得效率低下。对于这种情况,应使用另一个类System.Text.StringBuilder,因为它是专门为这种情况设计的。
格式化表达式:这些表达式将用于后面几章中的Console.WriteLine()方法。格式化表达式使用两个有效的接口IFormatProvider和IFormattable来处理。在自己的类上执行这两个接口,就可以定义自己的格式化序列,这样,Console.WriteLine()和类似的类就可以以指定的方式显示类的值。
正则表达式:.NET还提供了一些非常复杂的类来识别字符串,或从长字符串中提取满足某些复杂条件的子字符串。例如,找出字符串中重复出现的某个字符或一组字符,或者找出以s开头、且至少包含一个n的所有单词,或者找出遵循雇员ID或社会安全号码约定的字符串。虽然可以使用String类,编写方法来执行这类处理,但这类方法编写起来比较繁琐,而使用System.Text.RegularExpressions命名空间中的类就比较简单,System.Text. RegularExpressions专门用于执行这类处理。
在介绍其他字符串类之前,先快速复习一下String类上一些可用的方法。
System.String是一个类,专门用于存储字符串,允许对字符串进行许多操作。由于这种数据类型非常重要,C#提供了它自己的关键字和相关的语法,以便于使用这个类来处理字符串。
使用运算符重载可以连接字符串:
string message1 = "Hello"; //return "Hello"
message1 += ", There"; // return "Hello, There "
string message2 = message1 + "!"; // return "Hello, There!"
C#还允许使用类似于索引器的语法来提取指定的字符:
char char4 = message[4]; // returns 'a'. Note the char is zero-indexed
这个类可以完成许多常见的任务,例如替换字符、删除空白和把字母变成大写形式等。可用的方法如表8-1所示。
表 8-1
|
方 法 |
作 用 |
|
Compare |
比较字符串的内容,考虑文化背景(区域),确定某些字符是否相等 |
|
CompareOrdinal |
与Compare一样,但不考虑文化背景 |
|
Concat |
把多个字符串实例合并为一个实例 |
|
CopyTo |
把特定数量的字符从选定的下标复制到数组的一个全新实例中 |
|
Format |
格式化包含各种值的字符串和如何格式化每个值的说明符 |
|
IndexOf |
定位字符串中第一次出现某个给定子字符串或字符的位置 |
|
IndexOfAny |
定位字符串中第一次出现某个字符或一组字符的位置 |
|
Insert |
把一个字符串实例插入到另一个字符串实例的指定索引处 |
|
Join |
合并字符串数组,建立一个新字符串 |
|
LastIndexOf |
与IndexOf一样,但定位最后一次出现的位置 |
|
LastIndexOfAny |
与IndexOfAny,但定位最后一次出现的位置 |
|
PadLeft |
在字符串的开头,通过添加指定的重复字符填充字符串 |
|
PadRight |
在字符串的结尾,通过添加指定的重复字符填充字符串 |
|
Replace |
用另一个字符或子字符串替换字符串中给定的字符或子字符串 |
|
Split |
在出现给定字符的地方,把字符串拆分为一个子字符串数组 |
|
Substring |
在字符串中获取给定位置的子字符串 |
|
ToLower |
把字符串转换为小写形式 |
|
ToUpper |
把字符串转换为大写形式 |
|
Trim |
删除首尾的空白 |
注意:
这个表并不完整,但可以让您明白字符串所提供的功能。
如上所述,string类是一个功能非常强大的类,它执行许多很有用的方法。但是,string类存在一个问题:重复修改给定的字符串,效率会很低,它实际上是一个不可变的数据类型,一旦对字符串对象进行了初始化,该字符串对象就不能改变了。表面上修改字符串内容的方法和运算符实际上是创建一个新的字符串,如果必要,可以把旧字符串的内容复制到新字符串中。例如,下面的代码:
string greetingText = "Hello from all the guys at Wrox Press. ";
greetingText += "We do hope you enjoy this book as much as we enjoyed writing it.";
在执行这段代码时,首先,创建一个System.String类型的对象,并初始化为文本“Hello from all the guys at Wrox Press. ”。注意句号后面有一个空格。此时.NET 运行库会为该字符串分配足够的内存来保存这个文本(39个字符),再设置变量greetingText,表示这个字符串实例。
从语法上看,下一行代码是把更多的文本添加到字符串中。实际上并非如此,而是创建一个新字符串实例,给它分配足够的内存,以保存合并起来的文本(共103个字符)。最初的文本“Hello from all the people at Wrox Press. ”复制到这个新字符串中,再加上额外的文本“We do hope you enjoy this book as much as we enjoyed writing it.”。然后更新存储在变量greetingText中的地址,使变量正确地指向新的字符串对象。旧的字符串对象被撤销了引用——不再有变量引用它,下一次垃圾收集器清理应用程序中所有未使用的对象时,就会删除它。
这本身还不坏,但假定要对这个字符串加密,在字母表中,用ASCII码中的字符替代其中的每个字母(标点符号除外),作为非常简单的加密模式的一部分,就会把该字符串变成“Ifmmp gspn bmm uif hvst bu Xspy Qsftt. Xf ep ipqf zpv fokpz uijt cppl bt nvdi bt xf fokpzfe xsjujoh ju.”。完成这个任务有好几种方式,但最简单、最高效的一种(假定只使用String类)是使用String. Replace()方法,把字符串中指定的子字符串用另一个子字符串代替。使用Replace(),加密文本的代码如下所示:
string greetingText = "Hello from all the guys at Wrox Press. ";
greetingText += "We do hope you enjoy this book as much as we enjoyed writing it.";
for(int i = 'z'; i>='a' ; i––)
{
char old1 = (char)i;
char new1 = (char)(i+1);
greetingText = greetingText.Replace(old1, new1);
}
for(int i = 'Z'; i>='A' ; i––)
{
char old1 = (char)i;
char new1 = (char)(i+1);
greetingText = greetingText.Replace(old1, new1);
}
Console.WriteLine("Encoded:\n" + greetingText);
注意:
为了简单起见,这段代码没有把Z换成A,或把z换成a。这些字符分别编码为[和{。
Replace()以一种智能化的方式工作,在某种程度上,它并没有创建一个新字符串,除非要对旧字符串进行某些改变。原来的字符串包含23个不同的小写字母,和3个不同的大写字母。所以Replace()就分配一个新字符串,共26次,每个新字符串都包含103个字符。因此加密过程需要在堆上有一个能存储总共2678个字符的字符串对象,最终将等待被垃圾收集!显然,如果使用字符串进行文字处理,应用程序就会有严重的性能问题。
为了解决这个问题,Microsoft提供了System.Text.StringBuilder类。StringBuilder不像String那样支持非常多的方法。在StringBuilder上可以进行的处理仅限于替换和添加或删除字符串中的文本。但是,它的工作方式非常高效。
在使用String类构造一个字符串时,要给它分配足够的内存来保存字符串,但StringBuilder通常分配的内存会比需要的更多。开发人员可以选择显式指定StringBuilder要分配多少内存,但如果没有显式指定,存储单元量在默认情况下就根据StringBuilder初始化时的字符串长度来确定。它有两个主要的属性:
Length指定字符串的实际长度;
Capacity是字符串占据存储单元的最大长度。
对字符串的修改就在赋予StringBuilder实例的存储单元中进行,这就大大提高了添加子字符串和替换单个字符的效率。删除或插入子字符串仍然效率低下,因为这需要移动随后的字符串。只有执行扩展字符串容量的操作,才会给字符串分配需要的新内存,才可能移动包含的整个字符串。在添加额外的容量时,从经验来看,StringBuilder如果检测到容量超出,且容量没有设置新值,就会使自己的容量翻倍。
例如,如果使用StringBuilder对象构造最初的欢迎字符串,可以编写下面的代码:
StringBuilder greetingBuilder =
new StringBuilder("Hello from all the guys at Wrox Press. ", 150);
greetingBuilder.Append("We do hope you enjoy this book as much as we enjoyed
writing it");
注意:
为了使用StringBuilder类,需要在代码中引用System.Text。
在这段代码中,为StringBuilder设置的初始容量是150。最好把容量设置为字符串可能的最大长度,确保StringBuilder不需要重新分配内存,因为其容量足够用了。理论上,可以设置尽可能大的数字,足够给该容量传送一个int,但如果实际上给字符串分配20亿个字符的空间(这是StringBuilder实例允许拥有的最大理论空间),系统就可能会没有足够的内存。
执行上面的代码,首先创建一个StringBuilder对象,如图8-1所示。
![]()
图 8-1
在调用Append()方法时,其他文本就放在空的空间中,不需要分配更多的内存。但是,多次替换文本才能获得使用StringBuilder所带来的性能提高。例如,如果要以前面的方式加密文本,就可以执行整个加密过程,无须分配更多的内存:
StringBuilder greetingBuilder =
new StringBuilder("Hello from all the guys at Wrox Press. ", 150);
greetingBuilder.Append("We do hope you enjoy this book as much as we enjoyed writing it");
for(int i = 'z'; i>='a' ; i--)
{
char old1 = (char)i;
char new1 = (char)(i+1);
greetingBuilder = greetingBuilder.Replace(old1, new1);
}
for(int i = 'Z'; i>='A' ; i–– )
{
char old1 = (char)i;
char new1 = (char)(i+1);
greetingBuilder = greetingBuilder.Replace(old1, new1);
}
Console.WriteLine("Encoded:\n" + greetingBuilder.ToString());
这段代码使用了StringBuilder.Replace()方法,它的功能与String.Replace()一样,但不需要在过程中复制字符串。在上述代码中,为存储字符串而分配的总存储单元是150个字符,用于StringBuilder实例以及在最后一个Console.WriteLine()语句中执行字符串操作期间分配的内存。
一般,使用StringBuilder可以执行字符串的操作,String可以用于存储字符串或显示最终结果。
前面介绍了StringBuilder的一个构造函数,它的参数是一个初始字符串及该字符串的容量。还有几个其他的StringBuilder构造函数,例如,可以只提供一个字符串:
StringBuilder sb = new StringBuilder("Hello");
或者用给定的容量创建一个空的StringBuilder:
StringBuilder sb = new StringBuilder(20);
除了前面介绍的Length 和 Capacity属性外,还有一个只读属性MaxCapacity,它表示对给定的StringBuilder实例的容量限制。在默认情况下,这由int.MaxValue给定(大约20亿,如前所述)。但在构造StringBuilder对象时,也可以把这个值设置为较低的值:
// This will both set initial capacity to 100, but the max will be 500.
// Hence, this StringBuilder can never grow to more than 500 characters,
// otherwise it will raise exception if you try to do that.
StringBuilder sb = new StringBuilder(100, 500);
还可以随时显式地设置容量,但如果把这个值设置为低于字符串的当前长度,或者超出了最大容量,就会抛出一个异常:
StringBuilder sb = new StringBuilder("Hello");
sb.Capacity = 100;
主要的StringBuilder方法如表8-2所示。
表 8-2
|
名 称 |
作 用 |
|
Append() |
给当前字符串添加一个字符串 |
|
AppendFormat() |
添加特定格式的字符串 |
|
Insert() |
在当前字符串中插入一个子字符串 |
|
Remove() |
从当前字符串中删除字符 |
|
Replace() |
在当前字符串中,用某个字符替换另一个字符,或者用当前字符串中的一个子字符串替换另一字符串 |
|
ToString() |
把当前字符串转换为System.String对象(在System.Object中被重写) |
其中一些方法还有几种格式的重载方法。
注意:
AppendFormat()实际上会在调用Console.WriteLine()时调用,它负责确定所有像{0:D}的格式化表达式应使用什么表达式替代。下一节讨论这个问题。
不能把StringBuilder转换为String(隐式转换和显式转换都不行)。如果要把StringBuilder的内容输出为String,唯一的方式是使用ToString()方法。
前面介绍了StringBuilder类,说明了使用它提高性能的一些方式。注意,这个类并不总能提高性能。StringBuilder类基本上应在处理多个字符串时使用。但如果只是连接两个字符串,使用System.String会比较好。
前面的代码示例中编写了许多类和结构,对这些类和结构执行ToString()方法,都是为了显示给定变量的内容。但是,用户常常希望以各种可能的方式显示变量的内容,在不同的文化或地区背景中有不同的格式。.NET基类System.DateTime就是最明显的一个示例:可以把日期显示为10 June 2006、10 Jun 2006、6/10/06 (美国)、10/6/06 (英国)或10.06.2006 (德国)。
同样,第3章中编写的Vector结构执行Vector.ToString()方法,是为了以(4, 56, 8)格式显示矢量。编写矢量的另一个非常常用的方式是4i + 56j + 8k。如果要使类的用户友好性比较高,就需要使用某些工具以用户希望的方式显示它们的字符串表示。.NET运行库定义了一种标准方式:使用接口IFormattable,本节的主题就是说明如何把这个重要特性添加到类和结构上。
在显示一个变量时,常常需要指定它的格式,此时我们经常调用Console.WriteLine()方法。因此,我们把这个方法作为示例,但这里的讨论适用于格式化字符串的大多数情况。例如,如果要在列表框或文本框中显示一个变量的值,一般要使用String.Format()方法来获得该变量的合适字符串表示,但用于请求所需格式的格式说明符与传递给Console.WriteLine()的格式相同,因此本节把Console.WriteLine()作为一个示例来说明。首先看看在为基本类型提供格式字符串时会发生什么,再看看如何把自己的类和结构的格式说明符添加到过程中。
第2章在Console.Write()和 Console.WriteLine()中使用了格式字符串:
double d = 13.45;
int i = 45;
Console.WriteLine("The double is {0,10:E} and the int contains {1}", d, i);
格式字符串本身大都由要显示的文本组成,但只要有要格式化的变量,它在参数列表中的下标就必须放在括号中。在括号中还可以有与该项的格式相关的其他信息,例如可以包含:
该项的字符串表示要占用的字符数,这个信息的前面应有一个逗号,负值表示该项应左对齐,正值表示该项应右对齐。如果该项占用的字符数比给定的多,其内容也会完整地显示出来。
格式说明符也可以显示出来。它的前面应有一个冒号,表示应如何格式化该项。例如,把一个数字格式化为货币,或者以科学计数法显示。
第2章简要介绍了数字类型的常见格式说明符,表8-3再次引用该表。
表 8-3
|
格 式 符 |
应 用 |
含 义 |
示 例 |
|
C |
数字类型 |
专用场合的货币值 |
$4834.50 (USA) £4834.50 (UK) |
|
D |
只用于整数类型 |
一般的整数 |
4834 |
|
E |
数字类型 |
科学计数法 |
4.834E+003 |
|
F |
数字类型 |
小数点后的位数固定 |
4384.50 |
|
G |
数字类型 |
一般的数字 |
4384.5 |
|
N |
数字类型 |
通常是专用场合的数字格式 |
4,384.50 (UK/USA) 4 384,50 (欧洲大陆) |
|
P |
数字类型 |
百分比计数法 |
432,000.00% |
|
X |
只用于整数类型 |
十六进制格式 |
1120 (如果要显示0x1120,需要写上0x) |
如果要在整数上加上前导0,可以将格式说明符0重复所需的次数。例如,格式说明符0000会把3显示为0003,99显示为0099。
这里不能给出完整的列表,因为其他数据类型有自己的格式说明符。本节的主要目的是说明如何为自己的类定义格式说明符。
为了说明如何格式化字符串,看看执行下面的语句会得到什么结果:
Console.WriteLine("The double is {0,10:E} and the int contains {1}", d, i);
Console.WriteLine()只是把参数的完整列表传送给静态方法String.Format(),如果要在字符串中以其他方式格式化这些值,例如显示在一个文本框中,也可以调用这个方法。带有3个参数的WriteLine()重载方法如下:
// Likely implementation of Console.WriteLine()
public void WriteLine(string format, object arg0, object arg1)
{
Console.WriteLine(string.Format(format, arg0, arg1));
}
上面的代码依次调用了带有1个参数的重载方法WriteLine(),仅显示了传递过来的字符串的内容,没有对它进行进一步的格式化。
String.Format()现在需要用对应对象的合适字符串表示来替换每个格式说明符,构造最终的字符串。但是,如前所述,对于这个建立字符串的过程,需要StringBuilder实例,而不是String实例。在这个示例中,StringBuilder实例是用字符串的第一部分(即文本“The double is”)创建和初始化的。然后调用StringBuilder.AppendFormat()方法,传递第一个格式说明符“{0,10:E}”和相应的对象double,把这个对象的字符串表示添加到构造好的字符串中,这个过程会继续重复调用StringBuilder.Append()和StringBuilder.AppendFormat()方法,直到得到了全部格式化好的字符串为止。
下面的内容比较有趣。StringBuilder.AppendFormat()需要指出如何格式化对象,它首先检查对象,确定它是否执行System命名空间中的接口IFormattable。只要试着把这个对象转换为接口,看看转换是否成功即可,或者使用C#关键字is,也能实现此测试。如果测试失败,AppendFormat()只会调用对象的ToString()方法,所有的对象都从System.Object继承了这个方法或重写了该方法。在前面给出的编写各种类和结构的示例中,执行过程都是这样,因为我们编写的类都没有执行这个接口。这就是在前面的章节中,Object.ToString()的重写方法允许在Console.WriteLine()语句中显示类和结构如Vector的原因。
但是,所有预定义的基本数字类型都执行这个接口,对于这些类型,特别是这个示例中的double和int,就不会调用继承自System.Object的基本ToString()方法。为了理解这个过程,需要了解IFormattable接口。
IFormattable只定义了一个方法,该方法也叫作ToString(),它带有两个参数,这与System. Object版本的ToString()不同,它不带参数。下面是IFormattable的定义:
interface IFormattable
{
string ToString(string format, IFormatProvider formatProvider);
}
这个ToString()重载方法的第一个参数是一个字符串,它指定要求的格式。换言之,它是字符串的说明符部分,放在字符串的{}中,该参数最初传递给Console.WriteLine()或String. Format()。例如,在本例中,最初的语句如下:
Console.WriteLine("The double is {0,10:E} and the int contains {1}", d, i);
在计算第一个说明符{0,10:E}时,在double变量d上调用这个重载方法,传递给它的第一个参数是E。StringBuilder.AppendFormat()传递的总是显示在原始字符串的合适格式说明符内冒号后面的文本。
本书不讨论ToString()的第2个参数,它是执行接口IFormatProvider的对象引用。这个接口提供了ToString()在格式化对象时需要考虑的更多信息—— 一般包括文化背景信息(.NET文化背景类似于Windows时区,如果格式化货币或日期,就需要这些信息)。如果直接从源代码中调用这个ToString()重载方法,就需要提供这样一个对象。但StringBuilder. Append Format()为这个参数传递一个空值。如果formatProvider为空,ToString()就要使用系统设置中指定的文化背景信息。
现在回过头来看看本例。第一个要格式化的项是double,对此要求使用指数计数法,格式说明符为E。如前所述,StringBuilder.AppendFormat()方法会建立执行IFormattable接口的对象double,因此要调用带有两个参数的ToString()重载方法,其第一个参数是字符串“E”,第二个参数为空。现在double的这个方法在执行时,会考虑要求的格式和当前的文化背景,以合适的格式返回double的字符串表示。StringBuilder.AppendFormat()则按照需要在返回的字符串中添加前导空格,使之共有10个字符。
下一个要格式化的对象是int,它不需要任何特殊的格式 (格式说明符是{1})。由于没有格式要求,StringBuilder.AppendFormat()会给该格式字符串传递一个空引用,并适当地响应带有两个参数的int.ToString()重载方法。由于没有特殊的格式要求,所以也可以调用不带参数的ToString()方法。
整个字符串格式化过程如图8-2所示。

图 8-2
前面介绍了如何构造格式字符串,下面扩展本书前面的Vector示例,以多种方式格式化矢量。这个示例的代码可以从www.wrox.com上下载。只要理解了所涉及的规则,实际编写代码就相当简单了。我们只需要实现IFormattable,提供由该接口定义的ToString()重载方法即可。
要支持的格式说明符如下:
N 应解释为一个请求,以提供一个数字,即矢量的模,它是其成员的平方和,在数学上等于Vector的长度的平方,通常放在两个竖杠的中间:||34.5||。
VE 应解释为以科学计数法显示每个成员的一个请求,例如说明符E应用于double,就可以表示为(2.3E+01, 4.5E+02, 1.0E+00)。
IJK应解释为以格式23i + 450j + 1k显示矢量的一个请求。
其他内容应仅返回Vector的默认表示方法(23, 450, 1.0)。
为了简单起见,我们不以IJK和科学计数法的格式执行任何选项,以显示矢量,而是以不区分大小写的方式来测试说明符,允许使用ijk和IJK。注意,使用什么字符串表示格式说明符完全取决于用户。
为此,首先修改Vector的声明,使之执行IFormattable:
struct Vector : IFormattable
{
public double x, y, z;
// Beginning part of Vector
下面添加带有2个参数的ToString()重载方法:
public string ToString(string format, IFormatProvider formatProvider)
{
if (format == null)
{
return ToString();
}
string formatUpper = format.ToUpper();
switch (formatUpper)
{
case "N":
return "|| " + Norm().ToString() + " ||";
case "VE":
return String.Format("( {0:E}, {1:E}, {2:E} )", x, y, z);
case "IJK":
StringBuilder sb = new StringBuilder(x.ToString(), 30);
sb.AppendFormat (" i + ");
sb.AppendFormat (y.ToString());
sb.AppendFormat (" j + ");
sb.AppendFormat (z.ToString());
sb.AppendFormat (" k");
return sb.ToString();
default:
return ToString();
}
}
这就是我们要编写的代码。注意在调用任何方法前,应防止使用格式字符串为空的参数。我们希望这个方法尽可能健壮,所有基本类型的格式说明符都是不区分大小写的,其他开发人员也希望能使用我们的类。对于格式说明符VE,需要把每个成员格式化为科学计数法,所以再次使用String.Format ()方法。字段x、y和z都是double类型。对于IJK格式限定符,把几个子字符串添加到字符串中,因此使用StringBuilder对象来提高性能。
为了保证完整,也可以再次使用前面开发的无参数的ToString()重载方法:
public override string ToString()
{
return "( " + x + " , " + y + " , " + z + " )";
}
最后,需要添加一个Norm()方法,计算矢量的平方(模),因为在开发Vector结构时,没有提供这个方法:
public double Norm()
{
return x*x + y*y + z*z;
}
下面用一些合适的测试代码测试可格式化的矢量:
static void Main()
{
Vector v1 = new Vector(1,32,5);
Vector v2 = new Vector(845.4, 54.3, –7.8);
Console.WriteLine("\nIn IJK format,\nv1 is {0,30:IJK}\nv2 is {1,30:IJK}", v1,
v2);
Console.WriteLine("\nIn default format,\nv1 is {0,30}\nv2 is {1,30}", v1, v2);
Console.WriteLine("\nIn VE format\nv1 is {0,30:VE}\nv2 is {1,30:VE}", v1, v2);
Console.WriteLine("\nNorms are:\nv1 is {0,20:N}\nv2 is {1,20:N}", v1, v2);
}
运行这个示例的结果如下所示:
FormattableVector
In IJK format,
v1 is 1 i + 32 j + 5 k
v2 is 845.4 i + 54.3 j + -7.8 k
In default format,
v1 is ( 1 , 32 , 5 )
v2 is ( 845.4 , 54.3 , -7.8 )
In VE format
v1 is ( 1.000000E+000, 3.200000E+001, 5.000000E+000 )
v2 is ( 8.454000E+002, 5.430000E+001,–7.800000E+000 )
Norms are:
v1 is || 1050 ||
v2 is || 717710.49 ||
这说明了选用的定制格式说明符是正确的。