第3章 括号
3.1 分组
用字符组和量词可以匹配引号字符串,也可以匹配HTML tag,如果需要用正则表达式匹配身份证号码,依靠字符组和量词能不能做到呢?
身份证号码是一个长度为15或18个字符的字符串,如果是15位,则全部由数字组成,首位不能为0;如果是18位,则前17位全部是数字,末位可能是数字,也可能是x。规则非常明确,可以尝试编写正则表达式了。
整个表达式是[1-9]\d{13,16}[0-9x],它的匹配如例3-1所示。
例3-1身份证号码的匹配
idCardRegex = r"^[1-9]\d{13,16}[0-9x]$" re.search(idCardRegex, "110101198001017032") != None # => True re.search(idCardRegex, "1101018001017016") != None # => True re.search(idCardRegex, "11010119800101701x") != None # => True
看来,果然能够匹配各种形式的身份证号码,应该没问题。不过这还不够,这个正则表达式应该保证身份证号码的字符串能够匹配,其他字符串不能够匹配,例3-2展示了非身份证号码的匹配情况。
例3-2身份证号码的错误匹配
re.search(idCardRegex, "1101011980010176") != None # => True re.search(idCardRegex, "110101800101701x") != None # => True
这两个字符串分明不是身份证号码(第一个有16位长,第二个虽然有15位长,但末尾是x),却都匹配了。这是为什么呢?仔细观察所用的正则表达式,会发现两点原因:第一,\d{13,16}表示除去首尾两位,中间的部分长度可能在13~16之间,而不是“长度要么为13,要么为16”;第二,最后的[0-9x]只应该对应18位身份证号码的情况,但是在这个表达式中,它也可以对应到15位身份证号码,而15位身份证号码的末位是不能为x的!
虽然字符串的长度是可变的,但是除去第一位和最后一位,中间部分的长度必须明确指定,只能是13或者16,而不能使用量词{13,16};另外,末尾一位到底是[0-9](也就是\d)还是[0-9x],取决于长度——如果长度是15位,则是\d;如果长度是18位,则是[0-9x]。区分两种情况分别考虑,要更加清楚一些。
看来,只要以15 位号码的匹配为基础,末尾加上可能出现的\d{2}[0-9x]即可。这里的\d{2}[0-9x]必须作为一个整体,或许不出现(15位号码),或许出现(18位号码)。量词?可以表示“不出现,或者出现1次”,正好用在这里。
但是,在正则表达式\d{2}[0-9x]?中,量词?只能限定[0-9x]的出现,而\d{2}?[0-9x]?则更奇怪——即使只出现一个[0-9x],也可以匹配。到底怎样才能把\d{2}[0-9x]作为一个整体呢?
答案是:使用括号(…),把正则表达式改写为[1-9]\d{14}(\d{2}[0-9x])?。上一章提到过,量词限定之前元素的出现,这个元素可能是一个字符,也可能是一个字符组,还可能是一个表达式——如果把一个表达式用括号包围起来,这个元素就是括号里的表达式,括号内的表达式通常被称为“子表达式”。所以,(\d{2}[0-9x])?就表示子表达式\d{2}[0-9x]作为一个整体,或许不出现,或许最多出现一次。从例3-3可以看到,这个表达式确实可以准确匹配身份证号码。
例3-3身份证号码的准确匹配
idCardRegex = r"^[1-9]\d{14}(\d{2}[0-9x])?$" #应该匹配的 re.search(idCardRegex, "110101198001017016") != None # => True re.search(idCardRegex, "1101018001017016") != None # => True re.search(idCardRegex, "11010119800101701x") != None # => True #不应该匹配的 re.search(idCardRegex, "1101011980010176") != None # => False re.search(idCardRegex, "110101800101701x") != None # => False
注:为了方便讲解,我们在正则表达式的两端添加了^和$,它们分别定位到字符串的起始位置和结束位置,这样确保了表达式不会只匹配字符串的某个子串;如果要用表达式来提取数据,应当去掉^和$。下面的例子都遵循这条规则。
括号的这种功能,叫做分组(grouping)。如果用量词限定出现次数的元素不是字符或者字符组,而是几个字符甚至表达式,就应该用括号将它们“分为一组”。比如,希望字符串ab重复出现一次以上,就应该写作(ab)+,此时(ab)成为一个整体,由量词+来限定;如果不用括号而直接写ab+,受+限定的就只有b。例3-4显示了有括号与无括号的表达式的匹配异同。
例3-4用括号改变量词的作用元素
re.search(r"^ab+$", "ab") != None # => True re.search(r"^ab+$", "abb") != None # => True re.search(r"^ab+$", "abab") != None # => True re.search(r"^(ab)+$", "ab") != None # => True re.search(r"^(ab)+$", "abb") != None # => False re.search(r"^(ab)+$", "abab") != None # => True
有了分组,就可以准确表示“长度只能是m或n”。比如在上面匹配身份证号码的例子中,要匹配一个长度为13或者16的数字字符串。常犯的错误是使用表达式\d{13,16},看起来没问题,但长度为14或15的数字字符串同样会匹配。真正准确的做法是:首先匹配长度为13的数字字符串,然后匹配可能出现的长度为3的数字字符串,正则表达式就成了\d{13}(\d{3})?。
分组是非常有用的功能,因为使用正则表达式时经常会遇到并没有直接相连,但确实存在联系的部分,分组可以把这些概念上相关的部分“归拢”到一起,以免割裂,下面来看几个例子。
上一章使用表达式<[^/][^>]*>匹配HTML中的open tag,比如<table>,但是这个表达式会匹配self-closing tag,比如<br />。如果把表达式改为<[^/][^>]*[^/]>,确实可以避免匹配self-closing tag,但是因为两个排除型字符组要匹配两个字符,这个表达式又会放过<u>之类的open tag,仅仅依靠字符组和量词无法配合解决问题,必须用到括号的分组功能。
<[^/][^>]*[^/]>错过的只有一种情况,就是tag name为单个字母的情况。如果tag name不是单个字母,则第一个字母之后,必然会出现这样一个字符串:其中不包含>,结尾的字符并不是/。最后,才是tag结尾的>。像图3-1所示那样,将这几个元素拆开,能看得更清楚点。
图3-1 open tag的准确匹配
所以,需要用一个括号将可选出现的部分分组,再用量词?限定,就可以得到兼顾这两种情况,准确匹配open tag的正则表达式了,程序代码如例3-5所示。
例3-5准确匹配open tag
openTagRegex = r"^<[^/]([^>]*[^/])?>$" re.search(openTagRegex, "<u>") != None # => True re.search(openTagRegex, "<table>") != None # => True re.search(openTagRegex, "<u/>") != None # => False re.search(openTagRegex, "</table>") != None # => False
再看个更复杂的例子。在Web服务中,经常并不希望暴露真正的程序细节,所以用某种模式的URL来掩盖。比如这个URL:/foo/bar_qux.php,看起来是访问一个PHP页面,其实完全不是这样。真正的结构如图3-2所示,foo是模块的名称,bar是控制器的名字,qux则是方法名,三个名字都只能出现小写字母。
图3-2 URL的结构
希望能处理的情况有三种,其他情况都不予考虑。
为编写通用的正则表达式来匹配,许多人是这么总结的。
所以正则表达式就是:/[a-z]+/?[a-z]*_?[a-z]*(\.php)?。
仔细看看这个表达式,无论是/foo,还是/foo/bar.php,抑或是/foo/bar_qux.php,都可以匹配,看起来确实没有问题。
可是,这个表达式中只有/[a-z]+是必须出现的,其他部分都是“不一定出现”——也就是说,其中任意一个或几个部分出现,这个表达式都可以匹配。所以,/foo/_也是可以匹配的,/foo.php也是可以匹配的,如例3-6所示。
例3-6 URL匹配的表达式这里有个下画线
urlPatternRegex = r"^/[a-z]+/?[a-z]*_?[a-z]*(\.php)?$" re.search(urlPatternRegex, "/foo") != None # => True re.search(urlPatternRegex, "/foo/bar.php") != None # => True re.search(urlPatternRegex, "/foo/bar_qux.php") != None # => True re.search(urlPatternRegex, "/foo/_") != None # => True re.search(urlPatternRegex, "/foo.php") != None # => True
之所以会乱套,根源在于有些元素虽然是“不一定出现”的。可是,“不一定出现”的元素之间却是有关联的:“不一定出现”的元素虽然没有直接相连,却是“要么同时出现,要么同时不出现”的关系。这时候就要梳理清楚逻辑关系,用括号的分组功能把各种分支情况归拢到一起。
/foo是必须出现的;之后存在两种可能:/bar.php或者/bar_qux.php。前一种情况中,开头的/、控制器名bar、结尾的.php是必须出现的;在后一种情况中,开头的/、控制器名bar、下画线_、模块名qux、结尾的.php是必须出现的。
仔细观察这两个表达式,会发现它们可以合并:把第二个表达式中多出的部分,继续用分组?,再加上最开头“必须出现”的/foo括号配合量词?表示,塞到第一个表达式中,得到的表达式配合量词,最后得到完整的表达式。
从例3-7可以看到,这个表达式确实杜绝了错误的匹配。
例3-7杜绝了错误匹配的表达式
urlPatternRegex = r"^/[a-z]+(/[a-z]+(_[a-z]+)?\.php)?$" re.search(urlPatternRegex, "/foo") != None # => True re.search(urlPatternRegex, "/foo/bar.php") != None # => True re.search(urlPatternRegex, "/foo/bar_qux.php") != None # => True
re.search(urlPatternRegex, "/foo/_") != None # => False re.search(urlPatternRegex, "/foo.php") != None # => False
关于括号的分组功能,最后来看E-mail地址的匹配: E-mail地址以@分隔为两段,之前的是用户名(username),之后的是主机名(hostname),用户名一般只容许出现数字和字母(现在有些邮件服务商也容许用户名中出现点号等字符了,这种情况复杂些,此处不做考虑),而主机名则是类似mail.google.com、mail.163.com之类的字符串。
用户名的匹配非常简单,其中能出现的字符主要有大写字母[A-Z]、小写字母[a-z]、阿拉伯数字字符[0-9],下画线_、点号.,所以总的字符组就是[A-Za-z0-9_.],又可以简化为[\w.];另一方面,用户名的最大长度是64 个字符,所以匹配用户名的正则表达式就是[\w.]{0,64}。
主机名匹配的情况则要麻烦一些,简单的情况比如somehost.com;复杂的情况则还包括子域名,比如 mail.somehost.net,而且子域名可能不只一级,比如 mail.sub.somehost.net。查阅规范可知,主机名被点号分隔为若干段,叫做域名字段(label),每个域名字段中能出现的字符是字母字符、数字字符和横线字符,长度必须在1~63 之间。下面看几个例子,尝试从中找到主机名的规律。
看来规律是这样的:最后的域名字段是顶级域名,之前的部分可以看作某种模式的重复:该模式由域名字段和点号组成,域名字段在前,点号在后。比如somehost.com就可以这么看:顶级域名是 com,之前是 somehost.;sub.somehost.net就可以这么看:顶级域名是 net,之前是sub.和somehost.。
匹配域名字段的表达式是[-a-zA-Z0-9]{1,63},匹配点号的表达式是\.,使用括号的分组功能,把这两个表达式分为一组,用量词*限定表示“不出现,或出现多次”,就得到匹配主机名的表达式([-a-zA-Z0-9]{1,63}\.)*[-a-zA-Z0-9]{1,63}(因为顶级域名也是一个域名字段,所以即便主机名是localhost,也可以由最后那个匹配域名字段的表达式匹配)。
将匹配用户名的表达式、@符号、匹配主机名的表达式组合起来,就得到了完整的匹配E-mail地址的表达式:[-\w.]{0,64}@([-a-zA-Z0-9]{1,63}\.)*[-a-zA-Z0-9]{1,63},这个表达式的匹配情况如例3-8所示。
例3-8完整匹配E-mail地址的正则表达式
emailRegex = r"^[-\w.]{0,64}@([-a-zA-Z0-9]{1,63}\.)*[-a-zA-Z0-9]{1,63}$" #应该匹配的 re.search(emailRegex, "abc@somehost") != None # => True re.search(emailRegex, "abc@somehost.com") != None # => True re.search(emailRegex, "abc@some-host.com") != None # => True re.search(emailRegex, "123@somehost.info") != None # => True re.search(emailRegex, "abc123@somehost.info") != None # => True re.search(emailRegex, "abc123@sub.somehost.com") != None # => True re.search(emailRegex, "abc123@m-s.sub.somehost.com") != None # => True #不应该匹配的 re.search(emailRegex, "abc@.somehost.com") != None # => False re.search(emailRegex, "a#bc@some-host.commnication") != None # => False
3.2 多选结构
之前用表达式[1-9]\d{14}(\d{2}[0-9x])?匹配身份证号,思路是把18位号码多出的3位“合并”到匹配15位号码的表达式中。能不能直接分情况处理呢?15 位身份证号就是[1-9]开头,之后是14 位数字;18 位身份证号就是[1-9]开头,之后是16 位数字,最后是[0-9x]?。只要两个表达式中的一个能够匹配,就是合法的身份证号,这样的思路更加清晰。
答案是可以的,而且仍然使用括号解决问题,只是要用到括号的另一个功能:多选结构(alternative)。
多选结构的形式是(…|…),在括号内以竖线|分隔开多个子表达式,这些子表达式也叫多选分支(option);在一个多选结构内,多选分支的数目没有限制。在匹配时,整个多选结构被视为单个元素,只要其中某个子表达式能够匹配,整个多选结构的匹配就成功;如果所有子表达式都不能匹配,则整个多选结构匹配失败。
回到身份证号码匹配的例子,既然可以区分15位和18位两种情况,就可以将每种情况对应的表达式作为一个分支,使用多选结构([1-9]\d{14}|[1-9]\d{14}\d{2}[0-9x])。这个表达式的匹配如例3-9所示,它同样可以准确验证身份证号码。
例3-9用多选结构匹配身份证号码
idCardRegex = r"^([1-9]\d{14}|[1-9]\d{14}\d{2}[0-9x])$" #应该匹配的 re.search(idCardRegex, "110101198001017016") != None # => True
re.search(idCardRegex, "1101018001017016") != None # => True re.search(idCardRegex, "11010119800101701x") != None # => True #不应该匹配的 re.search(idCardRegex, "1101011980010176") != None # => False re.search(idCardRegex, "110101800101701x") != None # => False
多选结构在实际中经常用到,匹配IP地址就是如此:IP地址(暂不考虑IPv6)分为四段(四个字节),每段都是八位二进制数,换算成常见的十进制,取值在0~255之间,中间以点号.分隔。点号.的匹配非常容易,用\.就可以,所以暂且忽略它,只考虑匹配这个数值的问题,而且因为4段IP地址的取值范围是相同的,只考虑其中一段的匹配即可。
要匹配十进制形式的IP地址,最常见的正则表达式就是[0-9]{1,3},也就是1~3位十进制数字。粗看起来,这个表达式没什么错,细看却有很大问题。因为256、999这样的数值,显然不在0~255之间,却可以由[0-9]{1,3}匹配。
细致一点的表达式似乎是[0-2][0-5][0-5],这样就限制了数值只能是在255以内……不过,仔细想想,因为限定了第二位(十位)和第三位(个位)都只能出现0~5之间的字符,表达式没法匹配168之类的数值。
其实,问题可以这样解决:先用表达式匹配这个字符串,再将它转换为整数类型的变x,判断x是否在0和255之间:0<=x && x<=255。没错,这确实是一个解决问题的思路,只是麻烦一点,最好能用正则表达式“一次性”搞定这个问题。仔细想想就会发现,正则表达式虽然直接表示“匹配一段数值在0~255之间的文本”,但可以分几种情况描述符合这样规则的文本。
虽然不如 0<=x && x<=255 的判断简便,但如果文本符合其中任何一条规则(或者说,只要其中任何一个正则表达式能匹配),就可以判断它“表示数字的数值在0~255之间”。用多选结构把这几条规则对应的表达式合并起来,就得到了表达式 ([0-9]|[0-9]{2}|1[0-9][0-9]|2 [0-4][0-9]|25[0-5]),它的匹配如例3-10所示。
例3-10准确匹配0~255之间的字符串
partRegex = r"^([0-9]|[0-9]{2}|1[0-9][0-9]|2[0-4][0-9]|25[0-5])$" #应该匹配的
re.search(partRegex, "0") != None # => True re.search(partRegex, "98") != None # => True re.search(partRegex, "168") != None # => True #不应该匹配的 re.search(partRegex, "256") != None # => False
如果要更完善一点,能识别030、005这样的数值,可以修改对应的子表达式,为一位数和两位数的情况增加之前可能出现 0 的匹配,得到表达式((00)?[0-9]|0?[0-9]{2}|1[0-9] [0-9]|2[0-4][0-9]|25[0-5])。
上面讲解的,其实是用正则表达式匹配数值在某个范围内的字符串的通用模式,它很重要,因为许多时候会遇到类似的任务,比如匹配月(1~12)、日(不考虑只有30 天的情况,粗略记为1~31)、小时(0~24)、分钟(00~60)的正则表达式,用正则表达式解决这类问题,会用到同样的模式。
这个模式还可以用于匹配手机号码:大陆的手机号码是11位的,前面3位是号段,到目前为止有130~139 号段、150~153、155~156、180、182、185~189 号段,用多选分支(13[0-9]|15[0-356]|18[025-9])可以很准确地匹配号段;之后的8 位一般没有限制,只要是数字即可,用\d{8}匹配。另外,手机号码最开头可能有 0 或者+86,它可以用(0|\+86)匹配,因为整个部分是可能出现的,所以需要加上量词,也就是(0|\+86)?最后得到的正则表达式就是(0|\+86)?(13[0-9]|15[0-356]|18[025-9])\d{8}。
多选结构还可以解决更复杂的问题,比如上一章的tag匹配问题,当时使用的表达式是<[^>]+>,一般来说,这个表达式是没有问题的,但也有可能tag内部还是会出现>符号,比如<input name=txt value=">">。这类问题使用字符组解决不了,使用多选结构则可以解决。
仔细分析tag中可能出现>它只可能作为属性(attribute)出现在单引号字符串和双引号字符串中,根据html规范,引号字符串中不能出现嵌套转义的引号,所以单引号字符串可以用'[^']*'来匹配,双引号字符串可以用"[^"]*"来匹配,相应的,其他内容则可以用[^'">]来匹配,所以更完善的表达式是<('[^']*'|"[^"]*"|[^'">])+>。它的匹配情况见例3-11。
例3-11准确的HTML tag匹配
tagRegex = r"^<('[^']*'|\"[^\"]*\"|[^'\">])+>$" re.search(tagRegex, "<input name=txt value=\">\">") != None # => True re.search(tagRegex, "<input name=txt value='>'>") != None # => True re.search(tagRegex, "<a>") != None # => True
请注意其中的量词,因为单引号字符串和双引号字符串都可以是空字符串,比如 alt=''或alt="",所以匹配其中文本的内容使用*;而[^'">]则没有使用量词,因为它存在于多选结构内部,多选结构外部有+量词限制,保证了它不只匹配一个字符。如果在多选结构内部使用[^'">]*,虽然看来似乎没错,却可能导致非常奇怪的结果,不过现在不用关心,详细的讲解在第135页。
关于多选结构,最后还要补充三点。
第一,多选结构的一般表示法是(option1|option2)(其中option1和option2是两个作为多选分支的正则表达式),多选结构中一般会同时使用括号()和竖线|;但是如果没有括号(),只出现竖线|,仍然是多选结构。从例3-12可以看到,ab|cd既可以匹配ab,也可以匹配cd。
例3-12没有括号的多选结构
re.search(r"ab|cd", "ab") != None # => True re.search(r"ab|cd", "cd") != None # => True
在多选结构中,竖线|用来分隔多选结构,而括号()用来规定整个多选结构的范围,如果没有出现括号,则将整个表达式视为一个多选结构,所以ab|cd等价于(ab|cd)。如果在某些文档中看到没有括号的多选结构,不用奇怪。
不过,我还是推荐明确写出两端的括号,这样更形象,也能避免一些错误。如果你仔细看,就会发现在上面的表达式中,并没有使用^和$定位字符串的起始位置和结束位置,按道理说,加上之后应该匹配更加准确,结果却并非如此。
因为竖线|的优先级很低(关于优先级,☞106),所以^ab|cd$其实是(^ab|cd$),而不是^(ab|cd)$,它的真正意思是“字符串开头的 ab 或者字符串结尾的 cd”,而不是“只包含 ab或cd的字符串”,代码见例3-13。
例3-13没有括号的多选结构
re.search(r"^ab|cd$", "abc") != None # => True re.search(r"^ab|cd$", "bcd") != None # => True re.search(r"^(ab|cd)$", "abc") != None # => False re.search(r"^(ab|cd)$", "bcd") != None # => False
第二,多选分支并不等于字符组。多选分支看起来类似字符组,如[abc]能匹配的字符串和(a|b|c)一样,[0-9]能匹配的字符串和(0|1|2|3|4|5|6|7|8|9)一样。从理论上说,可以完全用多选结构来替换字符组,但这种做法并不推荐,理由在于:首先,[abc]比(a|b|c)要简洁许多,在多选结构中的每个分支都必须明确写出,不能使用-范围表示法,(0|1|2|3|4|5|6|7|8|9)比[0-9]麻烦很多;其次,大多数情况下,[abc]比(a|b|c)的效率要高很多。所以,能用字符组解决的问题,最好不用等价的多选结构。
反过来,多选结构不一定能对应到字符组。因为字符组的每个“分支”的长度相同,而且只能是单个字符;而多选结构的每个“分支”的长度没有限制,甚至可以是复杂的表达式,比如(abc|b+c*ab),字符组完全无能为力。
多选分支和字符组的另一点重要区别(同时也是最常犯的错误)是:排除型字符组可以表示“无法由某几个字符匹配的字符”,多选结构却没有对应的结构表示“无法由某几个表达式匹配的字符串”。从例3-14可以看到,[^abc]表示“匹配除a、b、c之外的任意字符”,(^a|b|c)却不能表示“匹配除a、b、c之外的任意字符串”。
例3-14多选结构不能表示“无法由某几个表达式匹配的字符串”
re.search(r"(^a|b|c)", "ab") != None # => True re.search(r"(^a|b|c)", "cd") != None # => True
在实际开发中确实可能遇到这种需求,不过没有现场的解法。如果你现在就希望匹配“无法由某几个表达式匹配的字符串”,请参考第140页。
第三,多选分支的排列是有讲究的。比如这个表达式(jeff|jeffrey),用它匹配jeffrey,结果到底是jeff还是jeffrey呢?这个问题并没有标准的答案,本书介绍的Java、.NET、Python、Ruby、JavaScript、PHP中,多选结构都会优先选择最左侧的分支。这一点从例3-15看得很清楚:如果使用字符串是 jeffrey,正则表达式是(jeff|jefferey)还是(Jeffrey|jeff),结果是不一样的(此处仅以Python为例,本书中介绍的其他语言中的结果与此相同)。
例3-15多选结构的匹配顺序
print re.search(r"(jeffrey|jeff)", " jeffrey").group(0) jeffrey print re.search(r"(jeff|jeffrey)", " jeffrey").group(0) jeff
在实际开发中可能会遇到这样的情况:统计一段文本中,“湖南”和“湖南省”分别出现的次数。如果直接查找“湖南”,可能会将“湖南省”中的“湖南”也找出来,如果使用多选结构(湖南省|湖南),就可以一次性找出所有“湖南”和“湖南省”,再按照字符串的长度分别计数,就可以得到两者出现的次数了。
不过,(湖南省|湖南)只是一个针对特殊应用的例子。在平时使用中,如果出现多选结构,应当尽量避免多选分支中存在重复匹配,因为这样会大大增加回溯的计算量。也就是说,应当避免这样的情况:针对多选结构(option1|regex2),某段文本既可以由 option1匹配,也可以由option2匹配。如果出现了这样的多选结构,效率可能会受到极大影响(第160页总结了可能影响效率的几种写法),尤其在受量词限定的多选结构中更是如此:一般人都不会遇到(a|[ab])这类多选结构,但([0-9]|\w)之类则一不留神就会遇到。
3.3 引用分组
括号不仅仅能把有联系的元素归拢起来并分组,还有其他的作用——使用括号之后,正则表达式会保存每个分组真正匹配的文本,等到匹配完成后,通过 group(num)之类的方法“引用”分组在匹配时捕获的内容(这个方法之前已经出现过)。其中,num表示对应括号的编号,括号分组的编号规则是从左向右计数,从1开始。因为“捕获”了文本,所以这种功能叫做捕获分组(capturing group)。对应的,这种括号叫做捕获型括号。
举个例子,我们经常遇到诸如 2010-12-22、2011-01-03这类表示日期的字符串,希望从中提取出年、月、日之类的信息,就可借助捕获分组来实现。正则表达式中,每个捕获分组都有一个编号,具体情况如图3-3所示。
图3-3 分组及编号
一般来说,正则表达式匹配完成之后,都会得到一个表示“匹配结果”的对象,对它调用获取分组的方法,传入分组编号 num,就可以得到对应分组匹配的文本。第1 章介绍过,如果匹配成功,re.search()返回一个MatchObject对象。如果只需要知道“是否能匹配”,判断它是否为None即可;但如果获取了MatchObject对象,也可以通过对应的方法,显示匹配结果的详细信息。使用MatchObject.group(num),就可以引用正则表达式中编号为 num 的分组匹配的文本。从例3-16可以看到,通过引用编号为1、2、3的捕获分组,分别获得了年、月、日的信息。
例3-16引用捕获分组
print re.search(r"(\d{4})-(\d{2})-(\d{2})", "2010-12-22").group(1) 2010 print re.search(r"(\d{4})-(\d{2})-(\d{2})", "2010-12-22").group(2) 12 print re.search(r"(\d{4})-(\d{2})-(\d{2})", "2010-12-22").group(3) 22
前面说过,num的编号从1开始。不过,也有编号为0的分组,它是默认存在的,对应整个表达式匹配的文本。在许多语言中,如果调用group()方法,不给出参数num,默认就等于调用group(0),比如Python就是如此,代码见例3-17。
例3-17默认存在编号为0的分组
print re.search(r"(\d{4})-(\d{2})-(\d{2})", "2010-12-22").group(0) 2010-12-22 print re.search(r"(\d{4})-(\d{2})-(\d{2})", "2010-12-22").group() 2010-12-22
有些正则表达式里可能包含嵌套的括号,比如在上面的例子中,除了能单独提取出年、月、日之外,再给整个表达式加上一重括号,就出现了嵌套括号,这时候括号的编号是怎样的呢?答案很简单:无论括号如何嵌套,分组的编号都是根据开括号出现顺序来计数的;开括号是从左向右数起第多少个开括号,整个括号分组的编号就是多少。图3-4举例说明了这种编号规则,具体的代码见例3-18。
图3-4 分组编号只取决于开括号出现的顺序
例3-18嵌套的括号
nestedGroupingRegex = r"(((\d{4})-(\d{2}))-(\d{2}))" print re.search(nestedGroupingRegex, "2010-12-22").group(0) 2010-12-22 print re.search(nestedGroupingRegex, "2010-12-22").group(1) 2010-12-22 print re.search(nestedGroupingRegex, "2010-12-22").group(2) 2010-12 print re.search(nestedGroupingRegex, "2010-12-22").group(3) 2010 print re.search(nestedGroupingRegex, "2010-12-22").group(4) 12 print re.search(nestedGroupingRegex, "2010-12-22").group(5) 22
上一章用正则表达式<a\s[\s\S]+?</a>提取HTML中的所有的超链接tag,配合括号的分组功能,可以更进一步,依靠引用分组把超链接的地址和文本分别提取出来。通常的超链接tag类似这样:<a href="url">text</a>。其中url是超链接地址,text是文本,为了准确获取这两部分内容,可以把表达式改为<a\s+href="([^"]+)">([^<]+)</a>。
其中给匹配url和text的表达式分别加上括号,就是([^"]+)和([^<]+)(注意其中<a之后是\s+,因为这里需要的是空白字符,而不限定是空格字符,而且可能不止一个字符)。
当然这只是最简单的情况,在等号=两端可能还有空白字符,比如<a href = "url">text</a>,所以正则表达式中的=两端也应该添加\s*,于是得到<a\s+href\s*=\s*"([^"]+)">([^<]+)</a>。
不过,属性既可以用双引号字符串表示,也可以用单引号字符串表示,比如<a href='url'>text</a>;甚至可以不用引号,比如<a href=url>text</a>。为了处理这两种情况,需要继续改造表达式:首尾出现的单引号或者双引号字符用["']?即可匹配;真正的URL,既不能包含单引号,也不能包含双引号,还不能是空白字符,所以可以用[^"'\s]+匹配,而且这部分是需要提取出来的,别忘了它外面的括号。于是得到了最后的表达式<a\s+href\s*=\s*["']?([^"'\s]+)["']?>([^<]+)</a>。
现在表达式已经编写完毕,第一个括号内的表达式用来匹配url,第二个括号内的表达式用来匹配text,所以如果要提取url和text,应该使用编号为1和2的分组。下面仍然以yahoo.com的首页为例来看看结果。需要说明的是,如果使用re.findall(),而且正则表达式中出现了捕获型括号,那么返回数组的每个元素都是数组,其中各个元素对应各个分组的文本,所以直接用下标2访问得到第二个分组对应的文本,不必显式调用group(2),代码见例3-19。
例3-19用分组提取出超链接的详细信息
# yahoo.com的源代码已经保存在htmlSource中 hrefTagRegex = r"<a\s+href\s*=\s*[\"']?([^\"'\s]+)[\"']?>([^<]+)</a>" for hyperlink in re.findall(hrefTagRegex, htmlSource): print hyperlink[2], hyperlink[1] Web http://search.yahoo.com/ Images http://images.search.yahoo.com/images Video http://video.search.yahoo.com/video ……更多结果未列出
类似的,还可以提取出网页标题(<head>)或网页中的图片链接(<img>)的表达式。应当注意的是,匹配<img>时,在<img和src之间可能还有其他内容,比如width=750之类,所以不能仅仅用\s+匹配,而应当添加[^>]*?。在 src=…之后也是同样如此。表3-1 总结了匹配网页标题和图片链接的表达式。
表3-1 提取网页标题和图片链接的正则表达式
应当记住的是,引用分组时,引用的是分组对应括号内的表达式捕获的文本。在这个问题上,正则表达式新手常犯错误。例3-20 仍然是用正则表达式匹配日期字符串,两个表达式能匹配的字符串是完全相同的,引用分组的编号也是相同的,结果却不同。
例3-20新手容易弄错分组的结构
re.search(r"(\d{4})-(\d{2})-(\d{2})", "2010-12-22").group(1) 2010 re.search(r"(\d){4}-(\d{2})-(\d{2})", "2010-12-22").group(1) 0
在第一个表达式中,编号为1的括号是(\d{4}),其中的\d{4}是“匹配四个数字字符”的子表达式。在第二个表达式中,编号为1的括号是(\d),其中的\d是“匹配一个数字字符”的子表达式,因为之后有量词{4},所以整个括号作为单个元素,要重复出现4次,而且编号都是1;于是每重复出现一次,就要更新一次匹配结果。所以在匹配过程中,编号为1 的分组匹配的文本的值,依次是 2、0、1、0,最后的结果是 0。在实际使用时,常常有人忽略了这一细节,得到匪夷所思的匹配结果。
引用分组捕获的文本,不仅仅用于数据提取,也可以用于替换,有时候这么做非常方便。仍然举上面的日期的例子,比如希望将 YYYY-MM-DD格式的日期变为 MM/DD/YYYY,就可以使用正则表达式替换。
在Python语言中进行正则表达式替换的方法是 re.sub(pattern, replacement, string),其中pattern是用来匹配被替换文本的表达式,replacement是要替换成的文本,string是要进行替换操作的字符串,比如re.sub(r"[a-z]", " ", string)就是将string中的每一个小写字母替换为一个空格。程序运行结果如例3-21。
例3-21正则表达式替换
print re.sub(r"[a-z]", " ", "1a2b3c") 1 2 3
在replacement中也可以引用分组,形式是\num,其中的num是对应分组的编号。不过,replacement并不是一个正则表达式,而是一个普通字符串。根据字符串中的转义规定,\t表示制表符,\n表示换行符,\1、\2却不是字符串中的合法转义序列,所以也必须指定replacement为原生字符串(☞93)。例3-22说明了如何通过在replacement中使用了引用分组,转换日期字符串的格式。
例3-22在替换中使用分组
print re.sub(r"(\d{4})-(\d{2})-(\d{2})", r"\2/\3/\1", "2010-12-22") 12/22/2010 print re.sub(r"(\d{4})-(\d{2})-(\d{2})", r"\1年\2年\3日", "2010-12-22") 2010年12月22日
值得注意的是,如果想在replacement中引用整个表达匹配的文本,不能使用\0,即便用原生字符串也不行。因为在字符串中,\0开头的转义序列通常表示用八进制形式表示的字符,\0本身表示ASCII字符编码为0的字符。如果一定要引用整个表达式匹配的文本,则可以稍加变通,给整个表达式加上一对括号,之后用\1来引用,如例3-23。
例3-23在替换中,使用\1替代\0
#ASCII编码为0的字符无法显示 print re.sub("(\\d{4})-(\\d{2})-(\\d{2})", "\\0", "2010-12-22") print re.sub("(\\d{4})-(\\d{2})-(\\d{2})", r"\0", "2010-12-22") print re.sub("((\\d{4})-(\\d{2})-(\\d{2}))", "[\\1]", "2010-12-22") [2010-12-22] print re.sub("((\\d{4})-(\\d{2})-(\\d{2}))", r"[\1]", "2010-12-22") 2010-12-22
3.3.1 反向引用
英文的不少单词中都有重叠出现的字母,比如shoot或beep,如果希望检查某个单词是否包含重叠出现的字母,该怎么办呢?
匹配字母的表达式是[a-z](这里暂时不考虑大写的情况),所以最先想到的往往是用两个字符组[a-z][a-z]来匹配,但这样做并不对,因为重叠出现的字母是不确定的。假设字符串是at,a 可以由第一个[a-z]匹配,t 可以由第二个[a-z]匹配,但是因为前一个[a-z]和后一个[a-z]之间并没有联系,所以[a-z][a-z]其实只能匹配两个小写字母,不关心它们是否相同。
这个问题有点复杂。“重叠出现”的字母,取决于第一个[a-z]在运行时的匹配结果,而不能预先设定。也就是说必须“知道”之前匹配的确切内容:如果前面的[a-z]匹配的是e,后面就只能匹配e;如果前面的[a-z]匹配的是o,后面就只能匹配o。
前面我们看到了引用分组,能引用某个分组内的子表达式匹配的文本,但引用都是在匹配完成后进行的,能不能在正则表达式中引用呢?
答案是可以的,这种功能被称作反向引用(back-reference),它允许在正则表达式内部引用之前的捕获分组匹配的文本(也就是左侧),其形式也是\num,其中 num 表示所引用分组的编号,编号规则与之前介绍的相同。
根据反向引用,查找连续重叠字母的表达式就是([a-z])\1,其中的[a-z]匹配第一个字母,再用括号将匹配分组,然后用\1来反向引用,这个表达式的匹配情况见例3-24。
例3-24用反向引用匹配重复字母
re.search(r"^([a-z])\1$", "aa") != None # => True re.search(r"^([a-z])\1$", "dd") != None # => True re.search(r"^([a-z])\1$", "ac") != None # => False
在日常开发中,我们可能经常需要反向引用来建立前后联系。最常见的例子就是解析HTML代码时匹配tag。之前我们说过,tag包括open tag和close tag,open tag和close tag经常是成对出现的,比如<bold>text</bold>或<h1>title</h1>。
有了反向引用功能,就可以先匹配open tag,再匹配其他内容,直到最近的close tag为止:在匹配open tag时,用一个括号分组匹配tag name的表达式([^>]+);在匹配close tag时,用\1引用之前匹配的tag name,就完成了配对(要注意的是,这里需要用到忽略优先量词*?,否则可能会出现错误匹配,理由在上一章匹配JavaScript代码时讲过)。最后得到的表达式就是<([^>]+)>[\s\S]*?</\1>,这个表达式的匹配如例3-25所示。
例3-25用反向引用匹配成对的tag
pairedTagRegex = r"<([^>]+)>[\s\S]*?</\1>" #应该匹配的 re.search(rpairedTagRegex, "<bold>text</bold>") != None # => True re.search(rpairedTagRegex, "<h1>title</h1>") != None # => True #不应该匹配的 re.search(rpairedTagRegex, "<h1>text</bold>") != None # => False
也有些tag更复杂一点,比如<span class="class1">text</span>,在tag名之后有一个空白字符,然后是其他属性,此时原有的表达式就无法匹配了。为应对这类情况,应当修改表达式让分组1 准确匹配tag name,它可以是数字、小写字母、大写字母,所以将它修改为<([a-zA-Z0-9]+)\s[^>]+>[\s\S]*?<\1>,但满足了\s[^>]+的匹配,就无法应对之前的那些open tag。为了兼容两种情况,必须用括号分组和量词?来限定,改为也就是(\s[^>]+)?,最后的表达式就是<([a-zA-Z0-9]+)(\s[^>]+)?>[\s\S]*?</\1>。具体程序如例3-26。
例3-26用反向引用匹配更复杂的成对tag
pairedTagRegex = r"<([a-zA-Z0-9]+)(\s[^>]+)?>[\s\S]*?</\1>" re.search(pairedTagRegex, "<bold>text</bold>") != None # => True re.search(pairedTagRegex, "<h1>title</h1>") != None # => True re.search(pairedTagRegex, "<span class=\"class1\">text</span>") != None #=> True re.search(pairedTagRegex, "<h1>text</bold>") != None # => False
反向引用还可以用在其他很多地方,比如在处理中文文本时,查找“浩浩荡荡”、“清清白白”之类AABB,或者“如火如荼”、“越快越好”之类AXAY的四字词语。
关于反向引用,还有一点需要强调:反向引用重复的是对应捕获分组匹配的文本,而不是之前的表达式;也就是说,反向引用的是由之前表达式决定的具体文本,而不是符合某种规则的未知文本。这一点,新手常犯错误。
仍然以匹配IP地址为例,前面说过,IP地址分4段(4个字节),匹配其中每一段的表达式是(0{0,2}[0-9]|0?[0-9]{2}|1[0-9][0-9]|2[0-4][0-9]|25[0-5]),之间用点号.分隔,所以匹配完整IP地址的表达式应该是用量词重复这个子表达式,而不是用反向引用重复这个表达式匹配的文本。例3-27 对比了这两个表达式,其中第二个表达式中使用了反向引用,故而要求后面3 段与第1 个字段完全一样,所以它只能匹配 8.8.8.8 之类地址,而不能匹配192.168.0.1之类地址。
例3-27匹配IP地址的正则表达式
#匹配其中一段的表达式 #segment = r"(0{0,2}[0-9]|0?[0-9]{2}|1[0-9][0-9]|2[0-4][0-9]|25[0-5])" #正确的表达式 ipAddressRegex = r"(" + segment + r"\.){3}" + segment #错误的表达式 ipAddressRegex = segment + r"\.\1\.\1\.\1"
3.3.2 各种引用的记法
根据前面的介绍,对分组的引用可能出现在三种场合:在匹配完成后,用 group(num)之类的方法提取数据;在进行正则表达式替换时,用\num引用;在正则表达式内部,用\num引用。
不过,这只是Python语言的规定,事情并不总是如此:group(num)之类的方法,在各种语言中都是差不多的;但是在有些语言中,替换时引用的记法和正则表达式内部引用的记法是不同的。表3-2总结了各种常用语言中的两类记法。
表3-2 各种语言中引用分组的记法
看起来\num和$num差别不大:\1或者$1表示第1个捕获分组,\2或者$2表示第2个捕获分组……不过一般来说,$num要好于\num。原因在于,$0可以准确表示“第0个分组(也就是整个表达式匹配的文本)”,而\0则不行,因为在不少语言的字符串中,\num本身是一个有意义的转义序列,它表示值为num的ASCII字符,所以\0会被解释为“ASCII编码为0的字符”。但是反向引用不存在这个问题,因为不能在正则表达式还没匹配结束时,就用\0引用整个表达式匹配的文本。
但无论是\num还是$num,都有可能遇到二义性的问题:如果出现了\10(或者$10,这里以\num为例),它到底表示第10个捕获分组\10,还是第1个捕获分组\1之后跟着一个字符0?Python的结果见例3-28。
例3-28可能具有二义性的反向引用
print re.sub(r"(\d)", r"\10", "123") Traceback (most recent call last): sre_constants.error: invalid group reference
原来\10会被解释成“第10个捕获分组匹配的文本”,而不是“第1个捕获分组匹配的文本之后加上字符 0”。如果我们就是希望做到后面这步,Python提供了\g<num>表示法,将\10 写成\g<1>0,这样同时也避免了替换时无法使用\0的问题,代码如例3-29。
例3-29使用g<n>消除二义性
print re.sub(r"(\d)", r"\g<1>0", "123") 102030
PHP中也有专门的记法解决这类问题,在替换时可以使用\${num}的写法,准确标注所引用分组的编号,也就是说,\${1}0表示“第1个捕获分组之后加上0”,${10}表示“第10个捕获分组”。而$10,在第10 个捕获分组存在的情况下,表示该捕获分组;否则,被视为空字符串。PHP的代码见例3-30。
例3-30 PHP中的引用
//正则表达式只包含9个捕获分组,将捕获的文本替换为空字符串 echo preg_replace("/^(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)/", "$10", "0123456789"); 9 //正则表达式包含10个捕获分组,将捕获的文本替换为10号分组匹配的9 echo preg_replace("/^(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)/", "$10", "0123456789"); 9 Echo preg_replace("/^(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)/", "${1}0", "0123456789"); 00
注:正则表达式两端的/是分隔符,PHP规定正则表达式两端必须使用分隔符。
Python和PHP的规定明确,所以避免了\num的二义性;其他一些语言却不是如此,根据它们的文档,引用捕获分组只有\num(或者$num)一种记法,这时候\10(其实\11、\21 等都是如此)的二义性问题就无可避免了(实际上,本书中介绍的语言,除了Python和PHP之外都是如此)。
比如Java对\num中的num是这样规定的:如果是一位数,则引用对应的捕获分组;如果是两位数且存在对应捕获分组时,引用对应的捕获分组,如果不存在对应的捕获分组,则引用一位数编号的捕获分组。
也就是说,如果确实存在编号为10的捕获分组,则\10引用此捕获分组匹配的文本;否则,\10表示“第1个捕获分组匹配的文本”和“字符0”。程序的运行结果见例3-31。
例3-31 Java中的引用
//存在10分组 System.out.println("0123456789".replaceAll("^(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)( \\d)(\\d)$", "$10")); 9 //不存在10分组 System.out.println("012345678".replaceAll("^(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)(\\d)(\ \d)$", "$10")); 00
除Java之外,Ruby和JavaScript也采用这种规定,它看起来有点古怪,而且有个问题无法解决:如果存在编号为10的捕获分组,无法用\10表示“编号为1的捕获分组和字符0”,因为此时\10表示的必然是编号为10的捕获分组。
在开发中,尤其是进行文本替换时有时确实会遇到这个问题,在现有的规则下是无解的。好在,一般我们并不会用到太多的捕获分组(包含捕获分组数超过10 个的表达式很少见,也很难理解和维护)。而且,已经有越来越多的语言提供了命名分组,它可以彻底解决这个问题。
3.3.3 命名分组
捕获分组通常用数字编号来标识,但这样有几个问题:数字编号不够直观,虽然规则是“从左向右按照开括号出现的顺序计数”,但括号多了难免混淆;引用时也不够方便,上面已经讲过\10引起混淆的情况。
为解决这类问题,一些语言和工具提供了命名分组(named grouping),可以将它看做另一种捕获分组,但是标识是容易记忆和辨别的名字,而不是数字编号。
命名分组的记法也并不复杂。在Python中用(?P<name>…)来分组的,其中的name是赋予这个分组的名字,regex则是分组内的正则表达式。这样,匹配年月日的正则表达式中,可以给年、月、日的分组分别命名,再用group(name)来获得对应分组匹配的文本。图3-5说明了命名分组的结构,具体的代码见例3-32。
图3-5 命名分组
例3-32命名分组捕获
namedRegex = r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})" result = re.search(namedRegex, "2010-12-22") print result.group("year") 2010 print result.group("month") 12 print result.group("day") 22
因为数字编号分组的历史更长,为保证向后兼容性,即便使用了命名分组,每个命名分组同时也具有数字编号,其编号规则没有变化。从例3-33可以看到,在全部使用命名分组的情况下,仍然可以使用数字编号来引用分组。
例3-33命名分组捕获时仍然保留了数字编号
namedRegex = r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})" result = re.search(namedRegex, "2010-12-22")
print result.group(1) 2010 print result.group(2) 12 print result.group(3) 22
在Python中,如果使用了命名分组,在表达式中反向引用时,必须使用(?P=name)的记法;而要进行正则表达式替换,则需要写作\g<name>,其中的name是分组的名字。代码见例3-34。
例3-34 命名分组的引用方法
re.search(r"^(?P<char>[a-z])(?P=char)$", "aa") != None # => True re.sub("(?P<digit>\d)", r"\g<digit>0", "123"); 102030
值得注意的是,命名分组不是目前通行的功能,不同语言的记法也不同,表3-3总结了目前常见的用法。
表3-3 不同语言中命名分组的记法
注1:Java 5和Java 6都不支持命名分组,根据目前看到的JRE的文档,Java 7开始支持命名分组,其记法与.NET相同。
注2:Ruby 1.9以上版本才支持使用命名分组。
3.4 非捕获分组
目前为止,总共介绍了括号的三种用途:分组,将相关的元素归拢到一起,构成单个元素;多选结构,规定可能出现的多个子表达式;引用分组,将子表达式匹配的文本存储起来,供之后引用。
1在PHP 5.2.2以后可以使用\k<name>或者\k'name',在PHP 5.2.4之后可以使用\k{name}和\g{name}。
这三种用途并不是彼此独立的,而是互相重叠的:单纯的分组可以视为“只包含一个多选分支的多选结构”;整个多选结构也会被视为单个元素,可以由单个量词限定。最重要的是,无论是否需要引用分组,只要出现了括号,正则表达式在匹配时就会把括号内的子表达式存储起来,提供引用。如果并不需要引用,保存这些信息无疑会影响正则表达式的性能;如果表达式比较复杂,要处理的文本又很多,更可能严重影响性能。
为解决这种问题,正则表达式提供了非捕获分组(non-capturing group),非捕获分组类似普通的捕获分组,只是在开括号后紧跟一个问号和冒号(?:…),这样的括号叫做非捕获型括号,它只能限定量词的作用范围,不捕获任何文本。在引用分组时,分组的编号同样会按开括号出现的顺序从左到右递增,只是必须以捕获分组为准,非捕获分组会略过,如例3-35所示。
例3-35 非捕获分组的使用
print re.search(r"(\d{4})-(\d{2})-(\d{2})", "2010-12-22").group(2) 12 print re.search(r"(?:\d{4})-(\d{2})-(\d{2})", "2010-12-22").group(1) 12
非捕获分组不需要保存匹配的文本,整个表达式的效率也因此提高,但是看起来不如捕获分组美观,所以很多人不习惯这种记法。不过,如果只需要使用括号的分组或者多选结构的功能,而没有用到引用分组,则应当尽量使用非捕获型括号。
如果不习惯这种记法,比较好的办法是,在写正则表达式时统一使用捕获分组,确保正确之后,再把不需要引用的分组修改为非捕获分组——当然,引用分组的编号可能也要调整(上例中,只需要取月份信息,把第一个分组改为非捕获分组之后,取月份信息对应分组的编号从2变为1)。
在本书中,为了使代码简洁和易于,除非特殊标注,否则不管匹配完成之后是否会引用文本,都使用捕获分组。
3.5 补充
3.5.1 转义
之前讲到,如果元字符是单个出现的,直接添加反斜线字符转义即可转义,所以*、+、?的转义形式分别是\*、\+、\?。如果元字符是成对出现的,则有可能只对第一个字符转义,比如{6}和[a-z]的转义分别是\{6}和\[a-z]。
括号的转义与它们都不同,与括号有关的所有三个元字符(、)、|都必须转义。因为括号非常重要,所以无论是开括号还是闭括号,只要出现,正则表达式就会尝试寻找整个括号,如果只转义了开括号而没有转义闭括号,一般会报告“括号不匹配”的错误。另一方面,多选结构中的|也必须转义(多选结构可以不用括号只出现|),所以,也不要忘记对|的转义;否则就可能出现例3-36的问题。
例3-36括号的转义
re.search(r"^\(a\)$", "(a)") != None # => True re.search(r"^\(a\)$", "(a)") != None # => True re.search(r"^\(a)$", "(a)") != None # => True Traceback (most recent call last): error: unbalanced parenthesis #未转义| re.search(r"^\(a|b\)$", "(a|b)") != None # => False #同时转义了| re.search(r"^\(a\|b\)$", "(a|b)") != None # => True
3.5.2 URL Rewrite
提到括号的分组和引用功能,就不能不提到URL Rewrite。URL Rewrite是常见Web服务器中都具备(也必须)的功能,它用来进行网址的转发,下面是一个转发的例子。
外部访问URL
http://www.example.com/blog/2006/12
内部实现
http://www.example.com/blog/posts.php?year=2006&month=12
这样的好处是隔离了外部接口和内部实现,方便修改;也有利于提供更有意义、更直观的URL。
一般来说,URL Rewrite都是使用转发规则实现的,每条转发规则对应一类URL,以正则表达式解析并提取出所需要的信息,重组之后再转发。比如上面的转发,就需要先提取年、月、日的信息进行重组。很自然地,我们会想到使用括号和引用分组的功能来实现。下面就以刚才提到的日期转发为例,看上面的转发规则在当前主流的Web服务器中如何配置。
Microsoft IIS
在Web.config配置文件中,找到<rewrite>节点,在<rules>下新增下面的代码。
<rule name="Rewrite Rule"> <match url="^blog/([0-9]{4})/([0-9]{2)/?$" /> <action type="Rewrite" url="blog/posts.php?year={R:1}&month={R:2}" /> </rule>
其中<match>节点中的url是外部访问的URL。对转发的URL而言,能接收的都是path部分,如果URL是 http://www.example.com/blog/2006/12,则path就是 blog/2006/12。正则表达式以^blog开头,分别用[0-9]{4}、[0-9]{2}匹配其中的年、月信息,因为之后的转发需要用到这些信息,所以必须使用捕获分组以便引用。另外,因为URL最后可能出现反斜线/,也可能不出现,意义没有区别,所以使用了量词/?。
Action节点中的url则是转发之后(也就是内部使用)的URL,转发到blog/posts.php,且将年、月信息作为请求参数,附在后面。在IIS中,通过{R:num}的记法引用分组,其中num为对应分组的编号;另外,因为这是一个XML文件,所有的&必须转义为&,URL中的&也不例外。
关于IIS中URL Rewrite的具体信息,可以参考下面的详细文档。
http://learn.iis.net/page.aspx/496/iis-url-rewriting-and-aspnet-routing/
Apache
在httpd.conf配置文件中,找到虚拟主机对应的配置字段,首先确认启用了URL Rewrite功能,也就是保证出现了下面这行:
RewriteEngine on
然后编写规则,上面的转义对应的规则如下:
RewriteRule ^blog/([0-9]{4})/([0-9]{2})/?$ blog/posts.php?year=$1&month=$2 [L]
以RewriteRule开头的行指定了转发规则,RewriteRule之后是外部URL和转发的URL,最后是可选出现的标志位(flags,[L]表示“如果URL匹配成功,按本条规则转发之后,不再考虑其他转发规则”),这几个字段之间用任意空白字符分隔。在Apache中,分组的引用使用$num的形式,其中num为分组对应的编号。
关于Apache中URL Rewrite的具体信息,可以参考下面的详细文档。
Apache 2.x版:http://httpd.apache.org/docs/2.0/misc/rewriteguide.html
Apache 1.3版:http://httpd.apache.org/docs/1.3/mod/mod_rewrite.html
Nginx
在Nginx.conf配置文件中找到对应虚拟主机的配置字段,在其中添加下面的规则。
rewrite ^blog/([0-9]{4})/([0-9]{2})/?$ blog/posts.php?year=$1&month=$2 last;
以 rewrite开头的行指定了转发规则,rewrite之后是外部URL和转发的URL,最后是可选出现的标志位(flags,last的含义与Apache转发规则中的[L]相同),这几个字段之间也是用任意空白字符分隔(要注意,行的末尾必须有分号;)。在Nginx中,使用$num的记法引用分组,其中num为分组对应的编号。
相对来说,Nginx的转发功能最为强大,因为Apache和IIS的转发一般都只限于单条语句,但是Nginx的转发可以使用复杂的判断逻辑,比如下面的转发首先判断浏览器的user-agent,如果是IE则转发,否则不转发。
if ($http_user_agent ~ MSIE) { rewrite ^blog/([0-9]{4})/([0-9]{2})/?$ blog/posts.php?year=$1&month=$2 last; }
关于Nginx中URL Rewrite的具体信息,可以参考下面的详细文档。
http://wiki.nginx.org/HttpRewriteModule
3.5.3 一个例子
这部分内容来自一位朋友的问题,这个问题相当有迷惑性和代表性,所以不妨列在这里,希望能解开更多读者的类似疑惑。
问题是这样的:运行re.findall('(\w+\.?)+', 'aaa.bbb.ccc'),期望得到序列aaa.、bbb.、ccc,实际运行的结果却只有ccc,这是为什么呢?
其实答案很简单——因为表达式(\w+\.?)+中存在量词+,所以整个正则表达式的匹配过程中,括号内的\w+\.?会多次匹配:第1次匹配aaa.,第2次匹配bbb.,第3次(也就是最后)匹配ccc,最终这个捕获分组匹配的文本就是ccc。调用re.findall()时,因为存在括号(也就是捕获分组),默认返回捕获分组匹配的文本,也就是ccc。
解答了这个问题之后,他继续问:如果字符串是aaa.bbb,或者aaa.bbb.ccc.ddd,如何能用一个表达式,逐个拆分出aaa.、bbb.之类的子串呢?(请注意,子串的个数是变化的,并且不能预先知道。)
要解答这个问题,需要记住:捕获分组的个数是不能动态变化的——单个正则表达式里有多少个捕获分组,一次匹配成功之后,结果中就必然存在多少个对应的元素(捕获分组匹配的文本),如果不能预先规定匹配结果中元素的个数,就不能使用捕获分组。如果要匹配数目不定的多段文本,必须通过重复多次匹配完成。具体到这个例子,在 re.findall('\w+\.?', 'aaa.bbb.ccc')中,整个正则表达式会匹配成功3次,得到3个子串;如果把字符串改为aaa.bbb.ccc.ddd,则整个正则表达式会匹配成功4次,得到4个子串。