Unix 之书
☰ 目录

第 20 章...

正则表达式 (Regular Expressions)

正则表达式用于指定字符模式。正则表达式 最常见的用途是查找字符串。因此, 正则表达式常用于查找与替换操作。

作为一种工具,正则表达式既有用又强大, 以至于专门讨论这个主题的书籍和网站比比皆是。 可以肯定,精通正则表达式的运用之道,是你为 熟练掌握 Unix 所能做的最重要的事情之一。

正则表达式可以写得非常复杂。然而 在大多数情况下,你需要的正则表达式都简单 直接,所以你要做的只是学习几条简单的规则, 然后练习、练习、再练习。本章的目标就是 帮你入门。

注意:在开始之前,我想先提一件事。 本章中我会用 grep 命令(第 19 章) 给你展示大量示例。如果你发现某些正则表达式 功能在你的 grep 版本上不起作用,你可能 需要改用 egrepgrep -E。 在这种情况下,你可以设置一个别名,自动使用 这些变体之一。详细信息见第 19 章中关于 grepegrep 的讨论。至于个中 原因,等我们稍后谈到扩展正则表达式与基本 正则表达式时就会明白。

正则表达式简介
(Introducing Regular Expressions)

本章中,我用 grep 命令来演示正则 表达式的示例,我们在第 19 章讨论过 grep。这些示例虽然方便,但你要知道, 正则表达式可以用在许多不同的 Unix 程序中, 比如 vi 和 Emacs 文本编辑器、lesssed 等等。此外,正则表达式还能用于许多 编程语言,比如 Awk、C、C++、C#、Java、Perl、 PHP、Python、Ruby、Tcl 和 VB.NET。

我要教给你的概念适用于一般的正则表达式。 一旦掌握了基本规则,你以后只需要在需要时 学习一些变体即可。虽然正则表达式中一些较为 深奥的特性在不同程序之间可能有所不同 —— 例如 Perl 就有一整套高级特性 —— 但基本思想 始终相同。如果你遇到问题,只需查阅你所用 程序的文档即可。

正则表达式(REGULAR EXPRESSION,常缩写为 REGEX 或 RE)是一种指定字符模式的紧凑方式。 例如,考虑下面这个由三个字符串组成的集合:

harley1 harley2 harley3

作为正则表达式,你可以把这个模式集合表示为 harley[123]

再看一个例子。你想描述这样一组字符串:以 大写字母 "H" 开头,后面跟着任意多个小写字母, 最后以小写字母 "y" 结尾。对应的正则表达式是 H[[:lower:]]*y

如你所见,正则表达式的威力来自于那些具有 特殊含义的元字符和缩写。我们将在接下来的 几节中讨论细节。作为参考,正则表达式的语法 汇总在图 20-1、20-2 和 20-3 中。现在花点时间 略读一下,在阅读本章其余内容时可以随时 回头查阅。

Figure 20-1: 正则表达式:基本匹配

正则表达式是一种指定字符模式的紧凑方式。在 正则表达式中,普通字符匹配自身,而某些 元字符具有特殊含义。本表列出了用于完成 基本模式匹配功能的元字符。

元字符 含义
 .匹配除换行符以外的任意单个字符
 ^锚点:匹配行的开头
 $锚点:匹配行的结尾
 \<锚点:匹配单词的开头
 \>锚点:匹配单词的结尾
 [list]字符类:匹配 list 中的任意字符
 [^list]字符类:匹配不在 list 中的任意字符
 (  )分组:视为一个整体
 |交替:匹配备选项之一
 \引用:将元字符按字面意义解释

Figure 20-2: 正则表达式:重复操作符

在正则表达式中,可以使用下面这些称为重复 操作符的元字符来匹配特定字符的多个实例。

注意:有些程序不支持 {,m},因为它不是标准写法。

操作符 含义
 *匹配零次或多次
 +匹配一次或多次
 ?匹配零次或一次
 {n}边界:匹配 n
 {n,}边界:匹配 n 次或更多
 {0,m}边界:匹配 m 次或更少
 {,m}边界:匹配 m 次或更少
 {n,m}边界:匹配 nm

Figure 20-3: 正则表达式:预定义字符类

正则表达式中可以使用字符类来定义一组字符。 为了方便,有一些预定义的字符类可以当作 缩写来使用。本表列出了最重要的几个。注意, 方括号和冒号是名称的一部分。

最右边一列列出了一些与预定义字符类等价的 范围。如果你的系统使用 C 排序序列,你可以 用这些范围代替类名。为确保系统确实如此, 你可以把环境变量 LC_COLLATE 设置为 C。(详见正文。)

字符类 含义 相当于...
[:lower:]小写字母a-z
[:upper:]大写字母A-Z
[:alpha:]大写和小写字母A-Za-z
[:alnum:]大写和小写字母、数字A-Za-z0-9
[:digit:]数字0-9
[:punct:]标点字符
[:blank:]空格、制表符

正则表达式起源
(The Origin of Regular Expressions)

"正则表达式"这个术语来自计算机科学,指的 是一套指定模式的规则。这个名字源自美国杰出 数学家、计算机科学家 Stephen Kleene (1909–1994)的工作。(他的名字发音为 "Klej-nee"。)

20 世纪 40 年代初,两位神经科学家 Walter Pitts 和 Warren McCulloch 提出了一个 数学模型,描述他们认为的神经元(神经细胞) 的工作方式。作为模型的一部分,他们使用了 一种非常简单、虚构的机器,称为自动机 (automata)。20 世纪 50 年代中期,Kleene 发明了一种用数学方式描述自动机的方法,使用 他所谓的"正则集"(regular sets):即可以用 少数简单性质来描述的集合。Kleene 随后创造 了一种简单的符号体系,称为正则表达式, 用来描述这样的集合。

1966 年,Ken Thompson —— 后来开发出 Unix 的 那位程序员 —— 加入了贝尔实验室。他最早做的 事情之一,就是编写了一个 QED 文本编辑器的 版本,他在加州大学伯克利分校时用过这个编辑器。 Thompson 对 QED 做了大量扩展,增加了一项 模式匹配功能(除了其他特性之外),它使用的 是 Kleene 正则表达式的增强形式。在此之前, 文本编辑器只能查找精确的字符串。而现在, 借助正则表达式,QED 编辑器也能查找模式了。

1969 年,Thompson 创建了第一个原始的 Unix 版本(见第 1 章和第 2 章)。不久之后,他 编写了第一个 Unix 编辑器 ed(发音为 "ee-dee"),设计时使用了正则表达式的一个 简化形式,功能不如他在 QED 中用的那样强大。 ed 程序是所谓 UNIX Version 1 的一部分, 该系统于 1971 年发布。

ed 的流行带动了正则表达式在 grep 中的应用,后来又应用于许多其他 Unix 程序。如今,正则表达式的应用十分广泛, 不仅在 Unix 内部,而且遍及整个计算机世界。 (有趣的是,Thompson 当年编写 ed 时舍弃 的那些特性,现在又都被加回来了。)

— 提示 —

下次你参加 Unix 聚会、大家开始谈论正则表达式 时,你可以不经意地提一句:它们对应于乔姆斯基 层次(Chomsky hierarchy)中的 3 型文法。一旦 吸引了所有人的注意,你就可以接着解释:任何 正则表达式都可以与 NFA(非确定性有限自动机) 之间建立一个简单的映射,因为每个正则表达式 长度有限,因此操作符的数量也有限。

转眼间,你就会成为全场最受欢迎的人。(*)

* 脚注

反正这招对我一直很灵。

基本正则表达式与扩展正则表达式
(Basic and Extended Regular Expressions)

本节是一份参考资料,第一遍阅读时 可能让人有点摸不着头脑。别担心。等你读完 本章剩下的内容并稍加练习之后,一切就都 说得通了。

正如我们在上一节所讨论的,当 Ken Thompson 创建 ed 文本编辑器时,正则表达式成了 Unix 的一部分;该编辑器随 UNIX Version 1 于 1971 年发布。最初的正则表达式功能很有用, 但也很有限,多年来已被大幅扩展。这就产生了 各种复杂程度不一的 regex 系统,容易让初学者 感到困惑。

就实际使用而言,你只需要记住几个基本观念。 我想现在花一点时间讲讲这些观念,这样等你 以后用本章示例做练习时就不会遇到麻烦。

Unix 支持两种主要的正则表达式变体:现代版本 和较旧的过时的版本。现代版本是扩展正则表达式 (EXTENDED REGULAR EXPRESSION,简称 ERE)。 它是当前的标准,是 IEEE 1003.2 总标准的 一部分(属于 POSIX;见第 11 章)。

较旧的版本是基本正则表达式(BASIC REGULAR EXPRESSION,简称 BRE)。它是一种较为原始的 正则表达式,使用多年,直到被 1003.2 标准化 所取代。基本正则表达式不如扩展正则表达式 强大,语法也稍微更令人困惑一些。因此,BRE 现在被认为是过时的,只是为了与旧程序保持 兼容才保留下来。

在本章中,我要教给你的是扩展正则表达式, 也就是现代 Unix 和 Linux 系统的默认类型。 不过,你有时会遇到只接受基本正则表达式的 旧程序。在这种情况下,我希望你知道自己在 做什么,所以这里先做几点说明。

最可能给你带来麻烦的两个命令是 grepsed(两者都在第 19 章中介绍)。要 了解它们在你的系统上支持哪种正则表达式, 请查看 man 手册页:

man grep
man sed

如果你的系统使用 GNU 工具集 —— Linux 和 FreeBSD 就是如此 —— 你会发现一些命令已经 更新,提供了 -E 选项,允许你使用扩展 正则表达式。grep 就是一例。一般说来, 你可以通过查看命令的 man 手册页,或者使用 --help 选项显示其语法(见第 10 章), 来判断某个命令是否提供 -E 选项。

— 提示 —

在 Linux 和 FreeBSD 上,一些命令提供 -E 选项,允许你使用扩展正则表达式。 由于扩展 regex 总是更可取,你应当养成使用 -E 的习惯。

如果你经常使用某个这样的命令,可以创建一个 别名,让 -E 自动生效。有关如何做到的 示例,见第 19 章中关于 grepegrep 的讨论。

尽管扩展正则表达式是现代标准,尽管一些程序 提供了 -E 选项,但有时你别无选择, 只能使用基本正则表达式。例如,你可能想使用 一个旧程序,而它在你的系统上只支持基本 正则表达式。(最常见的例子就是 sed。)

在这种情况下,懂得如何应对对你很有必要, 所以我要花一点时间解释基本正则表达式与 扩展正则表达式之间的区别。当然,这段讨论 有点为时过早,因为我们还没谈到 regex 的 技术细节。不过,正如我之前所说,第一遍 阅读时有什么不明白的地方,等你回头再看时 就会懂了。

正如我在本章前面提到的,正则表达式的威力 来自于使用具有特殊含义的元字符。本章将花 大量时间讨论如何使用这些元字符。(作为参考, 它们汇总在图 20-1、20-2 和 20-3 中。)

基本正则表达式与扩展正则表达式的最大区别 在于:使用基本 regex 时,某些元字符无法使用, 另一些则必须用反斜杠引用。(引用问题在第 13 章讨论。)无法使用的元字符是问号、加号和 竖线:

? + |

必须转义的元字符是花括号和圆括号:

{ } ( )

作为参考,我在图 20-4 中汇总了这些限制。 第一次看这张表时你可能不明所以,但等你读完 本章,它就会变得一目了然。

Figure 20-4: 扩展正则表达式与基本正则表达式

正则表达式的现代标准是扩展正则表达式(ERE), 它作为 1003.2 POSIX 标准的一部分而定义。 扩展正则表达式取代了较旧的基本正则表达式 (BRE)。作为参考,本表列出了基本正则表达式 的限制,可以概括为以下几点:

1. 花括号必须用反斜杠引用。
2. 圆括号必须用反斜杠引用。
3. 不能使用 ?,但可以用 \{0,1\} 模拟。
4. 不能使用 +,但可以用 \{1,\} 模拟。
5. 不能使用 |(竖线)。
6. 不能使用预定义字符类。

扩展 regex 基本 regex 含义
{  }\{  \}定义边界(花括号)
(  )\(  \)定义分组(圆括号)
?\{0,1\}匹配零次或一次
+\{1,\}匹配一次或多次
|交替:匹配备选项之一
[:name:]预定义字符类

匹配行与单词
(Matching Lines and Words)

正如我之前所解释的,正则表达式(简称 regex) 是一种指定字符模式的紧凑方式。要创建正则 表达式,你按照一定的规则把普通字符和 元字符组合在一起,然后用这个 regex 去查找 你想找到的字符串。

当一个正则表达式与某个特定的字符串相对应时, 我们就说它匹配(MATCHES)这个字符串。例如, regex harley[123] 匹配 harley1harley2harley3 中的任何一个。 目前你不必纠结细节,只要认识到 harley123 是普通字符,而 [] 是元字符即可。换一种说法就是:在 正则表达式中,harley123 匹配它们自身,而 [](方括号) 字符具有特殊含义。最终,你会学完所有元字符 及其特殊含义。

在这一节中,我们将介绍一类称为锚点(ANCHORS) 的元字符,它们用于匹配字符串开头或结尾的 位置。例如,regex harley$ 匹配字符串 harley,但仅当它出现在行末时才是如此。 这是因为 $ 是一个元字符,它作为锚点 匹配行的结尾。(现在先不用担心细节。)

为了开始正则表达式的冒险之旅,我们先从基本 规则出发:所有普通字符,比如字母和数字, 都匹配自身。下面几个例子演示它是如何工作的。

假设你有一个名为 data 的文件,包含 以下四行:

Harley is smart
Harley
I like Harley
the dog likes the cat

你想用 grep 找出所有在任意位置包含 "Harley" 的行。你可以这样用:

grep Harley data

在这个例子中,Harley 实际上就是一个 正则表达式,它会让 grep 选出第 1、2、3 行,而不选第 4 行:

Harley is smart
Harley
I like Harley

这没什么新鲜的,但它说明了在正则表达式中, 一个 H 匹配 "H",一个 a 匹配 "a",一个 r 匹配 "r",依此类推。所有 regex 都源自这一基本思想。

要扩展正则表达式的能力,你可以使用锚点来 指定你要查找的模式的位置。^(抑扬符) 元字符是一个锚点,匹配行的开头。因此,要只 查找以 "Harley" 开头的行,你可以这样用:

grep '^Harley' data

在我们的示例中,这条命令会选出第 1 行和第 2 行,而不选第 3、4 行(因为它们不是以 "Harley" 开头):

Harley is smart
Harley

你会注意到,在上一条命令中,我给正则表达式 加了引号。只要使用的 regex 包含元字符,你就 应当这样做,以确保 Shell 不去理会这些字符, 而是把它们原样传给程序(在本例中是 grep)。如果你不确定是否需要给正则 表达式加引号,那就加吧,这不会带来任何问题。

你会注意到,为了保险起见,我用了强引号 (单引号)而不是弱引号(双引号)。这能确保 所有元字符(而不只是其中一部分)都被正确 引用。(如果你想复习强引号与弱引号的区别, 见第 13 章。)

匹配行结尾的锚点是 $(美元符号) 元字符。例如,要只查找以 "Harley" 结尾的行, 你可以这样用:

grep 'Harley$' data

在我们的示例中,这只会选出第 2 行和第 3 行:

Harley
I like Harley

只要做法合理,你可以在同一个正则表达式中 组合使用 ^$。例如,要查找 整行内容恰好是 "Harley" 的所有行,你会同时 使用两个锚点:

grep '^Harley$' data

在我们的示例中,这会选出第 2 行,因为只有它 既以 "Harley" 开头又以 "Harley" 结尾:

Harley

把两个锚点连用、中间什么都不放,是查找空行 的简单方法。例如,下面这条命令统计文件 data 中所有空行的行数:

grep '^$' data | wc -l

同样,也有锚点可以用来匹配单词的开头或结尾, 或者两者同时匹配。要匹配单词的开头,使用两字符 组合 \<。要匹配单词的结尾,使用 \>

例如,假设你要在一个名为 data 的文件中 查找所有包含字母 "kn" 的行,但仅当 "kn" 出现在 单词开头时才算数。你可以这样用:

grep '\<kn' data

要查找字母 "ow",但仅限出现在单词结尾时,用:

grep 'ow\>' data

要查找完整的单词,就把 \<\> 一起用。例如,要查找 "know", 但仅作为完整单词查找,用:

grep '\<know\>' data

这条命令会选出这一行:

I know who you are, and I saw what you did.

但不会选出这一行:

Who knows what evil lurks in the hearts of men?

作为方便之计,在使用 GNU 工具集的系统上 —— 比如 Linux 和 FreeBSD —— 你可以把 \b 用作替代锚点,同时代替 \<\>。例如,下面两条命令是等价的:

grep '\<know\>' data
grep '\bknow\b' data

你可以把 \b 理解为"边界标记" (boundary marker)。

选择哪种单词边界锚点完全取决于你的喜好。我用 \<\>,因为在我看来它们 更容易看清。不过,很多人更喜欢 \b,因为 它更便于输入,而且在单词的开头和结尾可以用同 一个锚点。

在使用正则表达式时,"单词"的定义比在英语中 更灵活。在 regex 中,单词(WORD)是自成一体的 连续字符序列,由字母、数字或 _(下划线) 字符组成。因此,在 regex 中,下面这些都被视为 单词:

fussbudget Weedly 1952 error_code_5

许多 Unix 程序也采用同样的定义。例如,当你使用 -w 选项匹配完整单词时,grep 就用 这个定义。

— 提示 —

grep 查找完整单词时,使用 -w(word)选项往往比多次使用 \<\>\b 更省事。

例如,下面三条命令是等价的:

grep -w 'cat' data
grep '\<cat\>' data
grep '\bcat\b' data

匹配字符;字符类
(Matching Characters; Character Classes)

在正则表达式中,元字符 .(点)匹配除 换行符以外的任意单个字符。(在 Unix 中,换行符 标记一行的结尾;见第 7 章。)

例如,假设你要在名为 data 的文件中查找 所有符合下面模式的行:字母 "Har",后面跟着任意 两个字符,再后面是字母 "y"。你可以用这条命令:

grep 'Har..y' data

这条命令会找到包含如下内容的行,例如:

harley harxxy harlly har12y

你会发现 . 元字符非常有用,而且会经常 用到它。尽管如此,有时你还是想更精确一些: . 会匹配任意字符,但你可能只想 匹配特定的字符。例如,你可能想查找大写的 "H", 后面跟着 "a" 或 "A"。在这种情况下,你可以通过把 字符放在方括号 [ ] 内来指定你想查找的 字符。这样的构造称为字符类(CHARACTER CLASS)。

例如,要在文件 data 中查找所有包含字母 "H"、后面跟着 "a" 或 "A" 的行,你可以这样用:

grep 'H[aA]' data

在继续之前,我想强调一个重要观点。严格说来, 字符类并不包括方括号。例如,在上一条命令中, 字符类是 aA,而不是 [aA]。虽然 使用字符类时必须有方括号,但方括号并不算字符类 本身的一部分。当我们谈到称为"预定义字符类"的 特殊缩写时,这个区别就会很重要。

接下来是一个在同一个正则表达式中使用多个字符类 的例子。下面这条命令查找包含单词 "license" 的 行,即使有人把 "c" 和 "s" 弄混而拼错了它:

grep 'li[cs]en[cs]e' data

为了让命令更有用,我们可以用 \<\>\b,只匹配完整的单词:

grep '\<li[cs]en[cs]e\>' data
grep '\bli[cs]en[cs]e\b' data

这两条命令会匹配下面任何一种拼法:

licence license lisence lisense

预定义字符类;范围
(Predefined Character Classes; Ranges)

有些字符集合太常用了,以至于人们给它们起了名字, 以便使用。这些集合称为预定义字符类(PREDEFINED CHARACTER CLASSES),在本章前面的图 20-3 中就能 看到。(现在先停下来看一眼,再继续往下读,我要 你熟悉各个名称及其含义。)

使用预定义字符类很直截了当,只有一条奇怪的规则: 方括号实际上就是名称的一部分。因此,使用它们时, 你必须再加上第二对方括号才能保持语法正确。(你 还记得吧,前面我说过,使用字符类时,外面的方括号 属于该字符类。)

例如,下面这条命令用 grep 找出名为 data 的文件中所有包含数字 21、后面紧跟 一个单个小写或大写字母的行:

grep '21[[:alpha:]]' data

下一条命令找出所有包含连续两个大写字母、后面 跟一个数字、再跟一个小写字母的行:

grep '[[:upper:]][[:upper:]][[:digit:]][[:lower:]]' data

除了预定义字符类,还有另一种指定一组字母或数字 的方法:使用字符范围(RANGE),即由连字符分隔的 首尾两个字符。例如,要查找文件 data 中 所有包含 3 到 7 之间某个数字的行,你可以用:

grep '[3-7]' data

范围 0-9[:digit:] 含义相同。 例如,要查找包含大写字母 "X"、后面跟着任意两个 数字的行,可以用下面两条命令中的任何一条:

grep 'X[0-9][0-9]' data
grep 'X[[:digit:]][[:digit:]]' data

作为本节的结尾,我们再考虑一种情况:你想匹配 不在某个特定字符类之内的字符。你只需在 左方括号之后加上一个 ^(抑扬符)元字符 即可做到。在这种场合,^ 起的是取反操作符 的作用。

例如,下面这条命令在名为 data 的文件中 查找所有包含字母 "X" 的行,只要 "X" 后面跟的 不是 "a" 或 "o":

grep 'X[^ao]' data

下面两条命令找出所有至少包含一个非字母字符的 行:

grep '[^A-Za-z]' data
grep '[^[:alpha:]]' data

— 提示 —

理解复杂正则表达式的诀窍,是记住每一条字符类 —— 不管它看上去多么复杂 —— 都只代表单个字符。

区域设置与排序序列: locale;ASCII 码
(Locales and Collating Sequences: locale; The ASCII Code)

读到这里,你可能会想知道:能不能用其他范围来 代替预定义字符类?例如,能否用 a-z 代替 [:lower:]?类似地,能否用 A-Z 代替 [:upper:];用 A-Za-z 代替 [:alpha:];用 a-zA-Z0-9 代替 [:alnum:]?

答案是:看情况。在某些系统上可行,在另一些系统 上则不行。在我能向你解释个中缘由之前,我们需要 先谈谈"区域设置"(locale)这个概念。

当你写 0-9 时,它是 0123...9 的缩写,这当然说得通。 然而,当你写 a-z 时,它未必意味着 abcd...z。这是因为字母表在你所用 系统上的排列顺序,取决于所谓的"区域设置" (locale),而它在不同系统之间可能有所不同。

为什么会这样呢?在 20 世纪 90 年代之前,Unix (以及大多数计算机系统)使用的字符编码是 ASCII 码(ASCII CODE),常直接称为 ASCII。这个名字来自 "American Standard Code for Information Interchange"(美国信息交换标准代码)的首字母 缩略词。

ASCII 码创建于 1967 年。它为每个字符规定了一个 7 位模式,总共 128 个。这些位模式从 0000000 (十进制 0)到 1111111(十进制 127)。ASCII 码 包含我们在第 7 章讨论过的所有控制字符,以及 56 个可打印字符:字母、数字和标点。可打印字符如下。 (注意第一个字符是空格。)

 !"#$%&'()*+,-./0123456789:;<=>?
@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_
`abcdefghijklmnopqrstuvwxyz{|}~

可打印字符的顺序就是我上面列出的顺序。它们的编号 从 32 号字符(空格)到 126 号字符(波浪号)。 作为参考,完整的 ASCII 码图表见附录 D。(现在 花点时间看一眼。)

作为方便之计,大多数 Unix 系统都有一份包含 ASCII 码的参考页面。这很实用,让你随时都能快速 查阅。遗憾的是,ASCII 码参考页并没有统一标准, 显示它的方式取决于你使用的系统。详见图 20-5。

Figure 20-5: 显示 ASCII 码

你会在本书附录 D 中找到 ASCII 码的汇总。为了便于 联机查阅,大多数 Unix 系统都有一份方便实用的页面, 包含完整的 ASCII 码。传统上,这份页面存放在 /usr/pub/ 目录下一个名为 ascii 的文件里。近年来,一些系统重新组织了 Unix 文件系统,ASCII 参考文件被移到了 /usr/share/misc。在另一些系统上,这份 文件已被转换为联机手册中的一个页面。因此,显示 ASCII 参考页面的方式取决于你所使用的系统。

Unix 类型 显示 ASCII 参考页面的命令
Linuxman ascii
FreeBSDless /usr/share/misc/ascii
Solarisless /usr/pub/ascii

正如我们在第 19 章所讨论的,在一种字符编码方案 中,字符的排列顺序称为排序序列(collating sequence)。凡是需要给字符排序的场合都会用到 排序序列,例如对数据排序,或者在正则表达式中使用 范围。

当你使用 Unix 或 Linux 时,你的系统采用的排序 序列取决于你的区域设置。区域设置这一概念是 POSIX 1003.2 标准的一部分,我们在第 11 章和 第 19 章都讨论过。正如我在第 19 章所解释的, 你的区域设置 —— 由一个环境变量设定 —— 告诉 你的程序你想使用哪种语言约定。这使得世界上任何 人都可以选择与自己本地语言相匹配的区域设置。

在一些 Unix 系统上,区域设置被配置成 默认排序序列与 ASCII 码中字符的顺序一致。具体 而言,如上所示,所有大写字母归为一组,排在小写 字母之前。这种序列称为 C 排序序列(第 19 章), 因为 C 编程语言使用的就是它。

在另一些 Unix 系统上,包括许多 Linux 系统, 区域设置的配置方式使默认排序序列把大写和小写 字母成对归组:aAbBcCdD...zZ。 这种排序序列的优点在于,查找单词或字符时的顺序 与词典中的查找顺序一致。因此,它被称为词典排序 序列(dictionary collating sequence)(第 19 章)。

在使用正则表达式时,你可能会遇到麻烦,因为 Unix 会按照你系统所用的排序序列来展开 a-zA-Z

如果你使用 C 排序序列,所有大写字母自成一族, 所有小写字母也自成一族。这意味着,当你想指定 全部大写或小写字母时,可以用范围来代替预定义 字符类:用 A-Z 代替 [:upper:];用 a-z 代替 [:lower:];用 A-Za-z 代替 [:alpha:];用 A-Za-z0-9 代替 [:alnum:]。你在图 20-3 中就能看到这一点。

如果你使用词典排序序列,字母的顺序就不同了: AaBbCcDd...Zz。这意味着范围的 工作方式也会不同。例如,a-z 将表示 aBbCc...YyZz。注意,大写字母 "A" 不在其中。(你能明白为什么吗?)同样,A-Z 将表示 AaBbCc...YyZ。注意小写字母 "z" 不在其中。

举个例子,假设你要在文件 data 中查找 所有包含从 "A" 到 "E" 的某个大写或小写字母的 行。如果你的区域设置使用 C 排序序列,你会用:

grep '[A-Ea-e]' data

在这种情况下,这个 regex 等价于 ABCDEabcde,这也是大多数经验丰富的 Unix 用户所期望的。然而,如果你的区域设置使用词典排序 序列,你会用:

grep '[A-e]' data

传统上,Unix 使用 C 排序序列,许多资深 Unix 用户想当然地认为 a-z 总是指(仅指)小写 字母,A-Z 总是指(仅指)大写字母。然而, 这是一个不可靠的假设,因为某些类型的 Unix,包括 许多 Linux 发行版,默认使用的是词典排序序列, 而不是 C 排序序列。不过,无论你的区域设置的默认 排序序列是哪一个,都有办法确保它使用 C 排序序列, 而这正是大多数 Unix 用户所偏爱的。

首先,你需要判断你的系统默认使用哪种排序序列。 方法是用下面这条命令创建一个名为 data 的 简短文件:

cat > data

键入下面两行,然后按 ^D 结束命令:

A
a

现在键入下面这条命令:

grep '[a-z]' data

如果输出包含文件中的两行(Aa),说明你使用的是词典排序序列。如果输出 只包含 a 那一行,说明你使用的是 C 排序 序列。(这是为什么?)

如果你的系统使用 C 排序序列,你不需要做任何改动。 不过,请接着读完本节的其余部分,因为总有一天, 你会在别的系统上遇到这个问题。

如果你的系统使用词典排序序列,你多半会想改用 C 排序序列。为此,把名为 LC_COLLATE 的 环境变量设置为 CPOSIX。对于 Bourne Shell 家族,下面两条命令中的任何一条都 可以完成任务:

export LC_COLLATE=C
export LC_COLLATE=POSIX

对于 C-Shell 家族,你会用下面两条之一:

setenv LC_COLLATE C
setenv LC_COLLATE POSIX

要让这一改动永久生效,你只需把其中一条命令放进 你的登录文件。(登录文件在第 14 章讨论;环境 变量在第 12 章讨论。)

一旦你的系统使用 C 排序序列,你就可以用相应的 范围来替代预定义字符类,如图 20-3 所示。在本章 余下的内容中,我将假定你确实在使用 C 排序序列 (所以,如果你还没有,现在就赶紧把相应的命令写进 登录文件)。

要显示你系统上关于区域设置的信息,可以使用 locale 命令。语法是:

locale [-a]

你的区域设置通过设置一系列全局变量来维护,其中 包括 LC_COLLATE。要查看这些变量的当前 值,直接单独输入这条命令:

locale

要列出你系统上所有可用的区域设置,使用 -a(all)选项:

locale -a

使用范围与预定义字符类
(Using Ranges and Predefined Character Classes)

一旦确认自己使用的是 C 排序序列(如上一节所述), 你就有了一定的自由度。当你想匹配所有大写或小写 字母时,既可以用预定义字符类,也可以用范围。

例如,下面两条命令都在名为 data 的文件中 查找所有包含字母 "H"、后面跟着从 "a" 到 "z" 任意一个小写字母的行,比如 "Ha"、"Hb"、"Hc" 等等:

grep 'H[[:lower:]]' data
grep 'H[a-z]' data

下面两条命令查找所有包含单个大写或小写字母、 后跟一个数字、再跟一个小写字母的行:

grep '[A-Za-z][0-9][a-z]' data
grep '[[:alpha:]][[:digit:]][[:lower:]]' data

下面是一个更复杂的例子,查找加拿大的邮政编码。 这些编码的格式是"字母 数字 字母 空格 数字 字母 数字",其中所有字母都是大写,例如 M5P 3G4。花点时间分析这两条命令,直到 你完全理解它们为止:

grep '[A-Z][0-9][A-Z] [0-9][A-Z][0-9]' data
grep '[[:upper:]][[:digit:]][[:upper:]] [[:digit:]][[:upper:]][[:digit:]]' data

选择哪种字符类 —— 范围还是预定义名称 —— 完全 取决于你。许多老派 Unix 用户偏爱使用范围,因为 他们当初学的就是它。而且,输入范围比输入名称更省事, 名称还需要冒号和额外的方括号。(见上一个例子。)

不过,名称更易读,更适合用在 Shell 脚本中。 而且,名称的设计目标就是无论你的区域设置或语言 是什么都能正确工作,因此它们更可移植。例如,假设 你处理的文本包含非英文字符,比如 é (带尖音符的 "e")。使用 [:lower:],你 一定能匹配到 é。而如果你用 a-z,就不一定了。

重复操作符
(Repetition Operators)

在正则表达式中,单个字符(如 A)或字符类 (如 A-Za-z[:alpha:])只匹配 一个字符。要一次匹配多个字符,你要使用重复操作符 (REPETITION OPERATOR)。

最有用的重复操作符是 *(星号)元字符。 * 匹配前一个字符出现零次或多次。(关于 "零次或多次"这一概念,见第 10 章。)例如,假设 你要在名为 data 的文件中查找所有包含 大写字母 "H"、后面跟着零个或多个小写字母的行。 你可以用下面两条命令中的任何一条:

grep 'H[a-z]*' data
grep 'H[[:lower:]]*' data

这些命令会找到诸如以下模式:

H Har Harley Harmonica Harpoon HarDeeHarHar

最常见的组合是一个 .(点)后面跟着一个 *。这匹配任意字符出现零次或多次。例如, 下面这条命令查找包含 "error"、后面跟着零或多个 任意字符、再跟着 "code" 的行:

grep 'error.*code' data

举例来说,这条命令会选出下面这些行:

Be sure to document the error code.
Don't make an error while you are writing code.
Remember that errorcode #5 means "Too many parentheses".

下一个例子查找包含一个冒号、后面跟着零或多个任意 其他字符、再跟着另一个冒号的行:

grep ':.*:' data

有时,你可能想匹配一个或多个字符,而不是零或多个。 为此,使用 +(加号)元字符代替 *。例如,下面两条命令在文件 data 中查找包含字母 "variable"、后面跟着一串数字的 行:

grep 'variable[0-9]+' data
grep 'variable[[:digit:]]+' data

这些命令会选出下面这些行中的任意几条:

You can use variable1 if you want.
error in variable3x
address12, variable12 and number12

而不会选出这些行:

Remember to use variable 1, 2 or 3.
variableX3 is the one to use
The next thing to do is set Variable417.

如果你想让模式的开头既匹配大写 "V" 又匹配小写 "v",该怎么办?只需把第一个字母改成一个字符类:

grep '[vV]ariable[0-9]+' data

下一个重复操作符是 ?(问号)元字符。它 允许你匹配某样东西出现零次或一次。换一种说法就是, ? 使某样东西变成可选的。例如,假设你想 在文件 data 中查找所有包含单词 "color" (美式拼法)或 "colour"(英式拼法)的行。你可以 用:

grep 'colou?r' data

最后一组重复操作符允许你用花括号指定任意次数的 匹配,这叫作边界(BOUND)。边界有四种不同类型, 它们是:

{n}精确匹配 n
{n,}匹配 n 次或更多
{,m}匹配 m 次或更少    [非标准]
{n,m}匹配 nm

注意:第三种写法 {,m} 不属于 POSIX 1003.2 标准,在某些程序中无法使用。

下面举几个例子。第一个例子精确匹配 3 个数字; 第二个匹配至少 3 个数字;第三个匹配 5 个或更少 的数字;最后一个例子匹配 3 到 5 个数字。

[0-9]{3}
[0-9]{3,}
[0-9]{,5}
[0-9]{3,5}

为了演示边界与 grep 的配合用法,下面这条 命令在名为 data 的文件中查找所有包含 2 位 或 3 位数字的行。注意这里用 \<\> 来匹配完整的数字:

grep '\<[0-9]{2,3}\>' data

到目前为止,我们只把重复操作符用在单个字符上。 如果把多个字符放在圆括号里,就可以把它们用在 多个字符上。这样的模式称为分组(GROUP)。创建 分组之后,你就可以把一串字符当作一个整体来对待。 例如,要匹配连续 5 次的字母 "xyz",你可以用下面 两个正则表达式中的任何一个:

xyzxyzxyzxyzxyz
(xyz){5}

最后一个重复操作符是 |(竖线)字符,它 让我们可以使用交替。也就是说,我们可以匹配这个 或者那个。例如,假设我们要在文件中查找所有包含 下面任意一个单词的行:

cat dog bird hamster

用交替就很简单了:

grep 'cat|dog|bird|hamster' data

显然,这是一个强大的工具。不过在这个例子中,你能 看出问题吗?我们查找的是字符串,而不是完整的单词。 因此,上面的命令还会找到包含 "concatenate" 或 "dogmatic" 这类单词的行。要只查找完整的单词,我们 必须显式地匹配单词边界:

grep '\<(cat|dog|bird|hamster)\>' data

注意这里用圆括号创建了一个分组。这让我们能够把 整个模式当作一个整体。花点时间思考一下,直到你 想通为止。

再看一个例子。假设我们要在文件 data 中 查找所有包含 "pathname"、"filename" 或 "basename" 这三个单词之一的行。(这些是我们将在 第 24 章遇到的三个术语。)下面两个正则表达式都 能匹配这些单词,尽管第二个 regex 更简洁:

pathname|filename|basename
(path|file|base)name

再加上对单词边界的匹配,下面两条等价命令就能 完成任务:

grep '\<pathname|filename|basename\>' data
grep '\<(path|file|base)name\>' data

为了给本节收尾,我再解释最后一个元字符。你已知 道,元字符在正则表达式中具有特殊含义。那么问题来了: 如果你想匹配这些字符本身怎么办?例如,如果你想 匹配一个真正的 *(星号)、.(点) 或 |(竖线)字符,该怎么办?

答案是:你可以用 \(反斜杠)来引用这个 字符。这会让它从元字符变成普通字符,从而按字面 意义被解释。例如,要在文件 data 中查找 所有包含 "$" 字符的行,用:

grep '\$' data

如果你想查找反斜杠字符本身,就连续使用两个 反斜杠。例如,要查找所有包含字符 "\*"、后面 跟着任意多个字符、再跟着一个或多个字母、最后是 "$" 的行,用:

grep '\\\*.*[A-Za-z]+\$' data

如何理解复杂的正则表达式
(How to Understand a Complex Regular Expression)

一旦你理解了规则,大多数正则表达式都很容易写。 但它们读起来可能很难,尤其是篇幅很长的时候。 实际上,有经验的 Unix 用户常常连自己写过的正则 表达式都难以理解(*)。这里介绍一个我多年来总结 出来的简单技巧,可以帮助你理解那些看似晦涩的 regex。

* 脚注

因此就有了那个古老的谜语:"如果上帝无所不能, 他能否创造出一个连自己都看不懂的正则表达式?"

毫无疑问,托马斯·阿奎那在《神学大全》 (The Summa Theologica)中写的正是这个问题: "当我们说上帝能做所有事情时,'所有'一词的确切 含义可能令人存疑。"

当你遇到一个让你头疼的正则表达式时,把它写在纸上。 然后把 regex 拆分成若干部分,自上而下竖着写。 逐一处理每个部分,把它的含义写在同一行。例如, 考虑这个 regex:

\\\*.*[A-Za-z]+\$

我们可以把它分解如下:

\\一个 "\"(反斜杠)字符
\*一个 "*" (星号)字符
.*任意数量的其他字符
[A-Za-z]+一个或多个大写或小写字母
\$一个 "$" (美元符号)字符

解答三个有趣的谜题;字典文件
(Solving Three Interesting Puzzles; The Dictionary File)

作为本章讨论的收尾,我给你出三个有趣的谜题, 我们将用正则表达式来解答。解前两个谜题时,我们 要用到一个本身就很有趣的文件 —— 字典文件 (DICTIONARY FILE)。

字典文件从 Unix 诞生之初就包含在系统里,它是一 份极长的英语单词清单,其中包括简编词典里常见的 大部分单词。每个单词独占一行,各行按字母顺序 排列,这使得文件很容易查找。一旦你习惯了富有 创意地使用字典文件,你就能做出各种令人惊奇的 事情。一些 Unix 命令,比如 look(见第 19 章),就利用字典文件来完成它们的工作。

字典文件的名字是 words。在早期版本的 Unix 中,words 文件存放在名为 /usr/dict 的目录里。然而近年来,Unix 文件结构经过了重组,在大多数现代系统上 —— 包括 Linux 和 FreeBSD —— words 文件 存放在名为 /usr/share/dict 的目录中。 在少数系统上,比如 Solaris,该文件存放在 /usr/share/lib/dict。因此,字典文件的 路径名可能因系统而异。(我们将在第 23 章讨论 Unix 文件系统和路径名。)

作为参考,下面是你最可能找到字典文件的位置:

/usr/share/dict/words
/usr/dict/words
/usr/share/lib/dict/words

在下面的示例中,我将使用第一个路径名,它最常见。 如果这个名字在你的系统上不管用,试试其他几个。

首先来一个简单的谜题。所有以 "qu" 开头、以 "y" 结尾的英语单词有哪些?要解这个谜题,我们只需要 用下面这个正则表达式去 grep(*)字典文件:

grep '^qu[a-z]+y$' /usr/share/dict/words

* 脚注

正如我在第 19 章提到的,"grep" 一词常被用作 动词。

为了理解这个正则表达式,我们使用前面提到的技巧。 把 regex 拆分成若干部分,自上而下竖着写。分解 结果如下:

^行首
qu字母 "qu"
[a-z]+一个或多个小写字母
y字母 "y"
$行尾

记住,字典文件的每一行只包含一个单词。因此, 我们的查找从行首开始,到行尾结束。

下一个谜题是个老谜题。找出一个常见英语单词, 它按顺序包含全部五个元音字母 —— a、e、i、o、u。 这些字母不必相邻,但必须按字母表顺序出现。也就是 说,"a" 必须在 "e" 之前,"e" 必须在 "i" 之前, 依此类推。

要解这个谜题,我们可以 grep 字典文件,查找包含 字母 "a"、后跟零个或多个小写字母、再跟 "e"、 再跟零个或多个小写字母,依此类推的单词。这一次, 我们先把各个部分写下来,然后再拼装起来。当你构造 复杂的正则表达式时,这往往是一个有用的技巧:

a字母 "a"
[a-z]*零个或多个小写字母
e字母 "e"
[a-z]*零个或多个小写字母
i字母 "i"
[a-z]*零个或多个小写字母
o字母 "o"
[a-z]*零个或多个小写字母
u字母 "u"
[a-z]*零个或多个小写字母

于是,完整的命令是:

grep 'a[a-z]*e[a-z]*i[a-z]*o[a-z]*u' /usr/share/dict/words

为了避免卖关子,我现在就告诉你:你应该能找到 若干个单词,其中大多生僻。不过,常见的只有三个, 它们是(*):

adventitious
facetious
sacrilegious

* 脚注

严格说来,英语中有六个元音字母:a、e、i、o、u 和(有时是)y。如果你想要包含全部六个元音的单词, 把上面三个词变成副词即可:"adventitiously"、 "facetiously" 和 "sacrilegiously"。

最后一个谜题涉及在 Unix 文件系统中搜寻历史遗存。 许多最早的 Unix 命令只有两个字母:文本编辑器是 ed,复制程序是 cp,等等。我们要找出 所有这样的命令。

要解这个谜题,你需要知道:最古老的 Unix 程序都 存放在 /bin 目录中。要列出该目录下的所有 文件,我们使用 ls 命令(见第 24 章):

ls /bin

要分析 ls 的输出,我们可以把它用管道 传给 grep。当我们这样做时,ls 会 自动把每个名字放在单独一行上,因为输出是送往 一个过滤器的。接着用 grep,我们就可以查找 仅由两个小写字母组成的行。完整的管道命令如下:

ls /bin | grep '^[a-z]{2}$'

在某些系统上,grep 不会返回你想要的结果, 因为它不把花括号识别为元字符。如果你遇到这种 情况,有两个选择。你可以改用 egrep:

ls /bin | egrep '^[a-z]{2}$'

或者,你可以干脆不用花括号。只要重复写字符类, 就不需要使用边界了:

ls /bin | grep '^[a-z][a-z]$'

在你的系统上试试这些命令,看看能发现什么。当 你看到某个名字、想进一步了解对应的命令时,到 联机手册里查一查即可(第 9 章)。例如:

man ed cp

除了你在 /bin 中找到的文件之外, /usr/bin 中还有其他一些古老的 Unix 命令。 要在该目录中查找两字符的命令名,只需把上一条 命令稍加改动。

ls /usr/bin | grep '^[a-z]{2}$'

要统计这样的命令有多少个,给 grep 加上 -c(count,计数)选项:

ls /bin | grep -c '^[a-z]{2}$'
ls /usr/bin | grep -c '^[a-z]{2}$'

注意:查看 /usr/bin 时,你可能会发现一些 两字符命令并不古老。要判断某个命令是否源自 Unix 早期,可以查看它的 man 手册页。

练习
(Exercises)

复习问题 #1:

什么是正则表达式?

"regular expression"(正则表达式)的两个常见 缩写是什么?

复习问题 #2:

在正则表达式中,解释下列元字符各匹配什么:

.  ^  $  \<  \>  [list]  [^list]

解释下列重复操作符各匹配什么:

*  +  ?  {n}

复习问题 #3:

对于下列每个预定义字符类,给出定义并指出 等价的范围:

[:lower:]   [:upper:]   [:alpha:]  [:digit:]  [:alnum:]

例如,[:lower:] 代表所有小写字母; 等价的范围是 a-z

复习问题 #4:

你的系统默认使用词典排序序列,而你想使用 C 排序序列。你该如何更改?

对于 Bourne Shell 家族,你会用什么命令? 对于 C-Shell 家族呢?

你会把这样的命令放进哪个初始化文件?

应用你的知识 #1:

创建正则表达式,匹配:

• "hello"
• 单词 "hello"
• 单词 "hello" 或单词 "Hello" 之一
• 位于行首的 "hello"
• 位于行尾的 "hello"
• 整行只有 "hello" 的行

grep 检验你的答案。

应用你的知识 #2:

使用重复操作符,创建正则表达式,匹配:

• "start",后面跟 0 个或多个数字,再跟 "end"
• "start",后面跟 1 个或多个数字,再跟 "end"
• "start",后面跟 0 个或 1 个数字,再跟 "end"
• "start",后面正好跟 3 个数字,再跟 "end"

grep 检验你的答案。

提示:确保 grep 使用的是扩展正则表达式 (而不是基本正则表达式)。

应用你的知识 #3:

正如我们在第 20 章讨论的,下面两条命令能找出 文件 data 中所有至少包含一个非字母字符 的行:

grep '[^A-Za-z]' data
grep '[^[:alpha:]]' data

要找出连一个字母字符都不包含的所有行,你会用 什么命令?

应用你的知识 #4:

在 Usenet 全球讨论组系统中,言论表达的自由 非常重要。然而,让人们能够在愿意时避开冒犯性的 帖子,也同样重要。解决办法是把可能冒犯人的文本 加以编码,让粗心的旁观者看起来只是一堆乱码。 但任何愿意解码的人,都可以轻松地把编码后的文本 还原。

用于编码的体系称为 Rot-13。它的规则如下: 字母表中的每个字母,都被替换为字母表中往后数 第 13 个字母,必要时绕回字母表开头:

B O...   O B
C P...   P C
D Q...   Q D...

创建一条单独的命令,从名为 input 的文件 读取内容,用 Rot-13 对文本编码,并把编码后的文本 写到标准输出。然后再创建一条命令,读取 Rot-13 编码后的数据,把它转换回普通文本。

检验你的解法:创建一个名为 input 的文本 文件,先编码,再解码。

进一步思考 #1:

"regular expression"(正则表达式)这个术语来自 一个抽象的计算机科学概念。使用这样的名字是好 主意还是坏主意?

如果把 "regular expression" 换成一个更直白的 名字,比如 "pattern matching expression"(模式 匹配表达式)或 "pattern matcher"(模式匹配器), 会有什么大的区别吗?为什么?

进一步思考 #2:

随着为支持国际化而引入区域设置,一些多年来 一直有效的正则表达式模式在某些系统上不再工作 了。特别是,依赖传统 C 排序序列的正则表达式, 在词典排序序列下未必仍然有效。多数情况下, 解决办法是使用预定义字符类来代替范围。例如, 你应当用 [:lower:] 而不是 a-z。 (完整集合见图 20-3。)你如何看待这一安排?

给出三个旧体系更好的理由。

给出三个新体系更好的理由。