万字长文彻底剖析Python正则表达式

正则表达式在各种语言中都是一个复杂的主题,在Python中,正则表达式设计的尤其复杂以适应不同场景下的脚本。

python官方文档提供了正则表达式使用中的各种细节:

《正则表达式指南》

re --- 正则表达式操作》

如果仔仔细细看完这些文档,正则表达式也就掌握的差不多了,然鹅文档太长了,而且格式排版让人相当的难受,我将其常用功能重新分类整理并添加了一些案例,以方便查询。

一、元字符和转义字符

关于元字符和转义字符,可以参考官方文档:《正则表达式语法》 或者我整理的手册:《Python正则表达式匹配字符手册》,这里重新复习下核心内容部分。

大多数字母和符号都会简单地匹配自身。例如,正则表达式 test 将会精确地匹配到 test 。(你可以启用不区分大小写模式,让这个正则也匹配 TestTEST ,稍后会详细介绍。)

但该规则有例外。有些字符是特殊的 元字符(metacharacters),并不匹配自身。事实上,它们表示匹配一些非常规的内容,或者通过重复它们或改变它们的含义来影响正则的其他部分。本文的大部分内容都致力于讨论各种元字符及其作用。

这是元字符的完整列表:

python 复制代码
. ^ $ * + ? { } [ ] \ | ( )

首先介绍的元字符是 [] 。这两个元字符用于指定一个字符类,也就是你希望匹配的字符的一个集合。这些字符可以单独地列出,也可以用字符范围来表示(给出两个字符并用 '-' 分隔)。例如,[abc] 将匹配 abc 之中的任意一个字符;这与 [a-c] 相同,后者使用一个范围来表达相同的字符集合。如果只想匹配小写字母,则正则表达式将是 [a-z]

元字符 (除了 \) 在字符类中是不起作用的。 例如,[akm$] 将会匹配以下任一字符 'a', 'k', 'm''$''$' 通常是一个元字符,但在一个字符类中它的特殊性被消除了。

你可以通过对集合 取反 来匹配字符类中未列出的字符。方法是把 '^' 放在字符类的最开头。 例如,[^5] 将匹配除 '5' 之外的任何字符。 如果插入符出现在字符类的其他位置,则它没有特殊含义。 例如:[5^] 将匹配 '5''^'

也许最重要的元字符是反斜杠,\ 。 与 Python 字符串字面量一样,反斜杠后面可以跟各种字符来表示各种特殊序列。它还用于转义元字符,以便可以在表达式中匹配元字符本身。例如,如果需要匹配一个 [\ ,可以在其前面加上一个反斜杠来消除它们的特殊含义:\[\\

一些以 '\' 开头的特殊序列表示预定义的字符集合,这些字符集通常很有用,例如数字集合、字母集合或非空白字符集合。

让我们举一个例子:\w 匹配任何字母数字字符,\w 相当于字符类 [a-zA-Z0-9_]

以下为特殊序列的不完全列表。 完整列表参见标准库参考中 正则表达式语法 部分 。

转义字符 作用
\d 匹配任何十进制数字,等价于字符类 [0-9]
\D 匹配任何非数字字符,等价于字符类 [^0-9]
\s 匹配任何空白字符,等价于字符类 [ \t\n\r\f\v]
\S 匹配任何非空白字符,等价于字符类 [^ \t\n\r\f\v]
\w 匹配任何字母与数字字符,等价于字符类 [a-zA-Z0-9_]
\W 匹配任何非字母与数字字符,等价于字符类 [^a-zA-Z0-9_]

这些序列可以包含在字符类中。 例如,[\s,.] 是一个匹配任何空白字符、',''.' 的字符类。

二、简单正则

让我们从最简单的正则表达式开始吧。由于正则表达式是用来操作字符串的,我们将从最常见的任务开始:匹配字符。能够匹配各种各样的字符集合是正则表达式可以做到的第一件事。

我们先来说说重复元字符 ** 并不是匹配一个字面字符 '*' 。实际上,它指定前一个字符可以匹配零次或更多次,而不是只匹配一次。

例如,ca*t 将匹配 'ct' ( 0 个 'a' )、'cat' ( 1 个 'a' )、 'caaat' ( 3 个 'a' )等等。

1、贪婪匹配

类似 * 这样的重复是 贪婪的 。当重复正则时,匹配引擎将尝试重复尽可能多的次数。 如果表达式的后续部分不匹配,则匹配引擎将回退并以较少的重复次数再次尝试。

通过一个逐步示例更容易理解这一点。让我们分析一下表达式 a[bcd]*b 。 该表达式首先匹配一个字母 'a' ,接着匹配字符类 [bcd] 中的零个或更多个字母,最后以一个 'b' 结尾。 现在想象一下用这个正则来匹配字符串 'abcbd'

步骤 匹配 说明
1 a 正则中的 a 匹配成功。
2 abcbd 引擎尽可能多地匹配 [bcd]* ,直至字符串末尾。
3 失败 引擎尝试匹配 b ,但是当前位置位于字符串末尾,所以匹配失败。
4 abcb 回退,让 [bcd]* 少匹配一个字符。
5 失败 再次尝试匹配 b , 但是当前位置上的字符是最后一个字符 'd'
6 abc 再次回退,让 [bcd]* 只匹配 bc
7 abcb 再次尝试匹配 b 。 这一次当前位置的字符是 'b' ,所以它成功了。

此时正则表达式已经到达了尽头,并且匹配到了 'abcb' 。 这个例子演示了匹配引擎一开始会尽其所能地进行匹配,如果没有找到匹配,它将逐步回退并重试正则的剩余部分,如此往复,直至 [bcd]* 只匹配零次。如果随后的匹配还是失败了,那么引擎会宣告整个正则表达式与字符串匹配失败。

贪婪匹配在实际业务中会存在比较严重的问题,特别是在HTML解析方面,贪婪匹配的特性会让人很困惑。

看以下案例:

我们实际上想匹配第一个<html>标签,但是由于贪婪匹配的特性,最终匹配到了整个字符串。如何解决这个问题呢?

解决方案是使用 非贪婪限定符 ?,在代表重复的元字符后加上?即可限制正则表达式的贪婪匹配特性: *?, +?, ??{m,n}?,它们会匹配尽可能 少的 文本。

在一开始的a[bcd]*b 正则匹配中,只需要将正则表达式改成a[bcd]*?b即可消除贪婪匹配特性,最终匹配'abcbd'字符串的结果将是ab

2、代表重复的元字符

上面*是介绍的第一个代表重复的元字符,另一个重复元字符是 + ,表示匹配一次或更多次。请注意 *+ 之间的差别。 * 表示匹配 零次 或更多次,也就是说它所重复的内容是可以完全不出现的。而 + 则要求至少出现一次。举一个类似的例子, ca+t 可以匹配 'cat' ( 1 个 'a' )或 'caaat' ( 3 个 'a'),但不能匹配 'ct'

此外还有两个重复操作符或限定符。 问号 ? 表示匹配一次或零次;你可以认为它把某项内容变成了可选的。 例如,home-?brew 可以匹配 'homebrew''home-brew'

最复杂的限定符是 {m,n},其中 mn 都是十进制整数。 该限定符表示必须至少重复 m 次,至多重复 n 次。 例如,a/{1,3}b 将匹配 'a/b', 'a//b''a///b'。 它不能匹配 'ab',因为其中没有斜杠,也不能匹配 'a////b',因为其中有四个斜杠。

mn 不是必填的,缺失的情况下会设定为默认值。缺失 m 会解释为最少重复 0 次 ,缺失 n 则解释为最多重复无限次。

最简单情况 {m} 将与前一项完全匹配 m 次。 例如,a/{2}b 将只匹配 'a//b'

实际上*+以及?都可以用{m,n}限定符表示: {0,} 等同于 *, {1,} 等同于 +, 而 {0,1} 等同于 ?。 在可能的情况下使用 *, +? 会更好,因为它们更为简短易读。

三、使用正则表达式

现在我们已经了解了一些简单的正则表达式,那么我们如何在 Python 中实际使用它们呢? re 模块提供了正则表达式引擎的接口,可以让你将正则编译为对象,然后用它们来进行匹配。

1、编译正则表达式

正则表达式被编译成模式对象,模式对象具有各种操作的方法,例如搜索模式匹配或执行字符串替换。

re.compile() 也接受一个可选的 flags 参数,用于启用各种特殊功能和语法变体。 我们稍后将介绍可用的设置,但现在只需一个例子

正则作为字符串传递给 re.compile() 。 正则被处理为字符串,因为正则表达式不是核心Python语言的一部分,并且没有创建用于表达它们的特殊语法。 (有些应用程序根本不需要正则,因此不需要通过包含它们来扩展语言规范。)相反,re 模块只是Python附带的C扩展模块,就类似于 socketzlib 模块。

将正则放在字符串中可以使 Python 语言更简单,但有一个缺点是下一节的主题。

2、原始字符串解决反斜杠灾难

我们来思考一个问题,如何写一个正则表达式以匹配字符串\section

我们知道正则表达式使用反斜杠字符 ('\') 来表示特殊形式,比如\d表示数字,\D表示非数字等;普通字符串中反斜杠也是转义符号,比如\n是换行符,\t是制表符,使用\"来避免字符串提前结束等。

在本案例中,正则表达式中的反斜杠必须是原始反斜杠符号,所以正则表达式可以先写成\\s.*以抵消正则表达式中的反斜杠转义,然而这还不行,因为正则表达式本身要作为字符串使用,反斜杠在Python字符串中也有转义作用,所以必须要对\\s.*做再次转义:\\\\s.*;回到\section字符串,他作为被匹配的字符串,里面的转义符号也应当取消,所以它在Python字符串中的正确写法是\\section,完整的程序如下所示:

python 复制代码
>>> import re
>>> p = re.compile('\\\\s.*')
>>> p.match('\\section')
<re.Match object; span=(0, 8), match='\\section'>

可以看到,反斜杠在正则表达式中使用的时候要非常谨慎,当作为原始字符反斜杠使用的时候正则表达式更为复杂且难读懂,如何解决这个问题呢?

答案是:使用原始字符串 ,Python中的原始字符串以前缀'r'开头,原始字符串不处理反斜杠的转义功能,这意味着r"\n" 是一个包含 '\''n' 的双字符字符串,而 "\n" 是一个包含换行符的单字符字符串。正则表达式通常使用这种原始字符串表示法表示。

回到本节的主题,使用原始字符串写法如何写一个正则表达式以匹配字符串\section

python 复制代码
>>> import re
>>> p = re.compile(r'\\s.*')
>>> p.match(r'\section')
<re.Match object; span=(0, 8), match='\\section'>

3、匹配和查询

一旦你有一个表示编译正则表达式的对象,你用它做什么? 模式对象有几种方法和属性。 这里只介绍最重要的内容;请参阅 re 文档获取完整列表。

方法 / 属性 目的
match() 确定正则是否从字符串的开头匹配
search() 扫描字符串,查找此正则匹配的任何位置
findall() 找到正则匹配的所有子字符串,并将它们作为列表返回。
finditer() 找到正则匹配的所有子字符串,并将它们返回为一个 iterator

如果没有找到匹配, match()search() 返回 None 。如果它们成功, 一个 匹配对象 实例将被返回,包含匹配相关的信息:起始和终结位置、匹配的子串以及其它。

python 复制代码
>>> import re
>>> p = re.compile(r'\d+')
>>> m = p.match('')
>>> print(m)
None
>>> m = p.match('123a')
>>> print(m)
<re.Match object; span=(0, 3), match='123'>

匹配对象 中有以下几个方法最为重要:

方法 / 属性 目的
group() 返回正则匹配的字符串
start() 返回匹配的开始位置
end() 返回匹配的结束位置
span() 返回包含匹配 (start, end) 位置的元组
python 复制代码
>>> m.group()
'123'
>>> m.start(),m.end()
(0, 3)
>>> m.span()
(0, 3)

group() 返回正则匹配的子字符串。 start()end() 返回匹配的起始和结束索引。 span() 在单个元组中返回开始和结束索引。 由于 match() 方法只检查正则是否在字符串的开头匹配,所以 start() 将始终为零。 但是,模式的 search() 方法会扫描字符串,因此在这种情况下匹配可能不会从零开始。

python 复制代码
>>> m = p.match('a123')
>>> print(m)
None
>>> m = p.search('a123')
>>> print(m)
<re.Match object; span=(1, 4), match='123'>

在实际程序中,最常见的样式是在变量中存储 匹配对象,然后检查它是否为 None。 这通常看起来像:

python 复制代码
p = re.compile( ... )
m = p.match( 'string goes here' )
if m:
    print('Match found: ', m.group())
else:
    print('No match')

match方法和search方法返回Match对象;findall返回匹配字符串的列表,即所有匹配项:

python 复制代码
>>> import re
>>> p = re.compile(r'\d+')
>>> p.findall('11 people eat 24 apples ,every people eat 2 apples.')
['11', '24', '2']

findall() 必须先创建整个列表才能返回结果。 finditer() 方法将一个 匹配对象 的序列返回为一个 iterator

python 复制代码
>>> import re
>>> p = re.compile(r'\d+')
>>> iter = p.finditer('11 people eat 24 apples ,every people eat 2 apples.')
>>> for item in iter:
...     print(item)
...
<re.Match object; span=(0, 2), match='11'>
<re.Match object; span=(14, 16), match='24'>
<re.Match object; span=(42, 43), match='2'>
>>>

4、分割字符串

模式的 split() 方法在正则匹配的任何地方拆分字符串,返回一个片段列表。 它类似于 split() 字符串方法,但在分隔符的分隔符中提供了更多的通用性;字符串的 split() 仅支持按空格或固定字符串进行拆分。

python 复制代码
.split(string[, maxsplit=0])

如果 maxsplit 非零,则最多执行 maxsplit 次拆分,并且字符串的其余部分将作为列表的最后一个元素返回。 在以下示例中,分隔符是任何非字母数字字符序列。

python 复制代码
>>> p = re.compile(r'\W+')
>>> p.split('This is a test, short and sweet, of split().')
['This', 'is', 'a', 'test', 'short', 'and', 'sweet', 'of', 'split', '']
>>> p.split('This is a test, short and sweet, of split().', 3)
['This', 'is', 'a', 'test, short and sweet, of split().']

如果在正则中使用捕获括号,则它们的值也将作为列表的一部分返回。 比较以下调用:

python 复制代码
>>> p = re.compile(r'\W+')
>>> p2 = re.compile(r'(\W+)')
>>> p.split('This... is a test.')
['This', 'is', 'a', 'test', '']
>>> p2.split('This... is a test.')
['This', '... ', 'is', ' ', 'a', ' ', 'test', '.', '']

5、替换字符串

另一个常见任务是找到模式的所有匹配项,并用不同的字符串替换它们。 sub() 方法接受一个替换值,可以是字符串或函数,也可以是要处理的字符串。

python 复制代码
.sub(replacement, string[, count=0])

返回通过替换 replacement 替换 string 中正则的最左边非重叠出现而获得的字符串。 如果未找到模式,则 string 将保持不变。

可选参数 count 是要替换的模式最大的出现次数;count 必须是非负整数。 默认值 0 表示替换所有。

这是一个使用 sub() 方法的简单示例。 它用 colour 这个词取代颜色名称:

subn() 方法完成相同的工作,但返回一个包含新字符串值和已执行的替换次数的 2 元组:

空匹配陷阱

要注意当正则表达式能匹配空字符串的时候会在每个字符之间以及字符串首尾都添加替换的字符串

空匹配在分割字符串的场景下也会发生:

python 复制代码
>>> p = re.compile('x*')
>>> p.split('apple')
['', 'a', 'p', 'p', 'l', 'e', '']
>>>

后向引用

如果 replacement 是一个字符串,则处理其中的任何反斜杠转义。 也就是说,\n 被转换为单个换行符,\r 被转换为回车符,依此类推。 诸如 \& 之类的未知转义是孤立的。 后向引用 ,例如 \6,被替换为正则中相应组匹配的子字符串。 这使你可以在生成的替换字符串中合并原始文本的部分内容。

这个例子匹配单词 section 后跟一个用 {} 括起来的字符串,并将 section 改为 subsection

python 复制代码
>>> p = re.compile('section{([^}]*)}')
>>> p.sub(r'subsection{\1}','section{First} section{second}')
'subsection{First} subsection{second}'
>>>

还有一种语法用于引用由 (?P<name>...) 语法定义的命名组。\g\<name> 将使用名为 name 的组匹配的子字符串,\g\<number> 使用相应的组号。 因此 \g<2> 等同于 \2

python 复制代码
>>> p=re.compile('section{(?P<name>[^}]*)}')
>>> p.sub(r'subsection{\1}','section{First} section{second}')
'subsection{First} subsection{second}'
>>> p.sub(r'subsection{\g<1>}','section{First} section{second}')
'subsection{First} subsection{second}'
>>> p.sub(r'subsection{\g<name>}','section{First} section{second}')
'subsection{First} subsection{second}'

替换函数

replacement还可以是一个函数,它可以为你提供更多控制。如果 replacement 是一个函数,则为 pattern 的每次非重叠出现将调用该函数。 在每次调用时,函数都会传递一个匹配的 匹配对象 参数,并可以使用此信息计算所需的替换字符串并将其返回。

python 复制代码
>>> import re
>>>
>>> p = re.compile(r"(\d+)")
>>>
>>>
>>> def replacment_fun(match: re.Match):
...     match_str = match.group()
...     return match_str + "_"
...
>>> result = p.sub(replacment_fun, "11 people eat 22 apples , every people eat 2 apples.")
>>> print(result)
11_ people eat 22_ apples , every people eat 2_ apples.
>>>

6、模块级函数

模块级函数让我们不必创建模式对象并调用其方法:re 模块提供了顶级函数 match()search()findall()sub() 等等。 这些函数采用与相应模式方法相同的参数,并将正则字符串作为第一个参数添加,并仍然返回 None匹配对象 实例。:

python 复制代码
>>> print(re.match(r'From\s+', 'Fromage amk'))
None
>>> re.match(r'From\s+', 'From amk Thu May 14 19:12:10 1998')
<re.Match object; span=(0, 5), match='From '>

本质上,这些函数只是为你创建一个模式对象,并在其上调用适当的方法。 它们还将编译对象存储在缓存中,因此使用相同的未来调用将不需要一次又一次地解析该模式。

你是否应该使用这些模块级函数,还是应该自己获取模式并调用其方法? 如果你正在循环中访问正则表达式,预编译它将节省一些函数调用。 在循环之外,由于有内部缓存,没有太大区别。

7、编译标志

编译标志允许你修改正则表达式的工作方式。 标志在 re 模块中有两个名称,长名称如 IGNORECASE 和一个简短的单字母形式,例如 I。 (如果你熟悉 Perl 的模式修饰符,则单字母形式使用和其相同的字母;例如, re.VERBOSE 的缩写形式为 re.X。)多个标志可以 通过按位或运算来指定它们;例如,re.I | re.M 设置 IM 标志。

标志 含意
ASCII, A 使几个转义如 \w\b\s\d 匹配仅与具有相应特征属性的 ASCII 字符匹配。
DOTALL, S 使 . 匹配任何字符,包括换行符。
IGNORECASE, I 进行大小写不敏感匹配。
LOCALE, L 进行区域设置感知匹配。
MULTILINE, M 多行匹配,影响 ^$
VERBOSE, X (为 '扩展') 启用详细的正则,可以更清晰,更容易理解。

在上述表格中的标记中,需要特别关心的标记实际上有四个:DOTALLIGNORECASEMULTILINEVERBOSE,其余几个可以暂不考虑。

re.DOTALL

该标志使 '.' 匹配任何字符,包括换行符;没有这个标志,'.' 将匹配除了 换行符外的任何字符。

举个例子,现在我们有这样一段网页文本:

python 复制代码
html_content = """<div>
    <p>This is a paragraph</p>
    <p>Another paragraph</p>
</div>"""

如何使用正则表达式将div标签中的内容提取出来?

尝试写个脚本:

python 复制代码
import re

html_content = """<div>
    <p>This is a paragraph</p>
    <p>Another paragraph</p>
</div>"""

# 匹配div标签及其所有内容(包括换行)
div_pattern = r"<div>(.*?)</div>"
match_div = re.search(div_pattern, html_content)
print(match_div)

结果输出是None,也即是说没匹配到。这是因为.*没有匹配到换行符\n,此时就可以用到DOTALL标志了:

python 复制代码
>>> import re
>>>
>>> html_content = """<div>
...     <p>This is a paragraph</p>
...     <p>Another paragraph</p>
... </div>"""
>>>
>>> # 匹配div标签及其所有内容(包括换行)
>>> div_pattern = r"<div>(.*?)</div>"
>>> match_div = re.search(div_pattern, html_content, re.DOTALL)
>>> if match_div:
...     print("\nMatched HTML content:", match_div.group(1))
...

Matched HTML content:
    <p>This is a paragraph</p>
    <p>Another paragraph</p>

>>>

re.MULTILINE

通常 ^ 只匹配字符串的开头,而 $ 只匹配字符串的结尾,紧接在字符串末尾的换行符(如果有的话)之前。 当指定了这个标志时,^ 匹配字符串的开头和字符串中每一行的开头,紧跟在每个换行符之后。 类似地,$ 元字符匹配字符串的结尾和每行的结尾(紧接在每个换行符之前)。

听起来有些抽象,举个例子,现在有个提取错误日志的需求,日志格式如下所示:

python 复制代码
log_data = """
[INFO] 2023-01-01 10:00:00 Another log entry
[INFO] 2023-01-01 10:00:00 System started
[ERROR] 2023-01-01 10:05:23 Connection failed
[WARNING] 2023-01-01 10:06:10 Low disk space
[ERROR] 2023-01-01 10:00:00 Error details: Connection timeout
[INFO] 2023-01-01 10:10:00 Backup completed
[ERROR] 2023-01-01 10:05:23 Connection failed1
"""

如何将ERROR级别的日志提取出来?先看第一种写法:

python 复制代码
p = re.compile(r"^\[ERROR\].*")
result = p.findall(log_data)
for item in result:
    print(item)

这种写法没有匹配到任何日志,原因就在于log_data是作为整体的字符串来查找的,而限定符^要求必须以[ERROR]开头,我们这段字符串是以\n[INFO]开头的,所以并不会被匹配到,解决方式就是使用re.MULTILINE标志。

使用re.MULTILINE之后,每行字符串都能在开头被^匹配,结尾被$匹配。

好了,现在我们来看看进阶问题,现在日志格式变成了如下所示:

python 复制代码
log_data = """
[INFO] 2023-01-01 10:00:00 Another log entry
[INFO] 2023-01-01 10:00:00 System started
[ERROR] 2023-01-01 10:05:23 Connection failed
[WARNING] 2023-01-01 10:06:10 Low disk space
[ERROR] 2023-01-01 10:00:00
    Error details: Connection timeout
    Stack trace:
        at com.example.App.main(App.java:10)
[INFO] 2023-01-01 10:10:00 Backup completed
[ERROR] 2023-01-01 10:05:23 Connection failed1
"""

没错,错误日志有换行了,如何将错误日志完整的提取出来?使用之前的正则表达式r"^\[ERROR\].*"会遗漏部分日志。需要考虑以下几点:

  • 错误日志必须从每行[ERROR]开始匹配,所以需要用到re.MULTILINE标志;但是匹配的时候要跨行匹配,所以需要用到re.DOTALL,两者都要使用,则要使用re.MULTILINE | re.DOTALL
  • 由于正则表达式的默认贪婪匹配规则,会一次性匹配出最长的字符串,所以要禁用贪婪匹配,方法就是在重复标记后加上问号?,在这里要使用.*?
  • .*要有截止条件,这里要使用前视断言,前视断言是一种零宽度断言(关于前视断言在后续章节介绍),这里要同时考虑到ERROR日志在最后一行的情况,所以前视断言的写法为:(?=\[|\Z)

综合考虑以上情况,新的正则表达式写法为:p = re.compile(r"^\[ERROR\].*?(?=\[|\Z)", re.MULTILINE | re.DOTALL)

完整代码如下所示:

python 复制代码
import re

log_data = """
[INFO] 2023-01-01 10:00:00 Another log entry
[INFO] 2023-01-01 10:00:00 System started
[ERROR] 2023-01-01 10:05:23 Connection failed
[WARNING] 2023-01-01 10:06:10 Low disk space
[ERROR] 2023-01-01 10:00:00
    Error details: Connection timeout
    Stack trace:
        at com.example.App.main(App.java:10)
[INFO] 2023-01-01 10:10:00 Backup completed
[ERROR] 2023-01-01 10:05:23 Connection failed1
"""

p = re.compile(r"^\[ERROR\].*?(?=\[|\Z)", re.MULTILINE | re.DOTALL)
result = p.findall(log_data)
for item in result:
    print(item)

re.VERBOSE

此标志允许你编写更易读的正则表达式,方法是为您提供更灵活的格式化方式。 指定此标志后,将忽略正则字符串中的空格,除非空格位于字符类中或前面带有未转义的反斜杠;这使你可以更清楚地组织和缩进正则。 此标志还允许你将注释放在正则中,引擎将忽略该注释;注释标记为 '#' 既不是在字符类中,也不是在未转义的反斜杠之前。

例如,这里的正则使用 re.VERBOSE

python 复制代码
charref = re.compile(r"""
 &[#]                # 数字实体引用的开始
 (
     0[0-7]+         # 八进制形式
   | [0-9]+          # 十进制形式
   | x[0-9a-fA-F]+   # 十六进制形式
 )
 ;                   # 末尾分号
""", re.VERBOSE)

如果没有详细设置,正则将如下所示:

python 复制代码
charref = re.compile("&#(0[0-7]+"
                     "|[0-9]+"
                     "|x[0-9a-fA-F]+);")

四、正则表达式进阶

上面章节介绍了正则表达式的基本使用,下面介绍正则表达式的进阶写法。

1、零宽度断言

上面已经讨论过.*?以及\d等转义字符,它们都有一个特点:它们本身代表着匹配字符串中的某一段文本。有这样一种字符,它们在匹配字符串中不占用任何字符,只是代表成功或者失败,这种特殊的匹配字符叫做零宽度断言 。例如,\b 是一个断言,指明当前位置位于字边界;这个位置根本不会被 \b 改变。这意味着永远不应重复零宽度断言,因为如果它们在给定位置匹配一次,它们显然可以无限次匹配。

零宽度断言包含常见的\^$\A\Z\B\b以及前视断言。

元字符和转义字符中的零宽度断言

|

或者"or"运算符。 如果 AB 是正则表达式,A|B 将匹配任何与 AB 匹配的字符串。 | 具有非常低的优先级,以便在交替使用多字符字符串时使其合理地工作。 Crow|Servo 将匹配 'Crow''Servo',而不是 'Cro''w''S''ervo'。要匹配字面 '|',请使用 \|,或将其括在字符类中,如 [|]

^

在行的开头匹配。 除非设置了 MULTILINE 标志,否则只会在字符串的开头匹配。 在 MULTILINE 模式下,这也在字符串中的每个换行符后立即匹配。

例如,如果你希望仅在行的开头匹配单词 From,则要使用的正则 ^From。:

python 复制代码
>>> print(re.search('^From', 'From Here to Eternity'))
<re.Match object; span=(0, 4), match='From'>
>>> print(re.search('^From', 'Reciting From Memory'))
None

要匹配字面 '^',使用 \^

$

匹配行的末尾,定义为字符串的结尾,或者后跟换行符的任何位置。:

python 复制代码
>>> print(re.search('}$', '{block}'))
<re.Match object; span=(6, 7), match='}'>
>>> print(re.search('}$', '{block} '))
None
>>> print(re.search('}$', '{block}\n'))
<re.Match object; span=(6, 7), match='}'>

以匹配字面 '$',使用 \$ 或者将其包裹在一个字符类中,例如 [$]

\A

仅匹配字符串的开头。 当不在 MULTILINE 模式时,\A^ 实际上是相同的。 在 MULTILINE 模式中,它们是不同的: \A 仍然只在字符串的开头匹配,但 ^ 可以匹配在换行符之后的字符串内的任何位置。

\Z

只匹配字符串尾。

\b

字边界。 这是一个零宽度断言,仅在单词的开头或结尾处匹配。 单词被定义为一个字母数字字符序列,因此单词的结尾由空格或非字母数字字符表示。

以下示例仅当它是一个完整的单词时匹配 class;当它包含在另一个单词中时将不会匹配。

python 复制代码
>>> p = re.compile(r'\bclass\b')
>>> print(p.search('no class at all'))
<re.Match object; span=(3, 8), match='class'>
>>> print(p.search('the declassified algorithm'))
None
>>> print(p.search('one subclass is'))
None

使用这个特殊序列时,你应该记住两个细微之处。 首先,这是 Python 的字符串文字和正则表达式序列之间最严重的冲突。 在 Python 的字符串文字中,\b 是退格字符,ASCII 值为8。 如果你没有使用原始字符串,那么 Python 会将 \b 转换为退格,你的正则不会按照你的预期匹配。 以下示例与我们之前的正则看起来相同,但省略了正则字符串前面的 'r'。:

python 复制代码
>>> p = re.compile('\bclass\b')
>>> print(p.search('no class at all'))
None
>>> print(p.search('\b' + 'class' + '\b'))
<re.Match object; span=(0, 7), match='\x08class\x08'>

其次,在一个字符类中,这个断言没有用处,\b 表示退格字符,以便与 Python 的字符串文字兼容。

\B

另一个零宽度断言,这与 \b 相反,仅在当前位置不在字边界时才匹配。

前视断言和后视断言

从"断言"这个词上看,就知道该功能的作用是"判断",它只有两个值:True或者False。那判断什么呢?

(?=...)

肯定型前视断言。如果内部的表达式(这里用 ... 来表示)在当前位置可以匹配,则匹配成功,否则匹配失败。 但是,内部表达式尝试匹配之后,正则引擎并不会向前推进;正则表达式的其余部分依然会在断言开始的地方尝试匹配。

举个例子,在之前re.MULTILINE章节介绍的正则表达式^\[ERROR\].*?(?=\[|\Z),用于提取ERROR级别的完整日志,其中(?=\[|\Z)就是前视断言,它前面的.*不能无限匹配到字符串最后,需要有个停止条件,停止条件就是匹配到字符[[字符表示下一条日志的开头)或者匹配到字符串最后也就是\Z\Z能匹配到表示当前匹配的ERROR级别的日志在最后一条);如果没匹配到,则将会匹配失败。

(?!...)

否定型前视断言。 与肯定型断言正好相反,如果内部表达式在字符串中的当前位置 匹配,则成功。

(?<=...)

肯定型后视断言,它也是一种零宽度断言,表示匹配的内容必须出现在指定模式(断言)之后。

举个例子,我们有一段文本,text = "Price: $100, Discount: $50, i have 11 mantou",我们想提取出来以\(开头的数字,如何实现?可以使用`re.findall(r'\$\d+',text)` 提取出来包含\)符号的:

但是这样并不符合我们的要求,我们要以\(符号开头,但是只要数字。这时候就可以使用肯定型后视断言了,正则表达式可以这样写:`re.findall(r'(?<=\)\\d+', text)\`,这表示必须以\\)符号开头,但是不要匹配,只要数字。

(?<!...)

否定型后视断言,表示匹配的内容不能出现在指定的模式之后

总而言之,前视断言和后视断言在需要匹配特定上下文但不希望这些上下文成为匹配结果一部分时非常有用。

2、捕获组(分组)

分组是用 '(', ')' 元字符来标记的。 '('')' 与它们在数学表达式中的含义基本一致:它们会将所包含的表达式合为一组,并且你可以使用限定符例如 *, +, ?, 或 {m,n} 来重复一个分组的内容。 举例来说,(ab)* 将匹配 ab 的零次或多次重复。

python 复制代码
>>> p = re.compile('(ab)*')
>>> print(p.match('ababababab').span())
(0, 10)

'('')' 表示的组也捕获它们匹配的文本的起始和结束索引;这可以通过将参数传递给 group()start()end() 以及 span()。 组从 0 开始编号。组 0 始终存在;它表示整个正则,所以 匹配对象 方法都将组 0 作为默认参数。 稍后我们将看到如何表达不捕获它们匹配的文本范围的组。

python 复制代码
>>> p = re.compile('(a)b')
>>> m = p.match('ab')
>>> m.group()
'ab'
>>> m.group(0)
'ab'

子组从左到右编号,从 1 向上编号。 组可以嵌套;要确定编号,只需计算从左到右的左括号字符

python 复制代码
>>> p = re.compile('(a(b)c)d')
>>> m = p.match('abcd')
>>> m.group(0)
'abcd'
>>> m.group(1)
'abc'
>>> m.group(2)
'b'

group() 可以一次传递多个组号,在这种情况下,它将返回一个包含这些组的相应值的元组。:

python 复制代码
>>> m.group(2,1,2)
('b', 'abc', 'b')

groups() 方法返回一个元组,其中包含所有子组的字符串,从1到最后一个子组。:

python 复制代码
>>> m.groups()
('abc', 'b')

模式中的后向引用允许你指定还必须在字符串中的当前位置找到先前捕获组的内容。 例如,如果可以在当前位置找到组 1 的确切内容,则 \1 将成功,否则将失败。 请记住,Python 的字符串文字也使用反斜杠后跟数字以允许在字符串中包含任意字符,因此正则中引入反向引用时务必使用原始字符串。

例如,以下正则检测字符串中重复的单词。:

python 复制代码
>>> p = re.compile(r'\b(\w+)\s+\1\b')
>>> p.search('Paris in the the spring').group()
'the the'

分组重复

分组如果储存在重复,则在捕获的时候,下一次的捕获会覆盖上一次的捕获。如何理解这句话?还是之前的例子:

python 复制代码
>>> p = re.compile('(ab)*')
>>> print(p.match('ababababab').span())
(0, 10)

(ab)*可以完全匹配abababababgroup()group(0)都是ababababab,但是group(1)以及groups()都是ab,实际上(ab)分组匹配了5次,ab则是最后一个分组捕获的结果。

如果上述案例难以理解,可以再看下面的案例:正则表达式([abc])+匹配'abc'字符串。

分别匹配了(a)+、(b)+、(c)+并捕获成功,(b)+覆盖了(a)+的捕获结果,(c)+覆盖了(b)+的捕获结果,所以最后的捕获结果只剩下了(c)+的捕获结果'c'

分组重复和一般分组不一样,无论重复多少次,最终保留下来的只有最后一次捕获结果。

3、非捕获组

有时我们会想要使用组来表示正则表达式的一部分,但是对检索组的内容不感兴趣。 你可以通过使用非捕获组来显式表达这个事实: (?:...),你可以用任何其他正则表达式替换 ...。注意非捕获组的格式是(?:...)和前视断言(?=...)不同。

来看看使用正则表达式r'(\d+)asdf'匹配'1234asdf'

可以看到捕获组捕获到了1234作为group(1)。如果我将正则表达式改成r'(?:\d+)asdf' 会怎样?

可以看到,非捕获组的作用就是能匹配,但是不捕获

4、命名分组

前面说的非捕获组(?:...)以及前视断言(?=...)实际上都是Python 支持的 Perl 的扩展,命名组的格式是(?P=<name>...),它是Python的特定扩展之一,name 显然是该组的名称。使用命名组的时候,可以同时使用数字编号以及组名字符串来关联对应的捕获组。

python 复制代码
>>> p = re.compile(r'(?P<word>\b\w+\b)')
>>> m = p.search( '(((( Lots of punctuation )))' )
>>> m.group('word')
'Lots'
>>> m.group(1)
'Lots'

注意search方法只找第一个匹配,所以只输出了Lots,如果想获取所有匹配,应当使用findall或者finditer方法。

命名分组提取为字典

我们可以通过 groupdict() 将命名分组提取为一个字典:

python 复制代码
>>> m = re.match(r'(?P<first>\w+)\s+(?P<last>\w+)', 'Jane Doe')
>>> m.groupdict()
{'first': 'Jane', 'last': 'Doe'}

后向引用

后向引用是一种新的扩展语法,它的使用格式是:(?P=name)

举个例子,用于查找重复单词的正则表达式\b(\w+)\s+\1\b 也可以写为 \b(?P<word>\w+)\s+(?P=word)\b

python 复制代码
>>> p = re.compile(r'\b(?P<word>\w+)\s+(?P=word)\b')
>>> p.search('Paris in the the spring').group()
'the the'

要注意,在sub替换字符串方法中,命名分组的后向引用要使用\g\<name>的形式,相关功能科参考替换字符串章节。

都看到这里了,能关注下我的小站就更好了(˶‾᷄ꈊ‾᷅˵)https://blog.kdyzm.cn

END.