Expanding the Foundation
我希望,前面的例子和解释已经帮助读者牢固地打下了正则表达式的基础,也请读者明白,这些例子都很浅显,我们需要掌握的还有很多。
语言的差异
Linguistic Diversification
我已经介绍过大多数版本的egrep支持的正则表达式的特性,这样的特性还有很多,其中一些并不是所有的版本都支持,这个问题留到后面的章节讲解。
任何语言中都存在不同的方言和口音,很不幸,正则表达式也一样。情况似乎是,每一种支持正则表达式的语言都提供了自己的“改进”。正则表达式不断发展,但多年的变化也造就了数目众多的正则表达式“流派”(flavor)。我们会在下面的章节中见到各种例子。
正则表达式的目标
The Goal of a Regular Expression
从最宏观的角度看,一个正则表达式要么能够匹配给定文本(对egrep来说,就是一行文本)中的某些字符,要么不能匹配。在编写正则表达式的时候,我们必须进行权衡:匹配符合要求的文本,同时忽略不符合要求的文本。
尽管egrep不关心匹配文本在行中的位置,但对正则表达式的其他应用来说,这个问题却很重要。如果文本是这样:
…zip is 44272.If you write,send $4.95 to cover postage and…
我们只希望找出包含「[0-9]+」的那些行,就不需要关心真正匹配的数字。相反,如果我们需要操作这些数字(例如保存到文件、添加、替换之类——我们会在下一章看到这样的处理),就需要关心确切匹配的那些数字。
更多的例子
A Few More Examples
在任何语言中,经验都是非常重要的,所以我会给出更多用正则表达式匹配常用文本结构的例子。
编写正则表达式时,按照预期获得成功的匹配要花去一半的工夫,另一半的工夫用来考虑如何忽略那些不符合要求的文本。在实践中,这两方面都非常重要,但是目前我们只关注“获得成功匹配”的方面。即使我没有对这些例子进行最全面彻底的解释,它们仍然能够提供有用的启示。
变量名
许多程序设计语言都有标识符(identifier,例如变量名)的概念,标识符只包含字母、数字以及下画线,但不能以数字开头。我们可以用「[a-zA-Z_][a-zA-Z_0-9]*」来匹配标识符。第一个字符组匹配可能出现的第一个字符,第二个(包括对应的「*」)匹配余下的字符。如果标识符的长度有限制,例如最长只能是32个字符,又能使用第20页介绍的区间量词「{min,max}」,我们可以用「{0,31}」来替代最后的「*」。
引号内的字符串
匹配引号内的字符串最简单的办法是使用这个表达式:「"[^"]*"」。
两端的引号用来匹配字符串开头和结尾的引号。在这两个引号之间的文本可以包括双引号之外的任何字符。所以我们用「[^"]」来匹配除双引号之外的任何字符,用「*」来表示两个引号之间可以存在任意数目的非双引号字符。
关于引号字符串,更有用(也更复杂)的定义是,两端的双引号之间可以出现由反斜线转义的双引号,例如"nail·the·2/"x4/"·plank"。在后面的章节讲解匹配实际进行的细节时,我们会多次遇到这个例子。
美元金额(可能包含小数)
「/$[0-9]+(/.[0-9][0-9])?」是一种匹配美元金额的办法。
从整体上看,这个表达式很简单,分为三部分:「/$」、「…+」和「(…)?」,可以大致理解为:一个美元符号,然后是一组字符,最后可能还有另一组字符。这里的“字符”指的是数字(一组数字构成一个数值),“另一组字符”是由一个小数点和两位数字构成的。
从几个方面来看,这个表达式还很简陋。比如,它只能接受$1000,而无法接受$1,000。它确实能接受可能出现的小数部分,但对于egrep来说意义不大。因为egrep从不关心匹配文字的内容,而只关心是否存在匹配。处理可能出现的小数部分对整个表达式能否匹配并没有影响。
但是,如果我们需要找到只包含价格而不含其他字符的行,倒是可以在这个表达式两端加上「^…$」。这样一来,可选的小数部分就变得很重要了,因为在金额数值和换行符之间是否存在小数部分,决定了整个表达式的匹配结果是否存在差异。
另外,这个正则表达式还无法匹配‘$.49’。你可能认为把加号换成星号能够解决问题,不过这条路走不通。在这我先卖个关子,答案留待第5章(☞194)揭晓。
HTTP/HTML URL
Web URL的形式可能有很多种,所以构造一个能够匹配所有形式的URL的正则表达式颇有难度。不过,稍微降低一点要求的话,我们能够用一个相当简单的正则表达式来匹配大多数常见的 URL。进行这种检索的原因之一是,我只能大概记得在收到的某封邮件中有一个URL地址,不过一见到它我就能认出来。
常见的HTTP/HTML URL是下面这样的:
http://hostname/path.html
当然,.htm的结尾也很常见。
hostname(主机名,例如www.yahoo.com)的规则比较复杂,但是我们知道,跟在‘http://’之后的就有可能是主机名,所以这个正则表达式就很简单,「[-a-z0-9_.]+」。path部分的变化更多,所以我们需要使用「[-a-z0-9_:@&?=+,.!/~*%$]*」。请注意,连字符必须放在字符组的开头,保证它是一个普通字符,而不是用来表示范围(☞9)。
综合起来,我们第一次尝试的正则表达式就是:
%egrep-i '/<http://[-a-z0-9_.:]+/[-a-z0-9_:@&?=+,.!/~*%$]*/.html?/>' files
因为我们降低了对匹配的要求,所以‘http://..../foo.html’也能匹配,虽然它显然不是一个合法的 URL。我们需要关心这一点吗?这取决于具体的情况。如果我只是需要扫描自己的E-mail,得到一些错误结果并不算是问题。而且,我没准会用更简单的表达式:
%egrep-i '/<http://[^]*/.html?/>' files…
在深入了解如何调校正则表达式之后,读者会明白,要想在复杂性和完整性之间求得平衡,一个重要的因素是了解待搜索的文本。下一章,我们会更详细地考察这个例子。
HTML tag
对egrep这样的工具来说,简单地匹配包含HTML tag的行并不常见,也没什么用。但是,探索如何准确匹配一个HTML tag却是相当有启发的,在下一章深入接触更高级的工具时,这一点尤其明显。
简单的例子包括‘<TITLE>’和‘<HR>’,我们可能会想到「<.*>」。这个简单的表达式往往是最直接的想法,但它显然是不对的。「<.*>」的意思是,“先匹配一个‘<’,然后是任意多个任意字符,然后是‘>’”。所以,它无疑能够匹配不止一个tag的内容,例如‘thisexample’中标记的内容。
也许结果有点出乎你的意料,但是我们目前还只在第 1 章,对正则表达式的理解也不够深入。我之所以举这个例子,是想说明正则表达式并不复杂,但是如果你不真正弄懂它们,可能会被搞得晕头转向。在下面的几章中,我们会学习理解和解决这个问题需要的所有细节。
表示时刻的文字,例如“9:17 am”或者“12:30 pm”
匹配表示时刻的文字可能有不同的严格程度。
「[0-9]?[0-9]:[0-9][0-9]·(am|pm)」
能够匹配9:17·am或者12:30·pm,但也能匹配无意义的时刻,如99:99·pm。
首先看小时数,我们知道,如果小时数是一个两位数,第一位只能是 1。但是「1?[0-9] 」仍然能够匹配19(也能够匹配0),所以更好的办法应该是把小时部分分为两种情况来处理,「1[012] 」匹配两位数,「[1-9]」匹配一位数,结果就是「(1[012]|[1-9])」。
分钟数就简单些。第一位数字应该是「[0-5]」,此时第二位数字应该是「[0-9]」。综合起来就是「(1[012]|[1-9]):[0-5][0-9]·(am|pm)」。
举一反三,你能够处理24小时制的时间吗?多动动脑筋,想想该如何处理以0开头的情况,比如09:59呢?ϖ答案请见下页。
正则表达式术语汇总
Regular Expression Nomenclature
正则(regex)
你或许已经猜到了,“正则表达式”(regular expression)这个全名念起来有点麻烦,写出来就更麻烦。所以,我一般会采用“正则”(regex)的说法。这个单词念起来很流畅(有点像联邦快递的FedEx,与regular一样,g发重音,而不同于Regina),而且说“如果你写一个正则”,“巧妙的正则”(budding regexers),甚至是“正则化”(regexification)(注10)(译注4)。
匹配(matching)
一个正则表达式“匹配”一个字符串,其实是指这个正则表达式能在字符串中找到匹配文本。严格地说,正则表达式「a」不能匹配 cat,但是能匹配 cat中的 a。几乎没人会混淆这两个概念,但澄清一下还是有必要的。
元字符(metacharacter)
一个字符是否元字符(或者是“元字符序列”(metasequence),这两个概念是相等的),取决于应用的具体情况。例如,只有在字符组外部并且是在未转义的情况下,「*」才是一个元字符。“转义”(escaped)的意思是,通常情况下在这个字符之前有一个反斜线。「/*」是对「*」的转义,而「//*」则不是(第一个反斜线用来转义第二个反斜线),虽然在两个例子中,星号之前都有一个反斜线。
正则表达式的流派(flavor)不同,关于字符转义的规定也不相同。第3章对此进行了详细讨论。
流派(flavor)
我已经说过,不同的工具使用不同的正则表达式完成不同的任务,每样工具支持的元字符和其他特性各有不同。我们再举单词分界符的例子。某些版本的 egrep 支持我们曾见过的/<…/>表示法。而另一些版本不支持单独的起始和结束边界,只提供了统一的「/b」元字符(这个元字符我们还没见过,下一章才会用到)。还有些工具同时支持这两种表示法,另有许多工具哪种也不支持。
我用“流派(flavor)”这个词来描述所有这些细微的实现规定。这就好像不同的人说不同的方言一样。从表面上看,“流派”指的是关于元字符的规定,但它的内容远远不止这些。
即使两个程序都支持「/<…/>」,它们可能对这两个元字符的意义有不同的理解,对单词的理解也不相同。在使用具体的工具软件时,这个问题尤其重要。
请不要混淆“流派(flavor)”和“工具(tool)”这两个概念。两个人可以说同样的方言,两个完全不同的程序也可能属于同样的流派。同样,两个名字相同的程序(解决的任务也相同)所属的流派可能有细微(有时可能并非细微)的差别。有许多程序都叫egrep,它们所属的流派也五花八门。
由Perl语言的正则表达式开创的流派,在20世纪90年代中期因为其强大的表达能力广为人们所知,其他语言紧随其后,提供了汲取其中灵感的正则表达式(其中许多为了标明自己的思想来源,直接给自己贴上“兼容Perl(Perl-Compatible)”的标签)。它们包括PHP、Python、Java的大量正则包,微软的.NET Framework、Tcl,以及C的各种类库。不过,所有这些语言在重要的方面各有不同。而且 Perl 的正则表达式也在不断演化和发展(现在,有时候是受了其他语言的正则表达式的刺激)。像往常一样,总的局面变得越来越复杂,让人困惑。
子表达式(subexpression)
“子表达式”指的是整个正则表达式中的一部分,通常是括号内的表达式,或者是由「|」分隔的多选分支。例如,在「^(Subject|Date):·」中,「Subject|Date」通常被视为一个子表达式。其中的「Subject」和「Date」也算得上子表达式。而且,严格说起来,「S」、「u」、「b」、「j」这些字符,都算子表达式。
1-6 这样的字符序列并不能算「H[1-6]·*」的子表达式,因为‘1-6’所属的字符组是不可分割的“单元(unit)”。但是,「H」、「[1-6]」、「·*」都是「H[1-6]·*」的子表达式。
与多选分支不同的是,量词(星号、加号和问号)作用的对象是它们之前紧邻的子表达式。所以「mis+pell」中的+作用的是「s」,而不是「mis」或者「is」。当然,如果量词之前紧邻的是一个括号包围的子表达式,整个子表达式(无论多复杂)都被视为一个单元。
字符(character)
“字符”在计算机领域是一个有特殊意义的单词。一个字节所代表的单词取决于计算机如何解释。单个字节的值不会变化,但这个值所代表的字符却是由解释所用的编码来决定的。例如,值为64和53的字节,在ASCII编码中分别代表了字符“@”和“5”,但在EBCDIC编码中,则是完全不同的字符(一个是空格,一个是控制字符)。
另一方面,在流行的日文字符编码中,这两个字节代表一个字符正。如果换一种日文字符编码,这个字就需要两个完全不同的字节。那两个字节,在通行的Latin-1编码中,表示“”,而在Unicode 编码中又表示韩文的“”(注11)。问题在于,字节如何解释只是视角(称为“编码”encoding)的问题,我们要做的只是确保自己的视角和正在使用的工具的视角相同。
一直以来,文本处理软件一般都把数据视为一些 ASCII 编码的字节,而不考虑使用者期望采用的字符编码。不过,近来已经有越来越多的系统在内部使用某些格式的Unicode编码来处理数据(第3章介绍了Unicode,☞105)。如果这些系统中的正则表达式子系统的实现方式正确,使用者通常就不需要在编码的问题上费太多工夫。这个“如果”相当复杂,所以第3章深入讲解了这个问题。
改进现状
Improving on the Status Quo
总的来说,正则表达式并不难。但是,如果你与使用过支持正则表达式的程序或语言的人交流过就会发现,某些人确实“会用”正则表达式,但如果需要解决复杂的问题,或是换用他们不熟悉的工具,就会出问题。
传统的正则表达式文档大都只包含一两个元字符的简略介绍,然后就给出关于其他元字符的表格。给出的例子通常也是无意义的「a*((ab)*|b*)」,文本则是‘a·xxx·ce·xxxxxx· ci·xxx·d’。这些文档大都忽略了细微但重要的知识点,总是声称自己与其他出名的工具属于同一流派,而忘记提及必然存在的差异。它们缺乏实用价值。
当然,我的意思并不是,本章就能够填补这道鸿沟,让读者掌握所有正则表达式,或是掌握egrep的正则表达式。相反,这一章只是为本书的其他内容铺垫基础。我希望本书能够为读者填补这道鸿沟,虽然这期望有点自负。很多读者很满意本书的第一版,我本人也为拓展这一版的深度和广度付出了艰苦的努力。
或许是因为正则表达式的文档一直都非常欠缺,我感到自己必须做出额外的努力,才能把知识梳理清楚。因为我希望保证读者能够充分运用正则表达式的潜力,我希望你们能够真正精通正则表达式。
这既是件好事也是件坏事。
好处在于,你将学会如何以正则表达式的方式来思考问题。你将学习到,在面对属于不同流派的新工具时,需要注意哪些差异和特性。你还将会学习到,如果某个流派的功能弱小、特性简陋,该如何表达自己的意图。你将会明白,一个正则表达式的效率优于其他表达式的原因所在,而且你将能够在复杂性、效率和匹配准确性间进行取舍权衡。
面对特别复杂的任务,你将会知道如何通过程序容许的方式来构建和使用正则表达式。总的来说,你能够得心应手地使用正则表达式的所有潜能。
问题在于,这种方法的学习曲线非常陡峭,而且还有几大难点:
●正则表达式的使用 许多程序使用的正则表达式比egrep要复杂。在我们探讨如何构造真正有用的正则表达式的细节之前,需要知道正则表达式的使用方法。下一章关注这一问题。
●正则表达式的特性(feature) 面对问题,选择合适的工具是成功的一半,所以我会在全书中使用多种工具。不同的程序,甚至是同一个程序的不同版本,支持的特性和元字符都不一样。在了解使用细节之前,我们必须搞清楚这个问题。这是第3章的主题。
●正则表达式的工作原理 在我们接触有用(但通常也很复杂)的例子之前,我们必须“揭开盖子”来了解正则表达式的工作原理。我们将会看到,对某些元字符进行尝试匹配的次序是一个重要的问题。实际上,正则表达式引擎(regular expression engine)不同,工作原理也不同,所以对于同样的正则表达式,不同的程序会得到不同的结果。我们将在第4、5、6章中探讨这个复杂的问题。
正则表达式的工作原理是最重要同时也是最难以掌握的知识。研究这个问题有时的确很枯燥,更糟糕的是,读者在接触真正有趣的内容——解决实际问题——之前,不得不耐着性子看完它们。然而,弄懂正则表达式的工作原理,才是真正理解的关键。
你或许会想,如果只希望学会开车,是不需要了解汽车运行原理的。但是,学习开车与学习正则表达式之间并没有多少相似性。我的目的是教会读者如何使用正则表达式——也就是编写正则表达式——来解决问题。更合适的比喻是,学习正则表达式就如同学习如何造车,而不是如何开车。在制造汽车以前,我们必须了解汽车的工作原理。
第2 章提供了更多的关于开车的经验。第 3 章简要回顾了开车的历史,详细考察了正则表达式流派的主要内容。第4 章介绍了正则表达式流派的重要的引擎。第5 章展示了一些更复杂的例子,第 6 章告诉你如何调校某种具体的引擎,之后的各章则是检查具体的产品和模型。在第4、5、6章中,我们花了大量的篇幅来探讨幕后的原理,所以请务必做好准备。
总结
Summary
表1-3总结了我们在本章中见过的egrep的元字符。
表1-3:egrep的元字符总结
此外,请务必理解以下几点:
●各个 egrep 程序是有差别的。它们支持的元字符,以及这些元字符的确切含义,通常都有差别——请参考相应的文档(☞23)。
●使用括号的3个理由是:限制多选结构(☞13)、分组(☞14)和捕获文本(☞21)。
●字符组的特殊性在于,关于元字符的规定是完全独立于正则表达式语言“主体”的。
●多选结构和字符组是截然不同的,它们的功能完全不同,只是在有限的情况下,它们的表现相同(☞13)。
●排除型字符组同样是一种“肯定断言”(positive assertion)——即使它的名字里包含了“排除”两个字,它仍然需要匹配一个字符。只是因为列出的字符都会被排除,所以最终匹配的字符肯定不在列出的字符之内(☞12)。
●-i的参数很有用,它能进行忽略大小写的匹配(☞15)。
●转义有3种情况:
1.「/」加上元字符,表示匹配元字符所使用的普通字符(例如「/*」匹配普通的星号)。
2.「/」加上非元字符,组成一种由具体实现方式规定其意义的元字符序列(例如,「/<」表示“单词的起始边界”)。
3.「/」加上任意其他字符,默认情况就是匹配此字符(也就是说,反斜线被忽略了)。请记住,对大多数版本的egrep来说,字符组内部的反斜线没有任何特殊意义,所以此时它并不是一个转义字符。
●由星号和问号限定的对象在“匹配成功”时可能并没有匹配任何字符。即使什么字符都不能匹配到,它们仍然会报告“匹配成功”。