top of page

Hacker News 重新带火 Nikita Popov 的正则表达式文章,并重启了一场关键分歧

8月31日
讀畢需時 13 分鐘

Hacker News 重新讨论了一篇有 14 年历史的 Nikita Popov 文章,再次引出了编程中的简略说法经常掩盖的一场冲突。文章认为,现代正则表达式引擎能够识别远超形式正则语言的语言。Hacker News 的回应则聚焦于这种额外能力所付出的代价。

Popov 于 2012 年 6 月 15 日发表这篇文章,当时他经常在 Stack Overflow 回答 PHP 问题。他针对的是一条人们耳熟能详的禁令:HTML 不能用正则表达式处理,因为 HTML 不是正则语言。

这条规则依然有用,但 Popov 展示了其理论解释为何可能具有误导性。PCRE 模式并不局限于数学上称为正则表达式的对象。递归、反向引用、断言、条件表达式和子程序调用,让一些引擎具备了更广泛的能力。

因此,这场重新燃起的争论并非在于 Popov 是否找到了一个巧妙的模式,而是关乎开发者所说的 regex 究竟指什么、哪些保证会因实现选择而失效,以及何时识别会成为解析的糟糕替代品。

为什么 Hacker News 重提 2012 年的正则表达式争论

因为其核心区分在日常软件术语中至今仍未得到厘清。

Popov 的 2012 年文章一开始便区分了开发者经常混为一谈的两种含义。形式正则表达式描述的是正则语言,而生产环境中的正则表达式引擎往往实现了超出这一形式类别的附加运算符。

正则语言可以由有限状态识别,这意味着匹配器不需要一个随输入嵌套深度增长的无界栈。常见例子包括标识符、简单数字格式、固定令牌模式,以及许多搜索过滤器。

PCRE,即 Perl-Compatible Regular Expressions,加入了改变这一图景的结构。一个子模式可以调用自身,让匹配器跟随嵌套结构。反向引用则可以要求后续文本与此前捕获的文本相同。

这些功能让“正则表达式”一词在历史上依然熟悉,却在数学上不够精确。开发者通常将 regex 用作模式语言的统称。形式语言专家则可能将这一术语限定为与有限自动机等价的表达式。

这一差异推动了 8 月的讨论。一方认为,这篇文章有混淆真正正则表达式与 PCRE 特定模式匹配之嫌。另一方则回应,Popov 在考察更广泛的程序员语境前,已经明确说明了这一区别。

两种解读都指出了重要之处。文章谨慎地界定了范围,但其挑衅性的标题又容易让读者将不同的引擎视作同一种技术。一个使用递归的 PCRE 模式,对于 JavaScript、RE2、Rust、POSIX 或其他实现能接受什么,几乎说明不了问题。

讨论也超出了理论层面。评论者提出了可读性、引擎差异、内存使用、回溯、拒绝服务风险,以及 AI 生成的表达式。这些担忧解释了为何一篇 2012 年的文章至今仍显得切题。

现代代码助手能够生成密集复杂的模式,却无法确保维护者理解其执行方式。它们也可能建议目标引擎并不支持的语法。更容易生成正则表达式,并不意味着不再需要选择合适的匹配器。

原文依然有价值,因为它挑战了一个过度简化的限制。重新展开的讨论同样重要,因为它补上了缺失的操作性问题:这种额外的表达能力究竟要付出什么代价?

PCRE Regex 的能力来自正则语言之外的功能

文章的核心结论适用于 PCRE 风格引擎,而非所有带有 regex 标签的系统。

Popov 从乔姆斯基层级开始,它按照生成语言所需的语法对形式语言进行分类。正则语言包含于上下文无关语言,而上下文无关语言又包含于上下文相关语言。

传统正则表达式位于最小的那一组。连接、选择、字符类和重复可以描述所有正则语言。但它们本身无法记住无限的嵌套深度,也不能复制任意捕获的子字符串。

PCRE 的递归改变了第一个限制。Popov 使用递归组来识别由数量相等的 a 字符后接 b 字符组成的字符串。这种语言是上下文无关的,但不是正则语言。

其机制很紧凑。一个模式消费一个 a,递归调用外围组,然后消费一个 b。每深入一层调用,都会在嵌套匹配外围增加一对对应字符。

当前的 PCRE2 文档仍介绍了递归模式语法。它将平衡括号作为直接示例:一个组匹配左括号、普通内部字符或另一次组调用,以及右括号。

这是一项实质性的能力。传统有限状态匹配只能支持预先设定的嵌套上限。递归匹配可以跟随输入的嵌套深度,当然仍受制于引擎的资源限制和行为。

随后,Popov 将上下文无关语法规则映射为具名 PCRE 子模式。(?(DEFINE)...) 结构保存定义而不消费输入。具名子程序调用则允许一个规则调用另一个规则。

他扩展的示例将 RFC 5322 电子邮件语法的一部分翻译为这种记法。结果类似于嵌入 regex 字面量中的语法,其中通过扩展模式启用了空白和注释。

这支撑了文章引人注目的主张:在不兼容的左递归被转换后,PCRE 风格递归能够识别上下文无关语言。这并不意味着每个短小的 regex 都能处理每一种上下文无关语言。

它也不会让匹配器成为完整的解析器。识别回答的是输入是否属于某种语言;解析则会创建结构化输出,记录输入如何符合该语法。

对 HTML 而言,这种差异至关重要。匹配器或许能判断一个格式良好的片段是否符合某种语法,但应用程序通常需要元素、属性、文本节点、错误恢复、实体处理和文档遍历。

真实 HTML 还带来另一项复杂性。浏览器会通过规定的恢复行为处理格式错误的文档。匹配一种理想化、格式良好的语言,并不能复现这种行为。

Popov 也承认这两项限制。他建议通用 HTML 处理使用 DOM 库,而仅在限定场景中使用 regex。因此,那条著名主张的范围比许多转述所暗示的要窄。

反向引用让能力进一步超越经典正则表达式。反向引用匹配此前由某个组捕获的完全相同的文本。例如,模式 ^(.+)\1$ 能够识别由两个相同半段构成的字符串。

有限自动机通常无法记住任意长度的前半段并将其与后半段比较。引擎必须保留捕获内容,并探索可能的切分点。这种额外状态同时改变了表达范围与计算行为。

Popov 还结合递归和环视断言,以识别至少一些上下文相关语言。环视会检查当前位置周围的文本,但不消费该位置上的文本。

他没有声称 PCRE 能识别所有上下文相关语言。这种克制很重要。尽管采用了刻意宽泛的标题,文章仍区分了已展示的构造与尚未解答的问题。

形式 Regex 与回溯引擎优化的是不同承诺

核心冲突不是理论与实践之争,而是可预测执行与更大模式语言之间的取舍。

仅限于正则语言构造的引擎可以通过自动机执行模式。它跟踪每个输入字符之后可达的状态集合,而不是在一条路径上作出承诺并在失败后回退。

回溯引擎则更像深度优先搜索那样跟随备选分支。它选择一个分支、继续执行,并在后续匹配失败时返回到较早的决策点。这种方法以直观的语义支持捕获和高级行为。

但它也可能重复工作。含糊的嵌套量词可能产生许多切分同一输入的方式。一个几乎匹配的后缀,可能迫使引擎在报告失败前探索这些组合。

即便一个模式使用的是形式上正则的语言,这种风险也并非不存在。回溯实现可能会在形式上正则的模式上耗费过多时间。语法类别与执行策略相关,却并不相同。

这一点纠正了在线争论中的一种过度简化。移除非正则扩展,并不会自动让每一种实现都达到线性时间。引擎还必须采用避免指数级路径探索的算法。

Google 的线性时间引擎作出了明确取舍。RE2 保证匹配时间在渐近意义上与输入长度线性相关,并在可配置的内存预算内运行。

RE2 排除了反向引用和环视断言,因为其设计者不知道如何在保持相同保证的同时支持这些结构。它也排除了递归子程序调用。

结果是,它的表达能力弱于 PCRE2,但对于接受不可信模式或处理不可信文本的服务而言更可预测。这种差异是架构决策,而非证明某一种引擎能在所有情况下取代另一种引擎。

PCRE2 提供了生产系统可使用的控制机制,包括匹配限制、深度限制、替代执行路径,以及谨慎的模式构造。这些控制可以降低风险,但需要有意识地配置和测试。

实际选择取决于谁控制模式与输入。开发者拥有的表达式处理有界记录,与用户提供的表达式在共享服务中扫描大型载荷,风险截然不同。

所需输出同样重要。搜索命令可能只需要一个布尔匹配结果或少量捕获。编译器、文档处理器或配置读取器则需要结构化结果与有用的失败位置。

这就是为什么“regex 能匹配它”很少能解决工程决策。能力证明了可能性,却不能证明可维护性、资源边界、诊断质量或兼容性。

在这一框架下,Hacker News 的分歧会更清晰。Popov 描述的是特定引擎能够表达什么;批评者则追问,应用依赖这种能力时会牺牲哪些保证。

这些立场并不相互对立。它们讨论的是同一系统的不同层次。真正重要的错误,是不加限定地将某一层面的主张带到另一层面。

HTML 问题暴露了识别在实践中的局限

匹配一种语言,与构建可靠的文档表示,并不是同一项工作。

反对使用 regex 处理 HTML 的常见警告结合了多项理由。HTML 存在嵌套,真实文档往往格式错误,嵌入式语言会复杂化令牌边界,而应用通常需要结构化输出。

只有第一项理由直接涉及形式语言的表达能力。PCRE 递归可以处理嵌套。但仅凭这一事实,它无法解决其余要求。

设想一个用于提取链接的应用。一个简单表达式或许能处理由单一模板生成的受控标记。但当带引号的属性中出现 >,或脚本文本看起来像标签时,它就可能失效。

注释、字符引用、命名空间、可选标签以及浏览器恢复规则会带来更多情况。每增加一个条件,模式就会更复杂,而其输出仍不如 DOM 那样结构化。

这并不意味着每个用于 HTML 的正则表达式都不负责任。当输入约定严格、失败后果较小时,范围有限的表达式可以是合适的选择。

必须明确其适用范围。“在我们生成的片段中查找已知标记”是一项有边界的文本任务。“像浏览器一样解析任意网页”则是一项文档处理任务。

解析器会为每种结构赋予明确的角色。它可以附加源位置、报告意外标记、在出错后恢复,并为后续转换提供树结构。

大型正则表达式往往会把这些差异压缩进分组和控制流中。命名子模式与扩展格式有所帮助,但引擎返回的仍是匹配结果,而不是原生语法树。

当需求发生变化时,维护差距会不断扩大。在解析器中支持另一条文法产生式,通常意味着添加或修改一条规则。在高度耦合的正则表达式中,这可能改变其他位置的回溯和捕获行为。

因此,测试必须覆盖的不只是具有代表性的有效示例。团队还需要测试无效输入、近似匹配、大型输入、嵌套输入、Unicode 情况,以及专门触发高开销路径的对抗性字符串。

这也是文档从装饰变为可操作资产的地方。复杂表达式应说明其引擎、标志、接受的输入约定、预期捕获结果、大小限制,以及不使用解析器的原因。

保存技术决策的团队,可以将这些约束与可搜索的实现记录放在一起。共享的工程知识库可帮助未来维护者理解简洁匹配代码背后的意图。

关键决策并不是将正则表达式和解析器视为相互对立的身份标签。而是任务究竟需要识别、提取、转换、恢复,还是完整解释。

正则表达式依然非常适合分词、在有界规则下进行验证、搜索、日志过滤和小规模提取。当文法结构成为应用程序的工作数据时,解析器则更为合适。

Popov 自己的结论也遵循这一界限。他反对绝对化的理论禁令,同时保留了实用建议:对通用 HTML 处理应使用 DOM 库。

这种细微差别常常在网上消失。“正则表达式无法解析 HTML”之所以流传,是因为它能避免常见故障。“某些正则表达式引擎能够识别上下文无关结构”仍然正确,因为这种底层能力确实存在。

成熟的工程准则可以同时容纳这两种说法。不要把有用的默认原则误认为定理,也不要把理论构造误认为生产环境设计。

正则表达式论争仍低估了什么

额外的表达能力会带来安全与维护义务,而即使是成功的测试套件也未必能揭示这些问题。

正则表达式拒绝服务,或 ReDoS,发生在匹配器为精心构造的输入探索替代路径而耗费过多时间时。攻击者无需发送大量流量,就可能耗尽处理能力。

ReDoS 指导描述了这样的情况:当含糊模式遇到对抗性字符串时,引擎可能出现极端的执行时间。危险往往出现在接近匹配失败的时候。

验证器之所以能快速处理普通输入,可能是因为第一个分支便已成功。攻击者则可以提供一个能匹配多条路径的长前缀,再附加一个使所有路径都失效的字符。

引擎随后必须重新审视先前的选择。嵌套重复、重叠的替代分支和可选组件会成倍扩大搜索空间。简洁的模式可能在代码审查时掩盖这种行为。

反向引用带来了另一层复杂性。关于其表达能力边界的研究将其视为许多主流引擎支持的重要扩展。其行为无法简化为普通的有限状态匹配。

Popov 的文章指出,反向引用匹配会引入 NP 完全的情况。这是针对一般问题的表述,并不意味着每个模式都会运行缓慢。

许多包含捕获和反向引用的表达式在普通输入上能快速完成。复杂性结果警示我们:除非复杂性理论中的重大假设发生改变,否则不存在覆盖所有情况的高效通用解法。

递归会带来独立的资源问题。跟随深度嵌套输入的模式会消耗由引擎管理的深度或等价状态。不同实现会施加限制,且递归与回溯的交互方式也各不相同。

引擎版本同样重要。PCRE2 随时间改变了递归语义,使其在某些情况下更接近 Perl。一个在某个版本下测试通过的模式,迁移后可能表现不同。

因此,即使是在被称为 Perl 兼容的引擎之间,可移植性也有限。语法支持、Unicode 规则、捕获值、匹配顺序和递归语义都可能不同。

AI 生成的正则表达式提高了风险,因为生成过程降低了过去限制复杂性的门槛。开发者可以要求一个用于某种文法的单一表达式,并在几秒内收到看似语法正确的结果。

生成的模式可能针对错误的方言。它可能通过提示中的示例,却遗漏畸形输入、消耗过多资源,或返回语义出乎意料的捕获结果。

审查者应将生成的正则表达式视为生成代码。他们需要识别引擎、理解每个非平凡结构、运行对抗性测试,并强制执行输入与执行限制。

可读性在这里并非装饰性问题。难以阅读的控制流会阻碍维护者识别重叠替代分支,或发现一次小改动何时引发灾难性回溯。

扩展模式可在支持它的引擎中提供空白和注释。命名分组可减少对易变数字索引的依赖。由较小模式组成的结构能使职责更清晰。

这些做法有所帮助,但组合时必须遵守引擎语法。一些宿主语言会在正则表达式编译器看到字符串之前先进行插值,从而增加一层转义和潜在注入风险。

不可信片段绝不应在未经适合该引擎的转义处理时插入模式中。不可信用户也不应在共享服务中获得对回溯匹配器的不受限制访问。

怀疑论式的结论比“永远不要使用高级正则表达式”更狭窄:每一种高级结构都会消耗系统可预测性预算的一部分。

开发者应能说清自己从中获得了什么。递归或许能为有边界的嵌套结构提供简洁的识别方式。反向引用则可强制执行相等约束,否则可能需要过程式代码实现。

如果收益仅仅是避免编写一个小型解析器,这种权衡就更难合理化。解析器通常能提供更好的错误信息、更清晰的演进路径,以及下游代码可直接使用的输出。

三个信号将决定这一教训能否留下来

长期结果取决于引擎选择、生成代码审查,以及团队是否测试失败行为而非只测试示例。

第一个信号是更广泛且明确的引擎选择。开发者应停止将正则表达式视为一种可移植的单一语言,并记录某个模式针对的是 PCRE2、RE2、JavaScript、Java、.NET、Rust,还是其他实现。

如果库和平台能让执行保证变得可见,Hacker News 上的争论就更容易解决。开发者可以讨论一个明确的引擎,而非争论一个含义过载的标签。

第二个信号是编程助手如何处理正则表达式请求。实用的系统应在生成复杂表达式前,询问目标方言、输入边界、可信数据假设和所需捕获结果。

它们还应解释不受支持的结构,并在请求的输出是结构化内容时建议使用解析器。如果助手继续在缺少这些检查的情况下生成密集模式,维护与安全故障将会增加。

第三个信号是常规对抗性测试。团队应测量成功匹配、延迟失败、长重复输入、深度嵌套、Unicode 边界,以及被构造成最大化歧义的输入。

一个通过十个友好示例的模式,只证明了这些示例的正确性。它并未证明最坏情况下的行为可接受,也未证明与生产环境限制兼容。

这些信号强化了 Popov 更广泛的教训,同时缩小了它的适用范围。现代模式引擎可以超越其名称所暗示的形式限制。这个事实值得被理解,而不应被用作普遍设计背书。

这篇重新流传的文章也为双方提供了有益的纠正。形式理论之所以重要,是因为它解释了哪些保证可用。实现细节之所以重要,是因为已部署的引擎并不都会保留这些保证。

对于关注 Hacker News 辩论的开发者,下一步行动很具体。识别你最复杂的生产模式背后的引擎,记录其输入约定,并测试其最慢的失败情况。然后问问应用程序需要的是识别,还是结构化解析。

如果模式仍然清晰、有边界且可衡量,就保留它。如果其正确性依赖于未记录的方言行为或脆弱的回溯机制,就用显式解析器替换这套隐藏的文法。

 
 

免费开始

一款本地优先的AI助手

为了获得更好的人工智能体验,

remio 目前仅支持Windows 10+ (x64)M-Chip Mac

你的 AI 工作伙伴

remio 一起高效工作

规划、创作、交付

一站式完成

bottom of page