10.3 正则表达式

正则表达式作为小型技术领域的一部分,在各种程序中都有着难以置信的作用。正则表达式可以看成一种有特定功能的小型编程语言:在大的字符串表达式中定位一个子字符串。它不是一种新技术,最初是在UNIX环境中开发的,与Perl和JavaScript编程语言一起使用得比较多。System.Text.RegularExpressions名称空间中的许多.NET类都支持正则表达式。.NET Framework的各个部分也使用正则表达式。例如,在ASP.NET验证服务器的控件中就使用了正则表达式。

对于不太熟悉正则表达式语言的读者,本节将主要解释正则表达式和相关的.NET类。如果你很熟悉正则表达式,就可以浏览本节,选择学习与.NET基类引用有关的内容。注意,.NET正则表达式引擎用于兼容Perl 5的正则表达式,但它有一些新功能。

10.3.1 正则表达式概述

正则表达式语言是一种专门用于字符串处理的语言。它包含两个功能:

● 一组用于标识特殊字符类型的转义代码。你可能很熟悉DOS命令中使用*字符表示任意子字符串(例如,DOS命令Dir Re*会列出名称以Re开头的所有文件)。正则表达式使用与*类似的许多序列来表示“任意一个字符”、“一个单词的中断”和“一个可选的字符”等。

● 一个系统,在搜索操作中把子字符串和中间结果的各个部分组合起来。

使用正则表达式,可以对字符串执行许多复杂而高级的操作,例如:

● 识别(可以是标记或删除)字符串中所有重复的单词,例如,把“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,提取出其中的协议、计算机名和文件名等)。

当然,这些都是可以在C#中用System.String和System.Text.StringBuilder的各种方法执行的任务。但是,在一些情况下,还需要编写相当多的C#代码。如果使用正则表达式,这些代码一般可以压缩为几行。实际上,这是实例化了一个对象System.Text.RegularExpressions.RegEx(甚至更简单,调用静态的RegEx()方法),给它传递要处理的字符串和一个正则表达式(这是一个字符串,它包含用正则表达式语言编写的指令)。

正则表达式字符串初看起来像是一般的字符串,但其中包含了转义序列和有特定含义的其他字符。例如,序列\b表示一个字的开头和结尾(字的边界),因此如果要表示正在查找以字符th开头的字,就可以编写正则表达式\bth(即字边界是序列-t-h)。如果要搜索所有以th结尾的单词,就可以编写th\b(字边界是序列t- h-)。但是,正则表达式要比这复杂得多,包括可以在搜索操作中找到存储部分文本的工具性程序。本节仅简要介绍正则表达式的功能。

注意:正则表达式的更多信息可参阅Andrew Watt撰写的图书Beginning Regular Expressions(John Wiley&Sons,2005)。

假定应用程序需要把美国电话号码转换为国际格式。在美国,电话号码的格式为314-123-1234,常常写作(314)123-1234。在把这个国家格式转换为国际格式时,必须在电话号码的前面加上+1(美国的国家代码),并给区号加上圆括号:+1(314) 123-1234。在查找和替换时,这并不复杂。但如果要使用String类完成这个转换,就需要编写一些代码(这表示必须使用System.String类的方法来编写代码)。而正则表达式语言可以构造一个短的字符串来表达上述含义。

所以,本节只有一个非常简单的示例,我们只考虑如何查找字符串中的某些子字符串,无须考虑如何修改它们。

10.3.2 RegularExpressionsPlayaround示例

本章的正则表达式示例使用如下依赖项和名称空间:

依赖项:

        NETStandard.Library

名称空间:

        System
        System.Text.RegularExpressions
        static System.Console

下面将开发一个小示例RegularExpressionsPlayaround,通过实现并显示一些搜索的结果,说明正则表达式的一些功能,以及如何在C#中使用.NET正则表达式引擎。在这个示例文档中使用的文本是本书前一版的部分简介(代码文件RegularExpressionsPlayground/Program.cs):

        const string input =
            @"This book is perfect for both experienced C# programmers looking to " +
            "sharpen their skills and professional developers who are using C# for " +
            "the first time. The authors deliver unparalleled coverage of " +
            "Visual Studio 2013 and .NET Framework 4.5.1 additions, as well as " +
            "new test-driven development and concurrent programming features. " +
            "Source code for all the examples are available for download, so you " +
            "can start writing Windows desktop, Windows Store apps, and ASP.NET " +
            "web applications immediately.";

注意:上面的代码说明了前缀为@符号的逐字字符串的实用性。这个前缀在正则表达式中非常有用。

我们把这个文本称为输入字符串。为了说明.NET类的正则表达式,我们先进行一次纯文本的基本搜索,这次搜索不带任何转义序列或正则表达式命令。假定要查找所有的字符串ion,把这个搜索字符串称为模式。使用正则表达式和上面声明的变量Text,可编写出下面的代码:

        public static void Find1(text)
        {
          const string pattern = "ion";
          MatchCollection matches = Regex.Matches(text, pattern,
                                    RegexOptions.IgnoreCase |
                                    RegexOptions.ExplicitCapture);
          foreach (Match nextMatch in matches)
          {
            WriteLine(nextMatch.Index);
          }
        }

在这段代码中,使用了System.Text.RegularExpressions名称空间中Regex类的静态方法Matches()。这个方法的参数是一些输入文本、一个模式和从RegexOptions枚举中提取的一组可选标志。在本例中,指定所有的搜索都不应区分大小写。另一个标记ExplicitCapture改变了收集匹配的方式,对于本例,这样可以使搜索的效率更高,其原因详见后面的内容(尽管它还有这里没有探讨的其他用法)。Matches()方法返回MatchCollections对象的引用。匹配是一个技术术语,表示在表达式中查找模式实例的结果,用System.Text.RegularExpressions.Match类来表示它。因此,我们返回一个包含所有匹配的MatchCollection,每个匹配都用一个Match对象来表示。在上面的代码中,只是迭代集合,并使用Match类的Index属性,Match类返回输入文本中匹配所在的索引。运行这段代码将得到3个匹配。表10-3描述了RegexOptions枚举的一些成员。

表10-3

到目前为止,在前面的示例中,除了一些新的.NET基类外,其他都不是新的内容。但正则表达式的能力主要取决于模式字符串,原因是模式字符串不必仅包含纯文本。如前所述,它还可以包含元字符和转义序列,其中元字符是给出命令的特定字符,而转义序列的工作方式与C#的转义序列相同。它们都是以反斜杠(\)开头的字符,且具有特殊的含义。

例如,假定要查找以n开头的字,那么可以使用转义序列\b,它表示一个字的边界(字的边界是以字母数字表中的某个字符开头,或者后面是一个空白字符或标点符号)。可以编写如下代码:

        const string pattern = @"\bn";
        MatchCollection myMatches = Regex.Matches(input, pattern,
                                      RegexOptions.IgnoreCase |
                                      RegexOptions.ExplicitCapture);

注意字符串前面的符号@。要在运行时把\b传递给.NET正则表达式引擎,反斜杠(\)不应被C#编译器解释为转义序列。如果要查找以序列ion结尾的字,就可以使用下面的代码:

        const string pattern = @"ions\b";

如果要查找以字母a开头、以序列ion结尾的所有字(在本例中仅有一个匹配的字application),就必须在上面的代码中添加一些内容。显然,我们需要一个以\ba开头、以ion\b结尾的模式,但中间的内容怎么办?需要告诉应用程序,在a和ion中间的内容可以是任意长度的字符,只要这些字符不是空白即可。实际上,正确的模式如下所示。

        const string pattern = @"\ba\S*ions\b";

使用正则表达式要习惯的一点是,对像这样怪异的字符序列应见怪不怪。但这个序列的工作是非常逻辑化的。转义序列\S表示任何不是空白字符的字符。*称为限定符,其含义是前面的字符可以重复任意次,包括0次。序列\S*表示任意数量不是空白字符的字符。因此,上面的模式匹配以a开头以ion结尾的任何单个单词。

表10-4是可以使用的一些主要的特定字符或转义序列,但这个表并不完整,完整的列表请参考MSDN文档。

表10-4

如果要搜索其中一个元字符,就可以通过带有反斜杠的相应转义字符来表示。例如,“.”(一个句点)表示除了换行字符以外的任何单个字符,而“\.”表示一个点。

可以把替换的字符放在方括号中,请求匹配包含这些字符。例如,[1|c]表示字符可以是l或c。如果要搜索map或man,就可以使用序列ma[n|p]。在方括号中,也可以指定一个范围,例如,[a-z]表示所有的小写字母,[A-E]表示A~E之间的所有大写字母(包括字母A和E), [0-9]表示一个数字。如果要搜索一个整数(该序列只包含0~9的字符),就可以编写[0-9]+。

注意:使用“+”字符表示至少要有这样一个数字,但可以有多个数字,所以9、 83和854等都是匹配的。

^用在方括号中时有不同的含义。在方括号外部使用它,就标记输入文本的开头。在方括号内使用它,表示除了^之后的字符之外的任意字符。

10.3.3 显示结果

本节编写一个示例RegularExpressionsPlayaround,看看正则表达式的工作方式。

该示例的核心是一个方法WriteMatches(),它把MatchCollection中的所有匹配以比较详细的格式显示出来。对于每个匹配结果,该方法都会显示匹配在输入字符串中的索引、匹配的字符串和一个略长的字符串,其中包含匹配结果和输入文本中至多10个外围字符,其中至多有5个字符放在匹配结果的前面,至多5个字符放在匹配结果的后面(如果匹配结果的位置在输入文本的开头或结尾5个字符内,则结果中匹配字符串前后的字符就会少于5个)。换言之,在RegularExpressionsPlayaround示例开始时,如果要匹配的单词是applications,靠近输入文本开头的匹配结果应是“web applications imme”,匹配结果的前后各有5个字符,但位于输入文本的最后一个字immediately上的匹配结果就应是" ions immediately " ——匹配结果的后面只有一个字符,因为在该字符的后面是字符串的结尾。下面这个长字符串可以更清楚地表明正则表达式是在什么地方查找到匹配结果的:

        public static void WriteMatches(string text, MatchCollection matches)
        {
          WriteLine($"Original text was: \n\n{text}\n");
          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;
            WriteLine($"Index: {index}, \tString: {result}, \t" +
              "{text.Substring(index - charsBefore, charsToDisplay)}");
          }
        }

在这个方法中,处理过程是确定在较长的子字符串中有多少个字符可以显示,而无须超出输入文本的开头或结尾。注意在Match对象上使用了另一个属性Value,它包含标识该匹配的字符串。而且,RegularExpressionsPlayaround只包含名为Find1、Find2等的方法,这些方法根据本节中的示例执行某些搜索操作。例如,Find2查找以a开头的任意字符串:

        public static void Find2(string text)
        {
          string pattern = @"\ba\S*ions\b";
          MatchCollection matches = Regex.Matches(text, pattern,
              RegexOptions.IgnoreCase);
          WriteMatches(text, matches);
        }

下面是一个简单的Main()方法,可以编辑它,从而选择一个Find<n>()方法:

        public static void Main()
        {
          Find2();
          ReadLine();
        }

这段代码还需要使用RegularExpressions名称空间:

        using System;
        using System.Text.RegularExpressions;

运行带有Find 2()方法的示例,得到如下所示的结果:

        No. of matches: 2
        Index: 243,    String: additions,    .5.1 additions, as
        Index: 469,    String: applications,  web applications imme

10.3.4 匹配、组和捕获

正则表达式的一个优秀特性是可以把字符组合起来,其工作方式与C#中的复合语句一样。在C#中,可以把任意数量的语句放在花括号中,把它们组合在一起,其结果视为复合语句。在正则表达式模式中,也可以把任何字符组合起来(包括元字符和转义序列),像处理单个字符那样处理它们。唯一的区别是要使用圆括号而不是花括号,得到的序列称为一组。

例如,模式(an)+定位任意重复出现的序列an。限定符“+”只应用于它前面的一个字符,但因为我们把字符组合起来了,所以它现在把重复的an作为一个单元来对待。这意味着,如果(an)+应用到输入文本“bananas came to Europe late in the annals of history”上,就会从bananas中识别出anan。另一方面,如果使用an+,则程序将从annals中选择ann,从bananas中选择出两个分开的an序列。表达式(an)+可以识别出an、anan、ananan等,而表达式an+可以识别出an、ann、annn等。

注意:在上面的示例中,为什么(an)+从banana中选择的是anan,而没有把其中一个an作为一个匹配结果?因为匹配结果是不能重叠的。如果有可能重叠,在默认情况下就选择最长的匹配序列。

但是,组的功能要比这强大得多。在默认情况下,把模式的一部分组合为一个组时,就要求正则表达式引擎按照该组来匹配,或按照整个模式来匹配。换言之,可以把组当成一个要匹配和返回的模式。如果要把字符串分解为各个部分,这种模式就非常有效。

例如,URI的格式是<protocol>://<address>:<port>,其中端口是可选的。它的一个示例是http://www.wrox.com:80。假定要从一个URI中提取协议、地址和端口,而且不考虑URI的后面是否紧跟着空白(但没有标点符号),那么可以使用下面的表达式:

        \b(https? )(://)([.\w]+)([\s:]([\d]{2,5})? )\b

该表达式的工作方式如下:首先,前导\b序列和结尾\b序列确保只需要考虑完全是字的文本部分。在这个文本部分中,第一个组(https? )会识别http或https协议。S字符后面的?指定这个字符可能出现0次或1次,因此找到http和https。括号表示把协议存储为一组。

第二个组是一个简单的(://)。它仅指定字符://。

第三个组([.\w]+)比较有趣。这个组包含一个放在括号里的表达式,该表达式要么是句点字符(.),要么是用\w指定的任意字母数字字符。这些字符可以重复任意多次,因此匹配wwww.wrox.com

第四组([\s:]([\d]{2,5})? )是一个较长的表达式,包含一个内部组。在该组中,第一个放在括号中的表达式允许通过\ s指定空白字符或冒号。内部组用[\d]指定一个数字。表达式{ 2, 5 }指定前面的字符(数字)允许至少出现两次但不超过5次。数字的完整表达式用内部组后面的?指定允许出现0次或1次。使这个组变成可选非常重要,因为端口号并不总是在URI中指定;事实上,通常不指定它。

下面定义一个字符串来运行这个表达式(代码文件RegularExpressionsPlayground/ Program.cs):

        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";

与这个表达式匹配的代码使用类似于之前的Matches方法。区别是在Match.Groups属性内迭代所有的Group对象,在控制台上输出每组得到的索引和值:

        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)
        {
          WriteLine($"Match: {m}");
          foreach (Group g in m.Groups)
          {
            if (g.Success)
            {
              WriteLine($"group index: {g.Index}, value: {g.Value}");
            }
          }
          WriteLine();
        }

运行程序,得到如下组和值:

        Match https://www.wrox.com
        group index 70, value: https://www.wrox.com
        group index 70, value: https
        group index 75, value: ://
        group index 78, value: www.wrox.com
        group index 90, value:
        Match http://www.wrox.com:80
        group index 94, value http://www.wrox.com:80
        group index 94, value: http
        group index 98, value: ://
        group index 101, value: www.wrox.com
        group index 113, value: :80
        group index 114, value: 80

之后,就匹配文本中的URI, URI的不同部分得到了很好的分组。组还提供了更多的功能。一些组,如协议和地址之间的分隔,可以忽略,并且组也可以命名。

修改正则表达式,命名每个组,忽略一些名称。在组的开头指定?<name>,就可给组命名。例如,协议、地址和端口的正则表达式组就采用相应的名称。在组的开头使用?:来忽略该组。不要迷惑于组内的?::/ /,它表示搜索:/ /,组本身因为前面的?:而被忽略:

        string pattern = @"\b(? <protocol>https? )(? :://)" +
            @"(? <address>[.\w]+)([\s:](? <port>[\d]{2,4})? )\b";

为了从正则表达式中获得组,Regex类定义了GetGroupNames方法。在下面的代码段中,每个匹配都使用所有的组名,使用Groups属性和索引器输出组名和值:

        Regex r = new Regex(pattern, RegexOptions.ExplicitCapture);
        MatchCollection mc = r.Matches(line);
        foreach (Match m in mc)
        {
          WriteLine($"match: {m} at {m.Index}");
          foreach (var groupName in r.GetGroupNames())
          {
            WriteLine($"match for {groupName}: {m.Groups[groupName].Value}");
          }
        }

运行程序,就可以看到组名及其值:

        match: https://www.wrox.com at 70 
        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 94 
        match for 0: http://www.wrox.com:80 match for protocol: http
        match for address: www.wrox.com
        match for port: 80