本节书摘来自异步社区《UNIX/Linux 系统管理技术手册(第四版)》一书中的第2章,第2.3节,作者:【美】Evi Nemeth , Garth Snyder , Trent R.Hein , Ben Whaley著,更多章节内容可以访问云栖社区“异步社区”公众号查看
2.3 正则表达式
UNIX/Linux 系统管理技术手册(第四版)
尽管有些语言比其他语言对正则表达式更关注,但大多数现代语言都支持正则表达式。诸如grep和vi这样的UNIX命令也使用正则表达式。正则表达式如此常见,以至于通常都把其名称缩写为“regex”。有几本书全篇就讲如何发掘正则表达式的威力,而且无数博士论文的主题也都是正则表达式。
在解释诸如wc -l*.pl这样的命令行时,shell会做文件名匹配和扩展,但这并不是正则表达式匹配的一种形式。它是一种称之为“shell通配”的不同体系,并且它采用了一种不同的、更为简单的语法。
正则表达式非常强大,但是它们不能支持所有可能的语法。其最著名的弱点就是:不能识别嵌套的限定符。例如,当允许用圆括号组织算术表达式的时候,就不可能写出一个正则表达式,它能够辨别出这样的算术表达式是否有效。
正则表达式在Perl语言中达到了其威力和完美的巅峰。实际上,Perl的模式匹配功能如此精致,以至于把它们称为正则表达式的一种实现,都显得不够准确。Perl的模式能够匹配嵌套的限定符,能够识别回文,还能够匹配由若干个A后跟相同数量的B所组成的任意字符串——所有这些都超出了正则表达式能够支持的范围。当然,Perl也能处理普通的正则表达式。
Perl的模式匹配语言保持为业界的标准,它已经被其他语言和工具广泛采纳。Philip Hazel写的PCRE 库(Perl-compatible regular expression,兼容Perl的正则表达式)让开发人员能够相当容易地把这种语言加入到他们自己的项目中。
正则表达式自身并不是一种脚本编程语言,但是它们作用很大,以至于只要讨论脚本编程就要专门介绍它们的特色功能;因此也就有了本节内容1。本节我们讨论它们的基本形式,但带有一些Perl的改进。
2.3.1 匹配过程
当代码要判断一个正则表达式的值时,它会尝试用一个给定的文本字符串去匹配一个给定的模式。要匹配的“文本字符串”可以很长,其中可以包括换行符。用一个正则表达式去匹配整个文件或者HTML文档的内容,往往是非常方便的。
整个搜索模式必须和一段连续的搜索文本匹配,匹配程序才宣布匹配成功。不过,这个模式可以在任意位置进行匹配。成功匹配一次之后,取值程序就返回匹配出来的文本,以及该模式里所有特别限定的部分所匹配的结果列表。
2.3.2 普通字符
一般而言,在正则表达式中的字符就匹配它们自己。所以下面这个模式
I am the walrus
就匹配字符串“I am the walrus”,并且只匹配这个字符串。因为它能够匹配搜索文本中的任意位置,所以这个模式也能匹配成功字符串“I am the egg man. I am the walrus. Koo koo ka-choo!”不过,实际匹配的地方仅限于“I am the walrus”这部分。匹配是区分大小写的。
2.3.3 特殊字符
表2.4给出了在正则表达式中常见的一些特殊符号的含义。它们都是基本字符——还有许许多多其他字符。
a.也就是说,一个空格、一个换页、一个制表符、一个换行符或者一个回车|
许多特殊结构,像+和|,都会影响它们左边或者右边“东西”的匹配。一般而言,这个“东西”可以是一个字符、用括号括起来的子模式,或者用方括号括起来的字符类。不过,对于 | 这个字符来说,前面所说的“东西”的范围可以向左右无限制地扩展。如果想要限制这个竖线的作用域,那么可以把这个竖线和左右两边的东西,都一起放到它们自己的一对圆括号里。例如,
I am the (walrus|egg man)\.
就匹配“I am the walrus.”或者“I am the egg man.”。这个例子也演示了特殊字符(在这里是点)的转义。下面的模式
(I am the (walrus|egg man). ?){1,2}
匹配下面所有的句子:
I am the walrus.
I am the egg man.
I am the walrus. I am the egg man.
I am the egg man. I am the walrus.
它偏偏也匹配“I am the egg man. I am the egg man.”这一句(但那句话有什么意义呢?)。更重要的是,它还匹配“I am the walrus. I am the egg man. I am the walrus.”这句话,即使重复数明确限制为至多两次。那是因为这个模式不需要匹配整个搜索文本。本例中的正则表达式匹配两个句子之后就终止了,并宣告匹配成功。它根本不关心还有一次重复。
有一种常见的错误,把正则表达式的元字符*(零次或者多次的量词)和shell的通配符搞混。正则表达式中的星号需要有东西去修饰;否则,它并不会按照预期去起作用。如果任何字符序列(包括根本没有字符)都是能够接受的匹配结果,那么就用.。
2.3.4 正则表达式的例子
在美国,邮政编码是5个数字,或者5个数字后面加一个短划线和另外4个数字。要匹配一个常规的邮政编码,就必须匹配一个有5位数字的数。下面的正则表达式刚好符合要求:
^\d{5}$
^和$匹配搜索文本的开头和结尾,但是没有实际对应文本中的字符;它们是“零宽界定符(zero-width assertion)”。这两个字符确保了一点,只有正好5个数字才能匹配这个正则表达式——对于在一个更长字符串里面包含的5个数字,这个正则表达式不会去匹配它们。d这个转义符匹配一个数字,量词{5}表明必须正好匹配5个数字。
为了既能匹配5位数字的邮政编码,也能匹配那种扩展形式(即邮政编码+4位数字),就要加上一个可有可无的短划线,以及另外4位数字:
^\d{5}(-\d{4})?$
圆括号把短划线和多出来的数字放到一起,这样一来,它们就被当做一个整体上可有可无的单元。例如,这个正则表达式不会匹配5位数字后跟一个短划线。如果出现了短划线,那么也必须出现4位数字的扩展,否则就不会匹配。
下面的表达式是演示正则表达式匹配的一个经典例子:
M[ou]'?am+[ae]r ([AEae]l[- ])?[GKQ]h?[aeu]+([dtz][dhz]?)+af[iy]
它匹配了利比亚*卡扎菲名字的大多数不同拼法,包括:
Muammar al-Kaddafi (BBC)
Moammar Gadhafi(美联社)
Muammar al-Qadhafi (卡塔尔半岛电视台)
Mu’ammar Al-Qadhafi (美国国务院)
能看出这些名字是怎样匹配该模式的吗?
这个正则表达式也展示出:达到人们能够阅读清楚的上限有多快。许多正则表达式体系(包括Perl中的体系)都支持一个x选项,它忽略模式里的空白,并支持注释,从而使得模式能够被隔开,分成多行显示。于是用户就可以用空白把逻辑组划分开,搞清楚其中的关系,就像用一种过程式语言那样。例如:
\s # Whitespace; can't use a literal space here
( # Group for optional last name prefix
[AEae] l # Al, El, al, or el
[-\s] # Followed by dash or space
)?
[GKQ] h? [aeu]+ # Initial syllable of last name: Kha, Qua, etc.
( # Group for consonants at start of 2nd syllable
[dtz] [dhz]? # dd, dh, etc.
)+
af [iy]
这样做有点儿帮助,但是仍然很容易折磨以后阅读代码的人。所以要做得友善些:如果可以,就用层次匹配或者多个小的匹配,而不用一个较大的正则表达式覆盖所有可能的情况。
2.3.5 捕获
一次匹配成功之后,每一对圆括号都变成了一个“捕获组”,它们记录下了该正则表达式匹配的实际文本。这些结果的使用方式则取决于具体实现和上下文环境。在Perl中,可以把匹配结果当做一个列表或者一系列被编了号的变量来访问。
因为圆括号可以嵌套,怎样知道哪个匹配哪个呢?这很简单——匹配的次序和左括号的次序一样。捕获的数量和左括号的数量一样多,不管每个用括号括起来的捕获组在实际匹配中扮演什么角色(或者不扮演什么角色)。当括号括起来的捕获组没有用到的时候(例如,当用Mu(')?ammar匹配“Muammar”的时候),它对应的捕获组就为空。
如果一个捕获组匹配了不止一次,那么只返回最后一次匹配的内容。例如,用下面这个模式
(I am the (walrus|egg man)\. ?){1,2}
匹配文本
I am the egg man. I am the walrus.
的话,就会得到两个结果,一个结果对应一对括号。
I am the walrus. walrus
注意,这两个捕获组实际都匹配了两次。不过,实际只返回每对括号最后一次匹配的文本。
2.3.6 贪心、懒惰和灾难性的回溯
正则表达式从左到右进行匹配。模式的每个成分都要匹配尽可能长的字符串,然后再让位给下一个成分,这种特性称为“贪心”。
如果正则表达式匹配器达到了一种不能完成一次匹配的状态,那么它就从候选的匹配结果那里退回一点儿,让一个贪心的原子成分少匹配一点儿自己的文本。例如,考虑用正则表达式a*aa匹配输入的文本“aaaaaa”。
首先,正则表达式匹配器把整个输入都分配给这个正则表达式中的a部分,因为a是贪心的。在没有可匹配的a之后,匹配器就继续尝试匹配正则表达式接下来的部分。不过情况不妙,接下来的部分是个a,再没有输入文本能匹配一个a的了;这就到了回溯的时候。正则表达式的a*部分不得不少匹配一个它已经匹配过的a。
现在匹配器能够匹配aa了,但它仍然不能匹配这个模式里的最后那个a。所以它又得回溯,让a再次腾出一个a。现在该模式里的第二个和第三个a都有匹配的a了,匹配也就完成了。
这个简单的例子展示出一些要点。首先,在处理整个文件时,贪心的匹配方式加上回溯会让明显很简单的模式(如>)开销很大2。正则表达式中.的部分一开始就匹配了从第一个
而且,这个模式绑定的>部分是输入中“最后一个可能”的有效匹配,这或许并不是用户想要的效果。更可能的情况是,用户想要匹配一个后跟一个标签。这个模式更好的写法是1*>,让一开始的通配符只匹配到当前这个标签的结尾,因为它不能超过右尖括号形成的界限。
用户还可以使用懒惰(和贪心正好相反)通配符:用?来代替,用+?来代替+。这两个版本的通配符匹配尽可能少的输入字符。如果不能匹配更少的话,它们就多匹配一些字符。在许多情况下,这两个运算符比贪心的版本更有效,而且更接近用户想要的结果。
不过要注意,它们得到的匹配结果和贪心运算符的不一样;差异不仅仅是这一种实现。对于我们给的HTML那个例子,懒惰模式是?>。但即便在这里,?最终都会扩大到包括不想要的>,因为之后接下来的标签可能不是一个。这又可能不是用户想要的结果。
有多个通配部分的模式会导致正则表达式匹配器的处理量呈指数增长,如果文本的各个部分能够匹配几个通配表达式,而搜索文本实际上并不匹配该模式的话,处理量都会尤其大。这种情况可没有听上去的那么少见,特别是对HTML做模式匹配的时候。想匹配某些标签后面跟着其他标签,其间可能还被更多的标签隔开,这是频繁遇到的情形,但这种模式可能会要求正则表达式匹配器尝试许多可能的组合。
正则表达式专家Jan Goyvaerts把这种现象称为“灾难性的回溯”,他在自己的博客里写下了有关这种现象的内容;参考regular-expressions.info/catastrophic.html了解详情以及一些好的解决方案。
其中几个能用到的办法是:
如果可以一行一行地进行模式匹配,而不是一次整个文件进行模式匹配,那么性能差的风险就会小好多;
即使正则表达式的写法默认采用了贪心运算符,但是可能不应该用它们,用懒惰运算符吧;
出现.*的地方本身全都值得怀疑,应该仔细检查。
- > ↩