正则表达式优化 - 捕获组和非捕获组

3,889 阅读6分钟

正则表达式系列总结:

前言

最近看到项目中,有些正则表达式中包含了一些没啥用的捕获组, 所以就想研究一下当其他条件相同时,使用捕获组和非捕获组在程序执行时有什么样的差距。

基本语法

JavaScript语法 MDN文档指路: Groups and Ranges

捕获组 Capturing group

正则表达式中可以用一对小括号(x)来对文本进行分组和捕获,

捕获组的使用场景:

  • 提取字符串
  • 反向引用
  • 使括号内的部分成为一个整体,规定多选结构或作用在量词上

一个简单的例子:

var r = /a(.*)e/
var s = 'abcde fghi'

console.log(s.match(r))

结果如图:

group-1.png

加上这对括号,可以提取出字母a和e之间的字符。

命名捕获 Named capture group

为捕获组命名,可以更方便地读取提取到的内容,javascript中可以使用(?<name>x)的方式命名捕获组,

一个简单的例子:

假如要从字符串<div>hello regexp</div>中提取出div标签之间的内容,即hello regexp, 可以使用表达式/<div>(?<message>.*)<\/div>/,其中message就是为提取这部分内容取的分组名称。

var s = '<div>hello regexp</div>';
var r = /<div>(?<message>.*)<\/div>/

console.log(s.match(r))

结果如下图所示:

group-name.png

String的match()方法会返回一个匹配的结果数组:

  • 数组下标为0的元素为根据正则表达式匹配到的完整字符
  • 如果设置了捕获组,那么从下标1开始,往后的数组项为捕获组提取出的字符,左括号出现的顺序即为结果排列的顺序
  • 如果还设置了捕获组的名字,那在结果数组的groups字段下,会按照分组名列出提取结果

反向引用 Back reference

使用捕获括号匹配到的内容,可以在程序中进行引用,分为以下几种场景:

  • 在正则表达式中引用
  • 在替换字符串时引用,如javascript中String的replace()方法的第二个参数中
  • 通过Regexp对象来引用

引用方式:通过捕获组的序号,或根据捕获组的名称引用

在表达式中引用:

// 按照序号引用 使用\n形式 n为从左到右的捕获组序号
var r = /<(\w+)[^>]*>(.*?<\/\1>)?/g 

// 按照分组名称引用 使用\k<name>
// var r = /<(?<tag>\w+)[^>]*>(.*?<\/\k<tag>>)?/g

var s = '<div>div content</div> some words <script src="..."></script>'

var s1 = s.replace(r, '') // 可以过滤html标签

上面的正则摘自在上一篇文章中提到的any-rule,用于宽松匹配HTML中的标签,

这个表达式中包含两对捕获括号:

  • 第二对捕获括号中有一个\1,就是对第一个捕获组内容的引用,表示前后匹配成对的标签,第一对括号说我现在要匹配script标签,那</\1>组合起来就跟着匹配它对应的闭合部分</script>
  • 第二对捕获括号的作用,是为了使它内部的字符成为一个整体,使后面的?量词能作用在这个整体上,表示在匹配HTML标签时,可以有闭合标签,也可以没有。

在替换字符时引用:

通过String的replace方法进行字符串替换时,如果replace()的第一个参数为正则表达式,第二个参数可以是一个字符串或一个函数。

当字符串作为第二个参数时(表示要替换成的文本),可以包含几种特殊变量:

  • $n: 表示插入表达式中第n个捕获括号匹配的字符,没有捕获组时是空字符串,n的值为1-99,小于10的时候用01,02...也可以
  • $: 表示插入表达式中指定名称的捕获组匹配的字符,没有指定捕获组名称时是空字符串
  • $$: 单纯地替换为$符号
  • $&: 匹配整个表达式的子字符串
  • $`: 匹配的子字符串之后的子串
  • $': 匹配的子字符串之前的子串
var r = /(?<text>hello regexp)/
var s = '<div>hello regexp<\/div>'

var s1 = s.replace(r, '<em>$<text></em>')
// "<div><em>hello regexp</em></div>"

上面例子使用捕获组名称进行引用和替换

通过Regexp对象来引用:

使用Regexp.$n的形式也可以访问到捕获内容,例如:Regexp.$1

var r = /(hello regexp)/
var s = '<div>hello regexp<\/div>'
var s1 = s.match(r)

console.log(Regexp.$1) // "hello regexp"

非捕获组 Non-capturing group

非捕获括号(?:x)不能用来提取文本,《精通正则表达式》书中说:

非捕获括号有助于提高效率,如果正则引擎不需要记录捕获型括号匹配的内容,速度会更快,所用的内存也更少。

var r = /<(\w+)[^>]*>(.*?<\/\1>)?/g
var s = '<div>div content</div> some words <script src="..."></script>'

var s1 = s.replace(r, '') // 可以过滤html标签

还是前面这个匹配HTML标签的例子,其实第二个分组就是不需要被反向引用的, 仅仅是为了能让括号内的部分作为被量词作用的整体,因此完全可以用非捕获括号代替,改写后如下:

var r = /<(\w+)[^>]*>(?:.*?<\/\1>)?/g

性能测试

总结了上述用法,其实最想验证的是捕获组和非捕获组在性能上的差异到底是怎样的,下面是我的一丢丢测试和总结。

Round 1

第一轮测试,我找了一段大小约63kb的文本,分别比较同一个正则表达式中含有3个非捕获组和3个捕获组的程序执行情况:

文本大小捕获组个数程序执行耗时程序执行次数/秒
63kb00.02ms左右2.8万 ops/s 左右
63kb30.03ms左右2.7万 ops/s 左右

大概尝试了10次左右,粗略肉眼估计了一下,没有计算精确值。

结论是:文本内容不算特别大时,捕获组和非捕获组在执行效率上差异不是很明显,直接执行了几次大多数情况相差0.01ms左右。

Round 2

第二轮测试,找了一段大小约297kb的字典数据,用正则表达式批量随便提取四个字段。

分别比较正则表达式中含有4个非捕获组、2个捕获组+2个非捕获组、4个捕获组的程序执行情况:


console.time('0 capture groups')
var r3 = /"code":(?:.*?),"name":"(?:.*?)","parentCode":(?:.*?),"shortName":(?:.*?)/g
var list3 = [...s.matchAll(r3)]
console.timeEnd('0 capture groups')

console.time('2 capture groups')
var r2 = /"code":(?<code>.*?),"name":"(?<name>.*?)","parentCode":(?:.*?),"shortName":(?:.*?)/g
var list2 = [...s.matchAll(r2)]
console.timeEnd('2 capture groups')

console.time('4 capture groups')
var r = /"code":(?<code>.*?),"name":"(?<name>.*?)","parentCode":(?<parentCode>.*?),"shortName":(?<shortName>.*?)/g
var list = [...s.matchAll(r)]
console.timeEnd('4 capture groups')

直接执行JS并打印了5组执行时间的统计:

文本大小4个非捕获组2捕获组 + 2非捕获组4个命名捕获组
297kb2.94ms4.76ms7.74ms
297kb0.92ms1.81ms8.48ms
297kb3.02ms3.64ms19.12ms
297kb0.57ms2.61ms12.53ms
297kb0.53ms4.73ms9.78ms

使用JSBench进行的10次性能测试结果:

文本大小4个非捕获组2个捕获组+2非捕获组4个命名捕获组
297kb1398.44ops/s954.12ops/s (慢31.77%)796.08ops/s (慢43.07%)
297kb1438.64ops/s946.43ops/s (慢34.33%)744.85ops/s (慢48.32%)
297kb1457.66ops/s887.82ops/s (慢29.52%)777.46ops/s (慢38.28%)
297kb1318.97ops/s930.34ops/s (慢29.46%)734.85ops/s (慢44.29%)
297kb1468.66ops/s950.73ops/s (慢35.27%)749.94ops/s (慢48.94%)
297kb1431.64ops/s959.95ops/s (慢32.95%)758.47ops/s (慢45.13%)
297kb1355.06ops/s946.32ops/s (慢30.16%)752.23ops/s (慢44.49%)
297kb1428.12ops/s925.76ops/s (慢35.18%)695.8ops/s (慢51.28%)

结论:

综合两次简单的测试,结论就是:书上说得对!

虽然在文本不大的情况下,使用捕获组和非捕获组看起来在程序执行速度上差异不大, 但随着文本越来越大,表达式越来越复杂,使用越多的捕获组,程序执行速度越慢。

因此,在实际开发的时候,还是要明确正则表达式中的分组内容是否需要被捕获, 如果没有这个需要,那就直接用非捕获括号呗~

结束语

优化一小步,文明一大步,虽然正则表达式的优化重点是在减少回溯上,但我觉得正确使用捕获组和非捕获组也是有必要的。

回过头来看看自己曾经写过的正则表达式,关于捕获组和非捕获组的部分,是不是还有一些可优化的空间~

最后,如果文中有任何错漏之处,欢迎指正,鞠躬。