正则指引
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

第3章 括号

3.1 分组

用字符组和量词可以匹配引号字符串,也可以匹配HTML tag,如果需要用正则表达式匹配身份证号码,依靠字符组和量词能不能做到呢?

身份证号码是一个长度为15或18个字符的字符串,如果是15位,则全部由数字组成,首位不能为0;如果是18位,则前17位全部是数字,末位可能是数字,也可能是x一般来说,最后的x可以是小写也可以是大写,但也有些部门规定身份证号码最后的x必须是大写X,这里为讲解方便,只考虑了小写x的情况;如果要兼容大写X或保证只能出现大写X,只需要修改最后的字符组[0-9x]即可。。规则非常明确,可以尝试编写正则表达式了。

整个表达式是[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

有了分组,就可以准确表示“长度只能是mn”。比如在上面匹配身份证号码的例子中,要匹配一个长度为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.commail.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 之间。下面看几个例子,尝试从中找到主机名的规律。

看来规律是这样的:最后的域名字段是顶级域名虽然常见的顶级域名是cn、com、info之类的,最长4个字母,最短2个字母,但情况并非都是如此,在有些内部系统中,主机名中并不包含点号,可以视为顶级域名,所以这里认为顶级域名也是一个普通域名字段。,之前的部分可以看作某种模式的重复:该模式由域名字段和点号组成,域名字段在前,点号在后。比如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位十进制数字。粗看起来,这个表达式没什么错,细看却有很大问题。因为256999这样的数值,显然不在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

如果要更完善一点,能识别030005这样的数值,可以修改对应的子表达式,为一位数和两位数的情况增加之前可能出现 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)(其中option1option2是两个作为多选分支的正则表达式),多选结构中一般会同时使用括号()和竖线|;但是如果没有括号(),只出现竖线|,仍然是多选结构。从例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”,而不是“只包含 abcd的字符串”,代码见例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]表示“匹配除abc之外的任意字符”,(^a|b|c)却不能表示“匹配除abc之外的任意字符串”。

例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-222011-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,所以如果要提取urltext,应该使用编号为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>)的表达式虽然HTML 4.0规范中没有限制tag名的大小写,但XHTML规范要求都使用小写,在平时处理中,tag名也大多使用小写,如果实在要处理大写的情况,可以使用字符组,比如img写作[iI][mM][gG],更省事的办法是指定不区分大小写的模式,☞81。。应当注意的是,匹配<img>时,在<imgsrc之间可能还有其他内容,比如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 的分组匹配的文本的值,依次是 2010,最后的结果是 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]来匹配,但这样做并不对,因为重叠出现的字母是不确定的。假设字符串是ata 可以由第一个[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总结了各种常用语言中的两类记法在很多文档中都表示为\n,但\n容易误解为换行符,所以本书中统一用\num表示。

表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开始支持命名分组可参考http://cr.openjdk.java.net/~sherman/6350801/webrev.00/regex/Pattern.html#groupname。,其记法与.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}&amp;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文件,所有的&必须转义为&amp;,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个子串。