正则表达式入门学习笔记

296 阅读21分钟

在我们平常写代码过程中,多多少少会涉及到正则表达式。作为计算机领域最伟大的发明之一,强大的正则表达式可以极大的提高我们处理文本的工作效率。但是我们在使用的过程中大多数都是在网上搜一下现成的例子然后拿过来使用。 其实正则表达式并不难学,下面就系统的来看一下正则表达式吧。

什么是正则

正则的英文名称是"Regular Expression",简称RE。顾名思义就是"描述文本内容组成规律的表示方式"。 在编码中我们可以使用正则来简化文本处理逻辑。在命令行中我们可以使用正则帮助我们查找或者编辑、替换文件中的内容。在各种编辑器中我们可以使用正则帮助我们更轻松的完成查找和替换。 从上面我们可以看出来正则表达式是一款非常强大的文本处理工具。我们可以利用它:

  • 校验数据有效性。
  • 也可以提取文本中我们所需要的内容。
  • 还可以从做文本内容替换等操作。

基本规则

元字符

我们知道上面提到的正则强大的功能,但是正则的这些动能都建立在一个基础之上,那就是:正则可以查找到符合某个规则的文本

举个例子,我们可以在一堆文本中查找数字,在不需要正则的情况下我们需要输入"0~9"这十个数字。但是在正则中我们只需要输入"\d"就可以表示数字了。如果我们需要查找连续的数字,比如说5个,我们就可以使用"\d{5}"。 除了"\d"和{5}之外,还有一些其他在正则查找中使用的特殊字符,这些我们称之为元字符。即在正则表达式查找过程中使用到的特殊字符

从上面的例子中我们可以看到元字符在正则查找中扮演了重要的角色,可以说是正则表达式的重要组成元件

下面我们就来看一下元字符的基本规则:

单字符

我们来看一下我们平常使用到的单字符

  • "."表示任意字符,换行除外
  • "\d"表示任意数字,"\D"表示任意"非数字"
  • "\w"表示任意数字、字母、下划线,"\W"表示任意非数字、字母、下划线
  • "\s"表示空白符,"\S"表示任意非空白字符

我们可以通过这个链接测试一下这些元字符的作用。

空白符

在文本的组成中,并不是只有数字字符等,还有一些其他的空白字符,其中空白符细分之下包括换行符、回车符(在不同的系统中,文字末尾的换行会有所区别)、换页符等 下面我们来看一下空白符:

  • "\r" 表示回车符
  • "\n"表示换行符
  • "\f"表示换页符
  • "\t"表示制表符
  • "\v"表示垂直制表符
  • "\s"表示任意空白符

这里我们单独说一下"\s","\s"虽然能匹配到大多数的空白符,但是不能匹配换行符

量词

上面我们在"查找五个数字"的过程中有用到"{5}",这里的"{5}"就是我们要即将介绍的量词。下面我们来看一下量词的分类:

  • "*"表示0到多次
  • "+"表示1到多次
  • "?"表示0到1次
  • "{m}"表示出现m次
  • "{m,}"表示出现至少m次
  • "{m,n}"表示m到n次

我们可以通过下面的链接来测试一下量词的作用。

范围

除了量词之外,我们也可以在正则中创建分支结构来匹配多种选择,例如我们要匹配字符串中的"liberate或者liberal",这里我们可以通过"liberate|liberal"来匹配两者,这里面的"|"便表示范围符。 下面我们来看一下范围符的内容:

  • "|",表示
  • "[......]"表示多选一,任意单个元素
  • "[a-z0-9]"表示匹配字母a~z或者字母0~9之间的任意元素
  • "[^a-z]"表示表示不能匹配a~z字母中的任意元素,这里的"^"表示取反

这里首先说一下"|"和"[]"的区别,两者虽然都能表示多选一的关系,但是"[]"表示的是范围中的任意单个元素,"|"则表示符号两侧的元素,这些元素可以是单个也可以是多个

上面的内容,我们可以通过这个链接来测试一下。

断言

先举个简单的例子:假如我们想简单的匹配一下手机号,我们可以写成这样"\d{11}"。 但是这样有个问题,如果文本里面同事存在18位的身份证号信息,那么我们同样可以匹配上,可以看看这个例子 这个时候我们需要匹配单独出现的11位数字,比如说这11位数字的左边和右边不能出现数字,所以我们可以修改我们的正则为这样

这里我们在匹配的过程中,对文本的位置信息有明确的要求,为了解决这个问题,正则提供了一系列的元字符来匹配位置信息,这些元字符就是断言。 常见的断言分为三类,单词边界、行起始/结束位置、环视:

♣ 单词边界

\b表示"单词边界",是"Boundary"的简称。"\b"的作用是匹配一个单词边界,也就是指单词和空格间的位置。例如我们想找一个单词"correlate",我们可以使用"\bcorrelate\b",可以看一下这个例子

  • 注意,这里的"单词和空格间的位置"很重要,它是一个空元素

♣ 行的开始或结束

同样是断言字符,它表示的是文本每行的开始和结束,如果我们要匹配的内容主要中出现在一行文本开头或结尾,我们就可以使用"^ $"来匹配。

  • "^"表示匹配输入字符串的开始位置
  • "$"表示匹配输入字符串的结束位置 这里有个例子可以测试一下

注意,如果我们在正则里面设置了"Multiline"属性,"^"也能匹配"\r"或"\n"之后的位置,"$"也能匹配"\r"或者"\n"之前的位置。可以在这里测试一下

♣ 环视

环视的意思就是要求匹配部分的前面后者后面满足某种规则。先看一下具体的规则:

  • (?<=M),肯定逆序环视,表示匹配左边是M
  • (?<!M),否定逆序环视,表示匹配左边不是M
  • (?=M),肯定顺序环视,表示右边是M
  • (?!M),否定顺序环视,表示右边不是M

大概的规则就是左尖括号代表左边,没有尖括号代表右边,"="号代表肯定,"!"代表否定 假如我们要匹配一个带前缀的手机号"+86-18888888888",我们可以使用"(?<=(+86-))\d{11}"来匹配一下。具体例子看这里

贪婪匹配·非贪婪匹配·独占

当我们使用量词进行匹配时,默认情况下会匹配到尽可能长的字符。 例如我们想在"1234abc"中匹配到连续的数字,我们可以使用"\d*",这个时候我们匹配到尽可能长的内容,具体例子看着这里。 在我们的例子中,我们看到匹配到的结果包括字母中间的"空字符串",这是因为量词"*"包括0所以能匹配到空字符串。但是我们发现这里面匹配到的"空字符串"并不包括数字之间的"空字符串"。这里就涉及到了我们接下来要介绍的贪婪匹配

贪婪匹配

贪婪匹配和非贪婪匹配影响的是被量词修饰的子表达式的匹配行为,贪婪模式在整个表达式匹配成功的前提下,尽可能多的匹配。在正则中,表示次数的量词默认都是贪婪的。 这就是上面的例子中,数字间的"空字符串"没有显示在匹配结果中的原因。因为在贪婪模式下,匹配到的结果总是尽可能长的字符串。

非贪婪匹配

非贪婪匹配与贪婪匹配相同,同样影响的是被量词修饰的子表达式的匹配行为。不同的是,非贪婪模式在正则表达式匹配成功的前提下,尽可能少的匹配。 我们可以在量词后面添加"?"将当前子表达式切换成非贪婪匹配模式,还是上面的例子我们将正则修改为"\d*?",这样我们匹配的结果也会发生相应的变化,具体例子看这里

独占模式

在上面的"贪婪模式"和"非贪婪模式"的匹配过程中,都会发生回溯。但是在一些场景下,我们不需要回溯的过程,匹配不上直接返回失败就行。因此,我们需要用到另外一种模式,那就是独占模式

  • 在量词后面添加"+",可以将正则表达式切换为"独占模式",例如"\d*+"

这里我们,提供一个例子来测试一下,在这例子中,我们切换独占模式和贪婪模式,就能够看到匹配步骤的差别,独占模式的匹配次数要少很多。 注意:正则在匹配过程中,回溯次数过多会产生性能问题。因此我们在满足匹配的前提下可以使用独占模式来优化匹配性能。

分组与引用

分组

括号在正则中可以用于分组,被括号括起来的部分会被保存成一个分组。括号内的分组可以看作是一个"子表达式"。可以看一下这个例子 如果正则中出现了括号,一般正则会认为这个括号内的内容会在后面继续引用。 但是如果我们仅仅是将括号的内容作为一个"子表达式",而不想保存,那么我们可以使用"?:"来标记当前"子表达式"不被保存。可以看一下这个例子 不保存子组可以提高正则的性能。同时,不保存子组,也就不会给子组分配编号。

  • 分组后,正则会默认给分组一个编号,查看当前分组的规则就是正则体中从左向右数,左括号是第几个,就是第几个分组,这里有一个例子

注意,如果我么不保存分组,该分组就不会参与到正则的编号中。编号过程中会跳过忽略的子组。

同时我们也可以为分组命名:命名规则是在括号的内部前端添加"?<分组名称>"或者"?'分组名称'",具体例子可以看一下这里这里

引用

上面我们介绍了分组和分组编号。大部分情况下,我们可以使用"\编号"来引用。可以看一下这个例子

注意,在不同的语言中,正则的引用可能不相同,例如在JavaScript中则是用过"$编号"来引用。

上面我们还讲到过给分组命名。同样我们也可以使用命名后的分组

  • 使用"\k<分组名>"来引用分组,具体例子看这里
  • 或者使用"\k'分组名'"来引用,可以看一下这个例子

匹配模式

正则中的匹配模式,指的是改变元字符匹配行为的方式。常见的匹配模式有四种:单行模式、多行模式、忽略大小写模式、注释模式。 下面我们来介绍一下这几种模式。

单行模式

MSDN中定义:更改点(.)的含义,使它与每一个字符匹配(而不是与除\n之外的每个字符匹配)。

PS:如果大家忘了之前讲到元字符中"点(.)"的作用,可以回到之前看一下。 因为"点元字符"是匹配除了换行之外的文本,所以在出现换行符之后,"点元字符"匹配的结果会被换行符分隔开。大概结果是这样

  • 可以通过"(?s)"来切换单行模式,又叫"点号通配模式"

单行模式中的"s"英文缩写是"Single Line"。 而在单行模式下,"点元字符"能够匹配上"换行符"。这样匹配的结果就不会被换行符隔开,具体例子看一下这里

注意:JavaScript中不支持此模式,我们可以通过"[\w\W]"来完成这一操作。

多行匹配模式

MSDN定义:更改^和$的含义,使他们分别在任意一行的行首和行尾匹配,而不仅仅在整个字符串的开头和结尾匹配。

我们知道"^和$"匹配的是每一行的行首和行尾。但是文本默认是一行即使里面包括换行符,这就导致我们匹配的结果和我们想要的结果不相同,我们可以看一下这个例子

  • 通过"(?m)",切换为多行匹配模式

多行模式中的"m"英文缩写是"Multiline"。

当我们添加多行匹配模式后,就能够匹配上每行的开头或结尾了。具体例子看这里

忽略大小写模式

MSDN定义:指定不区分大小写的匹配。

这个模式很好理解,就是在匹配过程中忽略大小写。之前我们需要"[a-zA-Z]"才能够匹配上所有的大小写字母。通过"忽略大小写模式"我们可以直接通过"[a-z]"来匹配。具体例子看这里

  • 通过"(?i)"来切换成忽略大小写模式

忽略大小写模式中的"i"英文缩写是"Insensitive"。

注释模式

顾名思义,注释模式允许我们在正则表达式中添加注释。因为复杂的正则表达式不利于阅读,添加注释后更加容易理解

  • 添加"(?#注释)",来为正则表达式添加注释

具体的例子看这里

转义

转义大家在日常工作中的应用会比较多,但是在正则中,能够准确的使用转义并不是一件简单的事情。下面我们来解释一下正则中的转义。

转义字符

转义序列通常有两种功能。第一种功能是编码无法使用字母表直接表示的特殊数据。第二种功能是用于无法直接键盘录入的字符(如回车符)。 这里所说的转义字符就是第二种情况,转义字符自身和后面的字符看成一个整体,用来表示某种含义。之所以把这个字符称为"转义字符",是因为它后面的字符,不是原来的意思了。

下面是常见的转义字符和含义

  • \n 换行,将当前位置移动到下一行开头
  • \r 回车,将当前位置移动到下一行开头
  • \t 水平制表符
  • \v 垂直制表符
  • \\ 代表一个反斜线字符
  • ' 代表一个单引号字符
  • " 代表一个双引号字符

字符串转义和正则转义

正则中的转义和"转义字符"一样,都是通过反斜杠进行转义的。 例如,在正则中"\s"表示空白符,那么如果我们想表示反斜杠和s那么我们可以写成"\s",可以看一下这个例子

这里有个需要注意的地方,那就是字符串的转义也是通过反斜杠来完成的。也就是说,当我们在代码中使用字符串来表示正则时,我们如果要表示反斜杠,通常需要两个斜杠,因为只有一个反斜杠的话会被认为是转义而不是反斜杠本身。

位置含义
字符串的表现层\\[
字符串的概念层\[
正则表达式的表现层\[
正则表达式的概念层[ (非元字符)

上面的例子很好的解释了我们用字符串表达正则表达式的时候为什么需要写不同数量的反斜杠了。但是\n之类的有点特殊,无论写成"\n"或是"\\n",结果都一样"\t"的情况与之类似。

如果字符串中只是单纯的表示"反斜杠"本身,则需要在正则表达式中写四个反斜线字符。

位置含义
字符串的表现层\\\\
字符串的概念层\\
正则表达式的表现层\\
正则表达式的概念层\

括号的转义

在正则中,方括号"[]"只需要转义开括号,但是圆括号"()"两个都需要转义。这里有个例子可以看一下。

这是因为,在正则中,圆括号通常用于分组,如果只转义开括号或者必括号,正则会认为缺少了另一半,所以会报错。

元字符的转义

正常情况,如果我们想要将元字符"+*?.()"等表示成他原来的样子是需要进行转义的,但是如果元字符出现在字符组[]的括号里面,可以不转义。具体例子看这里

注意:刚刚说的在中括号中不需要转义的只是"+*.?()"这些表示量词和分组的元字符,其他的元字符比如\d或者\w等还是需要转义的。

字符组中的转义

上面讲到了一些元字符的转义。在字符组"[]"中需要转义的情况不是很多,只有三种情况:

  • 脱字符"^"在中括号中,且在首位需要转义,具体可以看一下这个例子
  • 中划线"-"在中括号中,且不在首位。可以看一下这两个例子:在首位 不在首位
  • 右括号在中括号中,且不在首位。这个例子

正则匹配原理

上面我们讲贪婪匹配的时候提到过正则的匹配过程会发生回溯。这里面涉及到了正则的匹配原理,了解这些匹配原理后能够帮助我们快速的理解正则表达式为什么没有符合预期也可以避免一些常见的错误。

有穷状态自动机

"有穷状态自动机(FA:Finite Automation)"保证了正则能够处理复杂的文本。这里的有穷状态是指一个系统具有有穷个状态,不同的状态代表不同的意义自动机是指系统可以根据相应的条件在不同的状态之间转义。最终达到终止状态(终止状态不止一个)。

有穷状态机的具体实现称为正则引擎,主要有"DFA"和"NFA"两种,其中"NFA"又分为"传统的NFA"和"POSIX NFA"。

  • DFA:确定性有穷自动机(Deterministic finite automaton)
  • NFA:非确定性有穷自动机(Non-deterministic finite automaton)

匹配过程

这里有一个很方便的工具我们能观察到正则的匹配过程:Regexper工具。这里我们展示了一下正则"a(bb)+a"的匹配过程

NFA.jpg

我们分析一下上图,状态S0为初始状态,输入a以后转换为S1状态。后续输入b转化为状态S2,这个时候出现了分支,继续输入b有可能是状态S1或者S3。后续的S3输入a后切换到转台S4,匹配完毕。 这里的状态S2输入b后是状态S1还是S3都是不确定的。S3这种状态机我们称之为非确定性有穷状态自动机

DFA和NFA是可以相互转化的。当我们把上面的状态表示成下面下面这个情况: DFA.png

这个状态图和上面的区别就是,在S3状态下,输入a切换为S4,输入b后切换为S2。这样修改完后,在每个状态输入a或者b后都能确定性的转化为特定的状态。这样我们就得到了一个确定性的有穷状态自动机

DFA和NFA工作机制

通过上面的例子,我们已经大概搞懂什么是DFA和NFA了。下面我们看一下两者的工作机制。

NFA

NFA引擎的工作方式是,先看正则再看文本,以正则为主导。下面来看一段正则和文本

文本:Rain is the bane of holidaymakers
正则:bane of (makers|holidays|holidaymakers|)

按照NFA工作机制看一下这段匹配。正则中第一个字母是b,NFA引擎开始查找文本中的b,查找到后接着匹配正则中的"a",一直匹配到"f"后面的"空白符"。 这个时候继续向后匹配后面的分组,从第一个"makers"开始。检索第一个字符"m",查看文本后发现不是"m",则淘汰"makers"分支。

文本:Rain is the bane of holidaymakers
                        ^
正则:bane of (makers|holidays|holidaymakers|)
             ^
          淘汰makers分支

继续检索另一个分支"holidays",查看该分支的首字符"h",文本中也是字符"h",继续向后匹配。直到字符"s",文本中的是"m",匹配失败。

文本:Rain is the bane of holidaymakers
                               ^
正则:bane of (makers|holidays|holidaymakers|)
                           ^
                    淘汰holidays分支
------->继续匹配holidaymakers分支

文本:Rain is the bane of holidaymakers
                        ^
正则:bane of (makers|holidays|holidaymakers|)
                             ^
                    开始匹配holidaymakers分支

"holidays"分支匹配失败,NFA引擎开始匹配"holidaymakers"分支。我们可以看到这个时候是从"holidaymakers"首部开始匹配的,也就是说这个时候引擎回溯文本到"h"字符重新匹配。最后"holidaymakers"分支匹配完毕。

从上面的匹配过程,我们可以看出,NFA是以正则为主导,反复回溯字符串进行测试,这个过程中,分组中相同的部分会被反复回溯多次。这个回溯过程也是消耗性能的过程。

DFA

DFA与NFA正好相反,DFA是以文本为主导的。还是以上面的匹配为例:

文本:Rain is the bane of holidaymakers
正则:bane of (makers|holidays|holidaymakers|)

DFA引擎从字符"R"开始,检查正则中第一个字符是"b",不匹配。文本取出后面的字符"a"继续匹配. 直到文本字符"b",与正则中的首字符"b"匹配,继续向后匹配到"h"。查看正则正则中的第一个分支"makers"的首字符是"m",不符合,分支被丢弃。 DFA引擎继续匹配"holidays"分支,检索到文本中的"m"字符。"holidays"分支不匹配,被丢弃。

文本:Rain is the bane of holidaymakers
                               ^
正则:bane of (makers|holidays|holidaymakers|)
                           ^
          淘汰holidays分支

"holidays"分支被淘汰后继续向后匹配"holidaymakers"分支直到结束。

两者性能区别

DFA引擎以文本为主导,它会更快一些,因为在整个匹配过程中字符串只遍历一遍,不会发生回溯,因此DFA的匹配时间是线性的。DFA引擎可以确保匹配到可能的最长的字符串。同时因为DFA引擎只包含有限的状态,所以它没有反向引用功能。并且DFA也不支持捕获子组。

NFA是以正则为主导,它是以贪心匹配回溯算法实现的。同时NFA通过构造特定扩展,支持子组和反向引用,但由于NFA支持回溯,NFA的速度可能也会比较慢。

DFA引擎NFA引擎
以文本为主导以正则为主导
不会发生回溯会发生回溯
不支持捕获子组支持捕获子组
没有反向引用功能支持反向引用
DFA速度会更快NFA引擎速度可能会比较慢

POSIX NFA 与传统NFA

由于NFA引擎会直接返回第一个匹配上的结果,所以可能会有更长的匹配未被发现。就像这个例子。 如果我们想匹配上"holidaymakers",除了将正则改为(holidaymakers|holiday)之外,还有另一种办法,那就是使用POSIX NFA。 POSIX NFA的应用很少,主要是Unix/Linux中的某些工具。POSIX NFA与NFA的不同之处在于,POSIX NFA会尽可能找最长的结果。如果分支一样长,以最左边的为准。 因此,POSIX NFA 引擎的速度会慢于传统的NFA引擎

三种引擎的特点

引擎程序忽略优先量词(非贪婪模式)捕获子组回溯
DFAGolang、MySQl、awk、egrep、flex、lex、Procmail不支持不支持不支持
传统NFAPCRE library、Perl、PHP、Java、Python、Ruby、gerp、GNU Emacs、less、more、.NET、sed、vi支持支持支持
POSIX NFAmawk、Motice Kern Systems`utlities、GNU Emacs(明确指定时使用)不支持不支持支持
DFA/NFA混合GNU awk、GNU gerp/egrep、Tcl支持支持NFA支持

回溯

回溯是NFA引擎才会有的。而且只有在正则中出现"量词和分组"的时候才会发生回溯。

例如我们使用正则"a+ab"来匹配文本"aab"的时候。在贪婪匹配的情况下,"a+"会匹配上两个字符"a",后续的字符是"b","a+"匹配结束,检索正则"ab"部分。但是这个时候文本已经只剩下"b"了,显然不能匹配上。 所以之前匹配的"aa"会回溯一个字符"a",这样就能匹配上后续的正则"ab"了。 这只是最简单的回溯情况,如果我们量词修饰的字符类型更多,那么回溯的情况会更加复杂。大量的回溯会让正则的性能急速下降。‘

测试引擎类型

有时候我们并不知道我们使用的工具是哪种引擎。我们可以用几个测试用的表达式来分辨引擎类型

  • 看是否支持忽略优先量词,如果支持,就能确定使用的是"传统型NFA"。可以使用这个例子。如果匹配结果是"nfa",那么就是"传统型的NFA"
  • 如果第一步不匹配,再试捕获子组。DFA不支持捕获子组和回溯。看一下这个例子.如果匹配的结果是一个"nfa nfa ",那么结果就是"NFA"引擎,否则就是DFA引擎。
  • 如何区分POSIX NFA和传统型NFA,可以通过分组来完成。看一下这个例子,如果匹配结果是"holidaymakers"。那么就是POSIX NFA引擎。

正则优化

学习了原理之后,我们可以写出更好的正则。在保证正则的功能正常使用的情况下,进行性能的优化。

测试性能的方法

这里有个网站可以直接的看到我们匹配的次数,和消耗的时间。我们可以通过这个来简单测试一下我们正则的性能。

尽量准确的表示匹配范围

比如要匹配引号中的内容,可以使用 ".+" 或者是 "[a-z]+"。这两者虽然都能完成匹配,但是发生匹配的次数有明显的区别,可以看一下这个两个例子:例1例2。 这是因为点号"."会将引号也匹配上,然后在后续的过程中会发生回溯。随着我们正则表达式复杂度的上升,回溯的次数会明显增多,所以,要准确的表示匹配的范围。

提取出公共部分

这个是针对正则分组的匹配过程,如果分组中重复的内容过多,发生过多次数的回溯也会影响性能。这里有两个例子,可以参考一下:例一(regex101.com/r/KxfbiY/1)… 可以看到,两者的发生的匹配次数还是有很大区别的。

出现可能性大的放左边

因为正则引擎默认是从左开始匹配的,所以可能性大的放左边会优先匹配出结果,减少多余的匹配次数。

尽量减少嵌套的子组重复

如果一个子组里面包含重复内容,接着这个子组整体也可以重复,例如"(.)",那么匹配的次数会呈指数级上升。可以看一下这两个例子:例一 例二

避免不同分支重复匹配

不同分支出现了重复的匹配和上面提到的"提取出公共部分"的原理是一样的,都会增加回溯的次数,消耗性能。

正确的编写我们的正则

这个主要是我们平常书写正则时养成良好习惯。例如:

  • 使用正确的边界匹配
  • 使用具体的元字符,缩小范围
  • 使用正确的量词减少回溯次数
  • 使用非捕获子组
  • 注意量词的嵌套

通过上面这些注意事项我们能看到,很多优化都是围绕着减少回溯这一原则进行的。所以我们平常在使用正则的过程中也要注意回溯的次数,这样会极大减少正则的性能消耗。

总结

以上介绍的正则就能满足我们平常工作中的大多数要求了。 正则涉及的编程语言和工具非常多,不同的编程语言的正则也可能有些细微的区别,具体的可以看一下自己使用的语言的API,这里就不过多赘述了。 总的来说,掌握了正则的核心规则,以后书写正则会比较简单。更进一步,当我们遇到正则的问题时,也可以快速定位问题,查阅相关的文档。 最后,要记住使用正则的过程中要克制,因为滥用正则可能会导致不可控的问题增多。