2.2 正则表达式
String类型里提供了基础的查找和替换API,分别是IndexOf和Replace方法。如果要执行更复杂的基于模式的搜索匹配操作,就需要用到正则表达式。正则表达式允许在大量文本中迅速找到特定的字符模式。其可以用来检验文本是否满足预定的模式(如手机号校验),可以提取、编辑、替换甚至移除部分子字符串等。在.NET中,System.Text.RegularExpressions命名空间的Regex类就是正则表达式引擎的核心类型。读者如果有DOS或者Linux Bash的操作经验的话,对“*”和“?”这两个通配符应该不会陌生。正则表达式可以看成是通配符的升级。代码清单2-7展示了正则表达式的一个最常用的场景——判断给定的字符串是否匹配预定的模式,这里做的是电话号码格式校验。
代码清单2-7 使用正则表达式匹配电话号码
1 // 源码位置:第2章\RegexDemo.cs 2 using System.Text.RegularExpressions; 3 // ... 4 var regex = new Regex("^\\d{3,4}-\\d{7,8}$"); 5 Console.WriteLine(regex.IsMatch("021-66106610")); // True 6 Console.WriteLine(regex.IsMatch("0731-6610661")); // True 7 Console.WriteLine(regex.IsMatch("02166106610")); // False 8 Console.WriteLine(regex.IsMatch("21-66106610")); // False 9 Console.WriteLine(regex.IsMatch(" 021-66106610")); // False
第4行中初始化Regex对象的参数就是一个正则表达式字符串,“\d”匹配0~9之间任意一个数字字符。由于“\”在字符串中被当作转移字符,因此在模式字符串中需要写成“\\d”的形式。一般来说,电话号码是“区号-电话号码”格式,区号通常是3~4个数字,电话号码是7~8个数字,在模式字符串中使用“\\d{3,4}”来匹配区号,使用“\\d{7,8}”匹配电话号码。“{3,4}”叫作数量限制符,跟在模式字符后面,表明最少匹配次数和最大匹配次数。区号和电话号码之间使用“-”分隔,即如果没有“-”分隔则认为输入字符串不是合法的电话号码,如第7行的匹配结果。模式字符串最前面的“^”和最后的“$”字符被称为锚点(Anchor)字符。限定匹配是从字符串的最开始一直匹配到字符串的结尾,这个限定条件造成了第9行的匹配失败,因为最前面有一个空格。
由于正则表达式在查找和替换字符串方面很好用,因此很多文本编辑器集成了正则表达式查找/替换功能,如Visual Studio IDE和Visual Studio Code。图2-6演示了在Visual Studio Code中使用正则表达式查找Unicode字符,首先需要在查找对话框中勾选最后一个齿轮状选项——该选项启用正则表达式匹配功能,然后在查找文本框中输入正则表达式即可匹配。
图2-6 在Visual Studio Code中使用正则表达式查找Unicode字符
笔者在表2-4中梳理了一些常用的正则表达式元素,供读者参考。.NET支持的完整元素列表和相关的说明请读者参阅https://docs.microsoft.com/en-us/dotnet/standard/base-types/regular-expression-language-quick-reference。
表2-4 常用的正则表达式元素说明
2.2.1 构造分组
在很多场景里,除了需要判断输入字符串是否匹配预定的模式以外,还有将部分字符串提取出来的需求,如在匹配一个日期字符串时,可能还希望将年月日部分分别提取出来。分组就是用来匹配输入字符串中的子字符串的。分组匹配的字符串既可以作为匹配的结果返回,也可以替换子字符串。我们可以使用下面的表达式构造最基本的字符串分组。subexpression可以是任何一个合法的正则表达式。
代码清单2-8中第2行演示了最简单的分组构造方式——使用分组将匹配成功的字符串部分提取出来,如匹配日期时,将成功匹配的日期的年月日部分保存下来,以便后续的代码处理。
代码清单2-8 正则表达式构造分组示例
1 // 源码位置:第2章\RegexDemo.cs 2 foreach (Match match in Regex.Matches("2018-12-31", @"(\d+)-")) 3 { 4 Console.WriteLine(match.Groups[0].Value); 5 Console.WriteLine(match.Groups[1].Value); 6 } 7 8 foreach (Match match in Regex.Matches( 9 "He said that that was the the correct answer.", @"(\w+)\s(\1)")) 10//"He said that that was the the correct answer.", @"(?<dup>\w+)\s(\k<dup>)")) 11 { 12 Console.WriteLine("重复单词:{0},位置:{1} - {2}", 13 match.Groups[1].Value, match.Groups[1].Index, match.Groups[2].Index); 14 } 15 16 var m1 = Regex.Match("2018-12-31", 17 @"(?<year>\d+)-(?'month'\d+)-(?<day>\d+)"); 18 Console.WriteLine($"{m1.Groups["year"].Value}年" + 19 "{m1.Groups["month"].Value}月{m1.Groups["day"].Value}日");
代码清单2-8中使用Regex的静态方法Matches来获取字符串中匹配正则表达式的所有子字符串。Matches方法的第一个参数是待匹配的输入字符串,第二个参数是正则表达式模式。当表达式匹配成功时,返回的Match对象中的Groups属性会保存所有匹配的子表达式。Groups中的第一个元素是整个正则表达式匹配到的字符串,第4行在第一次循环时输出“2018-”,即模式“(\d+)-”完整匹配到的字符串。从Groups的第二个元素开始才是每个分组的匹配结果,如第5行在第一次循环时输出“2018”,即分组“(\d+)”匹配到的结果。分组内部还可以嵌套分组。类似地,嵌套的分组和其外围的分组都会保存到Groups属性中,并按匹配的顺序来索引。正则表达式中使用了大量转义字符,但输入过多的“\\”字符不仅烦琐,而且影响代码的可读性,我们可以在正则表达式前缀加上原义识别符“@”来增加表达式的可读性,如第2行。
按照索引来获取分组信息比较烦琐,我们可以通过命名分组的方式来增加代码的可读性,如使用“(?<name>subexpression)”或者“(?'name'subexpression)”来命名分组,如第17行中将日期分成三个部分:“year”“month”“day”。匹配成功后,与其使用索引号来获取分组,不如直接用分组名字,如第19行。.NET的正则表达式引擎里允许获取分组匹配的所有字符串,这些匹配的字符串被称作Capture。这一点与很多其他编程语言是不一样的。举一个简单的例子:通过“(.)+”模式匹配字符串“abcd”。在大部分编程语言里,匹配的结果只有两个:Captures[0]返回的是完整匹配的字符串“abcd”,Captures[1]返回的是“(.)”最后一个匹配的结果“d”,如代码清单2-9中Node.js的结果。
代码清单2-9 JavaScript里的正则表达式分组匹配结果
1 var pattern = /(.)+/g; 2 var input = "abcd"; 3 var match = pattern.exec(input); 4 console.log(match.length); 5 console.log(match[0]); 6 console.log(match[1]);
在.NET里,如代码清单2-10的第2行中match.Groups[1].Captures是一个集合,保存了完整的4个匹配结果:“a”“b”“c”“d”。这是因为.NET的正则表达式匹配引擎在内部为每个分组分配了一个堆栈。每次“(.)”分组匹配成功后,匹配到的字符串就会压入相应的堆栈中。
代码清单2-10 .NET里正则表达式的分组匹配
1var match = Regex.Match("abcd", "(.)+"); 2for (var i = 0; i <match.Groups[1].Captures.Count; ++i) 3 Console.WriteLine($"{i}: '{match.Groups[1].Captures[i].Value}'");
当使用命名分组时,.NET允许表达式里相同的命名分组重复出现,即下面这种表达式是合法的:
(?<word>\w+)\W+(?<word>\w+)
程序运行时会将两个单词都匹配到同一个“word”分组里。如果我们用上面的表达式匹配字符串“hello world”,那么match.Groups[“word”].Captures返回的是包含“hello”和“world”两个匹配结果元素的集合,这个特性允许程序员将正则表达式在不同地方的匹配结果保存到同一个Captures集合里。
Captures集合是一个堆栈,既可以将匹配结果压入(Push)栈中,也可以将栈中的一些元素推出(Pop)。当在正则表达式的分组名前面加上“-”,就执行Pop操作,如“(?<-word> …)”是将“word”分组最后一次匹配的结果推出Captures集合。如将前面的表达式改成下面的格式,执行完毕后“word”分组的匹配集合是空的。
(?<word>\w+)\W+(?<-word>\w+)
这个特性可以用来匹配嵌套的模式,如匹配括号嵌套的表达式,判断被匹配的字符串中嵌套的括号是否匹配正确,这种分组模式被称为平衡分组(Balance Grouping)。代码清单2-11使用这个特性来判断待匹配的字符串如“(3 * (1 + 3))”的括号是不是正确关闭了,第1行的模式里使用选择操作符“|”在三个子模式之间对输入字符串的部分进行匹配。
下面是构造分组时常用到的正则表达式元素说明。
- ^:表明从字符串最开始的位置匹配。
- ?:[^()]:供选择的一个子模式,用于匹配所有非括号“(”和“)”的字符。“?:”是特殊的匹配分组,表示只匹配但不将匹配结果保存在分组的Captures属性里。
- ?<open>\(:供选择的一个子模式,匹配左括号“(”,并把匹配结果保存在“open”分组栈里。
- ?<-open>\):供选择的一个子模式,匹配右括号“)”,匹配成功的话,则说明可以关闭一个左括号。使用“-open”可从“open”分组栈里推出一个匹配结果。
- +:限制匹配的次数,至少匹配一次,作用是保证至少有一次模式匹配,以便屏蔽空字符串。
- $:表示需要完全匹配整个输入字符串。
代码清单2-11 正则表达式平衡分组的应用
// 源码位置:第2章\RegexDemo.cs 1 var pattern = @"^(?:[^()]|(?<open>\()|(?<-open>\)))+$"; 2 match = Regex.Match("(3 * (1 + 3))", pattern); 3 Console.WriteLine($"{match.Success}, open: {match.Groups["open"].Value}。"); 4 match = Regex.Match("(1 + 3)", pattern); 5 Console.WriteLine($"{match.Success}, open: {match.Groups["open"].Value}。"); 6 match = Regex.Match("(3 * (1 + 3)", pattern); 7 Console.WriteLine($"{match.Success}, open: {match.Groups["open"].Value}。"); 8 match = Regex.Match("(3 * (1 + 3)))", pattern); 9 Console.WriteLine($"{match.Success}, open: {match.Groups["open"].Value}。"); 10 pattern = @"^(?:[^()]|(?<open>\()|(?<-open>\)))+(?(open)(?!))$"; 11 match = Regex.Match("(3 * (1 + 3)", pattern); 12 Console.WriteLine($"{match.Success}, open: {match.Groups["open"].Value}。");
代码清单2-11中第2行和第4行由于括号都被正确关闭,所以匹配结果都是True,而且open分组栈都是空的。第8行的字符串多了一个右括号“)”,在匹配时open分组栈是空的,而在空栈里执行推出操作是错误的,所以匹配结果是False。但最有意思的是,第6行的字符串多了一个左括号“(”,字符串匹配到最后都是成功的,这是因为字符串的各个部分要么匹配左/右括号,要么匹配非括号字符,然而open分组栈里压入和推出的次数不平衡,导致最后栈里会多出一个左括号。
虽然第6行这种情况可以通过open分组栈是否为空来判断括号是否平衡,但.NET还提供了一个方案让我们在表达式内部来判断open分组栈里的情况。其语法是:
(?(condition)truePattern|falsePattern)
其中,falsePattern是可选的,而condition可以是一个子模式,也可以是一个分组名称。当condition是一个分组名称,且其相应的栈不为空时,使用truePattern,否则使用falsePattern。在前面的例子中,当有左括号没有平衡匹配时,open分组栈不会为空。第10行演示了这种用法,模式“(?(open)(?!))”通过判断open分组栈是否有值来判断输入字符串的括号是否平衡匹配,因为第11行的输入字符串多了一个左括号,所以第12行输出的匹配结果是False。
“(?! subexpression)”是一种否定性前瞻断言模式,只有输入字符串与subexpression表达式不匹配,整个表达式才成功匹配。如代码清单2-12中的“(?!b(?!c))”是双重否定,其要求字符“b”后面必须跟着“c”,因此第4行的匹配结果是True。第2行中“a”也能成功匹配,这是因为前瞻匹配只是一个条件,不是硬性匹配要求。
代码清单2-12 否定性前瞻正则表达式示例
1 var pattern = @"a(?!b(?!c))"; 2 Console.WriteLine(Regex.Match("a", pattern).Success); //匹配 (?!b) 3 Console.WriteLine(Regex.Match("ac", pattern).Success); //匹配 (?!b) 4 Console.WriteLine(Regex.Match("abc", pattern).Success);//匹配 (?!b(?!c)) 5 Console.WriteLine(Regex.Match("adc", pattern).Success);//匹配 (?!b(?!c)) 6 Console.WriteLine(Regex.Match("ab", pattern).Success); //不匹配 (?!b(?!c)) 7 Console.WriteLine(Regex.Match("abe", pattern).Success);//不匹配 (?!b(?!c))
代码清单2-11中,我们只是将平衡分组用在判断类似括号是否平衡匹配的问题上,.NET还允许在匹配过程中将压入和推出分组栈之间匹配的字符保存下来。模式语法如下:
(?<A-B> subexpression)
或
(?'A-B' subexpression)
该语法意思是,当匹配到subexpression的时候,不仅在分组栈B中取出上一个匹配结果,还将其和当前匹配的所有字符都存入分组栈A中。如改写代码清单2-11中第1行的(?<-open>\))为(?<content-open>\)),那么content分组里保存的是open分组栈中即将推出的左括号“(”和当前匹配的右括号“)”之间的内容。代码清单2-13中第3~4行返回的结果分别是“1 + 3”和“3 * (1 + 3)”,即最外层和最里面嵌套括号之间的内容。
代码清单2-13 捕获平衡分组间的内容
1 pattern = @"^(?:[^()]|(?<open>\()|(?<content-open>\)))+(?(open)(?!))$"; 2 match = Regex.Match("(3 * (1 + 3))", pattern); 3 Console.WriteLine($"0: {match.Groups["content"].Captures[0].Value}。"); 4 Console.WriteLine($"1: {match.Groups["content"].Captures[1].Value}。");
2.2.2 反向引用
我们不仅可以在匹配结果中使用分组,也可以在匹配的正则表达式中使用分组匹配的结果,这种功能称为反向引用(Backreference)。当待匹配的字符串中有些子字符串出现多次时,可以将第一个出现的子字符串保存在分组中,在模式的后面直接引用第一个匹配的结果。因为分组可以通过索引和名字来访问,所以反向引用里有索引和名字的版本。
索引反向引用的语法为“\number”,number是分组在正则表达式中的位置,从1开始计数。如代码清单2-8中第9行的模式“(\w+)\s(\1)”,\1表示反向引用第一个分组“(\w+)”的匹配结果,由于第13行需要将“\1”当作一个新的分组使用,因此使用括号给模式创建一个新分组,否则括号是可以省略的。
在正则表达式里,“\1”到“\9”永远被解析成索引反向引用语法。如果使用的分组索引不存在,会导致正则表达式引擎抛出ArgumentException异常,如“(\w+)\s\2”就会导致异常,因为“\2”前面只有一个分组“(\w+)”。“\10”及以上只有在分组数足够的情况下,才会被当作索引反向引用,否则会被当作普通的八进制数字进行匹配。不过,不建议读者写太复杂的正则表达式,以避免调试和代码阅读困难。
Visual Studio Code等编辑器同样是支持反向引用的。图2-7中使用模式“(\d+)(-)\1\2”成功匹配“2009-09-09”字符串,而不能匹配“2018-12-31”,这是因为“\1”对应的是第一个分组“(\d+)”,“\2”对应的是第二个分组“(-)”。
图2-7 在Visual Studio Code里使用反向引用
如果已为分组命名,使用命名反向引用就方便得多。命名反向引用的语法可以是\k<name>或\k'name',其中name是分组的名字。代码清单2-8中第10行演示了其使用方法——首先定义了一个<dup>分组用来匹配一个单词,再使用\k<dup>反向引用前面匹配的结果,从而找出重复的单词。
2.2.3 替换
正则表达式除了可以在输入字符串中匹配和提取子字符串以外,还可以用在字符串替换中,如Regex.Replace方法可以通过替换(Substitution)模式来使用匹配结果进行替换。这个方法有一个replacement参数,在replacement参数中可以使用替换模式。替换模式以字符“$”开头,通常跟分组一起使用,与反向引用类似,支持按索引和命名来使用分组匹配结果。如代码清单2-14中,使用正则表达式将不同货币金额中的货币符号去掉,只留下金额。在第1行的模式中,各表达式含义如下。
- \p{Sc}*:匹配货币符号符,这个字符是可选的。
- (\s?\d+[.,]?\d*):“\s?”匹配零到一个空格字符;\d+[.,]?\d*匹配金额,金额的整数部分和小数部分使用点号“.”或逗号“,”分隔。不同国家表示小数的方式是不一样的,中国习惯使用点号“.”分隔小数,而西欧一些国家如德国习惯使用逗号“,”分隔小数。当然,这个模式有一个额外的匹配效果,即可以匹配按千分位表示的数字,如第4行中最后一个数字123,456.00。
代码清单2-14 正则表达式替换模式示例
1 var pattern = @"\p{Sc}*(\s?\d+[.,]?\d*)"; 2 var replacement = "$1"; 3 var input = "$16.32 12.19 £16.29 €18.29 €18,29 ¥123.34 $123,456.00"; 4 var result = Regex.Replace(input, pattern, replacement); 5 Console.WriteLine(result); 6 7 pattern = @"\p{Sc}*(?<amount>\s?\d+[.,]?\d*)"; 8 replacement = "${amount}"; 9 result = Regex.Replace(input, pattern, replacement); 10 Console.WriteLine(result);
如果模式成功匹配,第2行中的“$1”保存的是第一个分组匹配的结果,数字“1”是分组的索引。与反向引用类似,正则表达式中的分组索引是从1开始的。第7行使用与第1行相同的模式,只不过命名匹配金额的分组为amount,因此在第8行替换模式中可以直接通过名称amount来使用匹配结果。表2-5列举了几种常见的.NET中正则表达式替换模式。
表2-5 .NET中的正则表达式替换模式说明
在Visual Studio IDE和Visual Studio Code等文本编辑器中,我们也可以直接使用替换模式来提高编辑效率,如笔者将从Excel、网页等地方复制的文字列表转换成源代码中的字符串数组,此时就会用到替换模式技巧。如图2-8所示,在查找文本框中使用“^(.+)$”模式来匹配每一行的完整字符串,在替换文本框中使用模式“$&”在每行文本的前后加上双引号,并在字符串的末尾加上逗号“,”来符合字符串数组的定义语法。替换完成后,稍加修正就可以直接复制到源码中当作数组定义使用了。
图2-8 在文本编辑器中使用替换模式