第19章:正则表达式
在接下来的几章,我们要学习一下操控文本的工具。如我们所见,文本数据在所有的类 Unix 系统,如 Linux 中扮演着非常重要的角色。但在我们能完全体会这些工具所提供的全部功能之前,我们不得不先来看一门技术,该技术经常和这些工具最复杂的用法关联在一起,这就是正则表达式(regular expressions)。
我们已经浏览了许多命令行提供的功能和设施,也领教了一些真正神奇的 shell 功能和命令,如 shell 扩展和引用、键盘快捷键、命令历史,更别提 vi 编辑器了。正则表达式继续了该「传统」,或许(可以说)是所有这些中最神奇的功能了。这不是说学习它们所花费的时间不值得。恰恰相反。良好的理解会使我们执行惊人的技艺,尽管它们完整的价值可能不会立刻显现出来。
什么是正则表达式?
简单地说,正则表达式是用来识别文本中的模式的符号表示法。在某些方面,它们和 shell 匹配文件和路径名的通配符很类似,不过其规模更甚。正则表达式得到很多命令行工具的支持,也被很多编程语言所支持,以利解决其文本操作问题。然而更使人困惑的是,不是所有的正则表达式都是相同的,各类工具和各门编程语言之间都存在着细微的差别。为了方便讨论,我们将限定这里所讨论的正则表达式符合 POSIX 标准所述(符合大多数命令行),与多数编程语言相反(最特别的是 Perl),它使用更大更丰富的表示法。
grep
我们用来工作的最主要的正则表达式程序,是我们的老朋友 grep
。"grep" 这名字实际上来自于 "global regular expression print"(全局正则表达式打印),所以可以看到 grep
可以用正则表达式做些什么。本质上,grep
搜索出现在文本中与指定正则表达式匹配的文本,并将所有包含匹配项的行输出到标准输出。
目前为止,我们已经用过固定字符串的 grep
,如:
该命令会列出所有在 /usr/bin
中文件名包含 zip
字符串的文件。
grep
程序以下列形式接受选项和参数,其中 regex 是正则表达式:
grep [options] regex [file...]
表 19-1 描述了常用的 grep
选项。
表 19-1:grep 选项
选项
长选项
描述
-i
--ignore-case
忽略大小写。不区分大小写字符。
-v
--invert-match
反转匹配。正常情况下 grep
会打印出包含匹配的行。该选项导致 grep
打印出不匹配的行。
-c
--count
打印匹配的数量(如指定 -v
则打印不匹配的数量),而非匹配行本身。
-l
--files-with-matches
打印每个匹配的文件名,而不是匹配行本身。
-L
--files-without-match
和 -l
选项类似,只打印每个不匹配的文件名。
-n
--line-number
在每个匹配行前加上该行在文件中的行号。
-h
--no-filename
对于多文件检索,则不输出文件名。
要更完全的探索 grep
,让我们创建一些要检索的文本文件。
可以执行一个简单的检索,如下:
本例中,grep
检索所有包含 bzip
字符串的列表文件,找到了两条匹配,都在 dirlist-bin.txt
文件中。如果我们不关心匹配项本身,而仅对哪个文件包含匹配项感兴趣,可以指定 -l
选项。
相反,如果仅想查看哪些文件不含匹配项,可以这样做:
元字符和字面值
或许看起来不那么明显,grep
检索总是使用正则表达式的,即便是非常简单的检索。正则表达式 bzip
被认为意味着将会发生一个匹配,仅当文件中包含的行有至少四个字符,且行中某处有 b、z、i、p 按序排列且无其它字符在四者之间。字符串 bzip
都是字面值(literal characters),匹配它们自身。除了字面值,正则表达式还可以包含元字符(metacharacters),可以用来指定更复杂的匹配。正则表达式的元字符由下列字符组成:
^ $ . [ ] { } - ? * + ( ) | \
其它所有字符则都被认为是字面意义的,但在某些场景中,反斜杠字符用以创建元序列(meta sequences),允许元字符被转义并被视为字面值,而非被解释为元字符。
注意:可以看到,很多正则表达式的元字符在 shell 执行扩展时,也是有其意义的。当我们在命令行中传递带有元字符的正则表达式时,非常重要的是,它们都必须用引号括起来,以预防 shell 尝试扩展它们。
任意字符
第一个要看的元字符是句点,它用来匹配任意字符。如果将其包含在一个正则表达式中,它将匹配该位置上的任意字符。下面是个例子:
我们检索文件中匹配正则表达式 .zip
的任意行。结果中有些有趣的东西需要注意。注意没有找到 zip
程序。这是因为在正则表达式中包含了句点元字符,将所需匹配的长度增加到了四个字符,而 zip
仅包含三个字符,所以没有匹配。还有,如果在清单中包含了扩展名为 .zip
的文件,也会被匹配,因为在文件扩展名中的句点字符也属于「任意字符」。
锚
插入符号(^
)和美元符号($
)在正则表达式中别当作锚(anchors)。这意思是,它们会导致正则表达式仅匹配发生在行首(^
)或行尾($
)的情况。
这里检索了文件清单中 zip
位于行首和位于行尾的、还有既是行首也是行尾的(亦即行内仅有其自身)三种情况。注意正则表达式 ^$
(一个开始一个结尾,之间没有任何字符)将匹配空行。
纵横字谜助手
仅我们现时所掌握的有限的正则表达式的知识,也可以做一些有用的事情了。
我的妻子热衷于纵横字谜,有时候会让我帮忙完成一些特殊的问题。有些如「一个五个字母的单词,第三个字母是 j,最后一个是 r,它是什么?」这般的问题,让我思考。
你知道 Linux 系统中有字典吗?它有的。看一下
/usr/share/dict
目录,你可以找到几个。那里的字典文件仅仅是单词列表,按字母顺序一行一个。在我的系统,words
文件包含了 98,500 个单词。要找到上面那个纵横字谜可能的答案,我们可以这样做:使用这个正则表达式,可以在字典文件里找到五个字母长且第三个字母是 j、最后字母是 r 的单词。
括号表达式和字符类
在正则表达式中,除了匹配给定位置的任意字符,还能用括号表达式(bracket expressions)从给定的字符集中匹配单个字符。使用括号表达式,可以指定一个字符集(包含被解释为元字符的字符)被匹配,在下面这个例子中,使用了一个由两个字母组成的集合,来匹配包含 bzip
或 gzip
的任意行:
一个集合可以包含任意数目的字符,在括号中的元字符也失去了它们的特殊意义。然而,在括号表达式中有两种情况,是要用到元字符的,且其具有不同的意义。第一个是插入符号(^
),用来指示否定;第二个是短横(-
)用来指示字符序列。
否定
如果括号表达式中的第一个字符是插入符号(^
),那么在括号中的其余字符就不应该出现在指定字符位置上了。来修改之前的例子:
当否定符号被激活时,我们得到了一份前缀除了 b
和 g
且包含 zip
的列表。注意 zip
文件不在其中。一个否定字符集也是要求一个字符在给定位置上的,只不过不能是否定字符集中的成员罢了。
插入符号仅在括号表达式中的第一个字符位置处才调用否定,其它位置上则失去了它的特殊意义,成为字符集中的一个普通字符。
传统字符序列
如果想要构建成儿正则表达式来找出列表中以任一大写字母开头的文件,可以这样做:
只不过是将 26 个大写字母放在一个括号表达式中罢了。不过,把这些字母都打一遍的主意,有点问题,所以,还有另外一个办法。
使用三个字符组成的序列,就可以缩写 26 个字母。任何字符序列都可以通过这种包含多个序列的方法来表示,下面这个例子中匹配所有以字母和数字开头的文件名:
字符序列中,我们看到短横字符被特殊对待,那我们该怎么做才能让括号表达式实际上包含一个短横字符呢?在括号中将短横作为表达式的首字符。考虑下面两个例子:
这会匹配每一个包含大写字母的文件名。下面这个例子将匹配每个包含一个短横或大写字母 A 或大写字母 Z 的文件名:
POSIX 字符类
传统字符序列是易于理解的,能有效处理快速指定字符集的问题。不幸的是,不是总是有效。目前为止我们用 grep
还没有碰到过任何问题,但可能在其它程序中使用,就会碰到问题了。
第04章中,我们学习了如何使用通配符以执行路径扩展。在那个讨论中,我们说过,字符序列的使用方式几乎与它们在正则表达式中的使用方式相同,但是这里有问题:
(不同的 Linux 发行版会得到不同的结果,也可能是个空列表。本例则来自 Ubuntu)。这个命令产生了一个意外的结果——一份仅以大写字母开头的文件名清单,但是该命令给出的确实完全不同的结果(仅仅显示部分结果):
为什么是那样的?这是一个很长的故事,不过这里有个简短的版本:
回到 Unix 第一次被开发出来的时刻,它仅仅认识 ASCII 字符,这一功能反映了事实。ASCII 的开头 32 个字符(数字 0 - 31)是控制编码(如制表符、退格、回车)。接下去的 32 个(32 - 63)包含了可打印字符,包括大多数标点符号和数字 0 - 9。下面的 32 个(64 - 95)包含大写字母和一些标点符号。最后的 31 个(96 - 126)包含小写字母和一些标点符号。基于这种安排,系统使用 ASCII 所用的排序顺序(collation order),看起来是这样的:
ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz
与下面所列正确的字典顺序是不同的:
aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStTuUvVwWxXyYzZ
随着 Unix 的普及蔓延到美国之外,需要支持那些在美国英语中没有的字符。ASCII 表被扩展到使用完整的八位,添加了字符 128 - 255,以适应更多的语言。要支持这种能力,POSIX 标准引入了一个叫区域(locale)的概念,使其可以调节,以便适应特定地区的字符集。我们可以用下面的命令查看系统的语言设置:
由此设置,POSIX 兼容的应用程序将使用字典排序的顺序,而非 ASCII 顺序。这就可以解释前面这个命令的行为了。字符序列 [A-Z]
在字典顺序中被解释为包含除了小写字母 a 的所有字母字符,于是就有了上面的结果。
要部分解决这个问题, POSIX 标准包含了一些字符类以提供有用的字符序列,如表 19-2 所列。
表 19-2:POSIX 字符类
字符类
描述
[:alnum:]
字母和数字字符。在 ASCII 中等价于 [A-Za-z0-9]
[:word:]
和 [:alnum:]
一样,外加一个下划线字符(_
)。
[:alpha:]
字母和字符。在 ASCII 中等价于 [A-Za-z]
[:blank:]
包含空格和制表符字符。
[:cntrl:]
ASCII 控制码。包括从 0 到 31 和 127 的 ASCII 字符。
[:digit:]
从 0 到 9 的数字字符。
[:graph:]
可视字符,在 ASCII,包括从 33 到 126。
[:lower:]
小写字母。
[:punct:]
标点符号,在 ASCII 等价于 [-!"#$%&'()*+,./:;<=>?@[\\\]_
{]`
[:print:]
可打印字符,包括 [:graph:]
和空格字符。
[:space:]
空白字符,包括空格、制表符、回车符、换行符、垂直制表符和换页符。在 ASCII 等价于 [ \t\r\n\v\f]
[:upper:]
大写字母。
[:xdigit:]
用来表示十六进制的字符。在 ASCII,等价于 [0-9A-Fa-f]
即便使用字符类,还是不能很方便地表示很多部分的序列,如 [A-M]
。
使用字符类,可以重复列出我们的目录,来看一下改进后的结果。
记住,这不是一个正则表达式的示例,而是由 shell 执行的路径扩展。在这里展示这个,是因为 POSIX 字符类同时可以用在两者。
回到传统排序
您可以通过更改
LANG
环境变量的值来选择让系统使用传统(ASCII)排序。如我们早先所看到的,LANG
变量包含你所在区域所用的语言名称和字符集。其原初值取决于安装 Linux 时所选择的安装语言。要查看区域设置,要用
locale
命令:如需变更区域以使用传统 UNIX 行为,需要设置
LANG
变量为POSIX
。注意这一变更将使系统使用美国英语(更具体地说,是
ASCII
)为其字符集,请确认是否真的如你所愿。也可以将下列代码置于
.bashrc
文件中,以永久变更该变量。
POSIX 基础与扩展正则表达式
正当我们认为这不会让人感到困惑时,我们发现 POSIX 将正则表达式的实现分成了两种类型:基本正则表达式 BRE(basic regular expressions)和扩展正则表达式 ERE(extended regular expressions)。目前我们所用到的功能都由 POSIX 兼容和实现的 BRE 所支持。grep
程序就是其中之一。
BRE 和 ERE 之间有什么区别?这跟元字符有关。BRE 中可以识别下列元字符:
^ $ . [ ] *
其它所有字符都被认为是字面值。在 ERE 中加入了下列元字符(及其关联的功能):
( ) { } ? + |
然而(这也是有趣的部分),( ) { }
字符如果被反斜杠所转义,那么在 BRE 中会被当作元字符,而在 ERE 中,任何元字符只要前置一个反斜杠,就会导致其被当作字面值。所有会出现的奇怪之处都将在随后的讨论中介绍。
因为接下去将要讨论的是 ERE 的一部分,我们将需要用到一个不同的 grep
。传统上这部分是由 egrep
程序执行的,但是只要在 GNU 版本的 grep
上加个 -E
选项,也能支持扩展正则表达式。
POSIX
在 1980 年代,Unix 成为非常流行的商用操作系统,但是在 1988 年,Unix 世界变得动荡不安。许多计算机生产厂商从它的发明方 AT&T 处取得了 Unix 源代码的许可,并且随他们的系统供应多种不同版本的操作系统。然而,在努力创造产品差异化方面,每个制造商加上了所有权变更和扩展。这就开始限制软件的兼容性了。与专有供应商一样,每个人都试图与客户进行「锁定」的胜利游戏。这段 Unix 历史上的黑暗时光,今天的人称之为「巴尔干化」(the Balkanization)。
进入电气和电子工程师协会(IEEE Institute of Electrical and Electronics Engineers)。1980 年代中期,IEEE 开始开发一套标准来定义 Unix 和类 Unix 系统将如何执行。这些标准,正式称谓是 IEEE 1003,定义应用程序接口(APIs application programming interfaces)、shell、和在标准类 Unix 系统中能找到的工具。POSIX 这个名字,则代表便携式操作系统接口(Portable Operating System Interface),(末尾的 X 不过是来点额外的痛快罢了),是由 Richard Stallman 建议(没错,就那个 Richard Stallman)并被 IEEE 采纳的。
转换
我们要讨论的第一个扩展功能叫做转换(alternation),是允许从一组表达式中进行匹配的工具。就和括号表达式允许从一组给定的字符中匹配单个字符一样,转换则允许从一组字符串或其它正则表达式中匹配。
要演示该功能,我们来用连结了 echo
的 grep
。首先,试一下普通的字符串匹配。
这是非常简单的例子,我们用管道将 echo
的输出输入到 grep
中并查看了结果。当产生一个匹配时,我们就看到打出了结果,当没有匹配时,就看不到结果。
现在,我们将加载转换,用竖线元字符表示。
这里看到了正则表达式 'AAA|BBB'
,意思是「匹配字符串 AAA
或 BBB
中的任何一个」。注意,因为这是扩展功能,我们给 grep
加了 -E
选项(虽然我们也可以用 egrep
程序替代),我们还将正则表达式用引号括了起来,以防止 shell 将竖线符号解释为管道操作符。转换并不限定为仅有两个选项。
要将转换和其它正则表达式结合在一起,可以用 ()
分离转换。
这个表达式将匹配我们列表中那些以 bz
、gz
、zip
开头的文件名。要是遗漏了括号,这个正则表达式的意思就变为匹配任何以bz
开头的文件名或者包含 gz
、zip
的文件名了:
数量限定词
扩展正则表达式支持几种指定元素匹配次数的方法,下面的几个章节中会讨论。
? 匹配某个元素零次或一次
实际上,该数量限定词意思是,「使前面的元素可选」。假设我们想检查电话号码的合法性,一个电话号码如果符合下列两种形式中的任何一个,就是合法的,其中 n
是个数字:
(nnn) nnn-nnnn
nnn nnn-nnnn
我们可以构建一个这样的正则表达式:
^\(?[0-9][0-9][0-9]\)? [0-9][0-9][0-9]-[0-9][0-9][0-9][0-9]
在这个表达式中,我们在括号后加了问号,以指示括号可以匹配零次或一次。再一次看到,因为括号在 ERE 中是元字符,我们在它们之前放置了反斜杠,以便将其解释为字面值。
试验一下:
这里可以看到该表达式匹配了两种形式的电话号码,但没有匹配包含非数字字符的那个。这个表达式还不够完美,因为它仍然允许区域代码周围的括号不匹配,不过它可以执行第一阶段的验证了。
* 匹配某个元素零次或多次
和 ?
元字符一样,*
用来表示一个可选项,然而,和 ?
不同的是,该可选项可以发生任意次数而非一次。假设我们想知道一个字符串是不是一个句子,就是说,以一个大写字母开始,然后包含任意个大小写字符和空格,最后以一个句点结尾。要匹配这样(粗疏)定义的一个句子,可以使用这样的正则表达式:
[[:upper:]][[:upper:][:lower:] ]*\.
该表达式由三个项目组成:一个包含了 [:upper:]
字符类的括号表达式,一个包含了 [:upper:]
和 [:lower:]
两种字符类和一个空格的括号表达式,和一个由反斜杠转义的句点。第二个元素后跟着一个 *
元字符,所以在起先的大写字母之后,跟随任意数目的大小写字母和空格都可以匹配。
这个表达式匹配了前两个测试,但是第三个测试没有匹配,因其缺少起头的大写字符和末尾的句点。
+ 匹配某个元素一次或多次
+
元字符像极了 *
,不过它需要它前面的元素至少出现一次才会匹配。这里是一个正则表达式,仅匹配由单个空格分隔的一组或多组字母字符组成的行:
^([[:alpha:]]+ ?)+$
可以看到这个表达式不匹配 a b 9
这一行,因为它含有一个非字母字符;也没有匹配 abc d
,因为在 c
和 d
之间的空格多了一个。
{ } 匹配某个元素指定次数
{
和 }
元字符用来表示需要匹配的最小和最大数值。它们可以有四种可能的方式,在表 19-3 中表述。
表 19-3:指定匹配的次数
指定符
意义
{n}
匹配前置元素准确发生 n 次。
{n,m}
匹配前置元素最少发生 n 次,但不得多于 m 次。
{n,}
匹配前置元素最少发生 n 次。
{,m}
匹配前置元素最多不超过 m 次。
回到早先那个电话号码的例子,我们可以用这种方式来指定重复次数以简化原来的那个正则表达式,将:
^\(?[0-9][0-9][0-9]\)? [0-9][0-9][0-9]-[0-9][0-9][0-9][0-9]$
简化为:
^\(?[0-9]{3}\)? [0-9]{3}-[0-9]{4}$
来试一下:
如我们所见,修改后的表达式可以成功地验证号码,不论有没有带括号,同时拒绝匹配没有按正确格式书写的号码。
在工作中的正则表达式
让我们来看一些已经学过的命令,用上正则表达式后会如何工作。
用 grep 验证电话清单
在之前的例子中,我们看到一个个单独的电话号码并检查它们是否按正确的格式书写。更现实的场景会是检查一份电话号码清单,所以,让我们做一份清单。在命令行中背诵一个神奇的咒语就可以了。说是魔法,是因为我们没有涉及大多数涉及的命令,但不用担心,我们会在后续章节中学习到。咒语就是:
该命令会制作一个名为 phonelist.txt
的文件,包含十个电话号码。每重复一次命令,就会有十个号码添加到文件中。也可以更改 10 这个在命令开始处附近的数值来制作更多或更少的电话号码。如果来检查一下文件的内容,会发现有问题。
有些号码不正常,不过刚好是我们所需要的,因为我们要用 grep
来验证它们。
一个有用的验证方式会扫描文件,检查非法号码并显示结果列表。
这里用 -v
选项制作一个反向匹配,可以输出文件中不匹配给定表达式的行。表达式自身包含了锚元字符在每个末尾,以保证每个号码在任何一端都没有额外的字符。这个表达式还要求括号出现在有效的数字中,和早先的号码示例不同了。
用 find 查找丑陋的文件名
find
命令支持基于正则表达式的测试。相对于 grep
,在 find
中使用正则表达式有一个重要的考虑因素。当文本行内包含(contains)一个匹配表达式的字符串时,grep
总是会打印这一行,而 find
则需要路径名精确匹配(exactly match)正则表达式。在下面的示例中,我们会用带正则表达式的 find
来查找不包含任一下列集合中的字符的路径名:
[-_./0-9a-zA-Z]
这样的一个扫描会探查出包含空格和其它潜在有攻击性字符的路径名。
由于要求完整匹配整个路径名,我们在表达式的两端使用 .*
来匹配任何字符的零个或多个实例。 在表达式的中间,我们使用包含我们的可接受路径名字符集的否定括号表达式。
用 locate 查找文件
locate
程序同时支持基本(用 --regexp
选项)和扩展(用 --regex
选项)正则表达式。通过这个,就可以执行许多和之前对 dirlist
文件相同的操作。
使用转换,可以查找包含 bin/bz
、bin/gz
、bin/zip
的路径名。
用 less 和 vim 查找文本
less
和 vim
两者共享相同的检索文本的方式。按下 /
键并跟随一个正则表达式,将执行一个搜索。如果用 less
查看 phonelist.txt
:
然后检索验证表达式:
less
会高亮显示匹配的字符串,剩下不匹配的,以便识别。
(高亮效果从略)
另一方面,vim 则支持基本正则表达式,所以我们的搜索表达式就写成这样:
/([0-9]\{3\}) [0-9]\{3\}-[0-9]\{4\}
可以看到两个表达式大致相同,然而,很多在扩展表达式中作为元字符的字符,在基本表达式中则被认为是字面值。它们仅在由反斜杠转义后才被认为是元字符。有赖于我们系统上的 vim 特殊配置,匹配项会被高亮显示。如果没有高亮,试试下面这个命令模式的命令,以便激活高亮:
:hlsearch
注意:
vim
会或不会支持文本检索高亮,取决于发行版。特别是 Ubuntu 默认提供精简的 vim 版本。在这样的系统上,可以使用包管理器安装一个完整版的 vim。
总结
本章中,我们学习了许多正则表达式的使用。如果我们使用正则表达式来搜索使用它们的其它应用程序,我们会发现更多。可以通过检索手册页来查看。
zgrep
程序提供了 grep
的一个前端,允许其读取压缩文件。本例中我们检索了存储在通常位置的经压缩的手册页文件的第一章节。该命令的结果是一个包含了字符串 regex
或 regular expression
的文件清单。可以看到,正则表达式在许多程序中都有显示。
在基本正则表达式中,有一个功能我们没学习过,叫反向引用(back references),将在下一章学习。
扩展阅读
有许多线上资源供学习正则表达式,包括了各种教程和备忘录。
另外,维基百科有两篇关于背景知识的好文章:
Last updated
Was this helpful?