正则表达式(“regexes”)即增强查找/字符串替换操作。当在文本编辑器中编辑文字时,正则表达式经常用于:
和文本编辑器一样,绝大多数高级编程语言支持正则表达式。在本文中,“文本”仅仅是一个字符串变量,但是有效的操作却是一致的。某些编程语言(Perl,JavaScript)甚至为正则表达式提供专用的语法。
但是正则表达式是什么?
一个正则表达式仅仅为一个字符串。它没有长度限制,但是通常该字符串很短。下面看几个例子:
I had a \S+ day today
[A-Za-z0-9\-_]{3,16}
\d\d\d\d-\d\d-\d\d
v(\d+)(\.\d+)*
TotalMessages="(.*?)"
<[^<>]>
这个字符串实际上是一个极小的计算程序,并且正则表达式是一门语法小而简洁,领域特定的编程语言。牢记以下几点,它们不该在学习过程中让你感到惊讶:
正则实现一直有着显著的改变。对于本文,我所关注的是那些几乎每个正则表达式都实现了的核心语法。
正则表达式由只代表自身的字面值和代表特定含义的元字符组成。
这里也有一些例子。我会对元字符进行高亮。
I had a \S+ day today
[A-Za-z0-9-_]{3,16}
\d\d\d\d-\d\d-\d\d
v(\d+)(.\d+)*
TotalMessages="(.*?)"
<[^<>]*>
大部分字符,包括字母数字字符,会以字面值的形式出现。这意味着它们查找的是自身。比如,正则表达式cat
代表“先找到c
,接着找到a
,最后找到t
”。
目前为止感觉良好。这的确很像
String.indexOf()
函数 strpos()
函数 提示:除非特别说明,正则表达式是大小写敏感的。然而,绝大多数实现都会提供一个标记来开启不区分大小写的功能。
我们第一个元字符是句号(译者注:句点,英文句号),.
。一个.
表示匹配任何单个字符。下面这个正则表达式c.t
代表“先找到c
,接着找到任何单个字符,再找到t
”。
在一段文本中,这个表达式将会找到cat
,cot
,czt
,甚至字面值为c.t
的字符串(c
,句点,t
),但是不包括ct
或者coot
。
任何元字符如果用一个反斜杆\
进行转义就会变成字面值。所以上述的正则表达式c\.t
就代表“先找到c
,接着找到句号,再找到t
”。
反斜杠是一个元字符,这意味着它也可以使用反斜杠转义。所以正则表达式c\\t
代表“先找到c
,接着找到反斜杆,再找到t
”。
注意! 在一些实现中,.
会匹配任意字符除了 换行符。这意味着“换行符”在不同的实现中也会变化。 要查看你的文档。在这篇文章中, 我会确保.
会匹配任意字符。
在其它情况下, 通常会有一个标记来调整这种行为,那就是`DOTALL`或类似的标记
字符类是字符在方括号中的集合。表示“找到其中任意的字符”。
c[aeiou]t
表示“找到c
后跟一个元音字母,再找到t
”。在一段文本中,将会匹配到cat
,cet
,cit
,cot
和cut
。 [0123456789]
表示找到一个数字 [a]
和a
意义相同:“找到a
” 一些转义的例子:
\[a\]
表示“找到一个左方括号紧跟着一个a
,再跟着一个右方括号”。 [\[\]ab]
表示“匹配一个左方括号或者右方括号或者a
或者 b
”。 [\\\[\]]
表示“匹配一个反斜杆或者一个左方括号或者一个右方括号”。(呕!) 在字符类中顺序和重复字符并不重要。[dabaaabcc]
跟[abcd]
一样。
在字符类内部的“规则”和在字符类内部的规则有所不同。一些字符在字符类内部扮演着元字符的角色,但在字符类外部则充当字面值。还有一些字符做着相反的事。一些字符在两种情形都为元字符,但在各自情形里代表不同的含义。
特别地, .
表示“匹配任意字符”,但是[.]
表示“匹配句点”。不能并为一谈。
你可以在字符类中使用连字符来表示一个字母或数字的区间:
[b-f]
和[bcdef]
都表示“找到一个b
或c
或d
或 e
或f
”。 [A-Z]
和[ABCDEFGHIJKLMNOPQRSTUVWXYZ]
都表示“匹配大写字母”。 [1-9]
和[123456789]
都表示“匹配一个非零数字”。 连字符在字符类外部使用时并没有特别都含义。正则表达式a-z
表示“找到一个a
接着跟着一个连字符,然后匹配一个z
”。
区间和单独的字符可能会共存于同一个字符类:
[0-9.,]
表示“匹配一个数字或者一个句点或者一个逗号”。 [0-9a-fA-F]
表示“匹配一位十六进制数”。 [a-zA-Z0-9\-]
表示“匹配一个字母数字字符或连字符”。 虽然你可以尝试在区间内以非字母数字字符结束(比如abc[!-/]def
),但这在其它实现中的语法不一定对。即使语法正确,但在这个区间内很难看出包含了哪个字符。请谨慎使用(我的意思是不要这么干)。
同样的,区间端点的范围应该一致。即使像[A-z]
这种表达式在你选择的实现中合法,但结果可能不如你愿。(补充:可以有Z
到a
的区间范围)。
注意。 区间是字符的区间,不是数字的区间。正则表达式[1-31]
表示“找到一个1
或一个 2
或一个3
”,不是“找到一个从1
到31
的整数"。
你可以通过在最开始的位置使用插入符号(译者注:^
)来否定一个字符类。
[^a]
表示“匹配除了a
的任意字符”。 [^a-zA-Z0-9]
表示“找到一个非字母也非数字的字符”。 [\^abc]
表示“找到一个插入符或者a
或者b
或者c
”。 [^\^]
表示“找到除了插入符外的任意字符”。(呕!) 正则表达式\d
含义与[0-9]
一致:“匹配一个数字”。(为了匹配一个反斜杆后跟一个d
,可以使用\\d
。)
\w
的含义与[0-9A-Za-z_]
一致:“匹配一个单词字符(译者注:字母或数字或下划线或汉字)”。
\s
表示“匹配任意空白字符(空格,tab,回车或者换行)”。
此外,
\D
同[^0-9]
:“匹配任意非数字的字符”。 \W
同[^0-9A-Za-z_]
:“匹配任意非单词字符(译者注:匹配任意不是字母,数字,下划线,汉字的字符)”。 \S
表示“匹配任意不是空白符的字符”。 这些字符类都很常见,你必须学会。
你可能也注意到了,句点.
本质上是一个包含任意字符的字符类。
许多实现提供了很多额外的字符类或标记,它们通过扩展现有的字符类来覆盖ASCII之外范围的字符。提示:Unicode包含更多的“数字字符”而不仅仅是0
到9
,这一点同样对于“单词”和“空格”也适用。注意你的文档所写。
你可以在一个字面值或者字符类后跟着一个大括号来使用乘法器。
a{1}
同a
,表示“匹配一个a
”。 a{3}
表示“找到一个a
后再跟一个a
,最后找到一个a
”。 a{0}
表示“匹配空字符”。就其本身而言,这似乎没有用处。如果你在任何一段文本中使用该表达式,你会在你刚开始搜索的端点处立即得到一个匹配。即使你的文本为空字符串结果也为真。 a\{2\}
代表“找到一个a
,跟着一个左大括号,接着跟匹配一个2
,然后跟着一个右大括号”。 [{}]
代表“匹配一个左大括号或者一个右大括号”。 注意。 乘法器没有记忆。该正则表达式[abc]{2}
表示“匹配a
或者b
或者c
,接着匹配a
或者b
或者c
。这跟“匹配aa
或ab
或ac
或ba
或bb
或bc
或ca
或cb
或cc
”相同。这跟“匹配aa
或bb
或cc
”含义不同!
乘法器可能会有区间:
x{4,4}
跟x{4}
一样。 colou{0,1}r
表示“匹配colour
或color
。 a{3,5}
表示“匹配aaaaa
或aaaa
或aaa
”。 值得注意的是优先选择更长的匹配,因为乘法器是贪婪的。如果你输入的文本是I had an aaaaawful day
,该正则表达式就会在aaaaawful
中匹配到aaaaa
。不会在第三个a
后就停止匹配。
乘法器是贪婪的,但它不会忽略一个更好的匹配。如果你的输入文本为I had an aaawful daaaaay
,之后这个正则表达式会在第一次的匹配中于aaawful
找到aaa
。只有在你说“给我找到另一个匹配”的时候,它才会继续搜索然后在daaaaay
中找到aaaaa
。
乘法器区间可能是开区间:
a{1,}
表示“在一列中找到一个或多个a”。然而你的乘法器将会是贪婪的。在找到第一个a
后,它将会尽可能匹配到更多的a
。 .{0,}
表示“匹配任何情形”。不管你的输入文本是什么——甚至为空——这个正则表达式都会匹配整个字符串然后返回给你。 ?
代表的含义与{0,1}
相同。比如说,colou?r
表示“匹配colour
或color
”。
*
等于{0,}
。比如说,.*
表示“匹配一切”,跟上面提到的一样。
+
等于{1,}
。比如说,\w+
表示“匹配一个单词”。这里的“单词”是1个或多个“单词字符”的序列,就像_var
或AccountName1
。
这些乘法器都很常见,你必须掌握。还有:
\?\*\+
表示“匹配一个问号,接着找到一个星号,然后跟着一个加号”。 [?*+]
表示“找到一个问号或者一个星号或者一个加号”。 正则表达式".*"
表示“找到一个双引号,接着找到尽可能多的字符,最后再找到一个双引号”。注意一下被.*
匹配的内部字符,很可能包含多个双引号。这通常不是非常有用。
乘法器可通过追加问号来实现惰性。这里对优先顺序进行了反转:
\d{4,5}?
表示“匹配\d\d\d\d
或\d\d\d\d\d
”。其实跟\d{4}
行为一致。 colou??r
就是colou{0,1}?r
,表示“找到color
或colour
”。和colou?r
行为一致。 ".*?"
表示“匹配一个双引号,跟着一个尽可能少的字符,再跟着一个双引号”。这个不像上面两个例子,实际上很有用。 你可以使用管道符号来实现匹配多种选择:
cat|dog
表示“匹配cat
或dog
”。 red|blue|
和red||blue
以及|red|blue
都是同样的意思,“匹配red
或blue
或空字符串”。 a|b|c
跟[abc]
一样。 cat|dog|\|
表示“匹配cat
或dog
或管道符号
”。 [cat|dog]
表示“找到a
或c
或d
或d
或g
或o
或t
或一个管道符号”。 你可以使用圆括号来组合表达式:
(Mon|Tues|Wednes|Thurs|Fri|Satur|Sun)day
。 (\w*)ility
等同于\w*ility
。都表示“找到以ility
结尾的单词”。为什么第一种形式更有用,后面会看到... \(\)
表示“匹配一个左圆括号后,再匹配一个右圆括号”。 [()]
表示“匹配一个左圆括号或一个右圆括号”。 (red|blue|)
表示“匹配red
或blue
或空字符串
”。 abc()def
等同于abcdef
可能你会在组合中使用乘法器:
(red|blue)?
等同于(red|blue|)
。 \w+(\s+\w+)*
代表“找到一个或多个单词,它们以空格隔开”。 单词边界是一个单词字符和非单词字符之间的位置。记住,一个单词字符是\w
,它是[0-9A-Za-z_]
,一个非单词字符是\W
,也就是[^0-9A-Za-z_]
。
文本的开头和结尾总是当作单词边界。
输入的文本it's a cat
有八个单词边界。如果我们在cat
后追加一个空格,这里就会有九个单词边界。
\b
表示“匹配一个单词边界”。 \b\w\w\w\b
表示“匹配一个三个字母的单词”。 a\ba
表示“找到a
,跟着一个单词边界,接着找到b
”。不管输入文本是什么,这个正则表达式永远都不会成功找到一个匹配。 单词边界不是字符。它们宽度为零.下面的正则表达式表示相同的含义:
(\bcat)\b
(\bcat\b)
\b(cat)\b
\b(cat\b)
每一块文本会分解成一个或多个行,用换行符分隔,像这样:
注意文本不是以换行符结束,而是以行结束。然而,任何行,包括最后一行,可以包含零个字符。
起始行位置是在一个换行符和下一行的第一个字符之间。与单词边界一样,在文本的开头也算作一个起始的行。
结束行位置是在行的最后一个字符和换行符之间。与单词边界一样,文本结束也算作行结束。
所以我们都细分为:
在此基础上,有:
^
表示“匹配开始行”。 $
表示“匹配结束行”。 ^$
表示“匹配空行”。 ^.*$
将会匹配整个文本,因为换行符是一个字符,所以.
会匹配它。为了匹配单行,要使用惰性乘法器,^.*?$
。 \^\$
表示“匹配尖符号后跟着一个美元符号”。 [$]
表示“匹配一个美元符”。然而,[^]
是非法单正则表达式。要记住的是尖符号在方括号中时有不同的特殊含义。把尖符号放在字符类中,这么用[\^]
。 像单词边界一样,行边界也不是字符。它们宽度为零。下面的正则表达式表示相同的含义:
(^cat)$
(^cat$)
^(cat)$
^(cat$)
很多实现提供一个标记,通过改变它来改变^
和$
的含义。从“行开始”和“行结束”变成“文本开始”和“文本结束”。
其它的一些实现提供单独的元字符\A
和\z
来达到这个目的。
这里就是正则表达式开始变得异常强大的地方。
你已经知道,括号是用来表示组。它们也可以用来捕获子串。如果正则表达式是一个很小的电脑程序,这个捕获组就是它的输出(的一部分)。
正则表达式(\w*)ility
表示“找到一个以ility
结束的单词”。捕获组1就是匹配了部分内容的\w*
。举个例子,如果我们的文本包含单词accessibility
,捕获组1就是accessib
。如果我们的文本自身只包含ility
,捕获组1就是空字符串。
你可以拥有多个捕获组,它们甚至可以嵌套使用。捕获组从左到右进行编号。只要计算左圆括号。
假设我们到正则表达式是(\w+) had a ((\w+) \w+)
。如果我们的输入文本是I had a nice day
,那么
I
。 nice day
。 nice
。 在一些实现中,你可能可以访问捕获组0,即完整匹配:I had a nice day
。
是的,这确实意味着圆括号有些重复。一些实现就提供了一个独立语法来声明“非捕获组”,但是这个语法不符合标准,所以这里我们不涉及。
从一个成功返回的匹配中捕获组数量总是等于原来正则表达式中捕获组的数量。记住这一点,因为它可以帮助你理解一些令人困惑的情形。
正则表达式((cat)|dog)
表示“匹配cat
或dog
”。这里总是存在两组捕获组。如果我们的输入文本是dog
,那么捕获组1是dog
,捕获组2是空字符串,因为另一个选择未被使用。
正则表达式a(\w)*
表示“匹配一个以a
开头的单词”。这里总是只有一个捕获组(译者注:除去捕获组0):
a
,捕获组1是空字符串。 ad
,捕获组1是d
。 avocado
,捕获组1是v
。然而,捕获组0会是整个单词,avocado
。 一旦你用了正则表达式来查找字符串,你可以指定另一个字符串来替换它。第二个字符串时替换表达式。首先,就像:
String.replace()
函数 String.replace()
函数 然而,你可以在你的替换表达式中引用捕获组。这是你可以在替换表达式唯一能的特殊的事,它是令人难以置信的强大,因为它意味着你不必完全销毁你刚刚发现的东西。
比方说,你尝试去用ISO 8691格式的日期(YYYY-MM-DD)去替换美式日期(MM/DD/YY)。
(\d\d)/(\d\d)/(\d\d)
开始。注意这里有三个捕获组:月,日和两个数字表示的年。 20\3-\1-\2
。 03/04/05
(表示 3月4号,2005年),那么
03
04
05
2005-03-04
你可以在替换表达式中多次引用捕获组。
([aeiou])
和替换表达式\1\1
来让元音翻倍。 在替换表达式中的反斜杆必须进行转义。举个例子,你有一些在计算机程序的字面值中使用的文本。那就意味着你需要在普通文本中的每个双引号或者反斜杆前放置一个反斜杆。
([\\"])
中,捕获组1是双引号或者反斜杆。 \\\1
中,一个字面值反斜杆后跟着一个匹配的双引号或者反斜杆。 你可以在同样的表达式中引用同一个捕获组。这称为后向引用。
举个例子,再次调用前面的表达式[abc]{2}
表示“匹配aa
或ab
或ac
or ba
或bb
或bc
或ca
或cb
或cc
”。但是表达式([abc])\1
表示“匹配aa
或bb
或cc
”。
一些具体的注意事项:
在一些编程语言中,如Java,对于含有正则表达式的字符串没有提供特别的支持。字符串有自己的转义规则,这些规则与正则表达式的转义规则叠加,通常会导致反斜杆过多(overload)。比如(还是Java):
\d
在源代码中变成String re = "\\d;"
。 "[^"]*"
变成String re = "\"[^\"]*\"";
。 [\\\[\]]
变成String re = "[\\\\\\[\\]]";
。 String re = "\\s";
和String re = "[ \t\r\n]";
是一样的。注意不同的转义“优先级”。 在其它编程语言里,通过一个特殊标记来标识正则表达式,通常是正斜杆/
。这里有一些JavaScript例子:
\d
变成var regExp = /\d/;
。 var regExp = /[\\\[\]]/;
。 var regExp = /\s/;
和var regExp = /[ \t\r\n]/;
一样。 var regExp = /https?:\/\//;
。 基于这一点,我希望你明白为什么我对你反复提及反斜杆。
在文本编辑器中,会在你光标所在处开始搜索。这个编辑器会向前开始搜索文字,然后停在第一个匹配的地方。下一次搜索会在第一次完成搜索的地方的右侧开始。
当编程的时候,文本的偏移量是必须的。这个偏移量会在代码中有明确的支持,或保存在包含文本的对象中(如Perl),或包含正则表达式的对象中(如JavaScirpt)。(在Java里,这是一个由正则表达式和复合对象的字符串。)在任何情况下,默认值为0,表示文本的开始。搜索后,偏移量会自动更新,或者作为输出的一部分返回。
无论什么情况,通常很容易去使用循环来解决这个问题。
注意。正则表达式匹配空字符串是完全可能的。 你可以立马实现的一个简单的例子是a{0}
在这种情况下,新的偏移量等于旧偏移量,从而导致死循环。
一些实现可能保护你避免发生这些情况,但要查下对应的文档。
动态地构造一个正则表达式字符串时一定要小心。如果你使用的字符串不是固定的,那么它可能包含意想不到的元字符。这会导致语法错误。更糟糕的是,它可能产生一个语法正确,但行为不可预期的正则表达式。
有bug的Java代码:
String sep = System.getProperty("file.separator");
String[] directories = filePath.split(sep);
这个bug就是:String.split()
认为sep
是一个正则表达式。但是在Windows下,sep
是由犯斜杆组成的字符串"\\"
.这不是一个语法正确的正则表达式。结果是:一个异常PatternSyntaxException
。
任何一个优秀的编程语言都提供了一种机制,用以转义在一个字符串中出现的所有元字符。在Java中,你可以这么做:
String sep = System.getProperty("file.separator");
String[] directories = filePath.split(Pattern.quote(sep));
把正则表达式字符串编译进一个正在运行的“程序”中是一个代价昂贵的操作。如果你能避免在循环内这么做的话能提高程序性能。
正则表达式能用于用户输入验证。但过于严格的验证会让用户感到难受。下面举几个例子:
支付卡号
我在网页上输入我的卡号如1234 5678 8765 4321
。会被这个站点拒绝。因为它使用\d{16}
来进行验证。
该正则表达式允许出现空格和连字符。
其实,为什么不直接去掉所有非数字字符,然后再进行验证?要做到这一点,使用正则表达式\D
和空字符串来替换表达式。
不要使用正则表达式来验证用户的名字。其实,不需要验证名字,你无能无力。
Falsehoods programmers believe about names提到了:
邮件地址
不要使用正则表达式来验证邮件地址。
首先,这很难保证正确无误。电子邮件地址确实符合一个正则表达式,但是这个表达式长又复杂地让人联想到世界末日。任何缩略都会可能产生遗漏(false negatives)。(你知道吗?电子邮件地址可以包含注释!)
其次,即使所提供的电子邮件地址符合正则表达式,但也并不能证明它的存在。验证电子邮件地址的唯一方法是发送电子邮件给它。
在正式的应用中,不要使用正则表达式来解析HTML或XML。解析HTML/XML是
不妨找一个已有的解析库来为你搞定这些工作。
总结:
a b c d 1 2 3 4
等等。 . [abc] [a-z] \d \w \s
.
表示“任何字符” \d
表示“一个数字” \w
表示“一个单词字符”,[0-9A-Za-z_]
\s
表示“一个空格,tab,回车或一个换行符” [^abc] \D \W \S
{4} {3,16} {1,} ? * +
?
表示“没有或一个” *
表示“没有或多个” +
表示“一个或多个” ?
(Septem|Octo|Novem|Decem)ber
\b ^ $ \A \z
\1 \2 \3
等等。(在替换表达式和匹配表达式中同时生效) . \ [ ] { } ? * + | ( ) ^ $
[ ] \ - ^
\
正则表达式无处不在,令人难以置信的有用。那些在编辑文本和写电脑程序方面将花费大量时间的人们应该学会如何使用它们。到目前为止,我们只接触了冰山一角。