正则表达式,从用到源

1,151 阅读8分钟

简介

 说到正则,最近可谓是碰到头,以前没注意,但用起来时,却发现了许多问题。所幸找到一本好书(文末推荐)解决了一直以来的困惑。本文算是看过该书后的一个学习总结吧。

文中正则表达式的结构图是由Regulex生成的,有兴趣的同学可以去看一下。

什么是正则

 简单来说,正则是一段验证字符串是否符合规则的文本。我们可以用它来进行对文本的验证,查询,替换,切分等操作。在javaScript中,正则有两种声明方式。

 //第一种
 const regx1 = /^[0-9a-z]+$/g;
 //第二种
 const regx2 = new RegExp('[0-9a-z]+','g');

正则的核心

 正则表达式的核心应用就是模糊匹配,其一般分为两类应用,一是匹配字符,二是匹配位置。在讲述之前,我们先来看下一个例子。 这是一个正则表达式的包含正则知识点比较完善的例子,接下来,我们将用它作为示例,分析正则表达使用一些知识点。

字符匹配

 字符匹配就是匹配字符串中的字符,例如图中[,-.A-Za-z\d?$]{8,16}匹配的就是包含有逗号、中短线、字母、数字、问、美元符号等中一种或多种字符的8-16位连续的字符串。为了方便记忆,进一步划分为:字符的横向匹配和字符的纵向匹配

纵向匹配

纵向匹配:一个正则匹配的字符串,具体到某一位字符时,它可以不是某个确定的字符,可以有多种可能。他类似代码中的或运算。在js中的编写形式为字符组。例如图中的[A-Za-z]或者\d

注意:字符组中每次只匹配其中的一个字符。 字符组的一般表示法:[123456789abcd]
字符组的范围表示法:[0-9]表示0,1,2,3,4,5,6,7,8,9中的一个;[a-z]表示从a到z的小写字母,[A-Z]表示从A到Z的大写字母。
排除字符组:匹配不是字符组中包含的内容,例如[^\d]表示匹配非数字的字符;[^A-Za-z]表示匹配非英文字母的字符;

常见字符组简写
简写具体含义
\d等价[0-9]表示匹配0到9数字中的一个
\D等价[^0-9]表示匹配非数字
\s等价于[ \f\n\r\t\v],表示匹配空格、制表符、换页符
\S等价于[^\f\n\r\t\v],表示匹配任何非空白字符
\w等价于[A-Za-z0-9_],表示匹配字母、数字、下划线
\W等价于[^A-Za-z0-9_],表示匹配非字母、数字、下划线

横向匹配

横向匹配:一个正则可匹配的字符串的长度不是固定的,这似代码中的循环。在js中的编写形式为表达子式+量词

量词即标注元字符或者表达式的循环次数。

常见量词
量词具体含义
{m,}表示至少连续出现m次相邻前面的表达式匹配的字符
{m,n}表示至少连续出现m次相邻前面的表达式,且最多连续出现n次
?等价于{0,1},表示最多出现一次或者不出现
+等价于{1,},表示至少出现一次
*等价于{0,},表示可以出现任意次,可以不出现

贪婪量词和惰性量词

 贪婪量词会尽可能的多匹配字符,如\d{8,16}会尽可能的匹配到16个数字。而惰性量词则会尽可能的少匹配,如\d{8,16}?,会尽可能的匹配到8个数字。这会对正则执行效率有影响。 如图所示,惰性量词即是在常见量词后加上?

位置匹配

 我们之前讲到过,正则表达式匹配分为两类,其中一类便是位置匹配。那么我们先来看一下字符串中正则匹配的位置在哪里。

如图所示字符串位置由左向右,从0开始位置数依次增大。空格、制表符、换行符等会影响位置。


(?!p)是正则位置匹配中的一种,p是正则表达子式。

常见位置元字符

元字符具体含义
^匹配字符串开始位置
$匹配字符串结束位置
(?=p)匹配表达式p成立的字符左位置
(?<=p)匹配表达式p成立的字符的右位置
(?!p)匹配表达式p不成立字符的左位置
(?<!p)匹配表达式p不成立字符的右位置

分析与验证

1.^$,其分别匹配字符串的开头和结尾,且与是否有换行、空格换行无关。

  1. (?=p)(?<=p),分别匹配的是符合后面表达式p的左位置和右位置。

3.(?!p)(?<!p),分别匹配的是不符合后面表达式p的左位置和右位置。

回顾

 分析了常见的元字符后,我们再来看下文章开头的那个例子。 上图中^(?!\d+$)(?![A-Za-z]+$)(?![-_.$*?,]+$)[,-.A-Za-z\d?$]{8,16}。是一个至少包含数字、字母、部分特殊字符(-_.$*?,)中任意两种的8-16位的密码验证。

个例分析

 当字符串匹配正则时,首先匹配第一个元字符^,寻找字符串开头,然后从上一个匹配到的位置开始匹配(?!\d+$),即寻找不全是数字的字符串的的位置,然后从上一个元字符匹配的位置开始匹配(?![A-Za-z]+$),即寻找不全是字母的位置,同理(?![-_.$*?,]+$)是获取不全是字符组中特殊字符的左位置,然后从这个位置,连续匹配8-16个数字、字母或者特殊字符(,-.?$)组成的字符串。如果上面的几步中哪一步出现匹配不到的情况,则会导致匹配失败,验证不通过。下图是该正则部分位置验证。

正则的分组与分支

 正则的分组跟用括号确保代码中优先执行差不多,不过正则中的分组会缓存该分组匹配上的字符以便于反向引用使用,而分支则和编程语言中的类似。

分支

 在正则中分支使用|表示。其意义表示正则中的的意思,也即并列跟js的逻辑或十分相似。
 不过这里得注意一下,/^[a-z]|\d$//^([a-z]|\d)$/的意义是不一样的,这是因为正则表达式元字符的优先级导致的。如下是操作符优先级:
 所以导致/^[a-z]|\d$/表示以字母开头的字符串或以数字结尾的字符串能通过验证,而/^([a-z]|\d)$/表示只包含一个字母或者一个数字的字符串能通过验证。

分组

分组:由括号包裹的非位置匹配的整体。在正则中用(p)表示,其中p为正则表达子式。

注意:(?!p),(?<!p),(?=p),(?<=p)属于位置匹配元字符,不属于分组。


如图中所示(admin)表示第一个分组,(\1)表示第二个分组,至于\1则表示对第一个分组的反向引用。

反向引用

 在引入反向引用之前,我们先来看下为什么需要使用反向引用。我们来看一个例子。

 上面的是一个验证字符串是否是年月日的时间格式,正则表达式/^\d+[-/,]\d{1,2}[-/,]\d{1,2}$/,乍看之下很符合要求,但验证时发现,分隔符不统一的情况下也能验证通过。这确实让人头疼了一把。那么如何解决这个问题呢?没错答案是使用反向引用。
 反向引用在正则中用\n表示。其中n代表大于0的整数。也即\1表示第一个分组的反向引用,\3表示第三个分组的反向引用。也就是说\n使用第n个分组匹配到的字符再次匹配。我们来看一下修改后的年月日验证结果

分组标序

 我们现在已经知道了反向引用的作用,那么正则中如何对分组进行标序,即如何知道该分组是第几个呢? 我们来看一下正则时如何分组的以及嵌套分组。 如图中所示正则分组标序由外到里,由左至右,先从外层开始遇到的第一个括号表示第一组,依次向里分组。

 但是当我们使用的时候,或者从第三方拿到一个正则,里面包含大量分组时,如果想要修改一下,例如当我们想要在校验连续两个相同字符字符串的正则表达式^([a-zA-Z])\1$中增添一个分支的让只有一个数字时的字符串也验证通过,如果我们直接修改为^([a-zA-Z])|\d\1$,正则的意义变为以一个字母开头或者以一个数字结和一个字母尾的字符串验证通过,而加上括号得到正则^(([a-zA-Z])|\d)\1$会导致正则表达式的分组改变,也会改变初始意义,当然我们可以使改变反向引用变为^(([a-zA-Z])|\d)\2$
 但是当遇到大量分组与反向引用时,也会变得束手束脚的。因此我们需要(?:)元字符来包正我们修改时,类似括号的作用,又不影响分组。
(?:p)表示正则表达式p是一个整体。其作用就如我们上面分析的那样,确保我们区分正则的整体而不影响原来的分组那样的影响。 所以上面的正则我们可以改为^(?:([a-zA-Z])|\d)\1$

正则的匹配过程

 首先是在匹配之前,会将正则表达式编译状态机。如下是本文开始的那个例子,画的一个简版的状态机图。
如图,从状态24开始一直向左匹配,知道到达状态25时,才算匹配成功。否则匹配失败。

匹配过程

 首先我们从24开始,因为分支|的左边优先执行且24->1没有任何条件,所以状态24到达状态1,然后状态1->2的条件为^,然后一次2->3->4->5->6->7->25进行条件匹配。

 特别注意的是,当遇到分支时当前状态会保存为一个版本,当遇到量词时,每次循环均会保存为一个版本。如例子中保存为第一个版本的是状态24,保存为第二个版本的是状态6,。当满足条件,则状态会从当前状态变为下一个状态,当不满足条件时,则当前状态会回退到上一个版本中的状态,然后再匹配,如果满足则进入下一个状态,否则会一直回退,直到回退到第一个保存版本的状态。

 不过当遇到贪婪量词的时候,如果字符串的字符满足当前匹配条件的时候,正则的状态并不会进入循环之外的状态,而是一直匹配,直到满足最高或者字符串的下一位不满足时才进入循环之外的状态,。如图中5->6状态的循环,需要字符串连续16个字符满足或者遇到不满足该元字符的字符时,才会执行从6->7的条件匹配。而遇到惰性则相反,只要满足量词中最低的条件,则会进入到循环之外的匹配过程。

 在回退到第一个版本中的状态后,也即状态回到24,然后进入另一个分支进行匹配,也即24->8然后开始条件匹配,同理如果满足条件则进入下个状态,直到到达状态25,则表示匹配成功,验证通过。当不满足条件时,也会一直回退第一个版本的状态,如果还不满足,且没有分支或者循环时,则表明字符串匹配失败,验证不通过。

回溯:上面分析过程中,当不满足条件时,当前状态回退到上一个版本中的状态的过程就是回溯。而保存版本的条件时需要使用分支或者量词。因此当不使用分支和量词时,回溯是不存在的。

不了解状态机的同学可以看下这篇文章《将正则表达式转换为有限状态自动》

文末推荐及参考

《正则表达式引擎执行原理——从未如此清晰!》
《将正则表达式转换为有限状态自动》
《JavaScript 正则表达式迷你书》

最后内容难免出错,欢迎指正,交流