正则表达式 - 从 1 到 0(弃坑篇)

2,336 阅读15分钟

书接上回《正则表达式 - 从 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,对于大多数正则引擎来说,bab 都将会导致失败。但是 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 位数字前四位和后四位相同的手机号码,比如 1301234123413156785678

/^(?: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 不一样,不属于字符类,它仅仅表示一个特殊的位置,这个位置通常有这样的特征:

  1. 如果整个字符串第一个字符是单词字符,则表示与 ^ 相同的位置
  2. 如果整个字符串最后一个字符是单词字符,则表示与 $ 相同的位置
  3. 如果字符串中间的某两个连续的字符,其中一个是单词字符,而另一个不是,则表示这两个字符中间的位置

具体哪些字符属于单词字符,也是取决于所使用的正则表达式引擎的,通常来说,能被 \w 匹配的字符都是单词字符。但是 Java 是个例外,在 Java 中,部分 Unicode 字符可以作为 \b 的单词字符,但却无法被 \w 匹配。

示例:

/\bbed\b/ 匹配独立为单词的 bed,比如 Lying in bed, but can't sleepThere is a bed in the corner,但是 Sleeping in the bedroom 就无法匹配到了。

十三、贪婪与懒惰

还记得上一篇提到的“重复”吗?除了 {m} 这样的固定次数匹配,其他的“重复”的具体重复次数都是不确定的,比如 + 可以匹配至少一次,但上不封顶。

这就麻烦了,考虑这个示例:/<.+>/,我们想要使用这个正则表达式来匹配类似于 <div> 这样的字符串,但是,如果你的字符串是 <div>Test</div>,你会发现,匹配结果是整个字符串,也的确,整个字符串也的确满足正则的条件,毕竟 . 表示任意字符,所以 div>Test</ 都是满足条件的。

在正则里,不定次数的重复都是“贪婪”的,贪婪的意思就是它会尽可能多的匹配字符,所以上面这个例子中,< 匹配完第一个字符后,.+ 开始重复任意次,所以它会一直往后找,直到找到一个不满足 . 的字符,但是后面都是匹配的,所以,就匹配到最后一个字符,然后再去尝试找满足 > 的字符。但是由于 .+ 已经匹配了所有字符,因此 > 无法匹配到,所以 .+ 需要做一个让步,放出一个字符 >,使得 > 匹配成功。

为了使上面的例子符合我们的要求,只匹配到第一个 > 就结束,我们可以将重复转为“懒惰”模式,只需要在重复符号后加一个 ? 即可,比如 + 变为 +?* 变为 *?? 变为 ??{m,n} 变为 {m,n}?。上面的例子可以改成 /<.+?>/,就可以匹配到 <div>Test</div> 中的 <div> 了。

在使用贪婪模式的时候,一定要小心!因为正则表达式在遇到贪婪重复的时候,会一直往后递归匹配,直到发现第一个不满足条件的结果时,才会一点一点向前回溯,直到找到满足条件的结果,或者回溯到原点,才会匹配失败。这个过程是比较危险的,因为这会导致正则表达式的匹配时间呈指数性爆炸式增长,并且会使 CPU 占用大量上涨。

2019 年 7 月初,全球知名的 CDN 提供商 Cloudflare 出现了全球范围的 502 故障,原因就是由于使用了一个贪婪匹配的正则表达式。感兴趣的读者可以阅读 Cloudflare 的博客(具体讲解在博客附录部分):blog.cloudflare.com/details-of-…

十四、正则表达式选项

如果我们在进行正则匹配的时候,要忽略大小写,我们通常可以使用 [a-zA-Z] 来同时匹配小写与大写,但是对于复杂情况,这样做可能会比较麻烦。

正则表达式通常都支持使用选项进行行为控制,但是不同的正则引擎支持的选项都不尽相同,具体使用的正则引擎支持哪些选项,需要读者自行查询相关文档。

常见的选项有:

  1. i:忽略大小写
  2. s:单行模式,使得 . 与包括换行符在内的所有字符匹配(默认 . 是不包含换行符的)
  3. m:多行模式,使得 ^$ 不再只表示整个字符串的开头和结尾,而是表示每一行的开头与结尾。

不同的正则引擎设置选项的方式也都不同,大概总结一下,有以下几种设置选项的方式:

  1. 对于 JavaScript 这类拥有正则语法的语言,可以直接将选项追加在正则标记的后面,比如 /Hello/i,这将对整个正则表达式生效,使其忽略大小写。
  2. 大部分编程语言都提供了正则的构造函数,此函数会接受多个参数,其中包含正则选项参数,将选项传递给这个参数即可,比如在 JavaScript 中支持:new RegExp('Hello', 'i'),这也将对整个正则表达式生效,使其忽略大小写。
  3. 很多正则引擎支持直接在正则表达式中使用 (?flag) 语法设置选项,比如 /(?i)Hello/
    • 在这类允许将正则选项放在正则表达式内部的语言,可以把选项放在正则表达式内的任意位置,并且这样一来,此选项仅对从当前位置开始右边的内容生效,比如 /Hello (?i)World/ 中,Hello 是要求 H 大写,ello 小写的,但是 World 则不区分大小写,所以可以匹配 Hello WORLDHello WorldHello world,但是 HELLO World 则无法匹配。
    • 在这类允许将正则选项放在正则表达式内部的语言,如果将选项放在正则表达式的结尾,比如 Hello World(?i) 将没有任何效果,或者在某些正则引擎中被视为错误。
    • Python 是个例外,对于 Python 来说,将选项放在正则内部的任何位置都将对整个正则表达式生效,所以 Hello World(?i) 会使得整个正则表达式忽略大小写。

示例:

/^Apple$/i 可以匹配 AppleappleAPPLEappLEAppLe 等。

十五、零宽断言

有时,我们需要为匹配结果增加一些条件限制,但又不想在匹配结果中包含这些限制条件。什么意思呢?比如我们要匹配区号为 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-1234567012-1234567 等不是由 0123- 开头的就会匹配失败。

/(?<!\d{4}-)\d{8}/ 可以匹配前面跟的不是 4 位数字加一个 - 的 8 位任意数字,比如 012-12345678 匹配结果为 1234567801212345678 匹配结果为 01212345。而 0123-12345678 前面跟了 4 位数字加一个 -,所以匹配失败。

/[a-z]+(?=ed)/ 可以匹配 ed 结尾的字母串,比如 ended 匹配结果为 endopened 匹配结果为 openabcededed 匹配结果为 abceded(贪婪原则)。而类似于 endbe 等不是由 ed 结尾的则会匹配失败。要注意的是,bedroom 可以匹配成功,匹配结果为一个字母 b

/\d{4}(?!\.12)/ 可以匹配不是由 .12 结尾的 4 位数字,比如 1234.56 匹配结果为 12341234.1 匹配结果为 123412345.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。这是两个条件,要同时满足。比如 130123456781234130123413056781234 等。

使用现有的正则能力,是否可以完成呢?

如果仅仅检查长度:/^\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 日),比如 201202292013043020150531 等,但是 20190230201306312015074020181305 就无法匹配。

这里首先 \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)$/ 可以匹配 abcbd,其他组合都不能匹配

/^(?<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/ 可以匹配字符数为偶数个的回文字,比如 aaabbaabccbaabcddcba 等。而字符数为奇数个,或者不是回文,则匹配失败,比如 abaabcbaabab 都会匹配失败。

/\([+\-*\/](?:\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)(?!))$/ 可以匹配任意回文字,比如 aaabaabbaabcbaabccba 等。

这个例子中,先是一个 (?<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 技术黑洞!