作者简介:余晟,抓虾网工程师,毕业于东北师范大学,主修计算机,辅修中文,现居北京。曾任高级程序员,技术经理;从事过大量文本解析和数据抽取的工作。对程序语言、算法、数据库和敏捷开发都有兴趣,译有《精通正则表达式》(第3版)。
在这篇文章中,我们将尝试对正则表达式的相关概念加以梳理,详细讲解一些使用中容易出现问题的概念,并介绍一些目前大家不常用但非常有用的功能。
元字符(meta-character)
正则表达式中的字符,可以粗略分为两类,文字(literal)和元字符(meta-character)。普通文字很好理解,例如字母『a』,出现在正则表达式中,就表示匹配字母‘a’,还有一类字符称为元字符,例如圆括号、方括号、花括号、美元符,等等,它们具有特殊的意义。
常用的元字符有:
点号『.』
一般来说,点号可以匹配任意字符(但不包括换行符),但在特殊的匹配模式(下文将会介绍)下,点号可以匹配所有字符。
方括号『[』『]』
表示字符组(character class),字符组可以罗列在单个位置能够出现的所有可能字符,否定型(negative)字符组罗列在单个位置不容许出现的所有字符。例如,『[a- z]』表示匹配成功时在此位置必须出现a-z中的一个字符,『[^a-z]』表示匹配成功时此位置不能出现a-z中的任意字符。
圆括号『(』『)』
主要有三种功能:
-
组合多选分支(Alternation)
-
分组量词(quantifier)的作用对象
-
捕获文本
在某处可能匹配的多种选择(每种选择又可以是一个表达式),可以用圆括号和竖线来并行排列(但顺序有讲究,这里不展开),例如『(ab|cd|ef)』表示匹配成功时此处必须出现“ab”或“cd”或“ef”,而方括号表示的字符组(character class)只能并行排列多个字符(单个字符);
量词限制某个元素在某位置重现的次数,常见的量词包括『*』(表示零次到任意多次)、『?』(表示零次到一次)和『+』(表示一次到任意多次)。量词只能修饰它之前紧邻的元素,因此『34*』能匹配“344444”,但不能匹配“343434”,如果希望匹配“343434”,必须将“34”两个字符单独分作一组,以量词修饰,也就是『(34)*』,我们也可以用括号+量词指定“出现的数字字符数目是3的倍数”之类的条件:『(\d\d\d)+』;
括号的另一个功能是捕获文本,供反向引用(back-reference)或按编号访问:它会为已经匹配的文字提供一个编号,供表达式中靠后的元素使用,或是在匹配之后提取信息。例如,我们可以用『(\b\w+\b)\s+\1』匹配两个重复的单词,这里『\1』表示反向引用第一组括号内的表达式匹配的内容(其内容是无法事先决定的),也可以用一个正则表达式『(\d{2})/(\d{2})/(\d{4})』来匹配“07/31/2007”这样的日期,匹配完成之后,月、日、年的信息分别保存在第1、2、3号分组内,直接访问就可以提取出来。除这三种情况之外,应用括号之前必须仔细思考,到底有没有必要。正则表达式中最常见的缺陷就是滥用括号,在复杂的表达式中,这样会严重降低效率。
实际上,为了提高效率,许多系统提供了仅能分组而不支持反向引用的括号:『(?:regex)』,『(?:AB)+』表示“AB”必须出现一次以上,但之后无法作为一个分组加以引用。
花括号『{』『}』
称为“区间”(interval),用数字指定之前元素的重现次数,『{1,3}』表示之前元素必须重现1~3次,『{,3}』表示之前元素至多能重现3次(也可以不出现),而『{3,}』表示之前元素至少必须出现3次。
^、$、\A、\Z
这四个元字符不匹配任何字符,只匹配某个位置,依匹配模式的不同,『^』可以匹配整个字符串的开头,或是一行文本的开头,『$』可以匹配整个字符串的结尾,或是一行的结尾;而『\A』在任何情况下都匹配整个字符串的开头,『\Z』在任何情况下都匹配整个字符串的结尾。不要以为这四个元字符的应用场合有限,许多时候,它们可以用来“锚定”(anchor)正则表达式进行尝试的位置(例如,如果希望匹配的数据肯定是从一行的开头开始,就可以使用『^』),减少不必要的尝试,大大提高匹配的效率。
\w、\W、\d、\D、\s、\S、\b、\B
这几个元字符也叫做“字符组简记法”,『\w』表示“单词字符”,通常相当于『[a-zA-Z0-9]』,而『\W』表示“非单词字符”,『\d』表示 “数字”,通常相当于『[0-9]』,而『\D』表示“非数字字符”,『\s』表示“空白字符”,可以匹配空格、指标符、换行符等等,而『\S』表示“非空白字符”,『\b』和『\B』则用来匹配位置,前者表示“单词分界位置”,也就是说一侧是单词字母,另一側不是单词字母的位置,而后者表示“非单词分界位置”,因此『\bbot\b』只能匹配单独出现的单词“bot”,绝对不会匹配单词“ro
bot”。在某些系统中,这几个元字符会受到具体规定的限制,例如『\w』或许也能匹配希腊字符,『\d』或许也能匹配罗马数字,使用时应参阅具体的文档。
模式修饰符(mode-modifier)
模式(mode)表示正则表达式在匹配时所采取的规则,模式修饰符用来设定模式。
最常见的模式是
Case-Insensitive Mode(简写为i),它表示“不区分大小写”。在默认情况下,CAT只能匹配单词CAT,如果采用不区分大小写的匹配模式,则『CAT』可以匹配“Cat”, “CAT”,“cat”等任意形式的“cat”。
另一个常用的模式是
Dot-match-all Mode(简写为s),它表示“点号通配”。在默认情况下,点号是不能匹配换行符的,此时,如果用『.*?』匹配一个 IMG tag,而这个tag的内容又跨越了两行,那么匹配是不会成功的。但如果使用“点号通配”模式,则点号可以匹配换行符,整个tag得以匹配。
还有个常见的模式是
Free-spacing and Comment Mode(简写为x),它表示忽略正则表达式中的空白字符(必须使用『\s』来表示空白字符,同时容许在正则表达式中加入注释)。
最后介绍的模式是
Multi-line Mode(多行模式,简写为m),又叫
Enhanced Line-anchor Mode(增强的行锚点模式),在默认情况下,『^』和『$』只能匹配字符串的开头和结尾,但在此模式下,『^』和『$』可以匹配字符串当中的行开头/行结束,如果我们需要在包括多行文本的字符串中精确查找满足某条件的一行文本,就可以启用此模式,并使用『^』和『$』精确定位文本的两端。
模式修饰符通常以flag(标志位)的形式指定,例如在Java中使用
Pattern.compile("CAT",
PATTERN.CASE_INSENSITIVE)
在Python中使用
re.compile("CAT", re.
IGNORECASE)
如果需要同时使用多个模式修饰符,可以以逻辑运算符“AND”来连接
Pattern.compile("C(?#
comment1)A(?#
comment2)T",
PATTERN.CASE_INSENSITIVE|PATTERN.COMMENTS)。
模式作用范围(mode-modified-span)
这是与模式修饰符对应的概念。通常,我们需要显式地设定标志位,指定匹配模式,但此时模式是对整个表达式起作用的,无法进行更细的限制。而模式修饰范围则可以精确限定各元素的匹配模式,解决此类问题。
不妨考虑这样的情况,一个正则匹配函数,接收两个参数,第一个参数为Tag的名字,第二个参数为匹配Tag内容中内容的表达式,而且,Tag名可以不分大小写,但内容匹配必须区分大小写。如果我们用一个匹配模式统摄整个表达式,必然无法完成要求,此时必须使用模式修饰范围,限定各个元素所使用的匹配模式。以Java为例,如果第一个参数为
tagNameRegex,第二个参数为
tagContentRegex,那么我们可以这样:
Pattern.compile(
"
(?i)<" +
tagNameRegex + ">" + ">
(?-i)"
+
tagContentRegex
+ "
(?i)tagNameRegex + ">" + ">
(?-i)")
如果传入的
tagNameRegex是“td”,
tagContentRegex是“\d{4}[a-z]3\d{3}”,则编译所用的表达式就是
『(?i) (?-i)\d{4}[a-z]3\d{3}(?i)(?-i)』
其中,我们在“ ”和“”两端分别以『(?i)』和『(?-i)』来开启/关闭不区分大小写的匹配模式,精确限定了此匹配模式的作用范围,而内部的
tagContentRegex则不受影响,仍采用默认的匹配模式,如此,这个表达式能匹配“ 4444azz333 ”,但不能匹配“ 4444AZZ333”,这正是我们需要的。
即使整个表达式采用同一种匹配模式,我们也可以使用模式修饰范围,例如『(?i)CAT』就表示,对整个表达式『CAT』进行不区分大小写的匹配。
各种实现对于模式作用范围的支持和规定有所不同,使用时应参阅具体的文档。
环视(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=...”的格式统一规范,等号两端没有空格,也没有引号),可以在表达式之前添加『(?<=).*?(?=< /B>)』来精确匹配“
...”之中的内容。在这两个例子中,当然也可以使用匹配-括号提取的办法,但使用环视的效率更高,也更切合程序的本意。
环视还可以多个连用,我曾遇到过这样的情形:有站点siteA.com,需要在Apache的配置文件中设定重定向规则,以一个正则表达式匹配除sub1, sub23之外的所有子域名(注意,是匹配所有子域名),首先我想到的是
『^[^.]*(?
但这行不通,因为多数系统都不容许在逆序环视中使用变长表达式(只有.NET容许),所以必须连用多个逆序环视
『^[^.]*(?
这样便可以了。
固化分组(Atomic-grouping)
回溯(back-tracking)是匹配过程中常见的现象,如果用『.*ab』来匹配“123456ab”,『.*』首先会匹配整个字符串,之后轮到『ab』,『.*』需要“依次释放”之前匹配的两个字符,供『ab』匹配,整个表达式才能匹配成功。这样“依次释放”的过程,就叫做回溯。但有时回溯完全是徒劳的,例如我们用『\w+:』来匹配“Subject”。因为字符串中不存在冒号,匹配肯定会失败,但引擎仍然必须依次回溯,最终得出失败的结果,而我们知道,『\w』“释放”的字符,:肯定无法匹配。此时可以使用固化分组,将『\w+』匹配的内容“固定”下来,禁止回溯:
『(?>\w+):』
这样报告匹配失败的速度就提高了许多倍,如果字符串很长,使用固化分组就能节省大量的时间。
固化分组的另一个用途是精确控制匹配,防止不期望的匹配。例如,表达式『<(\w+).*?』,本意是匹配对称的tag之间的文本,但它会错误地匹配“ <link>…”。如果使用固化分组『<((?>\w+)).*?』,就能解决这个问题。
总结
最后,我们来总结一下这篇文章中介绍的概念:
-
元字符是不同于普通文字的字符,表示特殊的含义,一类元字符用来匹配具体的字符(例如点号、『\w』、『\d』之类),它们可以很方便地表示各类字符组,增强表达式的可读性;另一类用来匹配位置,锚定表达式,运用得当的话,不但能增加匹配的准确程度,还能提升匹配的效率;
-
模式修饰符用来规定匹配进行的规则,例如进行不区分大小写的匹配,规定点号可以匹配换行符之类,多个模式修饰符可以同时使用;
-
模式修饰范围用来规定表达式中各个部分所使用的匹配模式,也就是说,一个表达式内的各个部分可以有各自的匹配模式,而不会互相冲突,这样我们能对匹配施加更精确的控制;
-
环视用来判断某个位置的左/右侧的文本是否满足要求,但环视结构中的表达式匹配的文字并不会作为最终的匹配结果,环视也可以用来准确定位;
-
固化分组可以禁止回溯,也是对匹配过程施加控制的一种手段,巧妙利用固化分组,可以大大提高匹配效率,或是进行更准确的匹配。