第十九章:正则表达式

在接下来的几章中,我们将研究用于操纵文本的工具。正如我们所看到的,文本数据在所有类Unix系统(如Linux)上都起着重要作用。但是,在我们能够充分了解这些工具提供的所有功能之前,我们必须首先研究一种经常与这些工具的最复杂用途相关的技术——正则表达式,regular expressions

在浏览命令行提供的许多功能和工具时,我们遇到了一些真正神秘的shell功能和命令,如shell扩展和引用、键盘快捷键和命令历史记录,更不用说vi编辑器了。正则表达式延续了这一“传统”(tradition),可能(可以说)是它们中最神秘的特征。这并不是说了解它们所花费的时间不值得付出努力。恰恰相反。良好的理解将使我们能够完成惊人的壮举,尽管它们的全部价值可能不会立即显现。

第十九章:正则表达式什么是正则表达式?grep元字符和文字任何字符填字游戏助手括号表达式和字符类否定传统字符范围POSIX字符类回归传统的排序方式POSIX基本正则表达式与扩展正则表达式POSIX交替量词? - 匹配一个元素零次或一次* - 匹配一个元素零次或多次+ - 匹配一个元素一次或多次{} - 将元素匹配特定次数使用正则表达式使用 grep 验证电话列表使用 find 查找丑陋的文件名使用 locate 搜索文件使用 lessvim 搜索文本总结

 

什么是正则表达式?

简单地说,正则表达式是用于识别文本中模式的符号表示法。在某些方面,它们类似于shell匹配文件名和路径名的通配符方法,但规模要大得多。许多命令行工具和大多数编程语言都支持正则表达式,以方便解决文本操作问题。然而,更令人困惑的是,并非所有正则表达式都是相同的;它们因工具和编程语言的不同而略有不同。在我们的讨论中,我们将仅限于POSIX标准中描述的正则表达式(它将涵盖大多数命令行工具),而不是许多编程语言(最著名的是Perl),它们使用稍微更大、更丰富的符号集。

grep

我们将使用的处理正则表达式的主程序是我们的老朋友 grep 。“grep”这个名字实际上来源于短语“global regular expression print”(全局正则表达式打印),所以我们可以看到 grep 与正则表达式有关。本质上, grep 在文本文件中搜索与指定正则表达式匹配的出现文本,并将任何包含匹配项的行输出到标准输出。

到目前为止,我们已经使用了带有固定字符串的 grep ,如下所示:

这将列出 /usr/bin 目录中名称包含子字符串 zip 的所有文件。

grep 程序以这种方式接受选项和参数,其中 regex 是一个正则表达式:

grep [options] regex [ file...]

下表描述了常用的 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对于多文件搜索,禁止输出文件名。
-q--quiet
--silent
抑制所有输出。当我们想测试是否找到匹配时,这在shell脚本中很有用。我们将在【第27章】中介绍测试命令的退出状态。

为了更全面地探索 grep ,让我们创建一些文本文件进行搜索。

我们可以对文件列表进行简单的搜索,如下所示:

在这个例子中, grep 在所有列出的文件中搜索字符串 bzip ,并在文件 dirlist-bin.txt 中找到两个匹配项。如果我们只对包含匹配项的文件列表而不是匹配项本身感兴趣,我们可以指定 -l 选项。

相反,如果我们只想看到不包含匹配项的文件列表,我们可以这样做:

元字符和文字

metacharacters —— 元字符

literals —— 文字

虽然这可能看起来不太明显,但我们的 grep 搜索一直在使用正则表达式,尽管非常简单。正则表达式 bzip 意味着只有当文件中的行包含至少四个字符,并且行中的某个位置按顺序找到字符 bzip ,中间没有其他字符时,才会发生匹配。字符串 bzip 中的字符都是文字字符(literal characters),因为它们自己匹配。除了文字外,正则表达式还可能包括用于指定更复杂匹配的元字符。正则表达式元字符由以下部分组成:

^ $ . [ ] { } - ? * + ( ) | \

所有其他字符都被认为是文字,尽管在少数情况下使用反斜杠字符来创建元序列(meta sequences),并允许元字符被转义并被视为文字,而不是被解释为元字符。

注意:正如我们所看到的,许多正则表达式元字符也是在执行扩展时对shell有意义的字符。当我们在命令行上传递包含元字符的正则表达式时,将它们括在引号中以防止shell试图展开它们至关重要。

任何字符

我们将看到的第一个元字符是点(dot)或句点(period)字符,用于匹配任何字符。如果我们将其包含在正则表达式中,它将匹配该字符位置的任何字符。这里有一个例子:

我们在文件中搜索了与正则表达式 “.zip” 匹配的任何一行。关于结果,有几件有趣的事情需要注意。请注意,找不到 zip 程序。这是因为在正则表达式中包含点元字符将所需匹配的长度增加到四个字符,其中一个必须在 z 之前。此外,如果我们列表中的任何文件都包含文件扩展名 .zip ,它们也会被匹配,因为文件扩展名中的句点字符也会被“任何字符”匹配

在这里,我们搜索了文件列表中位于行首、行尾的字符串 zip ,以及位于行首和行尾的行上的字符串 zip (即,它本身在行上)。请注意,正则表达式 ^$ (开头和结尾之间没有任何内容)将匹配空白行。

填字游戏助手

即使我们目前对正则表达式的知识有限,我们也可以做一些有用的事情。

我妻子喜欢填字游戏,她有时会问我一个特定的问题。比如,“第三个字母是'j',最后一个字母是'r'的五个字母单词是什么意思……?”这种问题让我思考。

你知道你的Linux系统包含一个字典吗?确实如此。看看 /usr/share/dict 目录,你可能会找到一个或多个。位于那里的词典文件只是一长串单词,每行一个,按字母顺序排列。在我的系统中,单词文件包含98500多个单词。为了找到上述填字游戏问题的可能答案,我们可以这样做:

使用这个正则表达式,我们可以找到字典文件中所有五个字母长、第三位有 j 、最后一位有 r 的单词。

括号表达式和字符类

bracket —— 括号

除了匹配正则表达式中给定位置的任何字符外,我们还可以使用括号表达式匹配指定字符集中的单个字符。使用括号表达式,我们可以指定要匹配的一组字符(包括否则将被解释为元字符的字符)。在这个例子中,使用两个字符集,我们匹配任何包含字符串 bzipgzip 的行:

一个集合可以包含任意数量的字符,元字符在放在括号内时会失去其特殊含义。然而,在括号表达式中使用元字符并具有不同含义的情况有两种。第一个是插入符号( ^ ),用于表示否定(negation);第二个是破折号( - ),用于表示字符范围。

否定

如果括号表达式中的第一个字符是插入符( ^ ),则其余字符将被视为一组不能出现在给定字符位置的字符。我们通过修改前面的示例来实现这一点,如下所示:

当否定激活时,我们会得到一个文件列表,其中包含字符串 zip ,前面除了 bg 之外的任何字符。请注意,找不到文件 zip 。否定字符集仍然需要在给定位置有一个字符,但该字符不能是否定集的成员。

插入字符只有在它是括号表达式中的第一个字符时才会调用否定;否则,它将失去其特殊意义,成为集中的一个普通字符。

传统字符范围

如果我们想构造一个正则表达式来查找列表中以大写字母开头的每个文件,我们可以这样做:

只需将所有26个大写字母放在括号表达式中即可。但是,所有这些打字的想法都令人深感不安,所以还有另一种方法。

通过使用三个字符的范围,我们可以缩写26个字母。任何字符范围都可以用这种方式表示,包括多个范围,例如匹配所有以字母和数字开头的文件名的表达式:

在字符范围中,我们看到破折号字符被特殊处理,那么我们如何在括号表达式中包含破折号呢?使其成为表达式中的第一个字符。考虑这两个例子:

这将匹配每个包含大写字母的文件名。以下内容将匹配每个包含破折号、大写字母 A 或大写字母 Z 的文件名:

POSIX字符类

传统的字符范围是一种易于理解且有效的方法来处理快速指定字符集的问题。不幸的是,它们并不总是有效的。虽然到目前为止,我们在使用 grep 时没有遇到任何问题,但在使用其他程序时可能会遇到问题。

在【第4章】中,我们研究了如何使用通配符来执行路径名扩展。在那次讨论中,我们说字符范围的使用方式几乎与正则表达式中的使用方式相同,但问题是:

(根据Linux发行版的不同,我们会得到一个不同的文件列表,可能是一个空列表。这个例子来自Ubuntu)。此命令产生预期的结果——一个仅包含名称以大写字母开头的文件的列表,但使用下面的命令,我们得到了一个完全不同的结果(仅显示了部分结果列表):

为什么?这是一个很长的故事,但以下是简短的版本:

当Unix首次开发时,它只知道ASCII字符,这一特性反映了这一事实。在ASCII中,前32个字符(编号0-31)是控制码(如制表符、退格和回车符)。接下来的32个(编号32-63)包含可打印字符,包括大多数标点符号和数字0-9。接下来的32个(编号64-95)包含大写字母和几个标点符号。最后的31个(编号96-126)包含小写字母和更多的标点符号。基于这种安排,使用ASCII的系统使用的排序顺序如下:

ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz

这与正确的字典顺序不同,字典顺序如下:

aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStTuUvVwWxXyYzZ

随着Unix的流行蔓延到美国以外,人们越来越需要支持美国英语中没有的字符。ASCII表被扩展为使用完整的8位,添加了字符128-255,可以容纳更多的语言。为了支持这种能力,POSIX标准引入了一个称为区域设置的概念,可以调整该概念以选择特定位置所需的字符集。我们可以使用以下命令查看系统的语言设置:

使用此设置,符合POSIX的应用程序将使用字典排序顺序而不是ASCII顺序。这解释了前面命令的行为。当按字典顺序解释时, [A-Z] 的字符范围包括除小写 a 之外的所有字母字符,造成了我们的结果。

为了部分解决这个问题,POSIX标准包含了许多字符类,如下表所示,这些字符类提供了有用的字符范围。

字符类描述
[:alnum:]字母数字字符。在ASCII中,相当于:[A-Za-z0-9]
[:word:]与[:alnum:]相同,但添加了下划线(_)字符。
[:alpha:]字母字符。在ASCII中,相当于:[A-Za-z]
[:blank:]包括空格和制表符。
[:cntrl:]ASCII控制码。包括ASCII字符0到31和127。
[: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还将正则表达式实现分为两种:基本正则表达式(basic regular expressions ,BRE)和扩展正则表达式(extended regular expressions ,ERE)。到目前为止,我们介绍的功能都得到了任何符合POSIX标准并实现BRE的应用程序的支持。我们的 grep 程序就是这样一个程序。

BRE和ERE有什么区别?这是元字符的问题。使用BRE,可以识别以下元字符:

^ $ . [ ] *

所有其他字符都被视为文字。使用ERE,添加了以下元字符(及其相关函数):

( ) { } ? + |

然而(这是有趣的部分),如果(、)、{和}字符用反斜杠转义,它们在BRE中会被视为元字符,而在ERE中,任何元字符前面加反斜杠会导致它被视为文字。任何出现的奇怪之处都将在接下来的讨论中讨论。

由于我们接下来要讨论的特性是ERE的一部分,因此我们需要使用不同的 grep 。传统上,这是由 egrep 程序执行的,但GNU版本的 grep 在使用 -E 选项时也支持扩展正则表达式。

POSIX

在20世纪80年代,Unix成为一个非常流行的商业操作系统,但到1988年,Unix世界陷入了混乱。许多计算机制造商从其创建者AT&T那里获得了Unix源代码的许可,并在其系统中提供了各种版本的操作系统。然而,在努力创造产品差异化的过程中,每个制造商都增加了专有的更改和扩展。这开始限制软件的兼容性。与专有供应商一样,每家公司都试图与客户玩一场成功的“lock-in(锁定)”游戏。Unix历史上的这个黑暗时期今天被称为“巴尔干化(the Balkanization)”。

进入电气和电子工程师协会(Institute of Electrical and Electronics Engineers,IEEE)。20世纪80年代中期,IEEE开始制定一套标准,定义Unix(和类Unix)系统的性能。这些标准正式称为IEEE 1003,定义了标准类Unix系统上的应用程序编程接口(application programming interfaces,API)、shell和实用程序。POSIX这个名字代表可移植操作系统接口(Portable Operating System Interface ,最后添加了X以获得额外的灵活性),是由Richard Stallman(是的,就是那个Richard Stallman)提出的,并被IEEE采用。

交替

我们将讨论的第一个扩展正则表达式特性称为 alternation ,交替。这是一种允许在一组表达式之间进行匹配的工具。正如括号表达式允许从一组指定字符中匹配单个字符一样,交替允许从一系列字符串或其他正则表达式中匹配。

为了演示,我们将结合(conjunction)使用 grepecho 。首先,让我们尝试一个简单的旧字符串匹配:

这是一个非常简单的例子,其中我们将 echo 的输出管道到 grep 中并查看结果。当匹配发生时,我们会看到它被打印出来;当没有匹配时,我们看不到结果。

现在我们将添加交替,由竖线元字符表示。

这里我们看到正则表达式 AAA|BBB ,意思是【匹配字符串AAA或字符串BBB】。请注意,由于这是一个扩展功能,我们在 grep 中添加了 -E 选项(尽管我们本可以直接使用 egrep 程序),并将正则表达式括在引号中,以防止shell将竖线元字符解释为管道运算符。

交替不限于两种选择。

要将交替与其他正则表达式元素组合,我们可以使用( )来分隔交替。

此表达式将匹配列表中以 bzgzzip 开头的文件名。如果我们省略了括号,这个正则表达式的含义就会改变,以匹配任何以 bz 开头或包含 gz 或包含 zip 的文件名:

量词

quantifiers —— 量词

扩展正则表达式支持多种方式来指定元素匹配的次数,如以下部分所述。

? - 匹配一个元素零次或一次

这个量词实际上意味着“使前面的元素可选”(Make the preceding element optional.)。假设我们想检查电话号码的有效性,如果电话号码与这两种形式中的任何一种匹配,我们认为它是有效的,其中 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 ,因为字符 cd 之间有多个空格字符分隔。

{} - 将元素匹配特定次数

{} 元字符用于表示所需匹配的最小和最大数量。如下表所示,它们可以通过四种可能的方式进行指定。

指定者含义
{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 验证电话列表

在前面的示例中,我们查看了单个电话号码,并检查了它们的格式是否正确。更现实的情况是检查一系列数字,所以让我们列一个清单。我们将通过在命令行中背诵一个神奇的咒语(magical incantation)来做到这一点。这将是神奇的,因为我们还没有涵盖所涉及的大部分命令,但不用担心。我们将在以后的章节中讨论。这是咒语:

此命令将生成一个名为 phonelist.txt 的文件,其中包含十个电话号码。每次重复该命令,列表中都会添加另外十个数字。我们还可以更改命令开头附近的值 10 ,以生成更多或更少的电话号码。然而,如果我们检查文件的内容,我们会发现我们有问题。

有些数字格式不正确,这非常适合我们的目的,因为我们将使用 grep 来验证它们。

一种有用的验证方法是扫描文件中的无效数字并显示结果列表。

在这里,我们使用 -v 选项生成反向匹配,这样我们将只输出列表中与指定表达式不匹配的行。表达式本身在每一端都包含锚(anchor)元字符——^$ ,以确保数字在两端都没有多余的字符。与我们之前的电话号码示例不同,此表达式还要求括号出现在有效数字中。

使用 find 查找丑陋的文件名

find 命令支持基于正则表达式的测试。在 findgrep 中使用正则表达式时,有一个重要的考虑因素需要牢记。当一行包含与表达式匹配的字符串时, grep 会打印一行,而 find 要求路径名与正则表达式完全匹配(exactly match)。在下面的示例中,我们将使用 find 和正则表达式来查找包含不属于以下集合的任何字符的每个路径名:

[-_./0-9a-zA-Z]

这样的扫描将揭示包含嵌入式空格和其他潜在冒犯性(offensive)字符的路径名。

由于需要整个路径名的精确匹配,我们使用 .* 在表达式的两端,匹配任何字符的零个或多个实例。在表达式的中间,我们使用一个否定的括号表达式,其中包含一组可接受的路径名字符。

使用 locate 搜索文件

locate 程序支持基本( --regexp 选项)和扩展( --regex 选项)正则表达式。有了它,我们可以执行许多与之前使用 dirlist 文件执行的操作相同的操作。

使用交替,我们搜索包含 bin/bzbin/gz/bin/zip 的路径名。

使用 lessvim 搜索文本

lessvim 都有相同的文本搜索方法。按 / 键,然后按正则表达式将执行搜索。如果我们使用 less 来查看我们的 phonelist.txt 文件,如下所示:

然后搜索我们的验证表达式,如下所示:

less 将突出显示匹配的字符串,使无效的字符串易于发现。

另一方面, vim 支持基本的正则表达式,因此我们的搜索表达式如下:

/([0-9]\{3\}) [0-9]\{3\}-[0-9]\{4\}

我们可以看到,表达方式基本相同;然而,在扩展表达式中被视为元字符的许多字符在基本表达式中被认为是文字。当用反斜杠转义时,它们只被视为元字符。根据我们系统上 vim 的特定配置,匹配将被突出显示。如果没有,请尝试此命令模式命令以激活突出显示:

:hlsearch

注意:根据您的发行版, vim 可能支持也可能不支持文本搜索突出显示。特别是Ubuntu,默认情况下提供了 vim 的精简版本。在这样的系统上,您可能希望使用包管理器安装更完整的 vim 版本。

总结

在本章中,我们看到了正则表达式的许多用法中的一些。如果我们使用正则表达式来搜索使用它们的其他应用程序,我们可以找到更多。我们可以通过搜索手册页来做到这一点。

zgrep 程序为 grep 提供了一个前端,允许它读取压缩文件。在我们的示例中,我们在压缩的第1节手册页文件的常规位置进行搜索。此命令的结果是一个包含字符串正则表达式或字符串正则表达式的文件列表。正如我们所看到的,正则表达式出现在很多程序中。

在基本正则表达式中发现了一个我们没有涵盖的特性。回调引用,此功能将在下一章中讨论。