正则表达式高级讲解

文/余晟

环视结构(look-around)

分析日志(或普通数据文件),恐怕是大家在日常工作中经常遇到的问题,正则表达式应当是理所当然的选择,简单的正则表达式应用,大家应该都会,即使暂时不熟悉,查查资料也能解决。但是,有时候情况复杂,看起来正则表达式往往“束手无策”,其实事实并非如此。在这篇文章中,我们通过一个具体的例子,来讲解正则表达式的高级技巧。

事情源于朋友的一封来信:

“最近我遇到个小问题:公司让我处理日志文件,说实话我还真是巧,本来没有打算学正则,要是没有正则可能我这次还不知道怎么处理。简单说一下,主要任务是逐行读取数据,对每行内容进行分析,第一行是字段名,其余是日志内容,行与行之间没有联系,每行中字段内容用逗号隔开(但前两个字段和最后两个字段没有引号包围),逗号中的数据内容是用引号包围起来的,因为在生成日志的时候,没有考虑到在引号中的数据会存在逗号,所以无法整齐用切割函数类似split()的函数以逗号进行分割。所以我想了一个办法:把引号中的逗号全部换成别的符号,这样就可以切割了,我想了个正则表达式『("[^"]*")』,用它来找出引号字段,然后将其中的逗号替换掉,再处理。不知道有没有其它更好的办法?”

示例:

2007-11-6 0:41:37,15,"58.47.136.198","Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; .NETCLR .1.4322)","gzzstresw,jgubrmkizefns55","","/ShowData.aspx","?db=cecdb&id=h17753&p=","大发财集团有限公司","http://www.somehost.net/somepath/07002.html","d1,浏览免费数据","","db=cecdb&dt=data&id=h17753&p=0",0,0

在这行数据中,共有两个字段包含引号且其中有逗号,它们分别是:

gzzstresw,jgubrmkizefns55

d1,浏览免费数据

类似的任务,许多读者应该都遇到过;类似的问题,或许大家都经历过——应用正则表达式时,有没有更好的办法?答案是:有,我们可以只用一个正则表达式,一个函数,实现完美的切分!

在详细的讲解之前,我们先罗列问题的特征:

1. 每行数据分为许多字段,以逗号分隔

2. 有的字段以引号包围,有的没有引号

3. 有引号的字段不可能出现在这一行的开头和结尾

4. 引号包围的字段中,可能存在逗号(补充一点,逗号不可能紧挨引号,而且这样的字段中逗号只有一个)

了解了这个问题,我们就可以着手考虑如何应用正则表达式了。我们知道,正则表达式的应用方式,通常有三大类:查找、替换和切分。查找,也叫提取、搜索,是从文本中提取具有某种结构特征的字符串;替换,类似于替换,是操作文本中具有某种结构特征的字符串,不过替换的字符串根据被替换字符串的内容动态生成;切分,是先在文本中进行查找,找到所有(或部分)具有某种结构特征的字符串,以它们为分隔符,将文本切分开来。针对这个具体的问题,我们最先想到的操作就是:切分。

如果使用切分,则作为切分符的逗号必须满足下面两个要求之一:

1. 之前和之后都是引号;

2. 不在引号字段之内。

针对第一个要求,选择的逗号之前和之后都必须有引号,写成正则表达式就是『(?<="),(?=")』,在这里我们使用了如今大多数正则表达式系统都支持环视(look-around)结构,用来规定需要查找的文本之前和之后的文本特征。其中的『(?<="),(?=")』称为逆序环视(look-behind)结构,表示逗号之前(左侧)必须出现一个引号,『(?<="),(?=")』是顺序环视(look-ahead)结构,表示逗号之后(右侧)必须出现一个引号。在这里,『(?<=")』和『(?=")』都是正则表达式预先定义的特殊结构,它们非常形象,大家在使用时可以按照具体的要求,把引号替换成自己需要的子表达式;

第二点要求逗号不能在引号字段内,如何判断逗号是否在引号字段内呢?我们不妨这样想:如果逗号在引号字段内,那么它与之前(左边)的第一个逗号(或者行开头位置)之间,必然存在引号(因为引号字段内不可能存在多于一个的逗号)。也就是说,我们要寻找的逗号与它之前的第一个逗号之间,不容许存在引号。仍然使用环视结构,我们得到这样一个正则表达式『(?<=(,|^)[^"]+),』。其中『(?<=(,|^)),』对应之前的逗号或是行开头位置,而『(?<=(,|^)[^"]+),』表示两个逗号(或行开头与逗号)之间的字符不能是引号,否则就会匹配失败。这个表达式能够找到的逗号,应该就是我们需要的逗号了。

最后,因为这两个要求是“或(or)”的关系,我们使用多选分支将它们并列起来,最终得到的表达式就是『((?<="),(?=")| (?<=(,|^)[^"]+),)』。

事情到这里就结束了吗?答案是否定的。虽然我们的思路正确,表达式的格式和结构也没问题,但问题并没有解决,因为在大多数语言和系统中,逆序环视结构中出现的子表达式必须有确定的长度(有的系统中可以使用量词『?』,但无法使用『*』和『+』),只有.NET是个例外,也就是说,这种方法只有在.NET系统中才有现实意义(使用.NET的程序员这下该高兴了)。

那么,是否存在其他途径呢?我们知道,在进行日志分析时,除了切分,还有一种常用的操作,就是查找——在日志中迭代应用正则表达式进行查找,依次提取出需要的字段。下面我们尝试使用查找来解决这个问题。

如果使用查找,我们需要总结出字段的公共特征,这样,就能保证每次迭代,都能找到一个字段——无论它是有引号包围,还是没有引号包围的。

同样,我们首先还是分情况考虑(注意,因为逗号只是分隔符,所以我们提取的字段中不应该包含逗号,另外,也不应当包含引号):

1. 如果是没有引号包围的字段,可能以上一个逗号之后的位置,或行开头位置为起点,以下一个逗号之前的位置,或者行结束位置为终点,并且,之中不能包含引号或逗号;

2. 如果是引号包围的字段,则以引号为起点,以引号为终点,之中不能包含引号。补充一点,为了保证匹配的准确性,我们把条件设置得更强一些,开头的引号之前必须还有一个逗号,结尾的引号之后必须还有一个逗号,在实际应用正则表达式时,将条件设置得更强一些,保证正确性,是一个好习惯。

先来考虑第一种情况,对起点和终点的判断,都可以以环视结构来进行,判断起点的表达式是『(?<=(,|^))』,这里的逆序环视结构中,又出现了不定长度的表达式,在某些系统中无法编译通过,但这种简单的情况,我们有办法突破限制,即将表达式改写为『((?<=,)|(?<=^))』,判断终点的表达式『(?=(,|$))』,不包含引号或逗号的表达式『[^",]+』的联合,就得到了第一种情况对应的表达式『((?<=,)|(?<=^))[^",]+(?=(,|$))』;对应第二种情况的表达式非常容易,是『(?<=,")[^"]+(?= ",)』。

同样,两种情况是“或(or)”的关系,我们使用多选分支将它们并列起来,最终得到的表达式就是『(((?<=,)|(?<=^))[^",]+(?=(,|$))|(?<=,")[^"]+(?= ",))』。

在Java和Python语言中,这个正则表达式(注意,是这个正则表达式,不是这个字符串,编译时我们还需要转义,这个问题会在下一节文章详细谈到)都可以编译通过,没有问题。

好了,到这里,这个问题已经解决完毕,解决问题的思路和步骤,有兴趣的朋友可以再推敲推敲,在最后,我们详细介绍环视功能:相信我,它很有用,但知道的人并不多。

环视(look-around)用来检查某个位置两侧的文本,但不会把检查时匹配的文本加入匹配的最终结果。通常情况下,表达式『/bJeff/b』只能匹配“Jeff”这个单词,如果我们需要精确匹配“Jeffrey”这个单词中的“Jeff”,就可以使用环视『Jeff(?=rey)』,后面的『(?=rey)』表示,如果匹配成功,“Jeff”之后必须出现“rey”(但是这三个字符并不会包含在最终的匹配结果之中)。有的读者可能会说,那我直接使用『(Jeff)rey』,先找出来,再提取分组,不是一样吗?请注意,环视的对象又可以是正则表达式,『Jeff(?=(rey|erson))』就可以找到“Jeffrey”或“Jefferson”中的“Jeff”,这种灵活性是前一种做法无法提供的;再者说,『(Jeff)rey』使用括号来捕获文本,正则表达式在匹配时必须保存处理括号,保存文本,效率有所降低;而且,环视在处理中文文本时有独特的价值,因为中文的字符是连在一起的,单词之间没有空格分隔:如果一段文本中包含许多句子,有些只包含“北京”,有些包含“北京市”,我们需要仅仅将包含“北京”的句子都筛出来,就必须使用环视功能。此外,环视结构也可用于匹配的定位,保证准确性,在上面的例子中,我们就用环视结构,保证了字段两端引号匹配的准确性。

按照环视的方向不同,可以分为顺序环视(lookahead,表示从左向右检查)和逆序环视(lookbehind,从右向左检查);按照环视成立的条件不同,又可分为肯定环视(positive lookaround,只有在环视对象能匹配时才成功)和否定环视(negative lookaround,只有在环视对象无法匹配时才成功)。两者组合起来,就得到四种环视:

肯定顺序环视,要求右侧的文本必须能被环视内的表达式匹配

肯定逆序环视,要求左侧的文本必须能被环视内的表达式匹配

否定顺序环视,要求右侧的文本必须不能被环视内的表达式匹配

否定逆序环视,要求左侧的文本必须不能被环视内的表达式匹配

所使用的标记也很好识别,『(?=Regex)』表示肯定顺序环视,『(?!Regex)』表示否定顺序环视,『(?<=Regex)』表示肯定逆序环视,『(?<!Regex)』表示否定逆序环视。

在常见的HTML解析中,如果我们需要精确获得“src=...”中的资源地址(这里假定“src=...”的格式统一规范,等号两端没有空格,也没有引号),可以在表达式之前添加『(?<=src=)』,我们还可以用『(?<=<B>).*?(?=</B>)』来精确匹配“<B>...</B>”之中的内容。在这两个例子中,当然也可以使用匹配-括号提取的办法,但使用环视的效率更高,也更切合程序的本意。

还需要提到的一点是,在大多数系统(也就是.NET之外)中,逆序环视结构存在限制,一般来说其中的表达式所匹配的文本的长度必须固定,或者必须有上限。如果我们能确定表达式能匹配的文本有几种情况,就可以先列出这几种情况对应的环视结构,再用多选分支连立起来——在上文中,我们就是用这种方法绕过这种限制的——在编辑Apache的Rewrite规则时,这是一条很有用的经验。

转义符

在日常应用正则表达式时,我们经常会遇到这样的问题,正则表达式中到底该如何转义——最明显的表现就是,搞不懂究竟要使用多少个反斜线(你能迅速准确回答下面的问题吗:正则表达式中的一个反斜线,在Java语言中,究竟需要多少个反斜线来表示?)。结果,在大部分时候,我们盲目尝试,直到测试成功为止。但是,许多时候,这个办法实现起来并不方便。

为了彻底解决这类问题,我们需要弄清楚正则表达式与字符串的关系:它其实很简单,根据本人的经验,我们只需要牢记下面两条原则即可:

1.正则表达式必须以字符串的形式指定,但它不等于字符串

大多数语言中都存在正则表达式(regex)对象,譬如Java语言中的Pattern,.NET中的Regex。如果没有提供专用对象,一般需要用某些特殊的字符来标注正则表达式,譬如PHP中常用的反斜线'/';另一方面,正则表达式对某些字符或字符序列有自己的规定,不同于字符串的规定,譬如字符'/b',在正则表达式中,它表示单词分界符(word-boundary,用来匹配这样的位置,一侧是英文单词字符,一侧是非单词字符,关于单词字符的规定,请参考具体的语言文档),而在普通字符串中表示退格符(backspace)。因此我们可以说,正则表达式对文本的规定,并不等同于普通的字符串。

但是,正则表达式又终究是一种处理文本的语言,我们给出的所有正则表达式,大都是以字符串形式指定的。

所以,在正则表达式的应用过程中,往往需要进行从字符串到正则表达式本身的转换;我们也知道,从源代码中的字符序列,到语言中的字符串,也需要经过一个转换的过程。综合起来,我们在源代码中指定的字符序列,到最终生成正则表达式,需要经过两步转换:

“源代码中的字符序列”->“字符串”->“正则表达式”

我们来看下面这个例子(用Java语言举例)

Pattern pattern = Pattern.compile('//b');

其中,源代码中的字符序列是" //b ",经过转义,生成的字符串(String对象)包含两个字符:反斜线和小写字母'b',以正则表达式的方式解析这个字符串,得到的正则表达式对应单词分界符(word-boundary)。如果我们这样写:

Pattern pattern = Pattern.compile('/b');

仍然能够编译通过,但此时生成的字符串仅包含一个字符 :退格符,于是正则表达式接收到的也就是单个退格符。

这里有一点需要指出:在Java和C#之类的语言中,如源代码中的字符序列无法识别,编译会出错,譬如这样:

Pattern pattern = Pattern.compile('/w');

尽管我们知道,在正则表达式中,/w匹配单词字符(一般来说,是数字、字母和下划线),编译仍然会报错。因为根据针对字符串的规定,'/w'不是一个合法的转义序列,也就是说,我们无法由字符序列/w生成一个合法的字符串:

String s = '/w'; //编译出错!

但是PHP和Python之类的语言却不存在这样的问题。原因在于,如果PHP和Python发现字符串中有无法识别的转义序列,会原封不动地保存下来。如果我们在Python中这么写:

p = re.compile("/w")

是没有问题的,因为尽管/w无法识别,仍然会保存下来,在正则表达式中被正确解析。

当然,我们也可以在这些语言中使用'//w',结果是一样的,因为此时,在进行字符串处理时,第1个反斜线转义了第2个反斜线,正则表达式接受到的,同样是'/w'。

在实际开发中,这样的问题可能非常迷惑人,但只要我们弄清了正则表达式和字符串的关系,就不会再被它困扰。

2.正则表达式中单独出现的反斜线也需要转义

与字符串一样,在正则表达式中,反斜线通常与其他字符一起构成特殊的结构,譬如'/d'用来匹配数字字符,'/s'用来匹配空白字符,'/1'用来反向引用第一个括号内的字表达式(也就是编号为1的分组)捕获的文本,等等等等。

可是,如果我们的正则表达式中仅仅需要“反斜线”本身,也就是字符'/',该如何做呢?

其实,正则表达式对这个问题的处理,与字符串的处理是一样的,也就是说,在正则表达式中,必须用转义序列'//'来表示单个反斜线。

这个规定会带来一个有趣的问题:正则表达式中单独出现的反斜线字符,在生成正则表达式的时候,必须以转义序列'//'来表示,而这其中的每个反斜线字符,在表示正则表达式的字符串中,又必须以转义序列'//'来表示。所以,在字符串中,必须写出四个反斜线'////',才能对应到正则表达式中单独出现的一个反斜线字符:生成的字符串中,只包含两个反斜线字符'//';由这个字符串生成的正则表达式,就只包含一个反斜线字符'/'。

牢记这两条原则,在以后的开发中,面对正则表达式的转义问题,我们就不会感到迷惑了。 

作者简介:

余晟,抓虾网高级顾问,历任高级程序员,技术经理;解决过大量文本解析和数据抽取的问题;本科毕业于东北师范大学,主修计算机,副修中文,现居北京。对程序语言、算法、数据库和敏捷开发都有兴趣,译有《精通正则表达式》(第3版)。

你可能感兴趣的:(正则表达式)