2.36. 分组与锚点

一个模式所能做的不仅仅是判断“这个字符串匹配”——它还能把匹配到的各个部分拆分出来,并按名称交给应用程序。在模式的某一部分外面加上括号,就使其成为一个 捕获组;匹配对象随后会把每个分组作为单独的子串暴露出来。

2.36.1. 捕获组

把模式的任意部分用 (...) 包裹起来,即可捕获它所匹配的内容:

>>> import re
>>> m = re.search(r'temp (\d+) at (\d+)s', 'temp 42 at 137s ok')
>>> m.group(0)
'temp 42 at 137s'
>>> m.group(1)
'42'
>>> m.group(2)
'137'
  • 分组 0 始终是整个匹配结果。

  • 分组 1、2、…… 是被捕获的子串,按其 括号从左到右编号。

  • 用超过最后一个分组的索引调用 match.group() 会引发 IndexError

一种常见的模式是“匹配某种已知结构,并把可变部分捕获为整数”:

def parse_temp(line):
    m = re.search(r'temp (\d+) at (\d+)s', line)
    if not m:
        return None
    return int(m.group(1)), int(m.group(2))

2.36.2. 非捕获组

括号也会把一个子表达式 分组,以便量词能够作用于整个分组。这正是 r'(ab)+' 中分组的唯一用途——“一个或多个 ab”。ab 同时作为分组 1 出现只是一个副作用。

若要分组但不捕获,请使用 (?:...):

>>> re.search(r'(?:ab)+', 'xababy').group(0)
'abab'

当模式仅出于结构目的使用分组、而并不关心把每个部分单独提取出来时,非捕获组能让分组编号保持整洁。

2.36.3. 锚点

锚点并不匹配某个字符——它匹配一个 位置

  • ^ —— 字符串的开头。

  • $ —— 字符串的结尾。

锚点正是使 re.match()re.search() 行为不同的原因。re.match(p, s) 等同于 re.search('^' + p, s):它强制模式从位置 0 开始匹配。在模式末尾再加上 $,就使模式匹配 整个 字符串而不匹配其他任何内容:

>>> re.search(r'^\d+$', '12345')
<match num=1>
>>> re.search(r'^\d+$', '12345 ok') is None
True

在 MicroPython 的 re 中,^$ 始终表示传入 re.search()整个 字符串的开头和结尾。没有 re.MULTILINE 标志可以让它们在每一个内嵌换行符处匹配,$ 也不会匹配末尾 \n 之前的位置——它必须是输入的绝对末尾。要获得逐行的行为,请先在换行符处拆分输入,再对每一行运行模式。

2.36.4. 字符集

方括号定义了一个明确的字符集合。匹配会从该集合中恰好消耗一个字符。

  • [abc] —— abc 中的一个。

  • [a-z] —— a-z 范围内(含端点)的一个字符。

  • [a-zA-Z0-9] —— 字母或数字。三个范围的组合。

  • [^abc] —— 不是 abc 中的任何一个。只有当 ^ 是方括号内的第一个字符时才表示取反。

示例:

>>> re.search(r'[A-F0-9]{6}', 'colour #1a2b3c rest').group(0)
'1A2B3C'
>>> re.search(r'[A-F0-9]{6}', 'colour #1a2b3c rest') is None
True

由于字面文本是小写的,第一次调用在实际中返回 None。MicroPython 的 re 没有 re.IGNORECASE 标志——要进行不区分大小写的匹配,请把两种大小写都写进字符集里:

>>> re.search(r'[A-Fa-f0-9]{6}', 'colour #1a2b3c rest').group(0)
'1a2b3c'

字符类简写(\d\s\w 及其取反形式)也可以用在 [...] 内部:[\w-] 表示“单词字符或一个字面的连字符”。

2.36.5. 贪婪量词与懒惰量词

量词 *+?{m,n} 默认是 贪婪 的——它们会在模式的其余部分仍能匹配的前提下,尽可能多地匹配字符。这往往正是我们想要的;但有时并非如此:

>>> re.search(r'<(.+)>', 'a <b> <c> d').group(1)
'b> <c'

贪婪的 .+ 一直抓取到了最后一个 >。在其后追加 ? 会使量词变为 懒惰——它会尽可能少地匹配:

>>> re.search(r'<(.+?)>', 'a <b> <c> d').group(1)
'b'

懒惰形式在第一个 > 处就停止。当从字符串中提取成对的分隔符内容时,懒惰量词会经常用到。

2.36.6. 替换中的反向引用

re.sub() 可以在替换字符串中通过 \1\2、…… 引用被捕获的分组。替换会用捕获到的各部分来重写每一处匹配:

>>> re.sub(r'(\d+)\.(\d+)', r'\2.\1', 'swap 12.34 and 5.6')
'swap 34.12 and 6.5'

每次匹配捕获两个数字,替换会把它们交换。\g<1> 是实现同样效果的另一种语法——当替换中紧随其后的字符是数字时很有用(用 r'\g<1>0' 在分组 1 后追加一个字面的零,而不是被读成“分组 10”)。

2.36.7. 哪些功能不可用

提醒一下 MicroPython 的 re 支持哪些功能,以免来自 CPython 的某个模式落到这里时让你措手不及:

  • 前瞻 (?=...) 和后顾 (?<=...) —— 未实现。

  • 命名分组 (?P<name>...) 和命名反向引用 (?P=name) —— 未实现。

  • 诸如 re.IGNORECASEre.MULTILINEre.DOTALL 之类的标志常量 —— 不予支持。请自行构建不区分大小写的字符集,或预先拆分输入。

  • match.groups()match.span()match.start()match.end() 方法受限于一个没有任何已发布的 OpenMV 开发板启用的 ROM 级别。依赖它们的代码无法在摄像头上运行。

有了模式、分组和锚点,摄像头上的正则表达式工具集足够小,一次就能学会;也足够丰富,除了上下文相关的解析之外几乎无所不能。