目录
一、典型示例
1. 稍加修改——先迈最好使的腿
2. 效率 vs 准确性
3. 继续前进——限制匹配优先的作用范围
4. “指数级”匹配
二、全面考察回溯
1. 传统 NFA 的匹配过程
2. POSIX NFA 需要更多处理
3. 无法匹配时必须进行的工作
4. 看清楚一点
5. 多选结构的代价很高
三、性能测试
1. 测试要点
2. 理解测量对象
3. MySQL测试
四、常见优化措施
1. 有得必有失
2. 优化各有不同
3. 正则表达式的应用原理
4. 应用之前的优化措施
(1)编译缓存
(2)集成式处理中的编译缓存
(3)程序式处理中的编译缓存
(4)面向对象式处理中的编译缓存
(5)预查必须字符 / 子字符串优化
(6)长度判断优化
5. 通过传动装置进行优化
(1)字符串起始 / 行锚点优化
(2)隐式锚点优化
(3)字符串结束 / 行锚点优化
(4)开头字符 / 字符组 / 子串识别优化
(5)内嵌文字字符串检查优化
(6)长度识别传动优化
6. 优化正则表达式本身
(1)文字字符串连接优化
(2)化简量词优化
(3)消除无必要的括号
(4)消除不需要的字符组
(5)忽略优先量词之后的字符优化
(6)“过度”回溯检测
(7)避免指数级(超线性 super-linear)匹配
(8)使用占有优先量词削减状态
(9)量词等价转换
(10)需求识别
五、提高表达式速度的诀窍
1. 常识性优化
(1)避免重新编译
(2)使用非捕获型括号
(3)不要滥用括号
(4)不要滥用字符组
(5)使用起始锚点
2. 将文字文本独立出来
(1)从量词中“提取”必须的元素
(2)“提取”多选结构开头的必须元素
3. 将锚点独立出来
(1)在表达式前面独立出 ^ 和 \G
(2)在表达式末尾独立出 $
4. 忽略优先还是匹配优先?具体情况具体分析
5. 拆分正则表达式
6. 模拟开头字符识别
7. 使用固化分组和占有优先量词
8. 主导引擎的匹配
(1)将最可能匹配的多选分支放在前头
(2)将结尾部分分散到多选结构内
9. 消除循环
(1)方法1:依据经验构建正则表达式
(2)方法2:自顶向下的视角
(3)匹配主机名
(4)使用固化分组和占有优先量词
(5)简单的消除循环的例子
10. 流畅运转的正则表达式
总的来说,提高正则表达式效率的关键在于彻底理解回溯背后的过程,掌握技巧来避免可能的回溯。
一、典型示例
首先来看一个真正体现回溯和效率的重要性的例子。用 "(\\.|[^\\"])*" 来匹配双引号字符串,其中容许出现转义的双引号。这个表达式没有错,但如果使用 NFA 引擎,对每个字符都应用多选结构的效率就会很低。对字符串中每个“正常”(非反斜杠、非双引号)的字符来说,引擎需要测试 '\\.',遇到失败后回溯,最终由 '[^\\"]' 匹配。可以做些改动来加速匹配速度。
1. 稍加修改——先迈最好使的腿
对于一般的双引号字符串来说,普通字符的数量比转义字符要多,一个简单改动就是调换两个多选分支的顺序,把 '[^\\"]' 放到 '\\.' 之前。这样,只有在字符串中遇到转义字符时才会按照多选结构进行回溯。RegexBuddy 工具中两个表达式匹配目标字符串 "2\"x3\" likeness" 的过程如下图所示。
图1:多选分支排列顺序的影响(传统型 NFA)
左边是 "(\\.|[^\\"])*" 的匹配过程,共执行了 32 次测试和 14 次回溯。右边是 "([^\\"]|\\.)*" 的匹配过程,共执行了 22 次测试和 4 次回溯。两个反斜杠导致了两次分支回溯,最后的双引号引起了两次回溯,第一次是因为与分支 [^\\"] 不匹配导致分支回溯,第二次是星号无法匹配引起的量词回溯。此时所有的多选分支都匹配失败,整个多选结构无法匹配。每行执行一次测试(第22行除外,该行显示 * 量词回溯到的位置,并不进行测试)。
要从下面两个方面思考这个修改:
- 哪种引擎从中获益?传统型 NFA,或是 POSIX NFA,或是两者?
- 什么情况下,这种修改带来的收益最大?在文本能够匹配时,无法匹配时,还是所有时候?
第一点,这种改动对 POSIX NFA 没有影响。因为它最终必须尝试正则表达式的每一种可能,多选分支的顺序其实不重要。但对于传统型 NFA 来说,这样提高速度的多选分支重排序是有利的,因为引擎一旦找到匹配结果就会停下来。
第二点,只有匹配成功时才会加快速度。只有在尝试所有的可能之后,NFA 才可能失败。如果确实不能匹配,每种可能都会被尝试,这种情况下排列顺序没有影响。
下表列出了若干种情况下所进行的测试和回溯的次数,数字越小越好:
目标字符串 | 传统型 NFA | POSIX NFA | ||||
"(\\.|[^\\"])*" | "([^\\"]|\\.)*" | 两个表达式情况相同 | ||||
测试 | 回溯 | 测试 | 回溯 | 测试 | 回溯 | |
"2\"x3\" likeness" | 32 | 14 | 22 | 4 | 48 | 30 |
"makudonarudo" | 28 | 14 | 16 | 2 | 40 | 26 |
"very...99 more chars...long" | 218 | 109 | 111 | 2 | 325 | 216 |
"No \"match\" here | 124 | 86 | 124 | 86 | 124 | 86 |
表1
两个表达式在 POSIX NFA 中的情况是一样的,而修改之后,传统型 NFA 的表现提升了(减少了回溯)。在不能匹配的情况下(最后一行),因为两种引擎都必须尝试所有的可能,结果就是一样的。
2. 效率 vs 准确性
为提高效率修正正则表达式时最需要考虑的问题是,改动是否会影响匹配的准确性。像上面那样重新安排多选分支的顺序,只有在排序与匹配结果无关时才不会影响准确性。看一个有缺陷的例子:用正则表达式 "(\\.|[^"])*" 匹配字符串 "You need a 2\"3\" photo"。经过 46 次测试和 21 次回溯,结果匹配到了整个字符串。
如果为了提高效率交换多选分支,把 [^"] 放在前面,的确提高了效率,一共只有 4 次回溯 27 次测试。但结果得到的是两个匹配的字符串:"You need a 2\" 和 " photo"。造成问题的根本原因是两个分支匹配的字符有重叠,都能匹配反斜杠。
所以,在关注效率的时候,万不可忘记准确性。对于多选分支,最好先保证每个分支互斥,这样多选分支的顺序与匹配结果无关,准确性得到保证,然后再考虑性能。
3. 继续前进——限制匹配优先的作用范围
从图1可以看出,在任意正则表达式中,星号会对每个普通字符进行迭代,重复进入-退出多选结构和括号。这需要成本,如果可能就要避免这些额外处理。
考虑 [^\\"] 匹配“普通”字符(非引号,非反斜线)的情况,使用 [^\\"]+ 会在 (...)* 的一次迭代中读入尽可能多的字符。对没有转义字符的字符串来说,这样会一次读入整个字符串。于是就几乎不会进行回溯,也就是把星号迭代的次数减少到最小。
图2 展示了传统型 NFA 上应用这个例子的情况。左边是 "(\\.|[^\\"]+)*" 的匹配过程,比较 图1 中的 "(\\.|[^\\"])*",与多选结构相关的回溯和星号迭代都减少了。右边是 "([^\\"]+|\\.)*" 的匹配过程,可以看到结合重排序技巧,这种修改也会带来更多收益。
图2:添加加号的结果(传统型 NFA)
新增的加号大大减少了多选结构回溯的次数,以及星号迭代的次数。星号量词作用于括号内的子表达式,每次迭代都需要进入然后再退出括号,这都需要成本,因为引擎需要记录括号内的子表达式匹配的文本。
4. “指数级”匹配
对于 POSIX NFA 来说,添加加号的改动不过是一场还未爆发的灾难。如果用 "([^\\"]+|\\.)*" 匹配 表1 中的 "very...99 more chars...long",需要超过 3 亿亿亿次回溯。简单地说,发生这种情况的原因在于,这个表达式中某个元素受加号限定的同时,还受括号外的星号限定,无法区分哪个量词控制哪个特殊的字符。这种不确定性就是症结。
没有加星号时,[^\\"] 是星号的约束对象,真正的 ([^\\"])* 能够匹配的字符是有限的。它先匹配一个字符,然后匹配下一个字符,如此继续,最多就是匹配目标文本中的每个字符。它也可能无法匹配目标字符串中的所有字符(引发回溯),不过充其量,匹配字符的个数与目标字符串的长度成线性关系。目标字符串越长,可能的工作量也越大。
但是,对正则表达式 ([^\\"]+)* 来说,加号和星号二者分割(divvy up)字符串的可能性是成指数形式增长的。如果目标字符串是 makudonarudo,是星号会迭代 12 次,每次迭代中 [^\\"]+ 匹配一个字符?还是星号迭代 3 次,内部的 [^\\"]+ 分别匹配了 5、3、4 个字符?或者是星号迭代了 4 次,内部[^\\"]+ 匹配了 2、2、5、3 个字符?还是其他......
对长度为 12 的字符串存在 4096 种可能,字符串中的每个字符都存在两种可能。POSIX NFA 在给出结果之前必须尝试所有可能,这就是“指数级”匹配的来历,也叫“超线性(super-linear)”。无论叫什么名字,终归都是大量的回溯,长度为 n 的字符串,回溯的次数是 2^(n+1),独立测试的次数为 2^(n+1)+2^n。
POSIX NFA 和传统型 NFA 的主要差别在于,传统型 NFA 在遇到第一个完整匹配可能时会停止。如果没有完整匹配,即使是传统型 NFA 也需要尝试所有的可能,在找到之前。即使是 表1 中出现的 "No \"match\" here 这样短的字符串,在报告失败之前,也需要尝试 8192 种可能。
在正则引擎忙于尝试这些数量庞大的可能时,整个程序看起来好像“锁死(lock up)”了。可以用这类正则表达式测试引擎的类型:
- 如果其中的某个表达式,即使不能匹配,也能很快给出结果,那可能就是 DFA。
- 如果只有在能够匹配时才很快出结果,那就是传统型 NFA。
- 如果总是很慢,那就是 POSIX NFA。
第一个判断中使用了“可能”这个词,因为经过高级优化的 NFA 没准能检测并且避免这些指数级的无休止(neverending)匹配。同样,后面也会见到各种方法来改进或重写这些表达式,加快它们匹配或报错的速度。
如果排除某些高级优化的影响,就能根据正则表达式的相对性能判断引擎的类型。传统型 NFA 是使用最广泛的引擎,而且它很容易识别。首先,如果支持忽略优先量词,基本就能确定是传统型 NFA。忽略优先量词是 DFA 不支持的,在 POSIX NFA 中也没有意义。为了确认这一点,只需要简单地用正则表达式 nfa|nfa not 来匹配字符串 nfa not,如果只有 nfa 匹配了,这就是传统型 NFA;如果整个 nfa not 都能匹配,则此引擎要么是 POSIX NFA,要么是 DFA。
MySQL的正则引擎是传统型 NFA:
mysql> set @r:='nfa|nfa not';
Query OK, 0 rows affected (0.00 sec)
mysql> set @s:='nfa not';
Query OK, 0 rows affected (0.00 sec)
mysql> select regexp_count(@s, @r, '') c, regexp_extract(@s, @r, '') s;
+------+------+
| c | s |
+------+------+
| 1 | nfa |
+------+------+
1 row in set (0.00 sec)
某些情况下,DFA 与 POSIX NFA 的区别是很明显的。DFA 不支持捕获型括号(capturing parentheses)和反向引用(backreferences)。这一点有助于判断,不过,也存在同时使用两种引擎的混合系统,在这种系统中,如果没有使用捕获型括号,就会使用 DFA。
下面这个简单的测试能说明很多问题,用 X(.+)+X 来匹配形如 =XX====================== 的字符串。如果执行需要花很长时间,就是 NFA。如果上一项测试显示不是传统型 NFA,那么它肯定是 POSIX NFA。如果执行时间很短,就是 DFA,或者是支持某些高级优化的 NFA。如果显示堆栈溢出(stack overflow),或者是超时退出,那么它是 NFA 引擎。
mysql> set @r:='X(.+)+X';
Query OK, 0 rows affected (0.00 sec)
mysql> set @s:='=XX======================';
Query OK, 0 rows affected (0.00 sec)
mysql> select regexp_count(@s, @r, '') c, regexp_extract(@s, @r, '') s;
ERROR 3699 (HY000): Timeout exceeded in regular expression match.
二、全面考察回溯
先来看个例子,把正则表达式 ".*" 应用到下面的文本:
The name "McDonald's" is said "makudonarudo" in Japanese
匹配过程如 图3 所示。
图3:".*" 的成功匹配过程
1. 传统 NFA 的匹配过程
正则表达式会从字符串的起始位置开始依次尝试每个字符,但是因为开头的引号无法匹配,此后的字符也无法匹配,直到尝试进行到第一个双引号的位置。接着尝试表达式的其他部分,但是传动装置知道如果这种尝试不成功,整个表达式可以从下一个位置开始尝试。
然后 .* 匹配直到字符串末尾,此时点号无法匹配,所以星号停止迭代。因为 .* 匹配成功可以不需要任何字符,所以此过程中引擎记录了 46 个状态供回溯。现在 .* 停止了,引擎从最后保存的状态开始回溯,即在字符串的末尾尝试匹配表示结束的双引号。这里双引号同样无法匹配,所以尝试仍然失败。然后引擎继续回溯、尝试,结果同样是无法匹配。
引擎倒过来尝试(最后保存的状态排在最先)保存的状态。在进行了多次尝试之后到达了 ...arudo" 位置,此时匹配成功,于是在此位置得到全局匹配:
"McDonald's" is said "makudonarudo"
这就是传统型 NFA 的匹配过程,剩下的未使用状态将被抛弃,报告匹配成功。
2. POSIX NFA 需要更多处理
POSIX NFA 的匹配是“到目前为止最长的匹配”,但是仍然需要尝试所有保存的状态,确认是否存在更长的匹配。对本例来说,第一次找到的匹配就是最长的,但正则引擎需要确认这一点。
3. 无法匹配时必须进行的工作
还需要分析无法匹配时的情况。".*"! 无法匹配范例文本。但是它在匹配过程中仍然会进行许多工作,图4 说明了这些。
图4:".*"! 匹配失败的经过
图4 中的整个尝试序列是传统型 NFA 和 POSIX NFA 都必须经历的:如果无法匹配,传统型 NFA 必须进行的尝试与 POSIX NFA 一样多。因为从开始的 A 到结束的 I 的所有尝试都不存在匹配,传动装置必须启动驱动过程开始新一轮尝试。从 J、Q、V 开始的尝试看来有可能匹配成功,但结果都与从 A 开始的尝试一样。最终到 Y,不存在继续尝试的途径,所以整个匹配宣告失败。如图4 所示,得到这个结果花费了许多工夫。
4. 看清楚一点
把点号换成 ^" 来做个比较。如果使用 "[^"]*"!,[^"]* 匹配的内容就不能包括双引号,减少了匹配和回溯。图5说明了尝试失败的过程。
图5:"[^"]*"! 无法匹配
从图中可以看到,回溯的次数大大减少了。如果这个结果满足需求,减少的回溯就是有益的伴随效应(side effect)。
5. 多选结构的代价很高
多选结构或许是回溯的主要原因。比较 u|v|w|x|y|z 和 [uvwxyz] 匹配下面的字符串:
The name "McDonald's" is said "makudonarudo" in Japanese
不同实现方式的效率可能存在差异,但总的来说字符组的效率要比相应的多选结构高。字符组一般只是进行简单的测试,所以 [uvwxyz] 只需要进行 34 次尝试就能匹配。
如果使用 u|v|w|x|y|z,则需要在每个位置进行 6 次回溯,在得到同样结果之前总共有 204 次回溯。当然,并不是每个多选结构都可以替换为字符组,即使可以,也不见得会这么简单。不过在某些情况下使用的技巧,能够大大减少与匹配所须的多选结构相关的回溯。
理解回溯可能是学习 NFA 效率中最重要的问题,但所有的问题不止于此。正则引擎的优化措施能够大大提升效率。
三、性能测试
1. 测试要点
基本的性能测试就是记录程序运行的时间:先取系统时间,运行程序,再取系统时间,计算两者的差,就是程序运行的时间。举个例子,比较 ^(a|b|c|d|e|f|g)+$ 和 ^[a-g]+$。下面是简单的 Perl 程序:
use Time::HiRes 'time'; # 这样 time() 的返回值更加精确
$StartTime = time();
"abababdedfg" =~ m/^(a|b|c|d|e|f|g)+$/;
$EndTime = time();
printf("Alternation takes %.3f seconds. \n", $EndTime - $StartTime);
$StartTime = time();
"abababdedfg" =~ m/^[a-g]+$/;
$EndTime = time();
printf("Character class takes %.3f seconds. \n", $EndTime - $StartTime);
它很简单,但是在进行性能测试时要记住几点:
- 只记录“真正关心的(interesting)”处理时间。尽可能准确地记录“处理”时间,尽可能避免“非处理时间”的影响。如果在开始前必须进行初始化或其他准备工作,在它们之后开始计时;如果需要收尾工作,在计时停止后进行这些工作。
- 进行“足够多”的处理。通常,测试需要的时间是相当短暂的,而计算机时钟的精度不够,无法给出有意义的数值。
在我的机器上运行这个 Perl 程序,结果是:
Alternation takes 0.000 seconds.
Character class takes 0.000 seconds.
如果程序运行的时间太短,就运行多次,来保证“足够多”的工作。这里的“足够多”取决于系统时钟的精度,大多数系统能够精确到 1/100s,这样即使程序只需要 0.5s,也能取得有意义的结果。
- 进行“准确的”处理。进行 1000 万次快速操作需要在负责计时的代码块中累加 1000 万次计数器。如果可能,最好的办法是增加真正的处理部分的比例,而不增加额外的开销。在 Perl 的例子中,正则表达式应用的文本相当短:如果应用到长得多的字符串,在每次循环中所作的“真正的”处理也会多一些。
考虑到这些因素,可以得出以下的程序:
use Time::HiRes 'time'; # 这样 time() 的返回值更加精确
$TimesToDo = 1000; # 设定重复次数
$TestString = "abababdedfg" x 1000; # 生成长字符串
$Count = $TimesToDo;
$StartTime = time();
while ($Count-- > 0) {
$TestString =~ m/^(a|b|c|d|e|f|g)+$/;
}
$EndTime = time();
printf("Alternation takes %.3f seconds. \n", $EndTime - $StartTime);
$Count = $TimesToDo;
$StartTime = time();
while ($Count-- > 0) {
$TestString =~ m/^[a-g]+$/;
}
$EndTime = time();
printf("Character class takes %.3f seconds. \n", $EndTime - $StartTime);
$TestString 和 $Count 的初始化在计算之前($TestString 使用了 Perl 提供的 x 操作符进行初始化,它表示将左边的字符串重复右边的次数)。在我的机器上,使用 Perl 5.10.1 运行的结果是:
Alternation takes 1.473 seconds.
Character class takes 0.012 seconds.
所以,对于这个例子来说,字符组要比多选结构快 123 倍左右。此测试应该执行多次,选取最短的时间,以减少后台系统活动的影响。
2. 理解测量对象
把初始化程序改为下面这样,会得到更有意思的结果:
$TimesToDo = 1000000;
$TestString = "abababdedfg";
现在,测试字符串只是上面的长度的 1/1000,而测试需要进行 1000000 次。每个正则表达式测试和匹配的字符总数并没有变化,因此从理论上讲,“工作量”应该没有变化。不过结果却大不相同:
Alternation takes 1.863 seconds.
Character class takes 0.247 seconds.
两个时间都比之前的要长。原因是新增的“非处理”开销——对 $Count 的检测和更新,以及建立正则引擎的时间,现在的次数是以前的 1000 倍。
对于字符组测试来说,新增的开销花费了大约 0.24s 的时间,而多选结构则增加了 0.39 秒。多选结构测试时间变化大的主要是因为捕获型括号,在每次测试之前和之后,它们都需要额外处理,这样的操作要多 1000 倍。
3. MySQL测试
下面是 MySQL 8.0.16 上的测试存储过程。
delimiter //
create procedure sp_test_regexp(s varchar(100), r varchar(100), c int)
begin
set @s:=1;
set @s1:='';
while @s<=c do
set @s1:=concat(@s1,s);
set @s:=@s+1;
end while;
select now(3) into @startts;
select regexp_like(@s1, r, 'c') into @ret;
select now(3) into @endts;
select @ret,@startts,@endts,timestampdiff(microsecond,@startts,@endts)/1000 diff_ts;
end;
//
delimiter ;
初始化字符串和正则表达式:
set @str:='abababdedfg';
set @reg1:='^(a|b|c|d|e|f|g)+$';
set @reg2:='^[a-g]+$';
第一次循环 1000 次,结果如下:
mysql> call sp_test_regexp(@str, @reg1, 1000);
+------+-------------------------+-------------------------+---------+
| @ret | @startts | @endts | diff_ts |
+------+-------------------------+-------------------------+---------+
| 1 | 2023-07-10 15:22:44.921 | 2023-07-10 15:22:44.922 | 1.0000 |
+------+-------------------------+-------------------------+---------+
1 row in set (0.01 sec)
Query OK, 0 rows affected (0.01 sec)
mysql> call sp_test_regexp(@str, @reg2, 1000);
+------+-------------------------+-------------------------+---------+
| @ret | @startts | @endts | diff_ts |
+------+-------------------------+-------------------------+---------+
| 1 | 2023-07-10 15:22:44.933 | 2023-07-10 15:22:44.933 | 0.0000 |
+------+-------------------------+-------------------------+---------+
1 row in set (0.01 sec)
Query OK, 0 rows affected (0.01 sec)
多选分支用了 1 毫秒,字符组用了 0 毫秒。对比不是很明显,继续增加循环次数。第二次循环 10000 次,结果如下:
mysql> call sp_test_regexp(@str, @reg1, 10000);
ERROR 3699 (HY000): Timeout exceeded in regular expression match.
mysql> call sp_test_regexp(@str, @reg2, 10000);
+------+-------------------------+-------------------------+---------+
| @ret | @startts | @endts | diff_ts |
+------+-------------------------+-------------------------+---------+
| 1 | 2023-07-10 15:25:25.918 | 2023-07-10 15:25:25.919 | 1.0000 |
+------+-------------------------+-------------------------+---------+
1 row in set (0.28 sec)
Query OK, 0 rows affected (0.28 sec)
多选分支报错,字符组用了 1 毫秒。报错是因为多选分支匹配时超过了系统变量 regexp_time_limit 的限制。该变量限制匹配引擎执行的最大允许步数,从而间接影响执行时间(通常数量级为毫秒),缺省值为 32。加大该变量的值继续测试。
第三次测试:
mysql> set global regexp_time_limit=3200;
Query OK, 0 rows affected (0.00 sec)
mysql> call sp_test_regexp(@str, @reg1, 10000);
+------+-------------------------+-------------------------+---------+
| @ret | @startts | @endts | diff_ts |
+------+-------------------------+-------------------------+---------+
| 1 | 2023-07-10 15:33:47.033 | 2023-07-10 15:33:47.046 | 13.0000 |
+------+-------------------------+-------------------------+---------+
1 row in set (0.29 sec)
Query OK, 0 rows affected (0.29 sec)
mysql> call sp_test_regexp(@str, @reg2, 10000);
+------+-------------------------+-------------------------+---------+
| @ret | @startts | @endts | diff_ts |
+------+-------------------------+-------------------------+---------+
| 1 | 2023-07-10 15:33:47.318 | 2023-07-10 15:33:47.319 | 1.0000 |
+------+-------------------------+-------------------------+---------+
1 row in set (0.27 sec)
这次多选分支用时 13 毫秒,字符组用了 1 毫秒,两者相差 13 倍。增加循环次数继续测试。
第四次测试:
mysql> call sp_test_regexp(@str, @reg1, 100000);
ERROR 3698 (HY000): Overflow in the regular expression backtrack stack.
mysql> call sp_test_regexp(@str, @reg2, 100000);
+------+-------------------------+-------------------------+---------+
| @ret | @startts | @endts | diff_ts |
+------+-------------------------+-------------------------+---------+
| 1 | 2023-07-10 15:36:42.548 | 2023-07-10 15:36:42.559 | 11.0000 |
+------+-------------------------+-------------------------+---------+
1 row in set (27.13 sec)
多选分支报错,字符组用了 11 毫秒。报错是因为多选分支匹配时超过了系统变量 regexp_stack_limit 的限制。该变量用于 regexp_like() 或类似的正则表达式函数执行匹配时,回溯堆栈可用的最大内存,以字节为单位,缺省值为 8000000。加大该变量的值继续测试。
第五次测试:
mysql> set global regexp_stack_limit=800000000;
Query OK, 0 rows affected (0.00 sec)
mysql> call sp_test_regexp(@str, @reg1, 100000);
+------+-------------------------+-------------------------+----------+
| @ret | @startts | @endts | diff_ts |
+------+-------------------------+-------------------------+----------+
| 1 | 2023-07-10 15:41:48.045 | 2023-07-10 15:41:48.190 | 145.0000 |
+------+-------------------------+-------------------------+----------+
1 row in set (27.24 sec)
Query OK, 0 rows affected (27.24 sec)
mysql> call sp_test_regexp(@str, @reg2, 100000);
+------+-------------------------+-------------------------+---------+
| @ret | @startts | @endts | diff_ts |
+------+-------------------------+-------------------------+---------+
| 1 | 2023-07-10 15:42:15.307 | 2023-07-10 15:42:15.317 | 10.0000 |
+------+-------------------------+-------------------------+---------+
1 row in set (27.12 sec)
这次多选分支用时 145 毫秒,字符组用了 10 毫秒,两者相差 14.5 倍。
四、常见优化措施
正则表达式实现的优化通常有两种办法:
- 加速某些操作。某些类型的匹配,例如 \d+,极为常见,引擎可能对此有特殊的处理方案,执行速度比通用的处理机制要快。
- 避免冗余操作。如果引擎认为,对于产生正确结果来说,某些特殊的操作是不必要的,或者某些操作能够应用到比之前更少的文本,忽略这些操作能够节省时间。例如,一个以 \A 开头的正则表达式只有在字符串的开头位置才能匹配,如果在此处无法匹配,传动装置不会徒劳地在其他位置进行无谓的尝试。
1. 有得必有失
在优化所需的时间,节省的时间,以及更重要的因素——优化的可能性之间,存在互相制约的关系。只有在检测优化措施是否可行所需的时间少于节省下来的匹配时间的情况下,优化才是有益的。
来看一个例子。表达式 \b\B(某个位置既是单词分隔符又不是单词分隔符)是不可能匹配的。如果引擎发现提供的表达式包含 \b\B 就知道整个表达式都无法匹配,因而不会进行任何匹配操作。它会立刻报告匹配失败。如果匹配的文本很长,节省的时间就非常可观。
不过正则引擎都没有进行这样的优化。因为某个包含 \b\B 的正则表达式很可能可以匹配,所以引擎必须做一些额外的工作来预先确认。虽然在某些情况下这样做可以节省大量时间,但其他情况下速度提高的代价高得多。 \b\B 可以用来保证正则表达式的某个部分匹配失败,例如把 \b\B 插入 ...(this|this other)... 保证第一个多选分支失败。
mysql> select regexp_substr('this this other','this|this other');
+----------------------------------------------------+
| regexp_substr('this this other','this|this other') |
+----------------------------------------------------+
| this |
+----------------------------------------------------+
1 row in set (0.00 sec)
mysql> select regexp_substr('this this other','this\\b\\B|this other');
+----------------------------------------------------------+
| regexp_substr('this this other','this\\b\\B|this other') |
+----------------------------------------------------------+
| this other |
+----------------------------------------------------------+
1 row in set (0.00 sec)
2. 优化各有不同
在进行各种优化措施时,务必记住一点“优化各有不同(everyone's lunch is different)”,不同引擎可能以不同的方式来优化。对某个正则表达式进行细微的改动,在某个实现方式中可能会带来速度的大幅提升,而在另一个实现方式中大大降低速度。
3. 正则表达式的应用原理
正则表达式应用到目标字符串的过程大致分为下面几步:
(1)正则表达式编译:检查正则表达式的语法正确性,如果正确,就将其编译为内部形式(internal form)。
(2)传动开始:传动装置将正则引擎“定位”到目标字符串的起始位置。
(3)元素检测:引擎开始测试正则表达式和文本,依次测试正则表达式的各个元素(component)。
- 相连元素,例如 Subject 中的 S、u、b、j、e 等等,会一次尝试,只有当某个元素匹配失败时才会停止。
- 量词修饰的元素,控制权在量词(检查量词是否应该继续匹配)和被限定的元素(测试能否匹配)之间轮换。
- 控制权在捕获型括号内外进行切换会带来一些开销。括号内的表达式匹配的文本必须保留,这样才能通过 $1 来引用。因为一对括号可能属于某个回溯分支,括号的状态就是用于回溯的状态的一部分,所以进入和退出捕获型括号时需要修改状态。
(4)寻找匹配的结果:如果找到一个匹配结果,传统型 NFA 会“锁定”在当前状态,报告匹配成功。而对 POSIX NFA 来说,如果这个匹配是迄今为止最长的,它会记住这个可能的匹配,然后从可用的保存状态继续下去。保存的状态都测试完毕之后返回最长的匹配。
(5)传动装置的驱动过程:如果没有找到匹配,传动装置就会驱动引擎,从文本中的下一个字符开始新一轮的尝试(回到步骤3)。
(6)匹配彻底失败:如果从目标字符串的每一个字符(包括最后一个字符之后的位置)开始的尝试都失败了,就会报告匹配彻底失败。
下面几节讲解高级的实现方式如何减少这些处理,以及如何应用这些技巧。
4. 应用之前的优化措施
优秀的正则引擎实现方式能够在正则表达式实际应用之前就进行优化,它有时甚至能迅速判断出,某个正则表达式无论如何也无法匹配,所以根本不必应用这个表达式。
(1)编译缓存
正则表达式使用之前要做的第一件事情是进行语法检查,如果没有问题则编译为内部形式。编译之后的内部形式能用来检查各种字符串,但是对下面这段程序的情况如何呢?
while (...) {
if ($line =~ m/^\s*$/) ...
if ($line =~ m/^Subject: (.*)/) ...
if ($line =~ m/^Date: (.*)/) ...
if ($line =~ m/^Reply-To: (\S+)/) ...
if ($line =~ m/^From: (\S+) \(([^()]*)\)/) ...
}
显然,每次循环都要重新编译所有正则表达式很浪费时间。相反,在第一次编译之后就把内部形式保存或缓存下来,在此后的循环中重复使用它们,显然会提高速度,只是要消耗些内存。具体做法取决于应用程序提供的正则表达式处理方式,有集成式、程序式和面向对象式三种。
MySQL 的正则属于集成式编译缓存,正则匹配功能是通过函数提供的。如果在存储过程或自定义函数中调用正则函数,本身就是预编译好的。如果用程序访问数据库,例如 Java,可以利用 MySQL JDBC 进行预编译。
(2)集成式处理中的编译缓存
Perl 和 awk 使用的就是集成式处理方法,非常容易进行编译缓存。从内部来说,每个正则表达式都关联到代码的某一部分,第一次执行时在编译结果与代码之间建立关联,下次执行时只需要引用即可。这样最节省时间,代价就是需要一部分内存来保存缓存的表达式。
变量插值功能(variable interpolation,即将变量的值作为正则表达式的一部分,如 MySQL 中的动态SQL)可能会给缓存造成麻烦。例如对 m/^Subject: \Q$DesiredSubject\E\s*$/ 来说,每次循环中正则表达式的内容可能会发生变化,因为它取决于插值变量,而这个变量的值可能会变化。如果每次都会不同,那么正则表达式每次都需要编译,完全不能重复利用。折中的优化措施就是检查插值后的结果(也就是正则表达式的具体值),只有当具体值发生变化时才重新编译。
(3)程序式处理中的编译缓存
在集成式处理中,正则表达式的使用与其在程序中所处的位置相关,所以再次执行这段代码时,编译好的正则表达式就能够缓存和重复使用。但是,程序式处理中只有通用的“应用此表达式”的函数。也就是说,编译形式并不与程序的具体位置相连,下次调用此函数时,正则表达式必须重新编译。从理论上说是如此,但是在实际应用中,禁止尝试缓存的效率无疑很低。相反,优化通常是把最近使用的正则表达式模式(regex pattern)保存下来,关联到最终的编译形式。
调用“应用此表达式”函数后,作为参数的正则表达式模式会与保存的正则表达式相比较,如果存在于缓存中,就使用缓存的版本。如果没有,就直接编译这个正则表达式,将其存入缓存。如果缓存有容量限制,可能会替换一个旧的表达式。如果缓存用完了,就必须放弃(thrown out)一个编译形式,通常是最久未使用的那个。
GUN Emacs 的缓存能够保存最多 20 个正则表达式,Tcl 能保存 30 个。PHP 能保存四千多个。.NET Framework 在默认情况下能保存 15 个表达式,不过数量可以动态设置,也可以禁止此功能。
(4)面向对象式处理中的编译缓存
在面向对象式处理中,正则表达式何时编译完全由程序员决定。正则表达式的编译是用户通过 New Regex、re.compile 和 Pattern.compile(分别对应 .NE、Python 和 java.util.regex)之类的构造函数来进行的。编译在正则表达式实际应用之前完成,但是它们也可以更早完成,有时候可以在循环之前,或者是程序的初始化阶段,然后可以随意使用。
在面向对象式处理中,程序员通过对象析构函数抛弃(thrown out)编译好的正则表达式。及时抛弃不需要的编译形式能够节省内存。
(5)预查必须字符 / 子字符串优化
相比正则表达式的完整应用,在字符串中搜索某个或一串字符是更加“轻量级”的操作,所以某些系统会在编译阶段做些额外的分析,判断是否存在成功匹配必须的字符或者字符串。在实际应用正则表达式之前,在目标字符串中快速扫描,检查所需的字符或字符串,如果不存在,根本就不需要进行任何尝试。
例如,^Subject: (.*) 的 ‘Subject: ’是必须出现的。程序可以检查整个字符串,或者使用 Boyer-Moore 搜索算法(一种很快的检索算法,字符串越长效率越高)。即便进行逐个字符检查也可以提高效率。选择目标字符串中不太可能出现的字符(如‘Subject: ’中的‘t’之后的‘:’)能够进一步提高效率。
正则引擎必须能识别出,^Subject: (.*) 的一部分是固定的文本字符串。对于任意匹配来说,识别出 this|that|other 中的‘th’是必须的,需要更多的功夫,而且大多数正则引擎不会这样做。此问题的答案并不是黑白分明的,某个实现方式或许不能识别出‘th’是必须的,但能够识别出‘h’和‘t’都是必须的,所以至少可以检查一个字符。
不同的应用程序能够识别出的必须字符和字符串有很大差别。许多系统会受到多选结构的干扰,在这种系统中,使用 th(is|at) 的表现好于 this|that。
(6)长度判断优化
^Subject: (.*) 能匹配文本的长度是不固定的,但至少必须包含 9 个字符。所以,如果目标字符串的长度小于 9 则根本不必尝试。当然需要匹配的字符更长,优化的效果才更明显,例如 :\d{79}:(至少需要 81 个字符)。
5. 通过传动装置进行优化
即使正则引擎无法预知某个字符串能否匹配,也能够减少传动装置真正应用正则表达式的位置。
(1)字符串起始 / 行锚点优化
这种优化措施能够推断,任何以 ^ 开头的正则表达式只能在 ^ 能够匹配的情况下才可能匹配,所以只需要在这些位置应用即可。任何使用此优化的实现方式都必须能够识别:如果 ^(this|that) 匹配成功,^ 必须能够匹配。但许多实现方式不能识别 ^this|^that,此时用 ^(this|that) 或者 ^(?:this|that) 能够提高匹配的速度。
同样的优化措施还对 \A 有效,如果匹配多次进行,对 \G 也有效。
(2)隐式锚点优化
能使用此种优化的引擎知道,如果正则表达式以 .* 或 .+ 开头,而且没有一个全局性多选结构(global alternation),则可以认为此正则表达式的开头有一个看不见的 ^。这样就能适用上一节的“字符串起始/行锚点优化”,节省大量的时间。
更聪明的系统能够认识到,即使开头的 .* 或 .+ 在括号内,也可以进行同样的优化,但是在遇到捕获型括号时必须小心。例如,(.+)X\1 期望匹配的是字符串在‘X’两侧是相同的,添加 ^ 就不能匹配 ‘1234X2345’。
mysql> select regexp_substr('1234X2345','(.+)X\\1');
+---------------------------------------+
| regexp_substr('1234X2345','(.+)X\\1') |
+---------------------------------------+
| 234X234 |
+---------------------------------------+
1 row in set (0.00 sec)
(3)字符串结束 / 行锚点优化
这种优化遇到末尾 $ 或者其他结束锚点(\Z、\z等)的正则表达式时,能够从字符串末尾倒数若干字符的位置开始尝试匹配。例如正则表达式 regex(es)?$ 匹配只可能从字符串末尾倒数的第 8 个字符开始,所以传动装置能够跳到那个位置,略过目标字符串中许多可能的字符。
在这里说 8 个字符,而不是 7 个,因为在许多流派中,$ 能够匹配字符串末尾的换行符之前的位置。
(4)开头字符 / 字符组 / 子串识别优化
这是“预查必须字符 / 子字符串优化”的更一般的版本,这种优化使用同样的信息(正则表达式的任何匹配必须以特定字符或文字子字符串开头),容许传动装置进行快速子字符串检查,所以它能够在字符串中合适的位置应用正则表达式。例如,this|that|other 只能从 [ot] 的位置开始匹配,所以传动装置预先检查字符串中的每个字符,只在可能匹配的位置进行应用,这样能节省大量的时间。能够预先检查的子串越长,“错误的开始位置”就越少。
(5)内嵌文字字符串检查优化
这有点类似初始字符串识别优化,不过更加高级,它针对的是在匹配中固定位置出现的文字字符串。如果正则表达式是 \b(perl|java)\.regex\.info\b,那么任何匹配中都要有‘.regex.info’,所以智能的传动装置能够使用高速的 Boyer-Moore 字符串检索算法寻找‘.regex.info’,然后往前数 4 个字符,开始实际应用正则表达式。
一般来说,这种优化只有在内嵌文字字符串与表达式起始位置的距离固定时才能进行。因此它不能用于 \b(vb|java)\.regex\.info\b,这个表达式虽然包含文字字符串,但此字符串与匹配文本起始位置的距离是不确定的(2 个或 4 个字符)。这种优化同样也不能用于 \b(\w+)\.regex\.info\b,因为 (\w+) 可能匹配任意数目的字符。
(6)长度识别传动优化
此优化与“长度判断优化”直接相关,如果当前位置距离字符串末尾的长度小于成功匹配所需的最小长度,传动装置会停止匹配尝试。
6. 优化正则表达式本身
(1)文字字符串连接优化
也许最基本的优化就是,引擎可以把 abc 当做“一个元素”,而不是三个元素“a,然后是 b,然后是 c”。如果能够这样,整个部分就可以作为匹配迭代的一个单元,而不需要进行三次迭代。
(2)化简量词优化
约束普通元素,例如文字字符串或者字符组的加号、星号之类的量词,通常需要经过优化,避免普通 NFA 引擎的大部分逐步处理开销(step-by-step overhead)。正则引擎内的主循环必须通用(general),能够处理引擎支持的所有结构。而在程序设计中,“通用”意味着速度慢,所以此种优化把 .* 之类的简单量词作为一个“整体”,正则引擎便不必按照通用的办法处理,而使用高速的,专门化的处理程序。这样,通用引擎就绕过(short-circuit)了这些结构。
举例来说,.* 和 (?:.)* 在逻辑上是相等的,但是在进行此优化的系统中,.* 实际上更快。在 java.util.regex 中,性能提升在 10% 左右;在 Ruby 和 .NET 中,大概是 2.5 倍;在 Python 中,大概是 50 倍;在 PHP/PCRE 中,大概是 150 倍;因为 Perl 实现了下一节介绍的优化措施, .* 和 (?:.)* 的速度是一样的。
MySQL 中,性能提升约4倍:
mysql> set @str:='abababdedfg';
Query OK, 0 rows affected (0.00 sec)
mysql> set @reg1:='.*';
Query OK, 0 rows affected (0.00 sec)
mysql> set @reg2:='(?:.)*';
Query OK, 0 rows affected (0.00 sec)
mysql> call sp_test_regexp(@str, @reg1, 100000);
+------+-------------------------+-------------------------+---------+
| @ret | @startts | @endts | diff_ts |
+------+-------------------------+-------------------------+---------+
| 1 | 2023-07-11 15:10:00.241 | 2023-07-11 15:10:00.252 | 11.0000 |
+------+-------------------------+-------------------------+---------+
1 row in set (27.15 sec)
Query OK, 0 rows affected (27.15 sec)
mysql> call sp_test_regexp(@str, @reg2, 100000);
+------+-------------------------+-------------------------+---------+
| @ret | @startts | @endts | diff_ts |
+------+-------------------------+-------------------------+---------+
| 1 | 2023-07-11 15:10:34.690 | 2023-07-11 15:10:34.731 | 41.0000 |
+------+-------------------------+-------------------------+---------+
1 row in set (27.74 sec)
(3)消除无必要的括号
如果某种实现方式认为 (?:.)* 与 .* 是完全等价的,那么它就会用后者替换前者。
(4)消除不需要的字符组
只包含单个字符的字符组有点多余,因为它要按照字符组来处理,而这么做完全没必要。所以聪明的实现方式会在内部把 [.] 转换为 \.。
(5)忽略优先量词之后的字符优化
忽略优先量词,例如 "(.*?)" 中的 *?,在处理时,引擎通常必须在作用的对象(点号)和 " 之后的字符之间切换。因为种种原因,忽略优先量词通常比匹配优先量词要慢,尤其是对上文中“化简量词优化”的匹配优先限定结构来说,更是如此。另一个原因是,如果忽略优先量词在捕获型括号内,控制权就必须在括号内外切换,这样会带来额外的开销。
所以这种优化的原理是,如果文字字符跟在忽略优先量词之后,只要引擎没有触及那个文字字符,忽略优先量词可以作为普通的匹配优先量词来处理。所以,包含此优化的实现方式在这种情况下会切换到特殊的忽略优先量词,迅速检测目标文本中的文字字符串,在遇到此文字字符之前,跳过常规的“忽略”状态。
mysql> set @str:='"aba"bab"dedfg';
Query OK, 0 rows affected (0.00 sec)
mysql> set @reg1:='"(.*)"';
Query OK, 0 rows affected (0.00 sec)
mysql> set @reg2:='"(.*?)"';
Query OK, 0 rows affected (0.00 sec)
mysql> call sp_test_regexp(@str, @reg1, 100000);
+------+-------------------------+-------------------------+---------+
| @ret | @startts | @endts | diff_ts |
+------+-------------------------+-------------------------+---------+
| 1 | 2023-07-11 15:47:52.673 | 2023-07-11 15:47:52.687 | 14.0000 |
+------+-------------------------+-------------------------+---------+
1 row in set (34.25 sec)
Query OK, 0 rows affected (34.25 sec)
mysql> call sp_test_regexp(@str, @reg2, 100000);
+------+-------------------------+-------------------------+---------+
| @ret | @startts | @endts | diff_ts |
+------+-------------------------+-------------------------+---------+
| 1 | 2023-07-11 15:48:28.258 | 2023-07-11 15:48:28.270 | 12.0000 |
+------+-------------------------+-------------------------+---------+
1 row in set (35.58 sec)
此优化还有各种其他形式,例如预查一组字符,而不是特殊的一个字符,例如检查 ['"](.*?)["'] 中的 ['"]。这有点类似前面介绍的开头字符识别优化。
(6)“过度”回溯检测
(.+)* 之类的量词结合结构,可能制造指数级的回溯。避免这种情况的简单办法就是限定回溯的次数,在“超限”时停止匹配。在某些实际情况中这非常有用,但是它也为正则表达式能够应用的文本人为设置了限制。
例如,如果上限是 10000 次回溯,.*? 就不能匹配长于 10000 的字符串,因为每个匹配的字符都对应一次回溯。这种情况并不罕见,尤其是处理 Web 页时更是如此,所以这种限制非常糟糕。
出于不同的原因,某些实现方式限制了回溯堆栈的大小(例如 Python 的上限是 10000)。也就是同时能够保存的状态的上限。像回溯上限一样,这也会限制正则表达式所能处理的文本的长度。
在“MySQL测试”一节,已经看到过相关的两个 MySQL 配置参数的缺省值、影响和更改。
(7)避免指数级(超线性 super-linear)匹配
避免指数级匹配的更好办法是,在匹配尝试进入超线性状态时进行检测。这样就能做些额外的工作,来记录每个量词对应的子表达式尝试匹配的位置,绕过重复尝试。
实际上,超线性匹配发生时是很容易检测出来的。单个量词迭代(循环)的次数不应该比目标字符串的字符数量更多,否则肯定发生了指数级匹配。如果根据这个线索发现匹配已经无法终止,检测和消除冗余的匹配是更复杂的问题,但是因为多选分支匹配次数太多,这么做或许值得。
检测超线性匹配并迅速报告匹配失败的副作用(side effect)之一就是,真正缺乏效率的正则表达式并不会体现出效率的低下。即使使用这种优化避免了指数级匹配,所花的时间也远远高于真正需要的时间,但是不会慢到很容易被用户发现。
当然,总的来说可能还是利大于弊。许多人不关心正则表达式的效率,他们对正则表达式怀着一种恐惧心里,只希望完成任务而不关心如何完成。
(8)使用占有优先量词削减状态
由正常量词约束的对象匹配之后,会保留若干“在此处不进行匹配”的状态,量词每一轮迭代创建一个状态。占有优先量词则不会保留这些状态。具体做法有两种,一种是在量词全部完成之后抛弃所有备用状态,效率更高的办法是每一轮迭代时抛弃上一轮的备用状态。匹配时总需要保存一个状态,这样在量词无法继续匹配时引擎还能继续运转。
在迭代中即时抛弃状态的做法效率更高,因为所占的内存更少。应用 .* 会在匹配每个字符时创造一个状态,如果字符很长,会占用大量的内存。
(9)量词等价转换
\d\d\d\d 和 \d{4} 两者的效率有差别么?对 NFA 来说答案几乎是肯定的,但工具不同结果不同。如果对量词做了优化,则 \d{4} 会更快一些,除非未使用量词的正则表达式能够进行更多的优化。MySQL 中 \d{4} 大概要快 25%。
mysql> set @str:='1234';
Query OK, 0 rows affected (0.00 sec)
mysql> set @reg1:='\\d\\d\\d\\d';
Query OK, 0 rows affected (0.00 sec)
mysql> set @reg2:='\\d{4}';
Query OK, 0 rows affected (0.00 sec)
mysql> call sp_test_regexp(@str, @reg1, 100000);
+------+-------------------------+-------------------------+---------+
| @ret | @startts | @endts | diff_ts |
+------+-------------------------+-------------------------+---------+
| 1 | 2023-07-11 17:26:35.087 | 2023-07-11 17:26:35.091 | 4.0000 |
+------+-------------------------+-------------------------+---------+
1 row in set (7.09 sec)
Query OK, 0 rows affected (7.09 sec)
mysql> call sp_test_regexp(@str, @reg2, 100000);
+------+-------------------------+-------------------------+---------+
| @ret | @startts | @endts | diff_ts |
+------+-------------------------+-------------------------+---------+
| 1 | 2023-07-11 17:26:42.168 | 2023-07-11 17:26:42.171 | 3.0000 |
+------+-------------------------+-------------------------+---------+
1 row in set (7.07 sec)
比较 ==== 和 ={4},此时重复的是确定的文字字符,而直接使用 ==== 引擎更容易将其识别为一个文字字符串。如果是,支持的高效的“开头字符 / 字符组 / 子串识别优化”就可以排上用场。对 Python 和 Java 来说,情况正是如此,==== 比 ={4} 快上 100 倍。
Perl、Ruby 和 .NET 的优化手段更高,它们不会区分 ==== 和 ={4},结果两者是一样快的。MySQL 中两者速度也一样。
mysql> set @str:='====';
Query OK, 0 rows affected (0.00 sec)
mysql> set @reg1:='====';
Query OK, 0 rows affected (0.00 sec)
mysql> set @reg2:='={4}';
Query OK, 0 rows affected (0.00 sec)
mysql> call sp_test_regexp(@str, @reg1, 100000);
+------+-------------------------+-------------------------+---------+
| @ret | @startts | @endts | diff_ts |
+------+-------------------------+-------------------------+---------+
| 1 | 2023-07-11 17:33:40.960 | 2023-07-11 17:33:40.963 | 3.0000 |
+------+-------------------------+-------------------------+---------+
1 row in set (7.08 sec)
Query OK, 0 rows affected (7.08 sec)
mysql> call sp_test_regexp(@str, @reg2, 100000);
+------+-------------------------+-------------------------+---------+
| @ret | @startts | @endts | diff_ts |
+------+-------------------------+-------------------------+---------+
| 1 | 2023-07-11 17:33:48.054 | 2023-07-11 17:33:48.057 | 3.0000 |
+------+-------------------------+-------------------------+---------+
1 row in set (7.10 sec)
(10)需求识别
另一种简单的优化措施是,引擎会预先取消它认为对匹配结果没有价值的工作,例如在不必捕获文本的地方使用了捕获型括号。识别能力在很大程度上依赖于编程语言,不过这种优化实现起来也可以很容易,如果在匹配时能够指定某些选项,就能禁止某些代价高昂的特性。
Tcl 就能进行这种优化,除非用户明确要求,否则它的捕获型括号并不会真正捕获文本。而 .NET 的正则表达式提供了一个选项,容许程序员指定捕获型括号是否需要捕获。
五、提高表达式速度的诀窍
前面介绍的是传统型 NFA 引擎使用的各种优化。如果还理解传统型 NFA 的工作原理,把这些知识结合起来,就可以从三方面获益:
- 编写适于优化的正则表达式
编写适应已知优化措施的表达式。举例来说,xx* 比 x+ 能适用的优化措施更多,例如检查目标字符串中必须出现的字符,或者开头字符识别。
- 模拟优化
通过手工模拟优化措施能节省大量时间。比如在 this|that 之前添加 (?=t),这样即使系统无法预知任何匹配结果必须以 t 开头,还是能模拟开头字符识别。
- 主导引擎的匹配
使用关于传统型 NFA 引擎工作原理的知识,能够主导引擎更快地匹配。拿 this|that 来说,每个多选分支都以 th 开头,如果第一个多选分支不能匹配 th,第二个显然也不行,所以不必白费功夫。因此可以使用 th(?:is|at)。这样 th 就只要检查一遍,只有在确实需要的时候才会用到代价相对高昂的多选结构功能。而且,th(?:is|at) 开头的纯文字字符就是 th,所以存在进行其他优化的可能。
效率和优化有时候处理起来比较麻烦,要注意下面几点:
- 进行看来确实有帮助的改动,有时反而事与愿违,因为这样可能禁止了有效的其他优化。
- 添加一些内容模拟优化措施,可能出现的情况是,处理那些添加内容的时间多于节省下来的时间。
- 添加一些内容模拟一个目前未提供的优化,如果将来升级以后的软件支持此优化,反而会影响或者重复真正的优化。
- 同样,控制表达式尝试触发某种当前可用的优化,将来某些软件升级之后可能无法进行某些更高级的优化。
- 为提高效率修改表达式,可能导致表达式难以理解和维护。
- 具体修改带来的好处或是坏处的程度,基本上取决于表达式应用的数据。对某类数据来说有益的修改,可能对另一类数据来说是有害的。
来举个极端点的例子:要在 Perl 脚本中找到 (000|999)$,并决定把这些捕获型括号替换为非捕获型括号,因为觉得这样不再需要捕获文本的开销,速度更快。但奇怪的是,这个微小而且看起来有益的改动反而会把表达式的速度降低几个数量级。怎么会这样呢?原来在这里有若干因素共同作用,使用非捕获型括号时,“字符串结束 / 行锚点优化”会被关闭。非捕获型括号在绝大多数情况下是有益的,但在某些情况下会带来灾难性的后果。
检测并性能测试期望应用的同类型的数据,有助于判断改动是否值得,但仍然必须权衡众多因素。
1. 常识性优化
(1)避免重新编译
编译和定义正则表达式的次数应该尽可能的少。在面向对象式处理中,用户能够精确控制这一点。例如要在循环中应用正则表达式,就应该在循环外创建这个正则表达式对象,在循环中重复使用。
在函数式处理,例如 GNU Emacs 和 Tcl 的情况下,应尽量保证循环中使用的正则表达式的数目少于工具所能缓存的上限。
如果使用的是集成式处理,例如 Perl,应尽量避免在循环内的正则表达式中使用变量插值,因为这样每次循环都需要重新生成正则表达式,即使值没有变化(不过 Perl 提供了高效的办法来避免这个问题)。
(2)使用非捕获型括号
如果不需要引用括号内的文本,使用非捕获型括号 (?:...)。这样不但能够节省捕获时间,而且会减少回溯使用的状态的数量,从两方面提高速度。而且能够进行进一步的优化,例如消除无必要括号。
(3)不要滥用括号
在需要时使用括号,其他时候使用括号会阻止某些优化措施。除非需要知道 .* 匹配的最后一个字符,否则不要使用 (.)*。
(4)不要滥用字符组
不要使用单字符字符组,如 ^.*[:]。这样做用不到字符组提供的多字符匹配功能,却要付出处理字符组的代价。当要匹配一个元字符时,用转义而不要用字符组,例如用 \. 或 \*,别用 [.] 或 [*]。用不区分大小写的匹配代替 ^[Ff][Rr][Oo][Mm] 之类的写法。
(5)使用起始锚点
除非是极其罕见的情况,否则以 .* 开头的正则表达式都应该在最前面添加 ^ 或者 \A。如果这个正则表达式在某个字符串的开头不能匹配,那么显然在其他位置它也不能匹配。添加锚点(无论是手工添加还是引擎自动添加)都能够配合“开头字符 / 字符组 / 子串识别优化”,节省大量不必要的工作。
2. 将文字文本独立出来
这里提供一些手动优化措施,有助于暴露文字文本,提高引擎识别的可能性,配合引擎有关文字文本的优化。
(1)从量词中“提取”必须的元素
用 xx* 替代 x+ 能够暴露匹配必须的 x。同理 -{5,7} 可以写作 ------{0,2}。
(2)“提取”多选结构开头的必须元素
用 th(?:is|at) 替代 (?:this|that),就能暴露出必须的 th。如果不同多选分支的结尾部分相同,也可以从右面“提取”,例如 (?:optim|standard)ization。下一节会看到,如果提取出来的部分包括锚点,这么做就非常有价值。
3. 将锚点独立出来
某些效果明显的内部优化措施是利用锚点,例如 ^、$ 和 \G,把表达式“绑定”在目标字符串的某一端,使用这些优化时,有一些技巧能够提供帮助。
(1)在表达式前面独立出 ^ 和 \G
^(?:abc|123) 和 ^abc|^123 在逻辑上是等价的,但是许多正则引擎只会对第一个表达式使用“开头字符 / 字符组 / 子串识别优化”,所以第一种办法的效率要高得多。在 PCRE 及其使用它的工具中两者的效率是一样的,但是大多数其他 NFA 工具中第一个表达式的效率更高。
比较 (^abc) 和 ^(abc) 能发现另一个区别,前者的设置并不很合适,锚点“藏”在捕获型括号内,在检测锚点之前必须先进入括号,在许多系统上,这样做的效率很低。某些系统(PCRE、Perl、.NET)中两者的效率相当,但在其他系统中(Ruby 和 Java)只会对后者进行优化。
(2)在表达式末尾独立出 $
此措施与上一节的优化思想类似,虽然 abc$|123$ 和 (?:abc|123)$ 在逻辑上等价,但优化的表现可能不同。目前这种优化还只适用于 Perl,因为现在只有 Perl 提供了“字符串结束 / 行锚点优化”。优化对 (...|...)$ 起作用,对 (...$|...$) 不起作用。
4. 忽略优先还是匹配优先?具体情况具体分析
通常,使用忽略优先量词还是匹配优先量词取决于正则表达式的具体需求。举例来说,^.*: 完全不同于 ^.*?:,因为前者匹配到最后的冒号,而后者匹配到第一个冒号。但是,如果目标数据中只包含一个冒号,两个表达式就没有区别了,匹配到唯一的冒号为止,所以选择速度更快的表达式可能更合适。
不过并不是任何时候优劣都如此分明,大的原则是,如果目标字符串很长,而认为冒号会比较接近字符串的开头,就使用忽略优先量词,这样引擎能更快地找到冒号。如果认为冒号在接近字符串末尾的位置,就使用匹配优先量词。如果数据是随机的,又不知道冒号会靠近哪一头,就使用匹配优先量词,因为它们的优化一般来说要比其他量词更好,尤其是表达式的后面部分禁止进行“忽略优先量词之后的字符优化”时,更是如此。
如果待匹配的字符串很短,差别就不那么明显了。这时候两个正则表达式的速度都很快,不过如果很在乎那一点点的速度差别,就对典型数据做个性能测试吧。
一个与此有关的问题是,在忽略优先量词和排除型字符组之间(^.*?: 与 ^[^:]*:),应该如何选择?答案还是取决于所使用的编程语言和应用的数据,但是对大多数引擎来说,排除型字符组的效率比忽略优先量词高得多。Perl 是个例外,因为它能对忽略优先量词之后的字符进行优化。
5. 拆分正则表达式
有时候,应用多个小的正则表达式的速度比一个大正则表达式要快得多。举个极端的例子,如果要检查一个长字符串中是否包含月份的名字,依次检查 January、February、March 之类的速度,要比 January|February|March|... 快得多。因为对后者来说,不存在匹配成功必须的文字内容,所以不能进行“内嵌文字字符串检查优化”。“大而全”的正则表达式必须在目标文本中的每个位置测试所有的子表达式,速度相当慢。
再看一个有趣的例子,查找类似 HASH(0x80f60ac) 的数据,使用的正则表达式相当直白:\b(?:SCALAR|ARRAY|...|HASH)\(0x[0-9a-fA-F]+\)。本希望足够先进的引擎应该能够明白 (0x 是任何匹配所必须的,因此会启用“预查必须字符 / 子字符串优化”。而这个正则表达式所应用的数据几乎不包含 (0x,预查能够节省许多时间。不幸的是 Perl 没有这样做,它在每个目标字符串的每个字符那里测试整个正则表达式的众多多选分支,速度达不到要求。
一个优化办法是以复杂的方式:\(0x(?<=(?:SCALAR|ARRAY|...|HASH)\(0x)[0-9a-fA-F]+\)。这样,一旦 \(0x 匹配之后,肯定性逆序环视就能确保之前匹配的是需要的文本,再检查之后的文本是否符合预期。费这番周折的原因在于,让正则表达式获得必须出现的文字文本 \(0x,这样就能进行多种优化。尤其是希望进行预查必须字符串优化,以及“开头字符 / 字符组 / 子串识别优化”。
如果 Perl 不会自动预查 \(0x,可以手动:
if ($data =~ m/\(0x/
and
$data =~ m/(?:SCALAR|ARRAY|...|HASH)\(0x[0-9a-fA-F]+\)/)
{
# 错误数据报警
}
\(0x 的检查事实上会过滤掉大部分文本,相对较慢的完整正则表达式只对有可能匹配的行进行检测,这样平衡了效率和可读性。
6. 模拟开头字符识别
如果所用的实现方式没有进行开头字符识别优化,则可以亲自动手,在表达式的开头添加合适的环视结构。在正则表达式的其他部分匹配之前,环式结构可以进行“预查”,选择合适的开始位置。
如果正则表达式为 Jan|Feb|...|Dec,对应的就是 (?=[JFMASOND])(?:Jan|Feb|...|Dec)。开头的 [JFMASOND] 代表了英文中月份单词可能的首字母。不过这种技巧不是所有情况下都适用,因为环视结构的开销可能大于节省的时间。
如果正则引擎能自动进行 [JFMASOND] 的检测,速度当然会比用户手工指定快得多。在许多系统上,可以用下面的复杂表达式让引擎自动进行检测:
[JFMASOND](?:(?<=J)an|(?<=F)eb|...|(?<=D)ec)
表达式开头的字符组可以被大多数系统的“开头字符 / 字符组 / 子串识别优化”所利用,这样传动装置就能够高效地预查 [JFMASOND]。如果目标字符串不包含匹配字符,结果会比原来的 Jan|Feb|...|Dec 或是手工添加环视功能的表达式要快。但是,如果目标字符串中包含许多字符组能够匹配的字符,那么额外的逆序环视可能反而会减慢匹配的速度。
7. 使用固化分组和占有优先量词
在许多情况下,固化分组和占有优先量词能够极大地提高匹配速度,而且它们不会改变匹配结果。举例来说,如果 ^[^:]+: 中的冒号第一次尝试时无法匹配,那么任何回溯其实都是没有意义的,因为根据定义,回溯“交还”的任何字符都不可能是冒号。使用固化分组 ^(?>[^:]+): 或者占有优先量词 ^[^:]++: 就能够直接抛弃备用状态,或者根本不会创造多少备用状态。因为引擎没有内容状态可以回溯,就不会进行不必要的回溯。
不过必须强调,这两种结构运用不当,就会在不经意间改变匹配结果,所以必须极为小心。如果不用 ^.*: 而用 ^(?>.*): 结果必然失败。整行文本都会被 .* 匹配,后面的 : 就无法匹配任何字符。固化分组阻止最后的 : 匹配必须进行的回溯,所以匹配必定失败。
8. 主导引擎的匹配
提高正则表达式匹配效率的另一个办法是尽可能准确地设置匹配过程中的“控制权”。比如用 th(?:is|at) 取代 this|that 的例子。在后一个表达式中,多选结构获得最高级别的控制权,而在前一个表达式中,相对代价高昂的多选结构只有在 th 匹配之后才获得控制权。
(1)将最可能匹配的多选分支放在前头
如果多选分支的顺序与匹配结果无关,就应该把最可能匹配的多选分支放在首位。例如在匹配主机名的正则表达式中,如果按照分布数量排序:(?:com|edu|org|net|...),更有可能迅速获得更常见的匹配。
当然,这只有对传统型 NFA 引擎才适用,而且只有存在匹配的时候才适用。如果使用 POSIX NFA,或是不存在匹配,此时所有的多选分支都必须检测,所以顺序无关紧要。
(2)将结尾部分分散到多选结构内
比较 (?:com|edu|...|[a-z][a-z])\b 和 com\b|edu\b|...\b|[a-z][a-z]\b。在后一个表达式中,多选结构之后的 \b 被分散到每个多选分支。可能的收益就是,它可能容许一个多选分支能够匹配,但之后的 \b 可能导致这个匹配不成功。把 \b 加到多选结构之内,匹配失败的更快,因为这样不需要退出多选结构就能发现失败。
这个优化是有风险的。切记,使用这个功能时要小心,不要因此阻止了本来可以进行的其他优化。举例来说,如果“分散的”子表达式是文字文本,那么把 (?:this|that): 更换为 this:|that: 就违背了“将文字文本独立出来”中的某些思想。各种优化都是平等的,所以在优化时务必小心,不要因小失大。
在能够进行独立结尾锚点的系统上把正则表达式末尾的 $ 分散,也会遇到这种问题。在这些系统上,(?:com|edu|...)$ 比 com$|edu$|...$ 快得多。
9. 消除循环
无论系统本身支持怎样的优化,最重要的收益或许还是来自于对引擎基本工作原理的理解,和编写能够配合引擎工作的表达式。此处所说的“循环”采用的是 (this|that|...)* 之类表达式中星号代表的意义,之前的无休止匹配 "(\\.|[^\\"]+)*" 其实就属于此类。如果无法匹配,这个表达式需要近乎无限的时间进行尝试,所以必须改进。
此技巧有两种不同的实现途径:
- 检查各种典型匹配中,(\\.|[^\\"]+)* 中的哪个部分真正匹配成功了,这样就能留下子表达式的痕迹。再根据刚刚发现的模式,重新构建高效的表达式。这个概念模型就是一个大球,它表示表达式 (...)*,球在某些文本上滚动。(...) 内的元素总是能够匹配某些文本,这样就留下了痕迹。
- 另一个办法是,从更高的层面考察期望用于匹配的结构,然后根据认为的常见情形,对可能出现的目标字符串做出非正式假设。从这个角度出发构建有效的表达式。
(1)方法1:依据经验构建正则表达式
在分析 "(\\.|[^\\"]+)*" 时,用若干具体的字符串来检验全局匹配情况是很自然的做法。举例来说,如果目标字符串是 "hi",那么使用的子表达式就是 "[^\\"]+"。这说明,全局匹配使用了最开始的 ",然后是多选分支 [^\\"]+,然后是末尾的 "。如果目标字符串是 "he said \"hi there\" and left",对应的表达式就是 "[^\\"]+\\.[^\\"]+\\.[^\\"]+"。虽然不可能为每个输入的字符串构造特定的表达式,但能找出一些常用的模式,构造效率更高,又不失通用性的正则表达式。
现在来看下表中前四行的例子。
目标字符串 | 对应的表达式 |
"hi there" | "[^\\"]+" |
"just one \" here" | "[^\\"]+\\.[^\\"]+" |
"some \"quoted\" things" | "[^\\"]+\\.[^\\"]+\\.[^\\"]+" |
"with \"a\" and \"b\"." | "[^\\"]+\\.[^\\"]+\\.[^\\"]+\\.[^\\"]+\\.[^\\"]+" |
"\"ok\"\n" | "\\.[^\\"]+\\.\\." |
"empty \"\" quote" | "[^\\"]+\\.\\.[^\\"]+" |
表2
每种情况下,开头是引号,然后是 [^\\"]+,然后是若干个 \\.[^\\"]+。综合起来就得到 [^\\"]+(\\.[^\\"]+)*。这个特殊的例子说明,通用模式可以用来构建许多有用的表达式。
在匹配双引号字符串时,引号自身和转义斜线是“特殊”的——因为引号能够表示字符串的结尾,反斜线表示它之后的字符不会终结整个字符串。在其他情况下,[^\\"] 就是普通的点号。考察它们如何组合为 [^\\"]+(\\.[^\\"]+)*,首先它符合通用的模式 normal+(specialnormal+)*。再添加两端的引号,就得到 "[^\\"]+(\\.[^\\"]+)*"。
但是,表2中最后两行的例子无法由这个表达式匹配。症结在于目前这个表达式中的两个 [^\\"]+ 要求字符串以一个普通字符开始。可以尝试把两个加号改成星号 "[^\\"]*(\\.[^\\"]*)*"。这会达到期望的结果么?更重要的是,它是否会产生负面影响?
现在表2中所有的例子都能匹配,即使是 "\"\"\"" 这样的字符串都能匹配。但还需要确认,这样重大的改变是否导致预料之外的结果。格式不对的引号字符串能否匹配?格式正确的引号字符串是否可能无法匹配?效率又如何呢?
仔细看看 "[^\\"]*(\\.[^\\"]*)*"。开头的 "[^\\"]* 只会应用一次,它匹配开头必须出现的引号,以及之后的任何普通字符,这没用问题。接下来的 (\\.[^\\"]*)* 是由星号限定的。如果这部分匹配了零次,等价于去掉这部分,就得到 "[^\\"]*"。这显然没问题,它代表了常见的,也就是没有转义元素的情形。
如果 (\\.[^\\"]*)* 部分匹配了一次,其实就等价于 "[^\\"]*\\.[^\\"]*"。即使结尾的 [^\\"]* 没有匹配任何文本,其实就是 "[^\\"]*\\.",也没有问题。照这样分析下去会发现,这样的改的其实是没有任何问题的。所以最后得到的,用来匹配包括转义引号的引号字符串的正则表达式就是: "[^\\"]*(\\.[^\\"]*)*"。这和原来的表达式能够匹配的结果是完全一致的。但是循环消除之后,表达式能够在有限的时间内结束匹配,不但效率高得多,并且避免了无休止匹配。
消除循环常用的解法是:
opening normal*(special normal*)* closing
避免 "[^\\"]*(\\.[^\\"]*)*" 中的无休止匹配,有三点很重要:
1) special 部分和 normal 部分匹配的开头不能重叠。
special 和 normal 部分的子表达式不能从同一位置开始匹配。在上例中,normal 部分是 [^\\"],special 部分是 \\.,显然它们不能匹配同样的字符,因为后者要求以反斜线开头,而前者不容许出现反斜线。
另一方面,\\. 和 [^"] 都能够从 "Hello \n" 中的反斜杠开始匹配,所以它们不符合这种解法。如果二者能够从字符串中的同一位置开始匹配,就无法确定该使用哪一个,这种不确定就会造成无休止匹配。makudonarudo 的例子说明了这一点。如果无法匹配(或是 POSIX NFA 引擎在任何情况下的匹配),就必须尝试所有的可能性,改进这个表达式的首要原因就是为了避免这种情况。
如果确认 special 和 normal 部分不能匹配同样的字符,就可以将 special 部分用作检查点,消除 normal 部分在 (...)* 的各轮迭代时匹配同样文本造成的不确定性。如果确认 special 部分和 normal 部分永远不会匹配同样的文本,则特定目标字符串的匹配中存在唯一的 special 部分和 normal 部分的“组合序列”。检查这个序列比检查成千上万种可能要快得多,于是避免了无休止匹配。
2) special 部分必须匹配至少一个字符
第二点是,special 部分必须匹配至少一个字符。如果不占用任何字符 special 部分就能匹配成功,那么后面的字符仍然必须由 (specialnormal*)* 的不同迭代来匹配,这样我们就回到了原来的 (...*)* 的问题。
选择 (\\.)* 作为 special 部分就违背了这条规定。如果用 "[^\\"]*((\\.)*[^\\"]*)*" 来匹配 "Tubby,在得到匹配失败的结论之前,引擎必须尝试若干个 [^\\"]* 匹配 Tubby 的每一种可能。因为 special 部分可以不匹配任何字符,所以它无法作为检查点。
3) special 部分必须是固化的
special 部分匹配的文本不能由该部分的多次迭代完成。例如需要匹配 Pascal 中可能出现的注释 {...} 和空白。能够匹配注释部分的正则表达式是 \{[^}]*\},所以整个正则表达式就是 (\{[^}]*\}| +)*。假设 ‘ +’ 和 \{[^}]*\} 分别被划分为 special 和 normal 部分。使用 normal*(special normal*)* 的解法,得到 (\{[^}]*\})*( +(\{[^}]*\})*)*。现在来看这个字符串:
{comment} {another}
匹配连续空格的可能是单个‘ +’,或多个‘ +’匹配(每个匹配一个空格),或是多个‘ +’的组合(每个匹配不同数目的空格)。这很像之前的‘makudonarudo’的问题。
此问题的根源在于,special 部分既能匹配很长的文本,也能通过 (...)* 匹配其中的部分文本。非确定性开了“多种方式匹配同样文本”的口子。
如果存才全局匹配,很可能‘ +’只匹配一次,但是如果不存在全局匹配(例如把这个表达式作为另一个大的表达式的一部分),引擎就必须对每一段空格测试‘( +)*’所有的可能。需要时间,但对全局匹配无益。
解决的办法是保证 special 部分只能匹配固定长度的空格。因为它必须匹配至少一个空格,但可能匹配更多,我们用‘ ’作为 special 部分,用 (...)* 来保证 special 的多重应用来匹配多个空格。
这个例子很适合讲解,但是实际应用起来,效率更高的办法可能是交换 special 和 normal 表达式:‘ *(\{[^}]*\} *)*’。因为估计 Pascal 程序的空格比注释多,而且对常见情况来说更有效的办法是用 normal 部分匹配常见的文本。
如果有若干个量词存在于不同的层面,例如 (...*)*,就必须小心对待,但是许多这样的表达式却是完全没有问题的。例如:
- (Re: *)* 用来匹配任意数目的‘Re:’序列(可以用来清除邮件主题中的‘Subject: Re: Re: re: hey’)。
- ( *\$[0-9]+)* 用来匹配美元金额,可能有空格作分隔。
- (.*\n)+ 用来匹配一行或多行文本。(事实上,如果点号不能匹配换行符,而这个子表达式之后又有别的元素导致匹配失败,就会造成无休止匹配。)
这些表达式都没有问题,因为每个都有检查点,也就不会产生“多种方式匹配同样文本”的问题。第一个里面是 Re:,第二个里面是 \$,第三个是 \n(如果点号不能匹配换行符)。
(2)方法2:自顶向下的视角
开始只匹配目标字符串中最常见的部分,然后增加对非常见情况的处理。来看会造成无休止匹配的表达式 (\\.|[^\\"]+)* 期望匹配的文本以及它可能应用的场合。通常情况下引号字符串中的普通字符会比转义字符多,所以 [^\\"]+ 承担了大部分工作。\\. 只需要用来处理偶然出现的转义字符。可以使用多选结构来应付这两种情况,但为了处理少部分转义字符,这样做会降低效率。
如果认为 [^\\"]+ 能够匹配字符串中的绝大部分字符,就知道如果匹配停止,就是遇到了闭引号或转义字符。如果是转义字符,后面容许出现任意一个字符,然后开始 [^\\"]+ 的新一轮匹配。每次 [^\\"]+ 的匹配终止都会回到相同的处境:期望出现一个闭引号或者是另一个转义。
可以很自然地用一个表达式来表达它,得到与方法1同样的结果:
"[^\\"]+(\\.[^\\"]+)*"
和之前一样,最开始的非引用内容或是引号内的文本可能为空。可以把两个加号改成星号,这样就得到与方法1相同的表达式。
(3)匹配主机名
主机名主要是用点号分隔的子域名序列。准确划定子域名的匹配规范很麻烦,为保证清晰,使用 [a-z]+ 来匹配子域名。如果子域名是 [a-z]+,希望得到点号分隔的子域名序列,首先要匹配第一个子域名,之后其他的子域名以点号开头。用正则表达式表示就是 [a-z]+(\.[a-z]+)*。
从概念上讲,能够把点号分隔的主机名问题看成双引号字符串的问题,也就是“由转义元素分隔的非转义元素构成的序列”。这里的 normal 部分是 [a-z]+,由 special 部分 \. 分隔,就能套用方法1的消除循环解法。
子域名的例子与双引号字符串的例子属于同一类,但有两大区别:
- 域名的开始和结束没有分界符。
- 子域名的 normal 部分不能为空,也就是说两个点号不能紧挨在一起,点号也不能出现在整个域名的开头或结尾。对双引号字符串来说,normal 部分可以为空,所以需要把 [^\\"]+ 改为 [^\\"]*,而子域名的例子不能进行这种修改。
回过头来看双引号字符串的例子,表达式 "[^\\"]*(\\.[^\\"]*)*" 的优缺点都很明显。
缺点:
- 可读性:这是最大的问题,原来的 "([^\\"]|\\.)*" 更容易一眼看懂,这里放弃了可读性来追求效率。
- 可维护性:可维护性可能更复杂,因为任何改动都必须保持对两个 [^\\"] 相同。这里牺牲了可维护性来追求效率。
优点:
- 速度:如果不能匹配,或是采用 POSIX NFA,这个正则表达式不会进入无休止匹配。因为进行了精心地调校,特定的文本只能以唯一的方式匹配,如果文本不能匹配,引擎会迅速发现它。
- 还是速度:正则表达式“操作连续性(flow)”很好,这也是“流畅运转的正则表达式”的主题。在对传统型 NFA 进行的检测中,消除循环之后的表达式总是比之前使用多选结构的表达式要快得多。即使匹配能够成功,不会进入无休止匹配状态时,也是如此。
(4)使用固化分组和占有优先量词
表达式 "(\\.|[^\\"]+)*" 之所以会进入无休止匹配的状态,问题在于,如果无法匹配,它会陷入徒劳地尝试。不过,如果存在匹配,就能很快结束,因为 [^\\"]+ 能够匹配目标字符串中的大多数字符,也就是之前讨论过的 normal 部分。因为 [...]+ 通常会为速度优化,而且能够匹配大多数字符,外面的 (...)* 量词的开销因此会大伟减少。
"(\\.|[^\\"]+)*" 的问题是当不能匹配时,在毫无用处的备用状态中不断回溯,这些状态没有价值,因为他们只是检查同样对象的不同排列,都不能匹配。如果能抛弃这些状态,正则表达式就能迅速报告匹配失败。抛弃(或者是忽略)这些状态的方法有两种:固化分组和占有优先量词。
在着手消除回溯之前,希望交换多选分支的顺序,把 "(\\.|[^\\"]+)*" 变为 "([^\\"]+|\\.)*" ,这样匹配“普通”文本的元素就出现在第一位。如果两个及以上多选分支能够在同一位置匹配,排列顺序可能影响到匹配的结果。但对本例来说,不同多选分支匹配的文本互斥,某个多选分支在一处能够匹配,则其他多选分支在此处就不能匹配。从正确匹配的角度来看,顺序无关紧要,所以可以根据清晰或效率的要求来选择顺序。
- 使用占有优先量词避免无休止匹配
会造成无休止匹配的表达式 "([^\\"]+|\\.)*" 有两个量词,可以把其中一个改为占有优先量词,或者两个都改。因为大多数回溯的麻烦都源自 [...]+ 留下的状态,所以把它改成占有优先得到的表达式即使找不到匹配,速度也很快。不过把外面的 (...)* 改成占有优先会抛弃括号内的所有状态,其中包括 [...]+ 和多选结构本身的备选状态,所以如果要选取一个的话,应选取后者。
还可以把两个都改为占有优先量词,具体哪种快可能取决于占有优先量词的优化情况。在 MySQL 中的测试情况是,只将外面的量词改为占有优先,比其它两种快两倍多,而其它两种的速度差不多。
mysql> set @str:='"empty \\\"\\\" quote"';
Query OK, 0 rows affected (0.00 sec)
mysql> set @reg1:='"([^\\\\"]++|\\\\.)*"';
Query OK, 0 rows affected (0.00 sec)
mysql> set @reg2:='"([^\\\\"]+|\\\\.)*+"';
Query OK, 0 rows affected (0.00 sec)
mysql> set @reg3:='"([^\\\\"]++|\\\\.)*+"';
Query OK, 0 rows affected (0.00 sec)
mysql>
mysql> call sp_test_regexp(@str, @reg1, 1000);
+------+-------------------------+-------------------------+----------+
| @ret | @startts | @endts | diff_ts |
+------+-------------------------+-------------------------+----------+
| 0 | 2023-07-17 09:18:46.766 | 2023-07-17 09:18:47.181 | 415.0000 |
+------+-------------------------+-------------------------+----------+
1 row in set (0.43 sec)
Query OK, 0 rows affected (0.43 sec)
mysql> call sp_test_regexp(@str, @reg2, 1000);
+------+-------------------------+-------------------------+----------+
| @ret | @startts | @endts | diff_ts |
+------+-------------------------+-------------------------+----------+
| 0 | 2023-07-17 09:18:47.191 | 2023-07-17 09:18:47.376 | 185.0000 |
+------+-------------------------+-------------------------+----------+
1 row in set (0.19 sec)
Query OK, 0 rows affected (0.19 sec)
mysql> call sp_test_regexp(@str, @reg3, 1000);
+------+-------------------------+-------------------------+----------+
| @ret | @startts | @endts | diff_ts |
+------+-------------------------+-------------------------+----------+
| 0 | 2023-07-17 09:18:47.386 | 2023-07-17 09:18:47.794 | 408.0000 |
+------+-------------------------+-------------------------+----------+
1 row in set (0.42 sec)
Query OK, 0 rows affected (0.42 sec)
- 使用固化分组避免无休止匹配
如果要对 "([^\\"]+|\\.)*" 使用固化分组,最容易想到的办法就是把普通括号改成固化分组括号:"(?>[^\\"]+|\\.)*"。但必须知道,在抛弃状态的问题上,(?>...|...)* 与占有优先的 (...|...)*+ 是迥然不同的。
(...|...)*+ 在完成时不会留下任何状态,而 (?>...|...)* 只是消除多选结构每次迭代时保留的状态。星号是独立于固化分组的,所以不受影响,这个表达式仍然会保留“跳过本轮迭代”的备用状态。也就是说,回溯中的状态仍然不是确定的最终状态。这里希望同时消除外面量词的备用状态,所以要把外面的的括号也改为固化分组,也就是说模拟占有优先 (...|...)*+ 必须用到 (?>(...|...)*)。
解决无休止匹配问题时,(...|...)*+ 和 (?>...|...)* 都很有用,但它们在抛弃状态的选择和时间上却是不同的。在 MySQL 中的测试情况是,两层括号使用固化分组最快,其次是仅在内层括号使用固化分组,最慢的是只对外层括号使用固化分组。总的来说三种固化分组的速度相差不大,都比最快的占有优先量词方式 "([^\\"]+|\\.)*+" 还要快些。
mysql> set @str:='"empty \\\"\\\" quote"';
Query OK, 0 rows affected (0.00 sec)
mysql> set @reg1:='"(?>[^\\\\"]+|\\\\.)*"';
Query OK, 0 rows affected (0.00 sec)
mysql> set @reg2:='"(?>([^\\\\"]+|\\\\.)*)"';
Query OK, 0 rows affected (0.00 sec)
mysql> set @reg3:='"(?>(?>[^\\\\"]+|\\\\.)*)"';
Query OK, 0 rows affected (0.00 sec)
mysql>
mysql> call sp_test_regexp(@str, @reg1, 1000);
+------+-------------------------+-------------------------+----------+
| @ret | @startts | @endts | diff_ts |
+------+-------------------------+-------------------------+----------+
| 0 | 2023-07-17 09:54:29.521 | 2023-07-17 09:54:29.684 | 163.0000 |
+------+-------------------------+-------------------------+----------+
1 row in set (0.17 sec)
Query OK, 0 rows affected (0.17 sec)
mysql> call sp_test_regexp(@str, @reg2, 1000);
+------+-------------------------+-------------------------+----------+
| @ret | @startts | @endts | diff_ts |
+------+-------------------------+-------------------------+----------+
| 0 | 2023-07-17 09:54:29.694 | 2023-07-17 09:54:29.874 | 180.0000 |
+------+-------------------------+-------------------------+----------+
1 row in set (0.19 sec)
Query OK, 0 rows affected (0.19 sec)
mysql> call sp_test_regexp(@str, @reg3, 1000);
+------+-------------------------+-------------------------+----------+
| @ret | @startts | @endts | diff_ts |
+------+-------------------------+-------------------------+----------+
| 0 | 2023-07-17 09:54:29.884 | 2023-07-17 09:54:30.033 | 149.0000 |
+------+-------------------------+-------------------------+----------+
1 row in set (0.16 sec)
Query OK, 0 rows affected (0.16 sec)
(5)简单的消除循环的例子
- 消除“多字符”引文中的循环
匹配字符串 ...<B>Billions</B> and <B>Zillions</B> of suns...
mysql> set @str:='<B>Billions</B> and <B>Zillions</B> of suns';
Query OK, 0 rows affected (0.00 sec)
mysql> set @reg:='<B>(?>[^<]*)(?>(?!</?B>)<[^<]*)*</B>';
Query OK, 0 rows affected (0.00 sec)
mysql> select regexp_count(@str,@reg,'') c, regexp_extract(@str,@reg,'') s;
+------+---------------------------------+
| c | s |
+------+---------------------------------+
| 2 | <B>Billions</B>,<B>Zillions</B> |
+------+---------------------------------+
1 row in set (0.01 sec)
<B> 匹配开头的 <B>;(?>[^<]*) 匹配任意数量的“normal”;(?!</?B>) 如果不是<B>也不是</B>;< 匹配“special”;[^<]* 继续匹配任意数量的“normal”;</B> 匹配结尾的 </B>。这里固化分组并不是必须的,但如果只能部分匹配,使用固化分组更够提高速度。
- 消除连续行匹配中的循环
mysql> set @str:=
-> 'SRC=array.c buildin.c eval.c field.c gawkmisc.c io.c main.c\\
'> missing.c msg.c node.c re.c version.c';
Query OK, 0 rows affected (0.01 sec)
mysql> set @reg:='^\\w+=((?>[^\\n]*)(?>\\n[^\\n]*)*)';
Query OK, 0 rows affected (0.00 sec)
mysql> select @reg r, regexp_count(@str,@reg,'') c, regexp_extract(@str,@reg,'') s\G
*************************** 1. row ***************************
r: ^\w+=((?>[^\n]*)(?>\n[^\n]*)*)
c: 1
s: SRC=array.c buildin.c eval.c field.c gawkmisc.c io.c main.c\
missing.c msg.c node.c re.c version.c
1 row in set (0.00 sec)
\w+ 匹配开头的文字和等号;(?>[^\n]*) 匹配“normal”;(?>\n[^\n]*) 匹配“special”“normal”。本例使用非 dotall 模式,只有 \n 是特殊字符。如果使用 dotall 模式,只有反斜杠是特殊字符,其他包括换行符在内均为普通字符。
mysql> set @str:=
-> 'SRC=array.c buildin.c eval.c field.c gawkmisc.c io.c main.c\\
'> missing.c msg.c node.c re.c version.c';
Query OK, 0 rows affected (0.00 sec)
mysql> set @reg:='^\\w+=((?>[^\\\\]*)(?>\\\\.[^\\\\]*)*)';
Query OK, 0 rows affected (0.00 sec)
mysql> select @reg r, regexp_count(@str,@reg,'mn') c, regexp_extract(@str,@reg,'mn') s\G
*************************** 1. row ***************************
r: ^\w+=((?>[^\\]*)(?>\\.[^\\]*)*)
c: 1
s: SRC=array.c buildin.c eval.c field.c gawkmisc.c io.c main.c\
missing.c msg.c node.c re.c version.c
1 row in set (0.00 sec)
与上例一样,固化分组不是必须的,但它能让引擎更快地报告匹配失败。
- 消除 CSV 正则表达式中的循环
用来匹配 CSV 字符串的正则表达式是 (?:[^"]|"")*,该表达式已经区分了 normal 和 special 部分:[^"] 和 ""。
mysql> set @s:='Ten Thousand,10000, 2710 ,,"10,000","It\'s ""10 Grand"", baby",10K';
Query OK, 0 rows affected (0.01 sec)
mysql> set @r:='\\G(?:^|,)(?:"((?>[^"]*)(?>""[^"]*)*)"|([^",]*))';
Query OK, 0 rows affected (0.00 sec)
mysql> select replace(trim(both '"' from (trim(leading ',' from regexp_substr(@s,@r,1,lv)))),'""','"') s
-> from (with recursive tab1(lv) as (select 1 lv union all select t1.lv + 1 from tab1 t1
-> where lv < regexp_count(@s, @r, '')) select lv from tab1) t;
+-----------------------+
| s |
+-----------------------+
| Ten Thousand |
| 10000 |
| 2710 |
| |
| 10,000 |
| It's "10 Grand", baby |
| 10K |
+-----------------------+
7 rows in set (0.00 sec)
开头添加 \G 能避免驱动过程带来的麻烦,并且效率也会提高。"((?>[^"]*)(?>""[^"]*)*)" 匹配双引号字段;([^",]*) 匹配引号和逗号之外的文本。和其他例子一样,固化分组不是必须的,但可以提高效率。
- 消除 C 语言注释的循环
在 C 语言中,注释以 /* 开头,以 */ 结尾,可以有多行,但不能嵌套(C++、Java 和 C# 也容许这种形式的注释)。最简单的办法是,使用匹配所有字符的点号的忽略优先量词:/\*.*?\*/。
mysql> set @s:='/** some comment here **/';
Query OK, 0 rows affected (0.00 sec)
mysql> set @r:='/\\*.*?\\*/';
Query OK, 0 rows affected (0.00 sec)
mysql> select @r, regexp_count(@s, @r, '') c, regexp_extract(@s, @r, '') s;
+-----------+------+---------------------------+
| @r | c | s |
+-----------+------+---------------------------+
| /\*.*?\*/ | 1 | /** some comment here **/ |
+-----------+------+---------------------------+
1 row in set (0.00 sec)
用消除循环技巧匹配 C 语言注释,也是一种高效的方法。因为结束符 */ 是两个字符,直接用 /\*[^*]*\*/ 不能匹配注释内容中的星号。
mysql> set @s:='/** some comment here **/';
Query OK, 0 rows affected (0.00 sec)
mysql> set @r:='/\\*[^*]*\\*/';
Query OK, 0 rows affected (0.00 sec)
mysql> select @s, @r, regexp_count(@s, @r, '') c, regexp_extract(@s, @r, '') s;
+---------------------------+-------------+------+------+
| @s | @r | c | s |
+---------------------------+-------------+------+------+
| /** some comment here **/ | /\*[^*]*\*/ | 0 | |
+---------------------------+-------------+------+------+
1 row in set (0.00 sec)
为了看得更清楚,在这个例子中使用 /x...x/,而不是 /*...*/。这样 /\*[^*]*\*/ 变成了 /x[^x]*x/,消除了反斜线转义,更容易看懂。
匹配分隔符之内文本的公式为:
- 匹配起始分隔符;
- 匹配正文:匹配“除结束分隔符之外的任何字符”;
- 匹配结束分隔符。
现在以 /x 和 x/ 作为开始和结束分隔符,难处在于匹配“除结束分隔符之外的任何字符”。如果结束分隔符是单个字符,可以用排除型字符组,但字符组不能用来进行多字符匹配。不过否定型顺序环视 (?:(?!x/).)* 就是“除结束分隔符之外的任何字符”,于是得到了 /x(?:(?!x/).)*x/。它没有问题,但速速很慢。
mysql> set @s:='/** some comment here **/';
Query OK, 0 rows affected (0.00 sec)
mysql> set @r:='/\\*(?:(?!\\*/).)*\\*/';
Query OK, 0 rows affected (0.00 sec)
mysql> select @r, regexp_count(@s, @r, '') c, regexp_extract(@s, @r, '') s;
+---------------------+------+---------------------------+
| @r | c | s |
+---------------------+------+---------------------------+
| /\*(?:(?!\*/).)*\*/ | 1 | /** some comment here **/ |
+---------------------+------+---------------------------+
1 row in set (0.00 sec)
因为几乎所有支持顺序环视的流派都支持忽略优先量词,完全可以用 /x.*?x/,所以效率并不是问题。
有两个可能的办法匹配第一个 x/ 之前的文本。一是把 x 作为开始分隔符和结束分隔符,也就是说匹配 x 之外的任何字符,以及之后字符不为斜线的 x。这样,“除结束分隔符之外的任何字符”就成了:
- 除 x 之外的任何字符:[^x]。
- 之后字符不是斜线的 x:x[^/]。
这样得到 ([^x]|x[^/])* 来匹配主体文本,/x([^x]|x[^/])*x/ 来匹配整个注释。但是,这条路行不通。
mysql> set @s:='/** some comment here **/';
Query OK, 0 rows affected (0.00 sec)
mysql> set @r:='/\\*([^*]|\\*[^/])*\\*/';
Query OK, 0 rows affected (0.00 sec)
mysql> select @r, regexp_count(@s, @r, '') c, regexp_extract(@s, @r, '') s;
+----------------------+------+------+
| @r | c | s |
+----------------------+------+------+
| /\*([^*]|\*[^/])*\*/ | 0 | |
+----------------------+------+------+
1 row in set (0.00 sec)
如果用 /x([^x]|x[^/])*x/ 来匹配 /xx foo xx/,在‘foo ’之后,第一个 x 由 x[^/] 匹配,没问题。但后一个 x 由 [^/] 匹配,而这个 x 本应该是标记注释的结束。于是继续下一轮迭代,[^x] 匹配斜线,结果会匹配 x/ 之后的文本,而没有匹配到结束符的斜线。
另一种办法是,把紧跟在 x 之后的斜线当作结束分隔符,这样“除结束分隔符之外的任何字符”就成了:
- 除斜线外的任何字符:[^/]。
- 不是紧跟在 x 之后的斜线:[^x]/。
于是用 ([^/]|[^x]/)* 匹配主文本,/x([^/]|[^x]/)*x/ 匹配整个注释。不幸的是,这同样是死路。
mysql> set @s:='/*/ some comment here /*/';
Query OK, 0 rows affected (0.00 sec)
mysql> set @r:='/\\*([^/]|[^*]/)*\\*/';
Query OK, 0 rows affected (0.00 sec)
mysql> select @r, regexp_count(@s, @r, '') c, regexp_extract(@s, @r, '') s;
+---------------------+------+------+
| @r | c | s |
+---------------------+------+------+
| /\*([^/]|[^*]/)*\*/ | 0 | |
+---------------------+------+------+
1 row in set (0.00 sec)
/x([^/]|[^x]/)*x/ 不能匹配 /x/ foo /x/。如果注释结尾后紧跟斜线,表达式匹配的内容会超过注释的结束分隔符,前一个方法也有这个问题。
mysql> set @s:='/** some comment here **// foo*/';
Query OK, 0 rows affected (0.00 sec)
mysql> set @r:='/\\*([^*]|\\*[^/])*\\*/';
Query OK, 0 rows affected (0.00 sec)
mysql> select @r, regexp_count(@s, @r, '') c, regexp_extract(@s, @r, '') s;
+----------------------+------+----------------------------------+
| @r | c | s |
+----------------------+------+----------------------------------+
| /\*([^*]|\*[^/])*\*/ | 1 | /** some comment here **// foo*/ |
+----------------------+------+----------------------------------+
1 row in set (0.00 sec)
mysql> set @s:='/*/ some comment here /*// foo*/';
Query OK, 0 rows affected (0.00 sec)
mysql> set @r:='/\\*([^/]|[^*]/)*\\*/';
Query OK, 0 rows affected (0.00 sec)
mysql> select @r, regexp_count(@s, @r, '') c, regexp_extract(@s, @r, '') s;
+---------------------+------+------------+
| @r | c | s |
+---------------------+------+------------+
| /\*([^/]|[^*]/)*\*/ | 1 | /*// foo*/ |
+---------------------+------+------------+
1 row in set (0.00 sec)
现在来修正这些表达式。在第一种情况下,x[^/] 匹配了结尾斜线前的 xx。如果用 /x([^x]|x+[^/])*x/,添加加号之后,x+[^/] 匹配以非斜线字符结尾的一连串 x。确实它能够这样匹配,但因为回溯“斜线之外的任意字符”仍然可以是 x,匹配优先的 x+ 匹配需要的额外的 x,但是如果全局匹配需要,回溯会逐个释放它们,所以它仍然会匹配过多内容。
mysql> set @s:='/** some comment here **// foo*/';
Query OK, 0 rows affected (0.00 sec)
mysql> set @r:='/\\*([^*]|\\*+[^/])*\\*/';
Query OK, 0 rows affected (0.00 sec)
mysql> select @r, regexp_count(@s, @r, '') c, regexp_extract(@s, @r, '') s;
+-----------------------+------+----------------------------------+
| @r | c | s |
+-----------------------+------+----------------------------------+
| /\*([^*]|\*+[^/])*\*/ | 1 | /** some comment here **// foo*/ |
+-----------------------+------+----------------------------------+
1 row in set (0.00 sec)
要解决这个问题,“紧跟字符不是斜线的一些 x”就应该用 x+[^/x],它会回溯到‘...xxx/’中的第一个 x 的位置停下来。为匹配注释结束之前的任意多个 x,必须加入 x+ 处理这种情况。于是得到 /x([^x]|x+[^/x])*x+/,匹配最终的注释。
mysql> set @r:='/\\*([^*]|\\*+[^/*])*\\*+/';
Query OK, 0 rows affected (0.00 sec)
mysql> set @s:='/** some comment here **// foo*/';
Query OK, 0 rows affected (0.00 sec)
mysql> select @r, regexp_count(@s, @r, '') c, regexp_extract(@s, @r, '') s;
+-------------------------+------+---------------------------+
| @r | c | s |
+-------------------------+------+---------------------------+
| /\*([^*]|\*+[^/*])*\*+/ | 1 | /** some comment here **/ |
+-------------------------+------+---------------------------+
1 row in set (0.00 sec)
mysql> set @s:='/*/ some comment here /*// foo*/';
Query OK, 0 rows affected (0.00 sec)
mysql> select @r, regexp_count(@s, @r, '') c, regexp_extract(@s, @r, '') s;
+-------------------------+------+---------------------------+
| @r | c | s |
+-------------------------+------+---------------------------+
| /\*([^*]|\*+[^/*])*\*+/ | 1 | /*/ some comment here /*/ |
+-------------------------+------+---------------------------+
1 row in set (0.00 sec)
为了提高表达式的效率,必须消除这个表达式的循环,下表给出了更够“消除循环”的表达式:
opening normal*(special normal*)* closing
元素 | 目的 | 正则表达式 |
opening | 注释开始 | /x |
normal* | 注释文本,包含一个或多个‘x’ | [^x]*x+ |
special | 不属于结束边界符的字符 | [^/x] |
closing | 结尾的斜线 | / |
表3
和子域名的例子一样,normal* 必须匹配至少一个字符。本例中必须的结束分隔符包含两个字符。任何以结束分隔符的第一个字符结尾的任何 normal 序列,只有在紧跟字符不能组成结束分隔符的情况下,才会把控制权交给 special 部分。所以按照通用的消除套路得到:
/x[^x]*x+([^/x][^x]*x+)*/
把每个 x 替换为 \*(字符组中的 x 替换为 *),得到实际的表达式。
mysql> set @r:='/\\*[^*]*\\*+([^/*][^*]*\\*+)*/';
Query OK, 0 rows affected (0.00 sec)
mysql> set @s:='/** some comment here **// foo*/';
Query OK, 0 rows affected (0.00 sec)
mysql> select @r, regexp_count(@s, @r, '') c, regexp_extract(@s, @r, '') s;
+------------------------------+------+---------------------------+
| @r | c | s |
+------------------------------+------+---------------------------+
| /\*[^*]*\*+([^/*][^*]*\*+)*/ | 1 | /** some comment here **/ |
+------------------------------+------+---------------------------+
1 row in set (0.00 sec)
mysql> set @s:='/*/ some comment here /*// foo*/';
Query OK, 0 rows affected (0.00 sec)
mysql> select @r, regexp_count(@s, @r, '') c, regexp_extract(@s, @r, '') s;
+------------------------------+------+---------------------------+
| @r | c | s |
+------------------------------+------+---------------------------+
| /\*[^*]*\*+([^/*][^*]*\*+)*/ | 1 | /*/ some comment here /*/ |
+------------------------------+------+---------------------------+
1 row in set (0.00 sec)
实际情况中,注释通常会包含多行,这个表达式也能应付。
mysql> set @s:=
-> '/*/ some comment here / foo
'> * some comment here * foo*
'> /**/';
Query OK, 0 rows affected (0.00 sec)
mysql> select @r, regexp_count(@s, @r, '') c, regexp_extract(@s, @r, '') s\G
*************************** 1. row ***************************
@r: /\*[^*]*\*+([^/*][^*]*\*+)*/
c: 1
s: /*/ some comment here / foo
* some comment here * foo*
/**/
1 row in set (0.00 sec)
这个正则表达式在实际中会遇到许多问题,它能识别 C 的注释,但不能识别 C 语法的其他重要方面。例如,下面的 /*...*/ 部分尽管不是注释,也能匹配。
mysql> set @s:='const char *cstart = "/*", *cend = "*/"';
Query OK, 0 rows affected (0.00 sec)
mysql> select @r, regexp_count(@s, @r, '') c, regexp_extract(@s, @r, '') s;
+------------------------------+------+------------------+
| @r | c | s |
+------------------------------+------+------------------+
| /\*[^*]*\*+([^/*][^*]*\*+)*/ | 1 | /*", *cend = "*/ |
+------------------------------+------+------------------+
1 row in set (0.00 sec)
下节接着讨论这个例子。
10. 流畅运转的正则表达式
正则表达式 /\*[^*]*\*+([^/*][^*]*\*+)*/ 存在错误匹配问题,比如下面这行 C 代码:
char *CommentStart = "/*"; /* start of comment */
匹配的结果是:/*"; /* start of comment */,然而希望的匹配结果应该是:/* start of comment */。
mysql> set @s:='char *CommentStart = "/*"; /* start of comment */';
Query OK, 0 rows affected (0.00 sec)
mysql> set @r:='/\\*[^*]*\\*+([^/*][^*]*\\*+)*/';
Query OK, 0 rows affected (0.00 sec)
mysql> select @r, regexp_count(@s, @r, '') c, regexp_extract(@s, @r, '') s;
+------------------------------+------+-----------------------------+
| @r | c | s |
+------------------------------+------+-----------------------------+
| /\*[^*]*\*+([^/*][^*]*\*+)*/ | 1 | /*"; /* start of comment */ |
+------------------------------+------+-----------------------------+
1 row in set (0.00 sec)
问题在于遇到双引号时该如何匹配。类似的情况还有单引号的 C 常量,双斜线方式的单行注释等。可以为每种情况定义一个分支进行匹配:
- 非单引号、双引号、斜杠符串:[^"'/]
- 双引号字符串:"[^\\"]*(?:\\.[^\\"]*)*"
- 单引号字符串:'[^'\\]*(?:\\.[^'\\]*)*'
- 单行或多行注释:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/
- 单行注释://[^\n]*
将这五个单独的表达式按顺序用 | 连接起来作为多选分支完全没有问题,因为它们之间没有重叠。传统型 NFA 只要找到匹配就会停止,因此将最常用的多选分支 [^"'/] 放在第一位。如果从左向右扫描用 | 串联起来的正则表达式会发现,应用到字符串时,一轮尝试存在以下几种可能:
- 匹配单个非单引号、双引号、斜杠字符
- 一次性匹配双引号字符串,直接到达其结尾。
- 一次性匹配单引号字符串,直接到达其结尾。
- 一次性匹配多行注释部分,直接到达注释末尾。
- 一次性匹配单行注释部分,直接到达注释末尾。
这样,正则表达式永远不会从单、双引号字符串或注释内部开始尝试,这就是成功的关键。用 MySQL 变量表示五个分支的正则表达式,注意反斜杠和单引号的转义。
set @other:='[^"\'/]';
set @double:='"[^\\\\"]*(?:\\\\.[^\\\\"]*)*"';
set @single:='\'[^\'\\\\]*(?:\\\\.[^\'\\\\]*)*\'';
set @comment1:='/\\*[^*]*\\*+(?:[^/*][^*]*\\*+)*/';
set @comment2:='//[^\\n]*';
将五个独立分支的表达式拼接起来:
set @r:=concat('(',@other,'+','|',@double,@other,'*','|',@single,@other,'*',')','|',@comment1,'|',@comment2);
这里有三点需要说明:
- 同一行中任何数量的 @other 字符都能归为一个单元,因此使用 @other+。因为之后没有元素强迫它回溯,不用担心发生无休止匹配。
- 一个引号字符串之后,在其他引号字符串和注释之前,很可能出现的就是 @other 的匹配,在每个引号字符串后添加 @other*,告诉引擎下面要匹配 @other,而不是马上进入下一轮循环。
这与消除循环的技巧很相似,此技巧之所以能提高速度,是因为它主导了正则引擎的匹配。这里使用了关于全局匹配的知识来进行局部优化,给引擎提供快速运转必须的条件。
非常重要的是,加在每个匹配引号字符串的子表达式之后的 @other 用的量词是星号,而排在多选结构最前面的 @other 必须用加号量词。如果开头的 @other 使用星号量词,则任何情况都能匹配。而引号字符串后的 @other 如果用加号量词,遇到两个连在一起的引号字符串就会出错。
- 将注释以外的所有分支放到一个捕获组中。这样如果能够匹配非注释分支,则 $1 会保存对应的内容。如果匹配了注释分支,$1 为空。结果是在 regexp_replace 函数中用 $1 进行替换就能将注释部分删除。
最终得到正则表达式 @r:
mysql> select @r;
+-------------------------------------------------------------------------------------------------------------------+
| @r |
+-------------------------------------------------------------------------------------------------------------------+
| ([^"'/]+|"[^\\"]*(?:\\.[^\\"]*)*"[^"'/]*|'[^'\\]*(?:\\.[^'\\]*)*'[^"'/]*)|/\*[^*]*\*+(?:[^/*][^*]*\*+)*/|//[^\n]* |
+-------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)
匹配和删除注释的结果如下:
mysql> set @s:=
-> 'char *CommentStart = "/*"; /* start of comment */
'> char *CommentEnd = "*/"; // end of comment';
Query OK, 0 rows affected (0.00 sec)
mysql> select regexp_count(@s, @r, '') c, regexp_extract(@s, @r, '') s\G
*************************** 1. row ***************************
c: 6
s: char *CommentStart = ,"/*"; ,/* start of comment */,
char *CommentEnd = ,"*/"; ,// end of comment
1 row in set (0.00 sec)
mysql> select @s, regexp_replace(@s, @r, '$1', 1, 0) c\G
*************************** 1. row ***************************
@s: char *CommentStart = "/*"; /* start of comment */
char *CommentEnd = "*/"; // end of comment
c: char *CommentStart = "/*";
char *CommentEnd = "*/";
1 row in set (0.00 sec)
开头的 @other+ 只有两种情况下能够匹配:1)匹配的文本在整个目标字符串的开头,此时还轮不到引号字符串的匹配;2)在任意注释之后。可能会想到不妨在注释后添加 @other+。这很不错,只是这里希望用第一对括号内的表达式匹配所有希望保留的文本。
那么如果 @other+ 出现在注释之后,是否还需要把 @other+ 放在开头呢?这取决于应用的数据——如果注释比引号字符串多,把它放在第一位有意义,否则就把它放后面。