正则表达式是描述文本模式的表示法,它可以有效地构造一种用于模式匹配的专用语言。
虽然正则表达式可以有多种不同的形式,但它们都有着共同的特点:模式中的大多数字符都是
匹配字符串中的字符本身,但有些元字符(metacharacter)却有着特定的含义,例如*表示某
种重复,而[...]表示方括号中字符集合的任何一个字符。
实际上,在文本编辑器之类的程序中,所执行的查找操作都是查找文字,因此正则表达式
通常是像“print”之类的字符串,而这类字符串将与文档中所有的“printf”或者“sprintf”
或者“printer paper”相匹配。在Unix和Windows中可以使用所谓的通配符来指定文件名,其
中字符*可以用来匹配任意数量的字符,因此匹配模式*.c就将匹配所有以.c结尾的文件。此外,
还有许许多多不同形式的正则表达式,甚至在有些情况下,这些正则表达式会被认为都是相同
的。Jeffrey Friedl编著的《Mastering Regular Expressions》一书对这一方面问题进行了广
泛的研究。
Stephen Kleene在20世纪50年代的中期发明了正则表达式,用来作为有限自动机的表示法,
事实上,正则表达式与其所表示的有限自动机是等价的。20世纪60年代年代中期,正则表达式
最初出现在Ken Thompson版本的QED文本编辑器的程序设置中。1967年Thompson申请了一项基于
正则表达式的快速文本匹配机制的专利。这项专利在1971年获得了批准,它是最早的软件专利
之一[U.S. Patent 3,568,156, Text Matching Algorithm, March 2, 1971].
后来,正则表达式技术从QED移植到了Unix的编辑器ed中,然后又被移植到经典的Unix工具
grep中,而grpe正是由于Thompson对ed进行了彻底地修改而形成的。这些广为应用的程序使得
正则表达式为早期的Unix社群所熟知。
Thompson最初编写的匹配器是非常快的,因为它结合了两种独立的思想。一种思想是在匹
配过程中动态地生成机器指令,这样就可以以机器指令执行的速度而不是解释执行的速度来运
行。另一种思想是在每个阶段中都尽可能地执行匹配操作,这样无需回朔(backtrack)就可以
查找可能的匹配。在Thompson后来编写的文本编辑器程序中,例如ed,匹配代码使用了一种更
为简单的算法,这种算法将会在必要的时候进行回朔。从理论上来看,这种方法的运行速度要
更慢,但在实际情况中,这种模式很少需要进行回朔,因此,ed和grep中的算法和代码足以应
付大多数的情况。
在后来的正则表达式匹配器中,例如egrep和fgrep等,都增加了更为丰富的正则表达式类
型,并且重点是要使得匹配器无论在什么模式下都能够快速执行。功能更为强大的正则表达式
正在被越来越多地使用,它们不仅被包含在用C语言开发的库中,而且还被作为脚本语言如Awk
和Perl的语法的一部分。
1.1 编程实践
在1998年,Rob Pike和我还在编写《The Practice of Programming》(Addison-Wesley)
一书。书中的最后一章是“记法”,在这一章中收录了许多示例代码,这些示例都很好地说明
了良好的记法将会带来更好的程序以及更好的设计。其中包括使用简单的数据规范(例如
printf)以及从表中生成代码。
由于我们有着深厚的Unix技术背景以及在使用基于正则表达式记法的工具上有着近30年的
经验,我们很自然地希望在本书中包含一个对正则表达式的讨论,当然包含一个实现也是必须
的。由于我们强调了工具软件的使用,因此似乎最好应该把重点放在grep中的正则表达式类型
上——而不是,比方说,在shell的通配符正则表达式上——这样我们还可以在随后再讨论grep
本身的设计。
然而,问题是现有的正则表达式软件包都太庞大了。grep中的代码长度超过500行(大约10
页书的长度),并且在代码的周围还有复杂的上下文环境。开源的正则表达式软件包则更为庞
大——代码的长度几乎布满整本书——因为这些代码需要考虑通用性,灵活性以及运行速度;
因此,所有这些正则表达式都不适合用来教学。
我向Rob建议我们需要一个最小的正则表达式软件包,它可以很好地诠释正则表达式的基本
思想,并且能够识别出一组有用的并且重要类的模式。理想的情况是,所需代码长度只有一页
就够了。
Rob听了我的提议后就离开了他的办公室。我现在还记得,一,两个小时后他回来了,并且给了
我一段大约30行的C代码,在《The Practice of Programming》一书的第9章中包含了这段代码。
在这段代码实现了一个正则表达式匹配器,用来处理以下的模型。
字符 含义
c 匹配任意的字母c
.(句点) 匹配任意的单个字符
^ 匹配输入字符串的开头
$ 匹配输入字符串的结尾
* 匹配前一个字符的零个或者多个出现
这是一个非常有用的匹配器,根据我在日常工作中使用正则表达式的经验,它可以轻松解
决95%的问题。在许多情况下,解决正确的问题就等于朝着创建漂亮的程序迈进了一大步。Rob
值得好好地表扬,因为他从大量可选功能集中选出了一组非常小但却重要的,并且是明确的以
及可扩展的功能。
Rob的实现本身就是漂亮代码的一个极佳示例:紧凑,优雅,高效并且实用。这是我所见过
的最好的递归示例之一,在这段代码中还展示了C指针的强大功能。虽然当时我们最关心的是通
过使程序更易于使用(同时也更易于编写)来体现良好记法的重要性,但正则表达式代码同样
也是阐述算法,数据结构,测试,性能增强以及其他重要主题的最好方式。
1.2 实现
在《The Practice of Programming》一书中,正则表达式匹配器是一个模拟grep程序中的
一部分,但正则表达式的代码完全可以从编写环境中独立出来。这里我们并不关心主程序;像
许多Unix工具一样,这个程序将读取其标准输入或者一组文件,然后输出包含与正则表达式匹
配的文本行。
以下是匹配算法的代码:
/* match: search for regexp anywhere in text */
int match(char *regexp, char *text)
{
if (regexp[0] == ´^´)
return matchhere(regexp+1, text);
do { /* must look even if string is empty */
if (matchhere(regexp, text))
return 1;
} while (*text++ != ´\0´);
return 0;
}
/* matchhere: search for regexp at beginning of text */
int matchhere(char *regexp, char *text)
{
if (regexp[0] == ´\0´)
return 1;
if (regexp[1] == ´*´)
return matchstar(regexp[0], regexp+2, text);
if (regexp[0] == ´$´ && regexp[1] == ´\0´)
return *text == ´\0´;
if (*text!=´\0´ && (regexp[0]==´.´ || regexp[0]==*text))
return matchhere(regexp+1, text+1);
return 0;
}
/* matchstar: search for c*regexp at beginning of text */
int matchstar(int c, char *regexp, char *text)
{
do { /* a * matches zero or more instances */
if (matchhere(regexp, text))
return 1;
} while (*text != ´\0´ && (*text++ == c || c == ´.´));
return 0;
}
1.3 讨论
函数match(regexp,text)用来判断文本中是否出现正则表达式;如果找到了一个匹配的正
则表达式则返回1,否则返回0。如果有多个匹配的正则表达式,那么函数将找到文本中最左边
的并且最短的那个。
match函数中的基本操作简单明了。如果正则表达式中的第一个字符是^(固定位置的匹配),
那么匹配就一定要出现在字符串的开头。也就是说,如果正则表达式是^xyz,那么仅当xyz出现
在文本的开头而不是中间的某个位置时才会匹配成功。在代码中通过把正则表达式的剩余部分
与文本的起始位置而不是其他地方进行匹配来判断。如果第一个字符不是^,那么正则表达式就
可以在字符串中的任意位置上进行匹配。在代码中通过把模式依次与文本中的每个字符位置进
行匹配来判断。如果存在多个匹配,那么代码只会识别第一个(最左边的)匹配。也就是说,
如果则在表达式是xyz,那么将会匹配第一次出现的xyz,而且不考虑这个匹配出现在什么位置
上。
注意,对输入字符串的推进操作是在一个do-while循环中进行的,这种结构在C程序中使用
相对较少。在代码中使用do-while而不是while通常会带来疑问:为什么不在循环的起始处判断
循环条件,而是在循环末尾当执行完了某个操作之后才进行判断呢?不过,这里的判断是正确
的:由于*运算符允许零长度的匹配,因此我们首先需要判断是否存在一个空的匹配。
大部分的匹配工作是在matchhere(regexp,text)函数中完成的,这个函数将判断正则表达
式与文本的开头部分是否匹配。函数matchhere把正则表达式的第一个字符与文本的第一个字符
进行匹配。如果匹配失败,那么在这个文本位置上就不存在匹配,因此matchhere将返回0。然
而,如果匹配成功了,函数将推进到正则表达式的下一个字符和文本的下一个字符继续进行匹
配。这是通过递归地调用matchhere函数来实现的。
由于存在着一些特殊的情况,以及需要设置终止递归的条件。因此实际的处理过程要更为
复杂些最简单的情况就是,当正则表达式推进到末尾时(regexp[0] == ´\0´),所有前面的判断
都成功了,那么这个正则表达式就与文本匹配。
如果正则表达式是一个字符后面跟着一个*,那么将会调用matchstar来判断闭包(closure)
是否匹配。函数matchstar(c, regexp, text)将尝试匹配重复的文本字符c,从零重复开始并且
不断累加,直到匹配text的剩余字符,如果匹配失败,那么函数就认为不存在匹配。这个算法
将识别出一个“最短的匹配”,这对简单的模式匹配来说是很好的,例如grep,这种情况下的
主要问题是尽可能快地找到一个匹配。而对于文本编辑器来说,“最长的匹配”则是更为直观,
且肯定是更好的,因为通常需要对匹配的文本进行替换。在目前许多的正则表达式库中同时提
供了这两种方法,在《The Practice of Programming》一书中给出了基于本例中matchstar函
数的一种简单变形,我们在后面将给出这种形式。
如果在正则表达式的末尾包含了一个$,那么仅当text此时位于末尾时才会匹配成功:
if (regexp[0] == ´$´ && regexp[1] == ´\0´)
return *text == ´\0´;
如果没有包含$,并且如果当前不是处于text字符串的末尾(也就是说,*text!=´\0´)并
且如果text字符串的第一个字符匹配正则表达式的第一个字符,那么到现在为止都是没有问题
的;我们将接着判断正则表达式的下一个字符是否匹配text的下一个字符,这是通过递归调用
matchhere函数来实现的。这个递归调用不仅是本算法的核心,也是这段代码如此紧凑和整洁的
原因。
如果所有这些匹配尝试都失败了,那么正则表达式和text在这个位置上就不存在匹配,因
此函数matchhere将返回0。
在这段代码中大量地使用了C指针。在递归的每个阶段,如果存在某个字符匹配,那么在随
后的递归调用中将执行指针算法(例如,regexp+1 and text+1),这样在随后的函数调用中,
参数就是正则表达式的下一个字符和text的下一个字符。递归的深度不会超过匹配模式的长度,
而通常情况下匹配模式的长度都是很短的,因此不会出现耗尽内存空间的危险。
1.4 其他的方法
这是一段非常优雅并且写得很好的代码,但并不是完美的。我们还可以做哪些其他的工作?
我可能对matchhere中的操作进行重新安排,在处理*之前首先处理$。虽然这种安排不会对函数
的执行带来影响,但却使得函数看上去要自然一些,而在编程中一个良好的规则就是:在处理
复杂的情况之前首先处理容易的情况。
不过,通常这些判断的顺序是非常重要的。例如,在matchstar的这个判断中:
} while (*text != ´\0´ && (*text++ == c || c == ´.´));
无论在什么情况下,我们都必须推进text字符串中的一个或多个字符,因此在text++中的
递增运算一定要执行。
该代码对终止条件进行了谨慎的处理。通常,匹配过程的成功与否,是通过判断正则表达
式和text中的字符是不是同时处理完来决定的。如果是同时处理完了,那么就表示匹配成功,
如果其中一方在另一方之前被处理完了,那么就表示匹配失败。在下面这行代码中很明显地说
明了这个判断。
if (regexp[0] == ´$´ && regexp[1] == ´\0´)
return *text == ´\0´;
但在其他的情况下,还有一些微妙的终止条件。
如果在matchstar函数中需要识别最左边的以及最长的匹配,那么函数将首先识别输入字符
c的最大重复序列。然后函数将调用matchhere来尝试把匹配延伸到正则表达式的剩余部分和
text的剩余部分。每次匹配失败都会将cs的出现次数减1,然后再次开始尝试,包括处理零出现
的情况:
/* matchstar: leftmost longest search for c*regexp */
int matchstar(int c, char *regexp, char *text)
{
char *t;
for (t = text; *t != ´\0´ && (*t == c || c == ´.´); t++)
;
do { /* * matches zero or more */
if (matchhere(regexp, t))
return 1;
} while (t-- > text);
return 0;
}
我们来看一下正则表达式(.*),它将匹配括号内任意长度的text。假设给定了text:
for (t = text; *t != ´\0´ && (*t == c || c == ´.´); t++)
从开头位置起的最长匹配将会识别整个括号内的表达式,而最短的匹配将会停止在第一次
出现右括号的地方。(当然,从第二个左括号开始的最长匹配将会延伸到text的末尾)
1.5 构建
《The Practice of Programming》一书主要讲授良好的程序设计。在编写该书时,Rob和
我还在贝尔实验室工作,因此我们知道在课堂上使用这本书有什么样的效果。令人高兴的是,
我们发现这本书中的某些内容在课堂上确实有着不错的效果。从2000年教授程序设计中的重点
要素时,我们就使用了这段代码。
首先,这段代码以一种全新的形式展示了递归的强大功能及其带来的整洁代码。它既不是
另一种版本的快速排序(或者阶乘!)算法,也不是某种树的遍历算法。
这段代码同时还是性能试验的一个很好示例。其性能与系统中的grep并没有太大的差异,
这表明递归技术的开销并不是非常大的,因此没有必要对这段代码进行调整。
此外,这段代码还充分说明了优良算法的重要性。如果在模式中包含了几个.*序列,那么
在简单的实现中将需要进行大量的回溯操作,并且在某些情况下将会运行得极慢。
在标准的Unix grep中有着同样的回朔操作。例如,下面这个命令:
grep ´a.*a.*a.*a.a´
在普通的机器上处理一个4 MB的文本文件要花费20秒的时间。
如果某个实现是基于把非确定有限自动机转换为确定有限自动机,例如egrep,那么在处理
恶劣的情况时将会获得比较好的性能;它可以在不到十分之一秒的时间内处理同样的模式和同
样的输入,并且运行时间通常是独立于模式的。
对于正则表达式类型的扩展将形成各种任务的基础。例如:
1.增加其他的元字符,例如+用于表示前面字符的一个或多个出现,或者?用于表示零个或
一个字符的匹配。还可以增加一些方式来引用元字符,例如\$表示在模式中的$字符。
2.将正则表达式处理过程分成编译阶段和执行阶段。编译阶段把正则表达式转换为内部形
式,使匹配代码更为简单或者使随后的匹配过程运行得更为迅速。对于最初设计中的简单的正
则表达式来说,这种拆分并不是必须的,但在像grep这样的程序中,这种拆分是有意义的,因
为这种类型的正则表达式要更为丰富,并且同样的正则表达式将会用于匹配大量的输入行。
3.增加像[abc]和[0-9]这样的类型,这在传统的grep中分别匹配a或b或c和一个数字。可
以通过几种方式来实现,最自然的方式似乎就是把最初代码中的char*变量用一个结构来代替:
typedef struct RE {
int type; /* CHAR, STAR, etc. */
int ch; /* the character itself */
char *ccl; /* for [...] instead */
int nccl; /* true if class is negated [^...] */
} RE;
并且修改相应的代码来处理一个结构数组而不是处理一个字符数组。在这种情况下,并不
一定要把编译阶段从执行阶段中拆分出来,但这里的拆分过程是非常简单的。如果学生们把匹
配代码预编译成这个结构,那么总会比那些试图动态地解释一些复杂模式数据结构的学生要做
得更好。
为字符类型编写清晰并且无歧义的规范是件有难度的工作,而要用代码完美地实现处理更
是难上加难,这需要大量的冗长并且晦涩的编码。随着时间的推移,我简化了这个任务,而现
在大多数人会要求像Perl那样的速记,例如\d表示数字,\D表示非数字,而不再像最初那样在
方括号内指定字符的范围。
4.使用不透明的类型来隐藏RE结构以及所有的实现细节。这是在C语言中展示面向对象编
程技术的好方法,不过除此之外无法支持更多的东西。在实际情况中,将会创建一个正则表达
式类,其中类中成员函数的名字像RE_new( )和RE_match( )这样,而不是使用面向对象语言
的语法。
5.把正则表达式修改为像各种shell中的通配符那样:匹配模式的两端都被隐含地固定了,
*匹配任意数量的字符,而?则匹配任意的单个字符。你可以修改这个算法或者把输入映射到现
有的算法中。
6.将这段代码转换成Java。最初的代码很好地使用了C指针,并且把这个算法在不同的语
言中实现出来是一个很好的实践过程。在Java版本的代码中将使用String.charA(使用索引而
不是指针)或者String.substring(更接近于指针)。这两种方法都没有C代码整洁,并且都不
紧凑。虽然在这个练习中性能并不是主要的问题,但有趣的是可以发现了Java版本比C版本在运
行速度上要慢6到7倍。
7.编写一个包装类把这种类型的正则表达式转换成Java的Patter类和Matcher类,这些类
将以一种与众不同的方式来拆分编译阶段和匹配阶段。这是适配器(Adapter)模式或者外观
(Fa.ade)模式的很好示例,这两种模式用来在现有的类或者函数集合外部设置不同的接口。
我曾经大量地使用了这段代码来研究测试技术。正则表达式非常的丰富,而测试也不
是无足轻重的,但正则表达式又是很短小的,程序员可以很快地写出一组测试代码来执行。对
于前面所列出的各种扩展,我要求学生用一种紧凑的语言写出大量的测试代码(这是“记法”
的另一种示例),并且在他们自己的代码中使用这些测试代码;自然我在其他学生的代码中也
使用了他们的测试代码。
1.6 小结
当Rob Pike最初写出这段代码时,我被它的紧凑性和优雅性感到惊讶——这段代码比我以
前所想像的要更为短小并且功能也更为强大。通过事后分析,我们可以看到为什么这段代码如
此短小的众多原因。
首先,我们很好地选择了功能集合,这些功能是最为有用的并且最能从实现中体现出核心
思想,而没有多余的东西。例如,虽然固定模式^和$的实现只需要写3~4行代码,但在统一处
理普通情况之前,它展示了如何优雅地处理特殊情况。闭包操作*必须出现,因为它是正则表达
式中的基本记号,并且是提供处理不确定长度的惟一方式。增加+和?并不会有助于理解,因此
这些符号被留作为练习。
其次,我们成功地使用了递归。递归这种基本的编程技巧通常会比明确的循环带来更短、
更整洁的以及更优雅的代码,这也正是这里的示例。从正则表达式的开头和tex的开头剥离匹配
字符,然后对剩余的字符进行递归的思想,模仿了传统的阶乘或者字符串长度示例的递归结构,
但这里是用在了一种更为有趣和更有用的环境中。
第三,这段代码的确使用了基础语言来达到良好的效果。指针也可能被误用,但这里它们
被用来创建紧凑的表达式,并且在这个表达式中自然地表达了提取单个字符和推进到下一个字
符的过程。数组索引或者子字符串可以达到同样的效果,但在这段代码中,指针能够更好的实
现所需的功能,尤其是当指针与C语言中的自动递增运算和到布尔值的隐式转换结合在一起使用
时。
我不清楚是否有其他的方法能够在如此少的代码中实现如此多功能,并且同时还要提供丰
富的内涵和深层次的思想。
虽然正则表达式可以有多种不同的形式,但它们都有着共同的特点:模式中的大多数字符都是
匹配字符串中的字符本身,但有些元字符(metacharacter)却有着特定的含义,例如*表示某
种重复,而[...]表示方括号中字符集合的任何一个字符。
实际上,在文本编辑器之类的程序中,所执行的查找操作都是查找文字,因此正则表达式
通常是像“print”之类的字符串,而这类字符串将与文档中所有的“printf”或者“sprintf”
或者“printer paper”相匹配。在Unix和Windows中可以使用所谓的通配符来指定文件名,其
中字符*可以用来匹配任意数量的字符,因此匹配模式*.c就将匹配所有以.c结尾的文件。此外,
还有许许多多不同形式的正则表达式,甚至在有些情况下,这些正则表达式会被认为都是相同
的。Jeffrey Friedl编著的《Mastering Regular Expressions》一书对这一方面问题进行了广
泛的研究。
Stephen Kleene在20世纪50年代的中期发明了正则表达式,用来作为有限自动机的表示法,
事实上,正则表达式与其所表示的有限自动机是等价的。20世纪60年代年代中期,正则表达式
最初出现在Ken Thompson版本的QED文本编辑器的程序设置中。1967年Thompson申请了一项基于
正则表达式的快速文本匹配机制的专利。这项专利在1971年获得了批准,它是最早的软件专利
之一[U.S. Patent 3,568,156, Text Matching Algorithm, March 2, 1971].
后来,正则表达式技术从QED移植到了Unix的编辑器ed中,然后又被移植到经典的Unix工具
grep中,而grpe正是由于Thompson对ed进行了彻底地修改而形成的。这些广为应用的程序使得
正则表达式为早期的Unix社群所熟知。
Thompson最初编写的匹配器是非常快的,因为它结合了两种独立的思想。一种思想是在匹
配过程中动态地生成机器指令,这样就可以以机器指令执行的速度而不是解释执行的速度来运
行。另一种思想是在每个阶段中都尽可能地执行匹配操作,这样无需回朔(backtrack)就可以
查找可能的匹配。在Thompson后来编写的文本编辑器程序中,例如ed,匹配代码使用了一种更
为简单的算法,这种算法将会在必要的时候进行回朔。从理论上来看,这种方法的运行速度要
更慢,但在实际情况中,这种模式很少需要进行回朔,因此,ed和grep中的算法和代码足以应
付大多数的情况。
在后来的正则表达式匹配器中,例如egrep和fgrep等,都增加了更为丰富的正则表达式类
型,并且重点是要使得匹配器无论在什么模式下都能够快速执行。功能更为强大的正则表达式
正在被越来越多地使用,它们不仅被包含在用C语言开发的库中,而且还被作为脚本语言如Awk
和Perl的语法的一部分。
1.1 编程实践
在1998年,Rob Pike和我还在编写《The Practice of Programming》(Addison-Wesley)
一书。书中的最后一章是“记法”,在这一章中收录了许多示例代码,这些示例都很好地说明
了良好的记法将会带来更好的程序以及更好的设计。其中包括使用简单的数据规范(例如
printf)以及从表中生成代码。
由于我们有着深厚的Unix技术背景以及在使用基于正则表达式记法的工具上有着近30年的
经验,我们很自然地希望在本书中包含一个对正则表达式的讨论,当然包含一个实现也是必须
的。由于我们强调了工具软件的使用,因此似乎最好应该把重点放在grep中的正则表达式类型
上——而不是,比方说,在shell的通配符正则表达式上——这样我们还可以在随后再讨论grep
本身的设计。
然而,问题是现有的正则表达式软件包都太庞大了。grep中的代码长度超过500行(大约10
页书的长度),并且在代码的周围还有复杂的上下文环境。开源的正则表达式软件包则更为庞
大——代码的长度几乎布满整本书——因为这些代码需要考虑通用性,灵活性以及运行速度;
因此,所有这些正则表达式都不适合用来教学。
我向Rob建议我们需要一个最小的正则表达式软件包,它可以很好地诠释正则表达式的基本
思想,并且能够识别出一组有用的并且重要类的模式。理想的情况是,所需代码长度只有一页
就够了。
Rob听了我的提议后就离开了他的办公室。我现在还记得,一,两个小时后他回来了,并且给了
我一段大约30行的C代码,在《The Practice of Programming》一书的第9章中包含了这段代码。
在这段代码实现了一个正则表达式匹配器,用来处理以下的模型。
字符 含义
c 匹配任意的字母c
.(句点) 匹配任意的单个字符
^ 匹配输入字符串的开头
$ 匹配输入字符串的结尾
* 匹配前一个字符的零个或者多个出现
这是一个非常有用的匹配器,根据我在日常工作中使用正则表达式的经验,它可以轻松解
决95%的问题。在许多情况下,解决正确的问题就等于朝着创建漂亮的程序迈进了一大步。Rob
值得好好地表扬,因为他从大量可选功能集中选出了一组非常小但却重要的,并且是明确的以
及可扩展的功能。
Rob的实现本身就是漂亮代码的一个极佳示例:紧凑,优雅,高效并且实用。这是我所见过
的最好的递归示例之一,在这段代码中还展示了C指针的强大功能。虽然当时我们最关心的是通
过使程序更易于使用(同时也更易于编写)来体现良好记法的重要性,但正则表达式代码同样
也是阐述算法,数据结构,测试,性能增强以及其他重要主题的最好方式。
1.2 实现
在《The Practice of Programming》一书中,正则表达式匹配器是一个模拟grep程序中的
一部分,但正则表达式的代码完全可以从编写环境中独立出来。这里我们并不关心主程序;像
许多Unix工具一样,这个程序将读取其标准输入或者一组文件,然后输出包含与正则表达式匹
配的文本行。
以下是匹配算法的代码:
/* match: search for regexp anywhere in text */
int match(char *regexp, char *text)
{
if (regexp[0] == ´^´)
return matchhere(regexp+1, text);
do { /* must look even if string is empty */
if (matchhere(regexp, text))
return 1;
} while (*text++ != ´\0´);
return 0;
}
/* matchhere: search for regexp at beginning of text */
int matchhere(char *regexp, char *text)
{
if (regexp[0] == ´\0´)
return 1;
if (regexp[1] == ´*´)
return matchstar(regexp[0], regexp+2, text);
if (regexp[0] == ´$´ && regexp[1] == ´\0´)
return *text == ´\0´;
if (*text!=´\0´ && (regexp[0]==´.´ || regexp[0]==*text))
return matchhere(regexp+1, text+1);
return 0;
}
/* matchstar: search for c*regexp at beginning of text */
int matchstar(int c, char *regexp, char *text)
{
do { /* a * matches zero or more instances */
if (matchhere(regexp, text))
return 1;
} while (*text != ´\0´ && (*text++ == c || c == ´.´));
return 0;
}
1.3 讨论
函数match(regexp,text)用来判断文本中是否出现正则表达式;如果找到了一个匹配的正
则表达式则返回1,否则返回0。如果有多个匹配的正则表达式,那么函数将找到文本中最左边
的并且最短的那个。
match函数中的基本操作简单明了。如果正则表达式中的第一个字符是^(固定位置的匹配),
那么匹配就一定要出现在字符串的开头。也就是说,如果正则表达式是^xyz,那么仅当xyz出现
在文本的开头而不是中间的某个位置时才会匹配成功。在代码中通过把正则表达式的剩余部分
与文本的起始位置而不是其他地方进行匹配来判断。如果第一个字符不是^,那么正则表达式就
可以在字符串中的任意位置上进行匹配。在代码中通过把模式依次与文本中的每个字符位置进
行匹配来判断。如果存在多个匹配,那么代码只会识别第一个(最左边的)匹配。也就是说,
如果则在表达式是xyz,那么将会匹配第一次出现的xyz,而且不考虑这个匹配出现在什么位置
上。
注意,对输入字符串的推进操作是在一个do-while循环中进行的,这种结构在C程序中使用
相对较少。在代码中使用do-while而不是while通常会带来疑问:为什么不在循环的起始处判断
循环条件,而是在循环末尾当执行完了某个操作之后才进行判断呢?不过,这里的判断是正确
的:由于*运算符允许零长度的匹配,因此我们首先需要判断是否存在一个空的匹配。
大部分的匹配工作是在matchhere(regexp,text)函数中完成的,这个函数将判断正则表达
式与文本的开头部分是否匹配。函数matchhere把正则表达式的第一个字符与文本的第一个字符
进行匹配。如果匹配失败,那么在这个文本位置上就不存在匹配,因此matchhere将返回0。然
而,如果匹配成功了,函数将推进到正则表达式的下一个字符和文本的下一个字符继续进行匹
配。这是通过递归地调用matchhere函数来实现的。
由于存在着一些特殊的情况,以及需要设置终止递归的条件。因此实际的处理过程要更为
复杂些最简单的情况就是,当正则表达式推进到末尾时(regexp[0] == ´\0´),所有前面的判断
都成功了,那么这个正则表达式就与文本匹配。
如果正则表达式是一个字符后面跟着一个*,那么将会调用matchstar来判断闭包(closure)
是否匹配。函数matchstar(c, regexp, text)将尝试匹配重复的文本字符c,从零重复开始并且
不断累加,直到匹配text的剩余字符,如果匹配失败,那么函数就认为不存在匹配。这个算法
将识别出一个“最短的匹配”,这对简单的模式匹配来说是很好的,例如grep,这种情况下的
主要问题是尽可能快地找到一个匹配。而对于文本编辑器来说,“最长的匹配”则是更为直观,
且肯定是更好的,因为通常需要对匹配的文本进行替换。在目前许多的正则表达式库中同时提
供了这两种方法,在《The Practice of Programming》一书中给出了基于本例中matchstar函
数的一种简单变形,我们在后面将给出这种形式。
如果在正则表达式的末尾包含了一个$,那么仅当text此时位于末尾时才会匹配成功:
if (regexp[0] == ´$´ && regexp[1] == ´\0´)
return *text == ´\0´;
如果没有包含$,并且如果当前不是处于text字符串的末尾(也就是说,*text!=´\0´)并
且如果text字符串的第一个字符匹配正则表达式的第一个字符,那么到现在为止都是没有问题
的;我们将接着判断正则表达式的下一个字符是否匹配text的下一个字符,这是通过递归调用
matchhere函数来实现的。这个递归调用不仅是本算法的核心,也是这段代码如此紧凑和整洁的
原因。
如果所有这些匹配尝试都失败了,那么正则表达式和text在这个位置上就不存在匹配,因
此函数matchhere将返回0。
在这段代码中大量地使用了C指针。在递归的每个阶段,如果存在某个字符匹配,那么在随
后的递归调用中将执行指针算法(例如,regexp+1 and text+1),这样在随后的函数调用中,
参数就是正则表达式的下一个字符和text的下一个字符。递归的深度不会超过匹配模式的长度,
而通常情况下匹配模式的长度都是很短的,因此不会出现耗尽内存空间的危险。
1.4 其他的方法
这是一段非常优雅并且写得很好的代码,但并不是完美的。我们还可以做哪些其他的工作?
我可能对matchhere中的操作进行重新安排,在处理*之前首先处理$。虽然这种安排不会对函数
的执行带来影响,但却使得函数看上去要自然一些,而在编程中一个良好的规则就是:在处理
复杂的情况之前首先处理容易的情况。
不过,通常这些判断的顺序是非常重要的。例如,在matchstar的这个判断中:
} while (*text != ´\0´ && (*text++ == c || c == ´.´));
无论在什么情况下,我们都必须推进text字符串中的一个或多个字符,因此在text++中的
递增运算一定要执行。
该代码对终止条件进行了谨慎的处理。通常,匹配过程的成功与否,是通过判断正则表达
式和text中的字符是不是同时处理完来决定的。如果是同时处理完了,那么就表示匹配成功,
如果其中一方在另一方之前被处理完了,那么就表示匹配失败。在下面这行代码中很明显地说
明了这个判断。
if (regexp[0] == ´$´ && regexp[1] == ´\0´)
return *text == ´\0´;
但在其他的情况下,还有一些微妙的终止条件。
如果在matchstar函数中需要识别最左边的以及最长的匹配,那么函数将首先识别输入字符
c的最大重复序列。然后函数将调用matchhere来尝试把匹配延伸到正则表达式的剩余部分和
text的剩余部分。每次匹配失败都会将cs的出现次数减1,然后再次开始尝试,包括处理零出现
的情况:
/* matchstar: leftmost longest search for c*regexp */
int matchstar(int c, char *regexp, char *text)
{
char *t;
for (t = text; *t != ´\0´ && (*t == c || c == ´.´); t++)
;
do { /* * matches zero or more */
if (matchhere(regexp, t))
return 1;
} while (t-- > text);
return 0;
}
我们来看一下正则表达式(.*),它将匹配括号内任意长度的text。假设给定了text:
for (t = text; *t != ´\0´ && (*t == c || c == ´.´); t++)
从开头位置起的最长匹配将会识别整个括号内的表达式,而最短的匹配将会停止在第一次
出现右括号的地方。(当然,从第二个左括号开始的最长匹配将会延伸到text的末尾)
1.5 构建
《The Practice of Programming》一书主要讲授良好的程序设计。在编写该书时,Rob和
我还在贝尔实验室工作,因此我们知道在课堂上使用这本书有什么样的效果。令人高兴的是,
我们发现这本书中的某些内容在课堂上确实有着不错的效果。从2000年教授程序设计中的重点
要素时,我们就使用了这段代码。
首先,这段代码以一种全新的形式展示了递归的强大功能及其带来的整洁代码。它既不是
另一种版本的快速排序(或者阶乘!)算法,也不是某种树的遍历算法。
这段代码同时还是性能试验的一个很好示例。其性能与系统中的grep并没有太大的差异,
这表明递归技术的开销并不是非常大的,因此没有必要对这段代码进行调整。
此外,这段代码还充分说明了优良算法的重要性。如果在模式中包含了几个.*序列,那么
在简单的实现中将需要进行大量的回溯操作,并且在某些情况下将会运行得极慢。
在标准的Unix grep中有着同样的回朔操作。例如,下面这个命令:
grep ´a.*a.*a.*a.a´
在普通的机器上处理一个4 MB的文本文件要花费20秒的时间。
如果某个实现是基于把非确定有限自动机转换为确定有限自动机,例如egrep,那么在处理
恶劣的情况时将会获得比较好的性能;它可以在不到十分之一秒的时间内处理同样的模式和同
样的输入,并且运行时间通常是独立于模式的。
对于正则表达式类型的扩展将形成各种任务的基础。例如:
1.增加其他的元字符,例如+用于表示前面字符的一个或多个出现,或者?用于表示零个或
一个字符的匹配。还可以增加一些方式来引用元字符,例如\$表示在模式中的$字符。
2.将正则表达式处理过程分成编译阶段和执行阶段。编译阶段把正则表达式转换为内部形
式,使匹配代码更为简单或者使随后的匹配过程运行得更为迅速。对于最初设计中的简单的正
则表达式来说,这种拆分并不是必须的,但在像grep这样的程序中,这种拆分是有意义的,因
为这种类型的正则表达式要更为丰富,并且同样的正则表达式将会用于匹配大量的输入行。
3.增加像[abc]和[0-9]这样的类型,这在传统的grep中分别匹配a或b或c和一个数字。可
以通过几种方式来实现,最自然的方式似乎就是把最初代码中的char*变量用一个结构来代替:
typedef struct RE {
int type; /* CHAR, STAR, etc. */
int ch; /* the character itself */
char *ccl; /* for [...] instead */
int nccl; /* true if class is negated [^...] */
} RE;
并且修改相应的代码来处理一个结构数组而不是处理一个字符数组。在这种情况下,并不
一定要把编译阶段从执行阶段中拆分出来,但这里的拆分过程是非常简单的。如果学生们把匹
配代码预编译成这个结构,那么总会比那些试图动态地解释一些复杂模式数据结构的学生要做
得更好。
为字符类型编写清晰并且无歧义的规范是件有难度的工作,而要用代码完美地实现处理更
是难上加难,这需要大量的冗长并且晦涩的编码。随着时间的推移,我简化了这个任务,而现
在大多数人会要求像Perl那样的速记,例如\d表示数字,\D表示非数字,而不再像最初那样在
方括号内指定字符的范围。
4.使用不透明的类型来隐藏RE结构以及所有的实现细节。这是在C语言中展示面向对象编
程技术的好方法,不过除此之外无法支持更多的东西。在实际情况中,将会创建一个正则表达
式类,其中类中成员函数的名字像RE_new( )和RE_match( )这样,而不是使用面向对象语言
的语法。
5.把正则表达式修改为像各种shell中的通配符那样:匹配模式的两端都被隐含地固定了,
*匹配任意数量的字符,而?则匹配任意的单个字符。你可以修改这个算法或者把输入映射到现
有的算法中。
6.将这段代码转换成Java。最初的代码很好地使用了C指针,并且把这个算法在不同的语
言中实现出来是一个很好的实践过程。在Java版本的代码中将使用String.charA(使用索引而
不是指针)或者String.substring(更接近于指针)。这两种方法都没有C代码整洁,并且都不
紧凑。虽然在这个练习中性能并不是主要的问题,但有趣的是可以发现了Java版本比C版本在运
行速度上要慢6到7倍。
7.编写一个包装类把这种类型的正则表达式转换成Java的Patter类和Matcher类,这些类
将以一种与众不同的方式来拆分编译阶段和匹配阶段。这是适配器(Adapter)模式或者外观
(Fa.ade)模式的很好示例,这两种模式用来在现有的类或者函数集合外部设置不同的接口。
我曾经大量地使用了这段代码来研究测试技术。正则表达式非常的丰富,而测试也不
是无足轻重的,但正则表达式又是很短小的,程序员可以很快地写出一组测试代码来执行。对
于前面所列出的各种扩展,我要求学生用一种紧凑的语言写出大量的测试代码(这是“记法”
的另一种示例),并且在他们自己的代码中使用这些测试代码;自然我在其他学生的代码中也
使用了他们的测试代码。
1.6 小结
当Rob Pike最初写出这段代码时,我被它的紧凑性和优雅性感到惊讶——这段代码比我以
前所想像的要更为短小并且功能也更为强大。通过事后分析,我们可以看到为什么这段代码如
此短小的众多原因。
首先,我们很好地选择了功能集合,这些功能是最为有用的并且最能从实现中体现出核心
思想,而没有多余的东西。例如,虽然固定模式^和$的实现只需要写3~4行代码,但在统一处
理普通情况之前,它展示了如何优雅地处理特殊情况。闭包操作*必须出现,因为它是正则表达
式中的基本记号,并且是提供处理不确定长度的惟一方式。增加+和?并不会有助于理解,因此
这些符号被留作为练习。
其次,我们成功地使用了递归。递归这种基本的编程技巧通常会比明确的循环带来更短、
更整洁的以及更优雅的代码,这也正是这里的示例。从正则表达式的开头和tex的开头剥离匹配
字符,然后对剩余的字符进行递归的思想,模仿了传统的阶乘或者字符串长度示例的递归结构,
但这里是用在了一种更为有趣和更有用的环境中。
第三,这段代码的确使用了基础语言来达到良好的效果。指针也可能被误用,但这里它们
被用来创建紧凑的表达式,并且在这个表达式中自然地表达了提取单个字符和推进到下一个字
符的过程。数组索引或者子字符串可以达到同样的效果,但在这段代码中,指针能够更好的实
现所需的功能,尤其是当指针与C语言中的自动递增运算和到布尔值的隐式转换结合在一起使用
时。
我不清楚是否有其他的方法能够在如此少的代码中实现如此多功能,并且同时还要提供丰
富的内涵和深层次的思想。