正则表达式的五个成功习惯
正则表达式难于书写、难于阅读、难于维护,经常错误匹配意料不到的文本或者错过了有效的文本,这些问题都是由正则表达式的表现和能力引起的。每个元字符(metacharacter)的能力和细微差别组合在一起,使得代码不借助于智力技巧就无法解释。& } _ r' J. C- |, C
许多包含一定特性的工具使阅读和编写正则表达式变得容易了,但是它们又很不符合习惯。对于很多程序员来说,书写正则表达式就是一种魔法艺术。他们坚持自己所知道的特征并持有绝对乐观的态度。如果你愿意采用本文所探讨的五个习惯,你将可以让你设计的正则表达式经受的住反复试验。
8 s# G+ ?# `( V( ^7 V+ p 本文将使用Perl、PHP和Python语言作为代码示例,但是本文的建议几乎适用于任何替换表达式(regex)的执行。
5 a2 i5 _' P1 P2 i1 g V- p: x# c# a3 T/ b! V! z8 n" }: R
一、使用空格和注释 s- |9 i2 y( a0 i8 Z
对于大部分程序员来说,在一个正则表达式环境里使用空格和缩进排列都不成问题,如果他们没有这么做一定会被同行甚至外行人士看笑话。几乎每个人都知道把代码挤在一行会难于阅读、书写和维护。对于正则表达式又有什么不同呢?
0 H7 u8 w `( q! @3 {4 S$ @) Y 大部分替换表达式工具都具有扩展的空格特性,这允许程序员把他们的正则表达式扩展为多行,并在每一行结尾加上注释。为什么只有少部分程序员利用这个特性呢?Perl 6的正则表达式默认就是扩展空格的模式。不要再让语言替你默认扩展空格了,自己主动利用吧。
. U" E9 w3 l. I1 a8 \ 记住扩展空格的窍门之一就是让正则表达式引擎忽略扩展空格。这样如果你需要匹配空格,你就不得不明确说明。
9 S5 p7 Z& w2 i) ^/ P 在Perl语言里面,在正则表达式的结尾加上x,这样“m/foo|bar/”变为如下形式: S: F, l- {1 @4 c: b
m/ h8 P7 r& |# {" a4 W+ A
foo8 ?8 E n1 c+ c+ ]% ^8 A
|
: u7 y6 Y @2 a8 `' r8 G+ V bar& I4 E4 M ^2 k- a1 F( K
/x
5 }& V# t4 o( R5 j# B& |: Z4 ~& C& V 在PHP语言里面,在正则表达式的结尾加上x,这样“"/foo|bar/"”变为如下形式:, x. \7 w2 i: T: d
"/0 b5 B* G* J4 r. v
foo$ E3 @( b- d+ q$ Y' S
|
, r4 N7 w; R' J9 [1 T0 x& _ O( F bar
o6 b: V- G% ~! f/x"
, |, ~- z6 ^9 }$ P% p2 S& T 在Python语言里面,传递模式修饰参数“re.VERBOSE”得到编译函数如下:
7 ^5 n* E0 ?! R/ ^pattern = r'''1 m) h# W; R: j: \! ^' }
foo
6 s' `& a0 W5 k5 G2 n2 J+ ]% S|
7 H5 ]% V+ K8 C. p5 y. N! B; n, s1 }bar) n' l; l" l4 N3 l
'''
! u: [$ `2 s. ~, @& mregex = re.compile(pattern, re.VERBOSE)
6 o( Z* Q2 k% P8 j* j6 O( M 处理更加复杂的正则表达式时,空格和注释就更能体现出其重要性。假设下面的正则表达式用于匹配美国的电话号码:
2 X* g, ]% G# z(?d{3})? ?d{3}[-.]d{4}
+ [6 h, Y4 E$ A9 e 这个正则表达式匹配电话号码如“(314)555-4000”的形式,你认为这个正则表达式是否匹配“314-555-4000”或者“555- 4000”呢?答案是两种都不匹配。写上这么一行代码隐蔽了缺点和设计结果本身,电话区号是需要的,但是正则表达式在区号和前缀之间缺少一个分隔符号的说明。9 g- @8 O3 q/ H& L
把这一行代码分成几行并加上注释将把缺点暴露无疑,修改起来显然更容易一些。! y, y* c& ]0 q* ?* ~" Z! F
在Perl语言里面应该是如下形式:
" v$ o C6 N C8 {) f1 j' r/
1 r5 S* v( M6 _ (? # 可选圆括号& N+ b- I! U" b/ g
d{3} # 必须的电话区号, V- w2 W1 o; n9 k0 Y7 y" u
)? # 可选圆括号+ F6 c/ \' o6 @( ~
[-s.]? # 分隔符号可以是破折号、空格或者句点4 }! d: Q' ?; @* u* E
d{3} # 三位数前缀# Y: j; C$ C6 A) Y( ^ K
[-.] # 另一个分隔符号8 q! E) _# H5 ]# ~6 J0 `
d{4} # 四位数电话号码
- d! ?8 G- R& V7 D# C/x* ?) O6 ^) O8 `- [8 k+ A3 h
改写过的正则表达式现在在电话区号后有一个可选择的分隔符号,这样它应该是匹配“314-555-4000”的,然而电话区号还是必须的。另一个程序员如果需要把电话区号变为可选项则可以迅速看出它现在不是可选的,一个小小的改动就可以解决这个问题。5 \0 `( o3 @8 w. z+ w" o
( t/ `2 [" ]% h z 二、书写测试! `# o- o: V% L8 a% r
一共有三个层次的测试,每一层为你的代码加上一层可靠性。首先,你需要认真想想你需要匹配什么代码以及你是否能够处理错误匹配。其次,你需要利用数据实例来测试正则表达式。最后,你需要正式通过一个测试小组的测试。
6 g- F7 p! B. X' H 决定匹配什么其实就是在匹配错误结果和错过正确结果之间寻求一个平衡点。如果你的正则表达式过于严格,它将会错过一些正确匹配;如果它过于宽松,它将会产生一个错误匹配。一旦某个正则表达式发放到实际代码当中,你可能不会两者都注意到。考虑一下上面电话号码的例子,它将会匹配“800-555-4000 = -5355”。错误的匹配其实很难发现,所以提前规划做好测试是很重要的。/ t1 P _2 f0 `1 Q+ t
还是使用电话号码的例子,如果你在Web表单里面确认一个电话号码,你可能只要满足于任何格式的十位数字。但是,如果你想从大量文本里面分离电话号码,你可能需要很认证的排除不符合要求的错误匹配。
) M/ o* L$ B5 M |, e 在考虑你想匹配的数据的时候,写下一些案例情况。针对案例情况写下一些代码来测试你的正则表达式。任何复杂的正则表达式都最好写个小程序测试一下,可以采用下面的具体形式。
1 ^% e s5 o* Z$ m. W 在Perl语言里面:0 Y! p ?* a F; q2 V6 R
#!/usr/bin/perl: n+ \. ^" ~1 v- x# w* D' g
U9 U4 v* W0 Imy @tests = ( "314-555-4000",; X E5 X8 q, ~* G9 j- E
"800-555-4400",3 e1 ~! s" Q# q% H' ?/ a
"(314)555-4000",( H+ o4 N5 h5 U8 E- F* K
"314.555.4000",9 W+ U& K' p5 u4 x( U6 v3 |
"555-4000",
; i' J# v' _; [- i "aasdklfjklas",) w9 e# I( s) x& m& r `
"1234-123-12345" 5 }7 s7 \+ {- K: I$ y
);
0 y/ [, b" ~0 {" n/ B! K, O( U, b+ N4 F+ }: A; n5 R3 K. U* T1 O8 ^! _3 U2 Y
foreach my $test (@tests) {
. M+ m+ K0 g: `: r4 V3 y/ A if ( $test =~ m/
1 b: D; W7 n* v, f (? # 可选圆括号2 N" w6 p. P D. t9 x% }
d{3} # 必须的电话区号6 ~" P' `3 I6 [, l7 y: B7 e
)? # 可选圆括号" {9 h! z9 r6 N5 ~; M/ f
[-s.]? # 分隔符号可以是破折号、空格或者句点5 n1 k3 [3 i$ Y* `
d{3} # 三位数前缀0 p' a7 o& ?4 P, i8 h: y
[-s.] # 另一个分隔符号+ o1 z7 \7 C6 a1 f9 d% W
d{4} # 四位数电话号码. j0 e( F( {+ ?% ~9 x, t6 }$ _
/x ) {: h: Z$ T4 H$ I5 C" l+ f
print "Matched on $test# p1 {& V( y3 ]& K j* [
";
$ B% y J# j2 L. O( S$ \5 p6 G4 o7 ^" y }1 U& a2 @) t- n
else {6 ]. \6 \: C) X$ T; X
print "Failed match on $test
" |* I- i4 A* L+ F7 E% a0 F";
0 k1 d j a" Y/ x }% _$ H1 z$ J, a& S" t' _' }) M
}
! P K! w+ o! C, W- K2 ~! n |4 I$ e0 h+ O3 X
在PHP语言里面:
2 g- w8 f# \0 W" D/ y. X* @<?php; X J8 r: _, k( r6 c/ K" {
$tests = array( "314-555-4000",
; D( r& U' x0 Z& t "800-555-4400",
" s/ z9 Z: `3 Y8 G4 K% } "(314)555-4000",+ B, \5 S, T7 `
"314.555.4000",* j* x; N' f) X& z7 J
"555-4000",
O6 t( |$ `4 a3 T& { "aasdklfjklas",5 l/ \+ X' C J9 K% d/ R! y
"1234-123-12345"; C* h, U3 v$ |# `, y; `7 E
);
& [8 q+ S( k0 M. Y5 k' [; S t8 t
" z, c: n5 s& M) y& B$ g0 G" _$regex = "/( j+ F* v! g8 F8 X& p4 H( W0 s
(? # 可选圆括号+ e. b L# o8 C, o! f7 s! s# |
d{3} # 必须的电话区号
% G8 o( h/ e- W0 I( e )? # 可选圆括号( P: D% _3 ?. c* B# t' L
[-s.]? # 分隔符号可以是破折号、空格或者句点+ c9 k5 X# H! m1 Q
d{3} # 三位数前缀* g, v8 a9 r( \+ X- n
[-s.] # 另一个分隔符号
. [. u% ]+ P% h: J d{4} # 四位数电话号码
+ l) |& G9 P8 e! X /x";0 R' `/ I; X! U6 d1 B
. i2 P, X& a/ \. t2 w8 d9 W+ E7 u* tforeach ($tests as $test) {# L" K/ z9 N1 H" t6 e
if (preg_match($regex, $test)) {
. W, W6 D: Q) Z; {/ C) Q# f+ s8 E echo "Matched on $test<br />;";
2 G0 M S3 z5 N+ [! G6 X6 W& G( O } y$ Z3 D$ [3 C3 L; }
else {9 L* t1 z7 P. K
echo "Failed match on $test<br />;";
1 M7 l3 v3 `* S. k } ~1 f: P8 y q; M
}! a8 ~0 V4 H4 O+ |
?>;
: ?- h9 V9 l1 L+ F+ c" y. C+ H1 l; w9 h( s6 s* v! \! K5 D( X' [
在Python语言里面:
" t( {' h S l ?import re! G( B: v3 v$ e% @8 S; J+ Y
! k* p, r& F# t2 J- F0 A, z
tests = ["314-555-4000",' K* M7 j- r5 x% O
"800-555-4400",; L9 C; u1 V+ e2 Z4 e
"(314)555-4000",& g8 e& R9 }, v; n
"314.555.4000",% @7 D" n5 E4 h1 U) q* _
"555-4000",
3 N2 X7 F1 U5 W9 c" z "aasdklfjklas",
& C! U( ~) h# ^. j "1234-123-12345" 4 e. H: `: u( {: ~( u4 d& n0 k
]
9 ?& p# V/ K# E8 C, \+ n( @4 b' C6 R: u
pattern = r'''
( K4 A% g% |8 Q4 _9 C4 X% E! o(? # 可选圆括号
6 @3 N% O9 ~/ A3 {3 m. \ d{3} # 必须的电话区号
9 v; T, p" {" i )? # 可选圆括号/ {: d6 d5 [* _# x8 Z5 z- D' [
[-s.]? # 分隔符号可以是破折号、空格或者句点
; d) e6 @# q- n( O- u d{3} # 三位数前缀6 i4 q8 a9 w) F+ L( S8 g( F+ V; y
[-s.] # 另一个分隔符号& Y: h- p* ~9 o2 A
d{4} # 四位数电话号码3 c/ C9 \. F* i6 {8 ]- o
'''4 d: H" c3 _, G. r7 `' q4 g0 l
0 W; {' C- G9 d9 ?7 \; Rregex = re.compile( pattern, re.VERBOSE ), [/ I) R3 l7 J0 a' {0 U) i! E' B
1 Q# N# L8 x. ?
for test in tests:
' A* D5 U8 M, E if regex.match(test):' F. C9 Z# G, r* b! e C2 W
print "Matched on", test, "
) @' r% q- r) |5 e: ?" o' u"
% Y6 ]! C0 I, C% B/ Y, ^' ?4 k' X* _ else:) R! I* d- S% \. y3 P* n o* ~
print "Failed match on", test, "+ C. S* w* d# l4 G2 o
"+ ~# y2 U/ A t! E
! h# `0 l' P4 U4 A$ @ 运行测试代码将会发现另一个问题:它匹配“1234-123-12345”。: c; i- K- j' b6 l1 w4 y; C3 C
理论上,你需要整合整个程序所有的测试到一个测试小组里面。即使你现在还没有测试小组,你的正则表达式测试也会是一个小组的良好基础,现在正是开始创建的好机会。即使现在还不是创建的合适时间,你也应该在每次修改以后运行测试一下正则表达式。这里花费一小段时间将会减少你很多麻烦事。
4 f5 T% C1 J' z( `4 M! J' f; ]8 C* L+ H3 \) s! G
三、为交替操作分组
7 o% L3 B4 e. b 交替操作符号(|)的优先级很低,这意味着它经常交替超过程序员所设计的那样。比如,从文本里面抽取Email地址的正则表达式可能如下:
+ b; n) m* X4 ?6 v# ^5 ]* m7 v^CCTo.*)2 I9 D' \+ K) S- ^2 P: R6 |2 ~+ G' F( m
上面的尝试是不正确的,但是这个bug往往不被注意。上面代码的意图是找到“CC:”或者“To:”开始的文本,然后在这一行的后面部分提取Email地址。
% \& ?# n. f4 k0 M! U- N 不幸的是,如果某一行中间出现“To:”,那么这个正则表达式将捕获不到任何以“CC:”开始的一行,而是抽取几个随机的文本。坦白的说,正则表达式匹配 “CC:”开始的一行,但是什么都捕获不到;或者匹配任何包含“To:”的一行,但是把这行的剩余文本都捕获了。通常情况下,这个正则表达式会捕获大量 Email地址,所有没有人会注意这个bug。" B \0 V3 ~ k) x ~) A
如果要符合实际意图,那么你应该加入括号说明清楚,正则表达式如下:
# {* j. l% O/ ](^CC|(To.*))
2 M e! w" J6 D6 o* b) M 如果真正意图是捕获以“CC:”或者“To:”开始的文本行的剩余部分,那么正确的正则表达式如下:8 I7 f9 |( w4 r' g3 p Q
^(CCTo(.*)" y' F. l4 X% g+ @' C
这是一个普遍的不完全匹配的bug,如果你养成为交替操作分组的习惯,你就会避免这个错误。! b) j$ ^% U/ D% H$ H
; A* I4 h. R6 {" V+ U 四、使用宽松数量词9 }* e2 _2 {3 N9 s. a7 K8 V9 g
很多程序员避免使用宽松数量词比如“*?”、“+?”和“??”,即使它们会使这个表达式易于书写和理解。
: n$ W6 z) w7 s `: y0 L 宽松数量词可以尽可能少的匹配文本,这样有助于完全匹配的成功。如果你写了“foo(.*?)bar”,那么数量词将在第一次遇到“bar”时就停止匹配,而不是在最后一次。如果你希望从“foo###bar+++bar”中捕获“###”,这一点就很重要。一个严格数量词将捕获“###bar++ +”。
( y C y3 W) {8 l7 E4 r 假设你要从HTML文件里面捕获所有电话号码,你可能会使用我们上文讨论过的电话号码正则表达式的例子。但是,如果你知道所有电话号码都在一个表格的第一列里面,你可以使用宽松数量词写出更简单的正则表达式:9 G1 s1 S$ u' ^6 H( m2 K1 a
<tr>;<td>;(.+?)<td>;
, Y7 f; h% I( o0 K 很多刚起步的程序员不使用宽松数量词来否定特定种类。他们能写出下面的代码:
- w" Z* P# Q0 }" `8 l5 E! ~$ W' U<tr>;<td>;([^>;]+)</td>;
; p& H/ z0 a. o, {2 T2 T* W 这种情况下它可以正常运行,但是如果你想捕获的文本包含有你分隔的公共字符(这种情况下比如</td>;),这将会带来很大麻烦。如果你使用了宽松数量词,你只要花上很少的时间组装字符种类就能产生新的正则表达式。
K/ y+ R: q/ U) u+ ?$ { 在你知道你要捕获文本的环境结构时,宽松数量词是具有很大价值的。
5 z+ @" k+ d7 |6 H/ w3 Z, h$ d, t+ Y# i/ ~, ~1 z x$ ^: o
五、利用可用分界符 S4 k7 \# q4 `& A
Perl 和PHP语言常常使用左斜线(/)来标志一个正则表达式的开头和结尾,Python语言使用一组引号来标志开头和结尾。如果在Perl和PHP中坚持使用左斜线,你将要避免表达式中的任何斜线;如果在Python中使用引号,你将要避免使用反斜线()。选择不同的分界符或引号可以允许你避免一半的正则表达式。这将使得表达式易于阅读,减少由于忘记避免符号而潜在的bug。
, a9 l- r/ S7 R6 d% R% L Perl和PHP语言允许使用任何非数字和空格字符作为分界符。如果你切换到一个新的分界符,在匹配URL或HTML标志(如“http://”或“<br/>;”)时,你就可以避免漏掉左斜线了。( S/ {" \0 a- g7 t
例如,“/http://(S)*/”可以写为“#http://(S)*#”。
8 T5 l% P' ?: h* t3 N 通用分界符是“#”、“!”和“|”。如果你要使用方括号、尖括号或者花括号,只要保持前后配对出现就可以了。下面就是一些通用分界符的示例:: L% x1 V) [7 N6 G4 h
#…# !…! {…} s|…|…| (Perl only) s[…][…] (Perl only) s<…>;/…/ (Perl only) 3 O0 c' w4 z3 K7 U) p
在Python中,正则表达式首先会被当作一个字符串。如果你使用引号作为分界符,你将漏掉所有反斜线。但是你可以使用“r''”字符串避免这个问题。如果针对“re.VERBOSE”选项使用三个连续单引号,它将允许你包含换行。例如 regex = "(\w+)(\d+)"可以写出下面的形式:& F8 A# S2 i: {1 K1 g2 a
regex = r'''
4 y& \- |) T; q8 }% m (w+)4 W+ P& }2 j2 D7 g
(d+)
6 x/ J4 E0 M. E D6 q9 v, G '''
1 O2 n3 X$ E3 b3 U8 M @ h# \+ F+ _" C( C3 B% \ l# P8 R
小结:本文的建议主要着眼于正则表达式的可读性,在开发中养成这些习惯,你将会更加清晰的考虑设计和表达式的结构,这将有助于减少bug和代码的维护,如果你自己就是这个代码的维护者你将倍感轻松。