常看常“新”的正则表达式
一、快速入门
1.1 什么是正则表达式?
简称 RE Regular Expression,是一种描述文本内容组成规律的表示方式
1.2 正则的作用及应用场景?
- 数据提取:简化查找、编辑、提取文件或数据等操作
- 数据校验:校验数据是否符合规则
- 命令执行:grep、egrep、sed、awk、vim
- 开发工具:Atom,Sublime Text,Idea
1.3 元字符
1.3.1 什么是元字符?
元字符,指在正则表达式中具有特殊意义的专用字符,元字符是构成正则表达式的基本元件之一
1.3.2 分字符的分类
(1).特殊单字符
符号 | 描述 | 示意图 |
---|---|---|
. |
表示换行以外的任意单个字符 | ![]() |
\d |
表示任意数字 | ![]() |
\D |
表示任意非数字 | ![]() |
\w |
表示任意字母数字下划线 | ![]() |
\W |
表示任意非字母数字下划线 | ![]() |
\s |
表示任意单个空白符 | ![]() |
\S |
表示任意非单个空白符 | ![]() |
(2).空白符
符号 | 描述 | 示意图 |
---|---|---|
\r |
表示回车符 | ![]() |
\n |
表示换行符 | ![]() |
\f |
表示换页符 | ![]() |
\t |
表示制表符 | ![]() |
\v |
表示垂直制表符 | ![]() |
\s |
表示任意空白符 | ![]() |
(3).量词
所谓量词,即元字符匹配几次,例如 \d
匹配0-9中的其中一个数字,而 \d+
则表示 匹配多次数字,具体规则见下表
符号 | 描述 | 示意图 |
---|---|---|
* |
0~N次 | ![]() |
+ |
1~N次 | ![]() |
? |
0~1次 | ![]() |
{m} |
m次 | ![]() |
{m,} |
至少m次 | ![]() |
{m,n} |
m~n次 | ![]() |
{...}? |
惰性匹配 | ![]() |
{...}+ |
独占模式 | ![]() |
(4).范围
符号 | 描述 | 示意图 |
---|---|---|
` | ` | 或 |
[...] |
多选一,括号内任意单个元素 | ![]() |
[^...] |
取反,不能是里面的任何单个元素 | ![]() |
[a-z] |
表示所有小写字母 | ![]() |
[A-Z] |
表示所有大写字母 | ![]() |
[0-9] |
表示所有阿拉伯数字 | ![]() |
(5).断言
有关更多断言内容,戳[这里](#1.6 断言)
正则 | 名称 | 含义 | 示例 |
---|---|---|---|
\b |
单词边界符 | 包含 引号' 、空格 、标点 、换行 等,常用来分隔单词 |
![]() |
^ |
行首 | 用来要求每一行的行首 所必须匹配的内容 |
![]() |
$ |
行尾 | 用来要求每一行的行尾 所必须匹配的内容 |
![]() |
(?<=元字符)<expr> |
肯定逆序环视(postive-lookahead) | 要求 左边字符 匹配 元字符 |
![]() |
(?<!元字符)<expr> |
否定逆序环视(negative-lookahead) | 要求 左边字符 不匹配 元字符 |
![]() |
<expr>(?=元字符) |
肯定顺序环视(postive-lookbehind) | 要求 右边字符 匹配 元字符 |
![]() |
<expr>(?!元字符) |
否定顺序环视(negative-lookbehind) | 要求 右边字符 不匹配 元字符 |
![]() |
1.4 正则匹配模式
前面我们看过量词的元字符,现在再聊一个与量词相关的重要知识点,匹配模式,之所以说它很重要,这是因为 “不同的匹配模式,可能会影响到正则匹配的结果”
正则表达式中有七种模式,前六种都可以对匹配结果产生影响:
- 贪婪匹配(默认):尽可能进行最长匹配(但受限于正则引擎,倘若是 NFA 引擎,那么同样可能匹配不到最长的结果)
- 非贪婪匹配(
{...}?
):尽可能进行最短匹配 - 独占模式(
{...}+
):尽可能的最长匹配,失败时不进行回溯,直接返回空 - 不区分大小写模式(
?i
):字面意思,通过模式修饰符(?i
)设置不区分英文字母大小写 - 点号通配模式(
?s
):.
默认情况下可以匹配上任何符号,但不能匹配换行,通过模式修饰符(?s)
设置匹配包括换行的任何字符 - 多行模式(
?m
):默认情况下,正则将所有内容当作单行进行处理,通过模式修饰符(?m)
设置多行模式(换行符分隔) - 注释模式(
?#
):为了提高可读性,通过模式修饰符(?#comment)
补充正则的注释信息
1.4.1 贪婪匹配(Greedy)
当我们使用量词时,默认就是基于规则进行 贪婪匹配
如图所示:\d+
产生了一次 match
匹配到了所有数字
由此,我们可以简单的将贪婪匹配理解为,当我们使用量词时,只要附和量词要求,那么正则会尽可能进行最长的匹配,能匹配多少匹配多少
1.4.2 惰性匹配(Lazy)
当我们在量词后使用 ?
进行修饰,原本默认的 贪婪匹配 就会变为 非贪婪匹配,也叫 惰性匹配
如图所示:\d+?
产生了 11 次 match
匹配,每次只匹配 1 个数字
由此,非贪婪匹配可以理解为,尽可能进行最短的匹配,匹配一个算一次
1.4.3 独占模式(Possessive)
当执行量词 贪婪|惰性
匹配时,如果匹配失败,直接返回失败(即空),不进行回溯,回溯是什么?这个概念比较抽象,但很重要,想要搞懂 独占模式前,我们必须先理解回溯是什么?
1.4.2.1 什么是回溯?
前面提到的 贪婪匹配 & 惰性匹配,它们背后的匹配原理都离不开一种机制,回溯
1.4.2.2 贪婪匹配中的回溯
那 回溯 到底是什么呢?先看一个例子:
这个规则不难理解,分三部分:匹配 字符x
、匹配 1~3
个y
以及匹配字符 z
,我们分解下它的步骤:
- 使用匹配规则
x
,匹配第一个字符x
,匹配成功,此时匹配内容为:x
- 使用匹配规则
y{1,3}
,匹配第二个字符y
,匹配成功,此时匹配内容为:xy
- 由于匹配规则使用到了量词
{1,3}
所以要尽可能长地去匹配,所以当目前量词未满足的情况下还要继续尝试匹配
- 使用匹配对应规则
y{1,3}
,匹配第三个字符y
,此时匹配内容为:xyy
- 由于匹配规则使用到了量词
{1,3}
所以要尽可能长地去匹配,所以当目前量词未满足的情况下还要继续尝试匹配
- 使用匹配规则
y{1,3}
,匹配第四个字符z
,匹配失败,因为z != y
- 此时正则会触发 向前回溯,跳过量词的贪婪匹配,使用后续的规则继续对字符
z
进行匹配
- 使用匹配规则
z
,匹配第四个字符z
,匹配成功,此时匹配内容为:xyyz
1.4.2.3 惰性匹配中的回溯
同时,为了对比加深理解,我们梳理下 惰性匹配 的步骤:
- 使用匹配规则
x
,匹配第一个字符x
,匹配成功,此时匹配内容为:x
- 使用匹配规则
y{1,3}
,匹配第二个字符y
,匹配成功,此时匹配内容为:xy
- 由于匹配规则为 惰性匹配,尽可能少地去匹配,所以
{1,3}
以满足,停止继续该部分的规则匹配
- 使用匹配对应规则
z
,匹配第三个字符y
,匹配失败,因为z != y
- 此时正则会触发 向前回溯,重新返回到前一规则,即量词匹配
y{1,3}
,尝试继续匹配发生失败字符y
,匹配成功,此时匹配内容为:xyy
- 使用匹配对应规则
z
,匹配第四个字符z
,匹配成功,此时匹配内容为:xyyz
OK 😃,到此,通过上面两个例子,我们可以基本得出一个理解:
- 当匹配模式为 贪婪匹配,由于是尽可能的多的匹配,所以很有可能出现量词贪婪匹配得不到满足,导致匹配失败,会触发回溯,跳过量词匹配,使用后续规则进行匹配(跳过错误,使用后续规则匹配发生失败的字符)
- 当匹配模式为 惰性匹配,由于是尽可能的少的匹配,当惰性量词匹配后规则匹配失败后,会触发回溯,重新使用上次量词规则进行匹配(跳过错误,使用前面量词匹配规则匹配之前失败的字符)
1.4.2.4 独占模式工作流程
独占模式,在匹配行为上类似于 贪婪匹配,但是,它的匹配过程中如果匹配失败,不会发生回溯,直接返回失败,因此在一些场合下性能会更好
默认情况下,Python、Go 标准库不支持 独占模式,但是可以通过安装第三方库 regex
实现需求:
$ pip install regex
Looking in indexes: http://mirrors.cloud.aliyuncs.com/pypi/simple/
Collecting regex
Downloading http://mirrors.cloud.aliyuncs.com/pypi/packages/c7/57/50479adc9028ecd30741e9f1be9881183b061540701cda46a8d2614d838c/regex-2021.8.28-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (759 kB)
|████████████████████████████████| 759 kB 6.4 MB/s
Installing collected packages: regex
Successfully installed regex-2021.8.28
使用示例:
先看下之前几个例子在 Python 中实际效果
>>> import regex
# 贪婪匹配:尽可能的多的匹配,当量词贪婪匹配失败,自动跳过发生错误的规则,使用后续规则匹配触发失败的字符
>>> regex.findall(r'xy{1,3}z', 'xyyz')
['xyyz']
# 惰性匹配:尽可能的少的匹配,当量词惰性匹配失败,自动跳过错误,使用前面量词匹配规则匹配触发失败的字符
>>> regex.findall(r'xy{1,3}?z', 'xyyz')
['xyyz']
# 独占模式:尽可能的多匹配,当进行 y{1,3} 量词匹配时,一次性的将两个 y 都用掉,满足匹配规则后,进行后续匹配
>>> regex.findall(r'xy{1,3}+z', 'xyyz')
['xyyz']
在看下新的例子,尝试理解下他们背后的匹配过程:
# 贪婪匹配:尽可能的多的匹配,量词惰性匹配y{1,2}到 xyy 后,规则y 匹配 字符z 匹配错误,触发回溯,跳过发生错误的规则,使用后续规则匹配触发失败的字符
>>>> regex.findall(r'xy{1,2}yz', 'xyyz')
['xyyz']
# 惰性匹配:尽可能的少的匹配,此表达式下几乎是一对一匹配(x:x|y{1,2}?:y|y:y|z:z),未发生回溯
>>> regex.findall(r'xy{1,2}?yz', 'xyyz')
['xyyz']
# 独占模式:尽可能的多的匹配,当进行 y{1,2} 量词匹配时,一次性的将两个 y 都用掉,后续 规则y 与 字符z 匹配失败,由于是独占模式,所以不进行回溯,直接返回失败,即空
>>> regex.findall(r'xy{1,2}+yz', 'xyyz')
[]
现在,我们明白了,为何 独占模式 可以在一定程度减少 CPU 开销了吧?不过,这不代表独占模式就是我们的第一选择了,选择什么模式是需要根据具体场景进行判断
- 校验数据:如当我们定义某一接口的参数时,如果客户端提交的请求参数,无法校验匹配通过,说明参数本身就不合法,这种情况不需要进行回溯,直接返回失败即可,这种场景下推荐用独占模式
- 检索数据:如当我们检索文本内容时,有时无法确定具体文本规则,那么必要的贪婪匹配是不可缺少的,毕竟首要任务是保证正则能满足功能需求,这时就不得不用贪婪模式
1.4.4 不区分大小写模式(IGNORECASE)
在进行文本匹配时,有时我们并不关心大小写,比如查找单词 cat
,我们并不需要关心单词是 CAT
、Cat
,还是 cat
根据之前我们学到的知识,你可能会把正则写成这样:[Cc][Aa][Tt]
虽然可以 work,但不够直观,书写写不方便,这时不区分大小写模式就派上用场了…
把模式修饰符 ?i
放在整个正则前面时,就表示整个正则表达式都是不区分大小写的,如下:
Python 示例:
In [5]: re.findall(r'(?i)cat', 'cat\r\nCat\r\nCAT')
Out[5]: ['cat', 'Cat', 'CAT']
而且,当统计重复单词时,即使大小写不同,一样可以被匹配上,如图:
Python 示例:
In [21]: re.findall(r'(?i)(cat) \1', 'cat Cat\nCat Cat\nCAT caT')
Out[21]: ['cat', 'Cat', 'CAT']
In [22]: re.match(r'(?i)(cat) \1', 'cat Cat\nCat Cat\nCAT caT').groups()
Out[22]: ('cat',)
我们在扩展下,如何实现部分区分大小写,部分不区分呢?例如:the world
,其中 the
不区分大小写,world
区分大小写,如何实现呢?
经过测试,PCRE、Golang 支持该语法 ((?i)<expr>)
,Python 不支持
Python 看样子并不支持
终端执行时虽没报错,但并未生效
In [29]: re.findall(r'(?:(?i)the) world', 'ThE world\nthe World')
Out[29]: ['ThE world', 'the World']
另外,需要补充的是,大部分编程语言中都提供一些预定义的常量,来进行匹配模式的指定。比如,Python 中可以使用 re.IGNORECASE
或 re.I
InIn [32]: re.findall(r'cat', 'CAT Cat cat', re.IGNORECASE)
Out[32]: ['CAT', 'Cat', 'cat']
In [33]: re.findall(r'cat', 'CAT Cat cat', re.I)
Out[33]: ['CAT', 'Cat', 'cat']
做个小节:
- 模式修饰符
(?i)
设置不区分大小写 - 修饰符如果在括号内,作用范围是这个括号内的正则,而不是整个正则(部分语言生效)
- 推荐使用预定义好的常量来指定匹配模式
re.IGNORECASE/re.I
1.4.5 点号通配模式(DOTALL)
在之前的 元字符 小节中,我们讨论说 .
用来点除换行外任意字符,如果我们需要真正的匹配任意字符,可以考虑使用诸如:[\s\S]
、[\d\D]
、[\w\W]
等,它们的含义如下:
如下图所示,虽然这么写可以实现效果,但不简洁自然
正则表达式中提供了一种模式,点号通配模式(也叫单行匹配模式,但有歧义易被误解),让英文的点.
可以匹配上包括换行的任何字符,如下:
需要注意的是,不同语言有所差异
JavasScript:不支持此模式,那么我们就可以使用前面说的
[\s\S]
等方式替代Ruby:用 Multiline 来表示点号通配模式(单行匹配模式),我猜测设计者的意图是把点
.
号理解成 “能匹配多行”Python:正常使用即可,示例如下:
In [38]: regex.findall(r'(?s).', 'ThE world\nthe World') Out[38]: ['T', 'h', 'E', ' ', 'w', 'o', 'r', 'l', 'd', '\n', 't', 'h', 'e', ' ', 'W', 'o', 'r', 'l', 'd'] In [39]: regex.findall(r'(?s).+', 'ThE world\nthe World') Out[39]: ['ThE world\nthe World']
1.4.6 多行模式(Multiline)
^
、$
,它们分别表示一行的开头、结尾,如下所示:
以小写字母 c
开头的一行
以小写字母 t
结尾的一行
可以看到,明明 cat|reboot
都是以 t
结尾的,却都没被匹配上,这是为什么?
原因很简单,在默认情况下,正则表达式会将所有内容当作 一行数据 进行查询处理,例如:
In [12]: text = """cat
...: reboot
...: school
...: """
等同于 cat\nreboot\nschool\n
In [13]: print(repr(text))
'cat\nreboot\nschool\n'
这就是为何没有匹配到 以 t
结尾的 reboot
等行,可如何处理这一问题呢?
但很简单,让正则正确的理解 \n
换行符,凡是遇到 \n
就把后面的内容当做新的一行进行处理不就好了吗~ 这就是正则中 多行模式
的由来
具体示例如下:
Python 示例:
In [14]: text = 'cat\nreboot\nschool\n'
# 单行模式进行匹配
In [15]: regex = '\w+t$'
In [16]: re.findall(regex, text)
Out[16]: []
# 多行模式进行匹配
In [17]: regex = '(?m)\w+t$'
In [18]: re.findall(regex, text)
Out[18]: ['cat', 'reboot']
这个模式算是比较常用,其中一个场景便是日志处理。在处理日志时,通常日志都以时间开头,后面包括 日志级别、模块、方法、执行耗时,以及异常时的堆栈信息等,但出现异常时,通常都会占用了多行。这时我们就可以使用多行匹配模式,以日期时间为匹配规则匹配每一行日志,如下所示:
Python 示例:
定义 日志内容 和 匹配规则
>>> import re
# 日志内容
>>> text = '''2021/08/06 06:56:58 [error] 18668#0: *4148 open() "/opt/nginx/html/boaform/admin/formLogin" failed (2: No such file or directory), client: 209.141.54.8, server: localhost, request: "POST /boaform/admin/formLogin HTTP/1.1", host: "47.115.121.119:80", referrer: "http://47.115.121.119:80/admin/login.asp"
... 2021/09/27 11:01:45 [error] 12055#12055: *20383 open() "/opt/nginx/html/index.html1" failed (2: No such file or directory), client: 47.115.121.119, server: yo-yo.fun, request: "GET /index.html1 HTTP/1.0", host: "47.115.121.119"'''
# 定义规则
>>> regex = '''(?m)^((?P<datetime>\d{4}/\d{2}/\d{2}\s\d{2}:\d{2}:\d{2})\s\[(?P<level>\w+)\]\s(?P<pid>\d+)\#\d+\:\s\*\d+\s(?P<message>\w+\(\)\s\".*\"\s\w+\s\(.*\)),\s\w+:\s(?P<client_ip>\d+\.\d+\.\d+\.\d+),\s\w+:\s(?P<server_host>.*),\s\w+:\s\"(?P<method>\w{3,7})\s(?P<url>.*)\s(?P<protocol>\w{4}\/\d.\d)\",\s\w+:\s\"(?P<request_host>.*?)\"(?:,\s\w+:\s\"(?P<referrer>.*)\")?)'''
执行 正则匹配
>>> from pprint import pprint
>>> pprint(re.findall(regex, text))
[('2021/08/06 06:56:58 [error] 18668#0: *4148 open() '
'"/opt/nginx/html/boaform/admin/formLogin" failed (2: No such file or '
'directory), client: 209.141.54.8, server: localhost, request: "POST '
'/boaform/admin/formLogin HTTP/1.1", host: "47.115.121.119:80", referrer: '
'"http://47.115.121.119:80/admin/login.asp"',
'2021/08/06 06:56:58',
'error',
'18668',
'open() "/opt/nginx/html/boaform/admin/formLogin" failed (2: No such file or '
'directory)',
'209.141.54.8',
'localhost',
'POST',
'/boaform/admin/formLogin',
'HTTP/1.1',
'47.115.121.119:80',
'http://47.115.121.119:80/admin/login.asp'),
('2021/09/27 11:01:45 [error] 12055#12055: *20383 open() '
'"/opt/nginx/html/index.html1" failed (2: No such file or directory), '
'client: 47.115.121.119, server: yo-yo.fun, request: "GET /index.html1 '
'HTTP/1.0", host: "47.115.121.119"',
'2021/09/27 11:01:45',
'error',
'12055',
'open() "/opt/nginx/html/index.html1" failed (2: No such file or directory)',
'47.115.121.119',
'yo-yo.fun',
'GET',
'/index.html1',
'HTTP/1.0',
'47.115.121.119',
'')]
逐行遍历进行匹配:
>>> for line in text.split('\n'):
... re.match(regex, line).groupdict()
...
{'datetime': '2021/08/06 06:56:58', 'level': 'error', 'pid': '18668', 'message': 'open() "/opt/nginx/html/boaform/admin/formLogin" failed (2: No such file or directory)', 'client_ip': '209.141.54.8', 'server_host': 'localhost', 'method': 'POST', 'url': '/boaform/admin/formLogin', 'protocol': 'HTTP/1.1', 'request_host': '47.115.121.119:80', 'referrer': 'http://47.115.121.119:80/admin/login.asp'}
{'datetime': '2021/09/27 11:01:45', 'level': 'error', 'pid': '12055', 'message': 'open() "/opt/nginx/html/index.html1" failed (2: No such file or directory)', 'client_ip': '47.115.121.119', 'server_host': 'yo-yo.fun', 'method': 'GET', 'url': '/index.html1', 'protocol': 'HTTP/1.0', 'request_host': '47.115.121.119', 'referrer': None}
同样,Python 提供了多行模式的常量,re.MULTILINE
和 re.M
,效果是一样的
>>> regex = '''^((?P<datetime>\d{4}/\d{2}/\d{2}\s\d{2}:\d{2}:\d{2})\s\[(?P<level>\w+)\]\s(?P<pid>\d+)\#\d+\:\s\*\d+\s(?P<message>\w+\(\)\s\".*\"\s\w+\s\(.*\)),\s\w+:\s(?P<client_ip>\d+\.\d+\.\d+\.\d+),\s\w+:\s(?P<server_host>.*),\s\w+:\s\"(?P<method>\w{3,7})\s(?P<url>.*)\s(?P<protocol>\w{4}\/\d.\d)\",\s\w+:\s\"(?P<request_host>.*?)\"(?:,\s\w+:\s\"(?P<referrer>.*)\")?)'''
# 逐行遍历匹配(为了获取kv对)
>>> for line in text.split('\n'):
... re.match(regex, line, re.M).groupdict()
...
{'datetime': '2021/08/06 06:56:58', 'level': 'error', 'pid': '18668', 'message': 'open() "/opt/nginx/html/boaform/admin/formLogin" failed (2: No such file or directory)', 'client_ip': '209.141.54.8', 'server_host': 'localhost', 'method': 'POST', 'url': '/boaform/admin/formLogin', 'protocol': 'HTTP/1.1', 'request_host': '47.115.121.119:80', 'referrer': 'http://47.115.121.119:80/admin/login.asp'}
{'datetime': '2021/09/27 11:01:45', 'level': 'error', 'pid': '12055', 'message': 'open() "/opt/nginx/html/index.html1" failed (2: No such file or directory)', 'client_ip': '47.115.121.119', 'server_host': 'yo-yo.fun', 'method': 'GET', 'url': '/index.html1', 'protocol': 'HTTP/1.0', 'request_host': '47.115.121.119', 'referrer': None}
# 批量多行匹配
>>> pprint(re.findall(regex, text, re.M))
[('2021/08/06 06:56:58 [error] 18668#0: *4148 open() '
'"/opt/nginx/html/boaform/admin/formLogin" failed (2: No such file or '
'directory), client: 209.141.54.8, server: localhost, request: "POST '
'/boaform/admin/formLogin HTTP/1.1", host: "47.115.121.119:80", referrer: '
'"http://47.115.121.119:80/admin/login.asp"',
'2021/08/06 06:56:58',
'error',
'18668',
'open() "/opt/nginx/html/boaform/admin/formLogin" failed (2: No such file or '
'directory)',
'209.141.54.8',
'localhost',
'POST',
'/boaform/admin/formLogin',
'HTTP/1.1',
'47.115.121.119:80',
'http://47.115.121.119:80/admin/login.asp'),
('2021/09/27 11:01:45 [error] 12055#12055: *20383 open() '
'"/opt/nginx/html/index.html1" failed (2: No such file or directory), '
'client: 47.115.121.119, server: yo-yo.fun, request: "GET /index.html1 '
'HTTP/1.0", host: "47.115.121.119"',
'2021/09/27 11:01:45',
'error',
'12055',
'open() "/opt/nginx/html/index.html1" failed (2: No such file or directory)',
'47.115.121.119',
'yo-yo.fun',
'GET',
'/index.html1',
'HTTP/1.0',
'47.115.121.119',
'')]
1.4.7 注释模式(Comment)
在实际工作中,正则可能会很复杂,这就导致编写、阅读和维护正则都会很困难,例如上面那个日志匹配的例子,写起来并不难(体力活儿),但是,由于命名、规则,乱七八糟揉成一团,看着是真费劲!
所以,为了让代码更易于理解,提高可读性,关键的地方加上注释是必不可少的,这就是正则的注释模式
语法规则:(?#<comment message>)
举个简单的例子:
Python 示例:
In [45]: regex = r'(\w+)(?#匹配英文单词) \1(?#引用分组匹配相邻重复单词)'
In [46]: text = 'cat cat'
In [47]: regex = r'(\w+)(?#匹配英文单词) \1(?#引用分组,匹配相邻重复单词)'
In [48]: re.findall(regex, text)
Out[48]: ['cat']
不过,如果这样书写,势必会导致正则越发的冗长,可读性并不一定会提高多少,我们试下想上面的日志匹配的规则,如果每个字段都加上注释,那规则得有多长…
所以,编程语言中也提供了 x
冗长模式(VERBOSE)来书写正则,X 模式支持通过分段和添加注释,如下所示:
In [60]: text = '2021-09-27'
In [61]: regex = r'''(?x)
...: (?P<date> # 日期
...: (?P<year>\d{4})- # 年
...: (?P<month>\d{2})- # 月
...: (?P<day>\d{2})) # 日
...: '''
In [62]: re.findall(regex, text)
Out[62]: [('2021-09-27', '2021', '09', '27')]
In [63]: re.match(regex, text).groupdict()
Out[63]: {'date': '2021-09-27', 'year': '2021', 'month': '09', 'day': '27'}
需要注意的是在 x
冗长模式下,所有的换行和空格都会被忽略。为了换行和空格的正确使用,我们可以通过把空格放入字符组中,或将空格转义来解决换行和空格的忽略问题,如下所示:
In [66]: text = '2021 09 27'
In [67]: regex = r'''(?x)
...: (?P<date> # 日期
...: (?P<year>\d{4}) # 年
...: [ ] # 匹配空格
...: (?P<month>\d{2}) # 月
...: [ ]
...: (?P<day>\d{2})) # 日
...: '''
In [68]: re.findall(regex, text)
Out[68]: [('2021 09 27', '2021', '09', '27')]
In [69]: re.match(regex, text).groupdict()
Out[69]: {'date': '2021 09 27', 'year': '2021', 'month': '09', 'day': '27'}
现在,我们用 x
冗长模式,重新书写下日志匹配正则:
定义日志内容:
In [88]: text = '''2021/08/06 06:56:58 [error] 18668#0: *4148 open() "/opt/nginx/html/boaform/admin/formLogin" failed (2: No such file or directory)
...: , client: 209.141.54.8, server: localhost, request: "POST /boaform/admin/formLogin HTTP/1.1", host: "47.115.121.119:80", referrer: "http://
...: 47.115.121.119:80/admin/login.asp"
...: 2021/09/27 11:01:45 [error] 12055#12055: *20383 open() "/opt/nginx/html/index.html1" failed (2: No such file or directory), client: 47.115.
...: 121.119, server: yo-yo.fun, request: "GET /index.html1 HTTP/1.0", host: "47.115.121.119"'''
定义正则规则:
In [89]: regex = '''(?mx)
...: ^ # 开头
...: (?P<datetime>\d{4}/\d{2}/\d{2}\s\d{2}:\d{2}:\d{2})\s # 日期时间
...: \[(?P<level>\w+)\]\s # 日志级别
...: (?P<pid>\d+)\#\d+\:\s\*\d+\s # 进程ID、分组外是线程ID、客户端ID
...: (?P<message>\w+\(\)\s\".*\"\s\w+\s\(.*\)), # 错误日志,包括系统调用、url、错误描述
...: \s\w+:\s(?P<client_ip>\d+\.\d+\.\d+\.\d+), # 客户端IP
...: \s\w+:\s(?P<server_host>.*), # 虚拟主机名称
...: \s\w+:\s\"(?P<method>\w{3,7})\s # 请求方法
...: (?P<url>.*)\s # 请求URL
...: (?P<protocol>\w{4}\/\d.\d)\", # 协议及版本
...: \s\w+:\s\"(?P<request_host>.*?)\" # 请求主机名(域名)
...: (?:,\s\w+:\s\"(?P<referrer>.*)\")? # 来源地址
...: $ # 结束
...: '''
执行正则匹配
In [90]: re.findall(regex, text)
Out[90]:
[('2021/08/06 06:56:58',
'error',
'18668',
'open() "/opt/nginx/html/boaform/admin/formLogin" failed (2: No such file or directory)',
'209.141.54.8',
'localhost',
'POST',
'/boaform/admin/formLogin',
'HTTP/1.1',
'47.115.121.119:80',
'http://47.115.121.119:80/admin/login.asp'),
('2021/09/27 11:01:45',
'error',
'12055',
'open() "/opt/nginx/html/index.html1" failed (2: No such file or directory)',
'47.115.121.119',
'yo-yo.fun',
'GET',
'/index.html1',
'HTTP/1.0',
'47.115.121.119',
'')]
逐行遍历进行匹配
需要注意,IPython 中不使用 print 不显示匹配内容的,如下所示
In [121]: for l in text.split('\n'): ...: re.match(regex, l).groupdict() ...: ...:
所以,为了测试方便,这里用列表解析式
In [112]: [re.match(regex, l).groupdict() for l in text.split('\n')]
Out[112]:
[{'datetime': '2021/08/06 06:56:58',
'level': 'error',
'pid': '18668',
'message': 'open() "/opt/nginx/html/boaform/admin/formLogin" failed (2: No such file or directory)',
'client_ip': '209.141.54.8',
'server_host': 'localhost',
'method': 'POST',
'url': '/boaform/admin/formLogin',
'protocol': 'HTTP/1.1',
'request_host': '47.115.121.119:80',
'referrer': 'http://47.115.121.119:80/admin/login.asp'},
{'datetime': '2021/09/27 11:01:45',
'level': 'error',
'pid': '12055',
'message': 'open() "/opt/nginx/html/index.html1" failed (2: No such file or directory)',
'client_ip': '47.115.121.119',
'server_host': 'yo-yo.fun',
'method': 'GET',
'url': '/index.html1',
'protocol': 'HTTP/1.0',
'request_host': '47.115.121.119',
'referrer': None}]
OK,搞定!
Python 官方文档:https://docs.python.org/zh-cn/3/library/re.html#module-contents
1.5 分组/引用
1.5.1 为什么需要表达式分组?
在正则中可以用 ()
进行分组,被 ()
括起来的部分 “子表达式” 会被保存成一个子组,那什么场合下会用到分组呢?
假设,我们现在要去查找 15 位或 18 位数字,我们第一反应可能是这样的…
但经过测试后发现,这个正则并不能很好地完成任务,因为匹配到了最左的规则,或许想到了调换位置来解决…
上面我们了解量词,那是否可以考虑用这个逻辑呢,先用\d{15}
匹配 15 个数字,后面使用 \d{3}
匹配3位数字,并在它的后面使用?
元字符,判断出现 0-1 次,我们尝试下:
不行,为什么呢?很明显,使用 {...}?
使得匹配模式变为独占模式了…肯定只有18位数字才匹配上了啊~ 那怎么办呢?难道使用 {}
后我们就无法使用 ?
元字符了吗?
这时,我们就需要用到()
,使用如下:
1.5.2 什么是分组?
括号 ()
在正则中可以用于分组,被括号括起来的部分 “子表达式(及匹配内容)” 会被保存成一个子组,如果存在多个分组,那么分组的编号规按需划分,简单来说,第几个括号就是第几个分组,如图所示:
图示中的正则,一共有两个分组,第一个分组用来匹配日期,第二个分组用来匹配时间,Python 示例:
In [8]: re.match(r'(\d{4}-\d{2}-\d{2})\s(\d{2}:\d{2}:\d{2})', '2021-09-21 18:01:23').groups()
Out[8]: ('2021-09-21', '18:01:23')
可以看到,日期 与 时间 分到两个组,方便我们获取使用,可是,有些情况下,我们只需要用括号将某些部分看成一个整体,后续不用再用它。
例如,上面匹配 15~18 位数字的例子,我们实际上用不到包含后三位数字的那个分组,所以在遇到类似这种情况下,是没必要保存子组的,这时我们可以在括号里面使用 ?:
显示声明,不去保存子组匹配的内容,示例如下:
# 保存分组匹配内容
In [16]: re.findall(r'\d{15}(\d{3})?', '110120119112114\n110120119112114911')
Out[16]: ['', '911']
# 不保存分组匹配内容
In [17]: re.findall(r'\d{15}(?:\d{3})?', '110120119112114\n110120119112114911')
Out[17]: ['110120119112114', '110120119112114911']
当使用 ?:
后就不再打印出了分组内容 911
,而是打印规则所匹配到的内容
1.5.3 分组嵌套
有些情况会比较复杂,我们可能需要在括号内进行多层嵌套,例如:除了要获取日期,还要获取年月日、时分秒等字段,那么规则如下:
In [29]: re.match(r'((\d{4})-(\d{2})-(\d{2}))\s((\d{2}):(\d{2}):(\d{2}))', '2021-09-21 18:01:23').groups()
Out[29]: ('2021-09-21', '2021', '09', '21', '18:01:23', '18', '01', '23')
In [30]: re.findall(r'((\d{4})-(\d{2})-(\d{2}))\s((\d{2}):(\d{2}):(\d{2}))', '2021-09-21 18:01:23')
Out[30]: [('2021-09-21', '2021', '09', '21', '18:01:23', '18', '01', '23')]
当我们想要判断所需数据所在的分组,只需要数左括号(开括号)是第几个,就可以确定在第几个子组
1.5.4 命名分组
前面我们讨论了分组编号,虽然数起来不难,但是,如若后续正则发生了变动,改动了括号的个数,随之而来的导致编号也会发生变化,那么代码逻辑就会产生问题因此一些编程语言提供了命名分组(namedgrouping),这样和数字相比更容易辨识,不容易出错。命名分组的格式为(?P<分组名>正则)。
因此,为了解决这一问题,部分编程语言提供了 命名分组(namedgrouping
)功能,通过为指定分组进行命名,大大提高了便利性,以及代码的健壮性,命名分组的格式为(?P<分组名>正则
)
示例如下:
Python 示例:
In [41]: re.findall(r'((\d{4})-(\d{2})-(\d{2}))\s((\d{2}):(\d{2}):(\d{2}))', '2021-09-21 18:01:23')
Out[41]: [('2021-09-21', '2021', '09', '21', '18:01:23', '18', '01', '23')]
In [42]: re.match(r'(?P<date>(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2}))\s(?P<time>(?P<hour>\d{2}):(?P<minute>\d{2}):(?P<second>\d{2}))', '2021
...: -09-21 18:01:23').groupdict()
Out[42]:
{'date': '2021-09-21',
'year': '2021',
'month': '09',
'day': '21',
'time': '18:01:23',
'hour': '18',
'minute': '01',
'second': '23'}
1.5.5 分组引用
前面的内容都是对匹配内容进行分组,以编号或特定名称并命名,现在开始要引用分组(表达式或数据)
1.5.5.1 查找中引用分组表达式
首先,看个例子,找出重复出现的单词:
([a-zA-Z]+)
:匹配大小写的英文单词\1
:引用编号为1的表达式
通过上面两部分的匹配规则,我们可以匹配到 两个相邻且重复的单词
1.5.5.2 替换中引用分组表达式
例子,将 2021-09-21 18:01:23
替换为 时期:2021-09-21 时间:18:01:23 2021年09月21日 18时:01分:23秒
Python 示例:
编号分组:使用使用 \<number>
In [43]: re.sub(r'((\d{4})-(\d{2})-(\d{2}))\s((\d{2}):(\d{2}):(\d{2}))', r'日期:\1 时间:\5 \2年\3月\4日 \6时\7分\8秒', '2021-09-21 18:01:23')
Out[43]: '日期:2021-09-21 时间:18:01:23 2021年09月21日 18时01分23秒'
命名分组:声明 (?P<name>expr)
、引用 \g<name>
In [47]: re.sub(r'(?P<date>(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2}))\s(?P<time>(?P<hour>\d{2}):(?P<minute>\d{2}):(?P<second>\d{2}))', r'日期:\g<date> 时间:\g<time> \g<year>年\g<month>月\g<day>日 \g<hour>时\g<minute>分\g<second>秒', '2021-09-21 18:01:23')
Out[47]: '日期:2021-09-21 时间:18:01:23 2021年09月21日 18时01分23秒'
1.5.6 课后练习
有一篇英文文章,里面有一些单词连续出现了多次,我们认连续出现多次的单词应该是一次,比如:
the little cat cat is in the hat hat hat, we like it.
其中 cat 和 hat 连接出现多次,要求处理后结果是:
the little cat is in the hat, we like it.
正则表达式该如何写呢?
…
首先,我们思考下,相邻的重复单词如何匹配?
(\w+)\s\1
但是,这能达到效果吗?似乎不能
为什么呢?问题处在了哪呢?
通过右侧的 MATCH INFORMATION
可以看到,第二次 Match hat hat
,只匹配到了两次重复,这说明我们引用的次数不够多?我们再添加一次引用试试…
Em…这次虽然是匹配上了 hat hat hat
,但是 cat cat
没有丢失匹配了,梳理下目前的逻辑:
(\w+)
:匹配一个单词\s\1\s\1
:引用分组,匹配两(1+2)个重复单词
将规则合起来理解,匹配三个重复出现的单词,oh,这么看来丢失cat cat
匹配就不足为奇了…可如何解决呢?
我们再看下后半部分的规则 \s\1\s\1
这规则不是等同于 (\s\1)+
吗?
修改之后,我们再梳理下当前规则:
(\w+)
:匹配一个单词(\s\1)+
:引用分组,匹配两一到多个重复单词
看起来似乎有门儿啊!~ 试试…
我们再放到 Python 中尝试下~
Python 示例:
In [48]: text = 'the little cat cat is in the hat hat hat, we like it.'
In [49]: regex = r'(\w+)(\s\1)+'
In [50]: subex = r'\1'
In [51]: re.sub(regex, subex, text)
Out[51]: 'the little cat is in the hat, we like it.'
OK,搞定!
1.6 断言
1.6.1 什么是断言?
简单来说,断言(Assertion)是指对匹配到的文本位置有要求,例如: \d{11}
能匹配 11 位数字,但是它对数字本身的位置是没有要求的,如图所示:
这在某些场景下是不符合要求的,为了解决这个问题,正则中提供了一些结构,用于限定匹配位置,使得匹配规则不仅局限在文本内容本身,这种结构就是 断言
1.6.2 断言的分类
常见的断言有三种:单词边界、行的开始或结束 以及 环视
- 单词边界:以
\b
表示单词的边界,简单来说就是单词(\w+
)的两边不能是[a-zA-Z0-9_]
范围内的字符 - 行开始/结束:以
^
、$
对行首行尾的内容进行位置界定,以啥开头,以啥结尾 - 环视:环视指 匹配部分前后需要满足(或不满足)某种规则,属于单词边界的进阶版(更灵活也更复杂)
1.6.3 单词边界(Word Boundary)
英文单词基本组成无非是大小写英文字母,可以用 \w+
或 [a-zA-Z]
去匹配,而分隔单词边界通常是 引号'
、空格
、标点
、换行
等
在正则表达式中,提供了原生的单词边界符 \b
, \b
中的 b
可以理解为是 边界(Boundary
)这个单词的首字母
举个例子,将下面的 tom
替换成 jerry
tom asked me if I would go fishing with him tomorrow.
如果按照之前的思路,肯定是不行的
这时,我们引入 单词边界符 \b
,如下图所示:
Python 示例:
In [10]: text = "tom asked me if I would go fishing with him tomorrow.\nhi tom, i 'm ratomas"
In [11]: regex = r'\btom\b'
In [12]: from pprint import pprint
In [13]: pprint(re.sub(regex, 'jerry', text, re.M))
('jerry asked me if I would go fishing with him tomorrow.\n'
"hi jerry, i 'm ratomas")
当 tom
两边加上 单词边界符 (\b)
后实现精确匹配替换,为了加深理解,这里梳理一个表格
单词 | tom 单词包含 tom |
\btom 以 tom 开头的单词 |
tom\b 以 tom 结尾的单词 |
\btom\b 精确匹配 tom |
---|---|---|---|---|
tom |
✔ | ✔ | ✔ | ✔ |
tomorrow |
✔ | ✔ | ✖ | ✖ |
atom |
✔ | ✖ | ✔ | ✖ |
atomic |
✔ | ✖ | ✖ | ✖ |
1.6.4 行开始/结束
与 单词边界符 类似,^
与 $
用来要求每一行的行首/行尾所必须匹配的内容,在讨论具体用法前,我们先明确下有关 行
的概念
通俗来说,一个换行符算是一行,哪怕这一行什么内容都没有,它也算是一行,所以 换行符
是很重要的概念,在不同的平台上换行的表现形式有所不同,具体如下:
平台 | 换行符 |
---|---|
Windows | \r\n |
Linux | \n |
Mac | \n |
OK,明确了行、换行符的概念,我们开始讨论 ^
、$
的使用场景,不知是否还记得,早先我们写过一个巨长的正则,用来匹配 Nginx 错误日志:
In [89]: regex = '''(?mx)
...: ^ # 开头
...: (?P<datetime>\d{4}/\d{2}/\d{2}\s\d{2}:\d{2}:\d{2})\s # 日期时间
...: \[(?P<level>\w+)\]\s # 日志级别
...: (?P<pid>\d+)\#\d+\:\s\*\d+\s # 进程ID、分组外是线程ID、客户端ID
...: (?P<message>\w+\(\)\s\".*\"\s\w+\s\(.*\)), # 错误日志,包括系统调用、url、错误描述
...: \s\w+:\s(?P<client_ip>\d+\.\d+\.\d+\.\d+), # 客户端IP
...: \s\w+:\s(?P<server_host>.*), # 虚拟主机名称
...: \s\w+:\s\"(?P<method>\w{3,7})\s # 请求方法
...: (?P<url>.*)\s # 请求URL
...: (?P<protocol>\w{4}\/\d.\d)\", # 协议及版本
...: \s\w+:\s\"(?P<request_host>.*?)\" # 请求主机名(域名)
...: (?:,\s\w+:\s\"(?P<referrer>.*)\")? # 来源地址
...: $ # 结束
...: '''
在这里我们就用到了 ^
、$
行首行尾限界符
^
:要求每行必须以日期时间开头$
:要求每行必须以"
结尾
除此之外呢,首尾限界符还常用于 数据校验,如下所示:
参数要求输入 6 位数字,我们可以使用 \d{6}
来校验,但这存在一些问题:
In [2]: import re
# 用户正常输入 6 位数字,通过
In [3]: re.search(r'\d{6}', '123456') is not None
Out[3]: True
# 用户输入 7 位数字,通过
In [4]: re.search(r'\d{6}', '1234567') is not None
Out[4]: True
# 用户输入 1 个字母、7 位数字,通过
In [5]: re.search(r'\d{6}$', 'a1234567') is not None
Out[5]: True
# 严格限定用户类型及长度,用户输入 7 位数字,失败
In [6]: re.search(r'^\d{6}$', '1234567') is not None
Out[6]: False
# 严格限定用户类型及长度,用户输入 6 位数字,通过
In [7]: re.search(r'^\d{6}$', '123456') is not None
Out[7]: True
Python 还提供了 \A
、\Z
两个首尾限界符,用来匹配整个文本的开头或结尾,如下所示:
In [20]: re.search(r'\A\d{6}\Z', '123456') is not None
Out[20]: True
In [21]: re.search(r'\A\d{6}\Z', 'a123456') is not None
Out[21]: False
In [22]: re.search(r'\A\d{6}\Z', '0123456') is not None
Out[22]: False
有的文章说 \A\Z 不受并匹配模式的影响,但目前 (Python 3.9) 测试来看,使用起来还是有很多小问题
In [26]: re.findall(r'\A\d{6}\Z', '123456') Out[26]: ['123456'] In [27]: re.findall(r'\A\d{6}\Z', '123456', re.M) Out[27]: ['123456'] In [28]: re.findall(r'\A\d{6}\Z', '123456\n123123', re.M) Out[28]: [] In [29]: re.findall(r'^\d{6}$', '123456\n123123', re.M) Out[29]: ['123456', '123123'] In [30]: re.findall(r'^\d{6}$', '123456\n123123') Out[30]: []
1.6.5 环视( Look Around)
环视,也叫 “零宽断言”,叫什么倒不重要,主要的作用在于 瞻前顾后,要求匹配部分的前面或后面要满足(或不满足)某种规则
举例说明:“邮政编码的规则一共有 6 位数字组成,首位为 1-9,现在要求你写出一个正则,提取文本中的邮政编码”
很明显,简单的 [1-9]\d{5}
是没法完成需求的(如上面几个例子),根据我们所学,单词边界能否处理呢?
如图所示:
看似它好像是完成了需求,可如果你稍微将内容变得复杂些,就会发现事实并非如此,如下所示:
Python 示例:
In [1]: import re
In [2]: re.search(r'\b[1-9]\d{5}\b', '123456') is not None
Out[2]: True
In [3]: re.search(r'\b[1-9]\d{5}\b', '012345') is not None
Out[3]: False
In [4]: re.search(r'\b[1-9]\d{5}\b', '130400') is not None
Out[4]: True
In [5]: re.search(r'\b[1-9]\d{5}\b', '405411') is not None
Out[5]: True
In [6]: re.search(r'\b[1-9]\d{5}\b', '-405411') is not None
Out[6]: True
In [7]: re.search(r'\b[1-9]\d{5}\b', '405411-') is not None
Out[7]: True
In [8]: re.search(r'\b[1-9]\d{5}\b', '!405411?') is not None
Out[8]: True
这背后的原因其实很简单,\b
是用来表示单词边界,在其中内置包含了诸如 空格
、回车
、换行
、标点
等分隔单词的符号,但不代表它其中包含了所有的特殊字符
,所以场景需要严格数据校验,更推荐环视
环视的书写规则有 4 中,如下表所示:
正则 | 名称 | 含义 | 示例 |
---|---|---|---|
(?<=元字符)<expr> |
肯定逆序环视(postive-lookahead) | 要求 左边字符 匹配 元字符 | ![]() |
(?<!元字符)<expr> |
否定逆序环视(negative-lookahead) | 要求 左边字符 不匹配 元字符 | ![]() |
<expr>(?=元字符) |
肯定顺序环视(postive-lookbehind) | 要求 右边字符 匹配 元字符 | ![]() |
<expr>(?!元字符) |
否定顺序环视(negative-lookbehind) | 要求 右边字符 不匹配 元字符 | ![]() |
诚然,这几个规则是比较难写的,除了多写没啥好的技巧,不过在阅读理解是有敲门的,说白点就四就话:
左尖看左面:
(?<=元字符)<expr>
,(?<!元字符)<expr>
问号看右面:
<expr>(?=元字符)
,<expr>(?!元字符)
尖叹左取反:
(?<!元字符)<expr>
问叹右取反:
<expr>(?!元字符)
大致弄清楚环视后,我们开始解决 单词边界符 所解决不了的邮编匹配问题
示例一:邮编匹配
邮编匹配除了本身的规则意外,我们还要求其前后不能存在别的无关字符。其实,在实际场景中,我们经常的做法是,先判断参数长度,后校验数据
不过这里是为了学习理解 环视 这个概念,通过简单的分析,结合上面表格中的 环视规则,可以比较轻松的写出解决办法
正则规则:(?<!.)[1-9]\d{5}(?!.)
Python 示例:
In [13]: text = '''012345
...: 130400
...: 405411
...: 405411-
...: 405411$
...: !405411?
...: 45001123
...: 13800138000'''
In [14]: regex = r'(?<!.)[1-9]\d{5}(?!.)'
In [15]: re.findall(regex, text)
Out[15]: ['130400', '405411']
尖叹左取反,问叹右取反,前后都不允许出现任意字符,直接加上 .
通配符,搞定!
示例二:单词匹配
同理,我们再思考下,如果用 环视 思路实现获取文中单词
Python 示例:
In [16]: text = '''the little cat is in the hat'''
In [17]: regex = r'(?<!\w)\w+(?!\w)'
In [18]: re.findall(regex, text)
Out[18]: ['the', 'little', 'cat', 'is', 'in', 'the', 'hat']
1.6.6 课后练习
有一篇英文文章,里面有一些单词连续出现了多次,我们认连续出现多次的单词应该是一次,比如:
the little cat cat2 is in the hat hat2, we like it.
其中 cat 和 hat 连接出现多次,要求处理后结果是:
the little cat is in the hat, we like it.
原先的正则是 (\w+)(?:\s\1)+
,数据发生变后,规则也需要调整了~
那么,新的正则表达式该如何写呢?
…
解法一:(\w+)(?:\s\1(\d)?)+
思路比较简单,完善后一个表达式的匹配,避免最终内容出现数字
Python 示例:
In [37]: text = """the little cat cat1 is in the hat hat hat, we like it.
...: the little cat cat2 is in the hat hat2, we like it."""
In [38]: from pprint import pprint
In [39]: pprint(re.sub(r'(\w+)(?:\s\1(\d)?)+', r'\1', text))
('the little cat is in the hat, we like it.\n'
'the little cat is in the hat, we like it.')
解法二:
目前没有想到如何使用 断言 实现上面效果的方法,(/ □ )…
1.7 转义
转义对我们来说都不算陌生,当某些符号影响代码逻辑时,我们通常都会对其进行 转义操作,如下:
In [42]: print('i 'm a theacher.')
File "<ipython-input-42-800033b842ab>", line 1
print('i 'm a theacher.')
^
SyntaxError: invalid syntax
In [43]: print('i \'m a theacher.')
i 'm a theacher.
Python 中的 '
单引号特殊的含义被 \
反斜线转换为 普通字符串,这就是为什么 \
被称为 转义符号 的原因了,因为它后面的字符,不是原来的意思了
在正则中,转义也门学问,正确且系统的掌握什么时候需要转义,什么时候不用转义,会减少以后工作中遇到的小麻烦
1.7.1 转移符号(Escape Character)
首先,我们说一下什么是转义字符,维基百科的解释是:所谓转义字符,即标志着转义序列开始的那个字符
在计算机科学与远程通信中,当 转义字符 放在 字符序列 中,它将对它 后续的几个字符 进行 替代并解释。通常,判定某字符是否为转义字符由上下文确定,所谓转义字符即标志着转义序列开始的那个字符。
不太好理解,通俗来说,转义序列有两种功能:
- 编码(动词)对无法用字母表直接表示的特殊数据进行
- 表示无法直接键盘录入的字符
在不同平台(协议),转义字符也有所不同
- 编程语言:如 C、Python 等都是使用
\
反斜线作为 转义字符 - 协议:如 URL 协议 使用
%
百分号作为 转义字符
在日常工作中经常会遇到转义字符,比如我们在 shell 中删除文件,如果文件名中有 * 号,我们就需要转义
$ rm access_log* # 删除当前目录下 access_log 开头的文件
$ rm access_log\* # 删除当前目录下名字叫 access_log* 的文件
下面是一些常见的转义字符及含义
转义符号 | 含义 | ASCII 码值(十进制) |
---|---|---|
\n |
换行(LF),将当前位置移到下一行开头 | 010 |
\r |
回车(CR),将当前位置移到本行开头 | 013 |
\t |
水平制表(HT),跳到下一个 TAB 位置 | 009 |
\v |
垂直制表(VT) | 011 |
\\ |
表示一个反斜线字符 | 092 |
\' |
表示一个单引号字符 | 039 |
\" |
表示一个双引号字符 | 034 |
1.7.2 转义的内部过程
正则中 \d
代表的是单个数字,但如果我们想匹配成 反斜杠和字母d
,这时候就需要进行转义,写成 \\d
,这个就表示匹配 反斜杠后面紧跟着一个字母d
如果想匹配 字符\
与 d
,那么可以这么做
而在 Python 中使用转移符号有那么几个细小的差异,具体是什么,看下嘛的例子
In [44]: re.findall(r'\\d', 'a*b+c?\d123d\')
File "<ipython-input-44-8cd73b954637>", line 1
re.findall(r'\\d', 'a*b+c?\d123d\')
^
SyntaxError: EOL while scanning string literal
In [45]: re.findall(r'\\d', 'a*b+c?\d123d\\')
Out[45]: ['\\d']
这个错误不难理解,当 \
临近 '
时,导致单引号被转义,所以为了避免这个问题,我们先对 \\
进行转义
哪假如我们把 r
去掉,再执行试下,不出意外,你会看到如下内容,思考下为什么会出现这种情况呢?
In [53]: re.findall('\\d', 'a*b+c?\d123d\\')
Out[53]: ['1', '2', '3']
要理解这个现象,就一定要深入理解下 转义的过程
在程序使用过程中,从输入的字符串到正则表达式,其实有两步转换过程,分别是 字符串转义 和 正则转义
我们输入的字符串,四个反斜杠 \\d
后
- 首先经过 第一步 字符串转义,从
\\d
→ 转换为\d
- 随后经过 第二部 正则转义,从
\d
→ 元字符所代表的原有含义
而 r
的作用就在直接声明,我们编写的字符串是正则表达式,不要对我写的内容进行字符串转义,所以 \d
直接可以到数字,\\d
可以匹配到 字符 \d
1.7.3 元字符的转义
前面我们讨论了很多 元字符,\d
、\w
、*
…如果现在我们要查找比如星号 *
、 +
、?
本身,而不是元字符的功能,相信大家也都知道怎么做了,同理,各类括号操作也是大同小异
In [58]: re.findall('\+', '+')
Out[58]: ['+']
In [60]: re.findall('\(\)\[]\{}', '()[]{}')
Out[60]: ['()[]{}']
只有 ()
需要稍微注意下,由于圆括号通常用于分组,或者将某个部分看成一个整体,如果只转义开括号或闭括号,正则会认为少了另外一半,所以会报错
1.7.4 函数消除元字符特殊含义
上面我们尝试过,如何手动消除 \d
元字符的含义,\\\\d
—字符串转移–> \\d
—正则转移–> 字符串\d
In [71]: re.findall('\\\\d', 'a*b+c?\d123d\\')
Out[71]: ['\\d']
我们可以使用编程语言自带的转义函数来实现元字符的转义,放入 <regex>
效果跟上面是一样的
In [72]: re.escape('\d') # 反斜杠和字母d转义
Out[72]: '\\\\d'
In [71]: re.findall(re.escape('\d'), 'a*b+c?\d123d\\')
Out[71]: ['\\d']
尤其是在复杂些的表达式,用它来转移是最适合的
In [74]: re.escape('[0-9]')
Out[74]: '\\[0\\-9\\]'
In [76]: re.findall(re.escape('[0-9a-z]'), 'a*b+c?\d123d\\[0-9a-z]')
Out[76]: ['[0-9a-z]']
小节下,转义函数 可以将整个文本转义,一般用于转义用户输入的内容,即把这些内容看成普通字符串去匹配,其他编程语言同样也转义函数,如下

1.7.5 字符组转义
所谓字符组指定是位于 []
中的字符,在字符组中,如果有过多的转义会导致代码可读性差,通常只有三种情况需要进行转义:
1.7.5.1 转义首位脱字符^
转义前,^
在字符组中 [^...]
表示 “非“,例如 [^ab]
,不匹配 a
与 b
,转义后 ^
仅作为普通字符,具体效果以下示例:
In [1]: import re
In [2]: re.findall(r'[^ab]', '^abc')
Out[2]: ['^', 'c']
In [3]: re.findall(r'[\^ab]', '^abc')
Out[3]: ['^', 'a', 'b']
1.7.5.2 转义不在首位的中杠
转义前,-
在字符组中 [a-z]
表示 “区间“,例如:[a-z]
从 a
到 z
小写字母的组合,转义后 -
仅作为普通字符,具体效果以下示例:
In [5]: re.findall(r'[a-c]', 'abc-')
Out[5]: ['a', 'b', 'c']
In [6]: re.findall(r'[a\-c]', 'abc-')
Out[6]: ['a', 'c', '-']
如果 -
是在首尾则不需要转义
In [7]: re.findall(r'[-ac]', 'abc-')
Out[7]: ['a', 'c', '-']
In [8]: re.findall(r'[ac-]', 'abc-')
Out[8]: ['a', 'c', '-']
1.7.5.3 转义不在首位的右中括号
转义前,]
是用来标明字符组结束符的,转义后 ]
仅作为普通字符,具体效果以下示例:
In [10]: re.findall(r'[a]b]', ']abc')
Out[10]: []
这里没有匹配上的原因在于,第一个 ]
被当作了字符组结束符,那么完整的规则是,匹配 [a]
且后面要伴随 b]
两个字符,很明显 ]abc
是匹配不是的
所以,如题,当右中括号不在首位时需要转义,如下:
In [11]: re.findall(r'[a\]b]', ']abc')
Out[11]: [']', 'a', 'b']
同样当 ]
出现在结尾时也需要转义
In [13]: re.findall(r'[ab]]', ']abc')
Out[13]: []
In [14]: re.findall(r'[ab\]]', ']abc')
Out[14]: [']', 'a', 'b']
只有当 ]
出现在 开头 时才不用转义
# ] 与 [ 结对出现在开头
In [12]: re.findall(r'[]ab]', ']abc')
Out[12]: [']', 'a', 'b']
1.7.5.4 字符组中不需要转义的元字符
在字符组中有些元字符,只要丢进 []
中就天然的转义了,例如:
In [15]: re.findall(r'[.*+?()]', '.*+?()')
Out[15]: ['.', '*', '+', '?', '(', ')']
但是,一旦你使用 \d
、\w
等元字符,.*+?()
又会恢复本身的意义,如下:
In [18]: re.findall(r'[\d.*]', 'd12\\')
Out[18]: ['1', '2']
In [19]: re.findall(r'[\d+]', 'd12\\')
Out[19]: ['1', '2']
In [20]: re.findall(r'[\d+\\]', 'd12\\')
Out[20]: ['1', '2', '\\']
1.7.6 课后练习
你能否解释出以下四个示例中的转义过程呢?
In [1]: import re
In [2]: re.findall('\n', '\\n\n\\')
Out[2]: ['\n'] # 匹配到了换行符
In [3]: re.findall('\\n', '\\n\n\\')
Out[3]: ['\n'] # 匹配到了换行符
In [4]: re.findall('\\\n', '\\n\n\\')
Out[4]: ['\n'] # 匹配到了换行符
In [5]: re.findall('\\\\n', '\\n\n\\')
Out[5]: ['\\n'] # 匹配到了 反斜杠 和 字母n
二、深入进阶
2.1 正则演变及流派
首先,先看一张图…
很奇怪,对吧,正则在 Linux 的应用怎么跟编程语言中区别如此大呢?产生 “淮南为橘,淮北为枳” 的现象呢?这一切的一切都和正则的演变有着密不可分的关系
2.1.1 正则表达式简史
2.1.1.1 缘起
正则表达式的起源,可以追溯到到 20 世纪 40 年代,有两位神经生理学家(Warren McCulloch)与(Walter Pitts),他们研究出了一种用数学方式来描述神经网络的方法
10 余年后,到了 1956 年,有一位数学家(Stephen Kleene)发表了一篇标题为《 神经网络事件表示法和有穷自动机》的论文,论文中欧冠描述了一种叫做 “正则集合(Regular Sets
)”的符号
随后,大名鼎鼎的 Unix 之父 Ken Thompson 于 1968 发表了文章《正则表达式搜索算法》,同时将 正则表达式算法 真正的引入到其开发的 qed
编辑器中,很快 正则表达式算法又移植到编辑器 ed
,以及直至现在日常必用的 grep
中。自此,正则表达式被广泛应用到 Unix 系统 或 类Unix系统(如 macOS、Linux)中的各种工具里
2.1.1.2 岔路
POSIX 流派的诞生
随后,由于正则功能强大,非常实用,越来越多的语言和工具都开始支持正则。不过遗憾的是,由于没有尽早确立标准,导致各种语言和工具中的正则虽然功能大致类似,但仍然有不少细微差别
1986 POSIX 开始进行标准化的尝试,它作为一系列规范,定义了 Unix 操作系统应当支持的功能,其中也包括正则表达式的规范,因此,类 Unix 系统上的大部分工具,如 grep
、sed
、awk
等,均遵循该标准
这些遵循 POSIX 正则表达式规范的正则表达式,被世人统称为 “POSIX 流派的正则表达式“
PCRE 横空出世
但是,谁成想,Perl 语言杀了出来,在 1987 年 12 月,LarryWall 发布了第一版的 Perl 语言,因其功能强大而一票走红,其中包括它的正则表达式功能,随着 Perl 语言的发展,Perl 语言中的正则表达式功能不断改进、优化,越来越强悍,为了把 Perl 语言中正则的功能移植到其他语言中,PCRE 在 1997 年就诞生了!
PCRE
,Perl Compatible Regular Expressions,PCRE 是一个兼容 Perl 语言正则表达式的解析引擎,由 Philip Hazel 开发,为当下许多语言、工具、平台普遍使用,PCRE 现已成为大部分语言和工具的隐然遵循的事实标准
2.1.2 正则表达式流派
到目前为止,正则表达式在各种计算机语言或各种应用领域得到了更为广泛的应用和发展,两个流派也有各自市场
- POSIX 流派:类 Unix 上的工具遵循 POSIX 标准
- PCRE 流派:编程语言及工具遵循 PCRE 标准
2.1.2.1 POSIX 流派
我们先简要介绍一下 POSIX 流派,POSIX 规范中定义了正则表达式的两种标准:
- BRE 标准(Basic Regular Expression) 基本正则表达式
- ERE 标准(Extended Regular Expression) 扩展正则表达式
BRE 标准
早期 BRE 标准 不支持量词(?+
),也不支持多选分支结构管道符(|
),并且在使用区间量词花括号{}
,圆括号()
时要转义才能表示特殊含义,这用起来肯定不爽啊,于是才有了 ERE 标准
ERE 标准
ERE 标准中使用花括号,圆括号时不需要转义了,还支持了问号、加号 和 多选分支
GNU 套件
现在使用的 Linux 发行版,大多都集成了 GNU 套件,GNU 在实现 POSIX 标准(BRE、ERE)时做了一定的扩展:
- GNU BRE 扩展支持
+?
,但是使用时需要转移\+
、\?
- GNU BRE 支持管道符多选分支结构
|
,同样需要转义,即用\|
表示 - GNU ERE 也支持使用反引用,和 BRE 一样,使用
\1\2...
表示
我们将上面的内容做个总结,如下图,浅黄色背景是 BRE 和 ERE 不同的地方、三处天蓝色字体是 GNU 扩展
总的来说,GNU BRE
和 GNU ERE
没什么用大的不同,主要区别是某些字符要不要转义,如{}()+|
等
POSIX 字符组
POSIX 流派还有一个特殊的地方,那就是它独有的字符组,也叫 POSIX 字符组,类似于 \d
描述数字 [0-9]
,\w
描述 [0-9a-zA-Z_]
,具体的表格与解释,见下表:
POSIX 字符组 | 解释 | 等价表示 | 备注 |
---|---|---|---|
[[:alnum:]] |
数字和字母 | [0-9a-zA-Z] |
比\w 少了_ |
[[:alpha:]] |
字母 | [a-zA-Z] |
|
[[:ascii:]] |
ASCII | [\x00-\x7F] |
|
[[:blank:]] |
空格和制表符 | [\s\t] |
|
[[:cntrl:]] |
控制字符 | [\x00-\x1F\x7F] |
|
[[:digit:]] |
数字 | [0-9] |
等同 \d |
[[:graph:]] |
可见字符 | [!~~] 、[0-9a-zA-Z!@#$%^&*()+,.’-/:<;=?>[]^_`{|}~] |
|
[[:lower:]] |
小写字母 | [a-z] |
|
[[:upper:]] |
大写字母 | [A-Z] |
|
[[:print:]] |
可打印字符 | [\s-~] 、[[:graph:]] |
比 graph 多了空格 |
[[:punct:]] |
标点符号 | [!-/:-@[-`{~] | |
[[:space:]] |
空白符号 | [\t\n\r\v\f] |
|
[[:xdigit:]] |
16进制数字 | [0-9A-Fa-f] |
2.1.2.2 PCRE 流派
目前大部分常用编程语言都是源于PCRE标准,这个流派显著特征是有 \d
、\w
、\s
这类字符组简记方式
兼容问题
虽然 PCRE 流派是从 Perl 语言中衍生出来的,但是各语言在实现 preg 正则表达式 时多多少少都会存在些许差异,这一点从之前我们的学习过程中也能体会到…
理论上来说 PCRE 流派是与 Perl 正则表达式相兼容的流派,但落到实际上,各种语言和工具中还存在程度上的差别,主要分两种情况:
- 直接兼容
- 间接兼容
直接兼容
PCRE 流派中与 Perl 正则表达式直接兼容的语言或工具,比如 Perl、PHPpreg、PCRE 库等,一般称之为 Perl 系
间接兼容
比如 Java 系(包括Java、Groovy、Scala等)、Python 系(包括 Python2/3)、JavaScript 系(包括原生 JavaScript 和扩展库 XRegExp)、Net系(包括C#、VB。Net等)等
2.1.3 linux 中使用正则
在遵循 POSIX 规范的 UNIX/LINUX 系统上,不同工具可能遵循的标准不同,例如:
- 遵循 BRE 标准:
grep
、sed
、vi/vim
等 - 遵循 ERE 标准:
egrep
、awk
等
通过下图,我们可以相对直观的观察到 PCRE 流派
与 POSIX 流派
在 Linux 系统上的差异
虽然刚刚我们提到不同工具可能遵循不同标准,但是,部分工具在实现时它兼容多套正则标准,比如 grep
、sed
,示例如下:
# 使用 ERE 标准
☁ /tmp $ grep -E '[[:digit:]]' testfile
123456
# 使用 PCRE 标准
☁ /tmp $ grep -P '\d+' testfile
123456
倘若,在使用命令前,你不清楚当前工具属于哪个流派,可以通过 Linux 中 man 命令,查看它所只支持的标准,比如:
☁ /tmp $ man grep
# 搜索 regex
-E, --extended-regexp
Interpret PATTERN as an extended regular expression (ERE, see below). (-E is specified by POSIX.)
-F, --fixed-strings, --fixed-regexp
Interpret PATTERN as a list of fixed strings, separated by newlines, any of which is to be matched. (-F is specified by POSIX,
--fixed-regexp is an obsoleted alias, please do not use it in new scripts.)
-G, --basic-regexp
Interpret PATTERN as a basic regular expression (BRE, see below). This is the default.
-P, --perl-regexp
Interpret PATTERN as a Perl regular expression. This is highly experimental and grep -P may warn of unimplemented features.
通过帮助信息可以得知,grep
支持 -G <BRE>
、-E <ERE>
、-P <PCRE>
两种流派,三种标准
再比如 sed
☁ /tmp $ man sed
# 搜索 regex
-r, --regexp-extended
use extended regular expressions in the script.
可以看到它还支持 -r
应用 ERE
标准
2.1.4 课后练习
Linux 中使用 grep 命令练习查找包含一到多个数字的行
123456
abcdef
\d
\d+
d+
解法:
☁ /tmp $ grep -P '\d+' testfile
123456
☁ /tmp $ grep -E '[[:digit:]]' testfile
123456
☁ /tmp $ egrep '[[:digit:]]' testfile
123456
☁ /tmp $ egrep '^[0-9]+$' testfile
123456
# egrep 不支持 \d 元字符
☁ /tmp $ egrep '\d+' testfile
abcdef
\d
\d+
d+
# 统计匹配规则的行数
☁ /tmp $ egrep -P '\d+' testfile
grep: conflicting matchers specified
☁ /tmp $ egrep -c '^[0-9]+$' testfile
1
☁ /tmp $ egrep -c '\\' testfile
2
2.2 Unicode 万国码
2.1.1 Unicode 是什么 ?
Unicode,中文也叫万国码、国际码,是计算机科学领域里的一项业界标准
2.1.2 Unicode 标准的作用
Unicode 通过对世界上大部分的文字进行了整理、编码,使得计算机呈现和处理各国文字变得简单,时至今日仍在不断增修,每个新版本都加入更多新的字符,目前 最新版本为 14.0.0(2021.9.14 发布),总共 144697
个字符
2.1.3 Unicode 平面分组
Unicode 字符分为 17 组 编排,每组为一个 平面(Plane
),每个平面拥有 65536(即2的16次方)个 码值(CodePoint
)
就目前而言,我们用到的绝大多数字符都属于 第 0 号平面,即 BMP 平面,除了它之外的其它的平面,我们统称为 补充平面
各个平面大致介绍见下表:
2.1.4 Unicode 编码
目前,Unicode 使用 4 个字节的编码表示一个字符(可以是全世界所有语言的字符),那么 Unicode 在计算机中如何存储和传输的呢?这就涉及部分编码的知识了…
首先,Unicode 规定了每个字符对应的 码值,这个 码值 得 编码 成 字节 的形式去 传输和存储,最常见的编码方式为 UTF-8
,除此外还有 UTF-16,UTF-32
等UTF-16,UTF-32
为什么现在最常见的是 UTF-8 呢?两个主要原因
- 兼容 ASCII 编码
- 采用的是变长的方法:根据不同类型字符占用 1 到 4 个字节不等,例如表示纯英文时,可能只有 1 字节,表示汉字时通常占用 3 字节
Python UTF-8 编码示例:
In [9]: u'正'.encode('utf-8')
Out[9]: b'\xe6\xad\xa3'
In [10]: u'则'.encode('utf-8')
Out[10]: b'\xe5\x88\x99'
Unicode 和 UTF-8 的转换规则表(补充了解)
Unicode | Bit 数 | UTF-8 | byte 数 |
---|---|---|---|
0000-007f | 0-7 | 0XXX XXXX | 1 |
0080-07FF | 8-11 | 110X XXXX 10XX XXXX |
2 |
0800-FFFF | 12-16 | 1110 XXXX 10XX XXXX 10XX XXXX |
3 |
10000-1FFFFF | 17-21 | 1111 0XXX 10XX XXXX 10XX XXXX 10XX XXXX |
4 |
2.1.5 Unicode & 正则
2.1.5.1 编码中的坑
2.1.5.1.1 中文匹配
在 Python 语言中使用正则优先选择 Python 3(当下基本也都是这样),为什么?先看个例子:
>>> import re
>>> re.search(r'[时间]', '极客') is not None
True
诡异吗?确实很诡异…正则规则明明只匹配时间
两字,为何与 极客
匹配上了?
我们深入分析下,在不显示使用 Unicode 编码时,正则会按照系统默认配置进行编码表示,比如,在macOS或Linux下,一般会编码成 UTF-8,而在 Windows 下一般会编码成 GBK
# 查看系统编码
☁ ~ $ locale
LANG=en_US.UTF-8
LC_CTYPE="en_US.UTF-8"
LC_NUMERIC="en_US.UTF-8"
LC_TIME="en_US.UTF-8"
LC_COLLATE="en_US.UTF-8"
LC_MONETARY="en_US.UTF-8"
LC_MESSAGES="en_US.UTF-8"
LC_PAPER="en_US.UTF-8"
LC_NAME="en_US.UTF-8"
LC_ADDRESS="en_US.UTF-8"
LC_TELEPHONE="en_US.UTF-8"
LC_MEASUREMENT="en_US.UTF-8"
LC_IDENTIFICATION="en_US.UTF-8"
LC_ALL=
知晓我们系统编码后,我们手动获取 极客
与 时间
二字的编码结果
>>> u'极客'.encode('utf-8')
'\xe6\x9e\x81\xe5\xae\xa2' # 含有 e6
>>> u'时间'.encode('utf-8')
'\xe6\x97\xb6\xe9\x97\xb4' # 含有 e6
可以看到,这两个词语都含有 16进制表示的 e6,这就是前面会匹配上的原因吗?我们再用 findall
方法确认下:
>>> re.findall(r'[时间]', '极客')
['\xe6']
果然如此,其实的匹配规则基本等同于
# [时间] # 极客
>>> re.findall(r'[\xe6\x97\xb6\xe9\x97\xb4]', '\xe6\x9e\x81\xe5\xae\xa2')
['\xe6']
这就是 Unicode 第一个坑——中文匹配问题
2.1.5.1.2 点号通配
我们了解过,.
点号可以匹配除了换行符以外的任何字符,不过,之前测试学习大多用的是单字节字符,而 Unicode 可是多字节的,那么点号可以匹配上Unicode 字符么?
Python 2.7 示例:
>>> import re
>>> re.findall(r'^.$', '学')
[]
>>> re.findall(r'^.$', u'学')
[u'\u5b66']
>>> print('\u5b66')
\u5b66
>>> re.findall(ur'^.$', u'学')
[u'\u5b66']
>>> print(u'\u5b66')
学
Python 3.9 示例:
In [2]: re.findall(r'^.$', '学')
Out[2]: ['学']
In [3]: re.findall(r'(?a)^.$', '学')
Out[3]: ['学']
(?a) 属于内联标识,等同于
re.A|ASCII
,作用如下:让
\w
,\W
,\b
,\B
,\d
,\D
,\s
和\S
只匹配 ASCII,而不是 Unicode,该标识只对 Unicode 内容有效,byte 内容不生效
OK,可以看到在 Python 2.X 的版本中 .
点号无法匹配多字节字符,其他语言的情况还需要多测试…
2.1.5.1.3 字符组匹配
用正则的字符组匹配全角的字符
Python 2.7.5 示例:
>>> re.findall(r'\d', u'0123456789')
[]
>>> re.findall(r'\s', u'0123456789')
[]
>>> re.findall(r'\s', u'0123456789 abd!@$')
[]
>>> re.findall(r'[a-zA-Z]', u'0123456789 abd!@$')
[]
>>> re.findall(r'\w', u'0123456789 abd!@$')
[]
>>> re.findall(r'\S', u'0123456789 abd!@$')
[u'\uff10', u'\uff11', u'\uff12', u'\uff13', u'\uff14', u'\uff15', u'\uff16', u'\uff17', u'\uff18', u'\uff19', u'\u3000', u'\uff41', u'\uff42', u'\uff44', u'\uff01', u'\uff20', u'\uff04']
Python 3.9 示例:
In [6]: re.findall(r'\d', u'0123456789')
Out[6]: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
In [18]: re.findall(r'\s', u'0123456789 abd!@$')
Out[18]: ['\u3000']
In [19]: re.findall(r'[a-zA-Z]', u'0123456789 abd!@$')
Out[19]: []
In [20]: re.findall(r'[a-z]', u'0123456789 abd!@$')
Out[20]: ['a']
In [21]: re.findall(r'[a-z]', u'0123456789 abd!@$')
Out[21]: ['a', 'b', 'd']
In [22]: re.findall(r'\w', u'0123456789 abd!@$')
Out[22]: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'd']
In [23]: re.findall(r'\S', u'0123456789 abd!@$')
Out[23]:
['0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'a',
'b',
'd',
'!',
'@',
'$']
在匹配全角符时,匹配规则也得保持抑制或使用\d、\w
等内置元字符
2.1.5.2 Unicode 属性
Unicode 把一些相关的 Unicode 字符集划分成不同的字符小集合,通常比较常用的有三种:
- 按功能划分:
Unicode Categories(也叫 Unicode Property)
,如标点符号,数字符号 - 按连续区间划分:
Unicode Blocks
,如中日韩字符 - 按书写系统划分:
Unicode Scripts
,如汉语中文字符
下图是相对详细的表格:
在正则中通过 \p{属性}
使用这些 Unicode 属性,如下:
Python 示例:
内置的 re 库不支持 unicode 属性
In [8]: re.findall(r'\p{Han}+', '轻轻地我走了')
---------------------------------------------------------------------------
error Traceback (most recent call last)
<ipython-input-8-3c427821623e> in <module>
----> 1 re.findall(r'\p{Han}+', '轻轻地我走了')
~/.pyenv/versions/3.9.0/lib/python3.9/re.py in findall(pattern, string, flags)
239
240 Empty matches are included in the result."""
--> 241 return _compile(pattern, flags).findall(string)
242
243 def finditer(pattern, string, flags=0):
~/.pyenv/versions/3.9.0/lib/python3.9/re.py in _compile(pattern, flags)
302 if not sre_compile.isstring(pattern):
303 raise TypeError("first argument must be string or compiled pattern")
--> 304 p = sre_compile.compile(pattern, flags)
305 if not (flags & DEBUG):
306 if len(_cache) >= _MAXCACHE:
~/.pyenv/versions/3.9.0/lib/python3.9/sre_compile.py in compile(p, flags)
762 if isstring(p):
763 pattern = p
--> 764 p = sre_parse.parse(p, flags)
765 else:
766 pattern = None
~/.pyenv/versions/3.9.0/lib/python3.9/sre_parse.py in parse(str, flags, state)
946
947 try:
--> 948 p = _parse_sub(source, state, flags & SRE_FLAG_VERBOSE, 0)
949 except Verbose:
950 # the VERBOSE flag was switched on inside the pattern. to be
~/.pyenv/versions/3.9.0/lib/python3.9/sre_parse.py in _parse_sub(source, state, verbose, nested)
441 start = source.tell()
442 while True:
--> 443 itemsappend(_parse(source, state, verbose, nested + 1,
444 not nested and not items))
445 if not sourcematch("|"):
~/.pyenv/versions/3.9.0/lib/python3.9/sre_parse.py in _parse(source, state, verbose, nested, first)
523
524 if this[0] == "\\":
--> 525 code = _escape(source, this, state)
526 subpatternappend(code)
527
~/.pyenv/versions/3.9.0/lib/python3.9/sre_parse.py in _escape(source, escape, state)
424 if len(escape) == 2:
425 if c in ASCIILETTERS:
--> 426 raise source.error("bad escape %s" % escape, len(escape))
427 return LITERAL, ord(escape[1])
428 except ValueError:
error: bad escape \p at position 0
第三方 regex 模块提供了 完整的 Unicode 支持
中文汉字 \p{Han}
In [7]: regex.findall(r'\p{Han}+', '轻轻地我走了')
Out[7]: ['轻轻地我走了']
标点符号 \p{P}
In [7]: regex.findall(r'\p{P}', ',.!?:[]"",。!?-【】《》「」『』——~“”')
Out[7]:
[',',
'.',
'!',
'?',
':',
'[',
']',
'"',
'"',
',',
'。',
'!',
'?',
'-',
'【',
'】',
'《',
'》',
'「',
'」',
'『',
'』',
'—',
'—',
'“',
'”']
数字符号 \p{N}
In [6]: regex.findall(r'\p{N}', '①⒉⑶❹Ⅴ㈥柒')
Out[6]: ['①', '⒉', '⑶', '❹', 'Ⅴ', '㈥']
箭头符号 \p{Arrows}
In [8]: regex.In [8]: regex.findall(r'\p{Arrows}', '↖ ↗ ↙ ↘ ← → ↓ ↑ ↕ ↨ ↔ ☜ ☞ > ≧ ≦')
Out[8]: ['↖', '↗', '↙', '↘', '←', '→', '↓', '↑', '↕', '↨', '↔']
注音字符 \p{Bopomofo}
In [9]: regex.findall(r'\p{Bopomofo}', 'ㄅㄆㄇㄈㄪㄉㄊㄋㄌㄍㄎㄫㄏㄐㄑㄬㄒㄓㄔㄕㄖㄗㄘㄙㄧㄨㄩㄚㄛㄝㄞㄟㄠㄡㄢㄣㄤㄥㄦ')
Out[9]:
['ㄅ',
'ㄆ',
'ㄇ',
'ㄈ',
'ㄪ',
'ㄉ',
'ㄊ',
'ㄋ',
'ㄌ',
'ㄍ',
'ㄎ',
'ㄫ',
'ㄏ',
'ㄐ',
'ㄑ',
'ㄬ',
'ㄒ',
'ㄓ',
'ㄔ',
'ㄕ',
'ㄖ',
'ㄗ',
'ㄘ',
'ㄙ',
'ㄧ',
'ㄨ',
'ㄩ',
'ㄚ',
'ㄛ',
'ㄝ',
'ㄞ',
'ㄟ',
'ㄠ',
'ㄡ',
'ㄢ',
'ㄣ',
'ㄤ',
'ㄥ',
'ㄦ']
2.1.5.3 表情符号
表情符号,也就是我们重用的 emoji 😀,起源于小日本,后来流行到了世界各地,小日本管它叫“绘文字”,英文名字 emoji
表情符号有如下特点:
- 许多表情不在 BMP(17个平面中最常用的 0 号平面) 内 且 码值超过了 FFFF,在用 UTF-8 编码时,ASCII 占用 1 字节,中文 3 字节,而表情通常需要 4 字节
- 表情分散在 BMP 和各个补充平面中,很难用一个正则来表示所有的表情符号
- 部分表情支持使用颜色修饰(5 种色调),使得原本 4 字节上升到 8 字节
如图所示:
安装 emoji 库
$ pip install emoji
Python 示例:
In [5]: import emoji
In [6]: emoji.emojize(':thumbs_up:')
Out[6]: '👍'
In [7]: emoji.emojize(':thumbs_up:').encode('utf-8')
Out[7]: b'\xf0\x9f\x91\x8d'
2.3 正则匹配原理及优化原则
掌握了正则的基本使用后,我们基本可以按照业务需求写出满足功能的正则规则,但满足需求只是第一步,如何在满足功能的基础上,提高规则的匹配效率,才是我们接下来该追求的。要达成这个目标,首先要对正则匹配的原理有一定的了解,这就是本节的内容
在讨论正则匹配过程前,我们先回顾下 回溯 的概念,以及 DFA、NFA 引擎的工作方式,只有了解了它们,我们才能理解正则表达式在使用上的各种小问题
2.3.1 为什么正则能处理复杂文本?
正则之所以能够处理复杂文本,是其背后依靠一种机制,“有穷状态自动机”,那什么是 有穷状态自动机 呢?
我们先把这个词语拆开:有穷状态 + 自动机
- 有穷状态:指一个系统具有有穷个状态,不同的状态代表不同的意义
- 自动机:指系统可以根据相应的条件,在不同的状态下进行转移。从一个初始状态,根据对应的操作(比如录入的字符集)执行状态转移,最终达到终止状态(可能有一到多个终止状态)
套用生活语言来说就是:
- 有穷状态:比如一个人,有“工作”,“吃饭”,“睡觉”,“空闲”等若干个状态,如果这些状态的数量是有限的,那么就是“有穷”的了
- 自动机:指自己能在不同的状态之间进行切换,下班 → 吃饭 → 空闲 → 睡觉 → 工作
有穷状态自动机(finite automaton,FA)本身是一种特定类型算法的数学方法,而正则引擎是其具体实现,主要有两种
DFA:确定性有穷自动机(Deterministic finite automaton)
NFA:非确定性有穷自动机(Non-deterministic finite automaton)
- 传统 NFA
- POSIX NFA
2.3.2 正则匹配过程
2.3.2.1 NFA 非确定性有穷自动机
在编程语言中,使用正则前通常会 compile
编译一下以此提高效率,如下所示:
In [1]: import re
In [2]: re.compile(r'a(?:bb)+a')
Out[2]: re.compile(r'a(?:bb)+a', re.UNICODE)
In [3]: reg = re.compile(r'a(?:bb)+a')
In [4]: reg.findall('abbbba')
Out[4]: ['abbbba']
我们在执行 re.compile
编译的过程实际上就是生成自动机的过程,正则引擎会通过这个自动机去和字符串进行匹配,生成状态机的过程大致是这样的,如同图所示
图像基于 Regexper 正则可视化网站 + 二次加工而成
正则首先从 a
进行匹配,当状态进入 s3 时,此时不需要输入任何字符,状态也有可能转换成 s1,因为 (bb)
后面有个 +
,再加上默认是贪婪模式,所以再匹配了字符 abb
之后,到底在 s3 状态,还是在 s1 状态,这是不确定的…
这种情况下状态机,就是非确定性有穷状态自动机(Non-deterministic finite automaton 简称 NFA)
NFA 非确定性有穷状态自动机 主要特点是:以正则为主导,先看正则、再看文本,我们通过一个例子来说明:
regex = 'jike(zhushou|shijian|shixi)'
text = 'we study on jikeshijian app'
根据 NFA 引擎的特点,先看正则,正则第一个符号是 j
,NFA 引擎首先在字符串中查找 字符 j
,接着在用 正则 i
匹配字符 j
的后面是否是 字符i
,按照以上规则找到 字符 jike
regex = 'jike(zhushou|shijian|shixi)'
↑
text = 'we study on jikeshijian app'
↑
随后,根据正则看文本后是否是 z
,发现不是,此时 zhushou
分支淘汰
regex = 'jike(zhushou|shijian|shixi)'
↑
淘汰 (zhushou) 分支 -> 剩余分支 (shijian|shixi)
text = 'we study on jikeshijian app'
↑
由于正则是多分支,所以继续使用其他分支进行匹配,用正则分支 s
,匹配 字符 s
,匹配成功,而后依次匹配 规则 hijian
与 字符 hijian
regex = 'jike(zhushou|shijian|shixi)'
↑
淘汰 (zhushou) 分支 -> 剩余分支 (shijian|shixi)
text = 'we study on jikeshijian app'
↑
当匹配上了 shijian 分支
后,不会再看 shixi 分支
,文本匹配完毕!
假设这里文本改一下,text 中的 jikeshijian
变成 jikeshixi
regex = 'jike(zhushou|shijian|shixi)'
↑ (正则 z 匹配不上 字符 s)
淘汰 (zhushou) 分支 -> 尝试其他分支 (shijian|shixi)
↑ (正则 j 匹配不上 字符 x)
淘汰 (shijian) 分支 -> 尝试其他分支 (shixi)
↑
text = 'we study on jikeshixi app'
↑
整个过程变为:
正则 z
匹配不上文本 s
,淘汰zhushou 分支
正则 s
匹配上了文本 s
,进入shijian 分支
,逐个匹配后发现shijian 分支规则中 shi 后面为 j 匹配不上 文本 shi 中 x
,淘汰shijian 分支
- 虽然此文本匹配位置来到了
x
,但由于匹配失败,NFA 会记住上次失败开始前的位置 即jike[s]hixi
,当使用下一个规则分支
进行匹配时,自动回到这里,重新匹配(由此导致了回溯) - 显然,这一次是可以匹配成功的
由此,我们可以理解 “NFA 是以正则主导的是正则引擎” 这句话了吧~,NFA 以正则为主导的情况下,会反复测试字符串,这样字符串在某些条件(多分支)下,可能会被反复测试很多次
2.3.2.2 DFA 确定下有穷自动机
直接上示例:
text = 'we study on jikeshijian app'
regex = 'jike(zhushou|shijian|shixi)'
DFA 与 NFA 不同,DFA 以文本为主导,会先文本,再看正则表达式,我们逐步分析上面示例的匹配过程
首先,DFA 会从 we
中的 字符 w
开始依次查找到 字符 j
text = 'we study on jikeshijian app'
↑
regex = 'jike(zhushou|shijian|shixi)'
↑
由于 字符 j
后面是 字符 i
,所以我们要看 规则 j
后是否也是 i
,随后依次匹配到 字符 e
、规则 e
text = 'we study on jikeshijian app'
↑
regex = 'jike(zhushou|shijian|shixi)'
↑
继续匹配,字符 e
后面是 字符 s
,DFA 此时看 正则表达式部分,很明显 分支 (zhushou)
被淘汰
text = 'we study on jikeshijian app'
↑
regex = 'jike(zhushou|shijian|shixi)'
↑ (字符 s 匹配不上 正则 z)
淘汰 (zhushou) 分支 -> 尝试其他分支 (shijian|shixi)
剩下的只有开头是 s 的分支 shijian
和 shixi
,DFA 依次检查,检测到 字符 shijian
中 j
,发现正则中只有 分支 (shijian)
符合,所以淘汰 分支 (shixi)
text = 'we study on jikeshijian app'
↑
regex = 'jike(zhushou|shijian|shixi)'
↑ (字符 s 匹配不上 正则 z)
淘汰 (zhushou) 分支 -> 尝试其他分支 (shijian|shixi)
↑ 符合 ↑ 淘汰(字符 j 匹配不上 正则 x)
# 由于是先看文本字符,再看正则,所以能够直接判断那些是无效分支,从而避免产生回溯
最终 字符 jikeshijian
与 正则 jike(shijian)
匹配成功,匹配结果为 jikeshijian
2.3.2.3 NFA vs DFA
通过上面两个示例可以看到,DFA 和 NFA 两种引擎工作方式的差异,这里补充一个小节的表格
正则引擎 | 主要特点 | 优点 | 缺点 | 代表 |
---|---|---|---|---|
NFA |
以正则表达式为主导的,先看正则表达式,再看文本 | 使用贪心匹配回溯算法实现,通过构造特定扩展,支持子组和反向引用 | 由于发生回溯问题,可能导致对字符串中的同一部分,进行很多次对比,因此在某些场景下,它的执行速度可能非常慢 | Java、Python、Ruby 等 |
DFA |
以文本为主导,先看文本,再看正则表达式 | 整个匹配过程中,字符串只看一遍,不会发生回溯(相同的字符不会被测试两次),所以 DFA 引擎执行的时间一般是线性的 | 由于 DFA 引擎只包含有限的状态,所以它没有反向引用功能,并且因为它不构造显示扩展,它也不支持捕获子组 | MySQL、Golang 等 |
2.3.2.4 NFA & POSIX NFA
明明已经有了 NFA 为啥后面有出现了一个 Posix NFA 呢?
事情是这样的,由于传统的 NFA 引擎“急于”报告匹配结果,通常找到第一个匹配上的就返回了,所以可能会导致还有更长的匹配未被发现,如下所示:
传统的 NFA 从文本中找到的是 pos,而不是 posix,而 POSIX NFA 找到的是 posix,类 Unix 平台的许多工具都是应用 POSIX NFA 引擎的,例如下面的 grep
、sed
☁ ~ $ echo 'posix'| grep -o -E 'pos|posix'
posix
☁ ~ $ echo 'posix'| sed -r s'/pos|posix/abc/'
abc
# 不过 grep 也支持 DFA 引擎
☁ ~ echo 'posix'| grep -o -P 'pos|posix'
pos
POSIX NFA 引擎的原理与传统的 NFA 引擎类似,但不同之处在于,POSIX NFA 在找到可能的最长匹配之前会继续回溯,也就是说它会尽可能找最长的,如果分支一样长,以最左边的为准(TheLongest-Leftmost),因此,POSIX NFA 引擎的速度比传统的 NFA 引擎还要慢上不少
不过,通常来说,编程语言基于 NFA 引擎,所以我们书写要注意把最容易匹配到的分支写到最左侧,以此提高匹配效率
以下是常见的几种 正则引擎 对比表格,大致了解下即可
2.3.3 深入理解回溯
此前在 [正则匹配模式](#1.4 正则匹配模式) 中的 [独占模式](#1.4.3 独占模式(Possessive)) 小节简单讨论过 什么是回溯,以及回溯是如何影响 贪婪匹配、惰性匹配 的匹配行为的,如果记不太清了,可以返回上面再去复习下,下面我们开始再深入探讨 回溯…
首先,明确一个概念,回溯 是 NFA 引擎独有的,这一点不难理解。DFA 是以文本为主导的,它一次性读取所有文本字符,在遇到量词或条件分支直接就能完成匹配或淘汰分支,所以不需要进行回溯
NFA 引擎也并非所有情况下都会进行回溯,只有正则中出现量词或多选分支结构 且 非独占模式 时,才可能会发生回溯(最左分支直接匹配不触发回溯)
先看个简单的回溯例子,如图所示:
经过前面的学习,我们按照 NFA 的思路重新捋一下,正则在贪婪模式下的匹配及回溯过程是怎样的
NFA 引擎是以正则规则为主导的,所以我们先看
规则 a+
,规则 a+
直接吞掉字符 aa
,字符 aa
后面是b
所以当前规则执行完成,撇皮内容aa
# Step 1 regex = 'a+ab' ↑ text = 'aab' ↑ (规则 a+ 匹配 aa) 匹配成功
使用
规则 a
匹配字符 b
,匹配失败,触发回溯# Step 2 regex = 'a+ab' ↑ text = 'aab' ↑ (规则 a 匹配不上 字符 b) 匹配失败,触发向前回溯
贪婪模式下匹配失败回溯的动作是什么啊?没错,从前一规则所匹配的内容中吐出一个字符,此时匹配内容由
aa
变为a
# Step 3 regex = 'a+ab' ↑ text = 'aab' ↑ 吐出 a 向前回溯
使用
规则 a
匹配 被吐出字符 a
,匹配成功,匹配内容aa
# Step 4 regex = 'a+ab' ↑ text = 'aab' ↑ (规则 a 匹配 字符 a) 匹配成功
使用
规则 b
匹配字符 b
,匹配成功,aab
# Step 5 regex = 'a+ab' ↑ text = 'aab' ↑ (规则 b 匹配 字符 b) 匹配成功
OK,一共 5 个步骤,这与图示中的返回也是对的上的,我们接下来再看个复杂些的例子,如果正则是 .*ab
,那么会发生什么?
它竟然足足经过了 164 个 Step,这说明了什么,说明正则匹配的很辛苦…
为了相对轻松的,我们把内容删减下
大致步骤如下所示:
Step 1:规则 .* 一下子匹配到了所有字符,匹配内容 lab is cmd
Step 2:规则 a 匹配 没得匹配(因为都让.*匹配完了) 匹配失败 触发回溯
Step 3:吐出 d,仍旧失败,继续触发回溯,匹配内容 匹配内容 lab is cm
Step 4:吐出 m,仍旧失败,继续触发回溯,匹配内容 匹配内容 lab is c
Step 5:吐出 c,仍旧失败,继续触发回溯,匹配内容 lab is
Step 6:吐出 空格,仍旧失败,继续触发回溯,匹配内容 lab is
Step 7:吐出 s,仍旧失败,继续触发回溯,匹配内容 lab i
Step 8:吐出 i,仍旧失败,继续触发回溯,匹配内容 lab
Step 9:吐出 空格,仍旧失败,继续触发回溯,匹配内容 lab
Step 10:吐出 b,仍旧失败,继续触发回溯,匹配内容 la
Step 11:吐出 a,直到回溯至 l(吐出l之后所有匹配上的)
Step 12:规则 a 匹配 字符 a,匹配成功,匹配内容 匹配内容 a
Step 13:规则 a 匹配 字符 a,匹配成功,匹配内容 匹配内容 ab
这里补充个 GIF 直观的感受下…
通过上面的大量的回溯过程,我们可以知晓,在 NFA 引擎中使用.*
进行匹配,会导致大量的向前回溯,这必然会浪费很多服务器资源,所以,在实际生产环境,一定要切记,尽可能把正则写的精确写,常见注意事项有以下几点:
- 尽量找出字符规律,使用
"[^..]+"
字符组取反功能获取内容 - 使用
.+?
非贪婪的方式获取内容 - 使用 独占模式 进行数据校验,避免产生回溯
- 绝对!!绝对不要在多选择分支中出现重复的元素,这会导致大量的回溯
- 另外,回溯不可怕,我们要尽量减少回溯后的判断(尽量少的使用多分支,多分支意味着可能多回溯好几轮儿)
2.3.4 正则优化建议
(1) 测试正则性能
Python:通过
timeit
方法进行测试In [2]: import re In [3]: x = '-' * 10000 + 'abc' In [4]: timeit re.search('abc', 'x') 702 ns ± 40.9 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)
regex101:通过在线站点进行性能分析、调优
(2) 预先编译规则
编程语言中一般都有 预先编译正则规则 的方法,我们可以使用这个方法提前将正则构造成自动机,从而避免每次使用的时候再重复构造,以此提高正则匹配的性能
>>> import re
>>> reg = re.compile(r'ab?c') # 先编译好,再使用
>>> reg.findall('abc')
['abc']
不过,调试的时候用 re.findall()
什么还是没啥问题的
(3) 明确区域范围
尽量减少使用 .*
,而是明确区间范围,避免产生大量回溯
(4) 提取公共部分
通过对 NFA 引擎的学习,我们知道 (abcd|abxy)
可以优化为 ab(xy|cd)
,具体效果如下:
优化前:(abcd|abxy)
优化后:ab(xy|cd)
类似的例子,诸如,th(?:is|at)
比 this|that
效率高、^th(is|at) is
比 (^this|^that) is
性能好,总而言之,通过提取公共部分,让我们要让匹配次数(step) 尽可能的少
(5) 高频条件左移
由于正则是 从左到右 进行匹配的,把出现概率大的放左边,比如:在域名中 .com
使用是比 .net
多的,我们可以写成 .(?:com|net)\b
,而不是 .(?:net|com)\b
(6) 尽量少用子组
()
可以用来保存规则(匹配内容)至分组中,用以后续使用,可如果后续不再使用那部分 规则 或 匹配内容,只是单纯的想把那部分规则作为一个整体,在这种情况就不用保存子组。因为一旦保存成子组后,正则引擎必须做一些额外工作来保存匹配到的内容,因为后面可能会用到,这会降低正则的匹配性能
(8) 警惕嵌套子组
如果一个组里面包含重复,接着这个组整体也可以重复,比如 (.*)*
这条匹配的次数会呈指数级增长,所以尽量不要写这样的正则
(9) 避免分支重叠
在多选分支选择中,要避免不同分支出现相同范围的情况,中心思想与 提取公共部分 差不多
① 优化小实验:Nginx 错误日志匹配
拿早先那条匹配 Nginx error 日志的规则来练手
优化前:1618
Steps、7.2
ms
优化后:290
Steps、1.6
ms
2.4 正则书写技巧和常见方案
2.4.1 七个小技巧
通过问题分解,把一个复杂的大问题 拆解为 若干个简单的小问题,常见的小问题
某个位置上可能有多个 字符 的话,就用 字符组
[...]
某个位置上有多个 字符串 的话,就用 多选结构
a|b
某些字符出现的 次数不确定 的话,就用 量词
?+*{m,n}
某些严格要求的 位置 的话,就用**边界符
/b^$
**某些字符不能前后出现,就用 环视
(?<=)(?<!)(?=)(?!)
查找的内容不能出现某些字符,就用 **字符组取反
[^...]
**,如原因[^aeiou]
查找的内容不能出现某些子符(字符串),例如:六位数的密码,中间不能连续两位数字,环视 双杀
- 提示
(?!\d\d)
代表右边不能是两个数字,但它左边没有正则,即为空字符串
2.4.2 通用问题解决方案
(1) 数字
- 数字在正则中可以使用
\d
或[0-9]
来表示 - 如果是连续的多个数字,可以使用
\d+
或[0-9]+
- 如果
n
位数据,可以使用\d{n}
- 如果是至少
n
位数据,可以使用\d{m,}
- 如果是
m-n
位数字,可以使用\d{m,n}
(2) 正数、负数、小数
[-+]?\d+\.?\d+
示例
(4) 十六进制数
十六进制,除了要匹配 0-9 之外,还要匹配 a-f(或 A-F) 代表 10 到 15 这 6 个数字
[0-9a-fA-F]+
示例
(5) 手机号码
手机号码的正则好写但不好维护,因为时常有新号段加入,所以需要定期维护,就目前而言,我这边使用的是这个
1(?:3\d|4[5-9]|5[0-35-9]|6[2567]|7[0-8]|8\d|9[1389])\d{8}
常见的都覆盖了,如果有新的稍微修改下就行了
(6) 身份证号码
主要就三种变化
- 18 位身份证号
- 15 位身份证号
- 最后是 x 的 18 位身份证号
[1-9]\d{14}(\d\d[0-9xX])?
(7) 邮政编码
邮编一般为 6 位数字,首位非 0,比较简单,可以写成 [1-9]\d{5}
[1-9]\d{5}
(8) QQ 邮箱
目前 QQ 号最长的有 10 位,最短的是 10000 开始,非 0 开头
[1-9][0-9]{4,9}
(9) 中文字符
使用 regex 库,通过 Unicode 中文汉字 \p{Han}
In [7]: regex.findall(r'\p{Han}+', '轻轻地我走了')
Out[7]: ['轻轻地我走了']
(10) IPV4 地址
这个最常用,也比较简单
\d{1,3}(\.\d{1,3}){3}
(11) 日期时间
\d{4}-(?:1[0-2]|0?[1-9])-(?:[12]\d|3[01]|0?[1-9])
(12) 邮箱
[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+
2.5 正则表达式 & IDE
一图胜千言,一言以蔽之,选 JetBrains 全家桶!