正则表达式的真正威力(2012)
摘要
这篇文章解释了像 PCRE 这样的现代正则表达式引擎能够解析远超正则语言的内容,驳斥了“HTML 无法用正则表达式解析”这一常见说法。
暂无内容
查看缓存全文
缓存时间: 2026/08/03 10:31
# 正则表达式的真正威力
Source: https://www.npopov.com/2012/06/15/The-true-power-of-regular-expressions.html
作为一个经常逛 StackOverflow 上 [PHP 标签](https://stackoverflow.com/questions/tagged/php) 的人,我经常看到关于如何使用正则表达式解析 HTML 某一部分的问题。对这种问题的常见回复是:
> 你不能用正则表达式解析 HTML,因为 HTML 不是正则的。请改用 XML 解析器。
这种说法——在该问题的语境下——介于非常误导和完全错误之间。我在这篇文章中想要展示的是,现代正则表达式*真正*有多强大。
## “正则”到底是什么意思?
在[形式语言理论](https://en.wikipedia.org/wiki/Formal_language)的语境下,当一个文法满足所有产生式都具有以下形式之一时,我们称它为“正则的”:
你可以把这些 `->` 规则理解为“左边可以被右边替换”。所以第一条规则就是“B 可以被替换为 a”,第二条是“B 可以被替换为 aC”,第三条是“B 可以被替换为空字符串”(`ε` 是空字符串的符号)。
那么 `B`、`C` 和 `a` 又是什么呢?按惯例,大写字符表示所谓的“非终结符”——即*可以*进一步分解的符号;而小写字符表示“终结符”——即*不能*再进一步分解的符号。
这些听起来可能有点抽象,那我们来看一个例子:把自然数定义成一个文法。
```
N -> 0
N -> 1
N -> 2
N -> 3
N -> 4
N -> 5
N -> 6
N -> 7
N -> 8
N -> 9
N -> 0N
N -> 1N
N -> 2N
N -> 3N
N -> 4N
N -> 5N
N -> 6N
N -> 7N
N -> 8N
N -> 9N
```
这个文法说的是:
```
一个自然数 (N) 是 ...
0 到 9 之间的某个数字,或者 ...
0 到 9 之间的某个数字,后面跟着另一个自然数 (N)
```
在这个例子中,0 到 9 这些数字是终结符(因为它们不能被进一步分解),而 `N` 是唯一的非终结符(因为它可以并且确实被进一步分解)。
如果你再看一遍这些规则,并将其与上面正则文法的定义对比,你会看到它们完全符合标准:前十条规则是 `B -> a` 的形式,后十条规则符合 `B -> aC` 的形式。因此,定义自然数的文法是*正则的*。
你可能还会注意到另一件事:虽然上述文法定义的东西如此简单,但它已经很臃肿了。如果我们能用更简洁的方式表达同样的概念,那不是更好吗?这正是正则表达式发挥作用的地方:上面的文法等价于正则表达式 `[0-9]+`(它要简单得多)。而这种转换对*任何*正则文法都是可行的:每个正则文法都有一个对应的正则表达式,该表达式定义了它所有的合法字符串。
## 正则表达式能匹配什么?
于是问题来了:正则表达式只能匹配正则文法,还是也能匹配更多?
这个问题的答案既是“能”也是“不能”:形式文法意义上的正则表达式(按照定义几乎可以肯定)只能解析正则文法,不能更多。但当程序员谈论“正则表达式”时,他们并不是在谈论形式文法。他们谈论的是其语言所实现的正则表达式*衍生品*。而这些正则表达式实现与最初的“正则性”概念只有非常微弱的联系。任何现代正则表达式风格都能匹配远*多*于正则语言的东西。
到底多多少?这正是本文余下部分要讲的内容。为了简单起见,下面我将重点讨论 PCRE 正则表达式实现,只因为我对它最熟悉(因为 PHP 使用它)。不过大多数其他正则表达式实现都相当类似,所以大部分内容也应该适用于它们。
## 语言层级
为了分析正则表达式能匹配什么、不能匹配什么,我们首先需要看看还存在哪些其他类型的语言。一个很好的起点是[乔姆斯基层级](https://en.wikipedia.org/wiki/Chomsky_hierarchy):
```
Chomsky hierarchy:
/-------------------------------------------\
| |
| Recursively enumerable languages Type 0 |
| |
| /-----------------------------------\ |
| | | |
| | Context-sensitive languages Type 1| |
| | | |
| | /---------------------------\ | |
| | | | | |
| | | Context-free languages | | |
| | | Type 2 | | |
| | | | | |
| | | /-------------------\ | | |
| | | | | | | |
| | | | Regular languages | | | |
| | | | Type 3 | | | |
| | | \-------------------/ | | |
| | \---------------------------/ | |
| \-----------------------------------/ |
\-------------------------------------------/
```
正如你所见,乔姆斯基层级把形式语言分为四种类型:正则语言(Type 3)能力最弱,其次是上下文无关语言(Type 2),再次是上下文相关语言(Type 1),最后是无所不能的递归可枚举语言(Type 0)。乔姆斯基层级是一个包含层级,因此上图中较小的方框被完全包含在较大的方框内。例如,每个正则语言同时也是上下文无关语言(但*反之*不然!)
那么,让我们在这个层级中向上迈一步:我们已经知道正则表达式能匹配任何正则语言。但它们也能匹配上下文无关语言吗?(提醒:这里说“正则表达式”,我显然是在程序员的意义上说的,而不是形式语言理论的意义。)
## 匹配上下文无关语言
答案是*可以*,它们能!让我们以经典的上下文无关语言为例,即 `{a^n b^n, n>0}`,意思是“一定数量的 `a` 字符,后面跟着*同样数量*的 `b` 字符”。这个语言的(PCRE)正则表达式是:
```
/^(a(?1)?b)$/
```
这个正则表达式非常简单:`(?1)` 是对第一个子模式的引用,即 `(a(?1)?b)`。所以基本上你可以用这个子模式替换 `(?1)`,从而形成一个递归依赖:
```
/^(a(?1)?b)$/
/^(a(a(?1)?b)?b)$/
/^(a(a(a(?1)?b)?b)?b)$/
/^(a(a(a(a(?1)?b)?b)?b)?b)$/
# 依此类推
```
从上面的展开应该可以清楚地看到,这个表达式可以匹配任何 `a` 和 `b` 数量相同的字符串。因此,正则表达式至少可以匹配一些非正则的上下文无关文法。
但它们能匹配所有吗?为了回答这个问题,我们首先需要看看上下文无关文法是如何定义的。在上下文无关文法中,所有产生式都采用以下形式:
这里的 `A` 同样是一个非终结符,而 `β` 是终结符和非终结符组成的任意字符串。因此,上下文无关文法的每条产生式左侧都有一个非终结符,右侧是一个任意符号串。
举个例子,看一下下面的文法:
```
function_declaration -> T_FUNCTION is_ref T_STRING '(' parameter_list ')' '{' inner_statement_list '}'
is_ref -> '&'
is_ref -> ε
parameter_list -> non_empty_parameter_list
parameter_list -> ε
non_empty_parameter_list -> parameter
non_empty_parameter_list -> non_empty_parameter_list ',' parameter
// ... ... ...
```
你看到的是 PHP 文法的一个片段(只是几个示例规则)。语法与我们之前使用的略有不同,但应该很容易理解。值得一提的是,这里大写的 `T_SOMETHING` 名称也是终结符。这些通常被称为*token*的符号编码了更抽象的概念。例如,`T_FUNCTION` 代表 `function` 关键字,`T_STRING` 是一个标签 token(如 `getUserById` 或 `some_other_name`)。
我用这个例子来展示一件事:上下文无关文法已经强大到足以编码相当复杂的语言。这就是为什么几乎所有的编程语言都有上下文无关文法。特别是,这还包括结构良好的 HTML。
现在,回到实际问题:正则表达式能匹配所有上下文无关文法吗?答案同样是*可以*!这很容易证明,因为正则表达式(至少 PCRE 和类似的实现)提供了与上述文法构造方式非常相似的语法:
```
/
(?(DEFINE)
(?<addr_spec> (?&local_part) @ (?&domain) )
(?<local_part> (?&dot_atom) | (?"ed_string) | (?&obs_local_part) )
(?<domain> (?&dot_atom) | (?&domain_literal) | (?&obs_domain) )
(?<domain_literal> (?&CFWS)? \[ (?: (?&FWS)? (?&dtext) )* (?&FWS)? \] (?&CFWS)? )
(?<dtext> [\x21-\x5a] | [\x5e-\x7e] | (?&obs_dtext) )
(?<quoted_pair> \\ (?: (?&VCHAR) | (?&WSP) ) | (?&obs_qp) )
(?<dot_atom> (?&CFWS)? (?&dot_atom_text) (?&CFWS)? )
(?<dot_atom_text> (?&atext) (?: \. (?&atext) )* )
(?<atext> [a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+ )
(?<atom> (?&CFWS)? (?&atext) (?&CFWS)? )
(?<quoted_string> (?&CFWS)? " (?: (?&FWS)? (?&qcontent) )* (?&FWS)? " (?&CFWS)? )
(?<qcontent> (?&qtext) | (?"ed_pair) )
(?<qtext> \x21 | [\x23-\x5b] | [\x5d-\x7e] | (?&obs_qtext) )
# comments and whitespace
(?<FWS> (?: (?&WSP)* \r\n )? (?&WSP)+ | (?&obs_FWS) )
(?<CFWS> (?: (?&FWS)? (?&comment) )+ (?&FWS)? | (?&FWS) )
(?<comment> \( (?: (?&FWS)? (?&ccontent) )* (?&FWS)? \) )
(?<ccontent> (?&ctext) | (?"ed_pair) | (?&comment) )
(?<ctext> [\x21-\x27] | [\x2a-\x5b] | [\x5d-\x7e] | (?&obs_ctext) )
# obsolete tokens
(?<obs_local_part> (?&word) (?: \. (?&word) )* )
(?<obs_domain> (?&atom) (?: \. (?&atom) )* )
(?<obs_dtext> (?&obs_NO_WS_CTL) | (?"ed_pair) )
(?<obs_qp> \\ (?: \x00 | (?&obs_NO_WS_CTL) | \n | \r ) )
(?<obs_FWS> (?&WSP)+ (?: \r\n (?&WSP)+ )* )
(?<obs_ctext> (?&obs_NO_WS_CTL) )
(?<obs_qtext> (?&obs_NO_WS_CTL) )
(?<obs_NO_WS_CTL> [\x01-\x08] | \x0b | \x0c | [\x0e-\x1f] | \x7f )
# character class definitions
(?<VCHAR> [\x21-\x7E] )
(?<WSP> [ \t] )
)
^(?&addr_spec)$
/x
```
上面看到的是用于匹配符合 [RFC 5322](http://tools.ietf.org/html/rfc5322) 的电子邮件地址的正则表达式。它仅仅是简单地将 RFC 中的 BNF 规则转换成 PCRE 能理解的表示法。语法非常简单:所有规则定义都被包裹在一个 `DEFINE` 断言中,这基本上意味着所有这些规则不应该被直接匹配,它们只是被定义。只有结尾的 `^(?&addr_spec)$` 部分指定了要匹配的内容。
这些规则定义实际上并不是真正的“规则”,而是命名子模式。在之前的 `(a(?1)?b)` 例子中,`1` 引用了第一个子模式。当子模式很多时,这显然不实用,因此它们可以被命名。`(?P<name>...)` 定义了一个名称为 `name` 的模式。`(?&name)` 则引用它。
另外,请注意另一个事实:上面这个正则表达式使用了 `x` 修饰符。这指示引擎忽略空白,并允许 `#` 风格的注释。这样你就可以把正则表达式格式得漂漂亮亮的,让别人真正看得懂。(完全不像[这个](http://www.ex-parrot.com/pdw/Mail-RFC822-Address.html) RFC 822 电子邮件地址正则表达式……)
因此,上面的语法允许从文法到正则表达式的简单映射:
```
A -> B C
A -> C D
// 变成
(?P<A> (?&B) (?&C) | (?&C) (?&D) )
```
唯一的问题是:正则表达式不支持左递归。例如,取上面参数列表的定义:
```
non_empty_parameter_list -> parameter
non_empty_parameter_list -> non_empty_parameter_list ',' parameter
```
你*不能*直接把它转换成基于文法的正则表达式。下面的写法是不行的:
```
(?P<non_empty_parameter_list> (?¶meter) | (?&non_empty_parameter_list) , (?¶meter) )
```
原因是,在这里 `non_empty_parameter_list` 作为其自身规则定义的最左部分出现。这被称为左递归,在文法定义中非常常见。原因是通常用于解析它们的 LALR(1) 解析器处理左递归比处理右递归好得多。
但是,别怕,这完全不影响正则表达式的能力。每个左递归文法都可以转换成右递归的。在上面的例子中,只需要交换两个部分:
```
non_empty_parameter_list -> parameter
non_empty_parameter_list -> parameter ',' non_empty_parameter_list
```
所以现在应该清楚了:正则表达式可以匹配任何上下文无关语言(因而也就能匹配几乎程序员会面对的所有语言)。唯一的问题是:尽管正则表达式能很好地*匹配*上下文无关语言,它们通常不能*解析*这些语言。解析意味着将某个字符串转换成抽象语法树。这对正则表达式来说是不可能的,至少 PCRE 是这样(当然,在 Perl 中你可以在正则表达式里嵌入任意代码,那几乎什么都能做……)。
尽管如此,上述基于 `DEFINE` 的正则表达式定义对我来说已经被证明*极为*有用。通常你并不需要完整的解析支持,而只是想匹配(例如电子邮件地址)或提取一小部分数据(而不是整个解析树)。大多数复杂的字符串处理问题,使用基于文法的正则表达式都会变得简单得多 :)
在这一点上,让我再次指出我之前已经简单提到过的内容:结构良好的 HTML 是上下文无关的。所以*可以*用正则表达式匹配它,这与流行的观点相反。但别忘了两件事:首先,你在现实中看到的大部分 HTML 都*不是*结构良好的(通常连接近都算不上)。其次,仅仅因为*可以*,并不意味着你*应该*。你也可以用 Brainfuck 写软件,但出于某种原因你并没有这么做。
我对这个问题的看法是:每当你需要通用的 HTML 处理时,使用你选择的 DOM 库。它能优雅地处理格式错误的 HTML,并为你承担解析的负担。另一方面,如果你处理的是特定情况,快速的正则表达式往往是正确的选择。而且我必须承认:尽管我经常告诉别人不要用正则表达式解析 HTML,但我自己也屡屡这么做。仅仅因为在大多数情况下,我面对的是特定而受控的场景,使用正则表达式更简单。
## 上下文相关文法
既然我们已经深入讨论了上下文无关语言,让我们在乔姆斯基层级上再上一步:上下文相关语言。
在上下文相关语言中,所有产生式都具有以下形式:
这堆字符混合在一起可能看起来更复杂了,但实际上很简单。核心仍然是我们定义上下文无关文法时的模式 `A → γ`。新的东西是,你现在在两侧还有 `α` 和 `β`。这两个构成了*上下文*(这也给了这个文法类别其名称)。所以基本上,`A` 现在只有在左边有 `α`、右边有 `β` 的情况下,才能被替换为 `γ`。
为了更清楚,试着解释以下规则:
```
a b A -> a b c
a B c -> a Q H c
H B -> H C
```
对应的中文解释是:
```
将 `A` 替换为 `c`,但前提是它的左边有 `a b`。
将 `B` 替换为 `Q H`,但前提是它的左边有 `a`,右边有 `c`。
将 `B` 替换为 `C`,但前提是它的左边有 `H`。
```
上下文相关语言在“正常”编程中很少遇到。它们主要在处理自然语言时很重要(因为自然语言显然不是上下文无关的。词语根据上下文有不同的含义)。但即使在自然语言处理中,人们通常也使用所谓的“轻度上下文相关语言”,因为它们足以对语言进行建模,而且解析速度要快得多。
为了理解上下文相关文法到底有多强大,让我们看看另一个文法类别,它与上下文相关文法具有完全相同的表达能力:非收缩文法。在非收缩文法中,每条产生式的形式为 `α -> β`,其中 `α` 和 `β` 都是任意符号串,只有一个限制:右侧符号的数量不少于左侧。形式上表示为 `|α| <= |β|`,其中 `|x|` 表示符号串 `x` 的长度。所以非收缩文法允许任何形式的规则,只要它们不把输入变短。例如,`A B C -> H Q` 就是一条无效规则。
相似文章
Stack Overflow上262,715个正则表达式问题尚未解答的问题(第二部分)
深入探讨正则表达式解析HTML的局限性,灵感来源于Stack Overflow的著名回答,讨论了形式语言理论和工业级正则表达式引擎的能力。
可在‘各处’工作的正则表达式
本文讨论了正则表达式在sed、awk、grep和Emacs等工具之间移植的挑战,并提供了一组在这些环境中可靠工作的正则表达式子集。
@TrisH0x2A: Rob Pike 用大约30行C代码写了一个完整的正则表达式匹配器,它支持 ^、.、* 和 $,仅使用递归……
一条推特重点介绍了 Rob Pike 经典的30行 C 语言正则表达式匹配器,展示了递归和指针算术,作为正则表达式引擎的入门介绍。
Stack Overflow 上 262,715 个正则表达式问题尚未解答的谜团
作者分析了 Stack Overflow 上的 262,715 个问题,以找出正则表达式的常见痛点,并展示了其新的正则表达式引擎 RE# 如何借助补集和交集运算来解决这些问题。
Regex Chess: 一个使用84,688个正则表达式的2层minimax国际象棋引擎
Nicholas Carlini 的项目使用84,688个正则表达式实现了一个2层minimax国际象棋引擎,这些表达式被顺序执行以走出合法的国际象棋走法。文章解释了一个能够解读指令的正则表达式计算机的设计。