精通 Python 正则表达式(全)
原文:
zh.annas-archive.org/md5/3C085EA0447FEC36F167335BDBD4428E
译者:飞龙
前言
自计算机科学迈出第一步以来,文本处理一直是最重要的话题之一。经过几十年的研究,我们现在拥有了最多才多艺和无处不在的工具之一:正则表达式。验证、搜索、提取和替换文本的操作得以简化,这要归功于正则表达式。
本书最初将从鸟瞰角度介绍正则表达式,逐步深入更高级的主题,如 Python 中的正则表达式细节或分组、解决方法和性能。所有主题都将以 Python 特定的示例进行介绍,这些示例可以直接在 Python 控制台中使用。
本书涵盖的内容
第一章,“介绍正则表达式”,将从非 Python 特定的角度介绍正则表达式语法的基础知识。
第二章,“Python 中的正则表达式”,将介绍 Python 的正则表达式 API 及其特点。
第三章,“分组”,涵盖了提取信息部分、对特定部分应用量词以及执行正确交替的正则表达式功能。
第四章,“四处张望”,解释了零宽断言的概念和不同类型的四处张望机制。
第五章,“正则表达式的性能”,将涵盖不同的工具来衡量正则表达式的速度,Python 的正则表达式模块的细节,以及改进正则表达式性能的不同建议。
本书所需内容
为了理解本书,需要对任何支持的平台上的 Python 有基本的了解。能够使用带有 Python 命令行访问权限的控制台非常重要。
不需要先前对正则表达式的了解,因为将从零开始介绍。
本书的受众
本书适用于希望了解正则表达式一般情况以及如何在 Python 中具体利用它们的 Python 开发人员。
约定
在本书中,您会发现一些文本样式,用以区分不同类型的信息。以下是一些这些样式的示例,以及它们的含义解释。
文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“我们可以通过使用include
指令来包含其他上下文。”
代码块设置如下:
>>> import re
>>> pattern = re.compile(r'<HTML>')
>>> pattern.match("<HTML>")
当我们希望引起您对代码块的特定部分的注意时,相关行或项目会以粗体显示:
>>> import re
>>> pattern = **re.compile(r'<HTML>')
>>> pattern.match("<HTML>")
新术语和重要单词以粗体显示。屏幕上看到的单词,例如菜单或对话框中的单词,会以这样的方式出现在文本中:“点击下一步按钮会将您移动到下一个屏幕”。
注意
警告或重要说明会以这样的方式出现在一个框中。
提示
提示和技巧会出现在这样的形式中。
第一章:介绍正则表达式
正则表达式是定义文本字符串应具有的形式的文本模式。使用它们,除其他用途外,将能够执行以下活动:
-
检查输入是否符合给定的模式;例如,我们可以检查 HTML 表单中输入的值是否是有效的电子邮件地址
-
在一段文本中查找模式的出现;例如,检查文档中是否只有一个扫描中出现了单词“color”或单词“colour”
-
提取文本的特定部分;例如,提取地址的邮政编码
-
替换文本的部分;例如,将“color”或“colour”的任何出现更改为“red”
-
将较大的文本分割成较小的部分,例如,通过任何出现的句点、逗号或换行字符来分割文本
在本章中,我们将从与语言无关的角度学习正则表达式的基础知识。在本章结束时,我们将了解正则表达式的工作原理,但我们还不能在 Python 中执行正则表达式。这将在下一章中介绍。因此,本章中的示例将从理论角度而不是在 Python 中执行的角度进行讨论。
历史、相关性和目的
正则表达式是无处不在的。它们可以在最新的办公套件或 JavaScript 框架中找到,也可以在追溯到 70 年代的 UNIX 工具中找到。没有现代编程语言能够被称为完整,直到它支持正则表达式。
尽管它们在语言和框架中很普遍,但正则表达式在现代程序员的工具包中还不是无处不在的。经常用来解释这一点的原因之一是它们的学习曲线陡峭。如果不小心编写,正则表达式可能很难掌握,而且阅读起来非常复杂。
由于这种复杂性,不难在互联网论坛上找到这个老生常谈的话题:
"有些人面对问题时,想到的是“我知道,我会使用正则表达式。”现在他们有了两个问题。" | ||
---|---|---|
--杰米·扎温斯基,1997 |
您可以在groups.google.com/forum/?hl=en#!msg/alt.religion.emacs/DR057Srw5-c/Co-2L2BKn7UJ
找到它。
通过阅读本书,我们将学习如何在编写正则表达式时利用最佳实践,从而大大简化阅读过程。
尽管正则表达式现在可以在最新和最伟大的编程语言中找到,并且可能会在很多年内都存在,但它们的历史可以追溯到 1943 年,当时神经生理学家沃伦·麦卡洛克和沃尔特·皮茨发表了《神经活动中所固有的思想的逻辑演算》。这篇论文不仅代表了正则表达式的开始,还提出了神经网络的第一个数学模型。
下一步是在 1956 年采取的,这次是由一位数学家。斯蒂芬·克利恩写了一篇名为《神经元和有限自动机中事件的表示》的论文,在其中他创造了术语regular sets和regular expressions。
十二年后,1968 年,计算机科学的传奇先驱接受了克利恩的工作并加以扩展,将他的研究发表在《正则表达式搜索算法》一文中。这位工程师就是肯·汤普森,他以 Unix、B 编程语言、UTF-8 编码等的设计和实现而闻名。
肯·汤普森的工作不仅仅是写论文。他在他的 QED 版本中包括了对这些正则表达式的支持。要在 QED 中使用正则表达式,需要编写以下内容:
g/<regular expression>/p
在上一行代码中,g
表示全局搜索,p
表示打印。如果我们不写regular expression
,而是写短形式re
,我们得到g/re/p
,因此,这是古老的 UNIX 命令行工具grep
的开端。
接下来的重要里程碑是 Henry Spence 发布了第一个非专有的regex库,后来是脚本语言Perl的创建。Perl 将正则表达式推向了主流。
Perl 中的实现向前迈进,并对原始正则表达式语法进行了许多修改,创建了所谓的Perl 风格。其他语言或工具中的许多后续实现都基于 Perl 风格的正则表达式。
IEEE 认为他们的 POSIX 标准试图标准化并为正则表达式语法和行为提供更好的 Unicode 支持。这被称为正则表达式的 POSIX 风格。
今天,用于正则表达式的标准 Python 模块re
仅支持 Perl 风格的正则表达式。有人正在努力编写一个新的 regex 模块,以更好地支持 POSIX 风格,网址为pypi.python.org/pypi/regex
。这个新模块打算最终取代 Python 的re
模块实现。在本书中,我们将学习如何利用标准的re
模块。
提示
正则表达式,regex,regexp 或 regexen?
Henry Spencer 将他著名的库不加区分地称为"regex"或"regexp"。维基百科建议使用regex或regexp作为缩写。著名的 Jargon File 将它们列为regexp、regex 和 reg-ex。
然而,尽管对于命名正则表达式似乎没有非常严格的方法,它们是基于数学领域中称为形式语言的领域,其中精确是一切。大多数现代实现支持无法用形式语言表达的特性,因此它们不是真正的正则表达式。Perl 语言的创建者 Larry Wall 因此使用了术语regexes或regexen。
在本书中,我们将不加区分地使用所有上述术语,就好像它们是完美的同义词一样。
正则表达式语法
任何有经验的开发人员无疑都使用过某种形式的正则表达式。例如,在操作系统控制台中,使用星号(*
)或问号(?
)来查找文件并不罕见。
问号将匹配文件名中任何值的单个字符。例如,模式file?.xml
将匹配file1.xml
、file2.xml
和file3.xml
,但不会匹配file99.xml
,因为该模式表示以file
开头,接着是任何值的一个字符,最后以.xml
结尾的任何内容都将匹配。
星号(*
)也有类似的含义。当使用星号时,任何数量的任何值的字符都被接受。在file*.xml
的情况下,任何以file
开头,接着是任何数量的任何值的字符,最后以.xml
结尾的内容都将匹配。
在这个表达式中,我们可以找到两种组件:文字(file
和.xml
)和元字符(?
或*
)。我们将在本书中学习的正则表达式比我们通常在操作系统命令行中找到的简单模式更强大,但两者都可以共享一个定义:
正则表达式是由普通字符(例如,字母a到z或数字0到9)和称为元字符的特殊字符组成的文本模式。这个模式描述了应用于文本时匹配的字符串。
让我们看看我们的第一个正则表达式,它将匹配任何以a
开头的单词:
正则表达式使用文字和元字符
注意
本书中正则表达式的表示
在本书的后续图表中,正则表达式将被/
符号限定。这是大多数教科书中遵循的 QED 标记。然而,代码示例不会使用这种表示法。
另一方面,即使使用等宽字体,正则表达式的空格也很难计算。为了简化阅读,在图表中的每个单个空格都将显示为。
前面的正则表达式再次使用字面量和元字符。这里的字面量是和a
,元字符是\
和w
,它们匹配包括下划线在内的任何字母数字字符,以及*
,它将允许前一个字符的任意重复,因此,任意数量的任何单词字符的重复,包括下划线。
我们将在本章后面介绍元字符,但让我们先了解字面量。
字面量
字面量是正则表达式中最简单的模式匹配形式。只要找到该字面量,它们就会简单地成功。
如果我们将正则表达式/fox/
应用于搜索短语The quick brown fox jumps over the lazy dog
,我们将找到一个匹配:
使用正则表达式进行搜索
然而,如果我们将正则表达式/be/
应用于以下短语To be, or not to be
,我们也可以获得多个结果而不仅仅是一个:
使用正则表达式进行多个结果搜索
我们刚刚在前一节中学到,元字符可以与字面量共存在同一个表达式中。由于这种共存,我们会发现一些表达式并不是我们想要的。例如,如果我们将表达式/(this is inside)/
应用于搜索文本this is outside (this is inside)
,我们会发现括号没有包含在结果中。这是因为括号是元字符,它们有特殊的含义。
错误未转义的元字符
我们可以将元字符用作字面量。有三种机制可以这样做:
-
通过在元字符前加上反斜杠来转义元字符。
-
在 Python 中,使用
re.escape
方法转义可能出现在表达式中的非字母数字字符。我们将在第二章中介绍这个内容,使用 Python 进行正则表达式。 -
使用\Q 和\E 进行引用:在正则表达式中,还有第三种引用机制,即使用
\Q
和\E
进行引用。在支持它们的语言中,只需用\Q(开始引用)和\E(结束引用)括起需要引用的部分即可。
然而,目前 Python 不支持这一点。
使用反斜杠方法,我们可以将前面的表达式转换为/\(this is inside\)/
,并再次应用到相同的文本中,以便将括号包含在结果中:
在正则表达式中转义元字符
在正则表达式中,有十二个元字符,如果要以它们的字面意义使用,就应该转义:
-
反斜杠
\
-
插入符号
^
-
美元符号
$
-
点
.
-
管道符号
|
-
问号
?
-
星号
*
-
加号
+
-
左括号
(
-
右括号
)
-
左方括号
[
-
左花括号
{
在某些情况下,正则表达式引擎会尽力理解它们是否应该具有字面意义,即使它们没有被转义;例如,左花括号{
只有在后面跟着一个数字表示重复时才会被视为元字符,我们将在本章后面学习到。
字符类
我们将首次使用元字符来学习如何利用字符类。字符类(也称为字符集)允许我们定义一个字符,如果集合中定义的任何字符存在,则匹配。
要定义字符类,我们应该使用开方括号元字符[
,然后是任何接受的字符,最后用闭方括号]
关闭。例如,让我们定义一个正则表达式,可以匹配英式和美式英语书写形式中的单词"license":
使用字符类进行搜索
也可以使用字符的范围。这是通过在两个相关字符之间使用连字符(-
)来完成的;例如,要匹配任何小写字母,我们可以使用[a-z]
。同样,要匹配任何单个数字,我们可以定义字符集[0-9]
。
字符类的范围可以组合在一起,以便通过将一个范围放在另一个范围后面来匹配字符对许多范围进行匹配—不需要特殊的分隔。例如,如果我们想匹配任何小写或大写字母数字字符,我们可以使用[0-9a-zA-Z]
(有关更详细的解释,请参见下表)。这也可以使用并集机制来替代写成[0-9[a-z[A-Z]]]
。
元素 | 描述 |
---|---|
[ | 匹配以下字符集 |
0-9 | 匹配0 到9 之间的任何内容(0 ,1 ,2 ,3 ,4 ,5 ,6 ,7 ,8 ,9 )。 |
或 | |
a-z | 匹配a 到z 之间的任何内容(a ,b ,c ,d ,...,z ) |
或 | |
A-Z | 匹配A 到Z 之间的任何内容(A ,B ,C ,D ,...,Z ) |
] | 字符集结束 |
还有另一种可能性—范围的否定。我们可以通过在开方括号元字符([
)后面直接放置一个脱字符(^
)来颠倒字符集的含义。如果我们有一个字符类,比如[0-9]
表示任何数字,否定的字符类[⁰-9]
将匹配任何不是数字的内容。但是,重要的是要注意,必须有一个不是数字的字符;例如,/hello[⁰-9]/
不会匹配字符串hello
,因为在之后必须有一个非数字字符。有一种机制可以做到这一点—称为负向先行断言—它将在第四章 环视中进行介绍。
预定义字符类
使用字符类一段时间后,很明显其中一些非常有用,可能值得一个快捷方式。
幸运的是,有许多预定义的字符类可以被重复使用,并且其他开发人员可能已经知道,使得使用它们的表达式更易读。
这些字符不仅作为典型字符集的众所周知的快捷方式非常有用,而且在不同的上下文中具有不同的含义。字符类\w
,它匹配任何字母数字字符,将根据配置的区域设置和对 Unicode 的支持匹配不同的字符集。
以下表格显示了 Python 目前支持的字符类:
元素 | 描述(对于默认标志的正则表达式) |
---|
|
.
此元素匹配除换行符\n 之外的任何字符 |
---|
|
\d
这匹配任何十进制数字;这相当于类[0-9] |
---|
|
\D
这匹配任何非数字字符;这相当于类[⁰-9] |
---|
|
\s
这匹配任何空白字符;这相当于类![预定义字符类\t\n\r\f\v] |
---|
|
\S
这匹配任何非空白字符;这相当于类[^ \t\n\r\f\v] |
---|
|
\w
这匹配任何字母数字字符;这相当于类[a-zA-Z0-9_] |
---|
|
\W
这匹配任何非字母数字字符;这相当于类[^a-zA-Z0-9_] |
---|
注意
Python 中的 POSIX 字符类
POSIX 标准提供了许多字符类的名称,例如,[:alnum:]
表示字母数字字符,[:alpha:]
表示字母字符,或[:space:]
表示所有空白字符,包括换行符。
所有的 POSIX 字符类都遵循相同的[:name:]
表示法,使它们易于识别。但是,它们目前在 Python 中不受支持。
如果你遇到其中一个,你可以通过利用我们在本节中学到的字符类的功能来实现相同的功能。例如,在英语环境中,ASCII 等效的[:alnum:]
可以写成[a-zA-Z0-9]
。
前一个表中的第一个元素——点——需要特别注意。点可能是最古老的元字符之一,也是最常用的元字符之一。点可以匹配除换行符之外的任何字符。
不匹配换行符的原因可能是 UNIX。在 UNIX 中,命令行工具通常逐行工作,并且当前可用的正则表达式是分别应用于这些行的。因此,没有换行符可匹配。
让我们通过创建一个正则表达式来实践点,该表达式匹配除换行符之外的任何值的三个字符:
/…/
元素 | 描述 |
---|---|
. | 匹配任何字符 |
. | 匹配前一个字符后面的任何字符 |
. | 匹配前一个字符后面的任何字符 |
点是一个非常强大的元字符,如果不适度使用可能会导致问题。在大多数使用点的情况下,可以考虑使用过度(或者只是在编写正则表达式时的懒惰的表现)。
为了更好地定义预期匹配的内容,并更简洁地表达正则表达式的意图,强烈推荐使用字符类。例如,在处理 Windows 和 UNIX 文件路径时,要匹配除斜杠或反斜杠之外的任何字符,可以使用否定字符集:
[^\/\]
元素 | 描述 |
---|
|
[
匹配一组字符 |
---|
|
^
不匹配此符号后的字符 |
---|
|
\/
匹配 / 字符 |
---|
|
\
匹配 \ 字符 |
---|
|
]
集合的结束 |
---|
这个字符集明确告诉你,我们打算匹配除了 Windows 或 UNIX 文件路径分隔符之外的任何内容。
交替
我们刚刚学会了如何匹配一组字符中的单个字符。现在,我们将学习更广泛的方法:如何匹配一组正则表达式。这是使用管道符号|
来实现的。
让我们从想要匹配的内容开始,如果我们找到单词 "yes" 或单词 "no"。使用交替,就会变得很简单:
/yes|no/
元素 | 描述 |
---|---|
匹配以下字符集中的任何一个 |
|
yes
字符 y ,e 和 s 。 |
---|
|
|
或 |
---|
|
no
字符 n 和 o 。 |
---|
另一方面,如果我们想要接受超过两个值,我们可以继续像这样添加值到交替中:
/yes|no|maybe/
元素 | 描述 |
---|---|
匹配以下字符集中的任何一个 |
|
yes
字面上的 "yes" |
---|
|
|
或 |
---|
|
no
字面上的 "no" |
---|
|
|
或 |
---|
|
maybe
字面上的 "maybe" |
---|
在更大的正则表达式中使用时,我们可能需要将我们的交替放在括号中,以表达只有那部分是交替的,而不是整个表达式。例如,如果我们犯了不使用括号的错误,就像下面的表达式一样:
/Licence: yes|no/
元素 | 描述 |
---|---|
匹配以下字符集中的任何一个 |
|
Licence: yes
字符 L ,i ,c ,e ,n ,c ,e ,: ,,y ,e 和 s |
---|
|
|
或 |
---|
|
no
字符 n 和 o 。 |
---|
我们可能认为我们接受Licence: yes
或Licence: no
,但实际上我们接受的是Licence: yes
或no
,因为交替已经应用于整个正则表达式,而不仅仅是yes|no
部分。正确的方法是:
使用交替的正则表达式
量词
到目前为止,我们已经学会了以各种方式定义单个字符。在这一点上,我们将利用量词——定义字符、元字符或字符集如何重复的机制。
例如,如果我们定义\d
可以重复多次,我们可以轻松地为购物车的商品数量
字段创建一个表单验证器(记住\d
匹配任何十进制数字)。但让我们从头开始,三种基本的量词:问号?
,加号+
和星号*
。
符号 | 名称 | 前一个字符的量化 |
---|
|
?
问号 | 可选的(0 次或 1 次重复) |
---|
|
*
星号 | 零次或多次 |
---|
|
+
加号 | 一次或多次 |
---|
|
{n,m}
大括号 | 重复n到m次 |
---|
在前面的表中,我们可以找到三种基本的量词,每种都有特定的用途。问号可以用来匹配单词car
及其复数形式cars
:
/cars?/
元素 | 描述 |
---|
|
car
匹配字符c ,a ,r 和s |
---|
|
s?
可选地匹配字符s |
---|
注意
在前面的例子中,问号只应用于字符s
,而不是整个单词。量词总是只应用于前一个标记。
使用问号量词的另一个有趣的例子是匹配电话号码,可以是555-555-555
,555 555 555
或555555555
的格式。
我们现在知道如何利用字符集来接受不同的字符,但是是否可以将量词应用于字符集?是的,量词可以应用于字符、字符集,甚至是组(我们将在第三章中介绍分组)。我们可以构建一个这样的正则表达式来验证电话号码:
/\d+[-\s]?\d+[-\s]?\d+/
在下表中,我们可以找到对前面的正则表达式的详细解释:
元素 | 类型 | 描述 |
---|
|
\d
预定义字符集 | 任何十进制字符 |
---|
|
+
量词 | - 重复一次或多次 |
---|
|
[-\s]
字符集 | 连字符或空格字符 |
---|
|
?
量词 | - 可能出现也可能不出现 |
---|
|
\d
预定义字符集 | 任何十进制字符 |
---|
|
+
量词 | - 重复一次或多次 |
---|
|
[-\s]
字符集 | 连字符或空格字符 |
---|
|
\d
预定义字符集 | 任何十进制字符 |
---|
|
+
量词 | - 重复一次或多次 |
---|
在本节的开头,提到了使用大括号的一种量词。使用这种语法,我们可以通过在其后附加{3}
来定义前一个字符必须出现三次,也就是说,表达式\w{8}
指定了确切的八个字母数字。
我们还可以通过提供重复的最小和最大次数来定义一定范围的重复,即,可以使用语法{4,7}
来定义三到八次之间的重复。最小值或最大值可以省略,默认为0
和无限。要指定最多重复三次,我们可以使用{,3}
,我们也可以使用{3,}
来至少重复三次。
提示
可读性提示
不要使用{,1}
,你可以使用问号。对星号*
使用{0,}
,对加号+
使用{1,}
也是一样的。
其他开发人员会期望你这样做。如果你不遵循这个做法,任何阅读你的表达式的人都会花费一些时间来弄清楚你试图完成的是什么样的花哨东西。
这四种不同的组合显示在下表中:
语法 | 描述 |
---|
|
{n}
前一个字符恰好重复n次。 |
---|
|
{n,}
前一个字符至少重复n次。 |
---|
|
{,n}
前一个字符最多重复n次。 |
---|
|
{n,m}
前一个字符重复n到m次(包括n和m)。 |
---|
在本章的前面,我们创建了一个正则表达式来验证电话号码,可以是555-555-555
,555 555 555
或555555555
的格式。我们使用元字符加号来定义验证它的正则表达式:/\d+[-\s]?\d+[-\s]?\d+/
。它将要求数字(\d
)重复一次或多次。
通过定义左侧数字组最多可以包含三个字符,同时其余的数字组应该包含恰好三个数字,来微调正则表达式:
使用量词
贪婪和勉强量词
我们仍然没有定义如果我们将这样的量词/" .+"/
应用到以下文本中会匹配什么:English "Hello", Spanish "Hola"
。我们可能期望它匹配"Hello"和"Hola"
,但实际上它将匹配"Hello", Spanish "Hola"
。
这种行为被称为贪婪,是 Python 中量词的两种可能行为之一:贪婪和非贪婪(也称为勉强)。
-
量词的贪婪行为是默认应用的。贪婪量词将尝试尽可能多地匹配,以获得最大的匹配结果。
-
非贪婪行为可以通过在量词后添加一个额外的问号来请求;例如,
??
,*?
或+?
。标记为勉强的量词将表现得像贪婪量词的完全相反。它们会尝试获得尽可能小的匹配。
注意
占有量词
量词还有第三种行为,即占有行为。这种行为目前只受 Java 和.NET 正则表达式的支持。
它们用额外的加号符号表示量词;例如,?+
,*+
或++
。这本书不会进一步涵盖占有量词。
通过查看下图,我们可以更好地理解这个量词是如何工作的。我们将几乎相同的正则表达式(除了将量词保持为贪婪或标记为勉强)应用到相同的文本上,得到两个非常不同的结果:
贪婪和勉强量词
边界匹配器
到目前为止,我们只是试图在文本中找到正则表达式。有时,当需要匹配整行时,我们可能还需要在行的开头或结尾匹配。这可以通过边界匹配器来实现。
边界匹配器是一些标识符,它们对应于输入中的特定位置。下表显示了 Python 中可用的边界匹配器:
匹配器 | 描述 |
---|
|
^
匹配行的开头 |
---|
|
$
匹配行的结尾 |
---|
|
\b
匹配单词边界 |
---|
|
\B
匹配\b 的相反。任何不是单词边界的东西 |
---|
|
\A
匹配输入的开头 |
---|
|
\Z
匹配输入的结尾 |
---|
这些边界匹配器在不同的上下文中会有不同的行为。例如,单词边界(\b
)将直接取决于配置的区域设置,因为不同的语言可能有不同的单词边界,而行的开头和结尾边界将根据我们将在下一章中学习的某些标志而有不同的行为。
让我们通过编写一个正则表达式来开始使用边界匹配器,该正则表达式将匹配以“Name:”开头的行。如果您看一下前面的表格,您可能会注意到存在元字符^
,表示行的开头。使用它,我们可以编写以下表达式:
/^Name:/
元素 | 描述 |
---|
|
^
匹配行的开头 |
---|
|
N
匹配后面的字符N |
---|
|
a
匹配后面的字符a |
---|
|
m
匹配后面的字符m |
---|
|
e
匹配后面的字符e |
---|
|
:
匹配后面的冒号符号 |
---|
如果我们想要更进一步,继续使用插入符和美元符号的组合来匹配行尾,我们应该考虑从现在开始我们将匹配整行,而不仅仅是在行内寻找模式。
在前面的例子中,假设我们想要确保在名字后面,直到行尾只有字母字符或空格。我们将通过匹配整行直到末尾来实现这一点,通过设置一个包含接受字符的字符集,并允许它们重复任意次数直到行尾。
/^Name:[\sa-zA-Z]+$/
元素 | 描述 |
---|
|
^
匹配行的开头。 |
---|
|
N
匹配后面的字符N 。 |
---|
|
a
匹配后面的字符a 。 |
---|
|
m
匹配后面的字符m 。 |
---|
|
e
匹配后面的字符e 。 |
---|
|
:
匹配后面的冒号符号。 |
---|
|
[\sa-zA-Z]
然后匹配后面的空格,或任何小写或大写字母字符。 |
---|
|
+
该字符可以重复一次或多次。 |
---|
|
$
直到行尾。 |
---|
另一个出色的边界匹配器是词边界\b
。它将匹配任何不是单词字符(在配置的语言环境中)的字符,因此,任何潜在的词边界。当我们想要处理孤立的单词,而不想用每个可能分隔我们的单词的字符集(空格、逗号、冒号、连字符等)时,这非常有用。例如,我们可以通过使用以下正则表达式来确保文本中出现单词hello
:
/\bhello\b/
元素 | 描述 |
---|
|
\b
匹配一个词边界。 |
---|
|
h
匹配后面的字符h 。 |
---|
|
e
匹配后面的字符e 。 |
---|
|
l
匹配后面的字符l 。 |
---|
|
l
匹配后面的字符l 。 |
---|
|
o
匹配后面的字符o 。 |
---|
|
\b
然后匹配另一个后面的词边界。 |
---|
作为练习,我们可以思考为什么前面的表达式比/hello/
更好。原因是这个表达式将匹配一个孤立的单词,而不是包含hello
的单词,也就是说,/hello/
很容易匹配hello
,helloed
或Othello
;而/\bhello\b/
只会匹配hello
。
总结
在这第一章中,我们已经学习了正则表达式的重要性,以及它们如何成为程序员如此重要的工具。
我们还从一个非实际的角度学习了基本的正则表达式语法和一些关键特性,比如字符类和量词。
在下一章中,我们将转到 Python 开始使用re
模块进行练习。
第二章:Python 正则表达式
在上一章中,我们已经看到了通用正则表达式的工作原理。在本章中,我们将带您了解 Python 提供给我们的所有操作来处理正则表达式以及 Python 如何处理它们。
为此,我们将看到处理正则表达式时语言的怪癖,不同类型的字符串,通过RegexObject
和MatchObject
类提供的 API,我们可以深入地了解它们的所有操作,并提供许多示例,以及一些通常由用户面临的问题。最后,我们将看到 Python 和其他正则表达式引擎以及 Python 2 和 Python 3 之间的小细微差别。
简要介绍
自 v1.5 以来,Python 提供了一种类似 Perl 的正则表达式,其中有一些微妙的例外情况,我们稍后会看到。要搜索的模式和字符串都可以是Unicode字符串,也可以是 8 位字符串(ASCII)。
提示
Unicode 是一种通用编码,有超过 110,000 个字符和 100 种文字,可以表示世界上所有的活字和历史文字。您可以将它视为数字之间的映射,或者称为代码点,和字符。因此,我们可以用一个单一的数字表示每个字符,无论是什么语言。例如,字符是数字 26159,它在 Python 中表示为\u662f(十六进制)。
正则表达式由re
模块支持。因此,与 Python 中的所有模块一样,我们只需要导入它就可以开始使用它们。为此,我们需要使用以下代码行启动 Python 交互式 shell:
>>> import re
一旦我们导入了模块,我们就可以开始尝试匹配模式。为此,我们需要编译一个模式,将其转换为字节码,如下面的代码行所示。这个字节码稍后将由用 C 编写的引擎执行。
>>> pattern = re.compile(r'\bfoo\b')
提示
字节码是一种中间语言。它是由语言生成的输出,稍后将由解释器解释。由 JVM 解释的 Java 字节码可能是最著名的例子。
一旦我们有了编译后的模式,我们可以尝试将其与字符串匹配,就像以下代码中所示的那样:
>>> pattern.match("foo bar")
<_sre.SRE_Match at 0x108acac60>
正如我们在前面的例子中提到的,我们编译了一个模式,然后搜索这个模式是否与文本foo bar匹配。
在命令行中使用 Python 和正则表达式很容易进行快速测试。您只需要启动 Python 解释器并像之前提到的那样导入re
模块。但是,如果您更喜欢使用 GUI 来测试您的正则表达式,您可以在以下链接下载一个用 Python 编写的 GUI:
svn.python.org/view/*checkout*/python/trunk/Tools/scripts/redemo.py?content-type=text%2Fplain
有许多在线工具,比如pythex.org/
,以及桌面程序,比如我们将在第五章中介绍的 RegexBuddy,正则表达式的性能。
在这一点上,最好使用解释器来熟练掌握它们并获得直接的反馈。
字符串文字中的反斜杠
正则表达式不是 Python 核心语言的一部分。因此,它们没有特殊的语法,因此它们被处理为任何其他字符串。正如我们在第一章中看到的,介绍正则表达式,反斜杠字符\
用于指示正则表达式中的元字符或特殊形式。反斜杠也用于字符串中转义特殊字符。换句话说,在 Python 中它有特殊含义。因此,如果我们需要使用\
字符,我们将不得不对其进行转义:\\
。这将给反斜杠赋予字符串字面意义。然而,为了在正则表达式中匹配,我们应该转义反斜杠,实际上写四个反斜杠:\\\\
。
举个例子,让我们写一个正则表达式来匹配\
:
>>> pattern = re.compile("\\\\")
>>> pattern.match("\\author")
<_sre.SRE_Match at 0x104a88e68>
正如你所看到的,当模式很长时,这是繁琐且难以理解的。
Python 提供了原始字符串表示法 r
,其中反斜杠被视为普通字符。因此,r"\b"
不再是退格键;它只是字符\
和字符b
,对于r"\n"
也是一样。
Python 2.x 和 Python 3.x 对字符串的处理方式不同。在 Python 2 中,有两种类型的字符串,8 位字符串和 Unicode 字符串;而在 Python 3 中,我们有文本和二进制数据。文本始终是 Unicode,并且编码后的 Unicode 表示为二进制数据(docs.python.org/3.0/whatsnew/3.0.html#text-vs-data-instead-of-unicode-vs-8-bit
)。
字符串有特殊的表示法来指示我们使用的类型。
字符串 Python 2.x
类型 | 前缀 | 描述 |
---|
| 字符串 | | 字符串字面值。它们通过使用默认编码(在我们的情况下是 UTF-8)进行自动编码。反斜杠是必要的,以转义有意义的字符。
>>>"España \n"
'Espa\xc3\xb1a \n'
|
| 原始字符串 | r
或 R
| 它们与字面字符串相同,除了反斜杠被视为普通字符。
>>>r"España \n"
'Espa\xc3\xb1a \\n'
|
Unicode 字符串 | u
或 U
| 这些字符串使用 Unicode 字符集(ISO 10646)。
>>>u"España \n"
u'Espa\xf1a \n'
|
Unicode 原始字符串 | ur
或 UR
| 它们是 Unicode 字符串,但将反斜杠视为普通的原始字符串。
>>>ur"España \n"
u'Espa\xf1a \\n'
|
转到Python 3 中的新内容部分,了解 Python 3 中的表示法是什么
根据 Python 官方文档,使用原始字符串是推荐的选项,这也是我们在整本书中将要使用的 Python 2.7。因此,考虑到这一点,我们可以将正则表达式重写如下:
>>> pattern = re.compile(r"\\")
>>> pattern.match(r"\author")
<_sre.SRE_Match at 0x104a88f38>
Python 正则表达式的构建块
在 Python 中,有两种不同的对象处理正则表达式:
-
RegexObject
:它也被称为Pattern Object。它表示编译后的正则表达式 -
MatchObject
:它表示匹配的模式
RegexObject
为了开始匹配模式,我们将不得不编译正则表达式。Python 给了我们一个接口来做到这一点,就像我们之前看到的那样。结果将是一个模式对象或RegexObject
。这个对象有几种用于正则表达式的典型操作的方法。正如我们将在后面看到的,re
模块提供了每个操作的简写,以便我们可以避免首先编译它。
>>> pattern = re.compile(r'fo+')
正则表达式的编译产生一个可重用的模式对象,提供了所有可以进行的操作,比如匹配模式和找到所有匹配特定正则表达式的子字符串。因此,例如,如果我们想知道一个字符串是否以<HTML>
开头,我们可以使用以下代码:
>>> pattern = re.compile(r'<HTML>')
>>> pattern.match("<HTML>")
<_sre.SRE_Match at 0x108076578>
有两种匹配模式和执行与正则表达式相关的操作的方法。我们可以编译一个模式,这给了我们一个RegexObject
,或者我们可以使用模块操作。让我们在以下示例中比较这两种不同的机制。
如果我们想要重复使用正则表达式,我们可以使用以下代码:
>>> pattern = re.compile(r'<HTML>')
>>> pattern.match("<HTML>")
另一方面,我们可以直接在模块上执行操作,使用以下代码行:
>>> re.match(r'<HTML>', "<HTML>")
re
模块为RegexObject
中的每个操作提供了一个包装器。您可以将它们视为快捷方式。
在内部,这些包装器创建了RegexObject
,然后调用相应的方法。您可能想知道每次调用这些包装器时是否都会先编译正则表达式。答案是否定的。re
模块会缓存已编译的模式,以便在将来的调用中不必再次编译它。
注意您的程序的内存需求。当您使用模块操作时,您无法控制缓存,因此可能会导致大量内存使用。您可以随时使用re.purge
来清除缓存,但这会影响性能。使用编译后的模式允许您对内存消耗进行精细控制,因为您可以决定何时清除它们。
这两种方式之间有一些区别。使用RegexObject
,可以限制模式将在其中搜索的区域,例如限制在索引 2 和 20 之间的模式搜索。除此之外,您可以通过在模块中使用操作来在每次调用中设置flags
。但是要小心;每次更改标志时,都会编译并缓存一个新模式。
让我们深入了解可以使用模式对象执行的最重要操作。
搜索
让我们看看我们必须在字符串中查找模式的操作。请注意,Python 有两种操作,match 和 search;而许多其他语言只有一种操作,match。
match(string[, pos[, endpos]])
这种方法尝试仅在字符串的开头匹配编译后的模式。如果匹配成功,则返回一个MatchObject
。因此,例如,让我们尝试匹配一个字符串是否以<HTML>
开头:
>>> pattern = re.compile(r'<HTML>')
>>> pattern.match("<HTML><head>")
<_sre.SRE_Match at 0x108076578>
在上面的示例中,首先我们编译了模式,然后在<HTML><head>
字符串中找到了一个匹配。
让我们看看当字符串不以<HTML>
开头时会发生什么,如下面的代码行所示:
>>> pattern.match("**⇢**<HTML>")
None
如您所见,没有匹配。请记住我们之前说过的,match
尝试在字符串的开头进行匹配。字符串以空格开头,与模式不同。请注意与以下示例中的search
的区别:
>>> pattern.search("⇢<HTML>")
<_sre.SRE_Match at 0x108076578>
正如预期的那样,我们有一个匹配。
可选的pos参数指定从哪里开始搜索,如下面的代码所示:
>>> pattern = re.compile(r'<HTML>')
>>> pattern.match("⇢ ⇢ <HTML>")
None
>>> pattern.match("**⇢ ⇢ **<HTML>", 2)
**<_sre.SRE_Match at 0x1043bc850>
在上面的代码中,我们可以看到即使字符串中有两个空格,模式也能匹配。这是可能的,因为我们将pos设置为2
,所以匹配操作从该位置开始搜索。
请注意,pos大于 0 并不意味着字符串从该索引开始,例如:
>>> pattern = re.compile(**r'^<HTML>'**)
>>> pattern.match("<HTML>")
<_sre.SRE_Match at 0x1043bc8b8>
>>> pattern.match("⇢ ⇢ <HTML>", 2)
None
在上面的代码中,我们创建了一个模式,用于匹配字符串,其中“start”后的第一个字符后面跟着<HTML>
。然后,我们尝试从第二个字符<
开始匹配字符串<HTML>
。由于模式试图首先在位置2
匹配^
元字符,因此没有匹配。
提示
锚字符提示
字符^
和$
分别表示字符串的开头和结尾。您既看不到它们在字符串中,也不能写它们,但它们总是存在的,并且是正则表达式引擎的有效字符。
请注意,如果我们将字符串切片 2 个位置,结果会有所不同,如下面的代码所示:
>>> pattern.match("⇢ ⇢ <HTML>"[2:])
<_sre.SRE_Match at 0x1043bca58>
切片给我们一个新的字符串;因此,它里面有一个^
元字符。相反,pos只是将索引移动到字符串中搜索的起始点。
第二个参数endpos设置模式在字符串中尝试匹配的距离。在下面的情况中,它相当于切片:
>>> pattern = re.compile(r'<HTML>')
>>> pattern.match("<HTML>"[:2])
None
>>> pattern.match("<HTML>", 0, 2)
None
因此,在下面的情况中,我们不会遇到pos中提到的问题。即使使用了$
元字符,也会有匹配:
>>> pattern = re.compile(r'<HTML>$')
>>> pattern.match("<HTML>⇢", 0,6)
<_sre.SRE_Match object at 0x1007033d8>
>>> pattern.match("<HTML>⇢"[:6])
<_sre.SRE_Match object at 0x100703370>
如您所见,切片和endpos之间没有区别。
search(string[, pos[, endpos]])
这个操作就像许多语言中的match,例如 Perl。它尝试在字符串的任何位置匹配模式,而不仅仅是在开头。如果有匹配,它会返回一个MatchObject
。
>>> pattern = re.compile(r"world")
>>> pattern.search("hello⇢world")
<_sre.SRE_Match at 0x1080901d0>
>>> pattern.search("hola⇢mundo ")
None
pos和endpos参数的含义与match
操作中的相同。
请注意,使用MULTILINE
标志,^
符号在字符串的开头和每行的开头匹配(我们稍后会更多地了解这个标志)。因此,它改变了search
的行为。
在下面的例子中,第一个search
匹配<HTML>
,因为它在字符串的开头,但第二个search
不匹配,因为字符串以空格开头。最后,在第三个search
中,我们有一个匹配,因为我们在新行后找到了<HTML>
,这要归功于re.MULTILINE
。
>>> pattern = re.compile(r'^<HTML>', re.MULTILINE)
>>> pattern.search("<HTML>")
<_sre.SRE_Match at 0x1043d3100>
>>> pattern.search("⇢<HTML>")
None
>>> pattern.search("**⇢ ⇢**\n<HTML>")
<_sre.SRE_Match at 0x1043bce68>
因此,只要pos参数小于或等于新行,就会有一个匹配。
>>> pattern.search("⇢ ⇢\n<HTML>", 3)
<_sre.SRE_Match at 0x1043bced0>
>>> pattern.search('</div></body>\n<HTML>', 4)
<_sre.SRE_Match at 0x1036d77e8>
>>> pattern.search("** **\n<HTML>", 4)
None
findall(string[, pos[, endpos]])
以前的操作一次只能匹配一个。相反,在这种情况下,它返回一个列表,其中包含模式的所有不重叠的出现,而不是像search
和match
那样返回MatchObject
。
在下面的例子中,我们正在寻找字符串中的每个单词。因此,我们得到一个列表,其中每个项目都是找到的模式,这里是一个单词。
>>> pattern = re.compile(r"\w+")
>>> pattern.findall("hello⇢world")
['hello', 'world']
请记住,空匹配是结果的一部分:
>>> pattern = re.compile(r'a*')
>>> pattern.findall("aba")
['a', '', 'a', '']
我敢打赌你想知道这里发生了什么?这个技巧来自*
量词,它允许前面的正则表达式重复 0 次或更多次;与?
量词发生的情况相同。
>>> pattern = re.compile(r'a?')
>>> pattern.findall("aba")
['a', '', 'a', '']
基本上,它们两个都匹配表达式,即使前面的正则表达式没有找到:
findall 匹配过程
首先,正则表达式匹配字符a
,然后跟着b
。由于*
量词,空字符串也会匹配。之后,它匹配另一个a
,最后尝试匹配$
。正如我们之前提到的,即使你看不到$
,它对于正则表达式引擎来说也是一个有效的字符。就像b
一样,由于*
量词,它会匹配。
我们在第一章介绍正则表达式中深入了解了量词。
如果模式中有组,它们将作为元组返回。字符串从左到右扫描,因此组将按照它们被找到的顺序返回。
以下示例尝试匹配由两个单词组成的模式,并为每个单词创建一个组。这就是为什么我们有一个元组列表,其中每个元组有两个组。
>>> pattern = re.compile(r"(\w+) (\w+)")
>>> pattern.findall("Hello⇢world⇢hola⇢mundo")
[('Hello', 'world'), ('hola', 'mundo')]
findall
操作以及groups
似乎是许多人困惑的另一件事情。在第三章分组中,我们专门有一个完整的部分来解释这个复杂的主题。
finditer(string[, pos[, endpos]])
它的工作原理与findall
基本相同,但它返回一个迭代器,其中每个元素都是一个MatchObject
,因此我们可以使用这个对象提供的操作。因此,当您需要每个匹配的信息时,例如匹配子字符串的位置时,它非常有用。有好几次,我发现自己使用它来理解findall
中发生了什么。
让我们回到我们最初的一个例子。匹配每两个单词并捕获它们:
>>> pattern = re.compile(r"(\w+) (\w+)")
>>> it = pattern.finditer("Hello⇢world⇢hola⇢mundo")
>>> match = it.next()
>>> match.groups()
('Hello', 'world')
>>> match.span()
(0, 11)
在前面的例子中,我们可以看到我们得到了一个包含所有匹配的迭代器。对于迭代器中的每个元素,我们得到一个MatchObject
,因此我们可以看到模式中捕获的组,在这种情况下是两个。我们还将得到匹配的位置。
>>> match = it.next()
>>> match.groups()
('hola', 'mundo')
>>> match.span()
(12, 22)
现在,我们从迭代器中消耗另一个元素,并执行与之前相同的操作。因此,我们得到下一个匹配,它的组和匹配的位置。我们与第一个匹配所做的事情一样:
>>> match = it.next()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
StopIteration
最后,我们尝试消耗另一个匹配,但在这种情况下会抛出StopIteration
异常。这是指示没有更多元素的正常行为。
修改字符串
在本节中,我们将看到修改字符串的操作,比如将字符串分割的操作和替换其中某些部分的操作。
split(string, maxsplit=0)
在几乎每种语言中,你都可以在字符串中找到split
操作。最大的区别在于re
模块中的split
更加强大,因为你可以使用正则表达式。因此,在这种情况下,字符串是基于模式的匹配进行分割的。和往常一样,最好的理解方法是通过一个例子,所以让我们将一个字符串分割成行:
>>> re.split(r"\n", "Beautiful⇢is better⇢than⇢ugly.\nExplicit⇢is⇢better⇢than⇢implicit.")
['Beautiful⇢is⇢better⇢than⇢ugly.', 'Explicit⇢is⇢better⇢than⇢implicit.']
在前面的例子中,匹配是\n
;因此,字符串是使用它作为分隔符进行分割的。让我们看一个更复杂的例子,如何获取字符串中的单词:
>>> pattern = re.compile(**r"\W")
>>> pattern.split("hello⇢world")
['Hello', 'world']
在前面的例子中,我们定义了一个匹配任何非字母数字字符的模式。因此,在这种情况下,匹配发生在空格中。这就是为什么字符串被分割成单词。让我们看另一个例子来更好地理解它:
>>> pattern = re.compile(**r"\W")
>>> pattern.findall("hello⇢world")
['⇢']
请注意,匹配的是空格。
maxsplit参数指定最多可以进行多少次分割,并将剩余部分返回为结果:
>>> pattern = re.compile(r"\W")
>>> pattern.split("Beautiful is better than ugly", 2)
['Beautiful', 'is', 'better than ugly']
正如你所看到的,只有两个单词被分割,其他单词是结果的一部分。
你是否意识到匹配的模式没有被包括?看一下本节中的每个例子。如果我们想要捕获模式,我们该怎么办?
答案是使用组:
>>> pattern = re.compile(r"(-)")
>>> pattern.split("hello-word")
['hello', '-', 'word']
这是因为分割操作总是返回捕获的组。
请注意,当一个组匹配字符串的开头时,结果将包含空字符串作为第一个结果:
>>> pattern = re.compile(r"(\W)")
>>> pattern.split("⇢hello⇢word")
['', '⇢', 'hello', '⇢', 'word']
sub(repl, string, count=0)
此操作返回原始字符串中替换匹配模式后的结果字符串。如果未找到模式,则返回原始字符串。例如,我们将用-
(破折号)替换字符串中的数字:
>>> pattern = re.compile(r"[0-9]+")
>>> pattern.sub("-", "order0⇢order1⇢order13")
'order-⇢order-⇢order-'
基本上,正则表达式匹配 1 个或多个数字,并用匹配的模式0
、1
和13
替换为-
(破折号)。
请注意,它替换了模式的最左边的非重叠出现。让我们看另一个例子:
>>> re.sub('00', '-', 'order00000')
'order--0'
在前面的例子中,我们是两两替换零。因此,首先匹配并替换前两个,然后接下来的两个零也被匹配并替换,最后剩下最后一个零。
repl
参数也可以是一个函数,这种情况下,它接收一个 MatchObject 作为参数,并返回的字符串是替换后的结果。例如,想象一下你有一个旧系统,其中有两种类型的订单。一些以破折号开头,另一些以字母开头:
-
-1234
-
A193, B123, C124
你必须将其更改为以下内容:
-
A1234
-
B193, B123, B124
简而言之,以破折号开头的应该以 A 开头,其余的应该以 B 开头。
>>>def normalize_orders(matchobj):
if matchobj.group(1) == '-': return "A"
else: return "B"
>>> re.sub('([-|A-Z])', normalize_orders, '-1234⇢A193⇢ B123')
'A1234⇢B193⇢B123'
如前所述,对于每个匹配的模式,都会调用normalize_orders
函数。因此,如果第一个匹配的组是–
,那么我们返回A
;在任何其他情况下,我们返回B
。
请注意,在代码中,我们使用索引 1 获取第一个组;看一下group
操作,以了解原因。
反向引用,也是sub
提供的一个强大功能。我们将在下一章中深入了解它们。基本上,它的作用是用相应的组替换反向引用。例如,假设你想要将 markdown 转换为 HTML,为了简化示例,只需将文本加粗:
>>> text = "imagine⇢a⇢new⇢*world*,⇢a⇢magic⇢*world*"
>>> pattern = re.compile(r'\*(.*?)\*')
>>> pattern.sub(r"<b>\g<1><\\b>", text)
'imagine⇢a⇢new⇢<b>world<\\b>,⇢a⇢magic⇢<b>world<\\b>'
和往常一样,前面的例子首先编译了模式,它匹配两个*
之间的每个单词,并且捕获了这个单词。请注意,由于?
元字符,模式是非贪婪的。
请注意,\g<number>
是为了避免与字面数字产生歧义,例如,想象一下,你需要在一个组后面添加"1":
>>> pattern = re.compile(r'\*(.*?)\*')
>>> pattern.sub(r"<b>\g<1>1<\\b>", text)
'imagine⇢a⇢new⇢<b>world1<\\b>,⇢a⇢magic⇢<b>world1<\\b>'
正如你所看到的,行为是符合预期的。让我们看看在使用没有<
和>
的符号时会发生什么:
>>> text = "imagine⇢a⇢new⇢*world*,⇢a⇢magic⇢*world*"
>>> pattern = re.compile(r'\*(.*?)\*')
>>> pattern.sub(r"<b>**\g1
1**<\\b>", text)
error: bad group name
在前面的示例中,突出显示了该组以消除歧义并帮助我们看到它,这正是正则表达式引擎所面临的问题。在这里,正则表达式引擎尝试使用不存在的第 11 组。因此,有\g<group>
表示法。
sub
的另一点需要记住的是,替换字符串中的每个反斜杠都将被处理。正如你在<\\b>
中看到的,如果你想避免它,你需要对它们进行转义。
您可以使用可选的count参数限制替换的次数。
subn(repl, string, count=0)
它基本上与sub
相同,你可以将它视为sub
的一个实用程序。它返回一个包含新字符串和替换次数的元组。让我们通过使用与之前相同的示例来看一下它的工作:
>>> text = "imagine⇢a⇢new⇢*world*,⇢a⇢magic⇢*world*"
>>> pattern = re.compile(r'\*(.*?)\*')
>>> pattern.subn(r"<b>\g<1><\\b>", text)
('imagine⇢a⇢new⇢<b>world<\\b>,⇢a⇢magic⇢<b>world<\\b>', 2)
这是一个很长的部分。我们探讨了我们可以使用re
模块和RegexObject
类进行的主要操作以及示例。让我们继续讨论匹配后得到的对象。
MatchObject
这个对象代表了匹配的模式;每次执行这些操作时都会得到一个:
-
match
-
search
-
finditer
这个对象为我们提供了一组操作,用于处理捕获的组,获取有关匹配位置的信息等。让我们看看最重要的操作。
group([group1, …])
group
操作给出了匹配的子组。如果没有参数或零调用它,它将返回整个匹配;而如果传递一个或多个组标识符,则将返回相应组的匹配。
让我们用一个例子来看看:
>>> pattern = re.compile(r"(\w+) (\w+)")
>>> match = pattern.search("Hello⇢world")
模式匹配整个字符串并捕获两个组,Hello
和world
。一旦我们有了匹配,我们可以看到以下具体情况:
- 没有参数或零,它返回整个匹配。
>>> match.group()
'Hello⇢world'
>>> match.group(0)
'Hello⇢world'
- 使用
group1
大于 0,它返回相应的组。
>>> match.group(1)
'Hello'
>>> match.group(2)
'world'
- 如果该组不存在,将抛出
IndexError
。
>>> match.group(3)
…
IndexError: no such group
- 使用多个参数,它返回相应的组。
>>> match.group(0, 2)
('Hello⇢world', 'world')
在这种情况下,我们想要整个模式和第二组,这就是为什么我们传递0
和2
。
组可以被命名,我们将在下一章中深入讨论;有一个特殊的表示方法。如果模式有命名组,可以使用名称或索引来访问它们:
>>> pattern = re.compile(r"(?P<first>\w+) (?P<second>\w+)")
在前面的示例中,我们编译了一个模式来捕获两个组:第一个命名为first
,第二个命名为second
。
>>> match = pattern.search("Hello⇢world")
>>> match.group('first')
'Hello'
通过这种方式,我们可以通过名称获取组。请注意,使用命名组,我们仍然可以通过它们的索引获取组,就像下面的代码中所示:
>>> match.group(1)
'Hello'
我们甚至可以同时使用两种类型:
>>> match.group(0, 'first', 2)
('Hello⇢world', 'Hello', 'world')
groups([default])
groups
操作类似于前面的操作。但是,在这种情况下,它返回一个包含匹配中所有子组的元组,而不是给出一个或一些组。让我们用前一节中使用的例子来看一下:
>>> pattern = re.compile("(\w+) (\w+)")
>>> match = pattern.search("Hello⇢World")
>>> match.groups()
('Hello', 'World')
就像我们在前一节中看到的那样,我们有两个组Hello
和World
,这正是groups
给我们的。在这种情况下,您可以将groups
视为group(1, lastGroup)
。
如果有不匹配的组,将返回默认参数。如果未指定默认参数,则使用None
,例如:
>>> pattern = re.compile("(\w+) (\w+)**?**")
>>> match = pattern.search("Hello⇢")
>>> match.groups("**mundo**")
('Hello', 'mundo')
>>> match.groups()
('Hello', **None**)
前面的示例中的模式试图匹配由一个或多个字母数字字符组成的两个组。第二个是可选的;所以我们只得到一个包含字符串Hello
的组。在获得匹配后,我们调用groups
,将default
设置为mundo
,这样它就返回mundo
作为第二组。请注意,在下面的调用中,我们没有设置默认值,因此返回None
。
groupdict([default])
groupdict
方法用于已使用命名组的情况。它将返回一个包含所有找到的组的字典:
>>> pattern = re.compile(r"(?P<first>\w+) (?P<second>\w+)")
>>> pattern.search("Hello⇢world").groupdict()
{'first': 'Hello', 'second': 'world'}
在前面的示例中,我们使用了与前几节中看到的类似的模式。它使用名称为first
和second
的两个组进行捕获。因此,groupdict
以字典形式返回它们。请注意,如果没有命名组,则它将返回一个空字典。
如果您不太明白这里发生了什么,不要担心。正如我们之前提到的,我们将在第三章中看到与分组相关的所有内容,分组。
start([组])
有时,知道模式匹配的索引位置是有用的。与所有与组相关的操作一样,如果参数组为零,则该操作将使用匹配的整个字符串:
>>> pattern = re.compile(r"(?P<first>\w+) (?P<second>\w+)?")
>>> match = pattern.search("Hello⇢")
>>> match.start(1)
0
如果有不匹配的组,则返回-1
:
>>> math = pattern.search("Hello⇢")
>>> match..start(2)
-1
end([组])
end
操作的行为与start
完全相同,只是它返回与组匹配的子字符串的结尾:
>>> pattern = re.compile(r"(?P<first>\w+) (?P<second>\w+)?")
>>> match = pattern.search("Hello⇢")
>>> match.end (1)
5
span([组])
这是一个操作,它给出一个包含start
和end
的元组。这个操作经常用于文本编辑器中来定位和突出显示搜索。以下代码是这个操作的一个示例:
>>> pattern = re.compile(r"(?P<first>\w+) (?P<second>\w+)?")
>>> match = pattern.search("Hello⇢")
>>> match.span(1)
(0, 5)
扩展(模板)
此操作返回替换模板字符串后的字符串。它类似于sub
。
继续上一节的示例:
>>> text = "imagine⇢a⇢new⇢*world*,⇢a⇢magic⇢*world*"
>>> match = re.search(r'\*(.*?)\*', text)
>>> match.expand(r"<b>\g<1><\\b>")
'<b>world<\\b>'
模块操作
让我们看看模块中的两个有用的操作。
转义()
它转义可能出现在表达式中的文字。
>>> re.findall(re.escape("^"), "^like^")
['^', '^']
清除()
它清除正则表达式缓存。我们已经谈论过这一点;当您通过模块使用操作时,您需要使用它以释放内存。请记住,这会影响性能;一旦释放缓存,每个模式都必须重新编译和缓存。
干得好,你已经知道了你可以用re
模块做的主要操作。之后,你可以在项目中开始使用正则表达式而不会遇到太多问题。
现在,我们将看到如何更改模式的默认行为。
编译标志
在将模式字符串编译为模式对象时,可以修改模式的标准行为。为了做到这一点,我们必须使用编译标志。这些可以使用按位或|
组合。
标志 | Python | 描述 |
---|---|---|
re.IGNORECASE 或re.I |
2.x3.x | 该模式将匹配小写和大写。 |
| re.MULTILINE
或re.M
| 2.x3.x | 此标志更改了两个元字符的行为:
-
^
:现在匹配字符串的开头和每一行的开头。 -
$
:在这种情况下,它匹配字符串的结尾和每一行的结尾。具体来说,它匹配换行符之前的位置。
|
re.DOTALL 或re.S |
2.x3.x | 元字符“。”将匹配任何字符,甚至包括换行符。 |
---|---|---|
re.LOCALE 或re.L |
2.x3.x | 此标志使\w、\W、\b、\B、\s 和\S 依赖于当前区域设置。“re.LOCALE 只是将字符传递给底层的 C 库。它实际上只适用于每个字符有 1 个字节的字节串。UTF-8 将 ASCII 范围之外的码点编码为每个码点多个字节,re 模块将把这些字节中的每一个都视为单独的字符。”(在www.gossamer-threads.com/lists/python/python/850772 )请注意,当使用re.L 和re.U 一起时(re.L|re.U,只使用区域设置)。另请注意,在 Python 3 中,不鼓励使用此标志;请查看文档以获取更多信息。 |
| re.VERBOSE
或re.X
| 2.x3.x | 它允许编写更易于阅读和理解的正则表达式。为此,它以一种特殊的方式处理一些字符:
-
忽略空格,除非它在字符类中或者在反斜杠之前
-
右侧的所有字符都被忽略,就像是注释一样,除非#之前有反斜杠或者它在字符类中。
|
re.DEBUG |
2.x3.x | 它为您提供有关编译模式的信息。 |
---|---|---|
re.UNICODE 或re.U |
2.x | 它使\w、\W、\b、\B、\d、\D、\s 和\S 依赖于 Unicode 字符属性数据库。 |
re.ASCII 或re.A (仅 Python 3) |
3.x | 它使\w、\W、\b、\B、\d、\D、\s 和\S 执行仅 ASCII 匹配。这是有道理的,因为在 Python 3 中,默认情况下匹配是 Unicode 的。您可以在“Python 3 的新功能”部分中找到更多信息。 |
让我们看一些最重要的标志示例。
re.IGNORECASE 或 re.I
正如您所看到的,以下模式匹配,即使字符串以 A 开头而不是 a 开头。
>>> pattern = re.compile(r"[a-z]+", re.I)
>>> pattern.search("Felix")
<_sre.SRE_Match at 0x10e27a238>
>>> pattern.search("felix")
<_sre.SRE_Match at 0x10e27a510>
re.MULTILINE 或 re.M
在下面的示例中,模式不匹配换行符后的日期,因为我们没有使用标志:
>>> pattern = re.compile("^\w+\: (\w+/\w+/\w+)")
>>> pattern.findall("date: ⇢12/01/2013 \ndate: 11/01/2013")
['12/01/2013']
但是,使用“多行”标志时,它匹配了两个日期:
>>> pattern = re.compile("^\w+\: (\w+/\w+/\w+)", re.M)
>>> pattern.findall("date: ⇢12/01/2013⇢\ndate: ⇢11/01/2013")
['12/01/2013', '12/01/2013']
注意
这不是捕获日期的最佳方法。
re.DOTALL 或 re.S
让我们尝试匹配数字后的任何内容:
>>> re.findall("^\d(.)", "1\ne")
[]
我们可以在前面的例子中看到,具有默认行为的字符类“。”不匹配换行符。让我们看看使用标志会发生什么:
>>> re.findall("^\d(.)", "1\ne", re.S)
['\n']
预期的是,使用DOTALL
标志后,它完美地匹配了换行符。
re.LOCALE 或 re.L
在下面的示例中,我们首先获取了前 256 个字符,然后尝试在字符串中找到每个字母数字字符,因此我们得到了预期的字符,如下所示:
>>> chars = ''.join(chr(i) for i in xrange(256))
>>> " ".join(re.findall(r"\w", chars))
'0 1 2 3 4 5 6 7 8 9 A B C D E F G H I J K L M N O P Q R S T U V W X Y Z _ a b c d e f g h i j k l m n o p q r s t u v w x y z'
在将区域设置为我们的系统区域设置后,我们可以再次尝试获取每个字母数字字符:
>>> locale.setlocale(locale.LC_ALL, '')
'ru_RU.KOI8-R'
在这种情况下,根据新的区域设置,我们得到了更多的字符:
>>> " ".join(re.findall(r"\w", chars, re.LOCALE))
'0 1 2 3 4 5 6 7 8 9 A B C D E F G H I J K L M N O P Q R S T U V W X Y Z _ a b c d e f g h i j k l m n o p q r s t u v w x y z \xa3 \xb3 \xc0 \xc1 \xc2 \xc3 \xc4 \xc5 \xc6 \xc7 \xc8 \xc9 \xca \xcb \xcc \xcd \xce \xcf \xd0 \xd1 \xd2 \xd3 \xd4 \xd5 \xd6 \xd7 \xd8 \xd9 \xda \xdb \xdc \xdd \xde \xdf \xe0 \xe1 \xe2 \xe3 \xe4 \xe5 \xe6 \xe7 \xe8 \xe9 \xea \xeb \xec \xed \xee \xef \xf0 \xf1 \xf2 \xf3 \xf4 \xf5 \xf6 \xf7 \xf8 \xf9 \xfa \xfb \xfc \xfd \xfe \xff'
re.UNICODE 或 re.U
让我们尝试在字符串中找到所有字母数字字符:
>>> re.findall("\w+", "this⇢is⇢an⇢example")
['this', 'is', 'an', 'example']
但是,如果我们想要在其他语言中执行相同的操作会发生什么呢?字母数字字符取决于语言,因此我们需要将其指示给正则表达式引擎:
>>> re.findall(ur"\w+", u"这是一个例子", re.UNICODE)
[u'\u8fd9\u662f\u4e00\u4e2a\u4f8b\u5b50']
>>> re.findall(ur"\w+", u"هذا مثال", re.UNICODE)
[u'\u0647\u0630\u0627', u'\u0645\u062b\u0627\u0644']
re.VERBOSE 或 re.X
在下面的模式中,我们使用了几个⇢;第一个被忽略,因为它不在字符类中,也没有在反斜杠之前,第二个是模式的一部分。我们还使用了#三次,第一个和第三个被忽略,因为它们没有在反斜杠之前,第二个是模式的一部分。
>>> pattern = re.compile(r"""[#|_] + #comment
\ \# #comment
\d+""", re.VERBOSE)
>>> pattern.findall("#⇢#2")
['#⇢#2']
re.DEBUG
>>>re.compile(r"[a-f|3-8]", re.DEBUG)
in
range (97, 102)
literal 124
range (51, 56)
Python 和正则表达式的特殊考虑
在本节中,我们将回顾与其他版本的差异,如何处理 Unicode,以及 Python 2.x 和 Python 3 之间的re
模块的差异。
Python 和其他版本之间的差异
正如我们在本书开头提到的,re 模块具有 Perl 风格的正则表达式。但是,这并不意味着 Python 支持 Perl 引擎具有的每个功能。
有太多的差异无法在这样一本简短的书中涵盖,如果您想深入了解它们,这里有两个很好的起点:
Unicode
当您使用 Python 2.x 并且要匹配 Unicode 时,正则表达式必须是 Unicode 转义。例如:
>>> re.findall(r"\u03a9", u"adeΩa")
[]
>>> re.findall(ur"\u03a9", u"adeΩa")
[u'\u03a9']
请注意,如果您使用 Unicode 字符,但您使用的字符串类型不是 Unicode,则 Python 会自动使用默认编码对其进行编码。例如,在我的情况下,我有 UTF-8:
>>> u"Ω".encode("utf-8")
'\xce\xa9'
>>> "Ω"
'\xce\xa9'
因此,在混合类型时,您必须小心:
>>> re.findall(r'Ω', "adeΩa")
['\xce\xa9']
在这里,您不是匹配 Unicode,而是默认编码中的字符:
>>> re.findall(r'\xce\xa9', "adeΩa")
['\xce\xa9']
因此,如果您在其中任何一个中使用 Unicode,则您的模式将不匹配任何内容:
>>> re.findall(r'Ω', u"adeΩa")
[]
另一方面,您可以在两侧使用 Unicode,并且它将按预期进行匹配:
>>> re.findall(ur'Ω', u"adeΩa")
[u'\u03a9']
re
模块不执行 Unicode 大小写折叠,因此在 Unicode 上不起作用:
>>> re.findall(ur"ñ" ,ur"Ñ", re.I)
[]
Python 3 中的新功能
Python 3 中有一些影响正则表达式行为的变化,并且已经向re
模块添加了新功能。首先,让我们回顾一下字符串表示法如何发生变化。
类型 | 前缀 | 描述 |
---|
| 字符串 | | 它们是字符串文字。它们是 Unicode。反斜杠是必要的,用于转义有意义的字符。
>>>"España \n"
'España \n'
|
| 原始字符串 | r
或 R
| 它们与文字字符串相同,只是反斜杠被视为普通字符。
>>>r"España \n"
'España \\n'
|
| 字节字符串 | b
或 B
| 以字节表示的字符串。它们只能包含 ASCII 字符;如果字节大于 128,必须进行转义。
>>> b"Espa\xc3\xb1a \n"
b'Espa\xc3\xb1a \n'
我们可以这样转换为 Unicode:
>>> str(b"Espa\xc3\xb1a \n", "utf-8")
'España \n'
反斜杠是必要的,用于转义有意义的字符。
| 字节原始字符串 | r
或 R
| 它们类似于字节字符串,但反斜杠被转义。
>>> br"Espa\xc3\xb1a \n"
b'Espa\\xc3\\xb1a \\n'
因此,用于转义字节的反斜杠再次被转义,这使得它们转换为 Unicode 变得更加复杂:
>>> str(br"Espa\xc3\xb1a \n", "utf-8")
'Espa\\xc3\\xb1a \\n'
|
Unicode | r 或 U |
u 前缀在 Python 3 的早期版本中被移除,但在 3.3 版本中又被接受。它们与字符串相同。 |
---|
在 Python 3 中,文字字符串默认为 Unicode,这意味着不再需要使用 Unicode 标志。
>>> re.findall(r"\w+", "这是一个例子")
['这是一个例子']
Python 3.3 (docs.python.org/dev/whatsnew/3.3.html
) 添加了更多与 Unicode 相关的功能以及语言中对其处理的方式。例如,它增加了对完整代码点范围的支持,包括非 BMP (en.wikipedia.org/wiki/Plane_(Unicode)
)。因此,例如:
- 在 Python 2.7 中:
>>> re.findall(r".", u'\U0010FFFF')
[u'\udbff', u'\udfff']
- 在 Python 3.3.2 中:
>>> re.findall(r".", u'\U0010FFFF')
['\U0010ffff']
正如我们在编译标志部分中看到的,已添加了 ASCII 标志。
在使用 Python 3 时需要注意的另一个重要方面与元字符有关。由于字符串默认为 Unicode,元字符也是如此,除非您使用 8 位模式或使用 ASCII 标志。
>>> re.findall(r"\w+", "هذا⇢مثال")
['هذا', 'مثال']
>>> re.findall(r"\w+", "هذا⇢مثال word", re.ASCII)
['word']
在前面的例子中,不是 ASCII 的字符被忽略了。
请注意,Unicode 模式和 8 位模式不能混合使用。
在下面的例子中,我们试图将一个 8 位模式与 Unicode 字符串匹配,这就是为什么会抛出异常(请记住,在 Python 2.x 中可以工作):
>>> re.findall(b"\w+", b"hello⇢world")
[b'hello', b'world']
>>> re.findall(b"\w+", "hello world")
….
TypeError: can't use a bytes pattern on a string-like object
总结
这是一个很长的章节!我们在其中涵盖了很多内容。我们从 Python 中字符串的工作方式及其在 Python 2.x 和 Python 3.x 中的不同表示开始。之后,我们看了如何构建正则表达式,re
模块提供给我们处理它们的对象和接口,以及搜索和修改字符串的最重要操作。我们还学习了如何通过MatchObject
从模式中提取信息,例如匹配的位置或组。我们还学习了如何使用编译标志修改一些字符类和元字符的默认行为。最后,我们看到了如何处理 Unicode 以及在 Python 3.x 中可以找到的新功能。
在本章中,我们看到组是正则表达式的重要部分,re
模块的许多操作都是为了与组一起使用。这就是为什么我们在下一章中深入讨论组。
第三章:分组
分组是一个强大的工具,允许您执行诸如以下操作:
-
创建子表达式以应用量词。例如,重复子表达式而不是单个字符。
-
限制交替的范围。我们可以定义确切需要交替的内容,而不是整个表达式交替。
-
从匹配的模式中提取信息。例如,从订单列表中提取日期。
-
再次在正则表达式中使用提取的信息,这可能是最有用的属性。一个例子是检测重复的单词。
在本章中,我们将探讨分组,从最简单的到最复杂的。我们将回顾一些先前的示例,以便清楚地了解这些操作的工作原理。
介绍
我们已经在第二章 使用 Python 的正则表达式中的几个示例中使用了分组。分组是通过两个元字符()
来完成的。使用括号的最简单示例将构建子表达式。例如,假设您有一个产品列表,每个产品的 ID 由一个数字序列和一个字母数字字符组成,例如 1-a2-b:
>>>re.match(r"(\d-\w){2,3}", ur"1-a2-b")
<_sre.SRE_Match at 0x10f690738>
如您在前面的示例中所见,括号指示正则表达式引擎,其中它们内部的模式必须被视为一个单元。
让我们看另一个例子;在这种情况下,我们需要匹配每当有一个或多个ab
后跟c
时:
>>>re.search(r"(ab)+c", ur"ababc")
<_sre.SRE_Match at 0x10f690a08>
>>>re.search(r"(ab)+c", ur"abbc")
None
因此,您可以在主模式中使用括号来分组有意义的子模式。
它也可以用来限制交替的范围。例如,假设我们想要编写一个表达式来匹配是否有人来自西班牙。在西班牙语中,国家名称是 España,西班牙人是 Español。因此,我们想要匹配 España 和 Español。西班牙字母ñ对于非西班牙人来说可能会令人困惑,因此为了避免混淆,我们将使用 Espana 和 Espanol 代替 España 和 Español。
我们可以通过以下交替实现:
>>>re.search("Espana|ol", "Espanol")
<_sre.SRE_Match at 0x1043cfe68>
>>>re.search("Espana|ol", "Espana")
<_sre.SRE_Match at 0x1043cfed0>
问题是这也匹配了ol
:
>>>re.search("Espana|ol", "ol")
<_sre.SRE_Match at 0x1043cfe00>
因此,让我们尝试字符类,如下面的代码所示:
>>>re.search("Espan[aol]", "Espanol")
<_sre.SRE_Match at 0x1043cf1d0>
>>>re.search("Espan[aol]", "Espana")
<_sre.SRE_Match at 0x1043cf850>
它有效,但这里我们有另一个问题:它还匹配了"Espano"
和"Espanl"
,这在西班牙语中没有任何意义:
>>>re.search("Espan[a|ol]", "Espano")
<_sre.SRE_Match at 0x1043cfb28>
解决方案是使用括号:
>>>re.search("Espan(a|ol)", "Espana")
<_sre.SRE_Match at 0x10439b648>
>>>re.search("Espan(a|ol)", "Espanol")
<_sre.SRE_Match at 0x10439b918>
>>>re.search("Espan(a|ol)", "Espan")
None
>>>re.search("Espan(a|ol)", "Espano")
None
>>>re.search("Espan(a|ol)", "ol")
None
让我们看看分组的另一个关键特性,捕获。组还捕获匹配的模式,因此您可以在以后的几个操作中使用它们,例如sub
或正则表达式本身。
例如,假设您有一个产品列表,其 ID 由代表产品国家的数字、作为分隔符的破折号和一个或多个字母数字字符组成。您被要求提取国家代码:
>>>pattern = re.compile(r"(\d+)-\w+")
>>>it = pattern.finditer(r"1-a\n20-baer\n34-afcr")
>>>match = it.next()
>>>match.group(1)
'1'
>>>match = it.next()
>>>match.group(1)
'20'
>>>match = it.next()
>>>match.group(1)
'34'
在前面的示例中,我们创建了一个模式来匹配 ID,但我们只捕获了由国家数字组成的一个组。请记住,在使用group
方法时,索引 0 返回整个匹配,而组从索引 1 开始。
捕获组由于可以与几个操作一起使用而提供了广泛的可能性,我们将在接下来的部分中讨论它们的使用。
反向引用
正如我们之前提到的,分组给我们提供的最强大的功能之一是可以在正则表达式或其他操作中使用捕获的组。这正是反向引用提供的。为了带来一些清晰度,可能最为人熟知的例子是查找重复单词的正则表达式,如下面的代码所示:
>>>pattern = re.compile(r"(\w+) **\1**")
>>>match = pattern.search(r"hello hello world")
>>>match.groups()
('hello',)
在这里,我们捕获了一个由一个或多个字母数字字符组成的组,然后模式尝试匹配一个空格,最后我们有\1
反向引用。您可以在代码中看到它被突出显示,这意味着它必须与第一个组匹配的内容完全相同。
反向引用可以与前 99 个组一起使用。显然,随着组数的增加,阅读和维护正则表达式的任务会变得更加复杂。这是可以通过命名组来减少的,我们将在下一节中看到它们。但在那之前,我们还有很多关于反向引用的东西要学习。所以,让我们继续进行另一个操作,其中反向引用真的非常方便。回想一下之前的例子,其中我们有一个产品列表。现在,让我们尝试改变 ID 的顺序,这样我们就有了数据库中的 ID,一个破折号和国家代码:
>>>pattern = re.compile(r"(\d+)-(\w+)")
>>>pattern.sub(**r"\2-\1"**, "1-a\n20-baer\n34-afcr")
'a-1\nbaer-20\nafcr-34'
就是这样。很简单,不是吗?请注意,我们还捕获了数据库中的 ID,所以我们以后可以使用它。通过突出显示的代码,我们在说,“用你匹配到的第二组、一个破折号和第一组来替换”。
与之前的例子一样,使用数字可能难以跟踪和维护。因此,让我们看看 Python 通过re
模块提供的帮助。
命名组
还记得上一章中我们通过索引获取组的时候吗?
>>>pattern = re.compile(r"(\w+) (\w+)")
>>>match = pattern.search("Hello⇢world")
>>>match.group(1)
'Hello'
>>>match.group(2)
'world'
我们刚刚学会了如何使用索引访问组来提取信息并将其用作反向引用。使用数字来引用组可能会很繁琐和令人困惑,最糟糕的是它不允许你给组赋予含义或上下文。这就是为什么我们有命名组。
想象一下一个正则表达式,其中有几个反向引用,比如说 10 个,然后你发现第三个是无效的,所以你从正则表达式中删除它。这意味着你必须更改从那个位置开始的每个反向引用的索引。为了解决这个问题,1997 年,Guido Van Rossum 为 Python 1.5 设计了命名组。这个功能被提供给了 Perl 进行交叉传播。
现在,它几乎可以在任何风格中找到。基本上它允许我们给组命名,这样我们可以在任何涉及组的操作中通过它们的名称来引用它们。
为了使用它,我们必须使用(?P<name>pattern)
的语法,其中P
来自于 Python 特定的扩展(正如你可以在 Guido 发送给 Perl 开发人员的电子邮件中所读到的那样markmail.org/message/oyezhwvefvotacc3
)
让我们看看它是如何在以下代码片段中与之前的例子一起工作的:
>>> pattern = re.compile(r"(?P<first>\w+) (?P<second>\w+)")
>>> match = re.search("Hello world")
>>>match.group("first")
'Hello'
>>>match.group("second")
'world'
因此,反向引用现在使用起来更简单,更容易维护,正如下面的例子所示:
>>>pattern = re.compile(r"(?P<country>\d+)-(?P<id>\w+)")
>>>pattern.sub(r"\g<id>-\g<country>", "1-a\n20-baer\n34-afcr")
'a-1\nbaer-20\nafcr-34'
正如我们在前面的例子中看到的,为了在sub
操作中通过名称引用组,我们必须使用`g
我们还可以在模式内部使用命名组,就像下面的例子中所示的那样:
>>>pattern = re.compile(r"(?P<word>\w+) (?P=word)")
>>>match = pattern.search(r"hello hello world")
>>>match.groups()
('hello',)
这比使用数字更简单和更易读。
通过这些例子,我们使用了以下三种不同的方式来引用命名组:
使用 | 语法 |
---|---|
在模式内 | (?P=name) |
在sub 操作的repl 字符串中 |
\g |
在MatchObject 的任何操作中 |
match.group('name') |
非捕获组
正如我们之前提到的,捕获内容并不是组的唯一用途。有时我们想要使用组,但并不想提取信息;交替是一个很好的例子。这就是为什么我们有一种方法可以创建不捕获的组。在本书中,我们一直在使用组来创建子表达式,就像下面的例子中所示的那样:
>>>re.search("Españ(a|ol)", "Español")
<_sre.SRE_Match at 0x10e90b828>
>>>re.search("Españ(a|ol)", "Español").groups()
('ol',)
你可以看到,即使我们对组的内容不感兴趣,我们仍然捕获了一个组。所以,让我们尝试一下不捕获,但首先我们必须知道语法,它几乎与普通组的语法相同,(?:pattern)
。如你所见,我们只是添加了?:
。让我们看看下面的例子:
>>>re.search("Españ(?:a|ol)", "Español")
<_sre.SRE_Match at 0x10e912648>
>>>re.search("Españ(?:a|ol)", "Español").groups()
()
使用新的语法后,我们拥有了与以前相同的功能,但现在我们节省了资源,正则表达式更容易维护。请注意,该组不能被引用。
原子组
它们是非捕获组的特殊情况;它们通常用于提高性能。它禁用回溯,因此您可以避免在模式中尝试每种可能性或路径都没有意义的情况。这个概念很难理解,所以请跟我一直看到本节的结束。
re
模块不支持原子组。因此,为了看一个例子,我们将使用 regex 模块:pypi.python.org/pypi/regex
。
假设我们要寻找由一个或多个字母数字字符组成的 ID,后面跟着一个破折号和一个数字:
>>>data = "aaaaabbbbbaaaaccccccdddddaaa"
>>>regex.match("(\w+)-\d",data)
让我们一步一步地看看这里发生了什么:
-
正则表达式引擎匹配了第一个
a
。 -
然后它匹配直到字符串的末尾的每个字符。
-
它失败了,因为它找不到破折号。
-
因此,引擎进行回溯,并尝试下一个
a
。 -
再次开始相同的过程。
它尝试了每个字符。如果您考虑我们正在做的事情,一旦第一次失败,继续尝试就没有任何意义。这正是原子组的用处。例如:
>>>regex.match("(?>\w+)-\d",data)
在这里,我们添加了?>
,这表示一个原子组,因此一旦正则表达式引擎无法匹配,
,它就不会继续尝试数据中的每个字符。
组的特殊情况
Python 为我们提供了一些形式的组,可以帮助我们修改正则表达式,甚至只有在匹配前一个组存在于匹配中时才匹配模式,比如if
语句。
每组的标志
有一种方法可以应用我们在第二章使用 Python 进行正则表达式中看到的标志,使用一种特殊的分组形式:(?iLmsux)
。
Letter | Flag |
---|---|
i | re.IGNORECASE |
L | re.LOCALE |
m | re.MULTILINE |
s | re.DOTALL |
u | re.UNICODE |
x | re.VERBOSE |
例如:
>>>re.findall(r"(?u)\w+" ,ur"ñ")
[u'\xf1']
上面的例子与以下相同:
>>>re.findall(r"\w+" ,ur"ñ", re.U)
[u'\xf1']
我们在上一章中多次看到了这些例子的作用。
请记住,标志适用于整个表达式。
yes-pattern|no-pattern
这是组的一个非常有用的情况。它尝试在找到前一个的情况下匹配模式。另一方面,它不会在找不到前一个组的情况下尝试匹配模式。简而言之,它就像一个 if-else 语句。此操作的语法如下:
(?(id/name)yes-pattern|no-pattern)
这个表达式的意思是:如果具有此 ID 的组已经匹配,那么在字符串的这一点,yes-pattern
模式必须匹配。如果组尚未匹配,则no-pattern
模式必须匹配。
让我们继续看看它是如何工作的。我们有一个产品列表,但在这种情况下,ID 可以用两种不同的方式制作:
-
国家代码(两位数字),一个破折号,三个或四个字母数字字符,一个破折号,和区号(2 位数字)。例如:
34-adrl-01
。 -
三个或四个字母数字字符。例如:
adrl
。
因此,当有国家代码时,我们需要匹配国家地区:
>>>pattern = re.compile(r"(\d\d-)?(\w{3,4})(?(1)(-\d\d))")
>>>pattern.match("34-erte-22")
<_sre.SRE_Match at 0x10f68b7a0>
>>>pattern.search("erte")
<_sre.SRE_Match at 0x10f68b828>
正如您在前面的例子中所看到的,当我们有国家代码和区号时,就会有匹配。请注意,当有国家代码但没有区号时,就没有匹配:
>>>pattern.match("34-erte")
None
no-pattern
是用来做什么的?让我们在前面的例子中添加另一个约束:如果没有国家代码,字符串的末尾必须有一个名字:
-
国家代码(2 位数字),一个破折号,三个或四个字母数字字符,一个破折号,和区号(2 位数字)。例如:
34-adrl-01
-
三个或四个字母数字字符,后面跟着三个或四个字符。例如:
adrl-sala
。
让我们看看它是如何运作的:
>>>pattern = re.compile(r"(\d\d-)?(\w{3,4})-(?(1)(\d\d)|[a-z]{3,4})$")
>>>pattern.match("34-erte-22")
<_sre.SRE_Match at 0x10f6ee750>
如预期的那样,如果有国家代码和区号,就会有匹配。
>>>pattern.match("34-erte")
None
在前面的例子中,我们确实有国家地区,但没有区号,因此没有匹配。
>>>pattern.match("erte-abcd")
<_sre.SRE_Match at 0x10f6ee880>
最后,当没有国家地区时,必须有一个名字,所以我们有一个匹配。
请注意,no-pattern
是可选的,因此在第一个例子中,我们省略了它。
重叠组
在第二章使用 Python 进行正则表达式中,我们看到了几个操作,其中有关重叠组的警告:例如,findall
操作。这似乎让很多人感到困惑。因此,让我们尝试通过一个简单的例子来带来一些清晰度:
>>>re.findall(r'(a|b)+', 'abaca')
['a', 'a']
这里发生了什么?为什么以下表达式给出了'a'
和'a'
而不是'aba'
和'a'
?
让我们一步一步地看看解决方案:
重叠组匹配过程
正如我们在前面的图中看到的,字符aba
被匹配,但捕获的组只由a
组成。这是因为即使我们的正则表达式将每个字符分组,它仍然保留最后的a
。请记住这一点,因为这是理解它如何工作的关键。停下来思考一下,我们要求正则表达式引擎捕获由a
或b
组成的所有组,但只对一个字符进行分组,这就是关键。那么,如何捕获由多个'a'
或'b'
组成的组,而且顺序无关呢?以下表达式可以实现:
>>>re.findall(r'((?:a|b)+)', 'abbaca')
['abba', 'a']
我们要求正则表达式引擎捕获由子表达式(a|b
)组成的每个组,而不是仅对一个字符进行分组。
最后一件事——如果我们想要用findall
获得由a
或b
组成的每个组,我们可以写下这个简单的表达式:
>>>re.findall(r'(a|b)', 'abaca')
['a', 'b', 'a', 'a']
在这种情况下,我们要求正则表达式引擎捕获由a
或b
组成的组。由于我们使用了findall
,我们得到了每个匹配的模式,所以我们得到了四个组。
提示
经验法则
最好尽可能简化正则表达式。因此,你应该从最简单的表达式开始,然后逐步构建更复杂的表达式,而不是相反。
总结
不要让本章的简单性愚弄你,我们在本章学到的东西将在你日常使用正则表达式的工作中非常有用,并且会给你很大的优势。
让我们总结一下到目前为止我们学到的东西。首先,我们看到了当我们需要对表达式的某些部分应用量词时,组如何帮助我们。
我们还学会了如何再次在模式中使用捕获的组,甚至在sub
操作中使用替换字符串,这要归功于反向引用。
在本章中,我们还查看了命名组,这是一种改进正则表达式可读性和未来维护的工具。
后来,我们学会了只有在先前存在一个组的情况下才匹配子表达式,或者另一方面,只有在先前不存在一个组的情况下才匹配它。
现在我们知道如何使用组,是时候学习一个与组非常接近的更复杂的主题了;四处看看吧!
第四章:环视
到目前为止,我们已经学习了在丢弃字符的同时匹配字符的不同机制。已经匹配的字符不能再次比较,匹配任何即将到来的字符的唯一方法是丢弃它。
这些字符指示位置而不是实际内容。例如,插入符号(^
)表示行的开头,或者美元符号($
)表示行的结尾。它们只是确保输入中的位置正确,而不实际消耗或匹配任何字符。
更强大的零宽断言是环视,这是一种机制,可以将先前的某个值(向后查找)或后续的某个值(向前查找)与当前位置匹配。它们有效地进行断言而不消耗字符;它们只是返回匹配的正面或负面结果。
环视机制可能是正则表达式中最不为人知,同时也是最强大的技术。这种机制允许我们创建强大的正则表达式,否则无法编写,要么是因为它代表的复杂性,要么是因为正则表达式在没有环视的情况下的技术限制。
在本章中,我们将学习如何使用 Python 正则表达式来利用环视机制。我们将了解如何应用它们,它们在幕后是如何工作的,以及 Python 正则表达式模块对我们施加的一些限制。
正向环视和负向环视都可以细分为另外两种类型:正向和负向:
-
正向环视:这种机制表示为一个由问号和等号
?=
组成的表达式,放在括号块内。例如,(?=regex)
将匹配传递的正则表达式是否与即将到来的输入匹配。 -
负向环视:这种机制被指定为一个由问号和感叹号
?!
组成的表达式,放在括号块内。例如,(?!regex)
将匹配传递的正则表达式是否不与即将到来的输入匹配。 -
正向环视:这种机制表示为一个由问号、小于号和等号
?<=
组成的表达式,放在括号块内。例如,(?<=regex)
将匹配传递的正则表达式是否与先前的输入匹配。 -
负向环视:这种机制表示为一个由问号、小于号和感叹号
?<!
组成的表达式,放在括号块内。例如,(?<!regex)
将匹配传递的正则表达式是否不与先前的输入匹配。
让我们开始期待下一节。
向前查看
我们将要学习的第一种环视机制是向前环视机制。它试图匹配作为参数传递的子表达式。这两种环视操作的零宽度特性使它们变得复杂和难以理解。
正如我们从前一节所知,它表示为一个由问号和等号?=
组成的表达式,放在括号块内:(?=regex)
。
让我们通过比较两个类似的正则表达式的结果来开始解决这个问题。我们可以回忆一下,在第一章中,介绍正则表达式,我们将表达式/fox/
与短语The quick brown fox jumps over the lazy dog
匹配。让我们也将表达式/(?=fox)/
应用到相同的输入中:
>>>pattern = re.compile(r'fox')
>>>result = pattern.search("The quick brown fox jumps over the lazy dog")
>>>print result.start(), result.end()
16 19
我们刚刚在输入字符串中搜索了字面上的fox
,正如预期的那样,我们在索引16
和19
之间找到了它。让我们看一下正向环视机制的下一个例子:
>>>pattern = re.compile(r'(?=fox)')
>>>result = pattern.search("The quick brown fox jumps over the lazy dog")
>>>print result.start(), result.end()
16 16
这次我们应用了表达式/(?=fox)/
。结果只是一个位置在索引16
(起始和结束点都指向相同的索引)。这是因为向前查找不会消耗字符,因此可以用来过滤表达式应该匹配的位置。但它不会定义结果的内容。我们可以在下图中直观地比较这两个表达式:
正常匹配和向前查找的比较
让我们再次使用这个特性,尝试匹配任何后面跟着逗号字符(,
)的单词,使用以下正则表达式/\w+(?=,)/
和文本They were three: Felix, Victor, and Carlos
:
>>>pattern = re.compile(r'\w+(?=,)')
>>>pattern.findall("They were three: Felix, Victor, and Carlos.")
['Felix', 'Victor']
我们创建了一个正则表达式,接受任何重复的字母数字字符,后面跟着一个逗号字符,这不会作为结果的一部分使用。因此,只有Felix
和Victor
是结果的一部分,因为Carlos
的名字后面没有逗号。
这与我们在本章中使用的正则表达式有多大不同?让我们通过将/\w+,/
应用于相同的文本来比较结果:
>>>pattern = re.compile(r'\w+,')
>>>pattern.findall("They were three: Felix, Victor, and Carlos.")
['Felix,', 'Victor,']
通过前面的正则表达式,我们要求正则表达式引擎接受任何重复的字母数字字符,后面跟着一个逗号字符。因此,字母数字字符和逗号字符将被返回,正如我们在列表中看到的。
值得注意的是,向前查找机制是另一个可以利用正则表达式所有功能的子表达式(这在向后查找机制中并非如此,我们稍后会发现)。因此,我们可以使用到目前为止学到的所有构造,如交替:
>>>pattern = re.compile(r'\w+(?=,|\.)')
>>>pattern.findall("They were three: Felix, Victor, and Carlos.")
['Felix', 'Victor', 'Carlos']
在前面的例子中,我们使用了交替(即使我们可以使用其他更简单的技术,如字符集)来接受任何重复的字母数字字符,后面跟着一个逗号或点字符,这不会作为结果的一部分使用。
负向查找
负向查找机制具有与向前查找相同的性质,但有一个显著的区别:只有子表达式不匹配时,结果才有效。
它表示为一个由问号和感叹号?!
组成的表达式,放在括号块内:(?!regex)
。
当我们想要表达不应该发生的情况时,这是很有用的。例如,要找到任何不是John Smith
的名字John
,我们可以这样做:
>>>pattern = re.compile(r'John(?!\sSmith)') >>> result = pattern.finditer("I would rather go out with **John** McLane than with John Smith or **John** Bon Jovi")
>>>for i in result:
...print i.start(), i.end()
...
27 31
63 67
在前面的例子中,我们通过消耗这五个字符来寻找John
,然后向前查找一个空格字符,后面跟着单词Smith
。如果匹配成功,匹配结果将只包含John
的起始和结束位置。在这种情况下,John McLane
的位置是27
-31
,John Bon Jovi
的位置是63
-67
。
现在,我们能够利用更基本的向前查找形式:正向和负向查找。让我们学习如何在替换和分组中充分利用它。
向前查找和替换
向前查找操作的零宽度特性在替换中特别有用。由于它们,我们能够执行在其他情况下会非常复杂的转换。
向前查找和替换的一个典型例子是将仅由数字字符组成的数字(例如 1234567890)转换为逗号分隔的数字,即 1,234,567,890。
为了编写这个正则表达式,我们需要一个策略来跟随。我们想要做的是将数字分组成三个一组,然后用相同的组加上一个逗号字符来替换它们。
我们可以从一个几乎天真的方法开始,使用以下突出显示的正则表达式:
>>>pattern = re.compile(r'**\d{1,3}**')
>>>pattern.findall("The number is: 12345567890")
['123', '455', '678', '90']
我们在这次尝试中失败了。我们实际上是在三个数字的块中进行分组,但应该从右到左进行。我们需要不同的方法。让我们尝试找到一个、两个或三个数字,这些数字必须后面跟着任意数量的三位数字块,直到我们找到一个不是数字的东西。
这将对我们的数字产生以下影响。当尝试找到一个、两个或三个数字时,正则表达式将开始只取一个,这将是数字1
。然后,它将尝试捕捉恰好三个数字的块,例如 234、567、890,直到找到一个非数字。这是输入的结尾。
如果我们用正则表达式来表达我们刚刚用普通英语解释的内容,我们将得到以下结果:
/\d{1,3}(?=(\d{3})+(?!\d))/
元素 | 描述 |
---|
|
\d
这匹配一个十进制字符 |
---|
|
{1,3}
这表示匹配重复一到三次 |
---|
|
(?=
这表示该字符后面跟着(但不消耗)这个表达式 |
---|
|
(
这表示一个组 |
---|
|
\d
这表示有一组十进制字符 |
---|
|
\s
这表示匹配重复三次 |
---|
|
)
|
+
这表示十进制字符应该出现一次或多次 |
---|
|
(?!
这表示匹配不是后面跟着(但不消耗)下一个表达式定义的内容 |
---|
|
\d
这表示一个十进制字符 |
---|
|
))
让我们在 Python 的控制台中再次尝试这个新的正则表达式:
>>>pattern = re.compile(r'\d{1,3}(?=(\d{3})+(?!\d))')
>>>results = pattern.finditer('1234567890')
>>>for result in results:
... print result.start(), result.end()
...
0 1
1 4
4 7
这一次,我们可以看到我们正在使用正确的方法,因为我们刚刚确定了正确的块:1
、234
、567
和890
。
现在,我们只需要使用替换来替换我们找到的每个匹配项,使其成为相同的匹配结果加上逗号字符。我们已经知道如何使用替换,因为我们在第二章中学习过,使用 Python 进行正则表达式,所以让我们把它付诸实践:
>>>pattern = re.compile(r'\d{1,3}(?=(\d{3})+(?!\d))')
>>>pattern.sub(r'\g<0>,', "1234567890")
'1,234,567,890'
Et voila!我们刚刚将一个未格式化的数字转换成了一个带有千位分隔符的美丽数字。
我们刚刚学会了两种技术,可以预见未来会发生什么。我们还研究了它们在替换中的用法。现在,让我们回头看看我们留下的东西向后看。
向后看
我们可以安全地将向后看定义为与向前看相反的操作。它试图匹配作为参数传递的子表达式之后的内容。它也具有零宽度的特性,因此不会成为结果的一部分。
它表示为一个表达式,前面有一个问号、一个小于号和一个等号,?<=
,在一个括号块内:(?<=regex)
。
例如,我们可以在类似于我们在负向向前看中使用的示例中使用它,只找到名为John McLane
的人的姓。为了实现这一点,我们可以写一个类似下面的向后看:
>>>pattern = re.compile(r'(?<=John\s)McLane')
>>>result = pattern.finditer("I would rather go out with John **McLane** than with John Smith or John Bon Jovi")
>>>for i in result:
... print i.start(), i.end()
...
32 38
通过前面的向后看,我们要求正则表达式引擎只匹配那些前面跟着John
和一个空格的位置,然后消耗McLane
作为结果。
在 Python 的re
模块中,然而,向前看和向后看的实现之间有一个根本的区别。由于一些根深蒂固的技术原因,向后看机制只能匹配固定宽度的模式。如果需要在向后看中使用可变宽度模式,则可以使用pypi.python.org/pypi/regex
中的正则表达式模块,而不是标准的 Python re
模块。
固定宽度模式不包含我们在第一章中学习的量词这样的可变长度匹配器,介绍正则表达式。其他可变长度构造,如反向引用也是不允许的。选择是允许的,但只有在备选项具有相同的长度时才允许。同样,这些限制在前述的正则表达式模块中是不存在的。
让我们看看如果我们在反向引用中使用不同长度的选择会发生什么:
>>>pattern = re.compile(r'(?<=(John|Jonathan)\s)McLane')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/re.py", line 190, in compile
return _compile(pattern, flags)
File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/re.py", line 242, in _compile
raise error, v # invalid expression
sre_constants.error: **look-behind requires fixed-width pattern
我们有一个例外,因为后面的查找需要一个固定宽度的模式。如果我们尝试应用量词或其他可变长度的结构,我们将得到类似的结果。
现在我们已经学会了不消耗字符的匹配前后不匹配的不同技术和我们可能遇到的不同限制,我们可以尝试编写另一个示例,结合我们学习过的一些机制来解决一个现实世界的问题。
假设我们想要提取出推文中存在的任何 Twitter 用户名,以创建一个自动情绪检测系统。为了编写一个正则表达式来提取它们,我们应该首先确定 Twitter 用户名是如何表示的。如果我们浏览 Twitter 的网站support.twitter.com/articles/101299-why-can-t-i-register-certain-usernames
,我们可能会找到以下描述:
用户名只能包含字母数字字符(A-Z 的字母,0-9 的数字),除了下划线,如上所述。检查一下,确保你想要的用户名不包含任何符号、破折号或空格。
对于我们的开发测试,我们将使用这条 Packt Publishing 推文:
我们应该首先构建一个包含所有可能用于 Twitter 用户名的字符的字符集。这可能是任何字母数字字符,后面跟着下划线字符,就像我们刚才在前面的 Twitter 支持文章中发现的那样。因此,我们可以构建一个类似以下的字符集:
[\w_]
这将表示我们想要从用户名中提取的所有部分。然后,我们需要在用户名前加上一个单词边界和 at 符号(@
)来定位用户名:
/\B@[\w_]+/
使用单词边界的原因是我们不想与电子邮件等混淆。我们只寻找紧随行首或单词边界之后,然后跟着@符号,然后是一些字母数字或下划线字符的文本。示例如下:
-
@vromer0
是一个有效的用户名 -
iam@vromer0
不是一个有效的用户名,因为它应该以@符号开头 -
@vromero.org
不是一个有效的用户名,因为它包含一个无效字符
如果我们使用目前的正则表达式,我们将得到以下结果:
>>>pattern = re.compile(r'\B@[\w_]+')
>>>pattern.findall("Know your Big Data = 5 for $50 on eBooks and 40% off all eBooks until Friday #bigdata #hadoop @HadoopNews packtpub.com/bigdataoffers")
['@HadoopNews']
我们只想匹配用户名,而不包括前面的@符号。在这一点上,后视机制变得有用。我们可以在后视子表达式中包含单词边界和@符号,这样它们就不会成为匹配结果的一部分:
>>>pattern = re.compile(r'(?<=\B@)[\w_]+')
>>>pattern.findall("Know your Big Data = 5 for $50 on eBooks and 40% off all eBooks until Friday #bigdata #hadoop @HadoopNews packtpub.com/bigdataoffers")
['HadoopNews']
现在我们已经实现了我们的目标。
负向后视
负向后视机制具有与主要后视机制完全相同的性质,但只有在传递的子表达式不匹配时才会得到有效结果。
它表示为一个表达式,前面有一个问号、一个小于号和一个感叹号,?<!
,在括号块内:(?<!regex)
。
值得记住的是,负向后视不仅具有前视机制的大部分特征,而且还具有相同的限制。负向后视机制只能匹配固定宽度的模式。这与我们在前一节中学习的原因和影响是一样的。
我们可以通过尝试匹配任何姓氏为Doe
但不叫John
的人来实践这一点,使用这样的正则表达式:/(?<!John\s)Doe/
。如果我们在 Python 的控制台中使用它,我们将得到以下结果:
>>>pattern = re.compile(r'(?<!John\s)Doe')
>>>results = pattern.finditer("John Doe, Calvin **Doe**, Hobbes **Doe**")
>>>for result in results:
... print result.start(), result.end()
...
17 20
29 32
环视和分组
在组内使用环视结构的另一个有益的用途。通常,当使用组时,必须在组内匹配并返回非常具体的结果。由于我们不希望在组内添加不必要的信息,因此在其他潜在选项中,我们可以利用环视作为一个有利的解决方案。
假设我们需要获取一个逗号分隔的值,值的第一部分是一个名称,而第二部分是一个值。格式类似于这样:
INFO 2013-09-17 12:13:44,487 authentication failed
正如我们在第三章中学到的分组,我们可以轻松地编写一个表达式,以获取以下两个值:
/\w+\s[\d-]+\s[\d:,]+\s(.*\sfailed)/
然而,我们只想在失败不是认证失败时进行匹配。我们可以通过添加负向后行来实现这一点。它看起来像这样:
/\w+\s[\d-]+\s[\d:,]+\s(.*(?<!authentication\s)failed)/
一旦我们将其放入 Python 的控制台,我们将得到以下输出:
>>>pattern = re.compile(r'\w+\s[\d-]+\s[\d:,]+\s(.*(?<!authentication\s)failed)')
>>>pattern.findall("INFO 2013-09-17 12:13:44,487 authentication failed")
[]
>>>pattern.findall("INFO 2013-09-17 12:13:44,487 something else failed")
['something else failed']
总结
在本章中,我们学习了零宽断言的概念,以及它如何在不干扰结果内容的情况下在文本中找到确切的内容。
我们还学习了如何利用四种类型的环视机制:正向先行断言,负向先行断言,正向后行断言和负向后行断言。
我们还特别关注了两种具有可变断言的后行环视的限制。
通过这样,我们结束了对正则表达式基本和高级技术的探讨。现在,我们准备在下一章节中专注于性能调优。
第五章:正则表达式的性能
到目前为止,我们担心学习如何利用一个功能或获得一个结果,而不太关心过程的速度。我们的唯一目标是正确性和可读性。
在本章中,我们将转向一个完全不同的关注点——性能。然而,我们会发现,通常性能的提高会导致可读性的降低。当我们修改某些东西以使其更快时,我们可能正在使机器更容易理解,因此,我们可能正在牺牲人类的可读性。
1974 年 12 月 4 日,著名书籍《计算机程序设计艺术》的作者唐纳德·克努斯写了一篇名为“结构化编程”的论文,其中包含了go-to
语句。这个著名的引用摘自这篇论文:
“程序员们浪费了大量时间思考或担心程序中非关键部分的速度,而这些对效率的努力实际上在调试和维护时产生了很大的负面影响。我们应该忘记小的效率,大约 97%的时间:过早的优化是万恶之源。然而,我们不应该放弃在关键的 3%中的机会。”
也就是说,我们应该谨慎考虑我们要优化什么。也许,对于用于验证电子邮件地址的正则表达式,我们应该更关注可读性而不是性能。另一方面,如果我们正在编写一个用于批处理大型历史文件的正则表达式,我们应该更关注性能。
最常用的优化方法是先编写,然后测量,然后优化关键的 3%。因此,在本章中,我们首先要学习如何测量和分析正则表达式,然后再进行优化技术。
使用 Python 对正则表达式进行基准测试
为了对我们的正则表达式进行基准测试,我们将测量正则表达式执行所需的时间。重要的是要用不同的输入来测试它们,因为对于小输入,几乎每个正则表达式都足够快。然而,对于更长的输入,情况可能完全不同,正如我们将在回溯部分中看到的那样。
首先,我们将创建一个小函数来帮助我们完成这个任务:
>>> from time import clock as now
>>> def test(f, *args, **kargs):
start = now()
f(*args, **kargs)
print "The function %s lasted: %f" %(f.__name__, now() - start)
因此,我们可以使用以下代码测试正则表达式:
>>> def alternation(text):
pat = re.compile('spa(in|niard)')
pat.search(text)
>>> test(alternation, "spain")
The function alternation lasted: 0.000009
Python 自带了一个内置的分析器docs.python.org/2/library/profile.html
,我们也可以用它来测量时间和调用次数等:
>>> import cProfile
>>> cProfile.run("alternation('spaniard')")
您可以在以下截图中看到输出:
分析输出
让我们看看另一种有用的技术,当你想要查看正则表达式下的情况时,这将会有所帮助。这是我们在第二章中已经见过的东西,使用 Python 进行正则表达式,标志 DEBUG。回想一下,它为我们提供了有关模式如何编译的信息。例如:
>>> re.compile('(\w+\d+)+-\d\d', re.DEBUG)
max_repeat 1 4294967295
subpattern 1
max_repeat 1 4294967295
in
category category_word
max_repeat 1 4294967295
in
category category_digit
literal 45
in
category category_digit
in
category category_digit
在这里,我们可以看到三个max_repeat
条件从1
到4294967295
,其中两个嵌套在另一个max_repeat
中。把它们想象成嵌套循环,你可能会觉得这是一种不好的迹象。事实上,这将导致灾难性的回溯,这是我们稍后会看到的。
RegexBuddy 工具
在编写正则表达式时,有许多不同的工具可用于提高生产力,其中RegexBuddy(www.regexbuddy.com/
)由 Just Great Software Co. Ltd.开发的工具非常出色。
Just Great Software 的幕后推手是 Jan Goyvaerts,也是Regular-Expressions.info(www.regular-expressions.info/
)的幕后人物,这是互联网上最著名的正则表达式参考之一。
使用 RegexBuddy,我们可以使用可视化界面构建、测试和调试正则表达式。调试功能几乎是独一无二的,并提供了一个很好的机制来理解正则表达式引擎在幕后的工作方式。在下面的截图中,我们可以看到 RegexBuddy 调试正则表达式的执行:
RegexBuddy 调试正则表达式
它确实具有其他功能,例如常用正则表达式库和不同编程环境的代码生成器。
尽管它有一些缺点,但它的许可证是专有的,唯一可用的构建是 Windows。然而,可以使用wine 模拟器在 Linux 上执行。
理解 Python 正则表达式引擎
re
模块使用回溯正则表达式引擎;尽管在Jeffrey E. F. Friedl的著名书籍《精通正则表达式》中,它被归类为非确定性有限自动机(NFA)类型。此外,根据Tim Peters(mail.python.org/pipermail/tutor/2006-January/044335.html
),该模块并非纯粹的 NFA。
这些是算法的最常见特征:
-
它支持“懒惰量词”,如
*?
、+?
和??
。 -
它匹配第一个匹配项,即使在字符串中有更长的匹配项。
>>>re.search("engineer|engineering", "engineering").group()'engineer'
这也意味着顺序很重要。
-
该算法一次只跟踪一个转换,这意味着引擎一次只检查一个字符。
-
支持反向引用和捕获括号。
-
回溯是记住上次成功位置的能力,以便在需要时可以返回并重试
-
在最坏的情况下,复杂度是指数级的 O(C^n)。我们稍后会在回溯中看到这一点。
回溯
正如我们之前提到的,回溯允许返回并重复正则表达式的不同路径。它通过记住上次成功的位置来实现。这适用于交替和量词。让我们看一个例子:
回溯
正如你在上图中看到的,正则表达式引擎尝试一次匹配一个字符,直到失败,然后从下一个可以重试的路径开始重新开始。
在图中使用的正则表达式是如何构建正则表达式的重要性的完美例子。在这种情况下,表达式可以重建为spa(in|niard)
,这样正则表达式引擎就不必返回到字符串的开头来重试第二个选择。
这导致了一种称为灾难性回溯的东西;这是回溯的一个众所周知的问题,它可能会给你带来从缓慢的正则表达式到堆栈溢出的崩溃等多种问题。
在前面的例子中,你可以看到行为不仅随着输入而增长,而且随着正则表达式中不同的路径而增长,因此算法可能是指数级的 O(C^n)。有了这个想法,就很容易理解为什么我们最终可能会遇到堆栈溢出的问题。当正则表达式无法匹配字符串时,问题就出现了。让我们用之前见过的技术来对正则表达式进行基准测试,以便更好地理解问题。
首先,让我们尝试一个简单的正则表达式:
>>> def catastrophic(n):
print "Testing with %d characters" %n
pat = re.compile('(a+)+c')
text = "%s" %('a' * n)
pat.search(text)
正如你所看到的,我们试图匹配的文本总是会失败,因为末尾没有c
。让我们用不同的输入进行测试:
>>> for n in range(20, 30):
test(catastrophic, n)
Testing with 20 characters
The function catastrophic lasted: 0.130457
Testing with 21 characters
The function catastrophic lasted: 0.245125
……
The function catastrophic lasted: 14.828221
Testing with 28 characters
The function catastrophic lasted: 29.830929
Testing with 29 characters
The function catastrophic lasted: 61.110949
这个正则表达式的行为看起来像是二次的。但是为什么?这里发生了什么?问题在于(a+)
是贪婪的,所以它试图尽可能多地获取a
字符。之后,它无法匹配c
,也就是说,它回溯到第二个a
,并继续消耗a
字符,直到无法匹配c
。然后,它再次尝试整个过程(回溯),从第二个a
字符开始。
让我们看另一个例子,这次是指数级的行为:
>>> def catastrophic(n):
print "Testing with %d characters" %n
pat = re.compile('(x+)+(b+)+c')
**text = 'x' * n
**text += 'b' * n
pat.search(text)
>>> for n in range(12, 18):
test(catastrophic, n)
Testing with 12 characters
The function catastrophic lasted: 1.035162
Testing with 13 characters
The function catastrophic lasted: 4.084714
Testing with 14 characters
The function catastrophic lasted: 16.319145
Testing with 15 characters
The function catastrophic lasted: 65.855182
Testing with 16 characters
The function catastrophic lasted: 276.941307
正如你所看到的,这种行为是指数级的,可能导致灾难性的情况。最后,让我们看看当正则表达式有匹配时会发生什么:
>>> def non_catastrophic(n):
print "Testing with %d characters" %n
pat = re.compile('(x+)+(b+)+c')
**text = 'x' * n
**text += 'b' * n
**text += 'c'
pat.search(text)
>>> for n in range(12, 18):
test(non_catastrophic, n)
Testing with 10 characters
The function catastrophic lasted: 0.000029
……
Testing with 19 characters
The function catastrophic lasted: 0.000012
优化建议
在接下来的章节中,我们将找到一些可以应用于改进正则表达式的建议。
最好的工具始终是常识,即使在遵循这些建议时,也需要使用常识。必须理解建议何时适用,何时不适用。例如,建议“不要贪婪”并不适用于所有情况。
重用编译模式
我们在第二章中学到,要使用正则表达式,我们必须将其从字符串表示形式转换为编译形式,即RegexObject
。
这种编译需要一些时间。如果我们使用模块操作的其余部分而不是使用编译函数来避免创建RegexObject
,我们应该明白编译仍然会执行,并且一些编译的RegexObject
会自动缓存。
然而,当我们进行编译时,缓存不会支持我们。每次编译执行都会消耗一定的时间,对于单次执行来说可能可以忽略不计,但如果执行多次则肯定是相关的。
让我们看看在以下示例中重用和不重用编译模式的区别:
>>> def **dontreuse**():
pattern = re.compile(r'\bfoo\b')
pattern.match("foo bar")
>>> def callonethousandtimes():
for _ in range(1000):
dontreuse()
>>> test(callonethousandtimes)
The function callonethousandtimes lasted: 0.001965
>>> pattern = re.compile(r'\bfoo\b')
>>> def **reuse**():
pattern.match("foo bar")
>>> def callonethousandtimes():
for _ in range(1000):
reuse()
>>> test(callonethousandtimes)
The function callonethousandtimes lasted: 0.000633
>>>
在交替中提取公共部分
在正则表达式中,交替总是存在性能风险。在 Python 中使用 NFA 实现时,我们应该将任何公共部分提取到交替之外。
例如,如果我们有/(Hello
World|Hello
Continent|Hello
Country,)/
,我们可以很容易地用以下表达式提取Hello
:/Hello
(World|Continent|Country)/
。这将使我们的引擎只检查一次Hello
,而不会回头重新检查每种可能性。在下面的示例中,我们可以看到执行上的差异:
>>> pattern = re.compile(r'/(Hello\sWorld|Hello\sContinent|Hello\sCountry)')
>>> def **nonoptimized**():
pattern.match("Hello\sCountry")
>>> def callonethousandtimes():
for _ in range(1000):
nonoptimized()
>>> test(callonethousandtimes)
The function callonethousandtimes lasted: 0.000645
>>> pattern = re.compile(r'/Hello\s(World|Continent|Country)')
>>> def **optimized**():
pattern.match("Hello\sCountry")
>>> def callonethousandtimes():
for _ in range(1000):
optimized()
>>> test(callonethousandtimes)
The function callonethousandtimes lasted: 0.000543
>>>
交替的快捷方式
在交替中的顺序很重要,交替中的每个不同选项都将逐个检查,从左到右。这可以用来提高性能。
如果我们将更有可能的选项放在交替的开头,更多的检查将更早地标记交替为匹配。
例如,我们知道汽车的常见颜色是白色和黑色。如果我们要编写一个接受一些颜色的正则表达式,我们应该将白色和黑色放在前面,因为这些更有可能出现。我们可以将正则表达式写成这样/(white|black|red|blue|green)/
。
对于其余的元素,如果它们出现的几率完全相同,将较短的放在较长的前面可能是有利的:
>>> pattern = re.compile(r'(white|black|red|blue|green)')
>>> def **optimized**():
pattern.match("white")
>>> def callonethousandtimes():
for _ in range(1000):
optimized()
>>> test(callonethousandtimes)
The function callonethousandtimes lasted: 0.000667
>>>
>>> pattern = re.compile(r'(green|blue|red|black|white)')
>>> def **nonoptimized**():
pattern.match("white")
>>> def callonethousandtimes():
for _ in range(1000):
nonoptimized()
>>> test(callonethousandtimes)
The function callonethousandtimes lasted: 0.000862
>>>
在适当的时候使用非捕获组
捕获组将为表达式中定义的每个组消耗一些时间。这个时间并不是很重要,但如果我们多次执行正则表达式,它仍然是相关的。
有时,我们使用组,但可能对结果不感兴趣,例如在使用交替时。如果是这种情况,我们可以通过将该组标记为非捕获来节省引擎的一些执行时间,例如(?:person|company)
。
具体化
当我们定义的模式非常具体时,引擎可以在实际模式匹配之前帮助我们执行快速的完整性检查。
例如,如果我们将表达式/\w{15}/
传递给引擎,以匹配文本hello
,引擎可能决定检查输入字符串是否实际上至少有 15 个字符长,而不是匹配表达式。
不要贪心
我们在第一章介绍正则表达式中学习了量词,并了解了贪婪和勉强量词之间的区别。我们还发现量词默认是贪婪的。
这在性能方面意味着什么?这意味着引擎将始终尝试尽可能多地捕获字符,然后逐步缩小范围,直到匹配完成。如果匹配通常很短,这可能使正则表达式变慢。然而,请记住,这仅适用于匹配通常很短的情况。
总结
在这最后一章中,我们开始学习优化的相关性,以及为什么我们应该避免过早的优化。然后,我们深入了解了通过学习不同的机制来测量正则表达式的执行时间。后来,我们了解了 RegexBuddy 工具,它可以帮助我们了解引擎是如何工作的,并帮助我们找出性能问题。
后来,我们了解了如何看到引擎在幕后的工作。我们学习了一些引擎设计的理论,以及如何容易陷入常见的陷阱——灾难性的回溯。
最后,我们回顾了不同的一般建议,以改善我们的正则表达式的性能。
标签:字符,精通,匹配,re,Python,pattern,正则表达式,我们 From: https://www.cnblogs.com/apachecn/p/18172747