正则表达式 Regular Expression 学习笔记(二):Grouping 集合

正则表达式中的 Grouping 已经越来越多,不整理一下经常会忘。

一、非抓取式集合 (Non-capturing grouping)

熟悉正则表达式的人都知道用 () 来把一段表达式与其他部分分开,()也可以嵌套使用。这个就称之为 Grouping(集合)。集合有个副作用,即会在匹配引擎中产生一个相应的序号来记住这个集合,以便可以在表达式或匹配结果中使用。在正则表达式中一般以\n 来表示这个集合,n就是那个序号。整个表达式的序号为0,第一个()的序号为 1,以此类推。当然下面还会看到可以以其他的方式来调用这个集合。

集合有许多好处,可以想到的大致有,

  • 使表达式更加清晰,集合后匹配的先后次序明确
  • 有些重复或量化符号(*+?等)可以直接在集合上使用了
  • 集合后可以在结果中按需求抓取需要的集合内容了
  • 可以在表达式的其他部分使用集合匹配过的内容

因为集合有个产生集合序号的副作用,所以有时候就是希望只起到集合的作用,即上面提到的前两个好处,而不希望利用后面的那些好处。于是出现了non-capturing grouping,即非抓取式的集合,就是简单的集合而不计序号。这个就是(?:...)。前面那个括弧后直接跟?:来表示一个非抓取式集合。

$str = "abcd 1234 xyz";
$pat = '/([a-z]+)\s(?:\d)+/';
if (preg_match($pat, $str, $matches)) {
    var_dump($matches);
} // 1234 not-captured

二、集合的引用 (References)

滞后引用(Back references)

Capturing grouping 集合的一个明确的好处就是可以在表达式的随后部分引用前面已经匹配的那个集合,不仅可以简化表达式,而且可以实时根据前面的匹配来匹配后面相应的部分。例如,表达式<([A-Z][A-Z0-9]*)\b[^>]*>.*?</\1> 可以用来匹配一对 HTML Tag。前面的Tag名称通过表达式([A-Z][A-Z0-9]*) 匹配成功后,后面可以用\1 来直接表示这个匹配,从而可以成功地匹配一对Tag。

需要注意的是,上面这个例子里集合后面的那个 \b 是必须的。如果缺了这个 \b,这个表达式会出现意料不到的结果,如下,

$str = "<BINGO>test</B>";
$pat = '!<([A-Z][A-Z0-9]*)[^>]*>.*?</\1>!';
if (preg_match($pat, $str, $matches)) {
    var_dump($matches);
} // '<B' and '</B>' matched!!!

可以看到,Back references 滞后引用适合于对象中重复出现的一些模式 repeated pattern。而且可以多次在后面使用同样的引用。例如,([abc]+)\b\1\b\1 可以用来匹配a a a 这个字符串。

需要注意的是,滞后引用的序号从1开始。0是保留给整个匹配的字符串。序号最大可以到 99。

另外一个需要特别注意的是,集合和滞后引用不能在字符组 character class 中使用。即 [(a)\1] 只是分别匹配(a),而\1 没有意义或只是匹配1

有个有趣的问题,如果前面那个集合没有匹配到任何东东,后面的滞后引用是如何表现的?我们通过例子来看看,

$str = "xyz";
$pat = '!(a?)xyz\1!';
if (preg_match($pat, $str, $matches)) {
    var_dump($matches);
} // matched!, \1 is '' empty string

如果没有匹配到任何东东,\1 中存入了一个空的字符串,整个匹配还是成功的。

超前引用 (Forward References)

上面提到了集合后面可以进行滞后引用,其实PCRE也支持超前引用,即引用部分出现在集合 capturing grouping 之前。但是,我测试下来,效率非常低下,对大型表达式或者超长的对象字符串,如果表达式没有优化,这个结果是非常非常的慢的。

而且你可以猜想,既然出现在前面了,为何不在前面进行 grouping 然后后面进行引用?

$str = "bab";
$pat = '!(\2a|(b))!';
if (preg_match($pat, $str, $matches)) {
    var_dump($matches);
} // matched!, 'b' is captured in '\2'
嵌套引用 (Nested references)

PCRE 支持嵌套式的引用,其实也是一种超前引用的方式。嵌套式引用有点类似递归表达式匹配(下一个学习笔记中讨论)。所以这里简单讨论。知道有这个事情就行了。

我们来看表达式 (\1a|(b))+。因为整个表达式用来集合Grouping,所以 \1 就是指整个表达式了。假设对象字符串是 bba

  1. 引擎开始匹配时,\1 匹配是失败,然后尝试并列选项 (b),成功了!
  2. 于是 \2 中存储了 b, \1 中也存储了 b
  3. \1 代入表达式成了 (ba|(b)) 来匹配bba 中第二个字符开始成功了
  4. \1 这时改写成了 ba\2 还是b
  5. 整个 bba 匹配成功!

虽然成功了,但是很难明确的说 嵌套引用可以使用于何种实际的情形?如果无法说明,那这个技巧只是一种花样,无法归纳到可以使用的情形中去。有请高手解惑?

$str = "bba";
$pat = '!(\1a|(b))+!';
if (preg_match($pat, $str, $matches)) {
    var_dump($matches);
} // matched!

三、名称集合 (Named Grouping)

有时候复杂的集合就很难追踪序号,并且如果是几个复杂的表达式拼凑起来的大表达式,就根本无法根据序号进行引用了。这时候可以采用的技巧就是名称集合 Named Grouping,相对应的名称引用,以及以后的递归笔记里会谈到的相对序号引用方式。

顾名思义,名称集合就是给集合取名。PCRE的名称集合支持多种形式,实际使用中,你可以采用一种,但是也需要知道以下这些其实都是支持的。

  1. 第一种格式:定义名称 (?P<name>...),引用名称 (?P=name)

    $pat = '!(?P<name>abc)[^abc]+(?P=name)!';
    $str = 'abc test test abc';
    if (preg_match($pat, $str, $matches)) {
        var_dump($matches);
    } // matched!
    

    从结果中可以看到,$matches['name'] = 'abc'$matches[1] = 'abc'。 可见,不仅名称引用可以使用,引擎还自动产生了序号1 的引用。

  2. 第二种格式:定义名称 (?<name>...),引用名称 \k<name>

    $pat = '!(?<name>abc)[^abc]+\k<name>!';
    $str = 'abc test test abc';
    if (preg_match($pat, $str, $matches)) {
        var_dump($matches);
    } // matched!
    

    这种格式相对简化,引用时格式相对不是非常清晰。个人认为不是非常容易辨认。

  3. 第三种格式:定义名称 (?'name'...),引用名称 \k'name'

    $pat = "!(?'name'abc)[^abc]+\k'name'!";
    $str = 'abc test test abc';
    if (preg_match($pat, $str, $matches)) {
        var_dump($matches);
    } // matched!
    

    这种格式与第二种类似,用单引号取代了 <>,显得更加简化。

  4. 其他格式:引用时可以使用 \k{name}\g{name}\g<name>\g'name'

    $pat = "!(?'name'abc)[^abc]+\k{name}!";
    $str = 'abc test test abc';
    if (preg_match($pat, $str, $matches)) {
        var_dump($matches);
    } // matched!
    

总结一下,定义时可以用三种格式,引用时更多种格式

  • (?P<name>...)(?<name>...)(?'name'...)
  • (?P=name)\k<name>\k'name'\k{name}\g{name}\g<name>\g'name'

那是否可以用同名的集合?脑子进水了!其实是可以的,但是牵涉到下面提到的 Brach Reset Group, 按字面来翻译是 “分支重置式集合”,其实更恰当的是“共用集合”

四、共用集合 (Brach Reset Group)

顾名思义,共用集合是几个集合共用一个名称或一个序号。PCRE中有个这个功能,可以使几个以 | 并列的集合共用一个名称或序号,英文名称是 Branch Reset Group。

在一个分支 (branch)中的不同选项(Alternatives)可以共用序号。句法是 (?|regex)。句法开始以(?| 开头,如果其中的 regex 是以下这种形式,(opt1)|(opt2),则这些集合共用一个序号或者一个名称(如果有名称的话)。不然,这个句法于非抓取式集合(?: 是同等效果。

或者一句话,如果里面有集合,则是共用序号和名称,不然就是非抓取式简单的集合。例子如下,正则表达式(?|(x)|(y)|(z)) 包含了3个并列的集合。在一般情况下,这3个集合的序号将是\1\2\3。而实际由于采用了共用集合序号,只有一个\1

因此 (?|(x)|(y)|(z))\1 可以用来匹配 xxyyzz,而不是yx。见例子,

$pat1 = '!(?|(x)|(y)|(z))\1(abc)!';
$pat2 = '!(?:(x)|(y)|(z))\1(abc)!';
$str1 = 'yyabc';
$str2 = 'xxabc';

if (preg_match($pat1, $str1, $matches)) {
    var_dump($matches);
} // matched! 'abc' is in \2

if (preg_match($pat2, $str2, $matches)) {
    var_dump($matches);
} // matched! but 'abc' is in \4

共用集合不要求不同选项里的集合数量相同,只是根据实际匹配来填充引用的内容,例子如下

$pat = '!(?|xyz|(j)(h)(k)|o(p)q)(x)!';
$str1= 'opqx';
$str2= 'jhkxrst';

if (preg_match($pat, $str1, $matches)) {
    var_dump($matches);
} // \1 = p, \2 = '', \3 = '', \4 = x

if (preg_match($pat, $str2, $matches)) {
    var_dump($matches);
} // \1 = j, \2 = h, \3 = k, \4 = x

共用集合不但可以共用引用序号,而且可以同名。而且采用最大数量的名称。例如,

$pat = "!(?|xyz|(?'nA'j)(?'nB'h)(?'nC'k)|o(?'nA'p)q)(?'n1'x)!";
$str = 'opqx';

if (preg_match($pat, $str, $matches)) {
    var_dump($matches);
} // 'nA' = p, 'nB' = '', 'nC' = '', 'n1' = x

注意 o(?'nA'p)q 中只能用名称 nA,不能用其他的nBnC

有趣的是,如果有些选项里省略了名称,结果同样会有名称。

$pat = "!(?|xyz|(?'nA'j)(?'nB'h)(?'nC'k)|o(p)q)(?'n1'x)!";
$str = 'opqx';

if (preg_match($pat, $str, $matches)) {
    var_dump($matches);
} // 'nA' = p, 'nB' = '', 'nC' = '', 'n1' = x

五、原子集合 (Atomic Grouping)

原子集合的名称显然来自以前的原子不可分概念。顾名思义,这个集合是不可分的,即要么匹配要么失败,不会有所谓的不同尝试路径,与前篇学习笔记中的拥有式重复符号非常接近,或者说重复符号只是原子集合的一种简化形式。

原子集合的形式是 (?>...),一旦匹配引擎出了原子集合,一切关于这个集合内的回溯(backtrack)位置都会丢弃。即开弓没有回头箭,一直向前去了,成败在此一举。

简单的说明,就是拿非原子集合 a(bc|b)c 和 原子集合 a(?>bc|b)c 进行直接的比较。对象字符串分别为abcabcc。大家可以发现,对于非原子集合表达式,这两个对象字符串都是匹配的。对于原子集合的表达式,abcc 是匹配的,abc 是不匹配的。

原子集合表达式下,abc 匹配失败的具体引擎工作步骤如下,

  1. 对象字符串第一个字符 a 匹配了表达式中的 a
  2. 对象字符串后面的 bc 直接匹配了原子表达式中的 (?>bc 部分。
  3. 一旦原子集合成功匹配后,就出了原子分配组
  4. 对象字符串中已经没有字符了,表达式中还有最后一个 c,匹配失败

非原子集合表达式下,这个匹配还将继续下去,

  1. 因为失败,所以回头到集合 (bc|b) 中尝试第二个选项 |b
  2. 第二个选项 |b 匹配了对象字符串中的第二个字符 b
  3. 然后跳出集合,表达式中的 c 匹配了对象字符串中的最后字符 c
  4. 成功了!a(bc|b)c 匹配了 abc

从上面的步骤中可以看出几点

  • 原子集合比较简单粗暴,但是效率高
  • 原子集合的编写是有技巧的,改成 a(?>b|bc)c 就成功了

看一下实际的代码,

$pat = "!a(?>b|bc)c!";
$str = 'abc';

if (preg_match($pat, $str, $matches)) {
    var_dump($matches);
} // matched!

这个原子集合就匹配成功了。唯一的缘由是我们把 (?>bc|b) 改成了 (?>b|bc),简单粗暴的原子集合匹配尝试了第一个b 成功后,就跳出进行下一步了。

从原子集合的简单粗暴匹配中,我们可以对一些现有的表达式进行优化,但是需要注意仔细斟酌集合中不同选项的先后次序,以防出错。

注意:原子集合不直接参与引用,没有引用序号或名称!但是你可以在原子集合里面加集合来抓取!

$pat = "!a(?>(?'name'b|bc))c!";
$str = 'abc';

if (preg_match($pat, $str, $matches)) {
    var_dump($matches);
} // matched! name = 'b'

六、前瞻后顾(Look Around)

PCRE支持表达式中对匹配字符的前后进行观察的功能。简单的表述类似,“我要匹配一个A,它的前面(右面)是fbb,而且最终结果不要把fbb列入其中!”。用表达式的语言来表述,就是A(?=fbb)

$pat = "!A(?=fbb)!";
$str = 'test Afbb Aziji';

if (preg_match($pat, $str, $matches, PREG_OFFSET_CAPTURE)) {
    var_dump($matches);
} // matched! result is A, position is 5

注意:Look Around 是没有长度的声明(zero-length assertions),肯定不参与引用,没有引用序号或名称!

但是,你可以通过在前瞻后顾里面加集合来抓取,即 (?=(regex))(?=(?'name'regex))

$pat = "!A(?=(?'name'fbb))!";
$str = 'test Afbb Aziji';

if (preg_match($pat, $str, $matches, PREG_OFFSET_CAPTURE)) {
    var_dump($matches);
} // matched! result is A, position is 5, name = fbb !!

前瞻后顾有四种形态,前瞻 Look Ahead,(?=...)(肯定式)(?!...)(否定式),以及后顾 Look Behind,(?<=...)(肯定式),(?<!...)(否定式)。

注意:PCRE 后顾的方式有个局限性,即表达式中的regex需要给确定的字符数,而不是类似 *+ 之类不定数量的重复符号,但可以用固定长度的{n} 重复符号。

前瞻后顾是原子集合,因为其本身就是一个没有长度的声明(zero-length assertions)。一旦匹配,就会忘记回溯位置,不会为了匹配而进行不同路径的尝试。

例如,(?=(\[A-Z]+))\w+\1 用来匹配 ABC0AB,步骤如下,

  1. 前瞻首先检查整个目标字符串,发行 ABC 匹配成功,然后存入 \1
  2. 然后 \w+ 用来匹配,尝试了多种可能后,回到第一个 A
  3. 发现后面还不是 \1 中的 ABC,所以整个匹配失败

如果前瞻不是原子集合,匹配时可以成功的(假设情况下)

  1. 上面这种匹配失败后,假设前瞻不是原子集合,所以可以尝试前瞻匹配 AB
  2. 所以 AB 存入了 \1 中,然后用\w+\1 来匹配整个目标字符串
  3. 匹配成功!即 \w+ 匹配了 ABC0,\1 匹配了最后剩余的AB

从上面的例子可以看到,前瞻(Look Ahead)可以用来匹配一个目标字符串两次!!简单说,我们可以对一个目标字符串进行两次限定,比如限定一定要6个字符长度,且包含fbb。这可能是一个非常有用的功能。

$pat = "!\b(?=\w{6})(.*fbb.*)\b!";
$str = 'wowfbb ';

if (preg_match($pat, $str, $matches, PREG_OFFSET_CAPTURE)) {
    var_dump($matches);
} // wowfbb is 6-char long and matched!

后顾否定式与采用否定class的肯定式的区别。(?<=[^x])Y(?<!x)Y 是不同的。后者可以匹配一个Y,而前者不可以(Y前必须有字符)。同理,前瞻的这两种表达式也是有区别的,使用时请注意。

刚才谈到了后顾(Look Behind)的限定长度的局限性,PCRE中有个类似后顾的句法 \K,可以一定程度的起到后顾的作用,而且可以使用任何正则表达式。表达式a\Kb 可以用来表示匹配b,它的前面必须是个a

据说 \K 除了在后顾(Look Behind)中不能使用外,其他地方都可以用。\K的工作原理,其实就是正常在目标字符串开始匹配,一旦成功就把前面的东东忘掉。这个模式与后顾(Look Behind)不同,

  • \K 其实是从位置0开始往前匹配,一旦成功就返回结果
  • 后顾是在位置0开始,先往后看,不成功后,前进一步,再往后看...

因此造成了以下例子中的不同结果,

$pat1 = "!(?<=x)x!";
$pat2 = "!x\Kx!";
$str  = 'xxxx';

if (preg_match_all($pat1, $str, $matches, PREG_OFFSET_CAPTURE)) {
    var_dump($matches);
} // match last 3 'x'

if (preg_match_all($pat2, $str, $matches, PREG_OFFSET_CAPTURE)) {
    var_dump($matches);
} // match second 'x' and last 'x'

简单的说,后顾(Look Behind)是一步一个位置进行后顾匹配。\K 匹配成功一个就跳过匹配成功的位置,然后进行下面的匹配。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值