浅谈正则表达式——从原理到实战

18,231 阅读7分钟

基本语法?底层原理?优化建议?实用示例?你想知道的,都在这里!

什么是正则表达式?

正则表达式(英语:Regular Expression,常简写为regex、regexp或RE),又称正则表示式正则表示法规则表达式常规表示法,是用来匹配和处理文本的一种特殊字符串模式。正则表达式主要由普通字符和特殊元字符组成,用于描述复杂的搜索模式。 它广泛应用于文本搜索、字符串替换、数据验证和数据处理等领域。

关于正则表达式的一些疑问?

  1. 可不可以写一个正则在全平台全场景下使用?
  2. 正则表达式如此强大,可否用它来解决一切问题?
  3. 为什么有些正则表达式在某些情况下可以匹配,但在其他情况下却不匹配?
  4. 正则表达式背后的原理是什么?

……

基础语法

元字符

类型字符描述举例
锚点^匹配输入字符串开始的位置。如果设置了多行匹配属性,^还会与\n 或 \r之后的位置匹配^download能匹配"download_finish",但不能匹配"finish_download" 或 pownload_finish
$匹配输入字符串结束的位置。如果设置了多行匹配属性,$还会与\n 或 \r之前的位置匹配download$能匹配"finish_download",但不能匹配"download_finish"
\A在多行模式下表示整个文本的起始位置,JavaScipt不支持
\Z在多行模式下表示整个文本的结束位置,JavaScipt不支持
量词*匹配前面的子表达式零次或多次(≥0)zo*能匹配"z"和"zoo"
+匹配前面的子表达式一次或多次(≥1)zo+能匹配"zo"和"zoo",但不能匹配"z"
?匹配前面的子表达式零次或一次(0|1)do(es)?能匹配"do"或"does"中的"do"
{n}n是一个非负整数,匹配确定的n次。o{2}不能匹配"Bob"中的'o',但能匹配"food"中的两个'o'
{n,}n是一个非负整数,至少匹配n次。{0,}等价于*,{1,}等价于+o{2,}不能匹配"Bob"中的'o',但能匹配"foooood"中的所有'o'
{n,m}n,m均是非负整数,且n≤m,最少匹配n次且最多匹配m次。{0,1}等价于?o{1,3}匹配"foooood"的结果是:ooo,oo
?紧跟上述任何量词后面时,匹配模式变为非贪婪。默认是贪婪匹配 o{1,3}?匹配"foooood"的结果是:o,o,o,o,o
范围x|y匹配x或ym|food能匹配m或food,(m|f)ood能匹配mood或food
[xyz]字符集合。匹配所包含的任意一个字符[abn]可以匹配"plain"中的a,n
[^xyz]负值字符集合。匹配未包含的任意字符[^abn]匹配"plain"的结果是p,l,i
[a-z]字符范围。匹配指定范围内的任意字符[a-m]可以匹配a到m范围内的任意字符,匹配"plain"的结果是l,a,i
[^a-z]负值字符范围。匹配任何不在指定范围内的任意字符[^a-m]可以匹配不在a到m范围内的任意字符,匹配"plain"的结果是p,n
简写字符.匹配除“\n”和"\r"之外的任何单个字符。要匹配包括“\n”和"\r"在内的任何字符,请使用像“[\s\S]”的模式。
\d匹配一个数字字符。等价于 [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_]'
\B匹配非单词边界'er\B' 能匹配 "verb" 中的 'er',但不能匹配 "never" 中的 'er'
\b匹配一个单词边界,也就是指单词和空格间的位置'er\b' 可以匹配"never" 中的 'er',但不能匹配 "verb" 中的 'er'
其他字符\转义字符*, +, ?, \, (, )……
(pattern)捕获型括号,匹配pattern,匹配pattern并捕获结果,自动获取组号顺序为按照左括号从左向右的的顺序
(?:pattern)非捕获型括号,匹配pattern,但不捕获匹配结果industr(?:y|ies)和industr(y|ies)表达的意思一致的,区别在于括号内的内容是否会被捕获。在下文中会有详细介绍。

值得注意的是,正则表达式拥有诸多流派和标准,各大标准中的元字符虽大同小异,但也有细微差别,需要仔细甄别后使用。

量词与贪婪匹配

从上文可知,正则表达式的量词统共有六种,在这 6 种元字符中,我们可以用 {m,n} 来表示 *、+、?这 3 种元字符:

元字符同义表示方法示例
*{0,}ab* 可以匹配 aabbb
+{1,}ab+ 可以匹配 ababbb,但不能匹配 a
?{0,1}(+86-)?\d{11} 可以匹配 +86-1380013800013800138000

但在实践中,还有一些细微差别,如下例子:

对于正则表达式"a+",用它来匹配"aaabb",得到的匹配结果是"aaa"。

而对于正则表达式"a*",用它来匹配"aaabb",得到的匹配结果除了"aaa",还有三个空字符串,这是因为*是匹配0次到多次到,0次就是空字符串。但是,为什么在"aaa"部分没有匹配空字符串呢,这里就引出了贪婪匹配和非贪婪匹配到概念。贪婪匹配和非贪婪匹配的简要概念如下:

  • 贪婪模式,尽可能进行最长匹配
  • 非贪婪模式,尽可能进行最短匹配

贪婪匹配

在正则中,表示次数的量词 默认是贪婪的,在贪婪模式下,会尝试尽可能最大长度去匹配。

首先,我们来看一下在字符串 aaabb 中使用正则"a*"的匹配过程。

匹配开始结束说明匹配内容
第 1 次03到第一个字母 b 发现不满足,输出 aaaaaa
第 2 次33匹配剩下的 bb,发现匹配不上,输出空字符串空字符串
第 3 次44匹配剩下的 b,发现匹配不上,输出空字符串空字符串
第 4 次55匹配剩下的空字符串,输出空字符串空字符串

贪婪模式的特点就是尽可能得进行最大长度匹配。

非贪婪匹配

与贪婪模式相反,非贪婪模式尽可能进行最短长度的匹配。在元字符后面加上?,就开启了非贪婪模式,我们还是用之前的例子:

可以看到,*尽可能匹配了最小长度,每次都匹配了空字符串,最终的结果如上所示。

独占模式

贪婪模式和非贪婪模式都需要回溯才能完成相应的功能,下面用一个例子来解释什么是回溯。

对于目标字符串"xxxyyy",正则表达式为"x{1,3}xy",分别来看它的匹配过程:

匹配模式正则表达式匹配过程能否匹配
贪婪模式x{1,3}xyregex101.com/r/oGWple/1
非贪婪模式x{1,3}?xyregex101.com/r/6LYQz3/1

贪婪模式和非贪婪模式虽然回溯的方式不同,但都会进行回溯。

独占模式和贪婪模式很像,独占模式会尽可能多地去匹配,如果匹配失败就结束,不会进行回溯,这样的话就比较节省时间。具体的方法就是在量词后面加上+。

用独占模式来匹配上面的例子,结果如下:

匹配模式正则表达式匹配过程能否匹配
独占模式x{1,3}+xyregex101.com/r/BH3rSo/1不能

可以看到,独占模式尽可能匹配了最大长度的x,当之后的xy无法被匹配时,本次匹配被放弃,最终导致无法匹配。

独占模式不会进行回溯,因此会有较好的性能,但也会导致一些情况不能被匹配,以及,不是所有语言、所有标准都支持独占模式,需要分析具体情况再进行选择。

回溯引发的性能问题

上文提到了贪婪匹配需要回溯才能完成相应匹配,而量词默认是贪婪匹配的。我们设想这样一个场景:用".*ab"来匹配"1abxxx",".*"的贪婪匹配会先进行最长匹配,覆盖整个字符串,再一个个“吐”出字符,来匹配之后的"ab",若后面的xxx足够长,肯定会导致严重的性能问题,下面举两个例子来说明这一点:

很长的xxx:regex101.com/r/OU4IYf/1

可以看到,尽管开头的"1ab"就已经可以进行匹配,但正则表达式还是用了200余次的回溯才完成匹配,那么,如果后面的xxx更长一些,会怎么样呢?

非常长的xxx:regex101.com/r/kcKog5/2 (直接爆栈)

一句非常简短的正则便引发了如此严重的性能问题,所以在写正则时一定要注意回溯问题,避免使用低效率正则,尽量避免使用".*"这样的正则。

不过,并不是所有场景下该正则都会导致性能问题,这与正则表达式的驱动引擎密切相关,会在后文中介绍。

捕获与引用

括号在正则中可以用于捕获,被括号括起来的部分 「子表达式」会被保存成一个捕获组。

捕获,顾名思义,就是指匹配特定的子表达式,并保存结果,如下面的例子:

如图,括号构成的捕获组捕获了两个子表达式,分别是"2023-10-12"和"20:23:32",日期是第一个,时间是第二个。

但是,如何知道某个子表达式是在哪一个分组呢?很简单,只需要数左括号在哪个位置即可。

日期分组编号是 1,时间分组编号是 5,年月日对应的分组编号分别是 2,3,4,时分秒的分组编号分别是 6,7,8。

分组也可以进行命名,这里就不展开讲了。

知道编号之后,在大部分情况下,我们就可以通过编号对捕获组进行查找和替换,这就是“引用”,一些引用的常见使用方式如下:

编程语言查找时引用方式替换时引用方式
Python\number 如 \1\number 如 \1
Go官方不支持官方不支持
Java\number 如 \1$number 如 $1
JavaScript$number 如 $1$number 如 $1
PHP\number 如 \1\number 如 \1
Ruby\number 如 \1\number 如 \1

如上例,我们可以用\1代表第一个捕获组,这样在日期被匹配后,\1会匹配一个完全相同的内容。这种模式很有用,比如在寻找两个重复出现的单词的时候,可以用"(\w+) \1"这一正则来寻找,如下所示:

此外,也可以在替换中使用,如下所示:

采用捕获和引用,我们可以更加方便地提取字符串中的一些信息,并进行转换和重组。

这里给出一个简单的例子,利用捕获和引用将11位手机号转为"xxx-xxxx-xxxx"格式:regex101.com/r/7uuu2M/1

不过,由于捕获并引用需要更多的内存,有时,一些不恰当的正则可能会导致性能问题,因而,有些编程语言不支持引用,如Golang(但是Golang支持捕获),这也与正则表达式的驱动引擎有关,会在下文中详细介绍。

匹配模式

正则表达式主要有四种匹配模式(Match Mode)。所谓匹配模式,指的是正则中一些 改变元字符匹配行为 的方式,比如匹配时不区分英文字母大小写。常见的匹配模式如下表所示:

匹配模式描述
不区分大小写模式 (i)在匹配时不区分大小写。例如,正则表达式 /hello/i 可以匹配 “hello”、”Hello”、”HELLO” 等。
点号通配模式 (s)让点号 (.) 也能匹配换行符。默认情况下,点号只匹配除换行符之外的所有字符。使用 /hello.*/ 可以匹配 “hello”、“hello\n\n\n” 等。
多行匹配模式 (m)在多行模式下,^ 或 不仅仅匹配整个文本的开头或结尾,还可以匹配每一行的开头或结尾。使用/hello/m不仅仅匹配整个文本的开头或结尾,还可以匹配每一行的开头或结尾。使用/hello/m 可以匹配 “hello”、“hello\nworld” 中的 “hello”。
注释模式 (x)注释模式允许在正则表达式中添加注释,以增加可读性。注释以 # 开头,直到行末结束。

环视

在正则中我们有时候也需要瞻前顾后,找准定位。环视就是要求匹配部分的前面或后面要满足(或不满足)某种规则,有些地方也称环视为零宽断言。

环视在检查子表达式能否匹配的过程中,它们本身不会“占用”任何文本,通俗来理解,可以认为表达式在环视进行时,会在前面或后面先检查是否满足一定的子表达式条件,若满足,则继续匹配。

下面用肯定顺序环视来举例:regex101.com/r/fJoAm5/1

在这个例子中,只有后面的字符串满足"bytedance"时,才会匹配"byte"这一字符串。

环视主要有四种,下面是对它的简要介绍:

类型正则表达式匹配成功的条件示例
肯定逆序环视(?<=……)子表达式能够匹配左侧文本(?<=abc)x,能匹配到x,但x左面必须是abc才行
否定逆序环视(?<!……)子表达式不能匹配左侧文本(?<!abc)x,能匹配到x,但只有x左面不是abc才行
肯定顺序环视(?=……)子表达式能够匹配右侧文本x(?=abc),能匹配到x,但x右面必须是abc才行
否定顺序环视(?!……)子表达式不能匹配右侧文本x(?!abc),能匹配到x,但只有x右面不是abc才行

匹配原理

有穷状态自动机

有穷状态自动机(Finite Automaton)是正则表达式处理文本的“引擎”,它根据输入的顺序和当前的状态,逐步改变其状态,在每个状态时都会有一个或多个输出。有穷状态自动机包含有限个状态,其中一个为初始状态,根据输入的类型,会有一组相应的转换规则决定接下来切换到的状态。

有穷状态自动机的具体实现有NFA(Nondeterministic finite automaton,非确定有限状态自动机)和DFA(Deterministic finite automaton,确定性有限状态自动机)两种,其中,NFA又分为传统NFA和POSIX NFA。

结论先行,这里先给出以上三种引擎的特点总结:

引擎类型程序忽略优先量词(非贪婪匹配)引用回溯
DFAGolang、MySQL、awk(大多数版本) 、egrep(大 多数版本) 、flex、lex、Procmail不支持(Golang除外)不支持不支持
传统型NFAPCRE library、Perl、PHP、Java、Python、Ruby、grep(大多数版本) 、GNU Emacs、less、 more、.NET语言、sed(大多数版本)、vi支持支持支持
POSIX NFAmawk、Mortice Kern Systems'utilities、GNU Emacs(明确指定时使用)不支持不支持支持
DFA/NFA混合GNU awk、GNU grep/egrep、Tcl支持支持DFA支持

其中,主流场景下一般使用DFA和传统型NFA,下文也主要针对这二者的特性进行详述。

表达式主导与文本主导

从一个可能的场景出发,来介绍NFA和DFA的重要区别:用正则表达式"byte(dance|tech|doc)"来匹配"bytetech"。

NFA引擎:表达式主导

对于NFA来说,正则表达式会从"b"开始,每次检查一部分(由引擎查看表达式的一部分),同时检查当前文本是否匹配表达式的当前部分,若是,则继续表达式的下一部分,如此继续,知道表达式的所有部分都能匹配,即整个表达式匹配成功。

在本例子中,正则表达式"byte(dance|tech|doc)" 第一个字母是"b",则它会不断尝试,直到在目标字符串中找到"b",在找到之后,就检查紧跟其后的"y"、"t"、"e"能否被匹配,之后进行到多选分枝中,NFA会依次尝试"dance"、"tech"、"doc"这三种分支,直到匹配成功或报告失败。表达式中的控制权在不同的元素之间转换,所以可以将它称为“表达式主导”。

我们将这一过程转换为NFA图像,如下所示,其中ε代表空转换,意思是不需要任何操作,即可从前一状态转换到下一状态。我们可以清晰的看到,状态4引出了三个状态,表达式在匹配时,会依次尝试这三种状态。

注:传统型NFA在多选结构中是从上到下依次选择,而 POSIX NFA则是优先选择较长路径进行匹配。

DFA引擎:文本主导

与NFA不同,DFA引擎在扫描字符串时,会记录“当前有效”的所有匹配可能,还是上面的例子,当引擎移动到"b"时,只有下一个字符是"y",才会移动到下一过程,否则报告匹配失败。当引擎移动到"e"时,可能的下一步是"d"或者"t",(可以理解为某个状态需要哪种字符才能转换到下一状态,已经在DFA被构建之时就计算好了),这一步可以表示为:byte(dance|tech|doc),当下一个字母是"t"时,引擎继续向前移动,其他两个分支被淘汰,就这样继续匹配,直到得出结论。

这种方式称为文本主导,是因为它扫描到字符串中的每个字符都对引擎进行了控制。

文字可能不容易精确描述这一过程,我们在这里给出该表达式的DFA转换图,如下所示。可以看到,在E状态,只有"d"或者"t"会让引擎转换为下一个状态,当走入新的状态时,没有回头路

在形式理论中可以证明NFA和DFA是等价的,所以DFA也是从NFA转换而来的,转换过程如下,感兴趣的同学可以了解一下(在编译原理中被大量使用)。

再谈回溯

在上文的表格中提到:NFA可以进行回溯,但是 DFA 不可以。 这是为什么呢?

在正则表达式中,基于NFA的实现允许回溯,因为NFA的每个状态对相同的输入,可能有多种转移选择。如果一条路径没有找到符合的匹配结果,NFA会回溯到上一个节点,寻找另外一条可能的转移路径。这种设计允许正则表达式具有更加丰富的表达性,并确保找到所有的匹配结果。我们继续拿出上例中的NFA转换图来举例,在4状态,NFA有三种可能选择的路径,而空转换ε,则为回溯的存在提供了空间,在实现中,可以理解为NFA在空转换中插入了锚点(或者理解为存档点?)当某条路不通时,NFA退回到之前的锚点,换条路继续走,直到匹配成功或报告失败。

而DFA没有此类回溯的能力,因为在DFA中,每个状态对相同的输入只有一个确定的转移结果。一旦某个状态的转移操作被触发,就无法再回溯到上一个状态。

我们继续用上图举例,在DFA中,不存在空转换,也就没有锚点一说,整个DFA就是一个只能继续向前的火车,没有回头路,这当然会让匹配效率加快,但相比NFA,也丧失了一些功能和灵活性(例如,如上表所示,DFA不支持引用、回溯等功能)。

从匹配原理角度理解“贪婪匹配”和“非贪婪匹配”

  • “贪婪匹配”从本质上讲,就是在进行匹配优先(标准量词都是匹配优先的)
  • “非贪婪匹配”从本质上讲, 就是在进行忽略优先(在".*"后加上忽略优先量词"?"即开启忽略优先)

我们还是回到使用".*ab"来匹配"1abxxx"这个例子中,看看如何理解匹配优先和忽略优先。

这里给出该正则表达式的NFA图:

从图中可以看出,从0状态到3状态有两条路径,匹配优先就是优先选择下面一条路径,因为要优先匹配".",一旦被匹配,就会一直在状态1和状态2之间流转,最终匹配完所有字符后,开始回溯,以匹配后面的"ab",而忽略优先则是优先进行空转换,直接跳到3状态。

值得一提的是,DFA中不存在“匹配优先”和“忽略优先”的概念,因为二者从NFA转为DFA后,都失去了空转换,无法进行回溯,如下图所示:

但是DFA仍然是“贪婪匹配”的,对于该例子,DFA会一直寻找最长的以ab为结尾的子串,直到遍历整个字符串。

尽管都遍历了整个字符串,但是DFA的时间复杂度是线性的,而且由于没有回溯,避免了栈的无序增长,所以在绝大部分场景下,性能要比NFA好很多。

一些优化建议

  • 对于复杂正则,可以提前编译好再调用,否则每次都会构建一次自动机

  • 避免使用"."进行匹配,尽量准确表示匹配范围

  • 对于多分支匹配,提取出公共部分

  • 将出现可能性大的子表达式放在左边

  • 只在必要的时候使用捕获组

    • 若需要分组以降低理解成本,尽量使用非捕获组(?:)
  • 警惕嵌套的子组重复

  • 避免不同分支的重复匹配

特性与流派概述

正则表达式简史

  • 正则表达式的历史可以追溯到二十世纪四十年代,神经生理学家Warren McCulloch和Walter Pitts首先提出了一种数学化的方式来描述神经网络。进一步,在1956年,数学家Stephen Kleene提出了名为“正则集合(Regular Sets)”的符号。
  • 1960年代,Unix的创始人之一Ken Thompson将正则表达式整合进了他开发的文本编辑器qed和ed,并在后续将其加强到了grep工具中。这给Unix和类Unix的系统工具带来了广泛的应用。
  • 由于早期不存在统一的标准,导致了正则表达式在不同语言和工具中存在一些差异。为此,1986年POSIX开始将正则表达式进行标准化,并被Unix及类Unix系统广泛接受。
  • 此后,Perl语言在20世纪八十年代末也引入了正则表达式,功能强大且使用方便,受到了广泛的欢迎。并在其基础上,Philip Hazel开发出了Perl兼容的正则表达式解析引擎——PCRE。
  • 进入到21世纪,又出现了一个新的正则表达式库——RE2。这是由谷歌开发的一个开源C++库,它主要设计用于安全且有效地匹配大型文本。RE2 是线性时间匹配的,适用于处理大规模数据。
  • 至今,正则表达式已经成为所有计算机语言及许多应用领域不可缺少的工具。POSIX和PCRE以及RE2是当前广泛应用的主要规范和库。

正则表达式流派

就像前面所说,目前正则表达式主要有三大流派(Flavor):POSIX 流派、 PCRE 流派以及RE2流派。

POSIX流派

POSIX 规范定义了正则表达式的两种标准:

  • BRE 标准(Basic Regular Expression 基本正则表达式)
  • ERE 标准(Extended Regular Expression 扩展正则表达式)

早期 BRE 与 ERE 标准的区别主要在于,BRE 标准不支持量词问号和加号,也不支持多选分支结构管道符。BRE 标准在使用花括号,圆括号时要转义才能表示特殊含义。BRE 标准不能满足全场景需要,于是有了 ERE 标准,在使用花括号,圆括号时不需要转义了,还支持了问号、加号 和 多选分支。

我们现在使用的 Linux 发行版,大多都集成了 GNU 套件。GNU 在实现 POSIX 标准时,做了一定的扩展。下面是BRE和ERE的一些对比:

正则表达式特性BRE标准ERE标准
点号.、^、$、[...]、[^...]✔️✔️
"任意数目"量词*✔️✔️
+和?量词✖️(GNU BRE支持)✔️
区间量词{min, max}{min, max}
圆括号分组(...)(...)
量词可否限定圆括号分组✔️✔️
捕获文本引用\1到\9✖️(GNU ERE支持)
多选分支结构✖️(GNU BRE支持)✔️

总之,GNU BRE 和 GNU ERE 它们的功能特性并没有太大区别,区别是在于部分语法层面上,主要是一些字符要不要转义。

POSIX 流派还有一个特殊的地方,就是有自己的字符组,叫 POSIX 字符组。POSIX字符组不同于常规定义的字符组。具体的清单和解释如下所示:

POSIX字符组解释等价表示备注
[[:alnum:]]数字和字母[0-9A-Za-z]比\w少了下划线
[[:alpha:]]字母[A-Za-z]
[[:ascii:]]ASCII[\x00-\x7F]
[[:blank:]]空格和制表符[\t]
[[:cntrl:]]控制字符[\x00-\x1F\x7F])
[[:digit:]]数字[0-9]\d
[[:graph:]]可见字符[!-~] [A-Za-z0-9!"#$%&'()*+,-./:;<=>? @\]^_{|}]
[[:lower:]]小写字母[a-z]
[[:upper:]]大写字母[A-Z]
[[:print:]]可打印字符[-~] [[:graph:]]比graph多了空格
[[:punct:]]标点符号[!-/:-@[-`{-~]
[[:space:]]空白符号[\t\n\v\f\r]
[[:xdigit:]]16进制数字[0-9A-Fa-f]

PCRE流派

PCRE,全称Perl Compatible Regular Expressions,是一个开源库,为了最大限度地兼容Perl的正则表达式语法而设计。PCRE提供了相对POSIX更丰富的特性,例如命名捕获组、向前和向后查找断言、递归匹配等。

PCRE流派是主要受Perl语言的影响而形成的,它使用非常灵活和丰富的语法,可以应用在各种复杂的文本处理任务中。PCRE流派的特点是以方便、灵活性和功能强大为主,语法元素更加丰富,特性更为强大。

相比之下,POSIX流派主要遵循POSIX的正则表达式标准。这个标准更关注通用性和跨平台的兼容性,语法较为简洁。

PCRE流派与POSIX流派的区别在于以下几个方面:

  1. 功能特性:PCRE流派的正则表达式语法更为丰富,支持更多特殊字符和元字符,提供了更灵活和强大的模式匹配功能。而POSIX流派的正则表达式语法较为简洁,功能相对有限。
  2. 兼容性:PCRE流派的正则表达式语法和功能是基于Perl语言的,因此与Perl语言中的正则表达式非常相似,可以很好地兼容和迁移。而POSIX流派的正则表达式则是基于POSIX标准,更为规范和标准化。
  3. 应用范围:PCRE流派的正则表达式被广泛应用于各种编程语言和工具中,提供了更大的灵活性和功能扩展性。而POSIX流派的正则表达式多用于Unix和类Unix系统中的工具,如grep、sed、awk等。

RE2流派

RE2 是 Google 公司开发的一个用 C++ 实现的正则表达式库。它是由 Google 的工程师 Russ Cox 所开发,2010年发布。RE2 的设计旨在确保正则表达式操作的时间和空间复杂性与输入大小呈线性关系,并且在算法上避免可导致超线性时间复杂性的构造。

RE2 的出现主要是由于传统的正则表达式库(例如 PCRE )在处理特定的、复杂的正则表达式时可能会导致严重的性能问题,甚至服务被拖垮。这主要是因为这类库通常采用回溯的算法,对于某些复杂的正则表达式,可能导致指数级别的匹配时间。

RE2 在这一块做了优化,它主要基于DFA构建,保证了在所有情况下都具有线性的时间复杂性,从而避免了正则表达式处理的“灾难性回溯”,使得其在需要处理大量数据或复杂模式时有更高的效率。但RE2也通过特殊方式实现了部分 PCRE 的功能,如非贪婪匹配,它并不是纯粹的DFA。

RE2流派特点如下:

  1. 高效性能:RE2引擎在匹配和搜索正则表达式时非常高效,其设计目标是提供快速的操作速度。
  2. 安全性:RE2引擎使用了一种受限制的正则表达式语法,可以确保匹配的过程在有限时间内完成,避免了可能出现的正则表达式回溯(catastrophic backtracking)问题。RE2基于DFA构建,能够对一些低效率的正则进行化简,很大程度上避免了上文提到的爆栈问题。
  3. 简洁性:RE2引擎提供了一种简洁的正则表达式语法,相对于PCRE来说更加简单易懂,但是缺少了一些高级特性,例如捕获组、后向引用以及环视等。
  4. 跨平台可移植性:RE2引擎支持多种编程语言,包括C++Go、Java和Python(Java和Python的自带正则仍然属于PCRE流派)等,因此具有很好的跨平台可移植性。

实用例子

列出Golang中所有函数名、参数列表及返参列表

这里用x模式来写,便于理解,此外,正则表达式很灵活,正确答案不止一个。

/
    (?<=func\s) # 环视,若左边有func,开始匹配
    (\w+) # 第一个捕获组,捕获函数名
    (?=() # 环视,若后面有括号,开始匹配(最终效果,只匹配左边有func,右边有空格的字符串)
    \s* # 匹配任意空白字符
    ( # 转义,匹配左括号
    ([\w\s.,]*) # 第二个捕获组,匹配参数列表
    ) # 转义,匹配右括号
    \s* # 匹配任意空白字符
    ([\w\s.,]*) # 第三个捕获组,匹配返参列表
/gmx

具体匹配过程及示例如下:regex101.com/r/ULPMYB/2

校验密码强度

利用环视进行密码长度校验,会让整个正则更加清晰易懂,但注意这里用了".*",在贪婪匹配下可能有性能问题,所以在校验之前最好先限制一下密码长度。

/
    ^ # 匹配行首
    (?=.*[A-Z]) # 向前环视,匹配到大写字母
    (?=.*[a-z]) # 向前环视,匹配到小写字母
    (?=.*\d) # 向前环视,匹配到数字
    (?=.*[_\W]) # 向前环视,匹配到特殊字符
    .{10,} # 若满足以上环视条件,且有10个以上字符,则进行匹配
    $ # 匹配行尾
/gmx

具体匹配过程及示例如下:regex101.com/r/iDnx1s/1

一些语言不支持环视(如Golang),可以使用多个正则来对各个条件进行分别判断。

剔除Markdown中的代码

这个问题并没有看上去那样简单,因为我们可能对Markdown中插入代码之灵活性不够了解。

首先,我们需要知道,如何在Markdown中导出代码块,主要有三种方式:

  1. 三个及以上反引号(开始反引号数量三个及以上,结束反引号数量大于等于开始反引号数量)对导出一个代码块

  1. 三个及以上波浪号对(开始波浪号数量三个及以上,结束波浪号数量大于等于开始波浪号数量)导出一个代码块

  1. 四个及以上空格或一个及以上的制表符导出一个代码块

要全部考虑以上情况,是很复杂的,考虑到不同语言和标准对各个特性的支持程度不同,这里给出两个版本的答案:

第一个版本:Golang版,由于Golang属于RE2流派,不支持回溯、引用与环视,所以只用它对三个反引号和三个波浪号导出的代码块进行匹配,这个版本能解决85%的问题。

/
    /(?:  # 采用非捕获括号进行分组(非必要不使用捕获型括号)
        ```[\s\S]*?```  # 采用非贪婪匹配(必须),匹配0到n次空白或非空白字符(任意字符)
    )  # 结束第一个分支
    |  # 或符号
    (?:  # 同上,采用非捕获型括号
        ~~~[\s\S]*?~~~  # 同上,非贪婪匹配来匹配0到n次空白或非空白字符
    )  # 结束第二个分支
/gmx

具体匹配过程及示例:regex101.com/r/H3L388/1

第二个版本, PCRE ,也就是大部分编程语言支持的流派,能够回溯、引用与环视,所以可以用它来匹配四个及以上空格或一个及以上制表符导出的代码块(需要采用肯定逆序环视观察前一行,避免误判),这个版本能解决98%的问题。

/
    /(?:  # 采用非捕获括号进行分组(非必要不使用捕获型括号)
        (`{3,})  # 捕获组,匹配三个及以上反引号,并进行捕获
        [\s\S]*?  # 采用非贪婪匹配(必须),匹配0到n次空白或非空白字符(任意字符)
        (?:(?:\1`*)|\Z)  # 引用,引用了之前的捕获组,若之前捕获了5个反引号,则在此必须匹配5个以上反引号,若开头三个以上反引号,后面没有比开头更多的反引号,则后面都是代码块
    )  # 结束第一个分支
    |  # 或符号
    (?:  # 同上,采用非捕获型括号
        (~{3,})  # 同上,匹配三个及以上波浪号并进行捕获
        [\s\S]*?  # 同上,非贪婪匹配来匹配0到n次空白或非空白字符
        (?:(?:\2~*)|\Z)  # 引用,引用了之前的捕获组
    )  # 结束第二个分支
    |  # 或符号
    (?:  # 同上,采用非捕获型括号
        (?<=  # 肯定型逆序环视,左侧文本必须满足条件才可以匹配
            (?:^\n)|(?:^\A)  # 上一行必须只有一个回车(以回车符号开头)或没有上一行
        )  # 结束环视
        (?:  # 采用非捕获型括号分组
            (?:\s{4,}?|\t+?)[^\n]*(?:\n|$)  # 匹配四个及以上空格或一个以上制表符开启的一行
        )+  # 上面的子组匹配1次到n次
    )  # 结束第三个分支
/gxm

具体的匹配过程及示例:regex101.com/r/pwkRKA/9

但是以上正则表达式都没有考虑最极端的一种情况:在Markdown中的列表之间,需要八个空格或两个制表符才能导出一个代码块,PCRE版本虽然能正常匹配,但存在误判的可能,若考虑这一点,正则表达式将更为复杂,也不便于阅读调试,故不给出。上面的例子已经可以适用于绝大部分场景。实际场景中应当综合考虑成本与复杂度来选择正则,正则无法解决所有问题,所以难免存在一些badcase,很多问题需要和程序结合才能得到更好的解决。

一些思考

正则表达式的本质是什么?

正则表达式可以看作是一种微型的、专注于文本处理的编程语言。正则表达式有自己的语法和结构,可以用来描述和匹配一系列满足某些特性的字符串。它本质上也是一门编程语言。 与SQL、LaTeX等语言类似,都是针对问题领域专门设计的语法元素来编写程序或表达式。

作为一种高度抽象的编程语言,正则表达式是为特定场景设计的领域特定语言(DSL),不同于我们一般接触的通用编程语言(GPPL),它在计算机能力和表达能力上是有很大限制的,但在自己的擅长领域——字符串处理领域,拥有极其强大的能量。

但即使如此,我们也不能奢望,用“一行很酷的正则”解决所有问题,正如Fred Brooks在《人月神话》中提到的,“没有银弹”,每个项目都有其特定的挑战和限制,需要综合考虑多种因素,并灵活应对。软件开发是一项复杂的任务,需要结合多种技术、方法和经验,持续演进和改进。

或许,正则表达式的发展历程也在提醒我们注意这一思想吧,从POSIX到PCRE,再到RE2,体现了技术人员在不同阶段对标准性、功能性、安全性的不同追求,我们在日常开发时也应注意随机应变,灵活应对。

附录