导航
第九章 Strings and Regular Expressions
9.1 System.String 类
在深入其他字符串类之前,本小节简单地回顾了String类它自身的一些可用方法。
System.String类专门设计来存储一个字符串,并且允许对其进行大量的操作。加之字符串作为基础数据类型的重要性,C#实现了自己的字符串关键字和连接语法,来让这个类操作字符串更加的容易。
你可以通过运算符重载连接字符串:
string message1 = "Hello"; // returns "Hello"
message1 += ", There"; // returns "Hello, There"
string message2 = message1 + "!"; // returns "Hello, There!"
C#还允许通过使用类似索引器的语法来提取字符串中的指定字符:
string message = "Hello";
char char4 = message[4]; // returns 'o'. Note the string is zero-indexed
这使得你可以处理一些常见的任务如替换某些字符,移除空白字符或者改变大小写,下面的表格介绍了String类中关键的方法。
方法 |
描述 |
Compare |
比较字符串的内容,考虑区域设置之间的某些字符的等价性。 |
CompareOrdinal |
比较字符串的内容,但不考虑区域设置。 |
Concat |
将不同的字符串实例合并成一个字符串实例。 |
CopyTo |
将指定序号,指定长度的字符拷贝到另外一个新建的数组中。 |
Format |
根据指定的格式格式化一个含有多种数值的字符串。 |
IndexOf |
定位某个字符或者子串在字符串中首次出现的位置。 |
IndexOfAny |
定位一组字符中任意一个字符在字符串中首次出现的位置。 |
Insert |
在指定位置插入另外一个字符串。 |
Join |
按照要求将一个字符串数组合并成一个字符串实例。 |
LastIndexOf |
跟IndexOf一样,但返回最后一次出现的位置。 |
LastIndexOfAny |
跟IndexOfAny一样,但返回最后一次出现的位置。 |
PadLeft |
如果字符串不够指定位数,则在其左侧按给定字符补全位数。 |
PadRight |
如果字符串不够指定位数,则在其右侧按给定字符补全位数。 |
Replace |
将指定字符或者子串替换成另外一个字符或者子串。 |
Split |
根据指定字符分隔成若干子串。 |
Substring |
根据指定位置和长度获取一个子串。 |
ToLower |
转成小写。 |
ToUpper |
转成大写。 |
Trim |
移除字符串开头与结尾的空格。 |
注意:以上方法并不是String类中所有的方法,这个表格只是为了让你了解到String类提供了很多丰富的功能特性。
9.1.1 构建字符串
正如你所见,String类是一个非常强大的类,提供了一系列非常有用的方法。尽管如此,String类依然有一个缺点:因为它是不可变的数据类型,这意味当你初始化一个string对象后,这个对象不会再次发生任何改变,这个设计使得你在试图多次重复修改某个字符串的时候非常的低效(inefficient)。修改某个字符串的方法或者运算符实际上产生的都是一个新的字符串,并且在必要的时刻全盘拷贝旧字符串的内容。举个例子,考虑以下代码:
string greetingText = "Hello from all the people at Wrox Press. ";
greetingText += "We do hope you enjoy this book as much as we enjoyed writing it.";
当这段代码运行时,首先创建了一个System.String对象实例,并且它被初始化成"Hello from all...Wrox Press.",.NET运行时为这个字符串分配了刚刚好的内存(41个字符),并且让变量greetingText引用这个字符串实例。
在接下来的第二行,从语法上看,它似乎作了一个追加操作,将新的一串字符串追加到旧字符串末尾,其实不然。反而,此时创建了一个刚刚好能够容纳这个合并后的大字符串(104个字符)的新字符串实例。原始的字符串文本,"Hello from all...Wrox Press.",被拷贝到这个新的字符串实例的内存空间中,并且后续的字符串"We do hope...writing it."也被一同拷贝到新的字符串实例中。然后,保存在greetingText中的引用地址得到更新,指向了新创建的字符串实例。旧的字符串现在没有任何变量引用它,在下一次GC回收启动的时候就会被清理并回收内存。
如果仅仅是这样子的示例,这点看起来也没什么太大的问题,但假定你想创建一个简单的加密串——通过为字符串里的每个字符的ASCII码自加1来实现,上面的greetingText经过加密后最终变成了"Ifmmp gspn bmm
uif qfpqmf bu Xspy Qsftt. Xf ep ipqf zpv fokpz uijt cppl bt nvdibt xf fokpzfe xsjujoh ju."的字符串。有很多种方式都可以实现这个效果,但最简单的一种(假定你限制自己只能使用String类),又或者说看上去最简单的一种方式就是使用String类里的Replace方法,它将指定的字符转换成新的字符,你的代码可能会像这么写:
string greetingText = "Hello from all the people at Wrox Press. ";
greetingText += "We do hope you enjoy this book as much as we" +
"enjoyed writing it.";
Console.WriteLine($"Not encoded:\n {greetingText}");
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}");
运行的结果如下所示:
Not encoded:
Hello from all the people at Wrox Press. We do hope you enjoy this book as much as weenjoyed writing it.
Encoded:
Ifmmp gspn bmm uif qfpqmf bu Xspy Qsftt. Xf ep ipqf zpv fokpz uijt cppl bt nvdi bt xffokpzfe xsjujoh ju.
在这个例子中,Replace方法已经采取了一种相当合理的方式,当它没有对源字符串进行任何修改的时候,它并不会创建一个新的字符串。源字符串中包含了23个不同的小写字符以及3个不同的大写字符。Replace方法因此将会进行总计26次的内存申请,并且每个新创建的字符串实例都包含104个字符(原文是103)。这意味着,为了完成这个加密过程,实际上有临时使用的26*104 = 2684个字符在托管堆里等待GC清理。显然,当你直接使用string进行文本处理的时候,你的应用程序就会陷入性能问题。
为了满足这类需求(address this kind of issue),微软提供了System.Text.StringBuilder类。StringBuilder并不像String类那么强大并且支持大量的方法。通过StringBuilder你仅仅只能追加或者移除字符串中的某些内容,然而,它却对文本处理有非常好的性能提升。
当你通过String类构造一个字符串的时候,托管堆仅仅只会为它分配刚好的内存来保存这个字符串对象。而StringBuilder则往往会比它实际需要的空间多分配一些。作为一个开发人员,你可以指定为StringBuilder分配多少内存,假如你没有指定,将会根据你传入的string的长度和默认容量值来决定如何构造一个StringBuilder实例。
StringBuilder类含有两个主要属性:
- Length:标识它已包含的string的长度。
- Capacity:标识它初始所能容纳的string的长度。
任何对string的修改都发生在StringBuilder实例包含的内存空间中,譬如添加新串,或者置换某些字符,由于StringBuilder的存在,使得这些操作都非常高效。移除或者插入某个子串免不了还是效率低一些,因为这意味着随后的子串需要跟着进行移动。只有当你需要进行的某个操作,超过当前字符串容量的时候,它才需要分配新的内存,并且可能会移动当前已有的字符串。在扩展容量(capacity)的时候,基于我们的多次试验,StringBuilder看起来会把自己的初始容量翻倍(appears to double its capacity)——假如没有指定新的容量值而它又检测到容量不够的时候。
例如,我们使用StringBuilder来改写前面的例子的话,你可以这么写:
var greetingBuilder = new StringBuilder("Hello from all the people at Wrox Press.", 150);
greetingBuilder.Append("We do hope you enjoy this book as much as we enjoyed writing it");
在这段代码中,首先设置了一个初始大小为150的StringBuilder,一开始就为StringBuilder设置一个比待操作的字符串略大一些的初始容量是一个比较好的做法,因为这样StringBuilder就不用在之后因为超过容量而进行扩容。默认的话,容量被设置成16。理论上,你可以将这个容量设置成int的最大值,只不过系统可能会提示它没有足够的内存分配给你,假如你尝试申请一块大概能容纳20多亿字符的内存的话,但StringBuilder实例是支持处理int.MaxValue也就是2,147,483,647这么多的字符的。
然后,通过调用AppendFormat方法,余下的字符串被存储在空闲空间中,而不需要重新分配更多的内存。然而,使用StringBuilder真正的高效之处在于,当你想进行重复多次的文本置换时(text substitutions)。还是拿前面加密的例子举例,使用StringBuilder,不管你如何替换,都不需要进行任何额外的内存的申请与分配:
var greetingBuilder = new StringBuilder("Hello from all the people at Wrox Press.", 150);
greetingBuilder.AppendFormat("We do hope you enjoy this book as much " +
"as we enjoyed writing it");
Console.WriteLine("Not Encoded:\n" + greetingBuilder);
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}");
新的代码里用到了StringBuilder的Replace方法,它跟String的Replace方法实现是差不多的,区别只在于不需要生成新的字符串,自始至终它使用的内存空间仅仅是一开始申请的那150个字符的大小,并且在过程中不需要扩容,也包括最后它在WriteLine语句中进行字符串输出时。
简单来讲,当你想操作字符串的时候,你可以使用StringBuilder,而当你想存储或者显示最终结果时,你可以使用String对象。
9.1.2 StringBuilder 成员
你已经看过了StringBuilder其中一种构造函数的实例,它接收一个初始字符串和一个初始容量作为它的参数,当然StringBuilder也包含了其他的构造函数,例如,你可以只给它传一个初始字符串:
var sb = new StringBuilder("Hello");
又或者你可以只指定它的初始容量:
var sb = new StringBuilder(20);
除了前面提到的Length和Capacity属性之外,还有一个只读的MaxCapacity属性,来标识一个StringBuilder实例最大能够存储多少字符。默认情况下,它被指定为int.MaxValue(约20亿,前面已经提到了),而你也可以在构造函数的时候将这个值设置的小一些:
// This will set the 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 an exception if you try to do that.
var sb = new StringBuilder(capacity: 100, maxCapacity: 500);
你也可以在之后任何时候显式地设置它的容量,虽然当你设置的容量小于当前字符串实际长度的时候,会得到一个异常:
var sb = new StringBuilder("Hello");
sb.Capacity = 100;
下面的表格列举了StringBuilder的主要方法:
方法 |
描述 |
Append |
将一个字符串追加在当前字符串之后。 |
AppendFormat |
按照指定格式格式化一个字符串,并追加在当前字符串之后。 |
Insert |
在当前字符串插入某个子串。 |
Remove |
从当前字符串移除某些字符。 |
Replace |
将当前字符串中的字符或者子串替换成新的字符或者子串。 |
ToString |
将当前StringBuilder保存的字符串输出为一个String对象。 |
在StringBuilder和String之间没有任何类型转换(不管是显式的还是隐式的),在某些方面StringBuilder提供了一些方式让你通过它来提升程序性能,但它也不是万金油的解决方案。大体上讲,当你操作多个字符串的时候,你可以使用StringBuilder类,但是,假如你仅仅只需要做一些特别简单的事情的时候,譬如拼接两个字符串,你可能会发现System.String类的表现更佳。
9.2 字符串格式化
在之前的章节里你已经看见过将字符串作为参数时,同时传递$前缀。本章将探讨这个C#特性的后台实现并且包含其他字符串格式化的功能特性。
9.2.1 字符串插值
C# 6.0开始支持字符串插值(string interpolation),通过在字符串前添加一个$ 前缀实现。下面的例子就利用了$ 来创建字符串s2:
string s1 = "World";
string s2 = $"Hello, {s1}";
添加$ 前缀允许包含在一对大括号里的占位符(placeholder)引用代码中的结果,在上面的代码中,{s1} 就是字符串里的占位符,当编译器遇到它的时候,就会将变量s1存储的值替换整个{s1} 。
实际上,这里只是一个语法糖。对于$ 前缀编译器实际上调用的是String.Format方法,所以上面的例子实际上是:
string s1 = "World";
string s2 = String.Format("Hello, {0}", s1);
String.Format方法的第一个参数用来接收一个待格式化的字符串,它可以包含从0开始的占位符,随后的参数会按顺序将值填充到相应的占位符中。
在$ 前缀修饰的字符串中你并不仅仅只能使用变量,你也可以调用任何带有返回值的方法:
string s2 = $"Hello, {s1.ToUpper()}";
实际上它被转换成了:
string s2 = String.Format("Hello, {0}", s1.ToUpper());
你也可以在字符串中应用多个变量:
int x = 3, y = 4;
string s3 = $"The result of {x} + {y} is {x + y}";
实际上它是:
string s3 = String.Format("The result of {0} and {1} is {2}", x, y, x + y);
9.2.1.1 可格式化的字符串
通过给格式化字符串(FormattableString)赋值可以轻易地插入相应的字符串。因为格式化字符串中定义好了结果串(normal string)中各个接收插入串(interpolated)的位置。这种方式定义了一种格式化属性,用来返回格式化后的结果串(the resulting format string),这种属性称之为ArgumentCount,并且你可以通过GetArgument方法来返回格式串中定义的Argument,如下面所示:
int x = 3, y = 4;
//FormattableString是这种字符串的返回类型
FormattableString s = $"The result of {x} + {y} is {x + y}";
Console.WriteLine($"format: {s.Format}");
for (int i = 0; i < s.ArgumentCount; i++)
{
Console.WriteLine($"argument {i}: {s.GetArgument(i)}");
}
运行结果如下所示:
format: The result of {0} + {1} is {2}
argument 0: 3
argument 1: 4
argument 2: 7
注意:FormattableString类需要.NET 4.6的支持,假如你在更旧的.NET版本中想要应用这个类,你可以自己创建这个类型,又或者使用NuGet包中的StringInterpolationBridge。
9.2.1.2 使用其他区域进行格式化
插入值的时候默认使用的是当前的区域对应的格式(make use of current culture),当然这一点也可以简单地进行修改。
public static string Invariant (FormattableString formattable); //类库提供的
通过使用FormattableString类里的Invariant方法,可以将字符串格式化修改成统一不变的(invariant culture)而非根据调用者当前所在的区域进行变化(instead of current culture)。因为插入值可以赋值给一个FormattableString对象,因此它们也可以传递给这个Invariant方法。FormattableString里定义了一个ToString方法,该方法允许接收IFormatProvider类型的参数。IFormatProvider可以通过CultureInfo类实现。通过给ToString方法传递一个CultureInfo.InvariantCulture参数,我们就可以达到格式化字符串统一结果的目的,如下所示:
private string Invariant(FormattableString s) => s.ToString(CultureInfo.InvariantCulture); //自己定义的
注意:第27章,“本地化”,将会更详细地讨论关于字符串格式化的问题,当然也包括区域化方面的内容。
在下面的代码片段中,我们将使用自己写的Invariant方法来给Console.WriteLine传递一个字符串。第一行的Console.WriteLine中调用的是会根据区域化显示不同内容的字符串,而第二行则不管你在地球的哪个角落,输出的都是统一格式的字符串:
var day = new DateTime(2025, 2, 14);
Console.WriteLine($"{day:d}");
Console.WriteLine(Invariant($"{day:d}"));
假如你是在欧美地区,你的输出结果应该是这样子的:
2/14/2025
02/14/2015
笔者是在中国大陆,结果如下:
2025/2/14
02/14/2025
你也可以直接使用系统提供的Invariant方法,这样你就不用自己每次都写一个了:
Console.WriteLine(FormattableString.Invariant($"{day:d}"));
9.2.1.3 忽略大括号
万一你需要在格式化字符串中保留大括号以及里面的变量名,你可以通过使用两个大括号来避免它被格式化,如下所示:
string s = "Hello";
Console.WriteLine($"{{s}} displays the value of s: {s}");
上面的代码实际被翻译成了:
Console.WriteLine(String.Format("{s} displays the value of s: {0}", s));
所以,结果为:
{s} displays the value of s : Hello
你也可以利用这一特性,利用一个格式化字符串来构造第二个格式化字符串,考虑以下代码:
string s = "Hello";
string formatString = $"{s}, {{0}}";
string s2 = "World";
Console.WriteLine(formatString, s2);
对于第一个变量formatString,编译器其实将它翻译成了:
string formatString = String.Format("{0}, {{0}}", s);
因此实际上只有第一个占位符{0}被字符串s的值代替,而第二部分则保留了{0}的字样,所以它的结果为:
string formatString = "Hello, {0}";
那么Console.WriteLine中的内容实际上是:
Console.WriteLine("Hello, {0}", s2);
因此最后的输出结果为:
Console.WriteLine("Hello, World"); //Hello, World
9.2.2 日期时间和数字的格式
除了使用占位符(placeholders),根据数据类型指定不同的格式化也是可行的。让我们通过date类型来进行说明。如下面所示:
var day = new DateTime(2025, 2, 14);
Console.WriteLine($"{day:D}");
Console.WriteLine($"{day:d}");
你可以看到在DateTime类型后面加上了d 和D 来指定相应的显示格式。通常一个指定的格式化字符串通过: 紧跟在相应类型后面。上面的代码显示的结果如下所示:
Friday, February 14, 2025
2/14/2025
DateTime类型根据指定不同的大小写格式字符会输出不同的结果。根据你操作系统使用的语言设置,输出可能会有所差异。日期和时间的格式是跟语言相关的。
DateTime类型支持一系列的不同格式字符输出——例如,t 用来显示一个短时间格式(short time format)而T 则显示的是一串长时间格式(long time format),同理还有g 和G 这样子。这里还有很多选项没有进行列举,感兴趣的同学可以在MSDN文档中的ToString方法里找到,跟DateTime类型相关的部分。
注意:这里不得不提的一件事是,当你给DateTime类型创建自定义格式化(custom format)的时候,可以组合不同的格式化符(format specifiers),例如dd-MMM-yyyy这样子:
Console.WriteLine($"{day:dd-MMM-yyyy}");
结果如下:
14-Feb-2025
在这个自定义的格式化串中,使用了两个d 来显示天数,这对小于10天的日期很有用,因为系统会补齐2位数,你可以看到此时d 和dd 显示的天数是不一样的,例如每月第一天,d 显示的是1,而dd 显示的则是01。而MMM 则是指定我们显示的是月份的缩略名(如这里,2月显示的是Feb,这里需要特别注意大小写,M是对月份的设置,而m则是对分钟的设置)。yyyy 则是指定了年份用4位数进行显示。最后再提一遍,所有的格式化字符(format specifiers)你都可以在MSDN的文档中找到。
为数字指定格式化字符串时大小写并没有任何影响,考虑以下例子:
int i = 2477;
Console.WriteLine($"{i:n} {i:e} {i:x} {i:c}");
这里n 表示按3位一组进行输出,e 表示使用指数计数法(exponential notation),x 表示转换成16进制,而c 则是以货币的格式(currency)进行输出,结果如下所示:
2,477.00 2.477000e+003 9ad $2,477.00 //书中的例子
2,477.00 2.477000e+003 9ad ¥2,477.00 //中国大陆的输出
对于数字来说你同样可以使用自定义格式化串(custom format strings)。# 就是数字中使用的占位符,当一个数字可以显示那么多位数的时候则显示,否则忽略多余的位数。而0 占位符则是有数字则保留,缺失的数字则用0进行补全,考虑以下的例子:
double d = 3.1415;
Console.WriteLine($"{d:###.###}");
Console.WriteLine($"{d:000.000}");
在示例中,小数的位数都指定了只显示3位,此时会通过四舍五入,将小数部分由0.1415变成0.142,然后根据占位符的不同进行输出:
3.142
003.142
微软的文档中提供了所有标准化数字输出的格式化字符串,如百分比(percent),回写(round-trip,会改写原数据)和定点数(fixed-point,小数点后的位数是固定的),并且支持为指数值、小数点、组分隔符等等自定义显示格式。
9.2.3 自定义字符串格式
格式化字符串并不单单仅限于内置类型,你也可以为你自己的类创建自定义格式化字符串,你只需要实现IFormattable接口即可。
让我们还是用前面那个带有FirstName和SecondName属性的Person类来进行说明:
public class Person
{
public string FirstName { get; set; }
public string LastName { get; set; }
}
一种简单的代表这个类的方式是通过重写基类的ToString方法,你可能会简单地这么写:
public override string ToString() => FirstName + " " + LastName;
假如此时除了支持这种简单的显示方式之外,还需要你让Person类支持如下的特性:当遇见格式化字符F 时,仅输出FirstName,而遇到L 时则显示LastName,A 则表示输出与ToString一样的全名。为了实现这一需求,你可以使用IFormattable接口,接口中定义了带有两个参数的ToString方法:
[return: NullableAttribute(1)]
string ToString(string? format, IFormatProvider? formatProvider);
我们下面的示例中仅仅使用了第一个参数format,而没有用到第二个参数IFormatProvider,当然你也可以用第二个参数来指定不同区域化的显示,就像CultureInfo类那样,CultureInfo类实现了IFormatProvider接口。
另外的一些类,如NumberFormatInfo和DateTimeFormatInfo也实现了IFormatProvider接口,你可以通过为ToString方法的第二个参数传递这样的类,来设置数字和日期的字符串显示。我们的示例为了简单起见,仅仅只是根据传入的format参数的不同,通过switch语句返回了不同的字符串。为了让ToString方法的调用方可以忽略第二个参数只使用第一个参数format,我们提供了ToString的重载方法,通过这个方法来调用带有两个参数签名的ToString方法:
public class Person : IFormattable
{
public string FirstName { get; set; }
public string LastName { get; set; }
public override string ToString() => FirstName + " " + LastName;
public virtual string ToString(string format) => ToString(format, null);
public string ToString(string format, IFormatProvider formatProvider)
{
switch (format)
{
case null:
case "A":
return ToString();
case "F":
return FirstName;
case "L":
return LastName;
default:
throw new FormatException($"invalid format string {format}");
}
}
}
这样,你就可以通过显式地调用ToString方法或者隐式地使用字符串插值(string interopolation)的方式来输出你想要的内容,如下所示:
var p1 = new Person { FirstName = "Stephanie", LastName = "Nagel" };
Console.WriteLine(p1.ToString("F"));
Console.WriteLine($"{p1:F}");
其中,字符串插值的方式实际上调用的是带有2个参数的ToString方法,只不过它直接给第二个IFormatProvider参数传递的是null而已。
9.3 正则表达式
正则表达式(Regular Expressions)是一个应用在大量程序中(wide range of programs)的,令人难以想象地好用(incredibly useful)的技术工具(technology aids)。你可以把正则表达式当成一种小型的程序设计语言,它实现一个特定的目的:在一个大的字符串中定位子串。这并不是一项新的技术,最初起源于UNIX系统并且在Perl语言中得到了广泛的应用,Javascript中也有它的身影。在.NET环境中,正则表达式由一系列位于System.Text.RegularExpressions命名空间下的类支持,你可以在不同的.NET Framework部分中看到对于正则表达式的应用。譬如,在ASP.NET中,正则表达式就被用来验证服务器端控件的有效性。
假如你对正则表达式还不太熟悉,本小节将会带你了解正则表达式以及相关的.NET类。假如你已经是个老手,你可能想直接跳过本小节,直接阅读有关.NET基类支持的部分。你可能更倾向于了解.NET正则表达式引擎是如何设计的,以便跟Perl 5定义的正则表达式更加兼容,尽管它只有少量的新特性。
9.3.1 正则表达式概述
正则表达式语言是专为字符串处理而设计的,它包含两个特性:
- 一组用来区分特定字符类型的转义码。你可能对在命令行中使用
* 字符来代表任意字符感到似曾相识,例如:Dir Re*这样的命令将会列举所有以Re开头的目录名。正则表达式使用了很多像这样的序列来代表各类项:如任一字符(any one character),断字(word break),可选字符(optional character)等等。
- 在搜索期间用于对子串和中间结果部分进行分组的系统。
通过正则表达式,你可以对字符串执行非常复杂和高级的操作。例如,你可以这样做:
- 区分(可能是标记或者移除)字符串中所有重复单词,例如:将The computer books books变成The computer
books。
- 将所有单词转换成首字母大写,就像将this is a Title变成This Is A Title这样子。
- 仅将长度大于3个的单词改成首字母大写,例如,将this is a Title变成This is a Title。
- 确保句子中的单词都正确地大小写了。
- 将URI的各个元素分离出来,例如给定了http://www.wrox.com,解析它的协议,计算机名,文件名等等。
当然,上述提到的任务你也可以通过丰富的String和StringBuilder方法来处理。然而在某些情况下,这意味着你需要编写相当数量的代码。而通过正则表达式,大量的代码可以被压缩至短短数行。通常来讲(Essentially),你会实例化一个RegEx对象(或者仅仅调用静态的RegEx方法),将要处理的字符串作为参数传递给它,然后按照实际需求编写好正则表达式,你的任务就完成了。
正则表达式初看像一个正经的字符串,但它是由各种转义序列和代表特殊含义的字符组成的。举个例子,转义序列\b 代表的是一个单词的边界(可能是起始又或者是单词结尾),所以当你需要查找所有th 开头的单词的时候,正则表达式则是\bth ,换句话说,假如你要查找所有th 结尾的单词的时候,表达式则是th\b 。除此之外,正则表达式包含了更多更丰富的语义和功能,譬如提供了存储搜索过程中部分文本(portions of text)的技术(facilities)。本小节仅仅谈及了正则表达式的一鳞半角(scratches the surface of the power of regular expressions)。
注意:如果你想了解更多关于正则表达式的细节,请阅读《Beginning Regular Expressions》 (John Wiley & Sons, 2005)一书。
假定你的程序需要将美国的电话号码转换成国际格式,在美国,电话号码是314-123-1234这样子。当转换成国际形势的时候,你需要在前面包含+1 ,这是美国的国家编码,然后在区域编码上插入一对小括号,最后变成+1 (314) 123-1234这样子。这个查找和替换操作并不困难,但它还是需要你使用String类进行一部分编码工作。正则表达式能让你用更简短的方式构造这个结果串。
本章仅仅只介绍一些非常简单的实例,因此我们优先关注如何搜索到相应的子串,而不是修改它们。
9.3.2 RegularExpressionsPlayground 示例
本章节的正则表达式示例使用了以下的命名空间:
using System;
using System.Text.RegularExpressions;
接下来我们通过演示一个叫作RegularExpressionsPlayground的示例程序,来展示正则表达式的部分特性,以及如何在C#中使用.NET正则表达式引擎执行和显示特定搜索的结果集。例子中用到的text字符串将会是你在本书前一版本的介绍中的一部分:
const string text =
@"Professional C# 6 and .NET Core 1.0 provides complete coverage " +
"of the latest updates, features, and capabilities, giving you " +
"everything you need for C#. Get expert instruction on the latest " +
"changes to Visual Studio 2015, Windows Runtime, ADO.NET, ASP.NET, " +
"Windows Store Apps, Windows Workflow Foundation, and more, with " +
"clear explanations, no-nonsense pacing, and valuable expert insight. " +
"This incredibly useful guide serves as both tutorial and desk " +
"reference, providing a professional-level review of C# architecture " +
"and its application in a number of areas. You'll gain a solid " +
"background in managed code and .NET constructs within the context of " +
"the 2015 release, so you can get acclimated quickly and get back to work.";
注意:上面的代码很好地演示了@前缀对于大量单词字符串的实用性(utility of verbatim strings)。同样地,@前缀在正则表达式中也是极其有用的。
这段文本被用来作为输入串。为了让你适应(get your bearings)并且开始尝试.NET类中的正则表达式,让我们先从一个不包含任何转义序列和正则表达式指令的纯文本搜索开始介绍。假定现在你想在输入串中找到所有ion 子串出现的位置,这个子串被称为模式(pattern)。通过使用正则表达式以及上面定义的text变量,我们的代码如下所示:
public static void Find1(string text)
{
const string pattern = "ion";
MatchCollection matches = Regex.Matches(text, pattern, RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture);
WriteMatches(text, matches);
}
书中没有给出的WriteMatches方法你可以在下一小节里找到,这里先拷贝过来,如下所示:
public static void WriteMatches(string text, MatchCollection matches)
{
Console.WriteLine($"Original text was: \n\n{text}\n");
Console.WriteLine($"No. of matches: {matches.Count}");
foreach (Match nextMatch in matches)
{
int index = nextMatch.Index;
string result = nextMatch.ToString();
int charsBefore = (index < 5) ? index : 5;
int fromEnd = text.Length - index - result.Length;
int charsAfter = (fromEnd < 5) ? fromEnd : 5;
int charsToDisplay = charsBefore + charsAfter + result.Length;
Console.WriteLine($"Index: {index}, \tString: {result}, \t" + $"{text.Substring(index - charsBefore, charsToDisplay)}");
}
}
在Find1函数里使用了RegEx类里的静态方法Matches,这个方法接收3个参数,作为输入串的文本,搜索模式,RegexOptions里定义的枚举类型。在本例中,你指定了查找的时候是大小写无关的(case-insensitive)。而另外一个Flag,ExplicitCapture,仅显式捕获,则修改了match集的填充方式,使得搜索效率有一定的提高,你将会在本章节后续的内容中了解到原因(虽然这个Flag实际上有别的用途,只是我们这里不过度深入,详细可以看扩展资料里的ExplicitCapture例子)。matches变量指向的是MatchCollection对象。一次匹配(A match)的技术定义指的是在表达式中根据模式(pattern)查找到的一个实例,.NET中用System.Text.RegularExpressions.Match类来表示。因此matches是一个包含了所有match的MatchCollection集合对象。在上面的例子中,你简单地遍历了这个集合,并且通过Match类里的Index属性,显示在输入串中匹配的子串的序号。输出的结果如下所示:
No. of matches: 6
Index: 7, String: ion, ofessional C#
Index: 172, String: ion, truction on t
Index: 300, String: ion, undation, and
Index: 334, String: ion, lanations, no
Index: 481, String: ion, ofessional-le
Index: 535, String: ion, lication in a
接下来的表格列举了部分RegexOptions枚举中的细节:
成员名 |
描述 |
CultureInvariant |
忽略与特定区域相关的字符串。 |
ExplicitCapture |
修改Match类的收集方式,确认只有显式命名的子串才是有效捕获内容。 |
IgnoreCase |
忽略输入串的大小写。 |
IgnorePatternWhitespace |
从字符串中移除所有非转义的空格,并且允许使用英镑或者哈希记号的注释。 |
Multiline |
修改^和$的定义,使得它们可以对每一行起效,而非仅应用于整个串。 |
RightToLeft |
对输入串启用从右到左搜索,默认是从左到右。 |
Singleline |
指定单行模式,这意味着. 将会匹配每一个字符。 |
目前来说,前面的例子中并没有任何内容超脱于.NET基础类。尽管如此,正则表达式的强大之处体现在模式字符串(pattern string)上,因为模式串并不单单仅限于纯文本。就像前面提示的,它还可以包含一些特殊的内容,我们称之为元字符(meta-characters),这类字符提供了特殊的命令,包括转义序列等等,这点跟C#中的转义很像。这是一些由\ 之后紧跟的字符组成的转义序列,带有特殊的含义。
举个例子,假设你想查找所有以n开头的单词,你可以使用转义序列\b ,它代表着一个单词的边界(一个单词的边界意味着在一个字母或数字字符之后,亦或是紧跟在一个空格或标点符号之后),考虑以下的代码:
const string pattern = @"\bn";
MatchCollection myMatches = Regex.Matches(input, pattern, RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture);
注意这里我们在pattern字符串前面使用了@标记。因为你希望将\b 传递给.NET正则表达式引擎,而非转义后的字符——你不想让C#编译器将\ 理解成你需要先转义再传递,因此这里的@标记不能省略。
假如你想查找以ure结尾的单词,你可以这么写:
const string pattern = @"ure\b";
又假如你想查找所有a开头ure结尾的子串(在本例中仅有architecture符合要求),这时候你就需要稍微思考一下你的模式串,显而易见的是你的模式串会包含\ba 代表a开头的以及ure\b 代表ure结尾,那么中间应该是啥呢?你可能需要告诉应用程序,在a和ure之间可以是任意数量的字符但不能是空格。所以,你可能会需要像这样的模式串:
const string pattern = @"\ba\S*ure\b";
到最后(Eventually),你得到了一个看上去似乎有些奇怪的字符序列,但正则表达式能从逻辑上很好地理解它。转义序列\S 指定其后的字符必须为不是空格的任意字符。而紧随其后的* 被称作量词(quantifier),它代表的含义是前面的字符可以出现任意次,甚至不出现。因此组合起来的\S* 就代表着任意数量的字符,只要它们不是空格就行。因此上面这个模式串就可以满足我们的需求,查找以a开头并且以ure结尾的任意单词。
下面的表格列举了一些常用的字符和转义序列,注意这并不是完整版,完整的列表你可以在Microsoft的文档中获取。
符号 |
描述 |
示例 |
匹配 |
^ |
输入串起始 |
^B |
输入串第一个字符是B |
$ |
输入串结束 |
X$ |
输入串最后一个字符是X |
. |
任意一个字符,换行符除外 |
i.ation |
isation,ization之类的 |
* |
前一个字符可能重复0或N次 |
ra*t |
rt,rat,raat,raaat等 |
+ |
前一个字符可能重复1或者N次 |
ra+t |
rat,raat,raaat等但不能是rt |
? |
前面的字符可能重复0或者1次 |
ra?t |
只能是rt或者rat |
\s |
任意空格 |
"\sa" |
[space]a,\ta,\na(\t和\n在 C#里有同样的含义) 原书这里是不对的,具体如下 |
\S |
任意非空格字符 |
\SF |
aF, rF, cF, 但不能是 [space]F |
\b |
单词左右边界 |
ion\b |
以ion结尾的单词 |
\B |
单词中非左右边界的任意位置 |
\Bx\B |
开头结尾不是x但中间 包含x的单词 |
Console.WriteLine($"{Regex.IsMatch(@"\ta", @"\sa")}"); //False
Console.WriteLine($"{Regex.IsMatch(@" a", @"\sa")}"); //True
Console.WriteLine($"{Regex.IsMatch(@"\r\na", @"\sa")}"); //False
Console.WriteLine($"{Regex.IsMatch(@"\na", @"\sa")}"); //False
Console.WriteLine($"{Regex.IsMatch(@" a", @"\sa")}"); //True
假如你想查找单个的元字符(meta-characters),同样你也可以使用转义符\ 来声明。举个例子,当你试图使用英文句号. 而非它代表的含义的时候,你可以直接用\. 来表示。
你可以用[] 包裹相应的字符,代表着可选字符的匹配请求。例如,[1c] 意味着当前这个位置的字符可以是1或者c,假如你想查找任何包含map或者man的单词,你可以这么写:ma[pn]。通过中括号[] ,你还可以声明一个范围,譬如[a-z]表示26个小写字母,而[A-E]则表示任意A到E之间的大写字母(包含A和E自身),又或者是[0-9],代表任意一位数字。[0-9]的快捷记法是\d ,假如你想搜索一个整数,这意味着它可能包括至少1位数字字符,你可以这么写:[0-9]+或者[\d]+。
^ 在中括号里也有不同的含义,当它处于中括号外侧的时候,它代表着文本的第一个字符,但当它处于中括号中的时候,它代表的是非其后的字符,例如[^0] 代表匹配0以外的任意字符。
9.3.3 显示结果
在本小节中,你将会从RegularExpressionsPlayground示例代码中了解到正则表达式是如何工作的。
这里主要介绍的方法是WriteMatches,如下所示:
public static void WriteMatches(string text, MatchCollection matches)
{
Console.WriteLine($"Original text was: \n\n{text}\n");
Console.WriteLine($"No. of matches: {matches.Count}");
foreach (Match nextMatch in matches)
{
int index = nextMatch.Index;
string result = nextMatch.ToString();
int charsBefore = (index < 5) ? index : 5;
int fromEnd = text.Length - index - result.Length;
int charsAfter = (fromEnd < 5) ? fromEnd : 5;
int charsToDisplay = charsBefore + charsAfter + result.Length;
Console.WriteLine($"Index: {index}, \tString: {result}, \t" + $"{text.Substring(index - charsBefore, charsToDisplay)}");
}
}
它的主要作用是更详细地显示MatchCollection集合里的信息。对于每个match,它显示了该match在输入串中的序号,匹配上的子串是啥,以及为了方便你定位补全的前后字符(前后各补齐5个字符,一共10个)。举个例子,当你匹配到applications ,前面例子通过补全后显示的就是 web applications imme ,这仅仅只是我们为了让你能够快速定位匹配位置而已,并不是系统类库提供的属性。
上面这个方法中大部分致力于将找到的匹配串扩展成尽可能长的显示串。留意你也可以使用Match类里的Value属性来获取match匹配到的子串。除此之外,我们在RegularExpressionsPlayground示例里简单的定义了一系列的方法,命名为Find1,Find2等等,本小节接下去的内容会一一演示,例如Find2就演示了如何查找a开头ure结尾的子串:
public static void Find2(string text)
{
string pattern = @"\ba\S*ure\b";
MatchCollection matches = Regex.Matches(text, pattern,
RegexOptions.IgnoreCase);
Console.WriteMatches(text, matches);
}
你可以在Main方法里调用FindN的一系列方法,这些代码同样也引用了相同的命名空间:
using System;
using System.Text.RegularExpressions;
运行Find2你将看到以下的输出:
No. of matches: 1
Index: 506, String: architecture, f C# architecture and
9.3.4 匹配、分组和捕获
正则表达式一个特别美妙的特性就是你可以对字符进行分组,它跟C#中的复合语句(compound statements)很相似。在C#语言里,你可以将任意数量的语句放置在一组大括号中,结果会被当成一个复合语句进行输出。在正则表达式的模式(pattern)中,你可以将任意数量的字符(包括元字符和转义序列)进行分组,它们会被当成一个单一字符,唯一的区别就是在正则表达式中你使用的是小括号而已。结果序列(resultant sequence)被当成一个组进行处理。
举个例子,考虑模式(an)+ ,它定位任何包含an 的子串。+ 计量符号仅仅只能针对前面的一个字符,但因为你将前面的俩字符用小括号括起来了,现在+ 将它俩当成一个字符进行看待。这意味着你将这个模式应用于ananas came to Europe late in the annals of history 这样的输入串的时候,bananas 里的anan 将会得到匹配;但是,如果你省略小括号,写成an+ 的话,那么程序将会匹配到annals 里的ann ,以及bananas里的两个an 。这是因为表达式(an)+ 匹配的是an,anan,anan...an等等,而an+ 匹配的则是an,ann,ann...n这样子。
注意:你可能会有疑问,为何在上面的例子中,模式(an)+ 匹配的时候,选取的是bananas里的anan而非单独的两个an。这是因为得到的匹配必须是不重叠的(not overlap)。假如一组可能的匹配里会有交集,那么默认会输出里面最长的匹配序列。
分组的力量远比上面介绍的还要强大。默认情况下,当你将模式中的某些部分组合起来作为一组的时候,你相当于要求正则表达式引擎记住那些与该分组不匹配的情况(remember any matches against just that group),还包括那些跟整个模式不匹配的部分(any matches against the entire pattern)。换句话说,你将把你分组的内容当成一个模式进行匹配,并且返回跟该模式匹配的部分。当你想将一个字符串分成各个构件(component parts)时这一特性将会极其有用。
例如,URI拥有这样的格式:
<protocol>://<address>:<port>
这里端口<port> 是可以缺省的。一个简单的URI可能是这样的:http://www.wrox.com:80,假定你现在想从这个URI里解析它的协议,地址和端口,这个URI可能会包含空格(但是没有标点符号),你可能会这么写表达式:
\b(https?)(://)([.\w]+)([\s:]([\d]{2,5})?)\b
让我们来看看这个表达式是怎么执行的:首先,首位和末尾的\b 序列确保你需要考虑的仅仅是中间完整单词的部分文本(consider only portions of text that are entire words)。在\b 之间,第一个分组,(https?) 定义了确定了协议要么是http要么是https。? 紧跟在s 之后说明s 出现的次数要么是0次还么是1次,因此允许http或者https,小括号说明协议被分成了一组进行判定。
第二个分组比较简单,仅仅只是(://) ,说明紧跟在协议之后的必须是:// 。
第三个分组([.\w]+) 就有意思多了,这个中括号括起来的表达式(parenthetical expression)包含了一个可选项,它表示之后的内容,要么是. 要么是字母(\w 代表字母),+ 表示这些字符可以重复若干次,因此[.\w]+ 能匹配上www.wrox.com。
第四个分组([\s:]([\d]{2,5})?) 是一个更长的表达式,它还嵌套了一个分组。分组中首先是[\s:] ,它的含义是这里可以是空格(\s 代表空格)或者: 。紧随其后的内置分组[\d]{2,5} 中\d 表示的是数字,其后的{2,5} 表示的是前面的字符可以是最少2位,最多5位。整个内置分组后面跟着? 说明数字要么出现0次或者1次。第四个分组是很重要的,因为URI里并非每次都会带上端口号,事实上往往都没有(absent)。
让我们来看看代码:
string line = "Hey, I've just found this amazing URI at " + "http:// what was it –oh yes https://www.wrox.com or " + "http://www.wrox.com:80";
string pattern = @"\b(https?)(://)([.\w]+)([\s:]([\d]{2,4})?)\b";
var r = new Regex(pattern);
MatchCollection mc = r.Matches(line);
foreach (Match m in mc)
{
Console.WriteLine($"Match: {m}");
foreach (Group g in m.Groups)
{
if (g.Success)
{
Console.WriteLine($"group index: {g.Index}, value: {g.Value}");
}
}
Console.WriteLine();
}
这段代码里跟前面的例子一样,使用了Matches方法来匹配整个表达式,唯一的区别在于我们这里用了Match.Groups属性来遍历整个Group对象并且将每个结果的索引和值输出到控制台上。
运行代码你将会看到:
Match: https://www.wrox.com
group index: 69, value: https://www.wrox.com
group index: 69, value: https
group index: 74, value: ://
group index: 77, value: www.wrox.com
group index: 89, value:
Match: http://www.wrox.com:80
group index: 93, value: http://www.wrox.com:80
group index: 93, value: http
group index: 97, value: ://
group index: 100, value: www.wrox.com
group index: 112, value: :80
group index: 113, value: 80
通过这个例子你可以看到,文本中的URI被很好的匹配出来了,并且URI中被很好地解析成了不同的部分。某些部分,譬如协议和地址之间的分隔符(such as the seperation between the protocol and the address),是可以忽略的,并且分组也可以被命名(and groups can also be named)。
让我们把正则表达式修改一下,给每个部分加上名称,并且忽略某些简单的部分,如下所示:
string pattern = @"\b(?<protocol>https?)(?:://)" + @"(?<address>[.\w]+)([\s:](?<port>[\d]{2,4})?)\b";
在分组前面以?<name> 的形式指定分组的名称。例如上面的代码中,我们指定了协议、地址、端口的名称。并且,使用?: 来忽略某个分组。不用为?::// 感到疑惑,你的表达式依然会搜索:// ,只是不再将它加入到Group结果集中,因为它前面使用了?: 标识了忽略这部分。将上面例子中用命名后的pattern重新执行一次,结果如下:
Match: https://www.wrox.com
group index: 69, value: https://www.wrox.com
group index: 89, value:
group index: 69, value: https
group index: 77, value: www.wrox.com
Match: http://www.wrox.com:80
group index: 93, value: http://www.wrox.com:80
group index: 112, value: :80
group index: 93, value: http
group index: 100, value: www.wrox.com
group index: 113, value: 80
Regex类里面还提供了一个方法GetGroupNames来获取表达式的分组名称。请看下面代码:
Regex r = new Regex(pattern, RegexOptions.ExplicitCapture);
MatchCollection mc = r.Matches(line);
foreach (Match m in mc)
{
Console.WriteLine($"match: {m} at {m.Index}");
foreach (var groupName in r.GetGroupNames())
{
Console.WriteLine($"match for {groupName}: {m.Groups[groupName].Value}");
}
}
先获取所有分组名称,然后根据每一个匹配Match的Groups属性,读取相应的分组名称对应的值。运行上面的代码显示如下:
match: https://www.wrox.com at 69
match for 0: https://www.wrox.com
match for protocol: https
match for address: www.wrox.com
match for port:
match: http://www.wrox.com:80 at 93
match for 0: http://www.wrox.com:80
match for protocol: http
match for address: www.wrox.com
match for port: 80
9.4 字符串和Span
现在我们编程的时候往往需要操作很长的字符串,譬如Web API给你返回的JSON或者XML格式的字符串。将一个长串分割成若干个小串进行分析意味着需要创建许多的对象,当这些字符串不再使用的时候,GC就要花很多的时间来回收相应的内存。
.NET Core拥有一种新的方式来解决这个问题:使用Span<T> 类型。第7章"数组"的时候我们已经初步介绍过它。这种类型可以引用数组的分片(slice of an array)而不用拷贝一份同样的内容。同样地,Span<T> 也可以用来引用字符串string的一部分内容而无需额外拷贝。
接下来的代码会用到我们前面用来演示正则表达式而创建的text变量,这是一个相当长的字符串,我们将会创建一个Span引用它。具体代码如下所示:
int ix = text.IndexOf("Visual");
ReadOnlySpan<char> spanToText = text.AsSpan();
ReadOnlySpan<char> slice = spanToText.Slice(ix, 13);
string newString = new string(slice.ToArray());
Console.WriteLine(newString);
通过AsSpan扩展方法,我们得到一个ReadOnlySpan<Char> 对象,这是一个string的扩展方法,因为string是由char元素组成的。Span<T> 在内部其实是使用了ref关键字来保持对string的引用。通过Slice方法可以得到string的子串,该方法有两个参数,第一个参数标识的是起始位置,我们通过在text文本中定位Visual字样得到,第二个参数是子串长度,我们这里传递的是13,意思是从Visual的V开始读取13个字符赋值给slice,结果同样也是ReadOnlySpan<Char> 类型。只有当你调用ToArray方法时才会重新给它分配所需的内存(allocates memory needed by the slide),这个方法得到的char数组我们传递给string的构造函数,以此来创建一个新的string,最后在控制台上输出Visual Studio 字样。
注意:对数组使用Span我们已经在第7章介绍过。如果你想了解更多关于span是如何在内存中进行分配以及ref关键字是如何应用的,你可以阅读第17章,"托管和非托管资源"。第32章,"Web API"将会介绍Web API返回的JSON或者XML格式串,你也可以在附赠章节2,"XML和JSON"里了解到更多的细节。
9.5 小结
当你使用.NET Framework的时候,你可以使用很多数据类型,应用程序(尤其是那些负责提交或者检索的程序)中最常见的数据类型之一就是string类型。string类型是如此的重要,这也是为何本书要用完整的一章来探讨如何使用string类型以及在你的应用程序中如何更好地操作它。
早期我们使用string的时候,通常会按照实际需要将其分割然后再组合(slice and dice the strings as needed using concatenation)。在.NET Framework中,你可以使用StringBuilder类来完成这些任务,因为它拥有更好的性能。
string的另外一个特性是字符串插入值(string interpolation)。在大部分应用程序中,使用这个特性会让你处理字符串更加简单。
使用正则表达式来操作string是一个更棒的工具,它能快速的检索和验证你的字符串。
最后,你也了解到了在大字符串中,如何使用Span结构体来更加高效地操作string,它无需重新分配和释放内存。
第10章和第11章我们将介绍不同的集合类。
扩展资料
|
请发表评论