This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,177 @@
<audio id="audio" title="05 | 断言:如何用断言更好地实现替换重复出现的单词?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/93/fb/9384244eea087fc460f991e809090bfb.mp3"></audio>
你好我是伟忠。今天我来和你聊聊正则断言Assertion
什么是断言呢?简单来说,断言是指对匹配到的文本位置有要求。这么说你可能还是没理解,我通过一些例子来给你讲解。你应该知道 \d{11} 能匹配上11位数字但这11位数字可能是18位身份证号中的一部分。再比如去查找一个单词我们要查找 tom但其它的单词比如 tomorrow 中也包含了tom。
也就是说,在有些情况下,我们对要匹配的文本的位置也有一定的要求。为了解决这个问题,正则中提供了一些结构,只用于匹配位置,而不是文本内容本身,这种结构就是断言。常见的断言有三种:单词边界、行的开始或结束以及环视。
<img src="https://static001.geekbang.org/resource/image/df/db/df5f394cc3c0beaee306881704512cdb.png" alt="">
## 单词边界Word Boundary
在讲单词边界具体怎么使用前,我们先来看一下例子。我们想要把下面文本中的 tom 替换成 jerry。注意一下在文本中出现了 tomorrow 这个单词tomorrow也是以 tom 开头的。
>
tom asked me if I would go fishing with him tomorrow.
>
中文翻译Tom问我明天能否和他一同去钓鱼。
利用前面学到的知识,我们如果直接替换,会出现下面这种结果。
```
替换前tom asked me if I would go fishing with him tomorrow.
替换后jerry asked me if I would go fishing with him jerryorrow.
```
这显然是错误的,因为明天这个英语单词里面的 tom 也被替换了。
那正则是如何解决这个问题的呢?单词的组成一般可以用元字符 **\w+** 来表示,**\w**包括了大小写字母、下划线和数字(即 [A-Za-z0-9_])。那如果我们能找出单词的边界,也就是当出现了**\w表示的范围以外**的字符,比如引号、空格、标点、换行等这些符号,我们就可以在正则中使用\b 来表示单词的边界。 \b中的b可以理解为是边界Boundary这个单词的首字母。
<img src="https://static001.geekbang.org/resource/image/4d/11/4d6c0dc075aebb6023ebcd791e787d11.jpg" alt="">
根据刚刚学到的内容,在准确匹配单词时,我们使用 \b\w+\b 就可以实现了。
下面我们以 Python3 语言为例子,为你实现上面提到的 “tom 替换成 jerry”
```
&gt;&gt;&gt; import re
&gt;&gt;&gt; test_str = &quot;tom asked me if I would go fishing with him tomorrow.&quot;
&gt;&gt;&gt; re.sub(r'\btom\b', 'jerry', test_str)
'jerry asked me if I would go fishing with him tomorrow.'
```
建议你自己也动手尝试一下利用我们前面说的方法在sublime text 3编辑器中实现一下这个替换操作这样你才可以记得更牢。
## 行的开始或结束
和单词的边界类似,在正则中还有文本每行的开始和结束,如果我们要求匹配的内容要出现在一行文本开头或结尾,就可以使用 ^ 和 $ 来进行位置界定。
我们先说一下行的结尾是如何判断的。你应该知道换行符号。在计算机中,回车(\r和换行\n其实是两个概念并且在不同的平台上换行的表示也是不一样的。我在这里列出了 Windows、Linux、macOS 平台上换行的表示方式。
<img src="https://static001.geekbang.org/resource/image/e8/51/e8c52998873240d57a33b6dfedb3a551.jpg" alt="">
那你可能就会问了,匹配行的开始或结束有什么用呢?
### 日志起始行判断
最常见的例子就是日志收集,我们在收集日志的时候,通常可以指定日志行的开始规则,比如以时间开头,那些不是以时间开头的可能就是打印的堆栈信息。我来给你一个以日期开头,下面每一行都属于同一篇日志的例子。
```
[2020-05-24 12:13:10] &quot;/home/tu/demo.py&quot;
Traceback (most recent call last):
File &quot;demo.py&quot;, line 1, in &lt;module&gt;
1/0
ZeroDivisionError: integer division or modulo by zero
```
在这种情况下,我们就通过日期时间开头来判断哪一行是日志的第一行,在日期时间后面的日志都属于同一条日志。除非我们看见下一个日期时间的出现,才是下一条日志的开始。
### 输入数据校验
在Web服务中我们常常需要对输入的内容进行校验比如要求输入6位数字我们可以使用 \d{6} 来校验。但你需要注意到如果用户输入的是6位以上的数字呢在这种情况下如果不去要求用户录入的6位数字必须是行的开头或结尾就算验证通过了结果也可能不对。比如下面的示例在不加行开始和结束符号时用户输入了 7 位数字,也是能校验通过的:
```
&gt;&gt;&gt; import re
&gt;&gt;&gt; re.search('\d{6}', &quot;1234567&quot;) is not None
True &lt;-- 能匹配上 (包含6位数字)
&gt;&gt;&gt; re.search('^\d{6}', &quot;1234567&quot;) is not None
True &lt;-- 能匹配上 (以6位数字开头)
&gt;&gt;&gt; re.search('\d{6}$', &quot;1234567&quot;) is not None
True &lt;-- 能匹配上 (以6位数字结尾)
&gt;&gt;&gt; re.search('^\d{6}$', &quot;1234567&quot;) is not None
False &lt;-- 不能匹配上 (只能是6位数字)
&gt;&gt;&gt; re.search('^\d{6}$', &quot;123456&quot;) is not None
True &lt;-- 能匹配上 (只能是6位数字)
```
在前面的匹配模式章节中,我们学习过,在多行模式下,^和$符号可以匹配每一行的开头或结尾。大部分实现默认不是多行匹配模式但也有例外比如Ruby中默认是多行模式。所以对于校验输入数据来说一种更严谨的做法是使用 \A 和 \z Python中使用 \Z 来匹配整个文本的开头或结尾。
解决这个问题还有一种做法,我们可以在使用正则校验前,先判断一下字符串的长度,如果不满足长度要求,那就不需要再用正则去判断了。相当于你用正则解决主要的问题,而不是所有问题,这也是前面说的使用正则要克制。
## 环视( Look Around
《孟子·梁惠王下》中有一个成语“王顾左右而言他”。其中“王顾左右”可以理解成“环视”,看看左边,再看看右边。在正则中我们有时候也需要瞻前顾后,找准定位。环视就是要求匹配部分的前面或后面要满足(或不满足)某种规则,有些地方也称环视为**零宽断言**。
那具体什么时候我们会用到环视呢我来举个例子。邮政编码的规则是由6位数字组成。现在要求你写出一个正则提取文本中的邮政编码。根据规则我们很容易就可以写出邮编的组成`\d{6}`。我们可以使用下面的文本进行测试:
```
130400 满足要求
465441 满足要求
4654000 长度过长
138001380002 长度过长
```
我们发现7位数的前6位也能匹配上12位数匹配上了两次这显然是不符合要求的。
<img src="https://static001.geekbang.org/resource/image/c7/ac/c708642e73f361ea81d42d465f3c56ac.png" alt="">
也就是说除了文本本身组成符合这6位数的规则外这6位数左边或右边都不能是数字。
正则是通过环视来解决这个问题的。解决这个问题的正则有四种。我给你总结了一个表。
<img src="https://static001.geekbang.org/resource/image/00/dd/00e823943baa33cd8a5e4690cfe44edd.png" alt="">
你可能觉得名称比较难记住,没关系,我给你一个小口诀,你只要记住了它的功能和写法就行。这个小口诀你可以在心里默念几遍:**左尖括号代表看左边,没有尖括号是看右边,感叹号是非的意思。**
因此针对刚刚邮编的问题就可以写成左边不是数字右边也不是数字的6位数的正则。即 `(?&lt;!\d)\d{6}(?!\d)`。这样就能够符合要求了。
<img src="https://static001.geekbang.org/resource/image/3c/81/3cc80c66f73c3192e05a65b6b7abd181.png" alt="">
### 单词边界用环视表示
学习到这里,你可以思考一下,表示单词边界的 **\b** 如果用环视的方式来写,应该是怎么写呢?
这个问题其实比较简单,单词可以用 **\w+** 来表示,单词的边界其实就是那些不能组成单词的字符,即左边和右边都不能是组成单词的字符。比如下面这句话:
`the little cat is in the hat`
the 左侧是行首右侧是空格hat 右侧是行尾,左侧是空格,其它单词左右都是空格。所有单词左右都不是 **\w**。
(?&lt;!\w) 表示左边不能是单词组成字符,(?!\w) 右边不能是单词组成字符,即 \b\w+\b 也可以写成(?&lt;!\w)\w+(?!\w)。
另外,根据前面学到的知识,**非\w**也可以用**\W**来表示。那单词的正则可以写成(?&lt;=\W)\w+(?=\W)。
这个例子是为了让你有更多的思考,并不推荐在日常工作中这么来表示单词的边界,因为 \b 明显更简洁,也更容易阅读和书写。
### 环视与子组
友情提醒一下,前面我们在第三讲中讲过“分组与引用”相关的内容,如果忘记了可以回去复习复习。环视中虽然也有括号,但不会保存成子组。保存成子组的一般是匹配到的文本内容,后续用于替换等操作,而环视是表示对文本左右环境的要求,即环视只匹配位置,不匹配文本内容。你也可以总结一下,圆括号在正则中都有哪些用途,不断地去复习学过的内容,巩固自己的记忆。
## 总结
好了,今天的课就讲到这里。我来给你总结回顾一下。
今天我们学习了正则中断言相关的内容,最常见的断言有三种:单词的边界、行的开始或结束、环视。
单词的边界是使用 \b 来表示,这个比较简单。而多行模式下,每一行的开始和结束是使用 ^ 和 $ 符号。如果想匹配整个字符串的开始或结束,可以使用 \A 和 \z它们不受匹配模式的影响。
最后就是环视,它又分为四种情况:肯定逆向环视、否定逆向环视、肯定顺序环视、否定顺序环视。在使用的时候记住一个方法:**有左尖括号代表看左边,没有尖括号是看右边,而感叹号是非的意思。**
<img src="https://static001.geekbang.org/resource/image/54/4e/54f61311f5fd506e2822992500fadf4e.png" alt="">
## 课后思考
最后,我们来做一个小练习吧。前面我们用正则分组引用来实现替换重复出现的单词,其实之前写的正则是不严谨的,在一些场景下,其实是不能正常工作的,你能使用今天学到的知识来完善一下它么?
>
the little cat cat2 is in the hat hat2, we like it.
需要注意一下,文本中 cat 和 cat2还有 hat 和 hat2 其实是不同的单词。你应该能想到在 \w+ 左右加上单词边界 \b 来解决这个问题。你可以试一下,真的能像期望的那样工作么?也就是说,在分组引用时,前面的断言还有效么?
多动手练习,思考和总结,你才能更好地掌握学习的内容。
<img src="https://static001.geekbang.org/resource/image/91/97/91adcfb5d21aaf6548d387c40738c697.png" alt="">
好,今天的课程就结束了,希望可以帮助到你。欢迎在评论区和我交流。也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,257 @@
<audio id="audio" title="06 | 转义:正则中转义需要注意哪些问题?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9d/87/9da3e8fc4abbc461e7fc165d498e4887.mp3"></audio>
你好,我是伟忠。今天我来和你聊聊转义。转义对我们来说都不算陌生,编程的时候,使用到字符串时,双引号里面如果再出现双引号,我们就可以通过转义来解决。就像下面这样:
```
str = &quot;How do you spell the word \&quot;regex\&quot;?&quot;
```
虽然转义在日常工作中比较常见,但正则中什么时候需要转义,什么时候不用转义,在真正使用的时候可能会遇到这些麻烦。所以我们很有必要来系统了解一下正则中的转义。
## 转义字符
首先我们说一下什么是转义字符Escape Character。它在维基百科中是这么解释的
>
在计算机科学与远程通信中,当转义字符放在字符序列中,它将对它后续的几个字符进行替代并解释。通常,判定某字符是否为转义字符由上下文确定。转义字符即标志着转义序列开始的那个字符。
这么说可能有点不好理解,我再来给你通俗地解释一下。转义序列通常有两种功能。第一种功能是编码无法用字母表直接表示的特殊数据。第二种功能是用于表示无法直接键盘录入的字符(如回车符)。
我们这节课说的就是第二种情况转义字符自身和后面的字符看成一个整体用来表示某种含义。最常见的例子是C语言中用反斜线字符“\”作为转义字符来表示那些不可打印的ASCII控制符。另外在URI协议中请求串中的一些符号有特殊含义也需要转义转义字符用的是百分号“%”。之所以把这个字符称为**转义字符**,是因为它后面的字符,不是原来的意思了。
在日常工作中经常会遇到转义字符比如我们在shell中删除文件如果文件名中有*号,我们就需要转义,此时我们能看出,使用了转义字符后,*号就能放进文件名里了。
```
rm access_log* # 删除当前目录下 access_log 开头的文件
rm access_log\* # 删除当前目录下名字叫 access_log* 的文件
```
再比如我们在双引号中又出现了双引号,这时候就需要转义了,转义之后才能正常表示双引号,否则会报语法错误。比如下面的示例,引号中的 Hello World! 也是含有引号的。
```
print &quot;tom said \&quot;Hello World!\&quot; to the crowd.&quot;
```
下面是一些常见的转义字符以及它们的含义。
<img src="https://static001.geekbang.org/resource/image/03/35/034b3619dab627fc96b85ac8de004b35.jpg" alt="">
## 字符串转义和正则转义
说完了转义字符,我们再来看一下正则中的转义。正则中也是使用反斜杠进行转义的。
一般来说,正则中 \d 代表的是单个数字,但如果我们想表示成 反斜杠和字母d这时候就需要进行转义写成 `\\d`这个就表示反斜杠后面紧跟着一个字母d。
<img src="https://static001.geekbang.org/resource/image/11/e9/112c1fa35dbc600c3fc8e6562aea46e9.png" alt="">
刚刚的反斜杠和d是连续出现的两个字符如果你想表示成反斜杠或d可以用管道符号或中括号来实现比如 \|d 或 [\d]。
<img src="https://static001.geekbang.org/resource/image/yy/35/yy70d6900cfbe786d64bdc7b5d87e735.png" alt="">
需要注意的是,如果你想用代码来测试这个,在程序中表示普通字符串的时候,我们如果要表示反斜杠,通常需要写成两个反斜杠,因为只写一个会被理解成“转义符号”,而不是反斜杠本身。
下面我给出使用 Python3 来测试的情况,你可以看一下。
```
&gt;&gt;&gt; import re
&gt;&gt;&gt; re.findall('\\|d', 'a*b+c?\d123d\') # 字符串没转义&quot;反斜杠&quot;
File &quot;&lt;input&gt;&quot;, line 1
re.findall('\\|d', 'a*b+c?\d123d\')
^
SyntaxError: EOL while scanning string literal
&gt;&gt;&gt; re.findall('\\|d', 'a*b+c?\\d123d\\')
[]
```
看到这里,你内心是不是有很多问号?为什么转义了还不行呢?我们来把正则表达式部分精简一下,看看两个反斜杠在正则中是什么意思。
```
&gt;&gt;&gt; import re
&gt;&gt;&gt; re.findall('\\', 'a*b+c?\\d123d\\')
Traceback (most recent call last):
省去部分信息
re.error: bad escape (end of pattern) at position 0
```
我们发现正则部分写的两个反斜杠Python3 处理的时候会报错,认为是转义字符,即认为是单个反斜杠,如果你再进一步测试在正则中写单个反斜杠,你会发现直接报语法错误,你可以自行尝试。
那如何在正则中正确表示“反斜杠”呢?答案是写四个反斜杠。
```
&gt;&gt;&gt; import re
&gt;&gt;&gt; re.findall('\\\\', 'a*b+c?\\d123d\\')
['\\', '\\']
```
你可以想一下,为什么不是三个呢?后面的文本部分,也得要用四个反斜杠表示才是正确的么?到这里,你是不是发现,转义其实没那么简单。
我来给你详细解释一下这里面的过程,在程序使用过程中,从输入的字符串到正则表达式,其实有两步转换过程,分别是字符串转义和正则转义。
在正则中正确表示“反斜杠”具体的过程是这样子:我们输入的字符串,四个反斜杠 \\,经过第一步字符串转义,它代表的含义是两个反斜杠 \;这两个反斜杠再经过第二步**正则转义**,它就可以代表单个反斜杠 \了。
<img src="https://static001.geekbang.org/resource/image/67/07/6775aee46fbb24d7c261bd1fd929c407.png" alt="">
你可以用这个过程,推导一下两个和三个反斜杠的转换过程,这样你就会明白上面报错的原因了。
那在真正使用的时候,有没有更简单的方法呢?答案是有的,我们尽量使用原生字符串,在 Python 中,可以在正则前面加上小写字母 r 来表示。
```
&gt;&gt;&gt; import re
&gt;&gt;&gt; re.findall(r'\\', 'a*b+c?\\d123d\\')
['\\', '\\']
```
这样看起来就简单很多,因为少了上面说的第一次转换。
## 正则中元字符的转义
在前面的内容中,我们讲了很多元字符,相信你一定都还记得。如果现在我们要查找比如星号(*)、加号(+)、问号(?)本身,而不是元字符的功能,这时候就需要对其进行转义,直接在前面加上反斜杠就可以了。这个转义就比较简单了,下面是一个示例。
```
&gt;&gt;&gt; import re
&gt;&gt;&gt; re.findall('\+', '+')
['+']
```
### 括号的转义
在正则中方括号 [] 和 花括号 {} 只需转义开括号,但圆括号 () 两个都要转义。我在下面给了你一个比较详细的例子。
```
&gt;&gt;&gt; import re
&gt;&gt;&gt; re.findall('\(\)\[]\{}', '()[]{}')
['()[]{}']
&gt;&gt;&gt; re.findall('\(\)\[\]\{\}', '()[]{}') # 方括号和花括号都转义也可以
['()[]{}']
```
在正则中,圆括号通常用于分组,或者将某个部分看成一个整体,如果只转义开括号或闭括号,正则会认为少了另外一半,所以会报错。
括号的转义示例,你可以参考这里:[https://regex101.com/r/kJfvd6/1](https://regex101.com/r/kJfvd6/1)。
### 使用函数消除元字符特殊含义
我们也可以使用编程语言自带的转义函数来实现转义。下面我给出了一个在 Python里转义的例子你可以看一下。
```
&gt;&gt;&gt; import re
&gt;&gt;&gt; re.escape('\d') # 反斜杠和字母d转义
'\\\\d'
&gt;&gt;&gt; re.findall(re.escape('\d'), '\d')
['\\d']
&gt;&gt;&gt; re.escape('[+]') # 中括号和加号
'\\[\\+\\]'
&gt;&gt;&gt; re.findall(re.escape('[+]'), '[+]')
['[+]']
```
这个转义函数可以将整个文本转义,一般用于转义用户输入的内容,即把这些内容看成普通字符串去匹配,但你还是得好好注意一下,如果使用普通字符串查找能满足要求,就不要使用正则,因为它简单不容易出问题。下面是一些其他编程语言对应的转义函数,供你参考。
<img src="https://static001.geekbang.org/resource/image/a7/0c/a75a4f990d969a046a468e35dbe8ef0c.jpg" alt="">
## 字符组中的转义
讲完了元字符的转义,我们现在来看看字符组中的转义。书写正则的时候,在字符组中,如果有过多的转义会导致代码可读性差。在字符组里只有三种情况需要转义,下面我来给你讲讲具体是哪三种情况。
### 字符组中需要转义的有三种情况
1. 脱字符在中括号中,且在第一个位置需要转义:
```
&gt;&gt;&gt; import re
&gt;&gt;&gt; re.findall(r'[^ab]', '^ab') # 转义前代表&quot;非&quot;
['^']
&gt;&gt;&gt; re.findall(r'[\^ab]', '^ab') # 转义后代表普通字符
['^', 'a', 'b']
```
1. 中划线在中括号中,且不在首尾位置:
```
&gt;&gt;&gt; import re
&gt;&gt;&gt; re.findall(r'[a-c]', 'abc-') # 中划线在中间,代表&quot;范围&quot;
['a', 'b', 'c']
&gt;&gt;&gt; re.findall(r'[a\-c]', 'abc-') # 中划线在中间,转义后的
['a', 'c', '-']
&gt;&gt;&gt; re.findall(r'[-ac]', 'abc-') # 在开头,不需要转义
['a', 'c', '-']
&gt;&gt;&gt; re.findall(r'[ac-]', 'abc-') # 在结尾,不需要转义
['a', 'c', '-']
```
1. 右括号在中括号中,且不在首位:
```
&gt;&gt;&gt; import re
&gt;&gt;&gt; re.findall(r'[]ab]', ']ab') # 右括号不转义,在首位
[']', 'a', 'b']
&gt;&gt;&gt; re.findall(r'[a]b]', ']ab') # 右括号不转义,不在首位
[] # 匹配不上,因为含义是 a后面跟上b]
&gt;&gt;&gt; re.findall(r'[a\]b]', ']ab') # 转义后代表普通字符
[']', 'a', 'b']
```
### 字符组中其它的元字符
一般来说如果我们要想将元字符(`.*+?`()之类)表示成它字面上本来的意思,是需要对其进行转义的,但如果它们出现在字符组中括号里,可以不转义。这种情况,一般都是单个长度的元字符,比如点号(`.`)、星号(`*`)、加号(`+`)、问号(`?`)、左右圆括号等。它们都不再具有特殊含义,而是代表字符本身。但如果在中括号中出现 \d 或 \w 等符号时,他们还是元字符本身的含义。
```
&gt;&gt;&gt; import re
&gt;&gt;&gt; re.findall(r'[.*+?()]', '[.*+?()]') # 单个长度的元字符
['.', '*', '+', '?', '(', ')']
&gt;&gt;&gt; re.findall(r'[\d]', 'd12\\') # \w\d等在中括号中还是元字符的功能
['1', '2'] # 匹配上了数字,而不是反斜杠\和字母d
```
下面我来给你简单总结一下字符组中的转义情况,我们提到了三种必须转义的情况,其它情况不转义也能正常工作,但在实际操作过程中,如果遇到在中括号中使用这三个字符原本的意思,你可以都进行转义,剩下其它的元字符都不需要转义。
## 总结
好了,今天的内容讲完了,我来带你总结回顾一下。
正则中转义有些情况下会比较复杂,从录入的字符串文本,到最终的正则表达式,**经过了字符串转义和正则转义两个步骤。**元字符的转义一般在前面加反斜杠就行,方括号和花括号的转义一般转义开括号就可以,但圆括号两个都需要转义,我们可以借助编程语言中的转义函数来实现转义。另外我们也讲了字符组中三种需要转义的情况,详细的可以参考下面的脑图。
<img src="https://static001.geekbang.org/resource/image/44/22/442bbc5d31ed8499ff7d37151434e522.png" alt="">
## 思考题
通过今天的学习不知道你对转义掌握的怎么样了呢再来一个例子加深一下你的理解吧文本部分是反斜杠n换行反斜杠四个部分组成。正则部分分别是1到4个反斜杠和字母n我用Python3写了对应的示例相应的查找过程是这样子的。
```
&gt;&gt;&gt; import re
&gt;&gt;&gt; re.findall('\n', '\\n\n\\')
['\n'] # 找到了换行符
&gt;&gt;&gt; re.findall('\\n', '\\n\n\\')
['\n'] # 找到了换行符
&gt;&gt;&gt; re.findall('\\\n', '\\n\n\\')
['\n'] # 找到了换行符
&gt;&gt;&gt; re.findall('\\\\n', '\\n\n\\')
['\\n'] # 找到了反斜杠和字母n
```
例子虽然看上去简单,不过你能不能解释出这四个示例中的转义过程呢?
好了,今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,160 @@
<audio id="audio" title="07 | 正则有哪些常见的流派及其特性?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/98/5a/98910d0de5c524288125923c0ff6565a.mp3"></audio>
你好,我是涂伟忠。今天我来给你讲讲正则常见的流派及其特性。
你可能要问了,讲正则流派有啥用呢?不如多来点实战啊。其实,我们去了解正则的演变过程是很有必要的。因为你一旦了解了正则的演变过程之后,就能够更加正确地去使用正则,尤其是在 Linux系统中。
那我们就先来看一个有关Linux系统的例子你先来感受一下。
如果你在 Linux 系统的一些命令行中使用正则,比如使用 grep 过滤内容的时候你可能会发现结果非常诡异就像下图这样在grep命令中使用正则\d+取不到数据,甚至在 egrep 中输出了英文字母d那一行。
<img src="https://static001.geekbang.org/resource/image/f1/09/f183b6fb3fba964ab9a9c3f8aa159b09.png" alt="">
这个执行结果的原因就和正则的演变有着密不可分的关系。那到底有什么样的关系呢?我们接着往下看,我从正则的发展历史给你讲起。
## 正则表达式简史
正则表达式的起源可以追溯到早期神经系统如何工作的研究。在20世纪40年代有两位神经生理学家Warren McCulloch和Walter Pitts研究出了一种用数学方式来描述神经网络的方法。
1956年一位数学家Stephen Kleene发表了一篇标题为《[神经网络事件表示法和有穷自动机](https://www.doc88.com/p-9763182364861.html)》的论文。这篇论文描述了一种叫做“正则集合Regular Sets”的符号。
随后大名鼎鼎的Unix之父Ken Thompson于1968年发表了文章《[正则表达式搜索算法](https://www.fing.edu.uy/inco/cursos/intropln/material/p419-thompson.pdf)》并且将正则引入了自己开发的编辑器qed以及之后的编辑器ed中然后又移植到了大名鼎鼎的文本搜索工具grep中。自此正则表达式被广泛应用到Unix系统或类Unix系统(如macOS、Linux)的各种工具中。
随后,由于正则功能强大,非常实用,越来越多的语言和工具都开始支持正则。不过遗憾的是,由于没有尽早确立标准,导致各种语言和工具中的正则虽然功能大致类似,但仍然有不少细微差别。
于是诞生于1986年的POSIX开始进行标准化的尝试。[POSIX](https://standards.ieee.org/develop/wg/POSIX.html)作为一系列规范定义了Unix操作系统应当支持的功能其中也包括正则表达式的规范。因此Unix系统或类Unix系统上的大部分工具如grep、sed、awk等均遵循该标准。我们把这些遵循POSIX正则表达式规范的正则表达式称为**POSIX流派**的正则表达式。
在1987年12月Larry Wall发布了Perl语言第一版因其功能强大一票走红所引入的正则表达式功能大放异彩。之后Perl语言中的正则表达式不断改进影响越来越大。于是在此基础上1997年又诞生了[PCRE](http://www.pcre.org/)——**Perl兼容正则表达式**Perl Compatible Regular Expressions
PCRE是一个兼容Perl语言正则表达式的解析引擎是由Philip Hazel开发的为很多现代语言和工具所普遍使用。除了Unix上的工具遵循POSIX标准PCRE现已成为其他大部分语言和工具隐然遵循的标准。
之后,正则表达式在各种计算机语言或各种应用领域得到了更为广泛的应用和发展。**POSIX流派** 与 **PCRE流派** 是目前正则表达式流派中的两大最主要的流派。
## 正则表达式流派
就像前面说的一样目前正则表达式主要有两大流派FlavorPOSIX流派与PCRE流派。下面我们分别来看看。
### 1. POSIX流派
这里我们先简要介绍一下POSIX流派。POSIX 规范定义了正则表达式的两种标准:
- **BRE标准**Basic Regular Expression 基本正则表达式);
- **ERE标准**Extended Regular Expression 扩展正则表达式)。
接下来,我们一起来看一下这两种标准的异同点。
#### BRE标准 和 ERE标准
早期BRE与ERE标准的区别主要在于BRE标准不支持量词问号和加号也不支持多选分支结构管道符。BRE标准在使用花括号圆括号时要转义才能表示特殊含义。BRE标准用起来这么不爽于是有了 ERE标准在使用花括号圆括号时不需要转义了还支持了问号、加号 和 多选分支。
我们现在使用的Linux发行版大多都集成了GNU套件。GNU在实现POSIX标准时做了一定的扩展主要有以下三点扩展。
1. GNU BRE支持了 +、?,但转义了才表示特殊含义,即需要用`\+、\?`表示。
1. GNU BRE支持管道符多选分支结构同样需要转义即用 `\|`表示。
1. GNU ERE也支持使用反引用和BRE一样使用 \1、\2…\9 表示。
BRE标准和ERE标准的详细区别我给了你一个参考图你可以看一下浅黄色背景是BRE和ERE不同的地方三处天蓝色字体是GNU扩展。
<img src="https://static001.geekbang.org/resource/image/53/6f/53fe0982c70fe89dff733345a6816e6f.png" alt="">
总之GNU BRE 和 GNU ERE 它们的功能特性并没有太大区别,区别是在于部分语法层面上,主要是一些字符要不要转义。
#### POSIX字符组
POSIX流派还有一个特殊的地方就是有自己的字符组叫POSIX字符组。这个类似于我们之前学习的 \d 表示数字,\s表示空白符等POSIX中也定义了一系列的字符组。具体的清单和解释如下所示
<img src="https://static001.geekbang.org/resource/image/c3/ya/c32024952cb6af3f78d9c08d9b5b3yya.png" alt="">
### 2. PCRE流派
除了POSIX标准外还有一个Perl分支也就是我们现在熟知的PCRE。随着Perl语言的发展Perl语言中的正则表达式功能越来越强悍为了把Perl语言中正则的功能移植到其他语言中PCRE就诞生了。
目前大部分常用编程语言都是源于PCRE标准这个流派显著特征是有\d、\w、\s这类字符组简记方式。
不过虽然PCRE流派是从Perl语言中衍生出来的但与Perl语言中的正则表达式在语法上还是有一些细微差异比如PHP的preg正则表达式(Perl Regular Expression)与Perl正则表达式的差异可看[这里](http://php.net/manual/zh/reference.pcre.pattern.differences.php)。
考虑到目前绝大部分常用编程语言所采用的正则引擎基本都属于PCRE流派的现实情况我们的课程也是主要讲解PCRE流派。前面对于正则表达式语法元素的解释都是以PCRE流派为准。
#### PCRE流派的兼容问题
虽然PCRE流派是与Perl正则表达式相兼容的流派但这种兼容在各种语言和工具中还存在程度上的差别这包括了直接兼容与间接兼容两种情况。
而且即便是直接兼容也并非完全兼容还是存在部分不兼容的情况。原因也很简单Perl语言中的正则表达式在不断改进和升级之中其他语言和工具不可能完全做到实时跟进与更新。
- **直接兼容**PCRE流派中与Perl正则表达式直接兼容的语言或工具。比如Perl、PHP preg、PCRE库等一般称之为Perl系。
- **间接兼容**比如Java系包括Java、Groovy、Scala等、Python系包括Python2和Python3、JavaScript系包括原生JavaScript和扩展库XRegExp、.Net系包括C#、VB.Net等等。
## 在Linux中使用正则
在遵循POSIX规范的UNIX/LINUX系统上按照 **BRE标准** 实现的有 grep、sed 和 vi/vim 等,而按照 **ERE标准** 实现的有 egrep、awk 等。
在UNIX/LINUX系统里PCRE流派与POSIX流派的对比我为你整理了一个表你可以看一下。
<img src="https://static001.geekbang.org/resource/image/eb/85/ebfd65253886552f034c50da3674ce85.png" alt="">
刚刚我们提到了工具对应的实现标准,其实有一些工具实现同时兼容多种正则标准,比如前面我们讲到的 grep 和 sed。如果在使用时加上-E选项就是使用ERE标准如果加上-P选项就是使用PCRE标准。
```
使用 ERE 标准
grep -E '[[:digit:]]+' access.log
使用 PCRE 标准
grep -P '\d+' access.log
```
在使用具体命令时如何知道属于哪个流派呢你不用担心太多了记不住。在Linux系统中有个 man 命令可以帮助我们。比如,我在 macOS 上执行 man grep ,可以看到选项 -G 是指定使用 BRE标准默认-E是ERE标准-P是PCRE标准。所以在使用具体工具时你通过这个方法查一下命令的说明就好了。
<img src="https://static001.geekbang.org/resource/image/1d/bc/1d43a1287e7881b87428ede0f85b63bc.png" alt="">
我们再看开篇提出的问题。
通过今天的学习,我们搞懂了各流派的差异,以及命令实现的是哪个正则标准。在 grep 中使用 \d+ 查找不到结果,是因为 grep 属于 BRE 流派,不支持 \d 来表示数字加号也要转义才能表示量词的一到多次所以无法找出数字那一行。如果你一定要用BRE流派可以通过**使<strong><strong>用**</strong>POSIX字符组</strong>**转义加号** 来实现。而egrep属于ERE流派也不支持 \d\d 相当于字母 d所以找到了字母那一行。
<img src="https://static001.geekbang.org/resource/image/f1/09/f183b6fb3fba964ab9a9c3f8aa159b09.png" alt="">
在grep命令中你可以指定参数-P来使用PCRE流派这样就和我们之前学习到的是一致的了。知道了原因之后你应该能写出相应的解决方法。下图是一些能工作的方法。
<img src="https://static001.geekbang.org/resource/image/a3/b2/a3bbeb6aa533cd06ea5d8f3b9e0b96b2.png" alt="">
为了方便加深你的理解我给你提供了一个例子来帮你巩固。你可以使用下面的文本在Linux中使用grep命令练习查找包含一到多个数字的行。
```
123456
abcdef
\d
\d+
d+
```
## 总结
好了,今天的内容讲完了,我来带你总结回顾一下。
今天我带你简单回顾了下正则表达式的历史。正则主要有两大流派分别是POSIX流派和PCRE流派。其中POSIX流派有两个标准分别是BRE标准和ERE标准**一般情况下我们面对的都是GNU BRE和GNU ERE。它们的主要区别在于前者要转义。**另外, POSIX流派一个特点就是有自己的字符组POSIX 字符组,这不同于常见的 \d 等字符组。
PCRE流派是如今大多数编程语言实现的流派最大的特点就是支持\d\s\w等我们前面讲的内容也是基于这个流派进行的。
如果你需要在类Unix平台命令等上使用正则使用前需要搞清楚工具属于哪个标准比如grep、sed、vi/vim 等属于BRE标准egrep、awk 属于ERE标准。而sed -P、grep -P等属于PCRE流派。这些也不需要死记硬背使用时用man命令看一下就好了。
我在这里给你放了一张今天所讲内容的总结脑图,你可以看一下。另外我还给你提供了一个记忆小窍门,你可以着重记忆一下这句话:**GNU ERE名称中有两个E不需要再转义。而GNU BRE 只有一个E使用时“花圆问管加”时都要转义**。
<img src="https://static001.geekbang.org/resource/image/23/2f/239d9c1cc82d840b7b27492c7b4c222f.png" alt="">
此外我还给了你一个Linux/Unix工具与正则表达式的POSIX规范余晟的[参考链接](https://www.infoq.cn/article/2011/07/regular-expressions-6-POSIX/),你可以看一下。
## 课后思考
最后我们来做一个小练习吧。在Linux上使用grep命令分别实现使用不同的标准即 BRE、ERE、PCRE ),来查找含有 ftp、http 或 https 的行。你可以动手体验一下不同标准的区别。
```
https://time.geekbang.org
ftp://ftp.ncbi.nlm.nih.gov
www.baidu.com
www.ncbi.nlm.nih.gov
```
好,今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,并把文章分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,222 @@
<audio id="audio" title="08 | 应用1正则如何处理 Unicode 编码的文本?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/61/f1/613871e207af4bcf76d87b23a65be1f1.mp3"></audio>
你好我是伟忠。这一节我们来学习如何使用正则来处理Unicode编码的文本。如果你需要使用正则处理中文可以好好了解一下这些内容。
不过,在讲解正则之前,我会先给你讲解一些基础知识。只有搞懂了基础知识,我们才能更好地理解今天的内容。一起来看看吧!
## Unicode基础知识
**Unicode**中文万国码、国际码、统一码、单一码是计算机科学领域里的一项业界标准。它对世界上大部分的文字进行了整理、编码。Unicode使计算机呈现和处理文字变得简单。
Unicode至今仍在不断增修每个新版本都加入更多新的字符。目前Unicode最新的版本为 2020 年3月10日公布的13.0.0,已经收录超过 14 万个字符。
现在的Unicode字符分为17组编排每组为一个平面Plane而每个平面拥有 65536即2的16次方个码值Code Point。然而目前Unicode只用了少数平面我们用到的绝大多数字符都属于第0号平面即**BMP平面**。除了BMP 平面之外,其它的平面都被称为**补充平面**。
关于各个平面的介绍我在下面给你列了一个表,你可以看一下。
<img src="https://static001.geekbang.org/resource/image/8c/61/8c1c6b9b87f10eec04dbc2224f755d61.png" alt="">
Unicode标准也在不断发展和完善。目前使用4个字节的编码表示一个字符就可以表示出全世界所有的字符。那么Unicode在计算机中如何存储和传输的呢这就涉及编码的知识了。
Unicode相当于规定了字符对应的码值这个码值得编码成字节的形式去传输和存储。最常见的编码方式是UTF-8另外还有UTF-16UTF-32 等。UTF-8 之所以能够流行起来是因为其编码比较巧妙采用的是变长的方法。也就是一个Unicode字符在使用UTF-8编码表示时占用1到4个字节不等。最重要的是Unicode兼容ASCII编码在表示纯英文时并不会占用更多存储空间。而汉字呢在UTF-8中通常是用三个字节来表示。
```
&gt;&gt;&gt; u'正'.encode('utf-8')
b'\xe6\xad\xa3'
&gt;&gt;&gt; u'则'.encode('utf-8')
b'\xe5\x88\x99'
```
下面是 Unicode 和 UTF-8 的转换规则,你可以参考一下。
<img src="https://static001.geekbang.org/resource/image/c8/ed/c8055321ed7e4782b3d862f5d06297ed.png" alt="">
## Unicode中的正则
在你大概了解了Unicode的基础知识后接下来我来给你讲讲在用Unicode中可能会遇到的坑以及其中的点号匹配和字符组匹配的问题。
### 编码问题的坑
如果你在编程语言中使用正则编码问题可能会让正则的匹配行为很奇怪。先说结论在使用时一定尽可能地使用Unicode编码。
如果你需要在Python语言中使用正则我建议你使用Python3。如果你不得不使用Python2一定要记得使用 Unicode 编码。在Python2中一般是以u开头来表示Unicode。如果不加u会导致匹配出现问题。比如我们在“极客”这个文本中查找“时间”。你可能会很惊讶竟然能匹配到内容。
下面是Python语言示例
```
# 测试环境 macOS/Linux/Windows Python2.7
&gt;&gt;&gt; import re
&gt;&gt;&gt; re.search(r'[时间]', '极客') is not None
True
&gt;&gt;&gt; re.findall(r'[时间]', '极客')
['\xe6']
# Windows下输出是 ['\xbc']
```
通过分析原因我们可以发现不使用Unicode编码时正则会被编译成其它编码表示形式。比如在macOS或Linux下一般会编码成UTF-8而在Windows下一般会编码成GBK。
下面是我在macOS上做的测试“时间”这两个汉字表示成了UTF-8编码正则不知道要每三个字节看成一组而是把它们当成了6个单字符。
```
# 测试环境 macOS/LinuxPython 2.7
&gt;&gt;&gt; import re
&gt;&gt;&gt; re.compile(r'[时间]', re.DEBUG)
in
literal 230
literal 151
literal 182
literal 233
literal 151
literal 180
&lt;_sre.SRE_Pattern object at 0x1053e09f0&gt;
&gt;&gt;&gt; re.compile(ur'[时间]', re.DEBUG)
in
literal 26102
literal 38388
&lt;_sre.SRE_Pattern object at 0x1053f8710&gt;
```
我们再看一下 “极客” 和 “时间” 这两个词语对应的UTF-8编码。你可以发现这两个词语都含有 16进制表示的e6而GBK编码时都含有16进制的bc所以才会出现前面的表现。
下面是查看文本编码成UTF-8或GBK方式以及编码的结果
```
# UTF-8
&gt;&gt;&gt; u'极客'.encode('utf-8')
'\xe6\x9e\x81\xe5\xae\xa2' # 含有 e6
&gt;&gt;&gt; u'时间'.encode('utf-8')
'\xe6\x97\xb6\xe9\x97\xb4' # 含有 e6
# GBK
&gt;&gt;&gt; u'极客'.encode('gbk')
'\xbc\xab\xbf\xcd' # 含有 bc
&gt;&gt;&gt; u'时间'.encode('gbk')
'\xca\xb1\xbc\xe4' # 含有 bc
```
这也是前面我们花时间讲编码基础知识的原因,只有理解了编码的知识,你才能明白这些。在学习其它知识的时候也是一样的思路,不要去死记硬背,搞懂了底层原理,你自然就掌握了。因此在使用时,一定要指定 Unicode 编码,这样就可以正常工作了。
```
# Python2 或 Python3 都可以
&gt;&gt;&gt; import re
&gt;&gt;&gt; re.search(ur'[时间]', u'极客') is not None
False
&gt;&gt;&gt; re.findall(ur'[时间]', u'极客')
[]
```
### 点号匹配
之前我们学过,**点号**可以匹配除了换行符以外的任何字符但之前我们接触的大多是单字节字符。在Unicode中点号可以匹配上Unicode字符么这个其实情况比较复杂不同语言支持的也不太一样具体的可以通过测试来得到答案。
下面我给出了在Python和JavaScript测试的结果
```
# Python 2.7
&gt;&gt;&gt; import re
&gt;&gt;&gt; re.findall(r'^.$', '学')
[]
&gt;&gt;&gt; re.findall(r'^.$', u'学')
[u'\u5b66']
&gt;&gt;&gt; re.findall(ur'^.$', u'学')
[u'\u5b66']
# Python 3.7
&gt;&gt;&gt; import re
&gt;&gt;&gt; re.findall(r'^.$', '学')
['学']
&gt;&gt;&gt; re.findall(r'(?a)^.$', '学')
['学']
```
```
/* JavaScript(ES6) 环境 */
&gt; /^.$/.test(&quot;学&quot;)
true
```
至于其它的语言里面能不能用,你可以自己测试一下。在这个课程里,我更多地是希望你掌握这些学习的方法和思路,而不是单纯地记住某个知识点,一旦掌握了方法,之后就会简单多了。
### 字符组匹配
之前我们学习了很多字符组,比如\d表示数字\w表示大小写字母、下划线、数字\s表示空白符号等那 Unicode 下的数字比如全角的1、2、算不算数字呢全角的空格算不算空白呢同样你可以用我刚刚说的方法来测试一下你所用的语言对这些字符组的支持程度。
## Unicode 属性
在正则中使用Unicode还可能会用到Unicode的一些属性。这些属性把Unicode字符集划分成不同的字符小集合。
在正则中常用的有三种,分别是**按功能划分**的Unicode Categories有的也叫 Unicode Property比如标点符号数字符号按**连续区间划分**的Unicode Blocks比如只是中日韩字符按**书写系统划分**的Unicode Scripts比如汉语中文字符。
<img src="https://static001.geekbang.org/resource/image/2y/ae/2yy1c343b4151d14e088a795c4ec77ae.jpg" alt="">
在正则中如何使用这些Unicode属性呢在正则中这三种属性在正则中的表示方式都是\p{属性}。比如,我们可以使用 Unicode Script 来实现查找连续出现的中文。
<img src="https://static001.geekbang.org/resource/image/38/9c/383a10b093d483c095603930f968c29c.png" alt="">
你可以在[这里](https://regex101.com/r/Bgt4hl/1)进行测试。
其中Unicode Blocks在不同的语言中记法有差异比如Java需要加上In前缀类似于 \p{**In****Bopomofo**} 表示注音字符。
知道Unicode属性这些知识基本上就够用了在用到相关属性的时候可以再查阅一下参考手册。如果你想知道Unicode属性更全面的介绍可以看一下维基百科的对应链接。
- [Unicode Property](https://en.wikipedia.org/wiki/Unicode_character_property)
- [Unicode Block](https://en.wikipedia.org/wiki/Unicode_block)
- [Unicode Script](https://en.wikipedia.org/wiki/Script_(Unicode))
## 表情符号
表情符号其实是“图片字符”最初与日本的手机使用有关在日文中叫“绘文字”在英文中叫emoji但现在从日本流行到了世界各地。不少同学在聊天的时候喜欢使用表情。下面是办公软件钉钉中一些表情的截图。
<img src="https://static001.geekbang.org/resource/image/0e/e8/0ee6f3c217a13337b46c0ff41dc866e8.png" alt="">
在2020 年 3 月 10 日公布的Unicode标准 13.0.0 中新增了55个新的emoji表情完整的表情列表你可以在这里查看[这个链接](http://www.unicode.org/emoji/charts/full-emoji-list.html)。
这些表情符号有如下特点。
1. 许多表情不在BMP内码值超过了 FFFF。使用 UTF-8编码时普通的 ASCII 是1个字节中文是3个字节而有一些表情需要4个字节来编码。
1. 这些表情分散在BMP和各个补充平面中要想用一个正则来表示所有的表情符号非常麻烦即便使用编程语言处理也同样很麻烦。
1. 一些表情现在支持使用颜色修饰Fitzpatrick modifiers可以在5种色调之间进行选择。这样一个表情其实就是8个字节了。
在这里我给出了你有关于表情颜色修饰的5种色调你可以看一看。
<img src="https://static001.geekbang.org/resource/image/2e/75/2e74dd14262807c7ab80c4867c3a8975.png" alt="">
下面是使用IPython测试颜色最深的点赞表情在macOS上的测试结果。你可以发现它是由8个字节组成这样用正则处理起来就很不方便了。因此在处理表情符号时我不建议你使用正则来处理。你可以使用专门的库这样做一方面代码可读性更好另一方面是表情在不断增加使用正则的话不好维护会给其它同学留坑。而使用专门的库可以通过升级版本来解决这个问题。
<img src="https://static001.geekbang.org/resource/image/cf/69/cf9fbeddf035820a9303512dbedb2969.png" alt="">
## 总结
好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。
今天我们学习了Unicode编码的基础知识、了解了UTF-8编码、变长存储、以及它和Unicode的关系。Unicode字符按照功能码值区间和书写系统等方式进行分类比如按书写系统划分 \p{Han} 可以表示中文汉字。
在正则中使用Unicode有一些坑主要是因为编码问题使用的时候你要弄明白是拿Unicode去匹配还是编码后的某部分字节去进行匹配的这可以让你避开这些坑。
而在处理表情时,由于表情比较复杂,我不建议使用正则来处理,更建议使用专用的表情库来处理。
<img src="https://static001.geekbang.org/resource/image/76/3f/76924343bfb8d3f1612b92b6cab4703f.png" alt="">
## 课后思考
最后,我们来做一个小练习吧。在正则 xy{3} 中,你应该知道, y是重复3次那如果正则是“极客{3}”的时候代表是“客”这个汉字重复3次还是“客”这个汉字对应的编码最后一个字节重复3次呢如果是重复的最后一个字节应该如何解决
```
'极客{3}'
```
你可以自己来动动手,用自己熟悉的编程语言来试一试,经过不断练习你才能更好地掌握学习的内容。
今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,139 @@
<audio id="audio" title="09 | 应用2如何在编辑器中使用正则完成工作" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cc/7c/ccfa53e01a5e49c12fb2acb840c2be7c.mp3"></audio>
你好,我是伟忠。今天我来和你分享一下,在常见的编辑器中如何使用正则来完成工作。
你可能要问了,这是正则专栏,为啥不直接开始讲正则?其实我给你讲的编辑器使用技巧,能够帮助我们更好地完成工作。因为我们学习正则的目的,就是想高效地完成文本处理。
但在实际工作中,我们一般不会只用正则,而是通常将编辑器的特性和正则结合起来,这样可以让文本处理工作更加高效。正所谓“工欲善其事,必先利其器”,你花点时间来了解一下编辑器的各种特性,可以少写很多代码。
## 编辑器功能
接下来我以Sublime Text 3 为例给你讲讲一些在编辑器里的强大功能这些功能在Sublime Text、Atom、VS Code、JetBrains 系列IntelliJ IDEA/PyCharm/Goland等) 中都是支持的。
### 光标移动和文本选择
在常见的编辑器、IDE、甚至Chrome等浏览器中我们编辑文本时使用键盘的左右箭头移动光标可以**按住Shift键**来选中文本。在左右移动时,如果你按住**AltmacOS的option**,你会发现光标可以“按块移动”,快速移动到下一个单词。两种方式组合起来,你可以快速选择引号里面的内容。<br>
<img src="https://static001.geekbang.org/resource/image/f2/34/f2ca77c68ef5bf42b7f997693483f334.png" alt="">
你可以动手练习一下,熟悉一下这些操作。你可能会说,有必要么,我用鼠标拖一下不就可以了?你说得没错,但这个功能和后面我要讲的多焦点编辑、竖向编辑等结合起来,就可以快速多行操作,这是鼠标做不到的。
### 多焦点编辑
在IDE中我们如果想对某个变量或函数重命名通常可以使用重构refactor功能。但如果处理的不是代码而是普通文本比如JSON字符串的时候就没法这么用了。不过现在很多编辑器都提供了多焦点编辑的功能。
比如选择单词 route 之后,点击菜单 Find -&gt; Quick Find All 就可以选中所有的 route 了。你可以进行多焦点编辑,非常方便。我给了你一个测试文本,你可以点击[这里](https://github.com/gin-gonic/gin/blob/5e40c1d49c21bf989e8d54dbd555086f06d4fb8a/README.md#bind-uri)获取。
<img src="https://static001.geekbang.org/resource/image/eb/23/eb19d2152d2be332125aeec2d1371f23.png" alt="">
这个特性结合光标移动,可以快速提取某些内容,比如提取 JSON中的姓名和手机号。选中所有的字段和值之间的字符": " 之后,按住 Shift+AltmacOS上是Shift + Option用箭头移动光标可以快速选择到另外一个引号前然后剪切再找个空白地方粘贴就可以了。
```
{
&quot;error_code&quot;: 0,
&quot;result&quot;: {
&quot;data&quot;: [
{
&quot;name&quot;: &quot;朱小明&quot;,
&quot;tel&quot;: &quot;138xx138000&quot;
},
{
&quot;name&quot;: &quot;王五&quot;,
&quot;tel&quot;: &quot;139xx139000&quot;
}
]
}
}
```
<img src="https://static001.geekbang.org/resource/image/52/7c/520533a63bc9a1079619a25dda8c897c.png" alt="">
详细操作你可以看一下这个小视频。<br>
<video preload="none" controls=""><source src="https://media001.geekbang.org/customerTrans/7e27d07d27d407ebcc195a0e78395f55/e31f7b5-17305511c6b-0000-0000-01d-dbacd.mp4" type="video/mp4"></video>
### 竖向编辑
在编辑多行时,如果我们需要编辑的内容都是纵向上同一个位置,就可以使用 Alt (macOS上是 Option加上鼠标拖拽的方式来选择或者尝试按下鼠标中键拖拽。比如下图当你选择了左侧的两个空格之后可以批量编辑比如修改成四个空格。将竖向编辑和刚刚上面说到的光标移动结合起来会非常方便。
<img src="https://static001.geekbang.org/resource/image/6a/ee/6a52fd04f915be6171f6fb39f4185fee.png" alt="">
## 在编辑器中使用正则
正则是一种文本处理工具,常见的功能有文本验证、文本提取、文本替换、文本切割等。有一些地方说的正则匹配,其实是包括了校验和提取两个功能。
校验常用于验证整个文本的组成是不是符合规则,比如密码规则校验。提取则是从大段的文本中抽取出需要的内容,比如提取网页上所有的链接。在使用正则进行内容提取时,要做到不能提取到错误的内容(准确性),不能漏掉正确的内容(完备性)。这就要求我们写正则的时候尽量考虑周全。但是考虑周全并不容易,需要我们不断地练习、思考和总结。
### 内容提取
我以编辑器 Sublime Text 3 为例来进行讲解,下图是编辑器 Sublime Text 查找界面的介绍。
<img src="https://static001.geekbang.org/resource/image/dd/2c/dd3362f28c66b8fabb279648e872042c.png" alt="">
我们来尝试使用 sublime 提取文本中所有的邮箱地址,这里并不要求你写出一个完美的正则,因此演示时,使用`\S+@\S+\.\S+` 这个正则。另外我们可以加上环视,去掉尾部的分号。你可以[在这里](http://www.zhongguosou.com/mailharddisk/mail_address_creator.aspx)随机生成一些邮箱用于测试。
<img src="https://static001.geekbang.org/resource/image/d1/fa/d19c306e136e48cce055c56888e54bfa.png" alt="">
你可能会有疑问,我直接找到最后的分号,然后删除掉不就可以了么?这个例子是没问题的,但如果文本中除了邮箱之外,还有其它的内容这样就不行了,这也是正则比普通文本强大的地方。
<img src="https://static001.geekbang.org/resource/image/a1/dc/a18de0a5516ee9e6f65744840dbdf6dc.png" alt="">
### 内容替换
说完了查找,我们再来看一下替换。之前课程里我们也有讲过,这里再来回顾一下。下图是编辑器 Sublime Text 替换界面的介绍。
<img src="https://static001.geekbang.org/resource/image/fa/a6/fa097dcb7ec8c647ed6c7204e30a76a6.png" alt="">
同样是上面邮箱的例子,我们可以使用子组和引用,直接替换得到移除了分号之后的邮箱,我们还可以在邮箱前把邮箱类型加上。操作前和操作后的示意图如下:
<img src="https://static001.geekbang.org/resource/image/91/8c/91cc91d71fa3deb371e8fc19d364d68c.png" alt="">
<img src="https://static001.geekbang.org/resource/image/5f/18/5fdcd64731ece75da67e5f323665f018.png" alt="">
替换和提取的不同在于,替换可以对内容进行拼接和改造,而提取是从原来的内容中抽取出一个子集,不会改变原来的内容。当然在实际应用中,可以两个结合起来一起使用。
### 内容验证
在编辑器中进行内容验证,本质上和内容提取一样,只不过要求编辑器中全部内容都匹配上,并且匹配次数是一次。
<img src="https://static001.geekbang.org/resource/image/bc/fc/bc4c8d5521dcff1cc39bc8e78bd819fc.png" alt="">
### 内容切割
在编辑器中进行内容切割,本质上也和内容提取一样,用什么切割,我们就提取什么,选中全部之后,把选中的内容删除掉或者编辑成其它的字符。
<img src="https://static001.geekbang.org/resource/image/a8/5c/a815cb2ea9754ae41048668d82ca545c.png" alt="">
刚刚我们讲解了在 Sublime Text 中使用正则处理文本的方法其它的编辑器或IDE如 Atom、VS Code、JetBrains系列IntelliJ IDEA/PyCharm/Goland等也都是类似的你可以在自己喜欢的编辑器中练习一下今天讲到的内容。
这里给出一些主流跨平台编辑器/IDE对正则的支持情况你可以参考一下。
<img src="https://static001.geekbang.org/resource/image/91/6b/91329d890437420a2eed55eef713yy6b.jpg" alt="">
## 总结
好了,今天的内容讲完了,我来带你总结回顾一下。
今天我们学习了编辑器中一些提高文本处理效率的操作方式:光标移动和选择、多焦点编辑以及竖向编辑。学会了这些,即使不使用正则,我们在编辑器中处理文本效率也会大大提高。接着通过一些示例,我们学习了在编辑器中使用正则来进行文本内容提取,内容替换等操作。正则的使用一般会和其它的方法结合起来,最终帮助我们高效地完成文本的处理工作。
今天所讲的内容总结脑图如下,你可以回顾一下:
<img src="https://static001.geekbang.org/resource/image/c7/be/c75e1165db7cafd9979551777cf72ebe.png" alt="">
## 课后思考
最后我们来做一个小练习吧统计一篇英文文章中每个单词出现的次数使用Sublime Text 等编辑器提取文章里所有的单词处理成一行一个单词保存到文件中然后再使用sort、uniq 等命令统计单词出现的次数。
为了帮你更好地完成这个任务,你可以参考一下下面的提示:
1. windows上的同学可以使用 [git for windows](https://gitforwindows.org/) 工具。
1. 使用 uniq 前需要先用 sort 命令排序uniq -c 可以统计次数。
```
sort words.txt | uniq -c
如果想取前10名可以继续对结果排序
sort words.txt | uniq -c | sort -nrk1 | head -n10
至于为什么要加 n、r 和 k1 你可以通过 man sort 看一下说明
```
好,今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,并把文章分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,528 @@
<audio id="audio" title="10 | 应用3如何在语言中用正则让文本处理能力上一个台阶" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f3/22/f3f806df5c8485f50dcf89bf91598022.mp3"></audio>
你好,我是伟忠。今天要和你分享的内容是如何在编程语言中使用正则,让文本处理能力上一个台阶。
现代主流的编程语言几乎都内置了正则模块,很少能见到不支持正则的编程语言。学会在编程语言中使用正则,可以极大地提高文本的处理能力。
在进行文本处理时,正则解决的问题大概可以分成四类,分别是校验文本内容、提取文本内容、替换文本内容、切割文本内容。在这一节里,我会从功能分类出发,给你讲解在一些常见的编程语言中,如何正确地实现这些功能。
## 1.校验文本内容
我们先来看一下数据验证通常我们在网页上输入的手机号、邮箱、日期等都需要校验。校验的特点在于整个文本的内容要符合正则比如要求输入6位数字的时候输入123456abc 就是不符合要求的。
下面我们以验证日期格式年月日为例子来讲解比如2020-01-01我们使用正则\d{4}-\d{2}-\d{2} 来验证。
### Python
在 Python 中,正则的包名是 re验证文本可以使用 re.match 或 re.search 的方法这两个方法的区别在于re.match 是从开头匹配的re.search是从文本中找子串。下面是详细的解释
```
# 测试环境 Python3
&gt;&gt;&gt; import re
&gt;&gt;&gt; re.match(r'\d{4}-\d{2}-\d{2}', '2020-06-01')
&lt;re.Match object; span=(0, 10), match='2020-06-01'&gt;
# 这个输出是匹配到了范围是从下标0到下标10匹配结果是2020-06-01
# re.search 输出结果也是类似的
```
**在Python中校验文本是否匹配的正确方式<strong><strong>如下所示**</strong></strong>
```
# 测试环境 Python3
&gt;&gt;&gt; import re
&gt;&gt;&gt; reg = re.compile(r'\A\d{4}-\d{2}-\d{2}\Z') # 建议先编译,提高效率
&gt;&gt;&gt; reg.search('2020-06-01') is not None
True
&gt;&gt;&gt; reg.match('2020-06-01') is not None # 使用match时\A可省略
True
```
如果不添加 \A 和 \Z 的话,我们就可能得到错误的结果。而造成这个错误的主要原因就是,没有完全匹配,而是部分匹配。至于为什么不推荐用`^``$`,因为在多行模式下,它们的匹配行为会发现变化,相关内容在前面匹配模式中讲解过,要是忘记了你可以返回去回顾一下。
```
# 错误示范
&gt;&gt;&gt; re.match(r'\d{4}-\d{2}-\d{2}', '2020-06-01abc') is not None
True
&gt;&gt;&gt; re.search(r'\d{4}-\d{2}-\d{2}', 'abc2020-06-01') is not None
True
```
### Go
Go语言又称Golang是Google开发的一种静态强类型、编译型、并发型并具有垃圾回收功能的编程语言。在Go语言中正则相关的包是 regexp下面是一个完整可运行的示例。
```
package main
import (
&quot;fmt&quot;
&quot;regexp&quot;
)
func main() {
re := regexp.MustCompile(`\A\d{4}-\d{2}-\d{2}\z`)
// 输出 true
fmt.Println(re.MatchString(&quot;2020-06-01&quot;))
}
```
保存成 main.go 在配置好go环境的前提下直接使用命令 go run main.go 运行。不方便本地搭建Go环境的同学可以点击 [这里](https://play.golang.org/p/bTQJe0mT839) 或 [这里](https://repl.it/@twz915/learn-regex#%E6%A0%A1%E9%AA%8C/date.go) 进行在线运行测试。
另外,需要注意的是,和 Python 语言不同,在 Go 语言中,正则尾部断言使用的是 \z而不是 \Z。
### JavaScript
在JavaScript中没有 \A 和 \z我们可以使用`^``$`来表示每行的开头和结尾,默认情况下它们是匹配整个文本的开头或结尾(默认不是多行匹配模式)。在 JavaScript 中校验文本的时候,不要使用多行匹配模式,因为使用多行模式会改变`^``$`的匹配行为。
JavaScript代码可以直接在浏览器的Console中很方便地测试。进入方式任意网页上点击鼠标右键检查Console
```
// 方法1
/^\d{4}-\d{2}-\d{2}$/.test(&quot;2020-06-01&quot;) // true
// 方法2
var regex = /^\d{4}-\d{2}-\d{2}$/
&quot;2020-06-01&quot;.search(regex) == 0 // true
// 方法3
var regex = new RegExp(/^\d{4}-\d{2}-\d{2}$/)
regex.test(&quot;2020-01-01&quot;) // tru
```
方法3本质上和方法1是一样的方法1写起来更简洁。需要注意的是在使用 RegExp 对象时,如果使用 g 模式,可能会有意想不到的结果,连续调用会出现第二次返回 false 的情况,就像下面这样:
```
var r = new RegExp(/^\d{4}-\d{2}-\d{2}$/, &quot;g&quot;)
r.test(&quot;2020-01-01&quot;) // true
r.test(&quot;2020-01-01&quot;) // false
```
这是因为 RegExp 在全局模式下,正则会找出文本中的所有可能的匹配,找到一个匹配时会记下 lastIndex在下次再查找时找不到lastIndex变为0所以才有上面现象。
```
var regex = new RegExp(/^\d{4}-\d{2}-\d{2}$/, &quot;g&quot;)
regex.test(&quot;2020-01-01&quot;) // true
regex.lastIndex // 10
regex.test(&quot;2020-01-01&quot;) // false
regex.lastIndex // 0
// 为了加深理解,你可以看下面这个例子
var regex = new RegExp(/\d{4}-\d{2}-\d{2}/, &quot;g&quot;)
regex.test(&quot;2020-01-01 2020-02-02&quot;) // true
regex.lastIndex // 10
regex.test(&quot;2020-01-01 2020-02-02&quot;) // true
regex.lastIndex // 21
regex.test(&quot;2020-01-01 2020-02-02&quot;) // false
```
由于我们这里是文本校验并不需要找出所有的。所以要记住JavaScript中文本校验在使用 RegExp 时不要设置 g 模式。
另外在ES6中添加了匹配模式 u如果要在 JavaScript 中匹配中文等多字节的 Unicode 字符,可以指定匹配模式 u比如测试是否为一个字符可以是任意Unicode字符详情可以参考下面的示例
```
/^\u{1D306}$/u.test(&quot;𝌆&quot;) // true
/^\u{1D306}$/.test(&quot;𝌆&quot;) // false
/^.$/u.test(&quot;好&quot;) // true
/^.$/u.test(&quot;好人&quot;) // false
/^.$/u.test(&quot;a&quot;) // true
/^.$/u.test(&quot;ab&quot;) // false
```
### Java
在 Java 中,正则相关的类在 java.util.regex 中,其中最常用的是 Pattern 和 Matcher Pattern 是正则表达式对象Matcher是匹配到的结果对象Pattern 和 字符串对象关联,可以得到一个 Matcher。下面是 Java 中匹配的示例:
```
import java.util.regex.Matcher;
import java.util.regex.Pattern;
class Main {
public static void main(String[] args) {
//方法1可以不加 \A 和 \z
System.out.println(Pattern.matches(&quot;\\d{4}-\\d{2}-\\d{2}&quot;, &quot;2020-06-01&quot;)); // true
//方法2可以不加 \A 和 \z
System.out.println(&quot;2020-06-01&quot;.matches(&quot;\\d{4}-\\d{2}-\\d{2}&quot;)); // true
//方法3必须加上 \A 和 \z
Pattern pattern = Pattern.compile(&quot;\\A\\d{4}-\\d{2}-\\d{2}\\z&quot;);
System.out.println(pattern.matcher(&quot;2020-06-01&quot;).find()); // true
}
}
```
Java 中目前还没有原生字符串,在之前转义一节讲过,正则需要经过字符串转义和正则转义两个步骤,因此在用到反斜扛的地方,比如表示数字的`\d`,就得在字符串中表示成`\\d`,转义会让书写正则变得稍微麻烦一些,在使用的时候需要留意一下。
部分常见编程语言校验文本方式,你可以参考下面的表。
<img src="https://static001.geekbang.org/resource/image/e9/13/e97814862f1943b59cf341728f789813.jpg" alt="">
## 2.提取文本内容
我们再来看一下文本内容提取,所谓内容提取,就是从大段的文本中抽取出我们关心的内容。比较常见的例子是网页爬虫,或者说从页面上提取邮箱、抓取需要的内容等。如果要抓取的是某一个网站,页面样式是一样的,要提取的内容都在同一个位置,可以使用 [xpath](https://lxml.de/xpathxslt.html) 或 [jquery选择器](https://pypi.org/project/pyquery/) 等方式,否则就只能使用正则来做了。
下面我们来讲解一下具体的例子,让你了解一下正则提取文本在一些常见的编程语言中的使用。
### Python
在 Python 中提取内容最简单的就是使用 re.findall 方法了,当有子组的时候,会返回子组的内容,没有子组时,返回整个正则匹配到的内容。下面我以查找日志的年月为例进行讲解,年月可以用正则 \d{4}-\d{2} 来表示:
```
# 没有子组时
&gt;&gt;&gt; import re
&gt;&gt;&gt; reg = re.compile(r'\d{4}-\d{2}')
&gt;&gt;&gt; reg.findall('2020-05 2020-06')
['2020-05', '2020-06']
# 有子组时
&gt;&gt;&gt; reg = re.compile(r'(\d{4})-(\d{2})')
&gt;&gt;&gt; reg.findall('2020-05 2020-06')
[('2020', '05'), ('2020', '06')]
```
通过上面的示例你可以看到,直接使用 findall 方法时,它会把结果存储到一个列表(数组)中,一下返回所有匹配到的结果。如果想节约内存,可以采用迭代器的方式来处理,就像下面这样:
```
&gt;&gt;&gt; import re
&gt;&gt;&gt; reg = re.compile(r'(\d{4})-(\d{2})')
&gt;&gt;&gt; for match in reg.finditer('2020-05 2020-06'):
... print('date: ', match[0]) # 整个正则匹配到的内容
... print('year: ', match[1]) # 第一个子组
... print('month:', match[2]) # 第二个子组
...
date: 2020-05
year: 2020
month: 05
date: 2020-06
year: 2020
month: 06
```
这样我们就可以实现正则找到一个在程序中处理一个不需要将找到的所有结果构造成一个数组Python中的列表
### Go
在 Go语言里面查找也非常简洁可以直接使用 FindAllString 方法。如果我们想捕获子组,可以使用 FindAllStringSubmatch 方法。
```
package main
import (
&quot;fmt&quot;
&quot;regexp&quot;
)
func main() {
re := regexp.MustCompile(`\d{4}-\d{2}`)
// 返回一个切片(可动态扩容的数组) [2020-06 2020-07]
fmt.Println(re.FindAllString(&quot;2020-06 2020-07&quot;, -1))
// 捕获子组的查找示例
re2 := regexp.MustCompile(`(\d{4})-(\d{2})`)
// 返回结果和上面 Python 类似
for _, match := range re2.FindAllStringSubmatch(&quot;2020-06 2020-07&quot;, -1) {
fmt.Println(&quot;date: &quot;, match[0])
fmt.Println(&quot;year: &quot;, match[1])
fmt.Println(&quot;month:&quot;, match[2])
}
}
```
### JavaScript
在 JavaScript 中,想要提取文本中所有符合要求的内容,正则必须使用 g 模式,否则找到第一个结果后,正则就不会继续向后查找了。
```
// 使用g模式查找所有符合要求的内容
&quot;2020-06 2020-07&quot;.match(/\d{4}-\d{2}/g)
// 输出:[&quot;2020-06&quot;, &quot;2020-07&quot;]
// 不使用g模式找到第一个就会停下来
&quot;2020-06 2020-07&quot;.match(/\d{4}-\d{2}/)
// 输出:[&quot;2020-06&quot;, index: 0, input: &quot;2020-06 2020-07&quot;, groups: undefined]
```
如果要查找中文等Unicode字符可以使用 u 匹配模式,下面是具体的示例。
```
'𝌆'.match(/\u{1D306}/ug) // 使用匹配模式u
[&quot;𝌆&quot;]
'𝌆'.match(/\u{1D306}/g) // 不使用匹配模式u
null
// 如果你对这个符号感兴趣,可以参考 https://unicode-table.com/cn/1D306
```
### Java
在 Java 中,可以使用 Matcher 的 find 方法来获取查找到的内容,就像下面这样:
```
import java.util.regex.Matcher;
import java.util.regex.Pattern;
class Main {
public static void main(String[] args) {
Pattern pattern = Pattern.compile(&quot;\\d{4}-\\d{2}&quot;);
Matcher match = pattern.matcher(&quot;2020-06 2020-07&quot;);
while (match.find()) {
System.out.println(match.group());
}
}
}
```
部分常见编程语言提取文本方式,你可以参考下面的表。
<img src="https://static001.geekbang.org/resource/image/b1/c9/b14435e91df9454f6fa361b1510ff2c9.jpg" alt="">
## 3.替换文本内容
我们接着来看一下文本内容替换,替换通常用于对原来的文本内容进行一些调整。之前我们也讲解过一些使用正则进行替换的例子,今天我们再来了解一下在部分常见的编程语言中,使用正则进行文本替换的方法。
### Python
在 Python 中替换相关的方法有 re.sub 和 re.subn后者会返回替换的次数。下面我以替换年月的格式为例进行讲解假设原始的日期格式是月日年我们要将其处理成 xxxx年xx月xx日的格式。你可以看到在Python中正则替换操作相关的方法使用起来非常地简单。
```
&gt;&gt;&gt; import re
&gt;&gt;&gt; reg = re.compile(r'(\d{2})-(\d{2})-(\d{4})')
&gt;&gt;&gt; reg.sub(r'\3年\1月\2日', '02-20-2020 05-21-2020')
'2020年02月20日 2020年05月21日'
# 可以在替换中使用 \g&lt;数字&gt;如果分组多于10个时避免歧义
&gt;&gt;&gt; reg.sub(r'\g&lt;3&gt;年\g&lt;1&gt;月\g&lt;2&gt;日', '02-20-2020 05-21-2020')
'2020年02月20日 2020年05月21日'
# 返回替换次数
&gt;&gt;&gt; reg.subn(r'\3年\1月\2日', '02-20-2020 05-21-2020')
('2020年02月20日 2020年05月21日', 2)
```
### Go
在 Go语言里面替换和Python也非常类似只不过子组是使用 ${num} 的方式来表示的。
```
package main
import (
&quot;fmt&quot;
&quot;regexp&quot;
)
func main() {
re := regexp.MustCompile(`(\d{2})-(\d{2})-(\d{4})`)
// 示例一,返回 2020年02月20日 2020年05月21日
fmt.Println(re.ReplaceAllString(&quot;02-20-2020 05-21-2020&quot;, &quot;${3}年${1}月${2}日&quot;))
// 示例二,返回空字符串,因为&quot;3年&quot;&quot;1月&quot;&quot;2日&quot; 这样的子组不存在
fmt.Println(re.ReplaceAllString(&quot;02-20-2020 05-21-2020&quot;, &quot;$3年$1月$2日&quot;))
// 示例三,返回 2020-02-20 2020-05-21
fmt.Println(re.ReplaceAllString(&quot;02-20-2020 05-21-2020&quot;, &quot;$3-$1-$2&quot;))
}
```
需要你注意的是,不建议把 `$`{num} 写成不带花括号的 `$`num比如示例二中的错误会让人很困惑Go认为子组是`“3年”“1月”“2日”`。 由于这样的子组不存在,最终替换成了空字符串,所以使用的时候要注意这一点。
### JavaScript
在 JavaScript 中替换和查找类似,需要指定 g 模式,否则只会替换第一个,就像下面这样。
```
// 使用g模式替换所有的
&quot;02-20-2020 05-21-2020&quot;.replace(/(\d{2})-(\d{2})-(\d{4})/g, &quot;$3年$1月$2日&quot;)
// 输出 &quot;2020年02月20日 2020年05月21日&quot;
// 不使用 g 模式时,只替换一次
&quot;02-20-2020 05-21-2020&quot;.replace(/(\d{2})-(\d{2})-(\d{4})/, &quot;$3年$1月$2日&quot;)
// 输出 &quot;2020年02月20日 05-21-2020&quot;
```
### Java
在 Java 中,一般是使用 replaceAll 方法进行替换,一次性替换所有的匹配到的文本。
```
import java.util.regex.Matcher;
import java.util.regex.Pattern;
class Main {
public static void main(String[] args) {
//方法1输出 2020年02月20日 2020年05月21日
System.out.println(&quot;02-20-2020 05-21-2020&quot;.replaceAll(&quot;(\\d{2})-(\\d{2})-(\\d{4})&quot;, &quot;$3年$1月$2日&quot;));
//方法2输出 2020年02月20日 2020年05月21日
final Pattern pattern = Pattern.compile(&quot;(\\d{2})-(\\d{2})-(\\d{4})&quot;);
Matcher match = pattern.matcher(&quot;02-20-2020 05-21-2020&quot;);
System.out.println(match.replaceAll(&quot;$3年$1月$2日&quot;));
}
}
```
部分常见编程语言替换文本方式,你可以参考下面的表。
<img src="https://static001.geekbang.org/resource/image/98/yy/98603bb41c59dac186bab6dc12a494yy.jpg" alt="">
## 4.切割文本内容
我们最后再来看一下文本内容切割,通常切割用于变长的空白符号,多变的标点符号等。
下面我们来讲解一下具体的例子,让你了解一下正则切割文本在部分常见编程语言中的使用。
### Python
在 Python 中切割相关的方法是 re.split。如果我们有按照任意空白符切割的需求可以直接使用字符串的 split 方法,不传任何参数时就是按任意连续一到多个空白符切割。
```
# 使用字符串的切割方法
&gt;&gt;&gt; &quot;a b c\n\nd\t\n \te&quot;.split()
['a', 'b', 'c', 'd', 'e']
```
使用正则进行切割,比如我们要通过标点符号切割,得到所有的单词(这里简单使用非单词组成字符来表示)。
```
&gt;&gt;&gt; import re
&gt;&gt;&gt; reg = re.compile(r'\W+')
&gt;&gt;&gt; reg.split(&quot;apple, pear! orange; tea&quot;)
['apple', 'pear', 'orange', 'tea']
# 限制切割次数,比如切一刀,变成两部分
&gt;&gt;&gt; reg.split(&quot;apple, pear! orange; tea&quot;, 1)
['apple', 'pear! orange; tea']
```
### Go
在 Go语言里面切割是 Split 方法,和 Python 非常地类似只不过Go语言中这个方法的第二个参数是必传的如果不限制次数我们传入 -1 即可。
```
package main
import (
&quot;fmt&quot;
&quot;regexp&quot;
)
func main() {
re := regexp.MustCompile(`\W+`)
// 返回 []string{&quot;apple&quot;, &quot;pear&quot;, &quot;orange&quot;, &quot;tea&quot;}
fmt.Printf(&quot;%#v&quot;, re.Split(&quot;apple, pear! orange; tea&quot;, -1)
}
```
但在Go语言中有个地方和 Python 不太一样,就是传入的第二个参数代表切割成几个部分,而不是切割几刀。
```
// 返回 []string{&quot;apple&quot;, &quot;pear! orange; tea&quot;}
fmt.Printf(&quot;%#v\n&quot;, re.Split(&quot;apple, pear! orange; tea&quot;, 2))
// 返回 []string{&quot;apple&quot;}
fmt.Printf(&quot;%#v\n&quot;, re.Split(&quot;apple&quot;, 2))
```
这里有一个[在线测试链接](https://play.golang.org/p/4VsBKxxXzYp),你可以尝试一下。
### JavaScript
在 JavaScript 中,正则的切割和刚刚讲过的 Python 和 Go 有些类似但又有区别。当第二个参数是2的时候表示切割成2个部分而不是切2刀Go和Java也是类似的但数组的内容不是 apple 后面的剩余部分,而是全部切割之后的 pear你可以注意比较一下。
```
&quot;apple, pear! orange; tea&quot;.split(/\W+/)
// 输出:[&quot;apple&quot;, &quot;pear&quot;, &quot;orange&quot;, &quot;tea&quot;]
// 传入第二个参数的情况
&quot;apple, pear! orange; tea&quot;.split(/\W+/, 1)
// 输出 [&quot;apple&quot;]
&quot;apple, pear! orange; tea&quot;.split(/\W+/, 2)
// 输出 [&quot;apple&quot;, &quot;pear&quot;]
&quot;apple, pear! orange; tea&quot;.split(/\W+/, 10)
// 输出 [&quot;apple&quot;, &quot;pear&quot;, &quot;orange&quot;, &quot;tea&quot;]
```
### Java
Java中切割也是类似的由于没有原生字符串转义稍微麻烦点。
```
import java.util.regex.Matcher;
import java.util.regex.Pattern;
class Main {
public static void main(String[] args) {
Pattern pattern = Pattern.compile(&quot;\\W+&quot;);
for(String s : pattern.split(&quot;apple, pear! orange; tea&quot;)) {
System.out.println(s);
}
}
}
```
在 Java 中,也可以传入第二个参数,类似于 Go 的结果。
```
pattern.split(&quot;apple, pear! orange; tea&quot;, 2)
// 返回 &quot;apple&quot; 和 &quot;pear! orange; tea&quot;
```
部分常见编程语言切割文本方式,你可以参考下面的表。
<img src="https://static001.geekbang.org/resource/image/67/56/6708a65e269e645abb9c6ca85b5a4b56.jpg" alt="">
## 总结
好了,今天的内容讲完了,我来带你总结回顾一下。
今天我们学习了正则解决的问题大概可以分成四类,分别是校验文本内容、提取文本内容、替换文本内容、切割文本内容。从这四个功能出发,我们学习了在一些常见的编程语言中,如何正确地使用相应的方法来实现这些功能。这些方法都比较详细,希望你能够认真练习,掌握好这些方法。
我给你总结了一个今天所讲内容的详细脑图,你可以长按保存下来,经常回顾一下:
<img src="https://static001.geekbang.org/resource/image/f1/25/f1d925e4795e1310886aaf82caf42325.png" alt="">
## 课后思考
最后,我们来做一个小练习吧。很多网页为了防止爬虫,喜欢把邮箱里面的 @ 符号替换成 # 符号,你可以写一个正则,兼容一下这种情况么?
```
例如网页的底部可能是这样的:
联系邮箱xxx#163.com (请把#换成@)
```
你可以试试自己动手,使用你熟悉的编程语言,测试一下你写的正则能不能提取出这种“防爬”的邮箱。
好,今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,并把文章分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,278 @@
<audio id="audio" title="11 | 如何理解正则的匹配原理以及优化原则?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2e/38/2eedef2dfdb54edafb3e392e785ae138.mp3"></audio>
你好,我是伟忠,这一节课我们一起来学习正则匹配原理相关的内容,以及在书写正则时的一些优化方法。
这节课我主要给你讲解一下正则匹配过程,回顾一下之前讲的回溯,以及 DFA 和 NFA 引擎的工作方式,方便你明白正则是如何进行匹配的。这些原理性的知识,能够帮助我们快速理解为什么有些正则表达式不符合预期,也可以避免一些常见的错误。只有了解正则引擎的工作原理,我们才可以更轻松地写出正确的,性能更好的正则表达式。
## 有穷状态自动机
正则之所以能够处理复杂文本,就是因为采用了**有穷状态自动机(****finite automaton。**那什么是有穷自动机呢?有穷状态是指一个系统具有有穷个状态,不同的状态代表不同的意义。自动机是指系统可以根据相应的条件,在不同的状态下进行转移。从一个初始状态,根据对应的操作(比如录入的字符集)执行状态转移,最终达到终止状态(可能有一到多个终止状态)。
有穷自动机的具体实现称为正则引擎,主要有 DFA 和 NFA 两种,其中 NFA 又分为传统的NFA 和POSIX NFA。
```
DFA确定性有穷自动机Deterministic finite automaton
NFA非确定性有穷自动机Non-deterministic finite automaton
```
接下来我们来通过一些示例,来详细看下正则表达式的匹配过程。
## 正则的匹配过程
在使用到编程语言时,我们经常会“编译”一下正则表达式,来提升效率,比如在 Python3 中它是下面这样的:
```
&gt;&gt;&gt; import re
&gt;&gt;&gt; reg = re.compile(r'a(?:bb)+a')
&gt;&gt;&gt; reg.findall('abbbba')
['abbbba']
```
这个编译的过程,其实就是生成自动机的过程,正则引擎会拿着这个自动机去和字符串进行匹配。生成的自动机可能是这样的(下图是使用[Regexper工具](https://regexper.com/#a%28%3F%3Abb%29%2Ba)生成,再次加工得到的)。
<img src="https://static001.geekbang.org/resource/image/e3/4f/e3e052fac55209937cfd20ab5117f24f.png" alt="">
在状态 s3 时,不需要输入任何字符,状态也有可能转换成 s1。你可以理解成 a(bb)+a 在匹配了字符 abb 之后,到底在 s3 状态,还是在 s1 状态这是不确定的。这种状态机就是非确定性有穷状态自动机Non-deterministic finite automaton 简称NFA
**NFA和DFA是可以相互转化的**当我们把上面的状态表示成下面这样就是一台DFA状态机了因为在 s0-s4 这几个状态,每个状态都需要特定的输入,才能发生状态变化。
<img src="https://static001.geekbang.org/resource/image/c7/f7/c7e756e33fd5ce6156e35d8ec66e2df7.png" alt="">
那这两种状态机的工作方式到底有什么不同呢?我们接着往下看。
### DFA&amp; NFA 工作机制
下面我通过一个示例,来简单说明 **NFA 与 DFA 引擎工作方式的区别**
```
字符串we study on jikeshijian app
正则jike(zhushou|shijian|shixi)
```
NFA引擎的工作方式是先看正则再看文本而且以正则为主导。正则中的第一个字符是jNFA引擎在字符串中查找 j接着匹配其后是否为 i ,如果是 i 则继续,这样一直找到 jike。
```
regex: jike(zhushou|shijian|shixi)
^
text: we study on jikeshijian app
^
```
我们再根据正则看文本后面是不是 z发现不是此时 zhushou 分支淘汰。
```
regex: jike(zhushou|shijian|shixi)
^
淘汰此分支(zhushou)
text: we study on jikeshijian app
^
```
我们接着看其它的分支,看文本部分是不是 s直到 shijian 整个匹配上。shijian 在匹配过程中如果不失败,就不会看后面的 shixi 分支。当匹配上了 shijian 后,整个文本匹配完毕,也不会再看 shixi 分支。
假设这里文本改一下,把 jikeshijian 变成 jikeshixi正则 shi**j**ian 的 j 匹配不上时shixi 的 x会接着使用正则 shixi 来进行匹配,重新从 s 开始NFA引擎会记住这里
```
第二个分支匹配失败
regex: jike(zhushou|shijian|shixi)
^
淘汰此分支(正则j匹配不上文本x)
text: we study on jikeshixi app
^
再次尝试第三个分支
regex: jike(zhushou|shijian|shixi)
^
text: we study on jikeshixi app
^
```
也就是说, NFA 是以正则为主导,反复测试字符串,这样字符串中同一部分,有可能被反复测试很多次。
而 DFA 不是这样的DFA 会先看文本再看正则表达式是以文本为主导的。在具体匹配过程中DFA 会从 we 中的 w 开始依次查找 j定位到 j ,这个字符后面是 i。所以我们接着看正则部分是否有 i ,如果正则后面是个 i ,那就以同样的方式,匹配到后面的 ke。
```
text: we study on jikeshijian app
^
regex: jike(zhushou|shijian|shixi)
^
```
继续进行匹配,文本 e 后面是字符 s DFA 接着看正则表达式部分,此时 zhushou 分支被淘汰开头是s的分支 shijian 和 shixi 符合要求。
```
text: we study on jikeshijian app
^
regex: jike(zhushou|shijian|shixi)
^ ^ ^
淘汰 符合 符合
```
然后 DFA 依次检查字符串,检测到 shijian 中的 j 时,只有 shijian 分支符合,淘汰 shixi接着看分别文本后面的 ian和正则比较匹配成功。
```
text: we study on jikeshijian app
^
regex: jike(zhushou|shijian|shixi)
^ ^
符合 淘汰
```
从这个示例你可以看到DFA 和 NFA 两种引擎的工作方式完全不同。NFA 是以表达式为主导的,先看正则表达式,再看文本。而 DFA 则是以文本为主导,先看文本,再看正则表达式。
一般来说DFA 引擎会更快一些因为整个匹配过程中字符串只看一遍不会发生回溯相同的字符不会被测试两次。也就是说DFA 引擎执行的时间一般是线性的。DFA 引擎可以确保匹配到可能的最长字符串。但由于 DFA 引擎只包含有限的状态,所以它没有反向引用功能;并且因为它不构造显示扩展,它也不支持捕获子组。
NFA 以表达式为主导它的引擎是使用贪心匹配回溯算法实现。NFA 通过构造特定扩展,支持子组和反向引用。但由于 NFA 引擎会发生回溯,即它会对字符串中的同一部分,进行很多次对比。因此,在最坏情况下,它的执行速度可能非常慢。
### POSIX NFA 与 传统 NFA 区别
因为传统的 NFA 引擎“急于”报告匹配结果,找到第一个匹配上的就返回了,所以可能会导致还有更长的匹配未被发现。比如使用正则 pos|posix 在文本 posix 中进行匹配,传统的 NFA 从文本中找到的是 pos而不是 posix而 POSIX NFA 找到的是 posix。
<img src="https://static001.geekbang.org/resource/image/43/5f/4386yyd95dc71323098e9c6ae187645f.png" alt="">
POSIX NFA的应用很少主要是 Unix/Linux 中的某些工具。POSIX NFA 引擎与传统的 NFA 引擎类似但不同之处在于POSIX NFA 在找到可能的最长匹配之前会继续回溯也就是说它会尽可能找最长的如果分支一样长以最左边的为准“The Longest-Leftmost”。因此POSIX NFA 引擎的速度要慢于传统的 NFA 引擎。
我们日常面对的一般都是传统的NFA所以通常都是最左侧的分支优先在书写正则的时候务必要注意这一点。
下面是 DFA、传统NFA 以及 POSIX NFA 引擎的特点总结:
<img src="https://static001.geekbang.org/resource/image/f6/1a/f60e745b693a11d50e4c41b02f9f4c1a.jpg" alt="">
## 回溯
回溯是 NFA引擎才有的并且只有在正则中出现**量词**或**多选分支结构**时,才可能会发生回溯。
比如我们使用正则 a+ab 来匹配 文本 aab 的时候过程是这样的a+是贪婪匹配,会占用掉文本中的两个 a但正则接着又是 a文本部分只剩下 b只能通过回溯让 a+ 吐出一个 a再次尝试。
如果正则是使用 `.*ab` 去匹配一个比较长的字符串就更糟糕了,因为 `.*` 会吃掉整个字符串(不考虑换行,假设文本中没有换行),然后,你会发现正则中还有 ab 没匹配到内容,只能将 `.*` 匹配上的字符串吐出一个字符,再尝试,还不行,再吐出一个,不断尝试。<br>
<img src="https://static001.geekbang.org/resource/image/f2/24/f2aac8643c053fd7fb010e18f9431624.jpg" alt="">
所以在工作中,我们要尽量不用 **.*** ,除非真的有必要,因为点能匹配的范围太广了,我们要尽可能精确。常见的解决方式有两种,比如要提取引号中的内容时,使用 “**[^"]+**”,或者使用非贪婪的方式 “**.+?**”,来减少“匹配上的内容不断吐出,再次尝试”的过程。
我们再回头看一下之前讲解的店铺名匹配示例:
<img src="https://static001.geekbang.org/resource/image/ac/fb/ac99c68754d4e6c54d958970e9c3a5fb.png" alt="">
从[示例](https://regex101.com/r/Qbsm4g/1)我们可以看到一个很短的字符串NFA引擎尝试步骤达到了 9021 次,由于是贪婪匹配,第一个分支能匹配上 this is a cat 部分接着后面的逗号匹配失败使用第二个分支匹配再次失败此时贪婪匹配部分结束。NFA引擎接着用正则后面的 $ 来进行匹配,但此处不是文本结尾,匹配不上,发生回溯,吐出第一个分支匹配上的 t使用第二个分支匹配 t 再试,还是匹配不上。
<img src="https://static001.geekbang.org/resource/image/10/28/10160dab5ccfcffb63abd79fc2299528.png" alt=""><br>
我们继续回溯,第二个分支匹配上的 t 吐出,第一个分支匹配上的 a 也吐出,再用第二个分支匹配 a 再试,如此发生了大量的回溯。你可以使用 regex101.com 中的 Regex Debugger 来调试一下这个过程,加深你的理解。
<img src="https://static001.geekbang.org/resource/image/06/f2/0646f1d176e08cbeayyf005495e301f2.png" alt="">
<img src="https://static001.geekbang.org/resource/image/1f/37/1f8860d7fd9f0bf88cc6b3be2e08e937.png" alt="">
我们来尝试优化一下,把第一个分支中的 A-Za-z 去掉,因为后面多选分支结构中重复了,我们再看一下正则尝试匹配的次数([示例](https://regex101.com/r/Qbsm4g/2)),可以看到只尝试匹配了 57 次就结束了。
<img src="https://static001.geekbang.org/resource/image/a7/79/a7438f0dc228edcb21fa69cd2fca8979.png" alt="">
所以一定要记住,不要在多选择分支中,出现重复的元素。到这里,你对之前文章提到的“回溯不可怕,我们要尽量减少回溯后的判断” 是不是有了进一步的理解呢?
另外,之前我们说的独占模式,你可以把它可以理解为贪婪模式的一种优化,它也会发生广义的回溯,但它不会吐出已经匹配上的字符。独占模式匹配到英文逗号那儿,不会吐出已经匹配上的字符,匹配就失败了,所以采用独占模式也能解决性能问题([示例](https://regex101.com/r/Qbsm4g/3))。
<img src="https://static001.geekbang.org/resource/image/b1/bd/b12a4239a5ee7a1071e7752d19ba23bd.png" alt="">
但要提醒你的是,独占模式“不吐出已匹配字符”的特性,会使得一些场景不能使用它。另外,只有少数编程语言支持独占模式。
解决这个问题还有其它的方式,比如我们可以尝试移除多选分支选择结构,直接用中括号表示多选一([示例](https://regex101.com/r/Qbsm4g/4))。
<img src="https://static001.geekbang.org/resource/image/7c/07/7c2c0660cb4187fc7e9e9c60a827ce07.png" alt="">
我们会发现性能也是有显著提升(这里只是测试,真正使用的时候,重复的元素都应该去掉,另外这里也不需要保存子组)。
## 优化建议
学习了原理之后,有助于我们写出更好的正则。我们必须先保证正则的功能是正确的,然后再进行优化性能,下面我给了你一些优化的方法供你参考。
### 1.测试性能的方法
我们可以使用 ipython 来测试正则的性能ipython 是一个 Python shell 增强交互工具,在 macOS/Windows/Linux 上都可以安装使用。在测试正则表达式时,它非常有用,比如下面通过一个示例,来测试在字符串中查找 abc 时的时间消耗。
```
In [1]: import re
In [2]: x = '-' * 1000000 + 'abc'
In [3]: timeit re.search('abc', x)
480 µs ± 8.06 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
```
另外,你也可以通过前面 regex101.com 查看正则和文本匹配的次数,来得知正则的性能信息。
### 2.提前编译好正则
编程语言中一般都有“编译”方法,我们可以使用这个方法提前将正则处理好,这样不用在每次使用的时候去反复构造自动机,从而可以提高正则匹配的性能。
```
&gt;&gt;&gt; import re
&gt;&gt;&gt; reg = re.compile(r'ab?c') # 先编译好,再使用
&gt;&gt;&gt; reg.findall('abc')
['abc']
&gt;&gt;&gt; re.findall(r'ab?c', 'abc') # 正式使用不建议,但测试功能时较方便
['abc']
```
### 3.尽量准确表示匹配范围
比如我们要匹配引号里面的内容,除了写成 “.+?” 之外,我们可以写成 “**[^"]+**”。使用 [^"] 要比使用点号好很多,虽然使用的是贪婪模式,但它不会出现点号将引号匹配上,再吐出的问题。
### 4.提取出公共部分
通过上面对 NFA引擎的学习相信你应该明白`(abcd|abxy)`这样的表达式,可以优化成`ab(cd|xy)`,因为 NFA 以正则为主导,会导致字符串中的某些部分重复匹配多次,影响效率。
因此我们会知道`th(?:is|at)`要比`this|that`要快一些,但从可读性上看,后者要好一些,这个就需要用的时候去权衡,也可以添加代码注释让代码更容易理解。
类似地,如果是锚点,比如`(^this|^that) is`这样的,锚点部分也应该独立出来,可以写成比如`^th(is|at) is`的形式,因为锚点部分也是需要尝试去匹配的,匹配次数要尽可能少。
### 5.出现可能性大的放左边
由于正则是从左到右看的,把出现概率大的放左边,域名中 .com 的使用是比 .net 多的,所以我们可以写成`\.(?:com|net)\b`,而不是`\.(?:net|com)\b`
### 6.只在必要时才使用子组
在正则中,括号可以用于归组,但如果某部分后续不会再用到,就不需要保存成子组。通常的做法是,在写好正则后,把不需要保存子组的括号中加上 ?: 来表示只用于归组。如果保存成子组,正则引擎必须做一些额外工作来保存匹配到的内容,因为后面可能会用到,这会降低正则的匹配性能。
### 7.警惕嵌套的子组重复
如果一个组里面包含重复,接着这个组整体也可以重复,比如 `(.*)*` 这个正则,匹配的次数会呈指数级增长,所以尽量不要写这样的正则。
### 8.避免不同分支重复匹配
在多选分支选择中,要避免不同分支出现相同范围的情况,上面回溯的例子中,我们已经进行了比较详细的讲解。
## 总结
好了,今天的内容讲完了,我来带你总结回顾一下。
今天带你简单学习了有穷自动机的概念,自动机的具体实现称之为正则引擎。
我们学习了正则引擎的匹配原理NFA 和 DFA 两种引擎的工作方式完全不同NFA 是以表达式为主导的,先看正则表达式,再看文本。而 DFA 则是以文本为主导的先看文本再看正则表达式。POSIX NFA是指符合POSIX标准的NFA引擎它会不断回溯以确保找到最左侧最长匹配。
接着我们学习了测试正则表达式性能的方法,以及优化的一些方法,比如提前编译好正则,提取出公共部分,尽量准确地表示范围,必要时才使用子组等。
今天所讲的内容总结脑图如下,你可以回顾一下:
<img src="https://static001.geekbang.org/resource/image/bd/0b/bddcyy348af2539aeedd72a7ebe5390b.png" alt="">
## 课后思考
最后我们来做一个小练习吧。通过今天学习的内容这里有一个示例要求匹配“由字母或数字组成的字符串但第一个字符要是小写英文字母”你能说一下针对这个示例NFA引擎的匹配过程么
```
文本a12
正则:^(?=[a-z])[a-z0-9]+$
```
好,今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,并把文章分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,228 @@
<audio id="audio" title="12 | 问题集锦:详解正则常见问题及解决方案" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cf/58/cf53966f5fc1a9d7796182bcbc9bc858.mp3"></audio>
你好,我是伟忠。今天我来给你讲一讲,使用正则处理一些常见问题的方法。
## **问题处理思路**
在讲解具体的问题前,我先来说一下使用正则处理问题的基本思路。有一些方法比较固定,比如将问题分解成多个小问题,每个小问题见招拆招:某个位置上可能有多个字符的话,就⽤字符组。某个位置上有多个字符串的话,就⽤多选结构。出现的次数不确定的话,就⽤量词。对出现的位置有要求的话,就⽤锚点锁定位置。
在正则中比较难的是某些字符不能出现,这个情况又可以进一步分为组成中不能出现,和要查找的内容前后不能出现。后一种用环视来解决就可以了。我们主要说一下第一种。
如果是要查找的内容中不能出现某些字符,这种情况比较简单,可以通过使用中括号来排除字符组,比如非元音字母可以使用 `[^aeiou]`来表示。
如果是内容中不能出现某个子串比如要求密码是6位且不能有连续两个数字出现。假设密码允许的范围是 \w你应该可以想到使用 \w{6} 来表示 6 位密码,但如果里面不能有连续两个数字的话,该如何限制呢?这个可以环视来解决,就是每个字符的后面都不能是两个数字(要注意开头也不能是 \d\d下面是使用 Python3语言测试的示例。
```
&gt;&gt;&gt; import re
&gt;&gt;&gt; re.match(r'^((?!\d\d)\w){6}$', '11abcd') # 不能匹配上
# 提示 (?!\d\d) 代表右边不能是两个数字,但它左边没有正则,即为空字符串
&gt;&gt;&gt; re.match(r'^((?!\d\d)\w){6}$', '1a2b3c') # 能匹配上
&lt;re.Match object; span=(0, 6), match='1a2b3c'&gt;
&gt;&gt;&gt; re.match(r'^(\w(?!\d\d)){6}$', '11abcd') # 错误正则示范
&lt;re.Match object; span=(0, 6), match='11abcd'&gt;
```
在写完正则后,我们可以通过一些工具去调试,先要确保正则满足功能需求,再看一下有没有性能问题, 如果功能不正确,性能考虑再多其实也没用。
## 常见问题及解决方案
<li>
<h3>** 匹配数字**</h3>
</li>
数字的匹配比较简单,通过我们学习的字符组,量词等就可以轻松解决。
- 数字在正则中可以使用 \d 或 [0-9] 来表示。
- 如果是连续的多个数字,可以使用 \d+ 或 [0-9]+。
- 如果 n 位数据,可以使用 \d{n}。
- 如果是至少 n 位数据,可以使用 \d{n,}。
- 如果是 m-n 位数字,可以使用 \d{m,n}。
<li>
<h3>**匹配正数、负数和小数**</h3>
</li>
如果希望正则能匹配到比如 33.14-3.3+2.7 等数字,需要注意的是,开头的正负符号可能有,也可能没有,所以可以使用 [-+]? 来表示,小数点和后面的内容也不一定会有,所以可以使用 `(?:\.\d+)?` 来表示,因此匹配正数、负数和小数的正则可以写成 `[-+]?\d+(?:\.\d+)?`
<img src="https://static001.geekbang.org/resource/image/b8/73/b8b0f61dfaf711ffdaa78cd709827573.png" alt="">
非负整数,包含 0 和 正整数,可以表示成`[1-9]\d*|0`
非正整数,包含 0 和 负整数,可以表示成`-[1-9]\d*|0`
<li>
<h3>**浮点数**</h3>
</li>
这个问题你可能觉得比较简单,其中表示正负的符号和小数点可能有,也可能没有,直接用 **`[-+]?\d+(?:\.\d+)?`** 来表示。
如果我们考虑 .5 和 +.5 这样的写法,但一般不会有 -.5 这样的写法。正则又如何写呢?
我们可以把问题拆解,浮点数分为符号位、整数部分、小数点和小数部分,这些部分都有可能不存在,如果我们每个部分都加个问号,这样整个表达式可以匹配上空。
根据上面的提示,负号的时候整数部分不能没有,而正数的时候,整数部分可以没有,所以正则你可以将正负两种情况拆开,使用多选结构写成 **`-?\d+(?:\.\d+)?|\+?(?:\d+(?:\.\d+)?|\.\d+)`**[示例](https://regex101.com/r/bSpQ1a/1))。
这个可以拆分成两个问题:
负数浮点数表示:**`-\d+(?:\.\d+)?`。**
正数浮点数表示:**`\+?(?:\d+(?:\.\d+)?|\.\d+)`。**
<li>
<h3>**十六进制数**</h3>
</li>
十六进制的数字除了有0-9之外还会有 a-f或A-F 代表 10 到 15 这6个数字所以正则可以写成 `[0-9A-Fa-f]+`
<li>
<h3>**手机号码**</h3>
</li>
手机号应该是比较常见的手机号段比较复杂如果要兼容所有的号段并不容易。目前来看前四位是有一些限制甚至1740 和 1741 限制了前5位号段。
我们可以简单地使用字符组和多选分支来准确地匹配手机号段。如果只限制前2位可以表示成 `1[3-9]\d{9}`,如果想再精确些,限制到前三位,比如使用`1(?:3\d|4[5-9]|5[0-35-9]|6[2567]|7[0-8]|8\d|9[1389])\d{8}`来表示。如果想精确到4位甚至5位可以根据公开的号段信息自己来写一下但要注意的是越是精确只要有新的号段你就得改这个正则维护起来会比较麻烦。另外在实际运用的时候你可能还要考虑一下有一些号码了+86或0086之类的前缀的情况。
手机号段的正则写起来其实写起来并不难但麻烦的是后期的维护成本比较高我之前就遇到过这种情况买了一个188的移动号码有不少系统在这个号段开放了挺长时间之后还认为这个号段不合法。
目前公开的手机号段([图片来源](https://www.cnblogs.com/zengxiangzhan/p/phone.html))。
<img src="https://static001.geekbang.org/resource/image/4a/64/4a982267e90cf39bb3792af152692e64.png" alt="">
<li>
<h3>**身份证号码**</h3>
</li>
我国的身份证号码是分两代的第一代是15位第二代是18位。如果是18位最后一位可以是X或x两代开头都不能是 0根据规则你应该能很容易写出相应的正则第一代可以用 [1-9]\d{14} 来表示第二代比第一代多3位数据可以使用量词0到1次即写成
`[1-9]\d{14}(\d\d[0-9Xx])?`
<li>
<h3>**邮政编码**</h3>
</li>
邮编一般为6位数字比较简单可以写成 `\d{6}`之前我们也提到过6位数字在其它情况下出现可能性也非常大比如手机号的一部分身份证号的一部分所以如果是数据提取一般需要添加断言即写成 `(?&lt;!\d)\d{6}(?!\d)`
<li>
<h3>**腾讯QQ号码**</h3>
</li>
目前QQ号不能以0开头最长的有10位最短的从 100005位开始。从规则上我们可以得知首位是1-9后面跟着是4到9位的数字即可以使用 **[1-9][0-9]{4,9}** 来表示。
<li>
<h3>**中文字符**</h3>
</li>
中文属于多字节Unicode字符之前我们讲过比如通过 Unicode 属性,但有一些语言是不支持这种属性的,可以通过另外一个办法,就是码值的范围,中文的范围是 4E00 - 9FFF 之间,这样可以覆盖日常使用大多数情况。
不同的语言是表示方式有一些差异,比如在 PythonJavaJavaScript 中Unicode 可以写成 `\u码值` 来表示,即匹配中文的正则可以写成 [\u4E00-\u9FFF],如果在 PHP 中使用Unicode 就需要写成 **\u{码值}** 的样式。下面是在 Python3 语言中测试的示例,你可以参考一下。
```
# 测试环境Python3
&gt;&gt;&gt; import re
&gt;&gt;&gt; reg = re.compile(r'[\u4E00-\u9FFF]')
&gt;&gt;&gt; reg.findall(&quot;和伟忠一起学正则regex&quot;)
['和', '伟', '忠', '一', '起', '学', '正', '则']
```
<li>
<h3>**IPv4地址**</h3>
</li>
IPv4 地址通常表示成 27.86.1.226 的样式4个数字用点隔开每一位范围是 0-255比如从日志中提取出IP如果不要求那么精确一般使用 `\d{1,3}(\.\d{1,3}){3}`就够了,需要注意点号需要转义。
<img src="https://static001.geekbang.org/resource/image/ef/49/ef7a165253a7b4db82c6578e039a9849.png" alt="">
如果我们想更精确地匹配,可以针对一到三位数分别考虑,一位数时可以表示成 `0{0,2}\d`,两位数时可以表示成 `0?[1-9]\d`,三位数时可以表示成 `1\d\d|2[0-4]\d|25[0-5]`,使用多选分支结构把他们写到一起,就是 `0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5]`这样。
这是表示出了 IPv4 地址中的一位(正则假设是 X我们可以把 IPv4 表示成X.X.X.X可以使用量词写成 (?:X.){3}X 或 X(?:.X){3},由于 X 本身比较复杂,里面有多选分支结构,所以需要把它加上括号,所以 IPv4 的正则应该可以写成<br>
`(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5])(?:\.0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5]){3}`
你以为这么写就对了么,如果你测试一下就发现,匹配行为很奇怪。([示例](https://regex101.com/r/e4xRLE/1)
<img src="https://static001.geekbang.org/resource/image/c8/d6/c8eb8d71db6836ea24c875816122b4d6.png" alt="">
看到这个结果,你可能觉得太难了,不要担心,更不要放弃。其实我一开始也觉得这么写就可以了,我也需要测试,如果不符合预期,那就找到原因不断完善。
我们根据输出结果的表现,分析一下原因。原因主要有两点,都和多选分支结构有关系。我们想的是所有的一到三位数字前面都有一个点,重复三次,但点号和 0{0,2}\d 写到一起,意思是一位数字前面有点,两位和三位数前面没有点,所以需要使用括号把点挪出去,最终写成`(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5])(?:\.(?:0{0,2}\d|0?[1-9]\d|1\d\d|2[0-4]\d|25[0-5])){3}`<br>
但经过测试,你会发现还是有问题,最后一个数字只匹配上了一位。([示例](https://regex101.com/r/e4xRLE/2)
<img src="https://static001.geekbang.org/resource/image/d8/1b/d85fd1dca6e2fee635303ec0c986001b.png" alt="">
上一讲正则匹配原理中我们讲解了NFA引擎在匹配多分支选择结构的时候优先匹配最左边的所以找到了一位数符合要求时它就”急于“报告并没有找出最长且符合要求的结果这就要求我们在写多分支选择结构的时候要把长的分支放左边这样就可以解决问题了即正则写成`(?:1\d\d|2[0-4]\d|25[0-5]|0?[1-9]\d|0{0,2}\d)(?:\.(?:1\d\d|2[0-4]\d|25[0-5]|0?[1-9]\d|0{0,2}\d)){3}`
<img src="https://static001.geekbang.org/resource/image/ed/93/ed8090a3e659bea900082334d378a293.png" alt="">
在这个案例里,我们通过一步步推导,得到最终的答案。其实是想让你明白,你在写正则的时候,需要以什么样的分析思路来思考,最终如何解决问题,复杂的正则也很难一下子写出来,需要写完之后进行测试,在发现不符合预期后,不断进行完善。
在这里我还是想说一下,如果只是验证是不是合法的 IPv4 地址,可以直接使用点号切割,验证一下是不是四个部分,每个部分是不是在 0-255 之间就可以了,比使用正则来校验要简单很多,而且不容易出错。总之正则不是解决问题的唯一方法,我们要在合适的时候使用它,而不是说能用正则的都要用正则来解决。
<li>
<h3>**日期和时间**</h3>
</li>
假设日期格式是 yyyy-mm-dd如果不那么严格我们可以直接使用 \d{4}-\d{2}-\d{2}。如果再精确一些,比如月份是 1-12当为一位的时候前面可能不带 0可以写成 01 或 1月份使用正则可以表示成 **`1[0-2]|0?[1-9]`**,日可能是 1-31可以表示成 **`[12]\d|3[01]|0?[1-9]`**,这里需要注意的是 **`0?[1-9]`** 应该放在多选分支的最后面,因为放最前面,匹配上一位数字的时候就停止了([示例](https://regex101.com/r/WZ0Pps/1)),正确的正则([示例](https://regex101.com/r/WZ0Pps/2))应该是 `\d{4}-(?:1[0-2]|0?[1-9])-(?:[12]\d|3[01]|0?[1-9])`
时间格式比如是 23:34如果是24小时制小时是 0-23分钟是 0-59所以可以写成 `(?:2[0-3]|1\d|0?\d):(?:[1-5]\d|0?\d)`。12小时制的也是类似的你可以自己想一想怎么写。
另外日期中月份还有大小月的问题比如2月闰年可以是29日使用正则没法验证日期是不是正确的。我们也不应该使用正则来完成所有事情而是只使用正则来限制具体的格式比如四位数字两位数字之类的提取到之后使用日期时间相关的函数进行转换如果失败就证明不是合法的日期时间。
<li>
<h3>**邮箱**</h3>
</li>
```
&gt; 邮箱示例:
&gt; weizhong.tu2020@abc.com
&gt; 12345@qq.com
```
邮箱的组成是比较复杂的,格式是 用户名@主机名,用户名部分通常可以有英文字母,数字,下划线,点等组成,但其中点不能在开头,也不能重复出现。根据 [RFC5322](https://www.ietf.org/rfc/rfc5322.txt) 没有办法写出一个完美的正则,你可以参考一下[这个网站](http://emailregex.com/)。不过我们可以实现一些简体的版本,比如:`[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+`
日常使用应该也够了。
<li>
<h3>**网页标签**</h3>
</li>
配对出现的标签,比如 title一般网页标签不区分大小写我们可以使用 `(?i)&lt;title&gt;.*?&lt;/title&gt;`来进行匹配。在提取引号里面的内容时,可以使用 `[^"]+`,方括号里面的内容时,可以使用 `[^&gt;]+` 等方式。
我们通过一些常见的问题,逐步进行分析,讲解了正则表达式书写时的思路,和一些常见的错误。这些正则如果用于校验,还需要添加断言,比如 \A 和 \z或\Z`^``$`。如果用于数据提取,还应当在首尾添加相应的断言。
## 总结
好了,今天的内容讲完了,我来带你总结回顾一下。
今天我们了解了下正则处理问题的基本思路,通常是将问题拆解成多个小问题,每个小问题见招拆招:某个位置上可能是多个单字符就⽤字符组,某个位置上可能是多个字符串就⽤多选结构,出现次数不确定就⽤量词,对出现的位置有要求就用断言。遇到问题,你遵循这些套路,写出正则其实并不难。
我们从一些日常的问题入手,详细地讲解了一些常见的案例,也讲解了可能会遇到的一些坑,比如在使用多选结构时要注意的问题,你在后续工作中要注意避开这些问题。
我在这里给你放了一张今天所讲内容的总结脑图。
<img src="https://static001.geekbang.org/resource/image/a4/2d/a4977a437da588720ae2762604464e2d.png" alt="">
## 课后思考
最后,我们来做一个小练习吧。你可以根据今天我们学习 IPv4 的方法,来写一下 IPv6 的正则表达式么?说一下你的分析思路和最终的答案,建议自己动手测试一下写出的正则。
```
IPv6示例
ABCD:EF01:2345:6789:ABCD:EF01:2345:6789
这种表示法中每个X的前导0是可以省略的例如
2001:0DB8:0000:0023:0008:0800:200C:417A
上面的IPv6地址可以表示成下面这样
2001:DB8:0:23:8:800:200C:417A
备注这里不考虑0位压缩表示
```
好,今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,并把文章分享给你的朋友或者同事,一起交流一下。