Skip to main content

正则表达式

到目前为止,我们一直在通读文件,寻找模式并提取我们感兴趣的行的各种片段。我们一直在使用像 splitfind 这样的字符串方法,并使用列表和字符串切片来提取行的部分内容。

搜索和提取这项任务非常普遍,以至于 Python 有一个非常强大的模块叫做正则表达式 (regular expressions),它能够相当优雅地处理许多这类任务。我们在本书前面没有介绍正则表达式的原因是,虽然它们非常强大,但它们有点复杂,其语法需要一些时间来适应。

正则表达式几乎是它们自己的一种用于搜索和解析字符串的小型编程语言。事实上,已经有整本书是关于正则表达式这个主题的。在本章中,我们只会涵盖正则表达式的基础知识。有关正则表达式的更多详细信息,请参阅:

https://en.wikipedia.org/wiki/Regular_expression

https://docs.python.org/zh-cn/3/library/re.html

在使用正则表达式模块 re 之前,必须将其导入到你的程序中。正则表达式模块最简单的用法是 search() 函数。以下程序演示了搜索函数的一个简单用法。

# 搜索包含 'From' 的行
import re
hand = open('mbox-short.txt')
for line in hand:
line = line.rstrip()
if re.search('From:', line):
print(line)

# 代码: https://www.py4e.com/code3/re01.py

我们打开文件,遍历每一行,并使用正则表达式 search() 来仅打印出包含字符串“From:”的行。这个程序没有使用正则表达式的真正威力,因为我们本可以同样轻松地使用 line.find() 来达到相同的结果。

正则表达式的威力在于我们向搜索字符串添加特殊字符,从而能够更精确地控制哪些行匹配该字符串。向我们的正则表达式添加这些特殊字符使我们能够进行复杂的匹配和提取,同时编写的代码量很少。

例如,脱字符 (caret character) ^ 在正则表达式中用于匹配一行的“开头”。我们可以更改我们的程序,使其仅匹配“From:”位于行首的行,如下所示:

# 搜索以 'From' 开头的行
import re
hand = open('mbox-short.txt')
for line in hand:
line = line.rstrip()
if re.search('^From:', line):
print(line)

# 代码: https://www.py4e.com/code3/re02.py

现在我们将只匹配字符串“From:”开头的行。这仍然是一个非常简单的例子,我们本可以用字符串模块中的 startswith() 方法等效地完成。但它有助于引入正则表达式包含特殊动作字符的概念,这些字符让我们能更好地控制哪些内容将匹配正则表达式。

正则表达式中的字符匹配

还有许多其他特殊字符可以让我们构建更强大的正则表达式。最常用的特殊字符是句点或句号 (.),它匹配任何字符。

在下面的例子中,正则表达式 F..m: 将匹配字符串“From:”、“Fxxm:”、“F12m:”或“F!@m:”中的任何一个,因为正则表达式中的句点字符匹配任何字符。

# 搜索以 'F' 开头,后跟
# 2 个字符,再后跟 'm:' 的行
import re
hand = open('mbox-short.txt')
for line in hand:
line = line.rstrip()
if re.search('^F..m:', line):
print(line)

# 代码: https://www.py4e.com/code3/re03.py

当与在正则表达式中使用 *+ 字符来表示一个字符可以重复任意次数的能力结合时,这一点尤其强大。这些特殊字符意味着它们不是匹配搜索字符串中的单个字符,而是匹配零个或多个字符(对于星号)或一个或多个字符(对于加号)。

我们可以使用重复的通配符 (wild card) 字符在以下示例中进一步缩小我们匹配的行范围:

# 搜索以 From 开头并包含 @ 符号的行
import re
hand = open('mbox-short.txt')
for line in hand:
line = line.rstrip()
if re.search('^From:.+@', line):
print(line)

# 代码: https://www.py4e.com/code3/re04.py

搜索字符串 ^From:.+@ 将成功匹配以“From:”开头,后跟一个或多个字符 (.+),再后跟一个 @ 符号的行。所以这将匹配下面这行:

你可以将 .+ 通配符看作是扩展以匹配冒号字符和 @ 符号之间的所有字符。

From:.+@

将加号和星号字符看作是“有推挤性的”(pushy) 或“贪婪的”(greedy) 会很有帮助。例如,以下字符串将匹配字符串中最后一个 @ 符号,因为 .+ 向外推挤,如下所示:

From: [email protected], [email protected], and cwen @iupui.edu

可以通过添加另一个字符来告诉星号或加号不要那么“贪婪”。有关关闭贪婪行为的信息,请参阅详细文档。

使用正则表达式提取数据

如果我们想从 Python 的字符串中提取数据,我们可以使用 findall() 方法来提取所有匹配正则表达式的子字符串。让我们以想要从任何格式的任何行中提取看起来像电子邮件地址的内容为例。例如,我们想从以下每一行中提取电子邮件地址:

From [email protected] Sat Jan  5 09:14:16 2008
Return-Path: <[email protected]>
for <[email protected]>;
Received: (from apache@localhost)
Author: [email protected]

我们不想为每种类型的行编写代码,对每一行进行不同的分割和切片。下面的程序使用 findall() 来查找包含电子邮件地址的行,并从这些行中提取一个或多个地址。

import re
s = 'A message from [email protected] to [email protected] about meeting @2PM'
lst = re.findall('\S+@\S+', s)
print(lst)

# 代码: https://www.py4e.com/code3/re05.py

findall() 方法搜索第二个参数中的字符串,并返回一个包含所有看起来像电子邮件地址的字符串的列表。我们使用的是一个匹配非空白字符 (\S) 的双字符序列。

程序的输出将是:

翻译这个正则表达式,我们正在寻找具有至少一个非空白字符,后跟一个 @ 符号,再后跟至少一个非空白字符的子字符串。\S+ 匹配尽可能多的非空白字符。

这个正则表达式会匹配两次([email protected][email protected]),但它不会匹配字符串“@2PM”,因为 @ 符号之前没有非空白字符。我们可以将这个正则表达式用在一个程序中,读取文件中的所有行,并打印出任何看起来像电子邮件地址的内容,如下所示:

# 搜索在字符之间包含 @ 符号的行
import re
hand = open('mbox-short.txt')
for line in hand:
line = line.rstrip()
x = re.findall('\S+@\S+', line)
if len(x) > 0:
print(x)

# 代码: https://www.py4e.com/code3/re06.py

我们读取每一行,然后提取所有匹配我们正则表达式的子字符串。由于 findall() 返回一个列表,我们只需检查返回列表中的元素数量是否大于零,即可仅打印出我们找到至少一个看起来像电子邮件地址的子字符串的行。

如果我们在 mbox-short.txt 上运行该程序,我们会得到以下输出:

我们的一些电子邮件地址在开头或结尾有不正确的字符,如“<”或“;”。让我们声明,我们只对以字母或数字开头和结尾的字符串部分感兴趣。

为此,我们使用正则表达式的另一个特性。方括号用于指示我们愿意考虑匹配的一组多个可接受字符。从某种意义上说,\S 是要求匹配“非空白字符”的集合。现在我们将更明确地指定我们将匹配的字符。

这是我们的新正则表达式:

[a-zA-Z0-9]\S*@\S*[a-zA-Z]

这变得有点复杂了,你可以开始明白为什么正则表达式本身就是一门小语言了。翻译这个正则表达式,我们正在寻找以单个小写字母、大写字母或数字“[a-zA-Z0-9]”开头,后跟零个或多个非空白字符 (\S*),再后跟一个 @ 符号,再后跟零个或多个非空白字符 (\S*),最后跟一个大写或小写字母的子字符串。请注意,我们将 + 切换为 * 来表示零个或多个非空白字符,因为 [a-zA-Z0-9] 已经是一个非空白字符。记住 *+ 应用于紧邻其左侧的单个字符。

如果我们在程序中使用这个表达式,我们的数据会干净得多:

# 搜索在字符之间包含 @ 符号的行
# 字符必须是字母或数字
import re
hand = open('mbox-short.txt')
for line in hand:
line = line.rstrip()
x = re.findall('[a-zA-Z0-9]\S*@\S*[a-zA-Z]', line)
if len(x) > 0:
print(x)

# 代码: https://www.py4e.com/code3/re07.py

请注意,在 [email protected] 行上,我们的正则表达式消除了字符串末尾的两个字母(“>;”)。这是因为当我们在正则表达式末尾附加 [a-zA-Z] 时,我们要求正则表达式解析器找到的任何字符串都必须以字母结尾。所以当它看到“sakaiproject.org>;”末尾的“>”时,它就在它找到的最后一个“匹配”字母处停止(即,“g”是最后一个好的匹配)。

另请注意,程序的输出是一个 Python 列表,该列表包含一个字符串作为列表中的单个元素。

结合搜索和提取

如果我们想在以字符串“X-”开头的行上查找数字,例如:

X-DSPAM-Confidence: 0.8475
X-DSPAM-Probability: 0.0000

我们不仅仅想要任何行中的任何浮点数。我们只想从具有上述语法的行中提取数字。

我们可以构造以下正则表达式来选择这些行:

^X-.*: [0-9.]+

翻译一下,我们是说,我们想要以 X- 开头,后跟零个或多个字符 (.*),再后跟一个冒号 (:),然后是一个空格的行。在空格之后,我们正在寻找一个或多个是数字(0-9)或句点 [0-9.]+ 的字符。请注意,在方括号内,句点匹配实际的句点(即,它在方括号之间不是通配符)。

这是一个非常严谨的表达式,它几乎只匹配我们感兴趣的行,如下所示:

# 搜索以 'X' 开头,后跟任何非空白字符和 ':'
# 再后跟一个空格和任何数字的行。
# 该数字可以包含小数。
import re
hand = open('mbox-short.txt')
for line in hand:
line = line.rstrip()
if re.search('^X\S*: [0-9.]+', line):
print(line)

# 代码: https://www.py4e.com/code3/re10.py

当我们运行程序时,我们看到数据被很好地过滤,只显示了我们正在寻找的行。

X-DSPAM-Confidence: 0.8475
X-DSPAM-Probability: 0.0000
X-DSPAM-Confidence: 0.6178
X-DSPAM-Probability: 0.0000
...

但现在我们必须解决提取数字的问题。虽然使用 split 足够简单,但我们可以使用正则表达式的另一个特性来同时搜索和解析行。

圆括号是正则表达式中的另一个特殊字符。当你向正则表达式添加圆括号时,它们在匹配字符串时会被忽略。但是当你使用 findall() 时,圆括号表示虽然你希望整个表达式都匹配,但你只对提取匹配正则表达式的子字符串的一部分感兴趣。

所以我们对程序做如下更改:

# 搜索以 'X' 开头,后跟任何非空白字符和 ':'
# 再后跟一个空格和任何数字的行。该数字可以包含小数。
# 然后如果数字大于零,则打印该数字。
import re
hand = open('mbox-short.txt')
for line in hand:
line = line.rstrip()
x = re.findall('^X\S*: ([0-9.]+)', line)
if len(x) > 0:
print(x)

# 代码: https://www.py4e.com/code3/re11.py

我们没有调用 search(),而是在表示浮点数的正则表达式部分周围添加了圆括号,以表明我们只希望 findall() 返回匹配字符串的浮点数部分。

这个程序的输出如下:

['0.8475']
['0.0000']
['0.6178']
['0.0000']
['0.6961']
['0.0000']
...

数字仍然在一个列表中,并且需要从字符串转换成浮点数,但我们已经利用了正则表达式的威力来同时搜索和提取我们感兴趣的信息。

作为这种技术的另一个例子,如果你查看文件,会发现有许多形如:

Details: http://source.sakaiproject.org/viewsvn/?view=rev&rev=39772

的行。如果我们想使用与上面相同的技术提取所有修订号(这些行末尾的整数),我们可以编写以下程序:

# 搜索以 'Details: rev=' 开头
# 后跟数字的行
# 如果找到数字,则打印该数字
import re
hand = open('mbox-short.txt')
for line in hand:
line = line.rstrip()
x = re.findall('^Details:.*rev=([0-9]+)', line)
if len(x) > 0:
print(x)

# 代码: https://www.py4e.com/code3/re12.py

翻译我们的正则表达式,我们正在寻找以 Details: 开头,后跟任意数量的字符 (.*),再后跟 rev=,然后是一个或多个数字的行。我们想找到匹配整个表达式的行,但我们只想提取行末尾的整数,所以我们将 [0-9]+ 用圆括号括起来。

当我们运行程序时,我们得到以下输出:

['39772']
['39771']
['39770']
['39769']
...

请记住,[0-9]+ 是“贪婪的”,它会尝试尽可能构成最长的数字字符串,然后再提取这些数字。这种“贪婪”行为是为什么我们为每个数字都得到了所有五位数字。正则表达式模块会向两个方向扩展,直到遇到非数字字符,或者行的开头或结尾。

现在我们可以使用正则表达式重做本书前面我们对每封邮件的时间感兴趣的一个练习。我们寻找形如:

From [email protected] Sat Jan  5 09:14:16 2008

的行,并想提取每一行的小时。之前我们通过两次调用 split 来完成此操作。首先将行分割成单词,然后我们取出第五个单词并再次使用冒号字符对其进行分割,以提取我们感兴趣的两个字符。

虽然这能行,但它实际上导致了相当脆弱的代码,因为它假定行的格式良好。如果你要添加足够的错误检查(或一个大的 try/except 块)来确保你的程序在遇到格式不正确的行时永远不会失败,那么代码将膨胀到 10-15 行,而且相当难读。

我们可以用以下正则表达式以更简单的方式完成此操作:

^From .* [0-9][0-9]:

这个正则表达式的翻译是,我们正在寻找以 From 开头(注意空格),后跟任意数量的字符 (.*),再后跟一个空格,再后跟两个数字 [0-9][0-9],最后是一个冒号字符的行。这就是我们正在寻找的那种行的定义。

为了仅使用 findall() 提取小时,我们在两个数字周围添加圆括号,如下所示:

^From .* ([0-9][0-9]):

这导致了以下程序:

# 搜索以 From 和一个字符开头
# 后跟一个 00 到 99 之间的两位数,再后跟 ':' 的行
# 如果找到数字,则打印该数字
import re
hand = open('mbox-short.txt')
for line in hand:
line = line.rstrip()
x = re.findall('^From .* ([0-9][0-9]):', line)
if len(x) > 0: print(x)

# 代码: https://www.py4e.com/code3/re13.py

当程序运行时,它产生以下输出:

['09']
['18']
['16']
['15']
...

转义字符

由于我们在正则表达式中使用特殊字符来匹配行的开头或结尾或指定通配符,我们需要一种方法来表明这些字符是“普通的”,并且我们想要匹配实际的字符,例如美元符号或脱字符。

我们可以通过在该字符前加上反斜杠来表明我们只想匹配一个字符。例如,我们可以使用以下正则表达式查找金额。

import re
x = 'We just received $10.00 for cookies.'
y = re.findall('\$[0-9.]+',x)

由于我们在美元符号前加上了反斜杠,它实际上匹配输入字符串中的美元符号,而不是匹配“行尾”,正则表达式的其余部分匹配一个或多个数字或句点字符。注意: 在方括号内,字符不是“特殊的”。所以当我们说 [0-9.] 时,它确实意味着数字或句点。在方括号外,句点是“通配符”字符并匹配任何字符。在方括号内,句点就是句点。

总结

虽然这仅仅触及了正则表达式的表面,但我们已经对正则表达式的语言有所了解。它们是包含特殊字符的搜索字符串,这些特殊字符向正则表达式系统传达了你关于什么定义了“匹配”以及从匹配的字符串中提取什么内容的意愿。以下是一些特殊字符和字符序列:

^ 匹配行的开头。 $ 匹配行的结尾。 . 匹配任何字符(通配符)。 \s 匹配一个空白字符。 \S 匹配一个非空白字符(与 \s 相反)。 * 应用于紧邻的前面的字符,并表示匹配零次或多次。 *? 应用于紧邻的前面的字符,并表示以“非贪婪模式”匹配零次或多次。 + 应用于紧邻的前面的字符,并表示匹配一次或多次。 +? 应用于紧邻的前面的字符,并表示以“非贪婪模式”匹配一次或多次。 ? 应用于紧邻的前面的字符,并表示匹配零次或一次。 ?? 应用于紧邻的前面的字符,并表示以“非贪婪模式”匹配零次或一次。 [aeiou] 匹配单个字符,只要该字符在指定的集合中。在这个例子中,它将匹配“a”、“e”、“i”、“o”或“u”,但不匹配其他字符。 [a-z0-9] 你可以使用减号指定字符范围。这个例子是一个必须是小写字母或数字的单个字符。 [^A-Za-z] 当集合表示法中的第一个字符是脱字符时,它会反转逻辑。这个例子匹配任何不是大写或小写字母的单个字符。 () 当向正则表达式添加圆括号时,它们在匹配时会被忽略,但允许你在使用 findall() 时提取匹配字符串的特定子集而不是整个字符串。 \b 匹配空字符串,但仅在单词的开头或结尾。 \B 匹配空字符串,但不在单词的开头或结尾。 \d 匹配任何十进制数字;等同于集合 [0-9]\D 匹配任何非数字字符;等同于集合 [^0-9]

Unix / Linux 用户的额外部分

自 20 世纪 60 年代以来,使用正则表达式搜索文件的支持已内置于 Unix 操作系统中,并且它以某种形式存在于几乎所有编程语言中。

事实上,Unix 中内置了一个名为 grep(Generalized Regular Expression Parser,通用正则表达式解析器)的命令行程序,它所做的与本章中的 search() 示例几乎相同。因此,如果你有 Macintosh 或 Linux 系统,你可以在命令行窗口中尝试以下命令。

$ grep '^From:' mbox-short.txt
From: [email protected]
From: [email protected]
From: [email protected]
From: [email protected]

这告诉 grep 显示文件 mbox-short.txt 中以字符串“From:”开头的行。如果你稍微试验一下 grep 命令并阅读 grep 的文档,你会发现 Python 中的正则表达式支持与 grep 中的正则表达式支持之间存在一些细微的差异。例如,grep 不支持非空白字符 \S,所以你需要使用稍微复杂一点的集合表示法 [^ ],它仅表示匹配任何不是空格的字符。

调试

Python 有一些简单而基础的内置文档,如果你需要快速复习以唤起你对特定方法确切名称的记忆,这些文档会很有帮助。可以在交互模式下的 Python 解释器中查看此文档。

你可以使用 help() 调出交互式帮助系统。

>>> help()

help> modules

如果你知道想要使用哪个模块,可以使用 dir() 命令查找模块中的方法,如下所示:

>>> import re
>>> dir(re)
[.. 'compile', 'copy_reg', 'error', 'escape', 'findall',\
'finditer', 'match', 'purge', 'search', 'split', 'sre_compile',\
'sre_parse', 'sub', 'subn', 'sys', 'template']

你还可以使用 help 命令结合所需的方法来获取关于特定方法的少量文档。

>>> help (re.search)
Help on function search in module re:

search(pattern, string, flags=0)
Scan through string looking for a match to the pattern, returning
a match object, or None if no match was found.
>>>

内置文档不是很详尽,但当你赶时间或者无法访问 Web 浏览器或搜索引擎时,它可能会很有帮助。

术语表

脆弱的代码 (brittle code) 当输入数据符合特定格式时可以工作,但如果与正确格式有任何偏差就容易崩溃的代码。我们称之为“脆弱的代码”,因为它很容易被破坏。 贪婪匹配 (greedy matching) 正则表达式中 +* 字符向外扩展以匹配尽可能长的字符串的概念。 grep 大多数 Unix 系统中可用的命令,用于在文本文件中搜索匹配正则表达式的行。该命令名称代表“Generalized Regular Expression Parser”(通用正则表达式解析器)。 正则表达式 (regular expression) 一种用于表达更复杂搜索字符串的语言。正则表达式可能包含特殊字符,指示搜索仅在行的开头或结尾匹配或许多其他类似功能。 通配符 (wild card) 匹配任何字符的特殊字符。在正则表达式中,通配符是句点。

练习

练习 1: 编写一个简单的程序来模拟 Unix 上的 grep 命令的操作。要求用户输入一个正则表达式,并计算匹配该正则表达式的行数:

$ python grep.py
输入一个正则表达式: ^Author
mbox.txt 有 1798 行匹配 ^Author

$ python grep.py
输入一个正则表达式: ^X-
mbox.txt 有 14368 行匹配 ^X-

$ python grep.py
输入一个正则表达式: java$
mbox.txt 有 4175 行匹配 java$

练习 2: 编写一个程序来查找以下形式的行:

New Revision: 39772

使用正则表达式和 findall() 方法从每行中提取数字。计算这些数字的平均值,并将平均值打印为整数。

输入文件:mbox.txt
38549

输入文件:mbox-short.txt
39756

如果你在本书中发现错误,欢迎使用 Github 给我发送修正。