在前几章里,我们学习了如何使用各种元字符和特殊的字符集合去匹配单个字符。
本章将学习如何匹配多个连续重复出现的字符或字符集合。
5.1 有多少个匹配
你现在已经学会了正则表达式的模式匹配中的基础知识,但目前所有的例子都有一个非常严重的局限。
请大家思考一下,如何构造一个匹配电子邮件地址的正则表达式。电子邮件地址的基本格式如下所示:
text@text.text
利用前一章讨论的元字符,你可能会写出这样的正则表达式:
\w@\w\.\w
\w可以匹配所有的大小写字母、数字的字符(以及下划线字符_,这个字符在电子邮件地址里是有效的),@字符不需要被转义,但.字符需要。
这个正则表达式本身没有任何错误,可它几乎没有任何实际的用处。它只能匹配形如a@b.c的电子邮件地址(虽然在语法方面没有任何问题,但这显然不是一个有效地址)。问题在于\w只能匹配单个字符,而我们无法预知电子邮件地址的各个字段会有多少个字符。举个最简单的例子,下面这些都是有效的电子邮件地址,但它们在@前面的字符个数都不一样:
b@forta.com
ben@forta.com
bforta@forta.com
你需要的是,想办法能够匹配多个字符,这可以通过使用几种特殊的元字符来做到。
5.1.1 + :匹配1~∞个字符
要想匹配某个字符(或字符集合)的一次或多次重复,只要简单地在其后面加上一个+字符就行了。+匹配一个或多个字符(至少一个;不匹配零个字符的情况)。比如,a匹配a本身,a+匹配一个或多个连续出现的a。类似地,[0-9]匹配任意单个数字,[0-9]+匹配一个或多个连续的数字。
提示 在给一个字符集合加上+后缀的时候,必须把+放在这个字符集合的外面。比如说,[0-9]+是正确的,[0-9+]则不正确。
[0-9+]其实也是一个有效的正则表达式,但它匹配的不是一个或多个数字,它定义了一个由数字0到9和+构成的字符集合,因而只能匹配单个的数字字符或加号。虽然有效,但它并不是我们需要的东西。
重新回到电子邮件地址的例子,我们这次使用+来匹配一个或多个字符:
文本
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?).
正则表达式
\w+@\w+\.\w+
结果
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?).
分析
该模式正确地匹配到了所有的3个电子邮件地址。这个正则表达式先用第一个\w+匹配一个或多个字母数字字符,再用第二个\w+匹配@后面的一个或多个字符,然后匹配一个.字符(使用转义序列\.),最后用第三个\w+匹配电子邮件地址的剩余部分。
提示 +是一个元字符。如果需要匹配+本身,就必须使用转义序列\+。
--PostgreSQL
with t1 as (
select 'Send personal email to ben@forta.com. ' txt
union all
select 'For questions about a book use support@forta.com. '
union all
select 'Feel free to send unsolicited email to spam@forta.com.'
union all
--两个单引号表示一个单引号,这样不会妨碍其他字符串的正常输入
select '(wouldn''t it be nice if it were that simple, huh?).'
)
select txt
,regexp_replace(txt,'\w+@\w+\.\w+','XXXXXXX','g')
from t1
+还可以用来匹配一个或多个字符集合。为了演示这种用法,我们在下面这个例子里使用了和刚才一样的正则表达式,但文本内容和上一个例子中稍有不同:
文本
Send personal 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?)
正则表达式
\w+@\w+\.\w+
结果
Send personal 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?)
分析
这个正则表达式匹配到了5个电子邮件地址,但其中有2个不够完整。为什么会这样?
因为\w+@\w+\.\w+并没有考虑到@之前的.字符,它只允许@之后的两个字符串之间出现单个.字符。尽管ben.forta@forta.com是一个完全有效的电子邮件地址,但该正则表达式只能匹配forta(而不是ben.forta),因为\w只能匹配字母数字字符,无法匹配出现在字符串中间的.字符。
--PostgreSQL
with t1 as (
select 'Send personal email to ben@forta.com or ben.forta@forta.com.' txt
union all
select 'For questions about a book use support@forta.com.'
union all
select 'If your message is urgent try ben@urgent.forta.com.'
union all
select 'Feel free to send unsolicited email to spam@forta.com.'
union all
select '(wouldn''t it be nice if it were that simple, huh?).'
)
select txt
,regexp_replace(txt,'\w+@\w+\.\w+','XXXXXXX','g')
from t1
在这里,需要匹配\w或.。用正则表达式语言来说,就是匹配字符集合[\w.]。下面是改进版本:
文本
Send personal 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?)
正则表达式
[\w.]+@[\w.]+\.\w+
结果
Send personal 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?)
分析
新的正则表达式看起来用了些技巧。[\w.]+匹配字母数字字符、下划线和.的一次或多次重复出现,而ben.forta完全符合这一条件。@字符之后也用到了[\w.]+,这样就可以匹配到层级更深的域(或主机)名。
注意 这个正则表达式的最后一部分是\w+而不是[\w.]+,你知道这是为什么吗?如果把[\w.]用作这个模式的最后一部分,在第二、第三和第四个匹配上就会出问题,因为会把该句子末尾的.也匹配进去。
注意 你可能已经注意到了:我们没有对字符集合[\w.]里的.字符进行转义,但依然能够匹配.字符。一般来说,当在字符集合里使用的时候,像.和+这样的元字符将被解释为普通字符,不需要转义,但转义了也没有坏处。[\w.]的使用效果与[\w\.]是一样的。
--PostgreSQL
with t1 as (
select 'Send personal email to ben@forta.com or ben.forta@forta.com. ' txt
union all
select 'For questions about a book use support@forta.com. '
union all
select 'If your message is urgent try ben@urgent.forta.com.'
union all
select 'Feel free to send unsolicited email to spam@forta.com. '
union all
select '(wouldn''t it be nice if it were that simple, huh?).'
)
select txt
,regexp_replace(txt,'[\w.]+@[\w.]+\.\w+','XXXXXXX','g')
from t1
5.1.2 * :匹配0~∞个字符
+匹配一个或多个字符,但不匹配零个字符,+最少也要匹配一个字符。
那么,如果你想匹配一个可有可无的字符,也就是该字符可以出现零次或多次的情况,该怎么办呢?
这种匹配需要用*元字符来完成。*的用法与+完全一样,只要把它放在某个字符(或字符集合)的后面,就可以匹配该字符(或字符集合)出现零次或多次的情况。比如说,模式B.* Forta将匹配B Forta、B. Forta、Ben Forta以及其他组合。
为了演示+的用法,来看一个修改版的电子邮件地址示例:
文本
Hello .ben@forta.com is my email address.
正则表达式
[\w.]+@[\w.]+\.\w+
结果
Hello .ben@forta.com is my email address.
分析
[\w.]+匹配字母数字字符、下划线和.的一次或多次重复出现,而.ben完全符合这一条件。这显然是一个打字错误(文本里多了一个.),不过这无关紧要。更大的问题在于,尽管.是电子邮件地址里的有效字符,但把它用作电子邮件地址的第一个字符就无效了。
--PostgreSQL
with t1 as (
select 'Hello .ben@forta.com is my email address.' txt
)
select txt
,regexp_replace(txt,'[\w.]+@[\w.]+\.\w+','XXXXXXX','g')
from t1
换句话说,你需要匹配的其实是带有可选的额外字符的字母数字文本,就像下面这样:
文本
Hello .ben@forta.com is my email address.
正则表达式
\w+[\w.]*@[\w.]+\.\w+
结果
Hello .ben@forta.com is my email address.
分析
这个模式看起来更难懂了(正则表达式的外表往往比实际看起来复杂),我们一起来看看吧。\w+匹配任意单个字母数字字符(这些可以作为电子邮件地址起始的有效字符),但不包括.。经过开头部分若干个有效字符之后,也许会出现一个.和其他额外的字符,不过也可能没有。[\w.]*匹配.或字母数字字符的零次或多次重复出现,这正是我们所需要的。
注意 可以把*理解为一种“使其可选”(make it optional)的元字符。+需要最少匹配一次,而*可以匹配多次,也可以一次都不匹配。
提示 *是一个元字符。如果需要匹配*本身,就必须使用转义序列\*。
--PostgreSQL
with t1 as (
select 'Hello .ben@forta.com is my email address.' txt
)
select txt
,regexp_replace(txt,'\w+[\w.]*@[\w.]+\.\w+','XXXXXXX','g')
from t1
5.1.3 ?:匹配0~1个字符
另一个非常有用的元字符是?。和+一样,?能够匹配可选文本(所以就算文本没有出现,也可以匹配)。但与+不同,?只能匹配某个字符(或字符集合)的零次或一次出现,最多不超过一次。?非常适合匹配一段文本中某个特定的可选字符。
请看下面这个例子:
文本
The URL is http://www.forta.com/.
It's to connect securely use https://www.forta.com/ instead.
正则表达式
http:\/\/[\w.\/]+[\w\/]
结果
The URL is http://www.forta.com/.
It's to connect securely use https://www.forta.com/ instead.
分析
该模式用来匹配URL地址:http:\/\/(包含两个转义斜杠,因此匹配普通文本)加上[\w.\/]+(匹配字母数字字符、.和/的一次或多次重复出现)。
这个模式只能匹配第一个URL地址(以http://开头的那个),不能匹配第二个(以https://开头的那个)。简单地在http的后面加上一个s*(s的零次或多次重复)并不能真正解决这个问题,因为这样也能匹配httpsssss://(显然是无效的URL)。
--PostgreSQL
with t1 as (
select 'The URL is http://www.forta.com/.' txt
union all
select 'It''s to connect securely use https://www.forta.com/ instead.' txt
)
select txt--对于正斜杠/,转不转义,都可以,如http://[\w./]+[\w/]
,regexp_replace(txt,'http:\/\/[\w.\/]+[\w\/]','XXXXXXX','g')
from t1
怎么办?可以在http的后面加上一个s?,看看下面这个例子:
文本
The URL is http://www.forta.com/.
It's to connect securely use https://www.forta.com/ instead.
正则表达式
https?:\/\/[\w.\/]+[\w\/]
结果
The URL is http://www.forta.com/.
It's to connect securely use https://www.forta.com/ instead.
分析
该模式的开头部分是https?:\/\/。?在这里的含义是:前面的字符(s)要么不出现,要么最多出现一次。换句话说,https?:\/\/既可以匹配http://,也可以匹配https://(仅此而已)。
--PostgreSQL
with t1 as (
select 'The URL is http://www.forta.com/.' txt
union all
select 'It''s to connect securely use https://www.forta.com/ instead.' txt
)
select txt
,regexp_replace(txt,'https?:\/\/[\w.\/]+[\w\/]','XXXXXXX','g')
from t1
?还可以顺便解决4.2节里的一个问题。当时我们使用\r\n匹配行尾标记,而且我还说过,在Unix或Linux系统上得使用\n(不包括\r),理想的解决方案是匹配一个可选的\r和一个\n。下面还是那个例子,但这次使用的正则表达式略有不同:
文本
"101","Ben","Forta"
"102","Jim","James""103","Roberta","Robertson"
"104","Bob","Bobson"
正则表达式
[\r]?\n[\r]?\n
结果
"101","Ben","Forta"
"102","Jim","James""103","Roberta","Robertson"
"104","Bob","Bobson"
分析
[\r]?\n匹配一个可选的\r和一个必不可少的\n。
提示 你应该已经注意到了,上面这个例子里的正则表达式使用的是[\r]?而不是\r?。[\r]定义了一个字符集合,该集合只有元字符\r这一个成员,因而[\r]?在功能上与\r?完全等价。[ ]的常规用法是把多个字符定义为一个集合,但有不少程序员喜欢把一个字符也定义为一个集合。这么做的好处是可以增加可读性和避免产生误解,让人们一眼就可以看出随后的元字符应用于谁。如果你打算同时使用[ ]和?,记得把?放在字符集合的外面。因此,http[s]?://是正确的,若是写成http[s?]://可就不对了。
提示 ?是一个元字符。如果需要匹配?本身,就必须使用转义序列\?。
--PostgreSQL
with t1 as (
select
'"101","Ben","Forta"
"102","Jim","James""103","Roberta","Robertson"
"104","Bob","Bobson"' txt
--一般windows系统下,文本类型中,每行以\r\n结尾;
--linux下,文本中,每行一般以\n结尾。
--这个字符串,因为是从windows系统上提交给服务器的,所以文本中每行以\r\n结尾
)
select txt--E'\r\n'的写法,表示我是用回车加换行符,去做替代,而非'\r\n'这四个字符,regexp_replace(txt,'[\r]?\n[\r]?\n',E'\r\n','g') r1
from t1
5.2 匹配的重复次数
正则表达式里的+、*和?解决了许多问题,但有时候光靠它们还不够。请思考以下问题:
+、*和?匹配的字符,最小数量是零个或一个。我们无法明确地为其匹配的字符个数另行设定一个最小值。
+和*匹配的字符个数,最大数量是没有上限。我们无法为其匹配的字符个数设定一个最大值。
总而言之,我们无法指定具体的匹配次数。
为了解决这些问题并对重复性匹配有更多的控制权,正则表达式允许使用重复范围(interval)。重复范围在{和}之间指定。
注意 {和}是元字符。如果需要匹配自身,就应该用\对其进行转义。
值得一提的是,即使你没有对{和}进行转义,大部分正则表达式实现也能正确地处理它们(根据具体情况把它们解释为普通字符或元字符)。话虽如此,为了避免不必要的麻烦,最好不要依赖这种行为。在需要把{和}当作普通字符来匹配的场合,应该对其进行转义。
5.2.1 具体的重复匹配
要想设置具体的匹配次数,把数字写在{和}之间即可。比如说,{3}意味着匹配前一个字符(或字符集合)3次。如果只能匹配2次,则不算是匹配成功。
为了演示这种用法,我们再来看一下匹配RGB值的例子(请对照第3章和第4章里的类似例子)。你应该记得,RGB值是一个十六进制数值,这个值分成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]
下面是我们在第4章里用来匹配RGB值的模式,它使用了POSIX字符类:
#[[:xdigit:]][[:xdigit:]][[:xdigit:]][[:xdigit:]][[:xdigit:]][[:xdigit:]]
这两个模式的问题在于,你不得不重复写出6次相同的字符集合(或POSIX字符类)。下面是一个同样的例子,但我们这次使用了区间匹配:
文本
div {background-color: #fefbd8;
}
h1 {background-color: #0000ff;
}
div {background-color: #d0f4e6;
}
span {background-color: #f08970;
}
正则表达式
#[A-Fa-f0-9]{6}
结果
div {
background-color: #fefbd8;
}
h1 {
background-color: #0000ff;
}
div {
background-color: #d0f4e6;
}
span {
background-color: #f08970;
}
分析
[A-Fa-f0-9]匹配单个十六进制字符,{6}要求重复匹配该字符6次。区间匹配的用法也适用于POSIX字符类。
--PostgreSQL
with t1 as (
select
'div {background-color: #fefbd8;
}
h1 {background-color: #0000ff;
}
div {background-color: #d0f4e6;
}
span {background-color: #f08970;
}' txt
)
select txt--使用[[:xdigit:]]代替[a-fA-F0-9],效果一样,regexp_replace(txt,'#[a-fA-F0-9]{6}','XXXXXXX','g') r1
from t1
5.2.2 重复匹配的次数范围
{}语法还可以用来为重复匹配次数设定一个区间范围,也就是匹配的最小次数和最大次数。区间必须以{2,4}(最少重复2次,最多重复4次)这样的形式给出。在下面的例子里,我们将使用一个这样的正则表达式来检查日期的格式:
文本
4/8/17
10-6-2018
2/2/2
01-01-01
正则表达式
\d{1,2}[-\/]\d{1,2}[-\/]\d{2,4}
结果
4/8/17
10-6-2018
2/2/2
01-01-01
分析
这里列出的日期是一些由用户可能通过表单字段输入的值,这些值必须先进行验证,确保格式正确。
\d{1,2}匹配一个或两个数字字符(匹配天数和月份);
\d{2,4}匹配年份;[-\/](请注意,这个\/其实是一个\和一个/)匹配日期分隔符-或/。
我们总共匹配到了3个日期值,但2/2/2不在此列(因为它的年份太短了)。
提示 在这个例子里,我们使用了/的转义序列\/。这在许多正则表达式实现里是不必要的,但有些正则表达式解析器要求必须这样做。为避免不必要的麻烦,在需要匹配/字符本身的时候,最好总是使用它的转义序列。
注意,上面这个例子里的模式并不能验证日期的有效性,诸如54/67/9999之类的无效日期也能通过这一测试。它只能用来检查日期值的格式是否正确(这一环节通常安排在日期有效性验证之前)。
注意 重复范围也可以从0开始。比如,{0,3}表示重复次数可以是0、1、2或3。我们曾经讲过,?匹配它之前某个字符(或字符集合)的零次或一次出现。因此,从效果上看,其等价于{0,1}。
--PostgreSQL
with t1 as (
select '4/8/17' txt union all
select '10-6-2018' txt union all
select '2/2/2' txt union all
select '01-01-01' txt
)
select * from t1
where txt ~ '\d{1,2}[-\/]\d{1,2}[-\/]\d{2,4}'
5.2.3 匹配“至少重复多少次”
重复范围的最后一种用法是指定至少要匹配多少次(不指定最大匹配次数)。这种用法的语法类似于区间范围语法,只是省略了最大值部分而已。比如说,{3,}表示至少重复3次,换句话说,就是“重复3次或更多次”。
来看一个综合了本章主要知识点的例子。在这个例子里,我们使用一个正则表达式把所有金额大于或等于100美元的订单找出来:
文本
1001: $496.80
1002: $1290.69
1003: $26.43
1004: $613.42
1005: $7.61
1006: $414.90
1007: $25.00
正则表达式
\d+: \$\d{3,}\.\d{2}
结果
1001: $496.80
1002: $1290.69
1003: $26.43
1004: $613.42
1005: $7.61
1006: $414.90
1007: $25.00
分析
这个例子里的文本,取自一份报表,其中第一列是订单号,第二列是订单金额。我们构造的正则表达式首先使用\d+:来匹配订单号(这部分其实可以省略——我们可以只匹配金额部分而不是包括订单号在内的一整行)。接着,:冒号后面有一个空格不要忽略。
模式\$\d{3,}\.\d{2}用来匹配金额部分,其中\$匹配$,\d{3,}匹配至少3位数字(因此,最少也得是100美元),\.匹配.,\d{2}匹配小数点后面的2位数字。该模式从所有订单中正确地匹配到了4个符合要求的订单。
提示 在使用重复范围的时候一定要小心。如果你遗漏了花括号里的逗号,那么模式的含义将从至少匹配n次变成只匹配n次。
注意 +在功能上等价于{1,}。
--PostgreSQL
with t1 as (
select '1001: $496.80' txt union all
select '1002: $1290.69' txt union all
select '1003: $26.43' txt union all
select '1004: $613.42' txt union all
select '1005: $7.61' txt union all
select '1006: $414.90' txt union all
select '1007: $25.00' txt
)
select * from t1
where txt ~ '\d+: \$\d{3,}\.\d{2}'
5.3 防止过度匹配
?字符的匹配范围有限(仅限零次或一次匹配),{n}和{m,n}字符,可以精确重复匹配次数的数量、范围。但除此之外,本章前文中所介绍的其他重复匹配形式,在重复次数方面却没有上限值,而这样做有时会导致过度匹配的现象。
我们目前为止选用的例子都经过了精心挑选,不存在过度匹配的问题。考虑下面这个例子,例子中的文本取自某个Web页面,里面包含两个HTML的标签。我们的任务是用正则表达式匹配标签中的文本(可能是为了替换格式)。
文本
This offer is not available to customers living in <B>AK</B> and <b>HI</b>.
正则表达式
<[Bb]>.*<\/[Bb]>
结果
This offer is not available to customers living in <B>AK</B> and <b>HI</b>.
分析
<[Bb]>匹配起始标签(大小写均可),<\/[Bb]>匹配闭合标签(也是大小写均可)。但这个模式只找到了一个匹配,而不是预期的两个。
第一个标签<B>和最后一个标签<B>之间的所有内容(AK</B> and <B>HI)都被.*一网打尽。这样做,包含了我们想要匹配的文本,但其中也夹杂了其他标签。
为什么会这样?因为*和+都是所谓的“贪婪型”(greedy)元字符,其匹配行为是多多益善而不是适可而止。它们会尽可能地从一段文本的开头一直匹配到末尾,而不是碰到第一个匹配时就停止。这是有意设计的,量词1就是贪婪的。
1 +、*和?也叫作“量词”(quantifier)。 ——译者注
--PostgreSQL
with t1 as (
select 'This offer is not available to customers living in <B>AK</B> and <b>HI</b>.' txt
)
select txt --对于正斜杠,转不转义,都可以,regexp_replace(txt,'<[Bb]>.*</[Bb]>', 'XXXXXXX','g') r1 ,regexp_replace(txt,'<[Bb]>.*<\/[Bb]>' ,'XXXXXXX','g') r2
from t1;
在不需要这种“贪婪行为”的时候该怎么办?答案是使用这些量词的“懒惰型”(lazy)版本(之所以称之为“懒惰型”,是因为其匹配尽可能少的字符,而非尽可能多地去匹配)。懒惰型量词的写法,是在贪婪型量词后面加上一个?。表5-1列出了贪婪型量词及其对应的懒惰型版本。
*?是*的懒惰型版本。下面是使用*?来解决之前那个例子的做法:
文本
This offer is not available to customers living in <B>AK</B> and <b>HI</b>.
正则表达式
<[Bb]>.*?<\/[Bb]>
结果
This offer is not available to customers living in <B>AK</B> and <b>HI</b>.
分析
问题解决了。因为使用了懒惰型的*?,第一个匹配将仅限于AK,HI则成为了第二个匹配。
注意 为了让模式尽可能简单,本书里的大多数例子使用的都是“贪婪型”量词。但是,可以根据需要将其替换成“懒惰型”量词。
--PostgreSQL
with t1 as (
select 'This offer is not available to customers living in <B>AK</B> and <b>HI</b>.' txt
)
select txt --对于正斜杠,转不转义,都可以,regexp_replace(txt,'<[Bb]>.*?</[Bb]>','XXXXXXX','g') r1 ,regexp_replace(txt,'<[Bb]>.*?<\/[Bb]>','XXXXXXX','g') r2
from t1;
5.4 小结
在使用重复匹配时,正则表达式的真正威力就显现出来了。
本章介绍了+(匹配字符或字符集合的一次或多次重复出现)、*(匹配字符或字符集合的零次或多次重复出现)和?(匹配字符或字符集合的零次或一次出现)的用法。
要想获得更大的控制权,你可以使用重复范围{}字符,精确地设定重复次数或是重复的最小次数和最大次数。量词分“贪婪型”和“懒惰型”两种,前者会尽可能多地匹配,后者则会尽可能少地匹配。