书接上回《正则表达式 - 从 0 到 1(前篇)》,这一篇主要是对正则表达式进阶语法的介绍。
在上一篇中,介绍了正则表达式常用的语法,比如字符类、重复、分组等,但是正则表达式还有一些高级语法,这些语法平常可能比较少使用到,但是当你碰到特定场景的时候,就会忍不住叫一声——真香!
正则表达式拥有一些高级功能,但是并不是所有正则引擎都支持,就比如上一篇中提到的自定义字符类的减法、交集等。还有一些正则表达式受环境不同而拥有特定的特性、语法,比如上一篇中提到的自定义字符类中的 ] 定界符、在使用 / 标记正则时,/ 本身也需要转义(自定义字符类中又不需要了)之类的,还有甚至预定义字符类 \d、\w、\s 所指代的字符类范围都根据不同的正则引擎而不一样。
所以,在使用正则表达式之前,还是要具体熟悉一下所使用的正则引擎是否有特定的要求。对于 \d、\w、\s 这些预定义字符类,可以使用自定义字符类来强制指定范围,比如 \d 可以使用 [0-9] 来代替,但是,这并不是必须的!因为即便是不同正则引擎中 \d 指代的范围不同,但不变的事实是,\d 永远匹配数字。
举个例子,\d 在 JavaScript、Java、PCRE 等支持 Unicode 的正则引擎中仅与 ASCII 数字匹配,但是在其他大部分支持 Unicode 的正则引擎中,\d 还与 Unicode 中的其他数字系统匹配。
比如真·阿拉伯数字(东方阿拉伯数字 Eastern Arabic numerals)“٠١٢٣٤٥٦٧٨٩”:
在 JavaScript 中是无法匹配的:
// 你可以把下面的代码复制到浏览器的 Console 中执行(Chrome 快捷键:Ctrl+Shift+J / Command+Option+J)
const regex = /\d+/;
const numbers1 = '0123456789';
const result1 = numbers1.match(regex);
console.log(numbers1.length); // 10
console.log(result1); // ["0123456789", ......]
const numbers2 = '٠١٢٣٤٥٦٧٨٩';
const result2 = numbers2.match(regex);
console.log(numbers2.length); // 10
console.log(result2); // null
而在 C#.NET 中,则可以成功匹配:
// 你可以在 https://ideone.com/dzh4iI 在线测试下面这个代码
using System;
using System.Text.RegularExpressions;
namespace RegexTester {
public class Program {
public static void Main(string[] args) {
Regex regex = new Regex(@"\d+");
var numbers1 = "0123456789";
var result1 = regex.Matches(numbers1);
Console.WriteLine("Length: {0}", numbers1.Length); // Length: 10
Console.WriteLine("Matched: {0}", result1[0]); // Matched: 0123456789
var numbers2 = "٠١٢٣٤٥٦٧٨٩";
var result2 = regex.Matches(numbers2);
Console.WriteLine("Length: {0}", numbers2.Length); // Length: 10
Console.WriteLine("Matched: {0}", result2[0]); // Matched: ٠١٢٣٤٥٦٧٨٩
}
}
}
在 Python3 中,也可以成功匹配:
# 你可以在 https://ideone.com/BIYK0O 在线测试下面这个代码
import re
regex = r'\d+'
numbers1 = '0123456789'
result1 = re.search(regex, numbers1)
print(len(numbers1)) # 10
print(result1[0]) # 0123456789
numbers2 = '٠١٢٣٤٥٦٧٨٩'
result2 = re.search(regex, numbers2)
print(len(numbers2)) # 10
print(result2[0]) # ٠١٢٣٤٥٦٧٨٩
\d 本身的含义就是匹配“数字字符”,因此匹配到 Unicode 中其他非 ASCII 数字也无可厚非,因为那些字符确实都是数字字符。所以,在实际应用过程中,要根据实际情况,决定是继续使用 \d 还是转成更明确的 [0-9]。毕竟,在多语言环境下,支持 Unicode 的 \d 带来的用户体验可能会更好。但是如果代码编写的时候存在考虑不完善的地方(也就是代码存在 Bug),\d 带来的 Unicode 支持可能会产生未知的后果。
嗯,扯了这么多,就是想说明一个事实,就是正则具体支持什么语法都是根据引擎实现而决定的,甚至还和不同引擎的不同版本相关,特别是本文将要提到的这些“高级语法”,以及我个人也很少接触到的其他“高级 S Plus Max 语法”。
所以,本文也仅作参考,作为知识扩充阅读即可,具体在读者所用的正则引擎是否支持,也还请读者自行测试。
下面我们就开始吧~
目录(序号接上一篇)
- 十、空分组、失败分组与不存在分组的反向引用
- 十一、非捕获组与命名组
- 十二、单词边界
- 十三、贪婪与懒惰
- 十四、正则表达式选项
- 十五、零宽断言
- 十六、条件语句
- 十七、递归重复与平衡组
- 十八、弃坑
十、空分组、失败分组与不存在分组的反向引用
在知道了分组与分组的反向引用之后,在有些时候,可能会出现一些问题。
考虑这样的正则表达式:/^(a?)b\1$/,如果要匹配的字符串是 aba,那毫无疑问,可以匹配成功,但是如果要匹配的字符串是 ba 或是 b 呢,由于第一个分组中的 a? 是可以没有的,此时第一个分组就没有东西了。所以后面的 \1 也应该是空的,所以,结果是 ba 无法成功匹配而 b 可以成功匹配。
再考虑这样的正则表达式:/^(a)?b\1$/,与上面类似,如果要匹配的字符串是 aba,毫无疑问,可以匹配成功,但是对于 ba 或是 b 呢?由于第一个分组整体都是可选的,所以此时,第一个分组将不存在。此时后面需要引用 \1,对于大多数正则引擎来说,ba 和 b 都将会导致失败。但是 JavaScript 是一个例外,对于 JavaScript 来说,即便第一个分组是可选的,在它不存在的时候,它也表示一个空分组。所以对 JavaScript 来说,ba 匹配失败,但是 b 可以成功匹配。
对于类似于 /(test)\7/、/(test)\12/ 这样,分组数只有 1 个,但却引用了 7 号分组、12 号分组,这在绝大多数正则引擎中都是一个错误。但是,JavaScript 由于支持八进制转义符,所以,如果分组不存在,JavaScript 会尝试将其解释为一个八进制字符,所以 \7、\12 都是合法的,但是 \8、\9 是不合法的八进制字符,就属于错误。而 Java 中,对不存在的组的引用将不会匹配任何内容。在 .NET 中,虽然支持八进制转义符,但是必须是两位数的八进制,所以,\7 属于错误,而 \12 确是合法的。
注:对 JavaScript 和 .NET 来说,只有分组号不存在时,才会尝试解释为八进制转义符。
十一、非捕获组与命名组
在“分组”中,我们知道正则中可以使用一对小括号 () 来创建一个分组,然后在“分组引用”中,我们学会了使用序号来引用一个分组的内容。
但是,在很多情况下,分组也被用来协助重复、枚举,而这些分组的存在会干扰我们对分组的统计计数,导致后面进行分组引用的时候编号很难确定。
为了方便计数,我们可以将不需要被引用的分组设置为“非捕获组”,只要在分组的开头,括号内,添加 ?: 即可。
示例:
/^(130|131)(\d{4})\2$/匹配 130、131 号段,后面 8 位数字前四位和后四位相同的手机号码,比如13012341234、13156785678
/^(?:130|131)(\d{4})\1$/同上。
这里第一个例子里使用前面说的普通分组,也叫“捕获组”,要引用第二个分组 (\d{4}) 时,分组号是 2。而第二个例子中,将第一个分组设置为了非捕获组,那么分组序号将从第二个开始设置为 1,所以要引用 (\d{4}) 这个分组时,分组号就是 1。
有了非捕获组,在复杂情况下还是很难编号,并且使用编号引用的话也会使正则变得难以阅读。为了方便分组,正则引入了“命名组”的概念,也就是给分组起名字,这样就不需要去数分组的序号了,只要使用分组的名字去匹配即可。
命名组在不同正则引擎中语法不同,第一个支持命名组的正则引擎是 Python 的 re,使用的语法是 (?P<name>group),而要引用这个命名组,则使用 (?P=name)。后来 .NET 也开始支持命名组,但是微软使用的语法是 (?<name>group) 或 (?'name'group),而要引用命名组则使用 \k<name> 或 \k'name',.NET 的命名组中的组名可以使用尖括号,也可以使用单引号,两者在正则引擎中没有区别。
在 Python 和 .NET 都有了命名组,并且有了三种命名组的写法后,Perl 5.10 冒了出来,同时支持 Python 和 .NET 的三种写法,并且在这个基础上,还给分组引用又带来了两种新的语法:\k{name} 和 \g{name}。emmmmmm,新增的这两种语法与 Python 和 .NET 的那三种在引擎中完全等同,没有任何区别。(╯‵□′)╯︵┻━┻
Java 也使用了 .NET 的语法,但是只支持使用尖括号作为组名的语法,而不支持使用单引号的形式。
JavaScript 从 ES2018 开始支持命名捕获组,与 Java 一样,使用 .NET 的语法,也只支持使用尖括号作为组名的语法而不支持使用单引号的形式。
总之,命名组现在几乎所有正则引擎都支持,但是具体使用的语法,还请读者自行尝试!
示例:
/^<(?P<tag>[a-zA-Z][a-zA-Z0-9]*)(?:\s+[^>]*)?>.*<\/(?P=tag)>$/使用 Python 语法,可以简单匹配 HTML 标签(复杂情况暂不考虑)
/^<(?<tag>[a-zA-Z][a-zA-Z0-9]*)(?:\s+[^>]*)?>.*<\/\k<tag>>$/使用 .NET 尖括号语法,同上
/^<(?'tag'[a-zA-Z][a-zA-Z0-9]*)(?:\s+[^>]*)?>.*<\/\k'tag'>$/使用 .NET 引号语法,同上
上面的例子中,先是以 < 符号开头,表示 HTML 标签开始。然后跟着一个名称为 tag 的命名组,组内容为 [a-zA-Z][-a-zA-Z0-9]*,即为单个字母,或字母后跟任意个数字、字母或是 -,也就是 HTML 标签名称的规则。然后跟着一个非捕获组,用于匹配 HTML 标签的属性,以一个或多个空格开头,跟着任意个不是 > 的字符(只做简单匹配,复杂情况暂不考虑),这个非捕获组后面有一个 ?,表示 HTML 属性可有可无。再后面跟着一个 > 符号,表示 HTML 标签结尾。然后是 .* 用于匹配 HTML 标签的内容(只做简单匹配,复杂情况咱不考虑),然后是 <\/,表示 HTML 结束标签的开始,因为使用 / 来标记正则,所以 / 需要 \ 进行转义。然后是引用前面名称为 tag 的分组。最后跟着一个 > 表示 HTML 结束标签的结尾。
十二、单词边界
在正则表达式中,有一些仅仅代表“位置”的符号,比如 ^ 表示字符串的开头位置,$ 表示字符串结尾的位置。实际上,正则还有一个表示位置的符号 \b,它表示“单词边界”。
它与上一篇中提到的 \d、\w 和 \s 不一样,不属于字符类,它仅仅表示一个特殊的位置,这个位置通常有这样的特征:
- 如果整个字符串第一个字符是单词字符,则表示与
^相同的位置 - 如果整个字符串最后一个字符是单词字符,则表示与
$相同的位置 - 如果字符串中间的某两个连续的字符,其中一个是单词字符,而另一个不是,则表示这两个字符中间的位置
具体哪些字符属于单词字符,也是取决于所使用的正则表达式引擎的,通常来说,能被 \w 匹配的字符都是单词字符。但是 Java 是个例外,在 Java 中,部分 Unicode 字符可以作为 \b 的单词字符,但却无法被 \w 匹配。
示例:
/\bbed\b/匹配独立为单词的bed,比如Lying in bed, but can't sleep、There is a bed in the corner,但是Sleeping in the bedroom就无法匹配到了。
十三、贪婪与懒惰
还记得上一篇提到的“重复”吗?除了 {m} 这样的固定次数匹配,其他的“重复”的具体重复次数都是不确定的,比如 + 可以匹配至少一次,但上不封顶。
这就麻烦了,考虑这个示例:/<.+>/,我们想要使用这个正则表达式来匹配类似于 <div> 这样的字符串,但是,如果你的字符串是 <div>Test</div>,你会发现,匹配结果是整个字符串,也的确,整个字符串也的确满足正则的条件,毕竟 . 表示任意字符,所以 d、i、v、>、T、e、s、t、< 和 / 都是满足条件的。
在正则里,不定次数的重复都是“贪婪”的,贪婪的意思就是它会尽可能多的匹配字符,所以上面这个例子中,< 匹配完第一个字符后,.+ 开始重复任意次,所以它会一直往后找,直到找到一个不满足 . 的字符,但是后面都是匹配的,所以,就匹配到最后一个字符,然后再去尝试找满足 > 的字符。但是由于 .+ 已经匹配了所有字符,因此 > 无法匹配到,所以 .+ 需要做一个让步,放出一个字符 >,使得 > 匹配成功。
为了使上面的例子符合我们的要求,只匹配到第一个 > 就结束,我们可以将重复转为“懒惰”模式,只需要在重复符号后加一个 ? 即可,比如 + 变为 +?,* 变为 *?,? 变为 ??,{m,n} 变为 {m,n}?。上面的例子可以改成 /<.+?>/,就可以匹配到 <div>Test</div> 中的 <div> 了。
在使用贪婪模式的时候,一定要小心!因为正则表达式在遇到贪婪重复的时候,会一直往后递归匹配,直到发现第一个不满足条件的结果时,才会一点一点向前回溯,直到找到满足条件的结果,或者回溯到原点,才会匹配失败。这个过程是比较危险的,因为这会导致正则表达式的匹配时间呈指数性爆炸式增长,并且会使 CPU 占用大量上涨。
2019 年 7 月初,全球知名的 CDN 提供商 Cloudflare 出现了全球范围的 502 故障,原因就是由于使用了一个贪婪匹配的正则表达式。感兴趣的读者可以阅读 Cloudflare 的博客(具体讲解在博客附录部分):blog.cloudflare.com/details-of-…
十四、正则表达式选项
如果我们在进行正则匹配的时候,要忽略大小写,我们通常可以使用 [a-zA-Z] 来同时匹配小写与大写,但是对于复杂情况,这样做可能会比较麻烦。
正则表达式通常都支持使用选项进行行为控制,但是不同的正则引擎支持的选项都不尽相同,具体使用的正则引擎支持哪些选项,需要读者自行查询相关文档。
常见的选项有:
i:忽略大小写s:单行模式,使得.与包括换行符在内的所有字符匹配(默认.是不包含换行符的)m:多行模式,使得^和$不再只表示整个字符串的开头和结尾,而是表示每一行的开头与结尾。
不同的正则引擎设置选项的方式也都不同,大概总结一下,有以下几种设置选项的方式:
- 对于 JavaScript 这类拥有正则语法的语言,可以直接将选项追加在正则标记的后面,比如
/Hello/i,这将对整个正则表达式生效,使其忽略大小写。 - 大部分编程语言都提供了正则的构造函数,此函数会接受多个参数,其中包含正则选项参数,将选项传递给这个参数即可,比如在 JavaScript 中支持:
new RegExp('Hello', 'i'),这也将对整个正则表达式生效,使其忽略大小写。 - 很多正则引擎支持直接在正则表达式中使用
(?flag)语法设置选项,比如/(?i)Hello/。- 在这类允许将正则选项放在正则表达式内部的语言,可以把选项放在正则表达式内的任意位置,并且这样一来,此选项仅对从当前位置开始右边的内容生效,比如
/Hello (?i)World/中,Hello是要求H大写,ello小写的,但是World则不区分大小写,所以可以匹配Hello WORLD、Hello World、Hello world,但是HELLO World则无法匹配。 - 在这类允许将正则选项放在正则表达式内部的语言,如果将选项放在正则表达式的结尾,比如
Hello World(?i)将没有任何效果,或者在某些正则引擎中被视为错误。 - Python 是个例外,对于 Python 来说,将选项放在正则内部的任何位置都将对整个正则表达式生效,所以
Hello World(?i)会使得整个正则表达式忽略大小写。
- 在这类允许将正则选项放在正则表达式内部的语言,可以把选项放在正则表达式内的任意位置,并且这样一来,此选项仅对从当前位置开始右边的内容生效,比如
示例:
/^Apple$/i可以匹配Apple、apple、APPLE、appLE、AppLe等。
十五、零宽断言
有时,我们需要为匹配结果增加一些条件限制,但又不想在匹配结果中包含这些限制条件。什么意思呢?比如我们要匹配区号为 0123 的中国座机号码,并且匹配结果不包含区号。中国的座机号码通常有两种,一种是 012-12345678,前面三位是区号,后面 8 位是座机号码,另一种是 0123-1234567,前面四位是区号,后面 7 位是座机号码,并且区号通常都是以 0 开头。
emmmmmm,是不是想打人,这都什么复杂的条件……
只要先用 StartsWith 函数检查字符串是以 0123 开头,然后使用 SubString 函数截取后面几位即可……
emmmmmm,要用正则匹配……
正则里有一个功能叫做“零宽断言”,用于声明当前位置要匹配的内容,但仅仅是声明,不做任何匹配。零宽断言分为前瞻和后顾两种模式,前瞻和后顾又分为正向和负向两种方式,排列组合一下一共四种:正向前瞻 (?=regex)、负向前瞻 (?!regex)、正向后顾 (?<=regex) 和负向后顾 (?<!regex)。
前瞻,就是向前看的意思,也就是从当前位置向字符串后面😓看;而后顾,就是向后看的意思,也就是从当前位置向字符串前面😓看。正好相反,是因为对于正则来说,永远都是从字符串“前面”开始向“后面”进行匹配(对于 LTR 语言来说,就是从左到右),所以,对于正则来说,前瞻就是“向字符串后面看”,后顾就是“向字符串前面看”。
正向和负向,分别表示匹配成功和匹配失败。
示例:
/(?<=0123-)\d{7}/可以匹配0123-后面的 7 位任意数字,比如0123-1234567,匹配结果为1234567,不包含0123-。而0234-1234567、012-1234567等不是由0123-开头的就会匹配失败。
/(?<!\d{4}-)\d{8}/可以匹配前面跟的不是 4 位数字加一个-的 8 位任意数字,比如012-12345678匹配结果为12345678,01212345678匹配结果为01212345。而0123-12345678前面跟了 4 位数字加一个-,所以匹配失败。
/[a-z]+(?=ed)/可以匹配ed结尾的字母串,比如ended匹配结果为end,opened匹配结果为open,abcededed匹配结果为abceded(贪婪原则)。而类似于end、be等不是由ed结尾的则会匹配失败。要注意的是,bedroom可以匹配成功,匹配结果为一个字母b。
/\d{4}(?!\.12)/可以匹配不是由.12结尾的 4 位数字,比如1234.56匹配结果为1234,1234.1匹配结果为1234,12345.12匹配结果为1234。而1234.12则会匹配失败。
/[a-z]+(?!ed)/可以匹配全部由字母组成的字符串。
/(?<!0123)\d+/可以匹配全部由数字组成的字符串。
(・∀・(・∀・(・∀・*)??? 匹配全部由字母组成的字符串?匹配全部由数字组成的字符串?可是上面不是说负向零宽断言,应该匹配“不是由 ed 结尾的字母串”、“不是由 0123 开头的数字串”吗?
再仔细想想,opened 这种字母串,虽然是由 ed 结尾,但是如果把 opened 看作一个整体,这一个整体后面可就没东西了,所以这个整体并不是由 ed 结尾的,所以匹配成功,匹配结果就是 opened;而数字串同理,01231234567 这样的数字串,虽然是由 0123 开头的,但是把他看作一个整体,整体前面没有数字了,所以这个整体不是由 0123 开头的,所以匹配成功,匹配结果就是 01231234567。注意!这与贪婪原则或懒惰原则没有关系!
所以,在使用负向的零宽断言时一定要注意匹配结果是否有意义!
部分正则引擎不支持后顾式的零宽断言 (?<=regex) 和 (?<!regex),比如 JavaScript(Chrome 62 开始支持,Firefox、Safari 至今完全不支持,Node.JS 与 Chrome 使用相同的 V8 引擎,所以自 Chrome 支持以后 Node.JS 也开始支持)。
零宽断言的概念比较复杂,它和 ^、$、\b 类似,就表示一个位置。
再举个例子,比如我要匹配一个 11 位的数字串,它中间要包含 1234。这是两个条件,要同时满足。比如 13012345678、12341301234、13056781234 等。
使用现有的正则能力,是否可以完成呢?
如果仅仅检查长度:/^\d{11}$/,就没有办法检查是否包含 1234 了。
如果简单的 /^\d*1234\d*$/,这样虽然能匹配到包含 1234 的数字串,但是没有办法保证整体的长度。
如果写成 /^\d{3}1234\d{4}$/,可以成功匹配类似于 13012345678 这样的数字串,但是 12341301234 这种就无能为力了。
可怕的想法是:/^(1234\d{7}|\d1234\d{6}|\d{2}1234\d{5}|\d{3}1234\d{4}|\d{4}1234\d{3}|\d{5}1234\d{2}|\d{6}1234\d{1}|\d{7}1234)$/,可以完美匹配,但是……emmmmmm,是不是有笨……
实际上,如果能灵活使用零宽断言,这个问题就可以很好的解决了:
示例:
/^(?=\d{11}$)\d*?1234\d*/可以匹配 11 位数字,并且包含1234的数字串。
仅此而已,非常简单。这里要对“仅仅表示一个位置”有一个深刻的理解。
没有人说过 $ 一定要放在整个正则表达式的结尾,也没有人说过正向前瞻零宽断言一定要放在正则其他内容的后面。
这里正则在匹配的时候,首先碰到一个正向前瞻零宽断言,所以会直接“向字符串后面看”,检查从当前位置(也就是起始位置)开始,后面是否跟着 11 位数字,并且 11 位数字之后是字符串的结尾。此时正则匹配的位置还是在字符串的开头,这只是一个“预检测”。检查通过,正则开始从当前位置正式开始匹配 \d*?1234\d*,第一个 \d*? 采用懒惰模式,后面一个实际上采用贪婪模式或是懒惰模式都无所谓,因为前面 \d*?1234 一定是成功匹配了 0 到 7 位数字 + 1234,而之前检查了字符串一定是由 11 位数字组成,所以,后面的 \d* 一定是匹配剩余的所有数字,所以用贪婪模式即可。
但注意,上面的示例中,最后面的 \d* 是不能省略的,虽然即便是省略了,也可以匹配成功,但是匹配结果将不会包含 1234 后面的内容。因为零宽断言只做检查,并不会将检查的内容放入匹配结果中,所以,结果只包含 \d*?1234\d* 所匹配到的内容。
十六、条件语句
if A then B else C 是常见编程语言中的基本逻辑之一(虽然不同语言语法不尽相同),它表示判断条件 A 是否成立,若成立则进入 B,若不成立则进入 C。
在 Python、.NET、Perl、PCRE 这些正则引擎中也有类似的结构,语法是 (?ifthen|else)。注意 if 和 then 之间没有空格。else 可以省略,变为 (?ifthen)。
其中 if 可以使用零宽断言或是分组引用来指定。当条件成立时,匹配 then,条件不成立时,匹配 else。
示例:
/^\d{4}(0[1-9]|1[012])(?(?<=0[469]|11)(0[1-9]|[12]\d|30)|(?(?<=02)(0[1-9]|[12]\d)|(0[1-9]|[12]\d|3[01])))$/可以匹配YYYYMMDD格式的日期,其中年份没有要求,但是日期必须合法(允许 2 月 29 日),比如20120229、20130430、20150531等,但是20190230、20130631、20150740、20181305就无法匹配。
这里首先 \d{4} 判断字符串以 4 位数字开头,表示年份。然后 (0[1-9]|1[012]) 匹配合法的月份(01 ~ 12)。然后后面的整体是一个条件语句,条件是正向后顾零宽断言 (?<=0[469]|11),因为月份已经被匹配了,所以此时的位置处于月份之后,所以需要使用“后顾”来判断前面的月份,也就是判断是有 30 天的“小月”。若条件成立,则匹配 (0[1-9]|[12]\d|30);若条件不成立,则匹配后面的内容,而后面又是一个条件语句,条件还是正向后顾零宽断言 (?<=02),由于至此位置还是处于月份之后,所以也是使用“后顾”来判断,如果月份为 02,则条件成立,匹配 (0[1-9]|[12]\d);否则条件不成立,匹配 (0[1-9]|[12]\d|3[01])。
虽然正则匹配日期的写法不止一种,大多数情况下都是使用枚举的方式直接匹配,这个例子只是举一个使用条件语句的例子而已。
条件语句使用分组引用来指定条件类似于这样:
示例:
/^(a)?b(?(1)c|d)$/可以匹配abc和bd,其他组合都不能匹配
/^(?<x>a)?b(?('x')c|d)$/同上,使用命名捕获组
由于第一个分组 (a)? 是可选的,因此后面判断第一个分组是否存在,若存在则条件成立,匹配 c 否则匹配 d。
十七、递归重复与平衡组
有时我们需要匹配一个递归嵌套的字符串,比如 XML 标签、前缀表达式等。他们的特点是层层嵌套,每一层都有区别,但又有特定的规则。
在软件开发的时候,我们通常会使用递归函数来做一些嵌套、重复的事情。
正则中也有类似的东西:Perl、PCRE、Ruby 等正则引擎支持递归重复,而 .NET 支持平衡组。
递归重复
递归重复在不同的正则引擎中语法也不太一样,在 Perl 中,使用 (?R) 或 (?0);在 Ruby 中,使用 \d<0>;PCRE(以及 PHP、Delphi、R 这些基于 PCRE 的正则引擎)同时支持 Perl 和 Ruby 的这三种语法。
正则在匹配的时候,如果碰到递归重复标记,将会从当前位置开始,将整个正则表达式重新匹配一遍,一层一层递归,直到最内层匹配结束之后,再一层一层往上回溯。若其中一层匹配失败,将会导致整个递归匹配失败。
示例:
/([a-z])(?R)??\1/可以匹配字符数为偶数个的回文字,比如aa、abba、abccba、abcddcba等。而字符数为奇数个,或者不是回文,则匹配失败,比如aba、abcba、abab都会匹配失败。
/\([+\-*\/](?:\s(?:\d+|(?R))){2}\)/可以匹配前缀表达式,要求操作符只有+、-、*和/,操作数有且仅有两个。比如(+ 1 2)、(* (/ 6 2) (+ 5 (- 7 6)))、(/ 123 (/ (* 99 99) (+ 0 1)))等。如果括号不匹配、操作符不匹配、操作数不匹配,都会导致匹配失败!
注意,递归重复必须拥有跳出条件,比如上例中使用的枚举,或是 ? 标记为可选。若递归重复没有跳出条件将导致递归死循环错误。比如,/(?R)/ 将直接报错,因为这将触发无限递归。由于递归重复是针对整个正则表达式进行重复,因此如果正则表达式以 ^ 开头将会永远匹配失败,因为当发生递归时,“当前”位置永远不可能是字符串开头。
还有,注意递归重复的性能问题,对于上面的第一个示例,其中的递归标记使用了两个 ? 表示懒惰,这在某些情况下可以加快速度。测试匹配 abcdefggfedcba 这个字符串,使用懒惰模式 /([a-z])(?R)??\1/ 完成匹配需要 50 步,而使用贪婪模式 /([a-z])(?R)?\1/ 则需要 81 步;而测试匹配 abcdefggfedcbb(最后一个 a 改成了 b),懒惰模式需要 97 步,而贪婪模式则需要 165 步。
平衡组
平衡组是微软在 .NET 中支持的一种“递归重复”的解决方案。
与递归重复不同的是,平衡组并不是采用重复整个正则表达式的方式来实现的,而是采用命名捕获组或是非捕获组来实现的。这比递归重复更加灵活。
使用命名捕获组的语法是:(?<name-balance>group) 或 (?'name-balance'group),其中 name 是捕获组的名字,而 balance 是平衡组的名字;使用非捕获组的语法是省略捕获组的名字:(?<-balance>group) 或 (?'-balance'group)。
在绝大多数正则引擎中,命名捕获组如果出现多次,那么匹配结果中命名捕获组的值将会是最后一次出现的值,比如 /^(?<name>\w)+$/ 匹配 ab,则捕获组 name 的结果只能得到 b,而 ^(?<name>\w)(?<name>\w)$ 这样重复的组名则被视为是错误。
但在 .NET 中,/^(?<name>\w)+$/ 和 ^(?<name>\w)(?<name>\w)$ 都可以对 ab 匹配成功,并且虽然捕获组 name 的值会被 b 覆盖,但是所有的历史匹配结果都会存储在捕获组的 Captures 属性中。
// 你可以在 https://ideone.com/SLDjlH 在线测试下面这个代码
using System;
using System.Text.RegularExpressions;
namespace RegexTester {
public class Program {
public static void Main(string[] args) {
Regex regex = new Regex(@"^(?<name>\w)+$");
var str = "abcde";
var result = regex.Matches(str);
for (var i = 0; i < result.Count; ++i) {
var item = result[i];
Console.WriteLine("Group Count: {0}", item.Groups.Count);
foreach (Group group in item.Groups) {
Console.WriteLine(@"Group ""{0}"" = ""{1}""", group.Name, group.Value);
foreach (Capture groupItem in group.Captures) {
Console.WriteLine(@"Group ""{0}"" Captured ""{1}""", group.Name, groupItem.Value);
}
}
}
}
}
}
// 输出结果:
/*
Group Count: 2
Group "0" = "abcde"
Group "0" Captured "abcde"
Group "name" = "e"
Group "name" Captured "a"
Group "name" Captured "b"
Group "name" Captured "c"
Group "name" Captured "d"
Group "name" Captured "e"
*/
正因为如此,.NET 拥有了追踪捕获组历史的能力,也就因此创造了平衡组。
正则引擎在匹配的时候,如果遇到捕获组,将会把捕获组放入 Captures 栈中,而遇到平衡捕获组时,将会在指定的捕获组的 Captures 栈中 pop 出最后一个结果。如果对应 Captures 栈中没有结果了,则匹配将会失败。
示例:
/^(?<char>[a-z])+[a-z]?(?<-char>\k<char>)+(?(char)(?!))$/可以匹配任意回文字,比如aa、aba、abba、abcba、abccba等。
这个例子中,先是一个 (?<char>[a-z])+ 命名捕获组 char 匹配 [a-z],并重复至少一次,并且每次重复都将匹配到的字母压入 Captures 栈中。然后是 [a-z]? 可以匹配可选的一个任意字母,因为回文字最中间的字母不一定需要重复。之后是 (?<-char>\k<char>)+,其中 \k<char> 反向引用捕获组 char 的值,若匹配成功,(?<-char>\k<char>) 平衡组将会 pop 出 Captures 栈中最后一个结果,此时捕获组 char 的值变为倒数第二个匹配的值;如果不存在 char 这个分组,或是 Captures 已经为空,则直接匹配失败,这个过程重复至少一次。最后是一个条件语句 (?(char)(?!)),省略了 else,它判断分组 char 是否存在,若存在则匹配 (?!),这是一个永远失败的匹配语法(前瞻匹配空字符串 /(?=)/ 永远成立,负向前瞻匹配空字符串 /(?!)/ 永远失败)。因为回文字是对称的,所以字母数量一定是相等的,因此如果满足条件,此时 char 分组应当全部被平衡组抵消而不存在了。若分组 char 仍然存在,则表示回文字母的数量前后不一致,使用 (?!) 强行匹配失败。
示例:
/^(?:[^<>]*?(?:(?'bracket'<)[^<>]*?)+?(?:(?'-bracket'>)[^<>]*?)+?)+(?(bracket)(?!))$/匹配完全配对的<>包围的字符串。比如bbb<aaa<ab<abc>abc<aaa<aaa>>a>aaa>aaa。
注意,在匹配失败的时候,性能损失是肉眼可见的。
这个例子中,最外层是一个 (?:...)+ 非捕获组的重复。
后面是一个条件语句 (?(bracket)(?!)),因为我们要求 <> 括号配对,那么两者数量一定是相等的,因此如果满足条件,此时 bracket 分组应当全部被平衡组抵消而不存在了。若分组 bracket 仍然存在,则表示 > 的数量小于 < 的数量,因此括号不匹配,使用 (?!) 强行匹配失败。
在第一个非捕获组中,先是 [^<>]*? 匹配任意个非括号字符;然后是一个 (?:(?'bracket'<)[^<>]*?)+? 要求至少出现一次的非捕获组,其中 (?'bracket'<) 表示匹配一个 < 并放入 Captures 栈,然后 [^<>]*? 匹配任意个非括号字符;然后又是一个 (?:(?'-bracket'>)[^<>]*?)+? 要求至少出现一次的非捕获组,其中 (?'-bracket'>) 平衡组尝试匹配一个 >,若匹配成功,再检查 Captures 栈是否非空,如果 Captures 栈为空则直接匹配失败,因为此时没有对应的 < 来与之配对了,若 Captures 栈非空,则 pop 出一个后继续匹配 [^<>]*? 任意个非括号字符。
十八、弃坑
实际上就是这样,正则还有很多东西没有提到,感兴趣的读者可以去 www.regular-expressions.info/tutorial.ht… 看到更多更详细的介绍。
是不是看的越来越迷糊?这还是上一篇中的那个简单高效的文本查找匹配工具吗?现在怎么有点怀疑人生了?
emmmmmm……
的确是这样的,正则本应该简单高效,再软件开发过程中,复杂的逻辑理应交给编程语言去实现。而像这种条件语句、递归循环之类的就没有必要用正则去写了。
但是,存在即合理,既然提供了这样的语法功能,那么就一定有应用场景。这些复杂语法功能,看过了,了解一下,语法不一定记住,至少知道有这么个东西,再碰到特定场景的时候,能够想起来,总是会喊一声“真香”的。
记得要点赞、分享、评论三连,更多精彩内容请关注ihap 技术黑洞!