高品质的音视频能力是怎样的? | Qcon 全球软件开发大会·上海站邀请函 了解详情
写点什么

正则表达式(五):浅谈两种匹配操作

  • 2011-05-03
  • 本文字数:5783 字

    阅读完需:约 19 分钟

在正则表达式中,匹配是最最基本的操作。使用正则表达式,换种说法就是“用正则表达式去匹配文本”。但这只是广义的“匹配”,细说起来,广义的“匹配”又可以分为两类:提取和验证。所以,本篇文章就来专门讲讲提取和验证。

提取

提取可以理解为“用正则表达式遍历整个字符串,找出能够匹配的文本”,它主要用来提取需要的数据,常见的任务有:找出文本中的电子邮件地址,找出 HTML 代码中的图片地址、超链接地址……提取数据时,首先要注意的,就是准确性。

准确

准确性分为两方面:完整精确。前者是要提取出需要的所有文本,不能漏过;后者是要保证提取的结果中没有不需要的文本,不可出错。

为保证完整,我们需要考虑足够多的变体,覆盖所有情况。一般来说,要提取的数据都只有概念的描述(比如,提取一个电子邮件地址,提取一个身份证号),如果没有拿到完整规范的特征描述,可能只能凭经验总结出几条特征,然后逐步完善,也就是不断考虑新的情况,照顾到各种情况。

拿“提取文本中的浮点数字符串”为例。最容易想到的情况,就是 3.14、3999.2、0.36 之类,也就是“数字字符串 + 小数点 + 数字字符串”,所以用表达式『\d+\.\d+』,按照我们上一篇文章说过的“与或非”,三个部分都是必须出现的,所以这个表达式似乎是没问题了。

复制代码
\d+\.\d+

但是有些时候,0.7 是写作.7 的,上面的表达式无法照顾这种情况,所以必须修改表达式:整数部分是可能出现也可能不出现的,所以小数点之前的\d+ 应该改为\d*,就成了『\d*\.\d+』。

复制代码
\d*\.\d+

但是且慢,浮点数还包括负数,比如 -0.7,但现在这个表达式无法匹配最开始的符号,所以还应该改成『-?\d*\.\d+』。

-?\d*\.\d+

但仅仅保证完整性还不够,提取的另一方面是精确,就是排除掉那些“能够由正则表达式匹配,但其实并非期望”的字符串,所以我们还需要仔细观察目前的正则表达式,适当添加限制条件。

仍然用上面的正则表达式作例子,『-?\d*\.\d+』中,『-?』和『\d*』都是可能出现的元素,所以它们可能都不出现,这时候表达式能匹配.7 之类,没有错;如果只出现了『\d*』能匹配的文本,可以匹配 3.14 之类,也没有错;但是,如果只出现『-?』呢?-.7,通常来说,负的浮点数是应该写作 -0.7 的,而 -.7 显然是不合法的。所以,这个表达式应该修改为『(-?\d+|\d*)\.\d+』。

复制代码
(-?\d+|\d*)\.\d+

事情到这里就完整了吗?似乎还不是。我们知道有些地方,日期字符串是“2010.12.22”的形式,如果你要处理的文本中不包含这种日期字符串还好,否则,上面的表达式会错误匹配 2010.12.22 或者 2010.12.22。为了避免这种情况,我们需要给表达式加上更多的限制。最直接想法就是,限定表达式两端不能出现点号.,变成『(?!<.)(-?\d+|\d*)\.\d+(?!.)』。

复制代码
(?!<.)(-?\d+|\d*)\.\d+(?!.)

这样确实避免了 2010.12.22 的错误匹配,但它也造成了新的问题,比如“…the value of π is 3.14. Therefore…”,3.14 本来是我们需要提取的浮点数,但加上这个限制之后,因为 3.14 之后的有一个作为英文句号使用的点号,所以 3.14 无法匹配。仔细观察我们要排除的 2010.12.22 这类字符串,我们发现点号. 的另一端仍然是数字,而用作句号的点号,另一端必定不是数字(一般是空白字符,或者就是字符串的开头 / 末尾),所以应当把限制条件表达的更精确些,变为『(?!<\d.)(-?\d+|\d*)\.\d+(?!.\d)』。

复制代码
(?!<\d.)(-?\d+|\d*)\.\d+(?!.\d)

好了,关于浮点数的匹配就讲到这里。回过头想想得到最后的这个表达式,我们发现,如果要用正则表达式匹配,必须兼顾完整和精确,通常的做法就像这个例子中的一样:先逐步放宽限制,保证完整;再添加若干限制,保证精确。

效率

提取数据时还有一点需要注意,就是效率。有时要处理的文本非常长,即便进行简单的字符串查找都很费力,更不用说可能出现各种变体的正则表达式了。这时候就应当尽量减少“变化”的范围。比如知道文本中只包含一个双引号字符串,希望将它提取出来,正则表达式写成了『".*"』。在文本不长时这样还可以接受,如果文本很长,『.*』这类子表达式就会导致大量的回溯,因为『.*』的匹配过程是这样的:

观察匹配过程就会发现,如果字符串很长,而引号字符串又出现在比较靠前的位置,比如"quoted string" and long long long text…,匹配时就需要进行大量的回溯操作,严重影响效率。如果这种问题并不是任何情况下都可能发生,但效率确实非常重要的,如果正则表达式编写不当,可以产生极为严重的影响,比如 ReDos(正则表达式拒绝服务),具体情况可以参考 http://en.wikipedia.org/wiki/ReDoS

另一方面,正则表达式提取的效率,不仅与正则表达式本身有关,也与调用的 API 有关。如果文本很大,要提取出的结果很多,集中到一次操作进行,就可能影响性能,所以条件容许(比如只需要逐步提取出来,依次处理),就可以“逐步进行”,下面的表格列出了常用语言中的提取操作。

语言

方法

备注

Java

Matcher.find()

只能逐步进行

PHP

preg_match(regex, string, result)

逐步进行

复制代码
preg\_match\_all(regex, string, result)

一次性进行

.NET

Regex.match(string)

逐次进行

复制代码
Regex.matches(string, regex)

一次性进行

Python

re.find(regex, string)

逐步进行

复制代码
re.finditer(regex, string)

逐步进行

复制代码
re.findall(regex, string)

一次性进行

Ruby

Regexp.match(text)

只能找到第一次匹配

复制代码
string.index(Regexp, int)

逐步进行

复制代码
string.scan(Regexp)

一次性进行

JavaScript

RegExp.exec(string)

一次性进行

复制代码
string.match(RegExp)

一次性进行

一次性提取所有匹配结果的操作这里不多说,我们要补充讲解的是,在“逐步进行”时,如何真正保证“逐步”?或者说,在第二次调用匹配时,如何保证是“承接”第一次调用,找到下一个匹配结果。通常的做法有几种,以下分别介绍。例子统一使用字符串为"123 45 6",查找其中的数字字符串,依次输出 123、45、6。

如果采用的是面向对象式处理,表示匹配结果的对象,可能可以“记住”匹配的位置,下次调用时自动“继续”,Java 就是这样,循环调用 Matcher.find() 方法,就可以逐个获得所有匹配,在.NET 中,是循环调用 Match.NextMatch()。

代码(以 Java 为例)

复制代码
String str = "123 45 6";
Pattern p = Pattern.compile("\\d+");
Matcher m = p.matcher(str);
while (m.find()) {
System.out.println(m.group());
}

如果不是面向对象式处理,无法记录匹配的状态信息,则可以手动指定偏移值。多数语言都有办法在匹配时指定偏移值,也就是“从字符串的 offset 位置开始尝试匹配”。如果要逐一获得所有匹配,每次将偏移值指定为上一次匹配的结束位置即可。注意,字符串处理时可能有人习惯将偏移值指定为“上一次匹配的起始位置 +1”,但正则表达式处理时这样是不对的,比如正则表达式是『\d+』,而字符串是"123 45 6",第一次匹配的结果是 123,如果把偏移值设定为“上一次匹配的起始位置 +1”,之后的匹配结果就是 23,3……。在 PHP、JavaScript、Ruby 中,通常采用这种办法。

代码(以 PHP 为例)

复制代码
$string="123 45 6";
$regex="/\\d+/";
$matched = 1;
$oneMatch=array();
$lastOffset = 0;
$matched = preg_match($regex, $string, $oneMatch, PREG_OFFSET_CAPTURE, $lastOffset);
while ($matched == 1) {
$lastOffset = $oneMatch[0][1] + strlen($oneMatch[0][0]);
echo $oneMatch[0][0]."<br />";
$matched = preg_match($regex, $string, $oneMatch, PREG_OFFSET_CAPTURE, $lastOffset);
}

第 3 种办法是使用迭代器,Python 的 re.finditer() 会得到一个迭代器,每次调用 next(),就会获得下一次匹配的结果。这种办法目前只有 Python 提供,其它语言尚不具备。

代码(以 Python 为例)

复制代码
for match in re.finditer("\\d+", "123 45 6")
print match.group(0)

验证

另一类“匹配”是数据验证,也就是“检查字符串能否完全由正则表达式匹配”,它主要用来测试和保证数据的合法性。比如有些网站要求你设定密码,密码只能由数字或小写字母构成,长度在 6 到 12 个字符之间,如果输入的密码不符合条件,则会提示你修改,这个任务,一般使用 JavaScript 的正则表达式来完成。

初看起来,这也是用正则表达式在字符串中查找匹配文本。但仔细想想,两者又不一样:一般来说,提取时正则表达式匹配的开始 / 结束位置都是不确定的,需要逐次试错,才能决定;验证时,同样需要考虑准确性,但效率并不是重点考虑的因素(一把验证的文本是用户名、手机号、密码之类,不会太长),虽然也要求准确性,但匹配的开始 / 结束位置都是确定的,只要从文本的开头验证即可,不用反复推进 - 尝试;而且只要发现任何一个“硬性”条件无法满足(比如长度、锚点),即可失败退出。

正因为验证操作有这些特点,有些语言中提供了专门的方法进行正则表达式验证。如果没有,我们也可以使用简单的查找功能,只是在正则表达式的首尾加上匹配字符串起始 / 结束位置的锚点来定位,这样既保证表达式匹配的是整个字符串,也可以在无法匹配时尽早判断失败退出。

常见语言中的验证方法

语言

验证方法

备注

Java

String.matches(regex)

专用于验证,返回 boolean 值,不需要『^』和『$』

PHP

preg_match(regex, string) != 0

preg_match 返回匹配成功的次数,需要『^』和『$』

.NET

Regex.IsMatch(string, regex)

专用于验证,返回 boolean 值,不需要『^』和『$』

Python

re.search(regex, string) != None

成功则返回 True,否则返回 False,需要『^』和『$』

复制代码
re.match(regex, string) != None

成功则返回 True,否则返回 False,需要『$』

Ruby

Regexp.match(text) != nil

Regexp.match(text) 返回匹配成功的起始位置,若无法匹配则返回 nil,需要『^』和『$』

JavaScript

Regexp.test(string)

专用于验证,返回 boolean 值,需要『^』和『$』

前面说过,在验证时,文本的开始 / 结束位置是预先知道的,所以验证的表达式编写起来更加简单。比如之前匹配浮点数的表达式,我们首先得到的是『(-?\d+|\d*)\.\d+』,在进行数据提取时,需要在两端加上环视,防止错误匹配其它字符;但是如果是验证浮点数,就不需要考虑两端的环视,应该 / 不应该出现什么字符,直接在首尾加上『^』和『$』即可,所以验证用的表达式是『^(-?\d+|\d*)\.\d+$』。

我们甚至可以简单将各个条件叠加起来,直接得到最后的表达式,比如下面这个例子:

需要验证密码字符串,前期的分析总结出 5 条明确的规则:

  1. 密码的长度在 6-12 个字符之间
  2. 只能由小写字母、阿拉伯数字、横线组成
  3. 开头和结尾不能是横线
  4. 不能全部是数字
  5. 不容许有连续(2 个及以上)的横线

下面依次列出对应 5 条规则的表达式:

  1. 密码长度在 6-12 个字符之间:其形式类似『.{6, 12}』
  2. 只能由小写字母、阿拉伯数字、横线组成:所有的字符都只能由『[0-9A-Za-z-]』匹配
  3. 开头和结尾不能是横线:开头『^(?!-)』,结尾『(?<!-)$』
  4. 不能全部是数字,也就是说必须出现一个『[^0-9]』或者『\D』
  5. 不容许有连续(2 个及以上)的横线,也就是说不能出现『–』

如果用来提取数据,就必须把这 5 条规则糅合到一起。前 3 条规则比较好办,可以合并为『^(?!-)[0-9A-Za-z-]{6,12}(?<!-)$』,但它与第 4 和第 5 个条件合并都不简单。

与第 4 条规则合并的难点在于,我们无法确定这个『[^0-9]』出现的位置,如果简单改为『^(?!-)[0-9A-Za-z-]{6,12}[^0-9][0-9A-Za-z-]{6,12}(?<!-)$』,看似正确,却无法保证整个字符串的长度在 6-12 之间——目前这个表达式的长度在 13(6+1+6)到 25(12+1+12)之间。这显然有问题,但照这个方式也确实无法保证整个字符串的长度,因为我们无法跨越『[^0-9]』,为两端『[0-9A-Za-z-]』的量词建立关联,让它们的和为 5-11 之间。同样,与第 5 条规则的合并也存在这类问题,因为我们无法确认『–』的出现位置。

看起来,把这 5 条规则糅合成一个正则表达式,找到能够匹配的文本,真不是件容易的事情。不过,如果我们要做的只是验证,不妨换个思路:我们要匹配的并不是所有的文本,而是文本的开始位置,它后面的文本满足 5 个条件,而每个条件都可以不用实际匹配任何文本,而用环视来满足。

对应 5 条规则的环视表达式依次是:

  1. 密码长度在 6-12 个字符之间:『^(?=.{6, 12}$)』
  2. 只能由小写字母、阿拉伯数字、横线组成:『^(?=[0-9A-Za-z-]*$)』
  3. 开头和结尾不能是横线:『^(?!-).*(?<!-)$』
  4. 不能全部是数字:『^(?=.*[^0-9])』(这里不需要出现 $,只要出现了非数字字符就可以)
  5. 不容许有连续(2 个及以上)的横线:『^(?!.*–)』

下面就是寻找这样一个文本起始位置,它后面的文本同时满足这 5 个条件。实际上,因为锚点并不真正匹配文本,所以多个锚点可以重叠在一起,因此我们完全可以寻找 5 个锚点,把它们串联起来:

复制代码
『(^(?=.{6, 12}$))(^(?=[0-9A-Za-z-]*$))(^((?!-).*(?<!-)$))(^(?=.*[^0-9])(^(?!.*--))』

意思就是:先寻找这样一个字符串起始位置,它之后的字符串满足条件 1;然后寻找这样一个字符串其实位置,它之后的字符串满足条件 2;…… 如果能找到 5 个这样的字符串起始位置(实际上,因为只有一个字符串起始位置,所以这 5 个位置是重叠的),就算验证成功。

其实我们也可以不用那么多的括号,只用一个『^』即可:

复制代码
『^(?=.{6, 12}$)(?=[0-9A-Za-z-]*$)(?=(?!-).*(?<!-)$)(?=.*[^0-9])(?!.*--)』

总结

虽然“匹配”是正则表达式的常见操作,但细分起来,“匹配”又可分为提取和验证两种操作。

提取时需要照顾准确性和效率,因为此时字符串的起始 / 结束位置是不确定的,应当添加适当的环视结构,避免匹配了不期望的数据。

验证时对效率的要求并不高,因为验证的字符串一般都很短,而且验证的起始 / 结束位置都是确定的,直接在字符串两端添加 ^ 和 $ 即可。而且验证有时候要比提取简单得多,我们可以改换思路,改“查找文本”为“查找位置”,针对验证时容许 / 不容许出现的每一个条件,写出对应的环视功能,作为一个将它们并列在一起。

关于作者

余晟,程序员,曾任抓虾网高级顾问,现就职于盛大创新院,感兴趣的方向包括搜索和分布式算法等。翻译爱好者,译有《精通正则表达式》(第三版)和《技术领导之路》,目前正在写作《正则表达式傻瓜书》(暂定名),希望为国内开发同行贡献一本实用的正则表达式教程。


感谢张凯峰对本文的策划和审校。

给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家加入到 InfoQ 中文站用户讨论组中与我们的编辑和其他读者朋友交流。

2011-05-03 00:0020869

评论

发布
暂无评论
发现更多内容

Docker的Image

陈磊@Criss

Grafana和ES打造的Nginx的仪表盘

陈磊@Criss

PIP的报错Could not fetch URL https://pypi.org/

陈磊@Criss

Git删除仓库中的文件和文件夹

陈磊@Criss

pipreqs:生成python项目的requirements

陈磊@Criss

最受欢迎的男友职业排行榜Top10

程序员生活志

程序员

架构师0期Week10作业1

Nan Jiang

架构师培训第10周练习

小蚂蚁

弹性计算的内部概念:弹性扩张、弹性收缩、弹性自愈

陈磊@Criss

Docker 容器连接

陈磊@Criss

原创 | 使用JPA实现DDD持久化-R:数据的世界

编程道与术

Java hibernate DDD JDBC jpa

标新立异的日志归档:用更少的内存归档大规模测试日志

陈磊@Criss

Nginx的容器部署

陈磊@Criss

看DLI服务4核心如何提升云服务自动化运维

华为云开发者联盟

Serverless 运维 运维自动化 华为云 DLI

作业一

Kiroro

欲速也可达:Battle接口测试训练系统的1分钟快速说明

陈磊@Criss

Clover:解决Java8和Cobertura的问题以及解决方法

陈磊@Criss

python判断文件和文件夹是否存在、创建文件夹

陈磊@Criss

第十周.命题作业

刘璐

22种超全用户触点采集,易观方舟SDK又更新了

易观大数据

高中生写LOL外挂1年狂赚500万,落网前刚买下120万保时捷...

程序员生活志

编程 程序员 外挂

架构师0期Week10作业2

Nan Jiang

如何有效防止sql注入

Java旅途

什么是死信队列

Java旅途

RabbitMQ

jmeter 执行python脚本

陈磊@Criss

Web前端性能优化,应该怎么做?

华为云开发者联盟

运维 大前端 HTTP js

第十周.总结

刘璐

作业二

Kiroro

该了解一波了!零基础入门Nginx

程序员的时光

nginx Docker

微信小程序的自动化测试框架

陈磊@Criss

企业微信群消息机器人发送开源项目

陈磊@Criss

正则表达式(五):浅谈两种匹配操作_Java_余晟_InfoQ精选文章