优雅的正则(1):字符组与量词

万丈高楼平地起,先来看看正则表达式的两个最基本的概念吧!

字符组

普通字符组

字符组概述

一对方括号中间列出所有可能出现的字符,这些字符中命中一个即为命中,并且一个字符组一定能且只能匹配一个字符,字符组中列举出的字符不分先后顺序。

范围表示法:可以使用连字符(-)来简略表示连续的ASCII码字符,如[0-9]可表示[0123456789],同样地,[a-z]可表示26个小写字母,且范围表示法可混合写在字符组中,如[0-9a-zA-Z.]可匹配数字、小写字母、大写字母和小数点。

十六进制转义字符:通过\x开头后跟两位十六进制数,可以表示一个四字节的字符,十六进制转义字符完全等价于它所表示的字符,因此范围表示法也可以使用在十六进制转义字符中。

字符转义

在字符组中,因为像连字符(-)这样的字符具有其他的含义,这些字符称为元字符,当希望匹配这些字符时,需要进行转义,转义的方式和各种编程语言一样,即在它的前面加上右斜线(\)。

转义时要特别注意的元字符有三个:连字符(-)、左方括号([)、右方括号(])。

  • 连字符(-):它需要注意的点是,连字符可能存在不需要转义的时候,即它出现在字符组的开头时,因为它在字符组开头时不会被解析为一个合法的字符范围。因此尽可能不通过转义而是把它放在开头来解决连字符的匹配问题
  • 左方括号([):左方括号在字符组内时不需要转义,它需要转义的时候反而是字符组外,也就是遇到左方括号会被自动解析为一个字符组的开始,如果只是想匹配左方括号,就需要对它转义,不让它成为字符组的开始,此时的右方括号是不需要转义的,因为左方括号被转义了,孤零零的右方括号本就会被当成一个普通字符。
  • 右方括号(]):右方括号在字符组中时需要转义,不转义就会使方括号提前闭合。

注意,由于大部分编程语言的右斜线(\)在字符串中需要转义,因此在实际编写正则表达式时,某些编程语言可能需要二次转义。这在JS语言中是不需要的,因为JS支持单独的正则表达式类型;而Python则需要双重转义,因为它用字符串来表示正则表达式。

排除型字符组

排除型字符组概述

字符组中以脱字符(^)开头则为排除型字符组,表示匹配除了列出的字符以外的所有字符,如[^0-9]就表示匹配除了数字以外的所有字符。

但是要注意读于以下两种表述:

  • 表述A:不匹配列出的字符。
  • 表述B:匹配除了列出字符以外的字符。

显然符合排除型字符组的是表述B。因为表述A意味着除了表述B以外,还可能在指定位置上不匹配任何字符,显然不符合实际,因此要注意凡字符组,必定会且仅会匹配一个字符,因此排除型字符组也一定会匹配一个字符的,只是这个字符不是列出的字符。

字符转义

在排除型字符组中出现的元字符为脱字符(^),如果想把它视为普通字符需要进行转义。而实际上,合法的排除型字符组是在字符组内容的开始使用一个脱字符,只要不把它放在字符组的开头,那么它就是一个普通字符。

同时要注意需要在排除型字符组中排除连字符(-)的情形,你当然可以对它进行转义,但是更好的解决办法是将连字符放在开头的脱字符后面,这样连字符就被视为一个普通字符了。

字符组简记法

对于一些常用的字符组,有以下简记法:

  • \d等价于[0-9],表示数字;
  • \w等价于[0-9a-zA-Z_],表述数字、字母和下划线;
  • \s等价于[ \t\r\n\v\f],表示空白字符(空格、制表符\t、回车符\r、换行符\n、垂直制表符\v、换页符\f)。

字符组简记法可以单独出现也可以放在字符组中,放在字符组中表示并入当前字符组。

此外相对于\d\w\s这三个普通字符组简记法,正则表达式也提供了对应排除型字符组的简记法:\D\W\S,分别表示三者对应的排除型字符组,它们每对求并集都等于全体字符,即:

  • \D等价于[^\d]
  • \W等价于[^\w]
  • \S等价于[^\s]

那么我们可以通过[\s\S][\w\W][\d\D]来匹配任意字符。

其他

通配符

点号(.)即为通配符,可以匹配“任意字符”。这里“任意字符”之所以加引号,就是因为它实际上还是有不能匹配的字符的,就是换行符\n)。

如果要匹配包括换行符在内的任意字符,应当使用[\s\S][\w\W][\d\D]

字符组运算

字符组运算即对两个字符组求交并补来获得新的字符组,如[[a-z]&&[^aeiou]]可以匹配小写字母中除了元音字母之外的所有辅音字母。

这种特性并不一定被语言所支持,JS语言是不支持字符组运算的

POSIX规范

[:digit:]可以匹配数字,[:lower:]可以匹配小写字母。类似于这种写法的字符组就是POSIX字符组。

除了POSIX字符组之外POSIX规范还规定了一些其他的规则,但这里不再展开说明,因为POSIX规范不被JS所支持。在Linux/UNIX下的各种工具可能会使用POSIX规范。

量词

一般量词

量词概述

一个字符组只能匹配单个字符,那么量词就是指定数量,也就可以让一个匹配单元连续匹配若干次

注意这里表述的是“匹配单元”,不止包括字符组,也包括普通字符,还包括后面要介绍的括号分组等结构。

使用量词

一般量词只需要使用花括号来表示,即{m,n}的形式,这样就可以让量词前面的匹配单元匹配m~n次。

\d{4,6}表示匹配最短4位、最长6位的数字。

如果要匹配固定的次数,可以直接把m和n合写,如a{3}只能匹配aaa

省略上下限

如果不规定上限n,则可以直接不写,如\d{4,}匹配至少4位数字,上不封顶。

如果不规定下限n,实际上下限就是0,也就是一个也不匹配。你可以在某些语言中写类似于\d{,4}这样的,来匹配至多4位数字,但是这种写法可能不被某些语言支持(JS也是不支持的),因此不推荐使用这种写法,这种情况下不应该省略,而应该写作\d{0,4}

常用量词

简写量词

简写量词就是最常用的三种量词:

  • 星号(*)等价于{0,},表示匹配任意次(包括匹配0次);
  • 加号(+)等价于{1,},表示至少匹配一次;
  • 问号(?)等价于{0,1},表示匹配0次或1次。

之所以把它们简写就是因为它们使用的频率太高了,在实际使用中会常常用到。

匹配优先量词

先说结论:上述三种简写量词都是匹配优先量词

什么是匹配优先量词?先来举个案例:

我希望匹配一个双引号表示的字符串,也就是第一个字符是双引号,最后一个字符也是双引号,那么请看".*"这种写法有没有问题:

1
2
3
const reg = /".*"/;
console.log('"str"'.search(reg) >= 0); // true
console.log('""'.search(reg) >= 0); // true

看似是没有问题,开头和结尾都是双引号,中间是零到多个任意字符,可是问题就在于它不光能完整匹配"str"""这样的合法字符串,"str"str"这样的有三个引号的非法字符串格式它也能完整匹配,这显然不应该。

之所以出现这样的问题就是在于:星号(*)量词是匹配优先量词,它遇到一个字符会先尝试匹配上,后面遇到失配了才会回溯。也就是说它是“贪心的”,所以也被称为“贪婪量词”。

".*"这个正则表达式在匹配"str"str"时是这样的:

也就是.*贪心地匹配了str"str,连中间的第二个引号也匹配上了,而最后一个引号也刚好能匹配上,所以它认为匹配成功了,这就导致了匹配出来的不是个合法的字符串。

设想如果要匹配的是"str"str,没有最后一个引号,首先.*贪心地匹配了str"str,然后发现了最后一个引号没得匹配了(失配),然后开始回溯,当回溯到.*匹配了str时,发现最后一个引号也能匹配上,这时匹配成功,匹配到的是"str",即以下过程:

可以看到这样的匹配方式就是:尽可能地匹配(贪心)——失配——回溯——失配——回溯——(……)——匹配成功或失败,这也就导致了".*"这种写法遇到大于两个引号时就无法正确匹配出一个正确的字符串。

那么如何修改才能正确匹配呢,答案是这样:"[^"]*",也就是在一对引号之间的内容排除掉引号。

忽略优先量词

对于三种简写量词星号(*)、加号(+)和问号(?),它们默认都是匹配优先量词,是贪心的,实际上它们有对应的“懒惰版本”,只需要在它们后面加一个问号即可,即*?+???,它们也被称为懒惰量词。

从微观上看:

  • 匹配优先会先尝试着匹配,对于需要匹配不定次数的匹配单元,会一直匹配到无法再匹配下去,才会尝试下一个匹配单元,直到遇到某个匹配单元失配,就回退一步,继续尝试直到匹配成功,或者回退完了还是失配,那就是匹配失败。
  • 忽略优先恰好相反,当一个需要匹配不定次数的匹配单元,匹配成功了下限次数,就不再贪心地继续匹配了,只保个下限就行了,然后就开始尝试匹配下一个匹配单元,同样每个匹配单元都见好就收,只要匹配够了下限次数,就让出匹配权,交给下一个匹配单元匹配,直到遇到失配,就往前回溯到最近的、可增加匹配次数的那个匹配单元,增加一次匹配,然后继续匹配,直到匹配成功或失败。

从宏观上看:

  • 对于ABC这样的正则表达式,假设A的匹配是确定的,B可匹配的内容包含了C可匹配的内容,那么B是把C可以匹配的内容匹配了还是不匹配呢?
  • 对于匹配优先,B会尽可能匹配多的内容,只给C留下最后一个可匹配内容
  • 对于忽略优先,B会尽可能少地匹配内容,把第一个能给C匹配的内容交给C匹配

此外注意,不光对于对于三种简写量词有对应的忽略优先版本,对于任意的普通量词,在后面加上一个问号(?)都会变成忽略优先的。也就是说,任意量词在默认情况下都是匹配优先的,只有在后面加了问号(?)才会变为忽略优先的,如{4,6}?表示忽略优先地匹配4~6次。

两个栗子

(1)第一个例子:"str"str"

使用".*"匹配时:

套用ABC格式,A为第一个引号("),B为.*,它是匹配优先的,C为最后的引号("),那么对于字符串"str"str",有以下示意图:

引号①是A确定匹配的,而引号②和引号③都是C能匹配的,可是B也能匹配引号,所以满足了条件——B能匹配的内容包含C能匹配的内容,那么B就会贪婪地匹配,只给C留下最后一个引号③匹配,前面的内容B照单全收了,匹配结果如下:

而使用".*?"匹配时:

B就会见好就收,它虽然能匹配引号,但是它不匹配,留给C来匹配,也就是遇到的第一个能给C匹配的内容(引号②)就交给C匹配,匹配结果如下:

(2)第二个例子:匹配HTML中的JS脚本

对于以下HTML中内嵌的JS脚本:

1
2
3
4
5
6
<script type="text/javascript">
console.log(1);
</script>
<script type="text/javascript">
console.log(2);
</script>

我们希望把两个script标签匹配出来,很显然我们写正则表达式时,ABC模式中的A匹配开始标签<script type="text/javascript">,C匹配的是闭合标签</script>,而中间的B则需要使用[\s\S]*?来懒惰匹配所有的JS代码。

此时如果使用匹配优先,那么就会误把两个标签当成一个标签来匹配,即匹配为了以下错误的模式:

显然这种贪婪的匹配方式总容易把成对的东西匹配错,因为它会把跨越了多对标签的开始标签和闭合标签当成内容去匹配,只给C留下了最后一个可以匹配的闭合标签。

具体原理就不再展开,与上例是一样的道理。

注意这样的ABC模式只是大森假设出来的最简单的匹配模式,只用于方便介绍匹配优先和忽略优先的区别,正则表达式的最大特点就是灵活,因此不要被死板的固定模式束缚住了,实际使用中的正则表达式可不会是这么简单的ABC模式,重点在于理解和领悟贪心匹配和非贪心匹配的原理和区别。

字符转义

如果不希望表示量词的字符被当作量词,而是当作普通字符来处理,那就需要对它们进行对应的转义。

在量词中涉及到的字符转义的情况较为简单,主要有以下几种:

  • 本身就不是合法的量词:如{1,,2}{-1,2}{12}{}等,这些原本就不构成合法量词,因此不会被解释成量词,故不需要转义。但是注意形如{2,1}这样的量词虽然不合法,但是会直接报错!这种情况下不属于不需转义的情况。
  • 普通量词的转义:只需要转义开始的左花括号就可以了,后面的字符都不需要转义,只要破坏掉开始的花括号,就无法构成量词了。
  • 简写量词的转义:三种简写量词都只有一个字符,只需要把它转义了就行了。
  • 忽略优先量词的转义:其实就是在普通量词和简写量词的后面加了个问号(?),在转义前面的部分之后,也要把这个多出来的问号(?)转义,否则就会认为这个问号(?)是量词{0,1}

大森给你出一些题吧!

题目

1、匹配一个字符,这个字符只有可能是小写元音字母(aeiou)中的一个。

2、匹配两个字符,其中第一个字符可能为数字、大小写字母、下划线,第二个字符可能为3456rstuvw

3、匹配三个字符,第一个字符不能是空白符,第二个字符为数字或大小写字母,第三个字符不能为数字。

4、匹配一个字符,这个字符可能为左方括号([)、右方括号(])、连字符(-)、脱字符(^)或右斜线(\),尽可能以最简洁的方式编写正则表达式。

5、匹配一个合法的标识符:允许以字母或下划线开始,但是不允许以数字开始,除了第一个位置外,其余位置支持下划线、字母、数字。

6、匹配一个大驼峰表示法的标识符,如HandsomeDasen,如果出现数字,只能出现在末尾,如HandsomeDasen1

7、匹配一个小驼峰表示法的标识符,如handsomeDasendasen,如果出现数字,只能出现在末尾,如dasen1

8、匹配一个科学计数法表示的数值,如1e32e-5-3e3

9、编写一个正则表达式用来匹配<script type="text/javascript">……</script>标签及其中的内容。

注意:只需要按照规则书写,不需要关心匹配位置。

答案

1、[aeiou]

2、\w[3-6r-w]

3、\S[0-9a-zA-z]\D

4、[-^[\]\\]

5、[a-zA-Z_]\w*

6、[A-Z][a-zA-Z]*\d*

7、[a-z][a-zA-Z]*\d*

8、-?\d+e-?\d+

9、<script type="text\/javascript">[\s\S]*?<\/script>

注意:答案按照JavaScript格式的正则表达式语法书写。




* 你好,我是大森。如果文章内容帮到了你,你可通过下方付款二维码支持作者 *