Gong Yong的Blog

一个错误的正则表达式示例

最近突然对bash shell很有兴趣,读了一本书很不错的书:Linux命令行与shell脚本编程。书中有一章讲正则表达式,这一章中有一个使用正则表达式匹配美国电话号码的示例,这个示例并不正确,在此我想尝试纠正一下这个错误。

书中给出了4种合法的电话号码格式:

(210)-123-2345
(210) 123-2345
210-123-2345
210.123.2345

由于书中的示例的应用场景是验证表单中输入的电话号码格式是否正确,所以这个正则表达式必须是全匹配,它给出的匹配正则表达式是:

^\(?[2-9][0-9]{2}\)?(| |-|\.)[0-9]{3}( |-|\.)[0-9]{4}$
我们一眼就可以看出它的问题,虽然它确实可以匹配正确的电话号码,但也会匹配不正确的电话号码,像下面的电话号码:
(210123-2345
201.123-2345
2011232345
201123 2345

书中说电话号码的第二个分隔符可以为空格符,而且所给出的正则表达式中匹配了空格,但在它所给出的正确的电话号码的格式的示例中却没有这种格式,这里我们以书中给出的四种格式为准,也就是说第二个分隔符不能为空格符。

这个正则表达式的问题就是它没有考虑后面的字符的匹配跟前面的字符是否匹配有关系。如果出现了左边的圆括号"(",那么必须出现右边的圆括号")",如果第一个分隔符是中划线"-",那么第二个分隔符也必须为中划线。而且按照上面的正确格式的示例,电话号码的第一部分使用了括号就不能使用点"."作为分隔符。先看一个比较笨的方法,使用多选结构元字符"|",每种格式都写一个正则表达式。

^(\([2-9][0-9]{2}\)( |-)[0-9]{3}-[0-9]{4}|[2-9][0-9]{2}-[0-9]{3}-[0-9]{4}|[2-9][0-9]{2}\.[0-9]{3}\.[0-9]{4})$

这里用了一个大分组,这个分组里面有三个子正则表达,它们可以匹配四种格式的正则表达式,第一个子表达式是:

\([2-9][0-9]{2}\)( |-)[0-9]{3}-[0-9]{4}

它会匹配前两种格式的电话号码,也就是带有括号的电话号码。第二个子表达式是:

[2-9][0-9]{2}-[0-9]{3}-[0-9]{4}

它会匹配第三种没有括号并且使用中划线"-"分隔的电话号码。最后一个子表达式是:

[2-9][0-9]{2}\.[0-9]{3}\.[0-9]{4}

它会匹配第四种没有括号并且使用点"."分隔的电话号码。

如果使用这个正则表达式,那么书中使用gawk进行匹配的程序就应该变成了:
#!/bin/bash
#script to filter out bad phone numbers

gawk --re-interval '/^(\([2-9][0-9]{2}\)( |-)[0-9]{3}-[0-9]{4}|[2-9][0-9]{2}-[0-9]{3}-[0-9]{4}|[2-9][0-9]{2}\.[0-9]{3}\.[0-9]{4})$/{print $0}'

如果你读到这里,可能会想:“WTF,这样就完了?”,当然不是,因为书中的示例是使用gawk来匹配正则表达式的,但是gawk支持的正则表达式的特性有限,对于这个问题这是使用gawk的最简方案。

下面我会使用支持PCRE的正则表达式来匹配,并且使用PHP语言来编写匹配程序,对于gawk等linux工具所支持的正则表达式规范,可以阅读Linux/Unix工具与正则表达式的POSIX规范这篇文章。如果你想更深入的了解不同正则表达式流派的特点,以及正则表达式的发展历史的话,可以阅读那篇文章的作者翻译的精通正则表达式

上面的正则表达式中的后面两个子表达式的模式是一样的,都是使用同一个分隔符将电话号码分隔起来,只是分隔符会不同而已,由于书中的正则表达式使用的是字符组,这肯定是不能保证只匹配同样的分隔符。所以为了保证肯定会匹配同一个分隔符,需要使用反向引用,使用下面的正则表达式:

[2-9][0-9]{2}(-|\.)[0-9]{3}\1[0-9]{4}

我们写一个php程序使用上面的正则表达式专门匹配后面两种格式的电话号码:

<?php
$pattern = '/^[2-9][0-9]{2}(-|\.)[0-9]{3}\1[0-9]{4}$/';

if(preg_match($pattern,$argv[1],$matches)){
var_dump($matches);
};

将这个php程序保存为isphone.php,然后在命令行环境中运行它:

php isphone.php 210-123-1234

得到的输出结果为:

array(2) {
[0]=>
string(12) "210-123-1234"
[1]=>
string(1) "-"
}

如果输入的电话号码为210-123.1234,则不会有结果输出,这表示匹配不成功。

这样我们的正则表达式就变成了:

^(\([2-9][0-9]{2}\)(\ |-)[0-9]{3}-[0-9]{4}|[2-9][0-9]{2}(-|\.)[0-9]{3}\1[0-9]{4})$

现在修改isphone.php中的正则表达式为上面的样子,注意之前使用gawk的时候的"( |-)"变成了"(\ |-)",空格符进行了转义,在命令行中运行下面的命令:

php isphone.php 210-123-1234

没有结果输出,why?

这是因为我们这里用的反向引用"\1"指向的是第一个分组,也就是整个正则表达式的大分组。从左往右数,一共有三次使用了分组元字符"()",其中第三个是"(-|\.)",这个就是需要反向引用的,所以按照出现次序需要将"\1"改成"\3",现在正确的正则表达式变成了下面的样子:

^(\([2-9][0-9]{2}\)(\ |-)[0-9]{3}-[0-9]{4}|[2-9][0-9]{2}(-|\.)[0-9]{3}\3[0-9]{4})$

除了将"\1"改成"\3"外,还有其他的方法解决这个问题,那就是使用非捕获型分组。所谓非捕获型分组就是不能进行反向引用的分组,也就是正则表达式引擎在解析正则表达式的时候不会记录圆括号中匹配的内容,这某种程度上也可以提高正则表达式的解析性能,非捕获型分组的元字符是"(?:)",现在我们的正则表达式就变成了下面的样子:

^(?:\([2-9][0-9]{2}\)(?:\ |-)[0-9]{3}-[0-9]{4}|[2-9][0-9]{2}(-|\.)[0-9]{3}\1[0-9]{4})$

还能不能进一步简化呢?能!不过在这之前我们先要把这个正则表达式的写法弄得好看一点,我们会使用模式修饰符x,使用这个修饰符我们就可以把正则表达式写成多行,而且还可以在正则表达式内部加注释:

/^(?:
\([2-9][0-9]{2}\)(?:\ |-)[0-9]{3}-[0-9]{4}

[2-9][0-9]{2}(-|\.)[0-9]{3}\1[0-9]{4}
)$
/x

这样看起来就舒服多了,下面开始继续探索。

可以看到上面的正则表达式多选结构中的两个子表达式的第一部分的内容基本相似,区别就是有没有括号。如果我们能够根据判断第一个字符是不是左圆括号"("来构建正则表达式,这需要if-then-else的结构。如果第一个字符是左圆括号"(",那么匹配了第一部分的号码后就匹配右圆括号")",刚好PCRE支持这个特性,我们先看看只匹配电话号码第一部分的正则表达式:

/^(?:
(\()?
[2-9][0-9]{2}  
(?(1)\))
)$
/x

上面的正则表达式可以正确匹配"(210)"和"210"这种格式的字符串。" (\()?"会匹配"(", 而"(?(1)\))"会判断前面的匹配是否成功,如果成功则匹配")",如果不成功则什么都不匹配。这只是if-then,如果要使用else只需要使用符号"|",假设我们要匹配"(210)"和"210."这两种格式的字符串,只需要对上面的正则表达式稍作修改就可以:

/^(?:
(\()?
[2-9][0-9]{2}
(?(1)\)|\.)
)$
/x

现在判断"("是否匹配成功的部分变成了" (?(1)\)|\.)",我们用文字来描述下整个过程:

匹配"("(这会用于后面的条件判断,记做(1))
if("(1)"成功匹配了"(")
then
匹配")"
else
匹配"."
end

需要注意的是条件判断语句的"if"的元字符是"(?)",这个圆括号不会被捕获,但是设定匹配条件的地方"(\()?"会被捕获,这个分组的编号是1。现在开始用条件表达式来解决我们的问题吧。

我们可以通过判断第一个字符是否匹配左圆括号来构建正则表达式,下面是最终的结果:

/
^(?:
(\()?
[2-9][0-9]{2}
(?(1)\))
(?(1)(?:\ |-)|(-|\.))
[0-9]{3}
(?(1)-|\2)
[0-9]{4}
)$
/x

重点关注其中的条件表达式,第一个条件表达式表示如果开头的字符是"(",那么就匹配")",这个之前已经讲过了。第二个条件表达式是:

(?(1)(?:\ |-)|(-|\.))

它用到了else分支,这个条件表达式会先判断前面的"("是否匹配,如果成功匹配则匹配空格符" "或者中划线"-",否则匹配中划线"-"或者点"."。

最后一个条件表达式:

(?(1)-|\2)

它也用到了else分支,如果成功匹配"(",则匹配"-",否则匹配第二个捕获分组,也就是第二个条件表达式中的else部分"(-|\.)",由于"(\()?"是第一个捕获分组,所以这里的反向引用用的是"\2"。

最后的PHP代码是:

<?php
$pattern = '/
^(?:
(\()?
[2-9][0-9]{2}
(?(1)\))
(?(1)(?:\ |-)|(-|\.))
[0-9]{3}
(?(1)-|\2)
[0-9]{4}
)$
/x';

if(preg_match($pattern,$argv[1],$matches)){
var_dump($matches);
};

写到这里,我有点怀疑这本书中没有说清楚电话号码的格式,我觉得正确的格式应该电话号码第一部分可以使用圆括号括起来,也可以不用,而中间分隔符可以是空格符" ",中划线"-"和点".",而且两个分隔符必须一样,下面的几组电话号码都是正确的格式:

(210)-123-1234
(210) 123 1234
(210).123.1234
210-123-1234
210 123 1234
210.123.1234

不过即使这样书中给出正则表达式也是错误的,也许我可能把问题理解错了,what ever!通过编写这个正则表达式我们还是可以学到很多东西的,最后把上面列出的电话号码的匹配留给各位吧,enjoy it!