正则表达式提供了一整套描述文本特征的方法,它的匹配其实也就是查找符合所描述特征的文本的过程。

按照元素(单个字符、字符组、多选分支等等)的出现情况,这些特征分为三类,也可称为三种逻辑:必须出现、可能出现、不能出现;具体解释如表 1。 表1  关于元素的三种逻辑
不管正则表达式多么复杂,总是这三种逻辑的组合。比如匹配双引号字符串的任务,可以按三种逻辑分析如下。
再比如,匹配
HTML 中的 open tag (如<h1>)和close tag (如</h1>),按三种逻辑分析如下。
必须出现 “必须出现”是正则表达式中最普通的逻辑关系,它表示某个元素必须出现。通常,这些元素是所要匹配文本的最重要特征:查找 tag ,则必须出现的是字符<和>;查找 E-mail 地址,必须出现的元素是@。 如果某个元素必须出现,通常不会(也不应该)用量词(*、?)来限制,也不出现在多选结构中(如果把普通的字符串也看作正则表达式,那么其中每个字符都是必须出现的)。所以,如果在匹配 tag 的正则表达式中,<和>出现在某个多选结构内,或者之后跟有?、*之类的量词,那么这个表达式多半有问题;同样,在匹配 E-mail 地址的正则表达式中的@字符,也不应该有量词限定,或者出现在多选结构内部。 这条要求看起来简单,但是在日常使用中却容易犯错误。经常遇到的情况是,为了考虑更多的可能情况而修改正则表达式,增加量词和多选结构,最后正则表达式中没有任何必须出现的元素。比如匹配数字字符串的正则表达式,一个数字字符串可能包含三个部分:开头的+或-、整数部分、小数点和小数部分,然后分别列出匹配这三个部分的正则表达式。
单独来看,这三个部分都不是必须出现的:数字字符串可以没有符号,比如3.14;也可以没有整数部分,比如.14;还可以没有小数点和小数部分,比如-3。所以,有些人就把对应的表达式元素加上量词,最终得到[-+]?(\d+)?(\.\d+)?。初看起来,这样并没有错,仔细看看却发现,这个表达式中所有元素都不是必须出现的,换句话说,这个表达式匹配成功时,可以不匹配任何文本! 简单去掉量词并不能解决问题,这样又会错过许多本应该匹配的文本。真正要做的,是理清表达式各个部分的关系,尤其是“可能出现”和“必须出现”之间的关系。
可能出现 与普通字符串处理相比,“可能出现”可以算作正则表达式最明显的特征,也是最常用的逻辑。虽然它看起来很直观,但细说起来,它其实分为两种情况:从元素外来看,元素可能出现也可能不出现,或者出现次数不确定;从元素内来看,元素可能表现为一种形态,也可能表现为另一种形态。 第一种情况需要用量词。在匹配双引号字符串的正则表达式中,两个双引号字符是必须出现的,它们之间的文本可能出现,也可能不出现,如果出现,长度没有限制,所以用*来限制;在匹配 tag 的正则表达式中,<和>之内的 tag 名,必须包括至少一个字符,所以用+来限制。 第二种情况需要使用字符组或多选结构。如果各种可能形态都是单个字符,则使用字符组,比如上一节匹配数字字符串正则表达式中的[-+],它说明“此处可能出现+号,也可能出现-号”。如果某一种可能形态不只是一个字符,比如this或that,则应该使用多选分支(this|that)。 回过头来看数字字符串的匹配,根据上一节的分析,符号部分、整数部分、小数部分(包括小数点)都是可能出现的,但这种“可能”其实是需要细分的。 符号部分的“可能出现”其实是“可能出现也可能不出现的”;整数和小数部分的可能出现情况要复杂一点,需要研究可能出现的几种形态:只出现整数部分;只出现小数部分;整数部分和小数部分都出现。
使用多选结构将这三种形态统一起来,得到(\d+|\.\d+|\d+\.\d+)。最后得到的正则表达式就是[-+]?(\d+|\.\d+|\d+\.\d+)。 有些人会觉得(\d+|\.\d+|\d\.\d+)麻烦,所以会把多选结构的各个分支合并,得到(\d+)?\.?\d+。这两个正则表达式能匹配的文本的确相同,但我并不推荐这样写正则表达式,因为它的逻辑不够清晰:对表达式(\d+)?\.?\d+来说,最后的\d+是必须出现的。如果文本只包含整数部分,比如3,\d+匹配的是整数部分,但如果文本包含小数部分,比如3.14,则\d+匹配的是小数部分的数字。表达式(\d+|\.\d+|\d+\.\d+)虽然麻烦一点,却是一目了然。 如果使用表达式[-+]?(\d+|\.\d+|\d+\.\d+)仍然不够完美,它可能错误匹配-.14。此时,[-+]?中真正匹配的是-,(\d+|\.\d+|\d+\.\d+)中真正匹配的是\.\d+。 要解决这个问题,可以将符号-和+分情况对待:如果是+,则之后的表达式有三种可能:只有整数部分;有整数和小数部分只有小数部分,所以用表达式(\d+|\.\d+|\d+\.\d+)匹配;如果是-,则之后的表达式只有两种可能,不可能出现“只有小数部分”的情况,所以用表达式(\d+|\d+\.\d+)匹配。 综合这些情况,最终得到的表达式就是(+?(\d+|\.\d+|\d+\.\d+) |-?(\d+|\d+\. \d+))。
不能出现 “不能出现”是正则表达式中最难处理的。 最简单的“不能出现”可以直接使用排除型字符组,它表示“此处必须出现一个字符,但不能是某些字符”。比如匹配双引号字符串,首尾两个双引号之间的字符,都不能出现双引号字符,用[^"]表示,上一节说到,这部分长度可以为零,应当使用量词*,所以整个表达式就是"[^"]*"。 再比如匹配 html tag ,在<和>之间“不能出现”>,用[^>]表示,应当使用量词+,所以整个表达式就是<[^>]+>,这个表达式仍然可能错误匹配</>。为解决这个问题,还需要细分两种可能:如果<之后是/,则还必须出现至少一个不为>的字符,比如</a>,应当使用表达式/[^>]+;如果<之后出现的字符不是/,则在这个字符之后,>之前,还可能出现>之外的字符,并且可能不出现,如果出现,长度没有限制,比如在<a>中,在[^>]匹配之后,>之前,就没有任何字符了,所以应当使用表达式[^/][^>]*。 不过,这只是最简单的情况;更复杂的情况是,在某个字符串中不容许出现某个字符串,比如 E-mail 地址中的用户名( username )就是如此。 点号和横线不能出现在开头的情况比较好满足,可以用\w匹配“非点号非横线字符”,既然整个用户名不能超过 64 个字符,那么之后的字符串长度不超过 63 个字符。综合起来,得到[\w][\w.]{0,63}。可是,不容许出现连续两个点号的要求则很难满足,所以下面集中讨论“不超过 63 个字符”部分的匹配。 要求不能出现两个连续点号,许多人的直观反应是[^.][^.],所以整个表达式改为[\w.]{0,63}[^.][^.][\w.] {0,63}。但是这样行不通,原因有两点:首先,在[^.][^.]前后的两个[\w.]{0,63}能匹配的文本,总长度其实在 0~126 之间,无论我们如何修改量词,也不能在两个量词之间建立联系,保证总长度在 0~63 之内;其次也是最重要的,[^.][^.]的真正意思是“找到连续的两个非点号字符”,正则表达式匹配时会尽力满足这一要求,即便是类似123..456这样明显包含两个连续点号的文本,[^.][^.]仍然可以在其中找到四处匹配:12、23、45、56,同时左右两侧的[-\w.]{0,63}仍然可以成功匹配。 还有人想到的是效仿排除型字符组,用(^\.\.)来表示“不能出现两个连续点号”,遗憾的是,这样也行不通,因为正则表达式只有排除型字符组,没有“排除型括号”。 那么,不妨反过来思考:不能出现两个连续点号,意思是这段文本中的所有字符的右侧都不能是两个连续点号,用环视表示就是[-\w.](?!\.\.),这样的字符最多有 63 个。所以,就得到了表达式([-\w.](?!\.\.)){0,63}。请注意,因为[-\w.](?!\.\.)不是单个字符也不是字符组,所以用量词限定时,必须使用括号将整个子表达式分为一组;再在表达式的最开头加上之前提到的\w,保证第一个字符的正确性。从例 1 可以看到,这个表达式完全可以保证字符串不会以点号开头,不会包含连续点号,也不会超出规定长度。  例1  使用环视实现“不能出现”的逻辑
# 注意验证时要在首尾加上 \A 和 \Z usernameRegex = r"\A\w([-\w.](?!\.\.)){0,63}\Z" # 合法的用户名 re.search(usernameRegex, "abc123_") != None  # => True re.search(usernameRegex, "abc1-2.3_") != None  # => True #包含连续点号 re.search(usernameRegex, "abc1-2..3_") != None  # => False #开头字符不合法 re.search(usernameRegex, ".abc1-2.3_") != None  # => False re.search(usernameRegex, "-abc1-2.3") != None      # => False #长度超过限制 re.search(usernameRegex, "0"*65) != None  # => False
  也可以更进一步,把“第一个字符不能是点号或横线”也用环视表达,整个表达式就是(?![-.])([-\w.](?!\.\.)){1,64}。实际上,这个表达式确实更直观、更容易理解,只是注意量词的下限应当改为 1 ,因为用户名不能是空字符串。 总结一下:正则表达式中的“不能匹配”,最简单的情况可以用排除型字符组直接表示,但它只能表示“某个字符不能出现”;如果要表示“某个字符串不能出现”,一般都要用到否定环视,其逻辑是:在文本中的每个位置,都用环视否定“不能出现”的字符串(除此之外,还有另一种逻辑,下面讲解验证操作时会看到)。不过,一些比较古老的工具(比如 Apache 1.3 ,以及 Linux/UNIX 下的某些工具)并不支持否定环视,所以使用时必须留意。 本文节选自《正则指引》一书,余晟著,由电子工业出版社出版。  
Logo

20年前,《新程序员》创刊时,我们的心愿是全面关注程序员成长,中国将拥有新一代世界级的程序员。20年后的今天,我们有了新的使命:助力中国IT技术人成长,成就一亿技术人!

更多推荐