第五章 重复匹配
概览
+
匹配一个或多个字符 (字符集合).*
匹配零个或多个字符 (字符集合).?
匹配零个或一个字符 (字符集合).{n}
匹配n个字符 (字符集合){m, n}
至少匹配 m 个, 至多匹配 n 个字符 (字符集合).{n,}
至少匹配 n 个字符 (字符集合).*?
,+?
,{n,}?
懒惰型的重复匹配.
5.1 有多少个匹配
前面已经介绍了正则表达式的基础知识, 以及如何在 Python 编程中的应用. 但是目前学习的知识, 都有很大的局限性, 它基本只能匹配固定的字符. 所以它的作用就大打折扣了, 因为现实世界是复杂多变的, 而且正则表达式的真正威力在于用有限来描述无限. 所以, 接下来让我们来学习如何在正则表达式中进行任意的匹配操作吧!
注意 前面几篇博客, 有点照本宣科了, 纯粹是学习的记录 + 示例的 Python 代码了. 我感觉这样效果不是很好, 对于我自己来说, 我基本上书上面的内容都看了, 收获很多, 但是对于读者来说不是那么友好. 就我个人而言, 我喜欢短小精悍的博客, 用较少的篇幅, 可以讲解到知识点. 至于说, 真正的学习, 那我还是建议通过深入阅读书籍 + 代码实践才是最佳之道. 博客太长的话, 容易让人失去耐心, 不过我的水平有限, 只能说尽量朝着这个方向去做吧.
如下是一个电子邮件地址: text@text.text
.
通过前面学习的知识, 写出匹配它的正则表达式: \w@\w\.\w
. 这个表达式本身没有任何错误, 但是他也没有任何实际用处. 它只能匹配形如 a@b.c
的电子邮件地址. 因为 \w
只能匹配单个字符, 但是我们自己也不知道需要匹配多少个字符?
例如, 下面这些有效的电子邮件地址, 但是它们在 @ 前面的字符个数都不一样:
- b@forta.com
- ben@forta.com
- bforta@forta.com
所以, 我们需要知道如何匹配多个字符, 而这可以通过几个特殊的元字符来做到.
5.1.1 匹配一个多多个字符
+
匹配一个或多个字符 (至少一个, 不匹配零个字符的情况). 例如: a+
, 匹配 a
, aa
, aaa
…
这里先来看一个简单的例子, 这个例子不是书上的, 是我临时想的一个匹配数字的例子, 它看起来更加简单一点.
import re
# 测试文本
text = """
0
10
100
1000
10000
"""
# 正则表达式
REGEXP = r'\d+'
# 编译
pattern = re.compile(REGEXP)
# 匹配, 返回值是数组
rs = pattern.findall(text)
if rs:
print(rs)
else:
print("No match!")
# 这里补充一个勘误, 前面我说了匹配全部, 有两个方法,
# 一个是 findall, 另一个是 finditer, 我当时只是简单说了,
# 一个是数组, 一个是迭代器. 但是这里是有点问题的, 有一个
# 地方没有说清楚. 我在这里说一下, 前面的就不改了.
# findall 返回的是匹配结果的数组;
# finditer 返回的是匹配对象的数组.
# 这里用上面这个例子来说明.
# 匹配, 返回值是迭代器
ms = pattern.finditer(text)
if ms:
print("迭代器对象", ms)
print("遍历迭代器对象: ")
for m in ms:
print(m)
else:
print("No match!")
# 数组是结果的数组, 但是迭代器是匹配对象的迭代器, 不是结果的迭代器.
# 所以这一点差异是很重要的, 因为通过匹配对象我们可以做更多的操作,
# 反而数组因为丢失了部分元信息, 导致一些操作是完成不了的. 我这里
# 使用数组, 是因为我们只是简单的搜索匹配, 所以也不需要其他的元信息了,
# 但是之后的内容, 我们是必须要使用迭代器的.
\d+
匹配一个或多个数字字符, 这样上面一个或者多个数字字符都是可以匹配的了.
我们接着看那个邮件地址匹配的例子, 这次使用 +
来匹配一个或多个字符.
import re
text = """
Send personal email to ben@forta.com. for questions
about a book use support@forta.com. Feel free to send
unsolicited email to spam@forta.com (wouldn't it be
nice if it were that simple, huh?).
"""
# 正则表达式
REGEXP = r"\w+@\w+\.\w+"
# 编译
pattern = re.compile(REGEXP)
# 匹配
rs = pattern.findall(text)
if rs:
print(rs)
else:
print("No match!")
提示 + 是一个元字符, 如果需要匹配 + 本身, 就必须使用转义序列
\+
.
+
还可以用来匹配一个或多个字符集合.下面来演示这种用法, 使用相同的正则表达式, 但是测试的文本和上面有一些不同:
# 电子邮件地址
# ben@forta.com
# ben.forta@forta.com
# support@forta.com
# ben@urgent.forta.com
# spam@forta.com
import re
text = """
Send persona1 email to ben@forta.com. or
ben.forta@forta.com. For questions about a
book use support@forta.com. If your message
is urgent try ben@urgent.forta.com. Feel
free to send unsolicited email to
spam@forta.com (wouldn't it be nice if
it were that simple, huh?).
"""
# 正则表达式
REGEXP = r"\w+@\w+\.\w+"
# 编译
pattern = re.compile(REGEXP)
# 匹配
rs = pattern.findall(text)
if rs:
print(rs)
else:
print("No match!")
总共有五个电子邮件, 匹配结果也是5个, 但是其中有两个匹配的不完整. 因为正则表达式 \w+@\w+\.\w+
并没有考虑到 @
之前的 .
字符, 它只允许 @
之后的两个字符串之间出现单个 \.
. 因为 \w
只能匹配字母数字字符, 无法匹配出现在字符串中间的 .
字符.
在这里, 需要匹配 \w
或 .
. 用正则表达式语言来说, 就是匹配字符集合 [\w.]
. 下面是上面程序的改进版本:
import re
text = """
Send persona1 email to ben@forta.com. or
ben.forta@forta.com. For questions about a
book use support@forta.com. If your message
is urgent try ben@urgent.forta.com. Feel
free to send unsolicited email to
spam@forta.com (wouldn't it be nice if
it were that simple, huh?).
"""
# 正则表达式
REGEXP = r"[\w.]+@[\w.]+\.\w+"
# 编译
pattern = re.compile(REGEXP)
# 匹配
rs = pattern.findall(text)
if rs:
print(rs)
else:
print("No match!")
这个新的正则表达式用了些技巧, [\w.]+
匹配字母数字字符, 下划线和 .
的一次或多次重复出现, 而 ben.forta
完全符合这一条件. @
字符之后也用到了 [\w.]+
, 这样就可以匹配到层级更深的域 (或主机) 名.
注意 这个正则表达式的最后一部分是
\w+
而不是[\w.]+
, 如果用了后者,会导致在第二, 第三和第四个匹配上出现问题.
注意 在字符集合里面,
[\w.]
和[\w\.]
是一样的, 一般来说, 但在字符集合里使用的时候, 像.
和+
这样的元字符将被解释为普通字符, 不需要额外转义, 但转义了也没有坏处.[\w.]
的使用效果与[\w\.]
是一样的.
5.1.2 匹配零个或多个字符
+
匹配一个或多个字符, 但不匹配零个字符, +
最少也要匹配一个字符. 那么, 如果你想匹配一个可有可无的字符. 所以如果想要匹配零个或多个字符, 我们需要使用 *
, 它的使用方式和 +
完全一样.
import re
# 测试文本
text = """
Hello .ben@forta.com is my email address.
"""
# 正则表达式
REGEXP = r'[\w.]+@[\w.]+\w+'
# 编译
pattern = re.compile(REGEXP)
# 匹配
rs = pattern.findall(text)
if rs:
print(rs)
else:
print("No match!")
5.1.3 匹配零个或 1 个字符
?
匹配某个字符 (或字符集合) 的零次或一次出现, 最多不超过一次. ?
非常适合匹配一段文本中某个特定的可选字符.
import re
import re
# 测试文本
text = """
The URL is http://www.forta.com/, to connect
securely use https://www.forta.com/ instead.
"""
# 正则表达式
REGEXP = r'http:\/\/[\w.\/]+'
# 编译
pattern = re.compile(REGEXP)
# 匹配
rs = pattern.findall(text)
if rs:
print(rs)
else:
print("No match!")
这里只能匹配到 http
开头的 URL, 无法匹配到 https
开头的. 如果使用 *
来匹配, 那么有可能匹配到 httpsssssss://
这样开头的无效 URL. 虽然通常使用 *
也可以解决问题, 因为这样无效的 URL 很少见. 但是, 如果要真正解决问题, 我们应该使用 ?
来匹配.
那么, 正则表达式就是: https?:\/\/[\w.\/]+
. 代码和上面的一样, 只是正则表达式换了.
这个正则表达式中 ?
的含义是: 前面的字符 (s
) 要么不出现, 要么最多出现一次. 即, 它只能匹配 http://
或者 https://
开头的 URL.
提示 这里可以将
s
作为一个单独的字符集合, 这样可以增加可读性.
http[s]?:\/\/[\w.\/]+
同样, 使用 ?
可以解决之前匹配不同操作系统中的文件换行符问题. 在 Windows 系统上, 使用 \r\n
, 在 Unix 或 Linux 系统上使用 \n
.
那么正则表达式可以这样来表示: [\r]?\n[\r]\n
. 这里将 \r
作为一个单独的字符集合, 这样做是为了增加可读性.
?
是一个元字符, 如果要匹配?
本身, 就必须使用转义序列\?
.
5.2 匹配的重复次数
让我们来回顾一下, 已经学习的这个重复匹配字符从作用:
+
匹配一个或多个字符或字符集合*
匹配零个或多个字符或字符集合?
匹配零个或 1 个字符集合.
大家发现了什么局限性吗? 如果用一个数轴表示的话, 它们可以匹配 0 个, 1 个, 任意个字符. 但是有时我们并不希望匹配任意个字符, 而是匹配有限个确定的字符.
为了解决这个问题并对重复性匹配有更多个控制权, 正则表达式允许使用重复范围 (interval). 重复范围在 {
和 }
之间指定.
注意
{
和}
是元字符.如果需要匹配自身, 就应该用\
对其进行转义. 但是, 即使你忘记进行转义, 大部分正则表达式实现也能正确地处理它们. 不过, 最好不要依赖这种行为. 在需要吧{
和}
当作普通字符来匹配的场合, 应该对其 进行转义.
5.2.1 具体的重复匹配
要先设置具体的匹配次数, 把数字写在 {
和 }
之间即可. 例如: {3}
意味着匹配前一个字符 (或字符集合) 3次.
下面来看一个匹配 RGB 值的例子, 这是前面已经演示的例子, 当时的正则表达式写法是:
#[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]
其实前面, 已经可以看出来了, 这个正则虽然很长, 但是后面的部分都是重复的, 所以我们其实只需要这样写就可以了: #[0-9A-Fa-f]{6}
. 至于演示代码, 这里就不演示了, 想了解这个示例的读者, 可以去参考之前的内容.
5.2.2 区间范围
{}
语法还可以用来为重复匹配次数设定一个区间范围, 也就是匹配的最小次数和最大次数. 它的形式为: {m, n}
. 即匹配次数最少为 m, 最多为n.
下面这个例子中, 将使用一个这样的正则表达式来检查日期的格式:
这里列出的日期格式是一些由于用户可能通过表单字段输入的值, 这些值必须向进行验证, 确保格式正确. \的{1, 2}
匹配一个或两个数字字符 (匹配天数和月份); \d{2,4}
匹配年份; [-\/]
(请注意, 这个 \/
其实是一个 \
和一个 /
) 匹配日期分隔符 -
或 /
. 下面的例子, 总共匹配了 3 个日期值, 但 2/2/2
不在匹配结果里, 因为它的年份太短了.
import re
# 测试文本
text = """"
4/6/17
10-6-2018
2/2/2
01-01-01
"""
# 正则表达式
REGEXP = r'\d{1,2}[-\/]\d{1,2}[-\/]\d{2,4}'
# 编译
pattern = re.compile(REGEXP)
# 匹配
rs = pattern.findall(text)
if rs:
print(rs)
else:
print("No match!")
注意, 上面这个例子里的模式并不能验证日期的有效性, 诸如 54/67/9999
之类的无效日期也能通过这一个测试. 它只能用来检查日期值的格式是否正确 (这一环节通常安排在日期有效性验证之前).
注意 重复范围也可以从 0 开始. 比如,
{0,3
表示重复次数可以是 0, 1, 2 或 3. 所以, 其实?
等价于{0,3}
.
5.2.3 匹配 “至少重复多少次”
重复范围的最后一种用法是指定至少要匹配多少次 (不指定最大匹配次数). 比如说, {3,}
表示至少重复 3 次, 换句话说, 就是 “重复 3 次或更多次”.
下面来看一个例子, 这里使用一个正则表达式把所有金额大于或等于 100 美元的订单找出来:
import re
# 测试文本
text = """"
1001: $496.80
1002: $1290.69
1003: $26.43
1004: $613.42
1005: $7.61
1006: $414.90
1007: $25.00
"""
# 正则表达式
REGEXP = r'\d+: \$\d{3,}\.\d{2}'
# 编译
pattern = re.compile(REGEXP)
# 匹配
rs = pattern.findall(text)
if rs:
print(rs)
else:
print("No match!")
这个例子里第一列是订单号, 第二列是订单金额. 这里使用的正则表达式首先使用 \d+
来匹配订单号. 然后是 \$\d{3,}\.\d{2}
用来匹配金额部分, 其中 \$
匹配 $
, \d{3,}
匹配至少 3 位数字, \.
匹配 .
, \d{2}
匹配小数点后面的 2 位数字. 该模式从所有订单中正确匹配到了 4 个符合要求的订单.
提示 在使用重复范围的时候一定要小心. 如果你遗漏了花括号里面的逗号, 那么模式的含义将从至少匹配 n 次变成只匹配 n 次.
注意
+
在功能上等价于{1,}
5.3 防止过度匹配
当我们使用 *
和 +
时, 它们的匹配是没有上限的, 即如果可能就会一直匹配下去, 这样会造成过度匹配的现象.
之前的例子都是经过了精心挑选的, 不存在过度匹配的问题. 下面, 我们来看一个会导致过度匹配的例子, 这里的任务是使用正则表达式匹配 <b>
标签中的文本 (可能是为了替换格式).
import re
# 测试文本
text = """"
This offer is not available to customers
living in <b>AK</b> and <b>HI</b>.
"""
# 正则表达式
REGEXP = r'<[Bb]>.*<\/[Bb]>'
# 编译
pattern = re.compile(REGEXP)
# 匹配
rs = pattern.findall(text)
if rs:
print(rs)
else:
print("No match!")
<[Bb]>
匹配其实 <b>
标签 (大小写均可), <\/[Bb]>
匹配闭合 </b>
标签 (也是大小写均可). 但是这个模式只找到了一个匹配, 而不是预期的两个. 因为匹配标签中间的内容使用了 .*
它会尝试尽可能长的匹配, 所以只会在最后一个 </b>
处停止匹配.
那么这是为什么呢? 因为 +
, *
都是所谓的 “贪婪型” (greedy) 元字符, 其匹配行为是多多益善而不是适可而止. 它们会尽可能地从一段文本的开头一直匹配到末尾, 而不是碰到第一个匹配时就停止. 这是有意设计的, 量词就是贪婪的.
注意
+
,*
和?
也叫做 “量词” (quantifier). 如果阅读英文文档的话, 会遇到这个词.
所以, 如果我们不需要这种 “贪婪行为” 的时候该怎么办呢? 答案是使用这些量词的 “懒惰型” (lazy) 版本 (之所以称之为 “懒惰型” 是因为其匹配尽可能少的字符, 而非尽可能多地去匹配). 懒惰型量词的写法是在贪婪型量词后面加上一个 ?
. 下面来列出贪婪型量词及其对应的懒惰型版本.
贪婪型量词 | 懒惰型量词 |
---|---|
* | *? |
+ | +? |
{n,} | {n,}? |
还是上面那个匹配 <b>
标签例子, 来让我们看一下吧.
import re
# 测试文本
text = """"
This offer is not available to customers
living in <b>AK</b> and <b>HI</b>.
"""
# 正则表达式
REGEXP = r'<[Bb]>.+?<\/[Bb]>'
# 编译
pattern = re.compile(REGEXP)
# 匹配
rs = pattern.findall(text)
if rs:
print(rs)
else:
print("No match!")
问题解决了. 因为使用了懒惰型的 *?
, 第一个匹配将仅限于 <b>AK</b>
, <b>HI</b>
则成为了第二个匹配.