正则表达式
正则表达式
正则是什么,能做什么?
正则,就是正则表达式,英文是 Regular Expression,简称 RE。顾名思义,正则其实就是一种描述文本内容组成规律的表示方式
- 在编程语言中,正则常常用来简化文本处理的逻辑
- 在 Linux 命令中,它也可以帮助我们轻松地查找或编辑文件的内容,甚至实现整个文件夹中所有文件的内容替换
- 在各种文本编辑器中,比如 Atom,Sublime Text 或 VS Code 等,在查找或替换的时候也会使用到它
正则功能:
- 校验数的的有效性,比如校验手机号、邮箱
- 查找符合要求的文本内容,比如查找符合某规则的号码
- 对文本进行切割、替换等操作,比如用连续的空白符切割
基础篇
元字符
元字符的概念
所谓元字符就是指那些在正则表达式中具有特殊意义的专用字符,元字符是构成正则表达式的基本元件。正则就是由一系列的元字符组成的。
正则元字符
特殊单字符
字符 | 说明 |
---|---|
. |
任意字符(换行除外) |
\d | 任意数字 |
\D | 任意非数字 |
\w | 任意字母数字下划线 |
\W | 任意非字母数字下划线 |
\s | 任意空白符 |
\S | 任意非空白符 |
空白符
字符 | 说明 |
---|---|
\r | 回车符 |
\n | 换行符 |
\f | 换页符 |
\t | 制表符 |
\v | 垂直制表符 |
\s | 任意空白符 |
量词
字符 | 说明 |
---|---|
* | 0到多次 |
+ | 1到多次 |
? | 0到1次 |
{m} | 出现m次 |
{m,} | 出现至少m次 |
{m,n} | m到n次 |
范围
字符 | 说明 |
---|---|
| | 或,如ab|cd代表ab或bc |
[…] | 多选一,括号中任意单个元素 |
[a-z] | 匹配a到z之间任意单个元素(按ASCII表,包含a,z) |
[^…] | 取反,不能是括号中的任意单个元素 |
练习题
- 第 1 位固定为数字 1;
- 第 2 位可能是 3,4,5,6,7,8,9;
- 第 3 位到第 11 位我们认为可能是 0-9 任意数字。
量词与贪婪
量词
用{m,n}来表示(*) (+) (?)这3种元字符:
元字符 | 同一表示方法 | 示例 |
---|---|---|
* | {0,} | ab* 可以匹配a或abbb |
+ | {1,} | ab+ 可以匹配ab或abbb,但不能匹配a |
? | {0,1} | (\ +86-)?\d{11}可以匹配+86-13800001111或13800001111 |
1 | import re |
可以看出(+)
和(*)
的区别,(*)
的结果匹配到了四次空字符串。为什么会匹配到空字符串呢?因为星号(*)
代表0到多次,匹配0次就是空字符串。查看上面的结果,会发现疑问,aaaa这部分也是有空字符串的,为什么没匹配上呢?
对于以上问题,就引出贪婪与非贪婪模式,这两种模式都必须满足匹配次数的:
- 贪婪模式,简单说就是尽可能进行最长匹配
- 非贪婪模式呢,则会尽可能进行最短匹配
正是这两种模式产生了不同的匹配结果。
贪婪、非贪婪与独占模式
贪婪匹配(Greedy)
在正则中,表示次数的量词默认是贪婪的,在贪婪模式下,会尝试尽可能最大长度去匹配。
示例:字符串aaabb 中使用正则 a* 的匹配过程
aaabb | |
---|---|
下标 | 012345 |
匹配 | 开始 | 结束 | 说明 | 匹配内容 |
---|---|---|---|---|
第1次 | 0 | 3 | 到第一个字母b发现不满足,输出aaa | aaa |
第2次 | 3 | 3 | 匹配剩下的bb,发现匹配不上,输出空字符串 | 空字符串 |
第3次 | 4 | 4 | 匹配剩下的b,发现匹配不上,输出空字符串 | 空字符串 |
第4次 | 5 | 5 | 匹配剩下的空字符串,输出空字符串 | 空字符串 |
a* 在匹配开头的 a 时,会尝试尽量匹配更多的 a,直到第一个字母 b 不满足要求为止,匹配上三个 a,后面每次匹配时都得到了空字符串。
贪婪模式的特点就是尽可能进行最大长度匹配。所以要不要使用贪婪模式是根据需求场景来定的。如果我们想尽可能最短匹配呢?那就要用到非贪婪匹配模式了。
非贪婪匹配(Lazy)
非贪婪模式会尽可能短地去匹配。那么如何将贪婪模式变成非贪婪模式呢?我们可以在量词后面加上英文的问号 (?),正则就变成了 a*?。
1 | import re |
独占模式(Possessive)
独占模式不会交还已经匹配上的字符。
不管是贪婪模式,还是非贪婪模式,都需要发生回溯才能完成相应的功能。
什么是回溯呢?如下的例子,首先是贪婪模式:
- regex = “xy{1,3}z”
- text = “xyyz”
在匹配时,y{1,3}会尽可能长地去匹配,当匹配完 xyy 后,由于 y 要尽可能匹配最长,即三个,但字符串中后面是个 z 就会导致匹配不上,这时候正则就会向前回溯,吐出当前字符 z,接着用正则中的 z 去匹配。
1 | import re |
匹配不上,回溯(即z会吐出来),在用z去匹配
正则为非贪婪模式:
- regex = “xy{1,3}?z”
- text = “xyyz”
由于 y{1,3}? 代表匹配 1 到 3 个 y,尽可能少地匹配。匹配上一个 y 之后,也就是在匹配上 text 中的 xy 后,正则会使用 z 和 text 中的 xy 后面的 y 比较,发现正则 z 和 y 不匹配,这时正则就会向前回溯,重新查看 y 匹配两个的情况,匹配上正则中的 xyy,然后再用 z 去匹配 text 中的 z,匹配成功。
1 | import re |
正则z匹配不上,回溯,重新尝试匹配两个y的情况
但是在一些场景下,我们不需要回溯,匹配不上返回失败就好了,因此正则中还有另外一种模式,独占模式,它类似贪婪匹配,但匹配过程不会发生回溯,因此在一些场合下性能会更好。
独占模式和贪婪模式很像,独占模式会尽可能多地去匹配,如果匹配失败就结束,不会进行回溯,这样的话就比较节省时间。具体的方法就是在量词后面加上加号(+)。
正则为独占模式:
- regex = “xy{1,3}+z”
- text = “xyyz”
需要注意的是 Python 和 Go 的标准库目前都不支持独占模式,会报错,如下所示:
1 | import re |
报错显示,加号(+)被认为是重复次数的元字符了。如果要测试这个功能,我们可以安装 PyPI 上的 regex 模块。
注意:需要先安装regex模块,python3 -m pip install regex
1 | import regex |
总结
模式 | 正则 | 文本 | 结果 |
---|---|---|---|
贪婪模式 | a{1,3}ab | aaab | 匹配 |
非贪婪模式 | a{1,3}?ab | aaab | 匹配 |
独占模式 | a{1,3}+ab | aaab | 不匹配 |
独占模式性能比较好,可以节约匹配的时间和 CPU 资源,但有些情况下并不能满足需求,要想使用这个模式还要看具体需求,另外还得看你当前使用的语言或库的支持程度
正则中量词默认是贪婪匹配,如果想要进行非贪婪匹配需要在量词后面加上问号。贪婪和非贪婪匹配都可能会进行回溯,独占模式也是进行贪婪匹配,但不进行回溯,因此在一些场景下,可以提高匹配的效率,具体能不能用独占模式需要看使用的编程语言的类库的支持情况,以及独占模式能不能满足需求。
练习题
有一篇英文文章,里面有很多单词,单词和单词之间是用空格隔开的,在引号里面的一到多个单词表示特殊含义,即引号里面的多个单词要看成一个单词。现在你需要提取出文章中所有的单词。我们可以假设文章中除了引号没有其它的标点符号,有什么方法可以解决这个问题呢?如果用正则来解决,你能不能写出一个正则,提取出文章中所有的单词呢(不要求结果去重)?
1 | we found “the little cat” is in the hat, we like “the little cat” |
注意:引号是中文情况下的
1 | 其中 the little cat 需要看成一个单词 |
分组与引用
分组与编号
括号在正则中可以用于分组,被括号括起来的部分“子表达式”会被保存成一个子组。
分组和编号的规则:第几个括号就是第几个分组,如下的例子,这里有个时间格式 2020-05-10 20:23:05。假设我们想要使用正则提取出里面的日期和时间。
正则表达式(\d{4}-\d{2}-\{2})(\d{2}:\d{2}:\{2})
,将日期和时间都括号括起来。这个正则中一共有两个分组,日期是第 1 个,时间是第 2 个。
不保存子组
在括号里面的会保存成子组,但有些情况下,你可能只想用括号将某些部分看成一个整体,后续不用再用它,类似这种情况,在实际使用时,是没必要保存子组的。这时我们可以在括号里面使用?:
不保存子组。
如果正则中出现了括号,那么我们就认为,这个子表达式在后续可能会再次被引用,所以不保存子组可以提高正则的性能。除此之外呢,这么做还有一些好处,由于子组变少了,正则性能会更好,在子组计数时也更不容易出错。
不保存子组是什么?
- 可以理解成,括号只用于归组,把某个部分当成“单个元素”,不分配编号,后面不会再进行这部分的引用。
正则 | 示例 | |
---|---|---|
保存子组 | (正则) | \d{15}(\d{3}) |
不保存子组 | (?:正则) | \d{15}(?:\d{3}) |
括号嵌套
在括号嵌套的情况里,我们要看某个括号里面的内容是第几个分组怎么办?不要担心,其实方法很简单,我们只需要数左括号(开括号)是第几个,就可以确定是第几个子组。
例子:在阿里云简单日志系统中,我们可以使用正则来匹配一行日志的行首。假设时间格式是 2020-05-10 20:23:05 。
日期分组编号是 1,时间分组编号是 5,年月日对应的分组编号分别是 2,3,4,时分秒的分组编号分别是 6,7,8。
命名分组
由于编号得数在第几个位置,后续如果发现正则有问题,改动了括号的个数,还可能导致编号发生变化,因此一些编程语言提供了命名分组(named grouping),这样和数字相比更容易辨识,不容易出错。命名分组的格式为(?P<分组名>正则)。
比如在 Django 的路由中,命名分组示例如下:
1 | url(r'^profile/(?P\w+)/$', view_func) |
需要注意的是,刚刚提到的方式命名分组和前面一样,给这个分组分配一个编号,不过你可以使用名称,不用编号,实际上命名分组的编号已经分配好了。不过命名分组并不是所有语言都支持的,在使用时,你需要查阅所用语言正则说明文档,如果支持,那你才可以使用。
分组引用
在知道了分组引用的编号 (number)后,大部分情况下,我们就可以使用 “反斜扛 + 编号”,即 \number 的方式来进行引用,而 JavaScript 中是通过$
编号来引用,如$1
。
常见的编程语言中,分组查找和替换的引用方式:
编程语言 | 查找时引用方式 | 替换时引用方式 |
---|---|---|
Python | \number 如\1 | \number 如\1 |
Go | 官方包不支持 | 官方包不支持 |
Java | \number 如\1 | $number ,如$1 |
JavaScript | \number,如\1 | $number ,如$1 |
PHP | \number 如\1 | \number 如\1 |
Ruby | \number 如\1 | \number 如\1 |
分组引用在查找中使用
正则查找时如何使用分组引用?。比如我们要找重复出现的单词,我们使用正则可以很方便地使“前面出现的单词再次出现”,具体要怎么操作呢?我们可以使用 \w+ 来表示一个单词,针对刚刚的问题,我们就可以很容易写出 (\w+) \1 这个正则表达式了。
分组引用在替换中使用
和查找类似,我们可以使用反向引用,在得到的结果中,去拼出来我们想要的结果。
Python3的实现
1 | import re |
练习题
有一篇英文文章,里面有一些单词连续出现了多次,我们认为连续出现多次的单词应该是一次,比如:
1 | the little cat cat is in the hat hat hat, we like it. |
其中 cat 和 hat 连接出现多次,要求处理后结果是
1 | the little cat is in the hat, we like it. |
匹配模式
所谓匹配模式,指的是正则中一些改变元字符匹配行为的方式,比如匹配时不区分英文字母大小写。常见的匹配模式有 4 种,分别是不区分大小写模式、点号通配模式、多行模式和注释模式。
不区分大小写模式(Case-Insensitive)
一个例子:在进行文本匹配时,我们要关心单词本身的意义。比如要查找单词 cat,我们并不需要关心单词是 CAT、Cat,还是 cat。根据之前我们学到的知识,你可能会把正则写成这样:[Cc][Aa][Tt]
,这样写虽然可以达到目的,但不够直观,如果单词比较长,写起来容易出错,阅读起来也比较困难。
不区分大小写是匹配模式的一种。当我们把模式修饰符放在整个正则前面时,就表示整个正则表达式都是不区分大小写的。模式修饰符是通过 (? 模式标识) 的方式来表示的。 我们只需要把模式修饰符放在对应的正则前,就可以使用指定的模式了。在不区分大小写模式中,由于不分大小写的英文是 Case-Insensitive,那么对应的模式标识就是 I 的小写字母 i,所以不区分大小写的 cat 就可以写成 (?i)cat。
(?i)cat
相比[Cc][Aa][Tt]
清晰简洁的很多了。
也可以用它来尝试匹配两个连续出现的 cat,如下图所示,你会发现,即便是第一个cat和第二个cat大小写不一致,也可以匹配上。
如果想要第一次和第二次重复时的大小写一致,只需要用括号把修饰符和正则cat部分括起来,加括号相当于作用范围的限定,让不区分大小写只作用于这个括号里的内容。
需要注意的是,这里正则写成了 ((?i)cat) \1
,而不是 ((?i)(cat)) \1
。也就是说,我们给修饰符和 cat 整体加了个括号,而原来 cat 部分的括号去掉了。如果 cat 保留原来的括号,即 ((?i)(cat)) \1
,这样正则中就会有两个子组,虽然结果也是对的,但这其实没必要。
如果用正则匹配,实现部分区分大小写,另一部分不区分大小写,这该如何操作呢?就比如说我现在想要,the cat 中的 the 不区分大小写,cat 区分大小写。
有一点需要你注意一下,上面讲到的通过修饰符指定匹配模式的方式,在大部分编程语言中都是可以直接使用的,但在 JS 中我们需要使用 /regex/i 来指定匹配模式。在编程语言中通常会提供一些预定义的常量,来进行匹配模式的指定。比如 Python 中可以使用 re.IGNORECASE 或 re.I ,来传入正则函数中来表示不区分大小写。
1 | >> import re |
总结一下不区分大小写模式的要点:
不区分大小写模式的指定方式,使用模式修饰符 (?i);
修饰符如果在括号内,作用范围是这个括号内的正则,而不是整个正则;
使用编程语言时可以使用预定义好的常量来指定匹配模式。
点号通配模式(Dot All)
.任意字符(换行除外)
可以匹配上任何符号,但不能匹配换行。当我们需要匹配真正的“任意”符号的时候,可以使用 [\s\S] 或 [\d\D] 或 [\w\W] 等。但这么写不够简洁自然,所以正则中提供了一种模式,让英文的点(.)可以匹配上包括换行的任何字符。
模式就是点号通配模式,有很多地方把它称作单行匹配模式
单行的英文表示是 Single Line,单行模式对应的修饰符是 (?s)
点可以匹配上换行
需要注意的是,JavasScript 不支持此模式,那么我们就可以使用前面说的[\s\S]等方式替代。在 Ruby 中则是用 Multiline,来表示点号通配模式(单行匹配模式)。
多行匹配模式(Multiline)
通常情况下,^匹配整个字符串的开头,$ 匹配整个字符串的结尾。多行匹配模式改变的就是 ^ 和 $ 的匹配行为
。
多行模式的作用在于,使 ^ 和 $ 能匹配上每行的开头或结尾,我们可以使用模式修饰符号(?m)
来指定这个模式
和以下好像没有差
这个模式有什么用呢?在处理日志时,如果日志以时间开头,有一些日志打印了堆栈信息,占用了多行,我们就可以使用多行匹配模式,在日志中匹配到以时间开头的每一行日志。
值得一提的是,正则中还有 \A 和 \z(Python 中是 \Z) 这两个元字符容易混淆,\A 仅匹配整个字符串的开始,\z 仅匹配整个字符串的结束,在多行匹配模式下,它们的匹配行为不会改变,如果只想匹配整个字符串,而不是匹配每一行,用这个更严谨一些。
注释模式(Comment)
在实际工作中,正则可能会很复杂,这就导致编写、阅读和维护正则都会很困难。我们在写代码的时候,通常会在一些关键的地方加上注释,让代码更易于理解。很多语言也支持在正则中添加注释,让正则更容易阅读和维护,这就是正则的注释模式。正则中注释模式是使用 (?#comment)
来表示。
比如我们可以把单词重复出现一次的正则 (\w+) \1 写成下面这样,这样的话,就算不是很懂正则的人也可以通过注释看懂正则的意思。
1 | (\w+)(?#word) \1(?#word repeat again) |
在很多编程语言中也提供了 x 模式来书写正则,也可以起到注释的作用。Python3的例子:
1 | import re |
需要注意的是在 x 模式下,所有的换行和空格都会被忽略。为了换行和空格的正确使用,我们可以通过把空格放入字符组中,或将空格转义来解决换行和空格的忽略问题。
1 | import re |
总结
正则中常见的四种匹配模式,分别是:不区分大小写、点号通配模式、多行模式和注释模式。
- 不区分大小写模式,它可以让整个正则或正则中某一部分进行不区分大小写的匹配。
- 点号通配模式也叫单行匹配,改变的是点号的匹配行为,让其可以匹配任何字符,包括换行。
- 多行匹配说的是 ^ 和 $ 的匹配行为,让其可以匹配上每行的开头或结尾。
- 注释模式则可以在正则中添加注释,让正则变得更容易阅读和维护。
应用篇
断言
如何用断言更好地实现替换重复出现的单词?