10.3.8 re
有些人面临问题时会想:“我知道,我将使用正则表达式来解决这个问题。”这让他们面临的问题变成了两个。
——Jamie Zawinski
模块re提供了对正则表达式的支持。如果你听说过正则表达式,就可能知道它们有多厉害;如果没有,就等着大吃一惊吧。
然而,需要指出的是,要掌握正则表达式有点难。关键是每次学习一点点:只考虑完成特定任务所需的知识。预先将所有的知识牢记在心毫无意义。本节描述模块re和正则表达式的主要功能,让你能够快速上手。
提示 除标准文档外, Andrew Kuchling撰写的文章“Regular Expression HOWTO”(https://docs.python.org/3/howto/regex.html)也是很有用的Python正则表达式学习资料。
1. 正则表达式是什么
正则表达式是可匹配文本片段的模式。最简单的正则表达式为普通字符串,与它自己匹配。换而言之,正则表达式'python'与字符串'python'匹配。你可使用这种匹配行为来完成如下工作:在文本中查找模式,将特定的模式替换为计算得到的值,以及将文本分割成片段。
通配符
正则表达式可与多个字符串匹配,你可使用特殊字符来创建这种正则表达式。例如,句点与除换行符外的其他字符都匹配,因此正则表达式'.ython'与字符串'python'和'jython'都匹配。它还与'qython'、 '+ython'和' ython'(第一个字符为空格)等字符串匹配,但不与'cpython'、'ython'等字符串匹配,因为句点只与一个字符匹配,而不与零或两个字符匹配。
句点与除换行符外的任何字符都匹配,因此被称为通配符(wildcard)。
对特殊字符进行转义
普 通 字符 只与 自 己匹 配 , 但 特殊 字符 的 情况 完全 不 同 。 例如 , 假设 要匹 配 字符 串'python.org',可以直接使用模式'python.org'吗?可以,但它也与'pythonzorg'匹配(还记得吗?句点与除换行符外的其他字符都匹配),这可能不是你想要的结果。要让特殊字符的行为与普通字符一样,可对其进行转义:像第1章对字符串中的引号进行转义时所做的那样,在它前面加上一个反斜杠。因此,在这个示例中,可使用模式'python\\.org',它只与'python.org'匹配。
请注意,为表示模块re要求的单个反斜杠,需要在字符串中书写两个反斜杠,让解释器对其进行转义。换而言之,这里包含两层转义:解释器执行的转义和模块re执行的转义。实际上,在有些情况下也可使用单个反斜杠,让解释器自动对其进行转义,但请不要这样依赖解释器。如果你厌烦了两个反斜杆,可使用原始字符串,如r'python\.org'。
字符集
匹配任何字符很有用,但有时你需要更细致地控制。为此,可以用方括号将一个子串括起,创建一个所谓的字符集。这样的字符集与其包含的字符都匹配,例如'[pj]ython'与'python'和'jython'都匹配,但不与其他字符串匹配。你还可使用范围,例如'[a-z]'与a~z的任何字母都匹配。你还可组合多个访问,方法是依次列出它们,例如'[a-zA-Z0-9]'与大写字母、小写字母和数字都匹配。请注意,字符集只能匹配一个字符。
要指定排除字符集,可在开头添加一个^字符,例如'[^abc]'与除a、 b和c外的其他任何字符都匹配。
字符集中的特殊字符
一般而言,对于诸如句点、星号和问号等特殊字符,要在模式中将其用作字面字符而不是正则表达式运算符,必须使用反斜杠对其进行转义。在字符集中,通常无需对这些字符进行转义,但进行转义也是完全合法的。然而,你应牢记如下规则。
脱字符(^)位于字符集开头时,除非要将其用作排除运算符,否则必须对其进行转义。换而言之,除非有意为之,否则不要将其放在字符集开头。
同样,对于右方括号(])和连字符(-),要么将其放在字符集开头,要么使用反斜
杠对其进行转义。实际上,如果你愿意,也可将连字符放在字符集末尾。
二选一和子模式
需要以不同的方式处理每个字符时,字符集很好,但如果只想匹配字符串'python'和'perl',该如何办呢?使用字符集或通配符无法指定这样的模式,而必须使用表示二选一的特殊字符:管道字符(|)。所需的模式为'python|perl'。
然而,有时候你不想将二选一运算符用于整个模式,而只想将其用于模式的一部分。为此,可将这部分(子模式)放在圆括号内。对于前面的示例,可重写为'p(ython|erl)'。请注意,单个字符也可称为子模式。
可选模式和重复模式
通过在子模式后面加上问号,可将其指定为可选的,即可包含可不包含。例如,下面这个不太好懂的模式:
r'(http://)?(www\.)?python\.org'
对于这个示例,需要注意如下几点。
我对句点进行了转义,以防它充当通配符。
为减少所需的反斜杠数量,我使用了原始字符串。
每个可选的子模式都放在圆括号内。
每个可选的子模式都可以出现,也可以不出现。
问号表示可选的子模式可出现一次,也可不出现。还有其他几个运算符用于表示子模式可重复多次。
(pattern)*: pattern可重复0、 1或多次。
(pattern)+: pattern可重复1或多次。
(pattern):模式可从父m~n次。
注意 在这里,术语匹配指的是与整个字符串匹配,而函数match(参见表10-9)只要求模式与字符串开头匹配。
字符串的开头和末尾
到目前为止,讨论的都是模式是否与整个字符串匹配,但也可查找与模式匹配的子串,如字符串'www.python.org'中的子串'www'与模式'w+'匹配。像这样查找字符串时,有时在整个字符串开头或末尾查找很有用。例如,你可能想确定字符串的开头是否与模式'ht+p'匹配,为此可使用脱字符('^')来指出这一点。例如, '^ht+p'与'http://python.org'和'htttttp://python.org'匹配,但与'www.http.org'不匹配。同样,要指定字符串末尾,可使用美元符号($)。
注意 完整的正则表达式运算符清单请参阅Python库中的Regular Expression Syntax部分。
2. 模块re的内容
如果没有用武之地,知道如何书写正则表达式也没多大意义。模块re包含多个使用正则表达式的函数,表10-9描述了其中最重要的一些。
表10-9 模块re中一些重要的函数
函 数 描 述
compile(pattern[, flags]) 根据包含正则表达式的字符串创建模式对象
search(pattern, string[, flags]) 在字符串中查找模式
match(pattern, string[, flags]) 在字符串开头匹配模式
split(pattern, string[, maxsplit=0]) 根据模式来分割字符串
findall(pattern, string) 返回一个列表,其中包含字符串中所有与模式匹配的子串
sub(pat, repl, string[, count=0]) 将字符串中与模式pat匹配的子串都替换为repl
escape(string) 对字符串中所有的正则表达式特殊字符都进行转义
函数re.compile将用字符串表示的正则表达式转换为模式对象,以提高匹配效率。调用search、 match等函数时,如果提供的是用字符串表示的正则表达式,都必须在内部将它们转换为模式对象。通过使用函数compile对正则表达式进行转换后,每次使用它时都无需再进行转换。模式对象也有搜索/匹配方法,因此re.search(pat, string)(其中pat是一个使用字符串表示的正则表达式)等价于pat.search(string)(其中pat是使用compile创建的模式对象)。编译后的正则
表达式对象也可用于模块re中的普通函数中。
函数re.search在给定字符串中查找第一个与指定正则表达式匹配的子串。如果找到这样的子串,将返回MatchObject(结果为真),否则返回None(结果为假)。鉴于返回值的这种特征,可在条件语句中使用这个函数,如下所示:
if re.search(pat, string):
print('Found it!')
然而,如果你需要获悉有关匹配的子串的详细信息,可查看返回的MatchObject。下一节将更详细地介绍MatchObject。
注意 函数match在模式与字符串开头匹配时就返回True,而不要求模式与整个字符串匹配。如果要求与整个字符串匹配,需要在模式末尾加上一个美元符号。美元符号要求与字符串末尾匹配,从而将匹配检查延伸到整个字符串。
函数re.split根据与模式匹配的子串来分割字符串。这类似于字符串方法split,但使用正则表达式来指定分隔符,而不是指定固定的分隔符。例如,使用字符串方法split时,可以字符串', '为分隔符来分割字符串,但使用re. split时,可以空格和逗号为分隔符来分割字符串。
>>> some_text = 'alpha, beta,,,,gamma delta'
>>> re.split('[, ]+', some_text)
['alpha', 'beta', 'gamma', 'delta']
注意 如果模式包含圆括号,将在分割得到的子串之间插入括号中的内容。例如, re.split('o(o)','foobar')的结果为['f', 'o', 'bar']。
从这个示例可知,返回值为子串列表。参数maxsplit指定最多分割多少次。
>>> re.split('[, ]+', some_text, maxsplit=2)
['alpha', 'beta', 'gamma delta']
>>> re.split('[, ]+', some_text, maxsplit=1)
['alpha', 'beta,,,,gamma delta']
函数re.findall返回一个列表,其中包含所有与给定模式匹配的子串。例如,要找出字符串包含的所有单词,可像下面这样做:
>>> pat = '[a-zA-Z]+'
>>> text = '"Hm... Err -- are you sure?" he said, sounding insecure.'
>>> re.findall(pat, text)
['Hm', 'Err', 'are', 'you', 'sure', 'he', 'said', 'sounding', 'insecure']
要查找所有的标点符号,可像下面这样做:
>>> pat = r'[.?\-",]+'
>>> re.findall(pat, text)
['"', '...', '--', '?"', ',', '.']
请注意,这里对连字符(-)进行了转义,因此Python不会认为它是用来指定字符范围的(如a-z)。
函数re.sub从左往右将与模式匹配的子串替换为指定内容。请看下面的示例:
>>> pat = ''
>>> text = 'Dear ...'
>>> re.sub(pat, 'Mr. Gumby', text)
'Dear Mr. Gumby...'
有关如何更有效地使用这个函数,请参阅随后的一节。
注意 在表10-9中,注意到有些函数接受一个名为flags的可选参数。这个参数可用于修改正则表达式的解读方式。有关这方面的详细信息,请参阅“Python库参考手册”中讨论模块re的部分。
3. 匹配对象和编组
在模块re中,查找与模式匹配的子串的函数都在找到时返回MatchObject对象。这种对象包含与模式匹配的子串的信息,还包含模式的哪部分与子串的哪部分匹配的信息。这些子串部分称为编组(group)。
编组就是放在圆括号内的子模式,它们是根据左边的括号数编号的,其中编组0指的是整个模式。因此,在下面的模式中:
'There (was a (wee) (cooper)) who (lived in Fyfe)'
包含如下编组:
0 There was a wee cooper who lived in Fyfe
1 was a wee cooper
2 wee
3 cooper
4 lived in Fyfe
通常,编组包含诸如通配符和重复运算符等特殊字符,因此你可能想知道与给定编组匹配的内容。例如,在下面的模式中:
r'www\.(.+)\.com$'
编组0包含整个字符串,而编组1包含'www.'和'.com'之间的内容。通过创建类似于这样的模式,可提取字符串中你感兴趣的部分。
表10-10描述了re匹配对象的一些重要方法。
表10-10 re匹配对象的重要方法
方 法 描 述
group([group1, ...]) 获取与给定子模式(编组)匹配的子串
start([group]) 返回与给定编组匹配的子串的起始位置
end([group]) 返回与给定编组匹配的子串的终止位置(与切片一样,不包含终止位置)
span([group]) 返回与给定编组匹配的子串的起始和终止位置
方法group返回与模式中给定编组匹配的子串。如果没有指定编组号,则默认为0。如果只指定了一个编组号(或使用默认值0),将只返回一个字符串;否则返回一个元组,其中包含与给定编组匹配的子串。
注意 除整个模式(编组0)外,最多还可以有99个编组,编号为1~99。
方法start返回与给定编组(默认为0,即整个模式)匹配的子串的起始索引。
方法end类似于start,但返回终止索引加1
方法span返回一个元组,其中包含与给定编组(默认为0,即整个模式)匹配的子串的起始索引和终止索引。
下面的示例说明了这些方法的工作原理:
4. 替换中的组号和函数
在第一个re.sub使用示例中,我只是将一个子串替换为另一个。这也可使用字符串方法replace(参见3.4节)轻松地完成。当然,正则表达式很有用,因为它们让你能够以更灵活的方式进行搜索,还让你能够执行更复杂的替换。
为利用re.sub的强大功能,最简单的方式是在替代字符串中使用组号。在替换字符串中,任何类似于'\\n'的转义序列都将被替换为与模式中编组n匹配的字符串。例如,假设要将'*something*'替换为'something',其中前者是在纯文本文档(如电子邮件)中表示突出的普通方式,而后者是相应的HTML代码(用于网页中)。下面先来创建一个正则表达式。
>>> emphasis_pattern = r'\*([^\*]+)\*'
请注意,正则表达式容易变得难以理解,因此为方便其他人(也包括你自己)以后阅读代码,使用有意义的变量名很重要。
提示
要让正则表达式更容易理解,一种办法是在调用模块re中的函数时使用标志VERBOSE。这
让你能够在模式中添加空白(空格、制表符、换行符等),而re将忽略它们——除非将它放在字符类中或使用反斜杠对其进行转义。在这样的正则表达式中,你还可添加注释。下述代码创建的模式对象与emphasis_pattern等价,但使用了VERBOSE标志:
>>> emphasis_pattern = re.compile(r'''
... \* # 起始突出标志——一个星号
... ( # 与要突出的内容匹配的编组的起始位置
... [^\*]+ # 与除星号外的其他字符都匹配
... ) # 编组到此结束
... \* # 结束突出标志
... ''', re.VERBOSE)
...
创建模式后,就可使用re.sub来完成所需的替换了。
>>> re.sub(emphasis_pattern, r'\1', 'Hello, *world*!')
'Hello, world!'
如你所见,成功地将纯文本转换成了HTML代码。
然而,通过将函数用作替换内容,可执行更复杂的替换。这个函数将MatchObject作为唯一的参数,它返回的字符串将用作替换内容。换而言之,你可以对匹配的字符串做任何处理,并通过细致的处理来生成替换内容。你可能会问,这有何用途呢?等你开始尝试使用正则表达式后,将发现这种机制的用途非常多,随后会介绍其中的一个。
贪婪和非贪婪模式
重复运算符默认是贪婪的,这意味着它们将匹配尽可能多的内容。例如,假设重写了前面的突出程序,在其中使用了如下模式:
>>> emphasis_pattern = r'\*(.+)\*'
这个模式与以星号打头和结尾的内容匹配。好像很完美,不是吗?但情况并非如此。
>>> re.sub(emphasis_pattern, r'\1', '*This* is *it*!')
'This* is *it!'
如你所见,这个模式匹配了从第一个星号到最后一个星号的全部内容,其中包含另外两个星号!这就是贪婪的意思:能匹配多少就匹配多少。
在这里,你想要的显然不是这种过度贪婪的行为。在你知道不应将某个特定的字符包含在内时,本章前面的解决方案(使用一个匹配任何非星号字符的字符集)很好。下面再来看另一个场景:如果使用'**something**'来表示突出呢?在这种情形下,在要强调的内容中包含单个星号不是问题,但如何避免过度贪婪呢?
这实际上很容易,只需使用重复运算符的非贪婪版即可。对于所有的重复运算符,都可在后面加上问号来将其指定为非贪婪的。
>>> emphasis_pattern = r'\*\*(.+?)\*\*'
>>> re.sub(emphasis_pattern, r'\1', '**This** is **it**!')
'This is it!'
这里使用的是运算符+?而不是+。这意味着与以前一样,这个模式将匹配一个或多个通配符,但匹配尽可能少的内容,因为它是非贪婪的。因此,这个模式只匹配到下一个'\*\*',即它末尾的内容。如你所见,效果很好。
5. 找出发件人
你曾将邮件保存为文本文件吗?如果这样做过,你可能注意到文件开头有大量难以理解的文本,如代码清单10-9所示。
代码清单10-9 一组虚构的邮件头
From foo@bar.baz Thu Dec 20 01:22:50 2008
Return-Path:
Received: from xyzzy42.bar.com (xyzzy.bar.baz [123.456.789.42])
by frozz.bozz.floop (8.9.3/8.9.3) with ESMTP id BAA25436
for ; Thu, 20 Dec 2004 01:22:50 +0100 (MET)
Received: from [43.253.124.23] by bar.baz
(InterMail vM.4.01.03.27 201-229-121-127-20010626) with ESMTP
id ; Thu, 20 Dec 2004 00:22:42 +0000
User-Agent: Microsoft-Outlook-Express-Macintosh-Edition/5.02.2022
Date: Wed, 19 Dec 2008 17:22:42 -0700
Subject: Re: Spam
From: Foo Fie
To: Magnus Lie Hetland
CC:
Message-ID: %
In-Reply-To: Mime- version: 1.0
Content-type: text/plain; charset="US-ASCII" Content-transfer-encoding: 7bit
Status: RO
Content-Length: 55
Lines: 6
So long, and thanks for all the spam!
Yours,
Foo Fie
我们来尝试找出这封邮件的发件人。如果你仔细查看上面的文本,肯定能找出发件人(尤其是看到邮件末尾的签名时)。但你能找出普适的规律吗?如何提取发件人姓名(不包含邮件地址)呢?如何列出邮件头中提及的所有邮件地址呢?先来解决第一个问题。
包含发件人的文本行以'From: '打头,并以包含在尖括号()内的邮件地址结尾,你要提取的是这两部分之间的文本。如果使用模块fileinput,这个任务应该很容易完成。解决这个问题的程序如代码清单10-10所示。
注意 如果你愿意,也可在不使用正则表达式的情况下解决这个问题。还可使用模块email来解决这个问题。
代码清单10-10 找出发件人的程序
# find_sender.py
import fileinput, re
pat = re.compile('From: (.*) $')
for line in fileinput.input():
m = pat.match(line)
if m: print(m.group(1))
可像下面这样运行这个程序(假设电子邮件保存在文本文件message.eml中):
$ python find_sender.py message.eml
Foo Fie
对于这个程序,应注意如下几点。
为提高处理效率,我编译了正则表达式。
我将用于匹配要提取文本的子模式放在圆括号内,使其变成了一个编组。
我使用了一个非贪婪模式,使其只匹配最后一对尖括号(以防姓名也包含尖括号)。
我使用了美元符号指出要使用这个模式来匹配整行(直到行尾)。
我使用了if语句来确保匹配后才提取与特定编组匹配的内容。
要列出邮件头中提及的所有邮件地址,需要创建一个只与邮件地址匹配的正则表达式,然后使用方法findall找出所有与之匹配的内容。为避免重复,可将邮件地址存储在本章前面介绍的集合中。最后,提取键,将它们排序并打印出来。
import fileinput, re
pat = re.compile(r'[a-z\-\.]+@[a-z\-\.]+', re.IGNORECASE)
addresses = set()
for line in fileinput.input():
for address in pat.findall(line):
addresses.add(address)
for address in sorted(addresses):
print address
请注意,排序时大写字母在小写字母之前。
注意 这里并没有完全按问题的要求做。问题要求找出邮件头中的地址,但这个程序找出了整个文件中的所有地址。为避免这一点,可在遇到空行后调用fileinput.close(),因为邮件头不可能包含空行。如果有多个文件,也可在遇到空行后调用fileinput.nextfile()来处理下一个文件。
6. 模板系统示例
模板(template)是一种文件,可在其中插入具体的值来得到最终的文本。例如,可能有一个只需插入收件人姓名的邮件模板。 Python提供了一种高级模板机制:字符串格式设置。使用正则表达式可让这个系统更加高级。假设要把所有的'[something]'(字段)都替换为将something作为Python表达式计算得到的结果。因此,下面的字符串:
'The sum of 7 and 9 is [7 + 9].'
应转换为:
'The sum of 7 and 9 is 16.'
另外,你还希望能够在字段中进行赋值,使得下面的字符串:
'[name="Mr. Gumby"]Hello, [name]'
转换成:
'Hello, Mr. Gumby'
这看似很复杂,我们来看看可供使用的工具。
可使用正则表达式来匹配字段并提取其内容。
可使用eval来计算表达式字符串,并提供包含作用域的字典。可在try/except语句中执行这种操作。如果出现SyntaxError异常,就说明你处理的可能是语句(如赋值语句)而不是表达式,应使用exec来执行它。
可使用exec来执行语句字符串(和其他语句),并将模板的作用域存储到字典中。
可使用re.sub将被处理的字符串替换为计算得到的结果。突然间,这看起来并不那么吓人了,不是吗?
提示 如果任务看起来吓人,将其分解为较小的部分几乎总是大有裨益。另外,要对手头的工具进行评估,确定如何解决面临的问题。
代码清单10-11提供了一个示例实现。
代码清单10-11 一个模板系统
# templates.py
import fileinput, re
# 与使用方括号括起的字段匹配
field_pat = re.compile(r'\[(.+?)\]')
# 我们将把变量收集到这里:
scope = {}
# 用于调用re.sub:
def replacement(match):
code = match.group(1)
try:
# 如果字段为表达式,就返回其结果:
return str(eval(code, scope))
except SyntaxError:
# 否则在当前作用域内执行该赋值语句
# 并返回一个空字符串
return ''
# 获取所有文本并合并成一个字符串:
#(还可采用其他办法来完成这项任务,详情请参见第11章)
lines = []
for line in fileinput.input():
lines.append(line)
text = ''.join(lines)
# 替换所有与字段模式匹配的内容:
print(field_pat.sub(replacement, text))
简而言之,这个程序做了如下事情。
定义一个用于匹配字段的模式。
创建一个用作模板作用域的字典。
定义一个替换函数,其功能如下。
从match中获取与编组1匹配的内容,并将其存储到变量code中。
将作用域字典作为命名空间,并尝试计算code,再将结果转换为字符串并返回它。如果成功,就说明这个字段是表达式,因此万事大吉;否则(即引发了SyntaxError异常),就进入下一步。
在对表达式进行求值时使用的命名空间(作用域字典)中执行这个字段,并返回一个空字符串(因为赋值语句没有结果)。
使用fileinput读取所有的行,将它们放在一个列表中,再将其合并成一个大型字符串。
调用re.sub来使用替换函数来替换所有与模式field_pat匹配的字段,并将结果打印出来。
注意 在以前的Python版本中,相比于下面的做法,将文本行放到一个列表中再合并的效率要高得多:
text = ''
for line in fileinput.input():
text += line
上述代码虽然看起来很优雅,但每次赋值都将创建一个新的字符串(在原有字符串后面附加新字符串)。这可能会浪费资源,导致程序运行缓慢。在较旧的Python版本中,这种做法与使用join的差别可能很大;而在较新的版本中,使用运算符+=的速度可能更快。如果性能很重要,可尝试这两种解决方案。如果想更优雅地读取文件中的所有文本,可参阅第11章。
只用15行代码(不包括空白和注释),就创建了一个强大的模板系统。但愿你已认识到,通过使用标准库, Python的功能变得非常强大。为结束这个示例,下面来测试一下这个模板系统:尝试对代码清单10-12所示的简单文件运行它。
代码清单10-12 一个简单的模板示例
[x = 2]
[y = 3]
The sum of [x] and [y] is [x + y].
你应看到如下输出:
The sum of 2 and 3 is 5.
别急,还可以做得更好!由于使用了fileinput,因此可依次处理多个文件。这意味着可以使用一个文件来定义变量的值,并将另一个文件用作模板,以便在其中插入这些值。例如,可能有一个包含定义的文件(magnus.txt,如代码清单10-13所示),还有一个模板文件(template.txt,如代码清单10-14所示)。
代码清单10-14 一个模板
[import time]
Dear [name],
I would like to learn how to program. I hear you
use the [language] language a lot -- is it something I
should consider?
And, by the way, is [email] your correct email address?
Fooville, [time.asctime()]
Oscar Frozzbozz
import time并非赋值语句(而是用于做准备工作的语句),但由于程序没那么挑剔(使用了一条简单的try/except语句),它支持任何可使用eval和exec进行处理的表达式和语句。可像下面这样运行这个程序(假设是在UNIX命令行中):
$ python templates.py magnus.txt template.txt
虽然这个模板系统能够执行非常复杂的替换,但也存在一些缺陷。例如,如果能够以更灵活的方式编写定义文件就好了。如果使用execfile来执行它,就可使用普通Python语法了。这样还将修复输出开头包含空行的问题。
你还能想出其他改进这个程序的方式吗?对于这个程序使用的概念,你还能想到它们的其他用途吗?无论要精通哪种编程语言,最佳的方式都是尝试使用它——找出其局限性和长处。看看你能不能重写这个程序,让它做得更好,并满足你的需求。
领取专属 10元无门槛券
私享最新 技术干货