`>本文是 Jan Goyvaerts 为 RegexBuddy 写的教程的译文,版权归原作者所有
在本文中讲述了正则表达式中的:
组
向后引用
先前向后查看
条件测试
单词边界
选择符
等表达式及例子,并分析了正则引擎在执行匹配时的内部机理。
单词边界
元字符\b是一种对位置进行匹配的“锚”。这种匹配是 0 长度匹配。
有 4 种位置被认为是“单词边界”:
- 如果字符串的第一个字符是一个“单词字符”,在字符串的第一个字符前的位置
- 如果字符串的最后一个字符是一个“单词字符”,在字符串的最后一个字符后的位置
- “非单词字符”紧跟在“单词字符”之后时,在“单词字符”和“非单词字符”之间
- “单词字符”紧跟在“非单词字符”之后时,在“非单词字符”和“单词字符”之间
“单词字符” 用“\w”匹配的字符
“非单词字符” 用“\W”匹配的字符
在大多数的正则表达式实现中,“单词字符”通常包括[a-zA-Z0-9_]
例
正则表达式\b4\b
能匹配单个的 4 ,而不是一个更大数的一部分(不会匹 配44中的 4)
即几乎可以说\b匹配一个“字母数字序列”的开始和结束的位置。
“单词边界”的取反集为\B
他要匹配的位置是两个“单词字符”之间或者两个“非单词字符”之间的位置。
深入正则表达式引擎内部
例
正则表达式\bis\b
字符串This island is beautiful
引擎 先处理符号\b
因为\b 是 0 长度 ,所以第一个字符 T 前面的位置会被检查。
T 是一个“单词字符” 且它之前的字符是一个空字符(void),这是一个单词边界,\b
匹配成功
但正则表达式中的i
和第1个字符T匹配失败,回溯~
单词边界\b
继续匹配,第5个空格符和第4个字符s之间是一个单词边界,\b
匹配成功
但正则表达式中的i
和第5个空格符匹配失败,回溯~
单词边界\b
继续匹配,第5个空格字符和第6个字符i之间是一个单词边界,\b
匹配成功
正则表达式is
和第6个第7个字符匹配成功
但第8个字符l不被单词边界\b
匹配,匹配失败,回溯~
单词边界\b
继续匹配,到了第 13 个字符i和前面一个空格符形成“单词边界”,同时is
和is
匹配。正则表达式中第二个\b
开始匹配,
单词s和他之后的空格符是一个单词边界,\b
匹配成功。
正则表达式结束。
引擎“急着”返回成功匹配的结果。
选择符
正则表达式中“|”表示选择。你可以用选择符匹配多个可能的正则表达式中的一个。
如果你想匹配文字“cat”或“dog”
正则表达式cat|dog
如果想多匹配就加入即可cat|dog|mouse|fish
选择符在正则表达式中具有最低的优先级,即它告诉引擎,要么匹配选择符左边的所有表达式,要么匹配右边的所有表达式。
你也可以用圆括号来限制选择符的作用范围。
例
\b(cat|dog)\b
这样告诉正则引擎把(cat|dog)当成一个正则表达式单位来处理。
正则引擎是急切的:当它找到一个有效的匹配时,停止搜索。
因此在一定条件下,选择符两边的表达式的顺序对结果会有影响。
例
用正则表达式搜索一个编程语言的函数列表
Get 或 GetValue 或 Set 或 SetValue
一个明显的解决方案是正则表达式Get|GetValue|Set|SetValue
结果
因为正则表达式Get
和GetValue
都失败了,而Set
匹配成功。因为正则导向的引擎都是“急切”的,所以它会返回第一个成功的匹配,文本Set,而不去继续搜索是否有其他更好的匹配。
和我们期望的相反,正则表达式并没有匹配整个字符串。有几种可能的解决办法。
1.改变选项的顺序,例如我们使用正则表达式GetValue|Get|SetValue|Set
这样我们就可以优先搜索最长的匹配。
2.把四个选项结合起来成两个选项Get(Value)?|Set(Value)?
因为问号重复符是贪婪的, 所以 SetValue 总会在 Set 之前被匹配。
3.更好的方案是. 使用单词边界
\b(Get|GetValue|Set|SetValue)\b
或 \b(Get(Value)?|Set(Value)?\b
既然所有的选择都有相同的结尾,正则表达式可优化为\b(Get|Set)(Value)?\b
组与向后引用
把正则表达式的一部分放在圆括号内,你可以将它们形成组。
然后你可以对整个组使用一些正则操作,例如重复操作符。
注意区别
()圆括号用于形成正则表达式组
[]用于定义字符集
{}用于定义重复操作
当用()定义了一个正则表达式组后,正则引擎则会把被匹配的组按照顺序编号,存入缓存。
当对被匹配的组进行向后引用的时候,可以用“\数字”的方式进行引用。
正则表达式\1
引用第1个匹配的后向引用组,\2
引用第2组,以此类推,\n
引用第 n 个组。
而\0
则引用整个正则表达式本身。
假设你想匹配一个 HTML 标签的开始标签和结束标签,以及标签中间的文本。
例
要匹配<B>和</B>以及中间的文字。
文本<B>This is a test</B>
正则表达式<([A-Z][A-Z0-9]*)[^>]*>.*?</\1>
首先,正则表达式<将会匹配第一个文本字符<
然后,正则表达式[A-Z]匹配文本B
[A-Z0-9]*匹配 0 到多次字母数字,后面紧接着 非“>”的字符 0个到多个。
最后,正则表达式的>
将会匹配文本<B>
接下来正则引擎将对结束标签之前的字符进行惰性匹配(急于表功,为了找到最短的文本,每次.*?匹配成功后都试图进行正则表达式</
的匹配。
然后正则表达式中的“\1”表示对前面匹配的组([A-Z][A-Z0-9]*)
进行引用,在本例中,被引用的是标签名 即 文本字符B
,所以需要被匹配的结尾标签为</B>
可以多次引用相同的后向引用组
正则表达式([a-c])x\1x\1
会匹配文本
axaxa
bxbxb
cxcxc
如果用数字形式引用的组没有有效的匹配,则引用到的内容简单的为空。
一个后向引用不能用于它自身。
错误正则表达式([abc]\1)
不能将\0
用于一个正则表达式匹配本身,它只能用于替换操作中。
后向引用不能用于字符集内部。
在[]
包含的字符集内部 \1
被解释为八进制形式的转码。
所以像正则表达式 (a)[\1b]
其中的\1
并不表示后向引用。
向后引用会降低引擎的速度,因为它需要存储匹配的组。
如果你不需要向后引用,你可以告诉引擎对某个组不存储。例如Get(?:Value)
其中(
后面紧跟的?:
会告诉引擎对于组(Value)
不存储匹配的值以供后向引用。
重复操作与后向引用
当对组使用重复操作符时,缓存里后向引用内容会被不断刷新,只保留最后匹配的内容。
例
正则表达式([abc]+)=\1
可以匹配文本cab=cab
但正则表达式([abc])+=\1
不会匹配文本cab=cab
因为
([abc])
第一次匹配文本c
时,\1
已经代表的是c
([abc])
继续匹配到了文本a
\1
已经代表的是a
([abc])
继续匹配到了文本b
最后\1
已经代表的是b
所以正则表达式([abc])+=\1
只会匹配到文本cab=b
应用:检查重复单词
当编辑文字时,很容易就会输入重复单词如the the
使用 \b(\w+)\s+\1\b
可以检测到这些重复单词。
要删除第二个单词,只要简单的利用替换功能替换掉“\1”即可
组的命名和引用
在 PHP,Python 中,可以用(?P<name>group)
来对组进行命名。
本例中?P<name>
就是把组(group)
命名为name
可以用 (?P=name)进行引用
.NET 的命名组
.NET framework 也支持命名组。不幸的是,微软的程序员们决定发明他们自己的语法, 而不是沿用 Perl、Python 的规则。目前为止,还没有任何其他的正则表达式实现支持微软发明的语法。
.NET 例
(?<first>group)(?’second’group)
正如你所看到的,.NET 供两种词法来创建命名组:
用尖括号<>
在字符串中使用更方便
用单引号. 在 ASP 代码中更有用 因为ASP代码中<>
被用作 HTML 标签。
引用命名组
\k<name>
或 \k’name’
当进行搜索替换时,用${name}
来引用一个命名组。
正则表达式的匹配模式
正则表达式引擎都支持三种匹配模式
/i
使正则表达式对大小写不敏感
/s
开启“单行模式”,即点号.
匹配换行符(nweline)
/m
开启“多行模式”,即^
和$
匹配换行符(nweline)的前面和后面的位置。
在正则表达式内部打开或关闭模式
如果你在正则表达式内部插入修饰符(?ism)
则该修饰符只对其右边的正则表达式起作用。 (?-i)是关闭大小写不敏感。你可以很快的进行测试。
(?i)te(?-i)st
应该匹配 TEst,但不能匹配 teST 或 TEST
原子组与防止回溯
一些特殊情况下回溯会使得引擎的效率极其低下。
例
要匹配这样的字串,字串中的每个字段间用逗号做分隔符,第 12 个字段由P开头。
容易想到这样的正则表达式^(.*?,){11}P
这个正则表达式在正常情况下工作的很好。
但如果第 12 个字段不是由 P 开头,则会发生灾难性的回溯。
如文本
1,2,3,4,5,6,7,8,9,10,11,12,13
首先,正则表达式一直成功匹配直到第 12 个字符。这时,前面的正则表达式消耗的字串为1,2,3,4,5,6,7,8,9,10,11,
正则表达式中的P
并不匹配12
引擎进行回溯,这时正则表达式消耗的字串为 1,2,3,4,5,6,7,8,9,10,11
继续下一次匹配过程,下一个正则符号为点号.
能匹配下一个逗号,
但,
并不匹配字符12
中的1
匹配失败,继续回溯。
... 这样的回溯组合是个非常大的数量 可能会造成引擎崩溃
用于阻止这样巨大的回溯有方案:
1.简单的方案 尽可能的使匹配精确
用取反字符集代替点号。例如我们用如下正则表达 式^([^,\r\n]*,){11}P
这样可以使失败回溯的次数下降到 11 次。
2.使用原子组
原子组的目的是使正则引擎失败的更快一点。因此可以有效的阻止海量回溯。原子组的语法 是(?>正则表达式)
位于(?>)之间的所有正则表达式都会被认为是一个单一的正则符号。 一旦匹配失败,引擎将会回溯到原子组前面的正则表达式部分。前面的例子用原子组可以表达成^(?>(.*?,){11})P
一旦第十二个字段匹配失败,引擎回溯到原子组前面的^
向前查看与向后查看
Perl 5 引入了两个强大的正则语法:“向前查看”和“向后查看”
他们也被称作“零长度断言”。他们和锚定一样都是 零长度的(即该正则表达式不消耗被匹配的字符串)
不同之处在于“前后查看”会实际匹配字符,只是他们会抛弃匹配只返回匹配结果:匹配或不匹配。这就是为什么他们被称作“断言”。他们并不实际消耗字符串中的字符,而只是断言一个匹配是否可能。
注意:Javascript 只支持向前查看,不支持向后查看。
肯定和否定式的向前查看
前面的例子
要查找一个 q,后面没有紧跟一个 u
即 要么 q 后面没有字符,要么后面的字符不是 u
采用否定式向前查看后的一个解决方案为q(?!u)
否定式向前查看的语法是(?!查看的内容)
肯定式向前查看和否定式向前查看很类似:?=查看的内容)
如果在“查看的内容”部分有组,也会产生一个向后引用。但是向前查看本身并不会产生向后引用,也不会被计入向后引用的编号中。这是因为向前查看本身是会被抛弃掉的,只保留匹配与否的判断结果。如果你想保留匹配的结果作为向后引用,你可以用(?=(regex))
来产生一个向后引用。
肯定和否定式的先后查看
向后查看和向前查看有相同的效果,只是方向相反 否定式向后查看的语法是:<<(?<!查看内容)>> 肯定式向后查看的语法是:<<(?<=查看内容)>> 我们可以看到,和向前查看相比,多了一个表示方向的左尖括号。 例:<<(?<!a)b>>将会匹配一个没有“a”作前导字符的“b”。 值得注意的是:向前查看从当前字符串位置开始对“查看”正则表达式进行匹配;向后查
看则从当前字符串位置开始先后回溯一个字符,然后再开始对“查看”正则表达式进行匹配。
深入正则表达式引擎内部
简单例子
把正则表达式q(?!u)
应用到字符串Iraq
正则表达式的第一个符号是q
开始匹配,当第四个字符q
被匹配后, q
后面是空字符(void)
而下一个正则符号是向前查看。引擎注意到已经进入了一个向前查看正则表达式部分。下一个正则符号u
和空字符不匹配,从而导致向前查看里的正则表达式匹配失败。因为是一个否定式的向前查看,意味着整个向前查看结果是成功的。于是匹配 结果q
被返回了。
我们在把相同的正则表达式应用到文本quit
正则表达式q
匹配了q
下一个正则符号是向前查看部分的正则表达式u
它匹配了字符串中的第二个字符i
引擎继续走到下个字符i
引擎这时注意到向前查看部分已经处理完了,并且向前查看已经成功。于是引擎抛弃被匹配的字符串部分,这将导致引擎回退到字符u
因为向前查看是否定式的,意味着查看部分的成功匹配导致了整个向前查看的失败,因此 引擎不得不进行回溯。最后因为再没有其他的文本q
和正则表达式q
匹配,所以整个匹配失败了。
为了确保你能清楚地理解向前查看的实现,让我们把正则表达式q(?=u)i
应用到文本quit
正则表达式q
首先匹配q
然后向前查看成功匹配u
匹配的部分被抛弃,只返回可以匹配的判断结果。引擎从字符i
回退到u
由于向前查看成功了,引擎继续处理下一个正则符号<<i>>。 结果发现<<i>>和“u”不匹配。因此匹配失败了。由于后面没有其他的“q”,整个正则表达 式的匹配失败了。
更进一步理解正则表达式引擎内部机制
让我们把<<(?<=a)b>>应用到“thingamabob”。引擎开始处理向后查看部分的正则 符号和字符串中的第一个字符。在这个例子中,向后查看告诉正则表达式引擎回退一个字符,然 后查看是否有一个“a”被匹配。因为在“t”前面没有字符,所以引擎不能回退。因此向后查看 失败了。引擎继续走到下一个字符“h”。再一次,引擎暂时回退一个字符并检查是否有个“a” 被匹配。结果发现了一个“t”。向后查看又失败了。
向后查看继续失败,直到正则表达式到达了字符串中的“m”,于是肯定式的向后查看被 匹配了。因为它是零长度的,字符串的当前位置仍然是“m”。下一个正则符号是<<b>>,和 “m”匹配失败。下一个字符是字符串中的第二个“a”。引擎向后暂时回退一个字符,并且发 现<<a>>不匹配“m”。
在下一个字符是字符串中的第一个“b”。引擎暂时性的向后退一个字符发现向后查看被满 足了,同时<<b>>匹配了“b”。因此整个正则表达式被匹配了。作为结果,正则表达式返回 字符串中的第一个“b”。
向前向后查看的应用
我们来看这样一个例子:查找一个具有 6 位字符的,含有“cat”的单词。 首先,我们可以不用向前向后查看来解决问题,例如:
<< cat\w{3}|\wcat\w{2}|\w{2}cat\w|\w{3}cat>> 足够简单吧!但是当需求变成查找一个具有 6-12 位字符,含有“cat”,“dog”或“mouse”
的单词时,这种方法就变得有些笨拙了。
我们来看看使用向前查看的方案。在这个例子中,我们有两个基本需求要满足:一是我们
需要一个 6 位的字符,二是单词含有“cat”。 满足第一个需求的正则表达式为<<\b\w{6}\b>>。满足第二个需求的正则表达式为
<<\b\wcat\w\b>>。
把两者结合起来,我们可以得到如下的正则表达式:
<<(?=\b\w{6}\b)\b\wcat\w\b>>
具体的匹配过程留给读者。但是要注意的一点是,向前查看是不消耗字符的,因此当判断 单词满足具有 6 个字符的条件后,引擎会从开始判断前的位置继续对后面的正则表达式进行匹 配。
最后作些优化,可以得到下面的正则表达式:
<<\b(?=\w{6}\b)\w{0,3}cat\w*>>
- 正则表达式中的条件测试 条件测试的语法为<<(?ifthen|else)>>。“if”部分可以是向前向后查看表达式。如果用
向前查看,则语法变为:<<(?(?=regex)then|else)>>,其中 else 部分是可选的。
如果 if 部分为 true,则正则引擎会试图匹配 then 部分,否则引擎会试图匹配 else 部分。 需要记住的是,向前先后查看并不实际消耗任何字符,因此后面的 then 与 else 部分的匹
配时从 if 测试前的部分开始进行尝试。 - 为正则表达式添加注释 在正则表达式中添加注释的语法是:<<(?#comment)>> 例:为用于匹配有效日期的正则表达式添加注释:
(?#year)(19|20)\d\d- /.(0[1-9]|1[012])- /.(0[1-9]|[12][0-9]|3[01])