正则表达式可以分为两个主要部分:标记和修饰符:
-
标记(Token):是正则表达式的基本构建块,它们表示具体的字符文字、元字符、字符类别、重复限定符、边界匹配、分组和捕获等;
-
修饰符(Modifier):是用来修改正则表达式的匹配行为的标志,例如忽略大小写、全局匹配、多行模式等。修饰符可以影响整个正则表达式的匹配结果;
例如. 正则表达式 \b\d/i
,由 2 个标记(分别是 \b
、\d
)与 1 个修饰符(i
)组成。
以上内容仅用作名词介绍,基础内容请自行 Google。
前瞻断言与后顾断言
前瞻断言(Lookahead)也称先行断言,后顾断言(Lookbehind)也称后行断言。
两者统称为环顾断言(Lookaround),都是零长度(Zero-length Assertions)断言。
什么是零长度断言
很多地方称为零宽度断言(Zero-width Assertions),我更倾向于使用零长度断言。
首先理解下什么是字符消费(Consume Character):在正则表达式匹配的过程中,正则表达式引擎对输入文本进行逐个字符匹配,当匹配到一个字符时,它会将该字符视为已经“消费”了,因此该字符不会再被用于匹配其他部分。
而环顾断言与输入文本进行匹配后,会放弃匹配结果,不消费输入文本中的字符,只返回输入文本是否匹配的结果:是与否。
所以:
-
零长度:是指对输入文本的字符消费长度是零;
-
断言:是指仅对是否匹配进行判断,是一种断言操作;
没有什么是举个例子说明不了的,常规正则表达式 /f(o)o/
(表达式里面的括号仅仅为了美观)与前瞻断言正则表达式 /f(?=o)o/
进行对比:
输入文本 foo
与 /f(o)o/
:
- 对正则表达式第一个标识
f
与输入文本第一个字符f
进行匹配,匹配成功,消费掉输入文本第一个字符f
。 - 对正则表达式第二个标识
o
与输入文本第二个字符o
进行匹配,匹配成功,消费掉输入文本第二个字符o
。 - 对正则表达式第三个标识
o
与输入文本第三个字符o
进行匹配,匹配成功,消费掉输入文本第三个字符o
。 - 正则表达式与输入文本都结束,匹配结果:
foo
。
输入文本 foo
与 /f(?=o)o/
:
- 对正则表达式第一个标识
f
与输入文本第一个字符f
进行匹配,匹配成功,消费掉输入文本第一个字符f
。 - 正则表达式第二个标识为前瞻断言,匹配标识
o
与输入文本第二个字符o
进行匹配,匹配成功,但不消费输入文本的字符o
。 - 由于输入文本并未消耗掉第二个字符,此时,对正则表达式第三个字符
o
与输入文本第二个字符o
进行匹配,匹配成功,消费掉输入文本第二个字符o
。 - 输入文本还剩下一个字符
o
,但正则表达式结束,匹配结果:fo
。
前瞻,先行断言
前瞻断言用于在匹配模式中检查一个子字符串是否紧跟在另一个子字符串的前面,分为正向前瞻与负向前瞻。
正向前瞻语法:(?=pattern)
负向前瞻语法:(?!pattern)
正向(Positive)与负向(Nagetive)完全可以理解为是(True
)与非(False
)的逻辑判定。例如:
f(?=o)
,只有是字符o
前面的f
会被匹配;f(?!o)o
,只有不是(非)字符o
前面的f
会被匹配;
前瞻断言本身的括号为非捕获组,如果希望对前瞻断言中的匹配内容进行捕获,需要在前瞻断言中使用括号,例如 (?=(pattern))
的形式。
任何有效的正则表达式都可以在前瞻中使用(但是后顾不可以,稍后讲解)。
q(?!u)
与 q[^u]
的区别
同样是非字符 u
的匹配操作,有什么区别?
例如. 输入文本为 quit
:
q(?!a)
:仅仅会匹配字符 q
,它的含义是仅仅期望匹配到那些后面没有跟着字符 a
的 q
。
q[^a]
:会匹配到 qu
,它的含义是匹配字符 q
与后面非字符 a
的内容。
引擎匹配逻辑
首先,让我们来看一下引擎是如何将 q(?!u)
应用于文本 Iraq
:
- 正则表达式第一个标记是
q
,引擎会遍历文本,直到匹配到最后的q
。 - 下一个标记是前瞻断言,进入前瞻内部匹配标记
u
,内部标记失败,前瞻断言结束。 - 但引擎注意到是负向前瞻操作,所以整个正则表达式的匹配却是成功的。
- 正则表达式与文本都结束,最后,正则表达式
q(?!u)
匹配成功并返回匹配项q
。
其次,还是这个正则表达式 q(?!u)
,看一下引擎是如何应用于文本 quit
:
- 同样
q
匹配q
字符。 - 下一个标记是前瞻断言,进入前瞻内部匹配标记
u
,内部的标记与文本第二个字符u
匹配成功,前瞻断言结束。 - 常规正则表达式下,引擎会匹配文本第三个字符
i
,但由于是前瞻断言,所以只记录成功与否并放弃匹配项,导致引擎回退到字符u
。 - 由于是负向前瞻,前瞻内部正则表达式匹配成功意味着整个正则表达式匹配失败,即该字符
q
不匹配,正则表达式将重新进行匹配。 - 直到文本结尾并未匹配到字符
q
,最后,正则表达式q(?!u)
匹配失败。
最后,我们使用正则表达式 q(?=u)i
观察下引擎是如何应用于文本 quit
:
- 同样
q
匹配q
字符。 - 正向前瞻内部标识
u
匹配文本第二个字符u
。 - 由于前瞻断言,只记录匹配成功并放弃匹配项,导致退出前瞻断言后引擎将从字符
i
回退到字符u
进行下一个标识的匹配。 - 正则表达式下一个标记为
i
,但当前匹配文本字符是u
,匹配失败,正则表达式将重新进行匹配。 - 直到文本结尾并未匹配到字符
q
,最后,正则表达式q(?=u)i
匹配失败。
后顾,后行断言
后顾断言用于在匹配模式中检查一个子字符串是否紧跟在另一个子字符串的后面,分为正向后顾与负向后顾。
正向后顾语法:(?<=pattern)
负向后顾语法:(?<!pattern)
例如:
-
(?<!a)b
:会匹配非字符a
后面的b
,它不会匹配cab
,但会匹配bed
或debt
中的b
(仅仅是字符b
); -
(?<=a)b
:会匹配cab
中的b
(仅仅是字符b
),而不会匹配bed
或debt
;
\b\w+(?<!s)\b
与 \b\w+[^s]\b
的区别
如果希望找到所有不以字符 s
结尾的单词,需要使用 \b\w+(?<!s)\b
,而不可以使用 \b\w+[^s]\b
。对于输入文本 John's
,前者会匹配 John
,而后者则会匹配到 John'
(包括单引号)。如果希望后者达到同样的效果,需要改写正则表达式为 \bw\w+[^s\W]\b
。
引擎匹配逻辑
让我们将 (?<=a)b
应用于 thingamabob
:
- 引擎开始于后顾断言标识和输入文本的第一个字符
t
。 - 后顾断言使引擎对输入文本向前移动一个字符,查看是否匹配字符
a
,但字符t
是第一个字符,无法向前移动,所以后顾断言失败。 - 匹配输入文本的下一个字符
h
,再次进行后顾断言,再次向前移动一个字符,查看是否匹配字符a
,它找到了字符t
,无法匹配,所以后顾断言再次失败。 - 继续匹配输入文本,当位于字符
m
时,后顾断言与字符m
的前一个字符a
进行匹配,匹配成功并放弃匹配项,由于是正向,后顾断言成功。 - 由于后顾断言是零长度断言,所以当前匹配位置仍位于字符
m
处,下一个匹配标记为b
,与字符m
不匹配,匹配失败。 - 直到匹配到位于输入文本的第一个
b
字符时,正向后顾断言成功,下一个匹配标记b
与字符b
匹配,整个正则表达式匹配成功。 - 最后,匹配结果为输入文本中第一个
b
字符。
注意
在很多正则表达式方言(flavor)中,后顾断言的内容不可以使用正则表达式(与前瞻断言不同)。
由上述内部匹配逻辑可知,后顾断言会让正则表达式引擎临时对前面的输入文本与后顾的内容进行匹配检查,所以引擎需要明确知道临时回查多少个字节,当匹配完需要回到当前位置。所以,后顾断言的内容在大多数正则表达式方言中只允许为固定长度的字符串。
使用前瞻断言进行数据校验
由于前瞻断言中可以使用正则表达式,所以通常使用前瞻断言对数据格式进行校验。例如密码,用户名等。
举个简单的示例,正则表达式 ^(?=.*[a-z])(?=.*[A-Z])[a-zA-Z]{8,16}$
的规则:
- 输入文本只能包含英文字母,不可以有其他字符;
- 输入文本必须包括小写字母;
- 输入文本必须包括大写字母;
- 输入文本必须是 8 - 16 位;
问:为什么前瞻断言可以写在正则表达式一开始的位置,按照常规写法不应该写在要匹配的内容后面么?
答:
为了便于理解,我们将上述正则表达式简化为 ^(?=.*[a-z])[a-z]{4,8}$
。
分解得:^(?=.*[a-z])[a-z]{4,8}$
= ^
+ (?=.*[a-z])
+ [a-z]{4,8}
+ $
这样看起来就清晰很多,是正向前瞻与另一个标识的组合形式,而并非整体是一个前瞻断言语法。
- 正则表达式引擎从输入文本起始位置开始,使用前瞻断言内部的正则表达式
.*[a-z]
对后续文本进行匹配。 .*[a-z]
匹配任意数量的任意字符(包括零个字符),但必须以至少一个小写字母结尾。- 如果前瞻断言匹配成功,则由于零长度断言,将会从输入文本开始对
[a-z]{4,8}
进行匹配。 - 再加上边界标识限制,所以最后的规则是从输入文本开始到结束,必须存在一个小写字母,且只能存在 4 - 8 的小写字母才能满足匹配。
对于这个简化后的正则表达式,前瞻断言有点多余,完全可以写成
^[a-z]{4,8}$
的形式,仅为了解释逻辑而已。
问:为什么会有多个连续的前瞻断言,这又是怎么回事儿?
答:
在 ^(?=.*[a-z])(?=.*[A-Z])[a-zA-Z]{8,16}$
中,由于前瞻断言都是零长度断言,所以每次判定完前瞻后,都会回到起始位置进行下一项判定,所以多个前瞻断言是并列(and
)关系,需要同时满足。
原子分组
原子分组(Atomic Group)也是非捕获分组的一种,但具有特殊的回溯行为。原子分组在匹配其内部模式后,不允许回溯,即一旦内部模式匹配成功,整个分组的匹配就固定下来,不再参与后续的回溯匹配。
语法:(?>pattern)
示例会让原子分组的行为更清晰,正则表达式 a(bc|b)c
(捕获分组)可以匹配 abcc
与 abc
。正则表达式 a(?>bc|b)c
(原子分组)仅可以匹配 abcc
,但会不匹配 abc
。
当应用于 abc
时,两者都会匹配标记 a
与字符 a
,标记 bc
与字符串 bc
。最后的标记 c
将会失败。此时,两者有所不同:
- 捕获分组的正则表达式会记住可选标记
|
的回溯位置,放弃之前匹配的bc
尝试匹配b
,然后匹配c
,最后匹配成功; - 原子分组的正则表达式一旦
bc
匹配成功退出分组,将会丢弃所有分组内部标记的回溯位置,不会回溯重新匹配;
环顾断言的原子性
环顾断言零长度断言的事实自身就说明了其是原子性的,一旦满足环顾断言的条件,正则表达式引擎就会放弃匹配,仅保留是否匹配的结果。所以在环顾断言中,一旦匹配成功,将不会回溯尝试其他可选排列。