《正则指引》读书笔记

爬虫真的非常有意思。为了更高效的处理获取的数据,开始正儿八经的学习正则表达式。

<!--more-->

1. 字符组

字符组就是一组使用[]中括号包围的字符集合,表示“在同一个位置可能出现的各种字符”。

// 这本书前面的章节都是使用Python来书写测试代码的
// 这篇笔记使用下面这段JS代码来测试正则表达式
var str = 'cat'
var re = /[abc]/g;
var result = null;
while(result = re.exec(str)){
    console.log(result[0]); // c,a
}

1.1. 范围表示法

字符组中普通字符的排列顺序并不影响字符组的功能(因为只是在该位置上匹配一个字符),但是为了简化表达式,对于多个连续字符的处理可以使用-范围表示法,所谓的连续字符,实际上是根据字符的ASCII编码来决定的,在使用范围表示法中,编码小的字符在前,编码大的字符在后:

var re = /[0-9]/;
var re = /[a-zA-Z]/; // 在一个字符组中可以使用多个范围表示法,并且这些表示法的顺序无关

1.2. 元字符与转义

正则表达式所使用的一些字符,比如^,$,-等(这些字符被称为元字符)在字符组中不能直接被使用,而是要使用转义符 ""进行转义,此外""也是元字符,因此如果需要匹配反斜杠本身,则也需要进行转义(就如现在我在写markdown,如果想要达到正确的转义符代码效果\\,则必须使用双斜杠...)。 字符组范围表示法元字符-如果靠近[(或者是下面的排除型字符组的^)则会被当作是普通字符,而在其他位置则会被当作元字符;其他元字符都必须通过转义才能被当作普通字符。

var re = /[-ac]/; // 表示匹配字符 -, a, c

1.3. 排除型字符组

在某些时候需要匹配一个不处于字符组中的字符,这时候就必须使用到排除行字符组[^]

var re = /[^0-9]/; // 匹配非数字的字符

作者在这里指出,需要注意:“在当前位置,匹配一个没有列出的字符”和“在当前位置,不要匹配列出的字符”这两种说法实际上是不一样的。排除型字符组的正确含义是前者,也就是实际上必须在当前位置匹配一个字符,且该字符不属于字符组;而后者暗示可以不匹配任何字符。

1.4. 字符组简化标识符

正则表达式提供了常用的字符组的简写标识符:

  • \d数字(digit) 和 \D非数组
  • \w字符(word) 和 \W非字符
  • \s空白(space) 和 \S非空白符

1.5. 通用字符

元字符.可以用来匹配除换行符外的全部字符(牢记这点:.不能匹配换行符!)。如果需要匹配真正意义上的“全部字符”,则可以使用[\w\W]来表示。

2. 量词

字符组用来匹配单个字符,而量词则用来匹配多个字符,使用{}来表示某个数量范围内的量词。

var re = /\d{3}/; // 匹配3个数字
var re = /\d{1,3}/; // 量词也可以用来表示某个数量范围的不确定长度

需要注意的是表示范围的量词中,逗号与前后的数字之间不能有空格。量词一般有固定的长度或者明确的范围下限,如果下限为0则可以直接省略(在某些语言中不支持);量词的范围上限可以是不固定的。

2.1. 常用量词标识符

{m,n}是通用形式的量词,此外还有三个常用量词元字符(在大多数情况下只需要表示这三种意思):

  • +表示{1,}一个或多个
  • *表示{0,}0个或多个
  • ?表示{0,1}0个或一个
var re =/<[^>]+>.*<[^<]+>/g; // 匹配一个HTML闭合标签,当然现在这里并没有考虑标签名前后一致以及标签嵌套

2.2. 贪婪匹配

通用字符.与量词组合就可以匹配任意长度的字符,但是事实上会存在一些问题

var re =/".*"/g; // 匹配一个由双引号包围的字符串
re.exec('"title"abcef'); // "title"
re.exec('"title"abc"ef'); // "title"abc"

产生上述结果的原因是:.*在匹配过程结束之前,每遇到一个字符,都"可以"进行匹配,但是到底是匹配这个字符,还是忽略它,这将取决于*量词后面的"字符来决定。 默认.*属于"匹配优先量词"(这是作者的叫法,在很多地方被称为贪婪匹配),下面是一个关于"title"abcef贪婪匹配的假想过程:

  • 首先逐个匹配字符串中的字符并将字符依次记录(因为在匹配过程中并不确定是否保留该字符,所以优先线记录该字符),即使是遇见了双引号",但此时仍然处于.*的匹配过程中,程序并不知道下一个字符是",所以会继续匹配后面的字符并记录。
  • 至字符串(或换行符,因为.不能匹配换行符)末尾(表示.*匹配过程已经结束,接下来应当匹配*量词后面的字符,这里也就是双引号"),发现“哦,后面原来是要匹配双引号啊”。
  • 但是现在所记录的字符串最后(可能)并不是双引号,"不满足要求啊我要反悔",因此正则表达式就开始从记录的最后往回查询(这里跟前面的依次记录不同,现在知道要查询的字符是双引号"了),这个过程被称为"回溯",然后在记录中寻找",当找到倒数第一个目标字符",嗯,就是你了,最后舍弃后面的字符,返回正确的匹配结果(所以这个匹配结果就尽可能多的包含了",返回的字符串也就会比较长)。

书中的描述是会回溯到倒数第一个双引号之前,以便让正则表达式.*后的"可以匹配到",大概意思应该也是差不多的吧。

因此,如果需要精确的匹配一个只由双引号围成的字符串,则最好不要使用通配字符:

var re = /"[^"]"/;

2.3. 非贪婪匹配

上面阐述贪婪匹配的原理,但是在某些时候我们更希望获取最短的匹配结果,比如一个单层闭合的的div标签(在HTML文档中存在无数个嵌套的div标签)。所幸还有另外一种非贪婪匹配(作者称为惰性匹配)的量词类型.*?。 贪婪匹配的原理是,遇见一个.能匹配的字符就优先进行匹配记录,最后再反悔回溯匹配到量词后面的那个字符。 而非贪婪匹配的原理恰好相反:遇见一个.能匹配的字符就优先进行忽略,并直接匹配?后面的那个字符,如果匹配不成功则将前面的字符记录并尝试匹配下一个字符,依次进行(好像一步一步地探地雷,生命只有一次,没有后悔药可以吃),如果在某次忽略并对?后面的那个字符匹配成功之后,则直接返回记录的字符串,整个匹配过程到此结束(因此非贪婪匹配的字符串会尽可能短)。

var str = '<div>head</div><div>body</div>';
// 由于html文档中可能出现大量的换行,因此最好不要直接使用通配符进行匹配
var re =/<div>[\w\W]*?<\/div>/g; // "<div>head</div>"
var re =/<div>[\w\W]*<\/div>/g; // "<div>head</div><div>body</div>"

由于非贪婪匹配必须兼顾他所限定的字符与之后的字符,因此效率相对于贪婪匹配会更低,如果字符串很长,则速度差异会更加明显(这里理解的并不是很透彻)。

3. 括号

3.1. 分组

量词不仅可以用来表示字符的数量,也可以用来表示表达式的数量,使用()来定义一个表达式(通常被称为正则表达式的子表达式),子表达式作为一个整体参与匹配 。 在使用正则表达式时经常会遇到并没有直接相连,但确实存在联系的部分,分组可以把这些概念上相关的部分“归拢”到一起,以免割裂。作者在这里列举了一个URL的例子:

// 目的是匹配/moudle/controller_action.php这样的路径
// 规则是/moudle必须出现,而方法名可以省略或控制器与方法名可同时省略
// 难点在于虽然有些元素不一定出现,但是不一定出现的元素之间是有关联的,要么同时出现,要么同时不出现

var re = /\/[a-z]+(\/[a-z]+(_[a-z])?\.php)?/g;

关于正则表达式,一定要明白它不过是按顺序匹配相应关系的字符串,因此书写正则表达式之前,应当首先理清所需要的规则,以及各依赖元素之间的关联,思路清晰了,写起来肯定务必顺畅!

3.2. 多选结构

可以在括号内以|竖线分隔开多个子表达式,在匹配时,只要其中某个表达式能够匹配,整个多选结构就匹配成功

var re = /([0-9]|[1][0-9]|[2][0-5])/; // 匹配0-25之间的数字

实际上也可以不在括号中使用分割符|,括号的真正作用是用来限定多选结构的"范围",如果没有括号,则相当于把整个表达式当作一个多选结构。 多选结构看起来与字符组十分相似,但是也有很多区别:可以使用排除型字符组来表示“无法由某几个字符匹配的字符”,而多选结构并没有类似的结构,即不存在"无法由某几个表达式匹配的字符串"

3.3. 捕获分组

使用括号之后,正则表达式会保存每个分组真正匹配的文本(JS是保存在一个数组中)。因为"捕获"了文本,所以这种功能叫做"捕获分组"。 被捕获的分组保存在数组中,那么每个分组的索引是什么呢(因为可能存在分组嵌套的情况)?无论括号如何嵌套,分组的编号都是根据开括号从左向右出现的顺序来计数的。

var str = '1-4';
var re = /([0-3])-([4-5])/;
re.exec(str); // ['1-4','1','4'] 索引0为匹配到的完整表达式

利用捕获,可以很轻松地找到某个规律字符串下的具体信息,而不必再单独为该信息重新编写正则表达式。 此外,当为分组使用量词的时候,捕获到的值是最后一次匹配到的子表达式的结果,而具体的细节是每重复出现一次,就要重新更新一次捕获的分组:

var str = '2010';
var re = /(\d){3}/; // res[1] == 1;

3.4. 反向引用

前面提到的引用分组,能捕获某个分组内的子表达式匹配的文本,但是捕获都是在匹配完成之后进行的,怎么匹配一个HTML闭合标签呢?实际上也可以在正则表达式匹配过程中使用引用,这种功能被称为“反向引用”。 反向引用允许在正则表达式内部引用之前的捕获分组匹配的文本(即表达式左侧的分组),使用\num来表示一个反向引用,其中num是对应分组的编号。使用反向引用可以很方便地使用反向引用来建立前后联系

var re = /([a-z])\1/g; // 匹配两个连续相同的字符
var re = /<([a-z]+)(\s[^>])?>[\s\S]*?<\/\1>/g; // 匹配一个闭合标签

需要注意的是反向引用是由前面的捕获分组所匹配的具体文本,而不是那个子表达式。此外还需要注意的是由于\\也是转义符的标识,加之如果分组多余9个则存在二义性(\11是第一个分组后跟字符1还是第11个分组?),不过一般在表达式中出现数十个分组是比较少见的情形。 解决这个问题可以使用类似于?P<name>的命名方式为分组命名,但是并不是所有语言的正则都支持(比如JS就不支持)

3.5. 非捕获分组

默认只要使用的小括号,就会捕获相应的分组,如果实际上并不需要获取相应的子表达式,则为了提高性能可以显式地声明非捕获分组。 非捕获分组使用(?:)来进行声明,他只能限定量词和都多选结构的作用范围,不捕获任何文本,并且在统计捕获分组的标号时,非捕获分组会被忽略掉。

var str = '2016-12';
var re = /(?:\d{4})-(0[1-9]|1[0-2])/g; // res[1] = 12;

4. 断言

正则表达式中的大多数结构匹配的文本都会出现在最终的匹配结果中,但是有些结果只是用来判断某个位置左/右侧的文本是否符合要求,这种结构被称为"断言"。

4.1. 单词边界

某些单词可能是其他单词的子字符串(比如java和javascript),在子希望匹配具体单词而非某个子字符串时,单词边界\b十分有用

var re = /\d\w+\b/; // 匹配文本中全部单词,当然这里只能是\w字符能表示的单词

4.2. 行起始/结束

匹配某个位置的元素叫做“锚点”,用来定位到某个位置,最常见的锚点就是^$,用来表示字符串的开始位置和结束位置

  • ^用来表示单行匹配模式下的字符串起始位置或者多行匹配模式下每一行的起始位置;如果不论多少行都只想匹配第一行的起始位置则使用\A(貌似JS下也没有效果)
  • $通常匹配整个字符串的结束为止

4.3. 环视

有时候需要在某个位置向左或者向右看,要求必须出现或者不能出现某些字符,这种需求在正则表达式中十分有用,对于这种需求正则表达式提供了"环视"(更常见的叫法叫做“向前/后捕获”)。 (?!)就是“向后环视”,真正需要被观察的表达式位于!之后,这个子表达式表示当前位置之后,不允许存在这个子表达式能够匹配的字符,从而限定匹配前面的字符或分组,根据正则表达式从左向右的匹配顺序,?!也被称为“否定顺序环视”。 而如果需要限定当前位置之后必须匹配环视子表达式,则使用(?=)来声明一个“肯定顺序环视”。

var str = 'xabacy'
var re = /[a-z]a(?!b)/g; // 表示匹配字符[a-z]a且其后不为字符b,结果为ba
var re = /[a-z]a(?=b)/g; // 表示匹配字符[a-z]a且其后为字符b,结果为xa

既然存在"向后环视",想必也存在“向前环视”。在大部分语言的正则中,使用(?<!)来声明“否定逆序环视”,表示在当前位置之前不允许出现环视子表达式所能匹配的内容;使用(?<=)来声明"肯定逆序环视"。然而令人悲伤的是:JS并不指出逆序环视,Ruby也是如此。 实际上,可以使用分组来代替肯定逆序环视,但是关于否定逆序环视,并没有比较好的解决办法。

最后,需要注意的是最后的匹配结果中不会包含环视子表达式所匹配的内容。所以在环视匹配之后会继续从表达式前面的那个位置继续向后匹配

var str = "12345";
var re = /\d(?=(\d{3})+)/g; // 结果是 1, 2

环视的一个重要作用是:即可以集中关注某个部分,添加全局性的限制,又不会干扰其他部分的匹配。 环视的另一个作用是:提取数据时杜绝错误的匹配,一般来说,只要是提取有长度特征的数据,都需要用到环视。

5. 匹配模式

匹配模式是指匹配时使用的规则,设定特定的规则,可能会改变对正则表达式的识别,常见的模式包括四种:不区分大小写模式,单行模式,多行模式和注释模式。 一般有两种方式来指定匹配模式:模式修饰符指定和预定义常量(gim)。但是JS中只支持预定义常量指定匹配模式。

5.1. 不区分大小写

一般用来匹配只关系字母含义而不在乎具体的大小写的情形。不区分大小写模式对应的模式修饰符是i

5.2. 单行模式

前面起到,通用字符.可以匹配除\n换行符之外的任意字符,因此最多匹配到单行行尾,但有时候需要匹配行尾的\n,于是可以声明单行模式,单行模式对应的模式修饰符是s,主要用来改变.的匹配规则。

5.3. 多行模式

实际上单行模式与多行模式没有任何关系。多行模式影响的是^$的匹配规则:在默认模式下,他们匹配的是整个字符串的起始位置和结束位置;在多行模式下,他们匹配的是字符串内部某一行文本的起始位置和结束位置。 多行模式对应的模式修饰符是m

5.4. 注释模式

如果正则表达式非常复杂,则可能会需要在其中添加注释(一脸懵比)

6. 结语

至此第一部分到此结束,应当停下来写几个爬虫练习一番,然后再继续阅读。