为什么正则表达式引擎允许/自动尝试在输入字符串的末尾进行匹配?

Why do regex engines allow / automatically attempt matching at the end of the input string?

注:
* Python 用于说明行为,但此问题与语言无关。
* 出于本次讨论的目的,假设单行仅输入,因为换行符(多行输入)的存在会引入行为变化$. 附带 手头的问题。

大多数正则表达式引擎:

也许不用说,如果所讨论的正则表达式匹配空字符串(默认情况下正则表达式),这样的匹配尝试成功/ 被配置为报告零长度匹配项)。

这些行为至少乍一看反直觉,不知道是否有人可以为他们提供设计原理,尤其是因为:


请注意,正则表达式引擎在零长度(空字符串)匹配后继续匹配的行为有所不同.

任一选择(从相同的字符位置开始与从下一个字符位置开始)都是合理的 - 参见 the chapter on zero-length matches at www.regular-expressions.info

相比之下,这里讨论的 .*$ 案例的不同之处在于,对于任何非空输入,.*$first 匹配是 not 零长度匹配,因此行为差异 not 适用 - 相反,字符位置应 无条件地 [=116] =] 在第一场比赛之后,如果你已经在比赛结束,这当然是不可能的。
同样,令我惊讶的是 另一个 匹配仍然被尝试,即使根据定义没有任何剩余。


[1] 我在这里使用 $ 作为输入结束标记,即使在某些引擎中,例如 .NET,它可以将结束标记为结束输入的 可选地后跟一个尾随的换行符 。但是,当您使用 无条件 输入结束标记 \z.

时,该行为同样适用

[2] Python 2.x and 3.x up to 3.6.x 看似特殊 replacement 在此上下文中的行为: python -c "import re; print(re.sub('.*$', '[\g<0>]', 'a'))" 过去只产生 [a] - 也就是说,只有 一个 匹配被找到并替换。
自 Python 3.7 以来,行为现在与大多数其他正则表达式引擎一样,其中执行 两次 替换,产生 [a][]

[3] 您可以通过 (a) 选择旨在最多找到 one 匹配项的替换方法或 (b) 来避免此问题使用 ^.* 防止通过输入开始锚定找到多个匹配项。
(a) 可能不是一个选项,这取决于给定语言如何呈现功能;例如,PowerShell 的 -replace 运算符 invariably 替换 all 出现;考虑以下将所有数组元素包含在 "...":
中的尝试 'a', 'b' -replace '.*', '"$&"'。由于匹配 两次 ,这会产生元素 "a""""b""";
选项 (b),'a', 'b' -replace '^.*', '"$&"',解决了问题。

我给出这个答案只是为了说明为什么正则表达式希望允许任何代码出现在模式中最后的 $ 锚点之后。假设我们需要创建一个正则表达式来匹配具有以下规则的字符串:

  • 以三个数字开头
  • 后跟一个或多个字母、数字、连字符或下划线
  • 仅以字母和数字结尾

我们可以编写以下模式:

^\d{3}[A-Za-z0-9\-_]*[A-Za-z0-9]$

但这有点笨重,因为我们要使用两个相似的字符类彼此相邻。相反,我们可以将模式写为:

^\d{3}[A-Za-z0-9\-_]+$(?<!_|-)

^\d{3}[A-Za-z0-9\-_]+(?<!_|-)$

在这里,我们消除了其中一个字符 类,而是在 $ 锚点 之后使用负向后视 来断言最终字符是不是下划线或连字符。

除了回顾之外,我不明白为什么正则表达式引擎会允许某些东西出现在 $ 锚点之后。我在这里的观点是,正则表达式引擎可能允许在 $ 之后出现回溯,并且在某些情况下这样做在逻辑上是有意义的。

回忆几件事:

  1. ^$zero width assertions - 它们在字符串的逻辑开始之后匹配(或者在每行以多行模式结束之后 m flag in most regex implementations) or at the logical end of string (or end of line BEFORE end of line character or characters in multiline mode.)

  2. .* 可能是一个 zero length match of no match at all. The zero length only version would be $(?:end of line){0} DEMO (我猜这作为评论很有用......)

  3. . 不匹配 \n(除非你有 s 标志)但匹配 Windows 中的 \r CRLF 行结尾。因此 $.{1} 仅匹配 Windows 行结尾(但不要那样做。改用文字 \r\n。)

除了简单的副作用情况外,没有特别的好处

  1. 正则表达式 $ 很有用;
  2. .* 很有用。
  3. 正则表达式 ^(?a lookahead)(?a lookbehind)$ 很常见且很有用。
  4. 正则表达式 (?a lookaround)^$(?a lookaround) 可能 有用。
  5. 正则表达式 $.* 没有用且很少见,因此不保证实施一些优化以使引擎停止查找该边缘情况。大多数正则表达式引擎在解析语法方面做得不错;例如缺少大括号或括号。要让引擎将 $.* 解析为无用,需要将该正则表达式的含义解析为不同于 $(something else)
  6. 您得到的内容将高度依赖于正则表达式风格以及 sm 标志的状态。

有关替换示例,请考虑以下 Bash 某些主要正则表达式风格的脚本输出:

#!/bin/bash

echo "perl"
printf  "123\r\n" | perl -lnE 'say if s/$.*/X/mg' | od -c
echo "sed"
printf  "123\r\n" | sed -E 's/$.*/X/g' | od -c
echo "python"
printf  "123\r\n" | python -c "import re, sys; print re.sub(r'$.*', 'X', sys.stdin.read(),flags=re.M) " | od -c
echo "awk"
printf  "123\r\n" | awk '{gsub(/$.*/,"X")};1' | od -c
echo "ruby"
printf  "123\r\n" | ruby -lne 's=$_.gsub(/$.*/,"X"); print s' | od -c

打印:

perl
0000000    X   X   2   X   3   X  \r   X  \n                            
0000011
sed
0000000    1   2   3  \r   X  \n              
0000006
python
0000000    1   2   3  \r   X  \n   X  \n                                
0000010
awk
0000000    1   2   3  \r   X  \n                                        
0000006
ruby
0000000    1   2   3   X  \n                                            
0000005

启用全局修饰符后使用 .* 的原因是什么?因为有人以某种方式期望一个空字符串被 returned 作为匹配项,或者他/她不知道 * 量词是什么,否则不应设置全局修饰符。 .* 没有 g 不会 return 两个匹配项。

it's not obvious what the benefit of this behavior is.

应该没有好处。实际上你是在质疑零长度匹配的存在。您在问 为什么存在零长度字符串?

零长度字符串存在三个有效位置:

  • 主题字符串开始
  • 两个字符之间
  • 主题字符串结束

我们应该寻找原因而不是使用 .*g 修饰符(或搜索所有出现的函数)的第二个零长度匹配输出的好处。输入字符串后的零长度位置有一些逻辑用途。下面的状态图是从 debuggex 针对 .* 抓取的,但我在从开始状态到接受状态的直接转换中添加了 epsilon 以演示定义:


(来源:pbrd.co

这是一个零长度匹配(阅读更多关于 epsilon transition)。

这些都涉及到贪心和非贪心。没有零长度位置,像 .?? 这样的正则表达式就没有意义。它不会首先尝试点,而是跳过它。为此,它匹配一个零长度字符串,以将当前状态转换为临时可接受的状态。

没有零长度位置 .?? 永远无法跳过输入字符串中的字符,这会产生全新的风格。

贪婪/懒惰的定义导致零长度匹配。

注:

  • 我的问题 post 包含两个相关但不同的问题,正如我现在意识到的那样,我应该为它们创建单独的 post。
  • 此处的其他答案分别关注 一个 个问题,因此部分答案提供了一个路线图,说明哪些答案解决了哪些问题.

至于为什么允许$<expr>这样的模式(即在输入[=103=之后匹配 ]end) / 当它们有意义时:

  • 认为 $.+ 可能 等无意义的组​​合并没有被阻止 pragmatic原因;将它们排除在外可能不值得。

  • 展示了 某些 表达式 可以 $ 之后如何变得有意义,即消极的回顾断言

  • 答案的后半部分有力地综合了 dawg 和 Tim 的答案。


至于为什么全局匹配找到两个匹配.*.*$等模式:

  • 包含有关零长度(空字符串)匹配的重要背景信息,这就是问题 最终 归结的原因。

让我通过更直接地将其与行为如何与我在全局匹配的情况下的预期相矛盾联系起来来补充他的回答:

  • 从纯常识的角度,按理说一旦输入在匹配时被完全消耗掉,根据定义有一无所获,因此没有理由寻找更多匹配项。

  • 相比之下,大多数正则表达式引擎考虑字符位置在输入字符串的最后一个字符之后——这个位置被称为主题字符串结尾 在某些引擎中 - 匹配的有效起始位置,因此尝试另一个匹配

    • 如果手头的正则表达式恰好匹配空字符串(产生零长度匹配;例如,正则表达式如 .*a?),它匹配该位置和 returns 空字符串匹配。

    • 相反,如果正则表达式不(也)匹配空字符串,您将看不到额外的匹配项 - 而额外的匹配项仍在尝试 在所有情况下,在这种情况下都找不到匹配项,因为空字符串是主题字符串结尾位置唯一可能的匹配项。

虽然这提供了行为的技术解释,但它仍然没有告诉我们为什么匹配之后 最后一个字符已实现。

最接近的是 有根据的猜测 Wiktor Stribiżew 在评论中(强调),这再次表明 务实 行为原因:

... as when you get an empty string match, you might still match the next char that is still at the same index in the string. If a regex engine did not support it, these matches would be skipped. Making an exception for the end of string was probably not that critical for regex engine authors.

的前半部分通过告诉我们 [input] 字符串末尾的 void 是一个有效位置来更详细地解释行为用于匹配,就像任何其他 character-boundary position.
然而,虽然对所有这些职位都一视同仁肯定 内部一致 并且可能会简化 实现 ,但这种行为仍然违背常识并且没有明显的好处给 用户.


关于空字符串匹配的进一步观察:

注意:在下面的所有代码片段中,执行全局字符串 替换 以突出显示结果匹配:每个匹配都包含在 [...] 中,而输入的不匹配部分按原样传递。

总而言之,3 种不同的、独立的行为适用于空(字符串)匹配的上下文,并且不同的引擎使用不同的组合:

  • 是否遵守 POSIX ERE 规范的 longest leftmost ruleThanks, revo

  • 在全局匹配中:

    • 空匹配后字符位置是否向前移动。
    • 是否尝试对输入末尾的定义空字符串进行另一个匹配(我的问题 post 中的第二个问题)。

主题字符串结尾位置的匹配仅限于匹配在相同字符位置继续的那些引擎在匹配之后。

例如,.NET 正则表达式引擎不会这样做(PowerShell 示例):

PS> 'a1' -replace '\d*|a', '[$&]'
[]a[1][]

即:

  • \d* 匹配空字符串 before a
  • a本身然后匹配,这意味着字符位置在空匹配之后高级
  • 1\d*
  • 匹配
  • 主题字符串的结尾位置再次与 \d* 匹配,导致另一个空字符串匹配。

Perl 5 是 相同 字符位置恢复匹配的引擎示例:

$ "a1" | perl -ple "s/\d*|a/[$&]/g"
[][a][1][]

注意 a 也是如何匹配的。

有趣的是,Perl 6 不仅表现不同,而且表现出另一种行为变体:

$ "a1" | perl6 -pe "s:g/\d*|a/[$/]/"
[a][1][]

看起来,如果交替发现两者和空匹配以及非空匹配,则只报告非空匹配。

Perl 6 的行为似乎遵循最长的最左边规则。

虽然 sedawk 也这样做,但它们不会尝试在字符串末尾进行另一个匹配:

sed,BSD/macOS 和 GNU/Linux 实现:

$ echo a1 | sed -E 's/[0-9]*|a/[&]/g'
[a][1]

awk - BSD/macOS 和 GNU/Linux 实现以及 mawk:

$ echo a1 | awk '1 { gsub(/[0-9]*|a/, "[&]"); print }'
[a][1]

不知道哪里乱来的
正则表达式引擎基本上 愚蠢 .
他们就像 Mikey,他们会吃任何东西。

$ python -c "import re; print(re.findall('$.*', 'a'))"
[''] # !! Matched the hypothetical empty string after the end of 'a'

您可以在 $ 之后放置一千个可选表达式,它仍然会匹配
EOS。引擎是愚蠢的。

$ python -c "import re; print(re.findall('.*$', 'a'))"
['a', ''] # !! Matched both the full input AND the hypothetical empty string

这样想,这里有两个独立的表达式
.* | $。原因是第一个表达式是可选的。
它恰好反对 EOS 断言。
因此,您在非空字符串上得到 2 个匹配项。

Why does functionality designed to find multiple, non-overlapping matches of a regex - i.e., global matching - decide to even attempt another match if it knows that the entire input has been consumed already,

字符位置不存在称为断言的 class。
它们仅存在 BETWEEN 个字符位置。
如果它们存在于正则表达式中,您不知道整个输入是否已被消耗。
如果他们作为一个独立的步骤可以满足,但只有一次,他们将匹配
独立地。

请记住,正则表达式是一个 left-to-right 命题。
还要记住,引擎 愚蠢 .
这是设计使然。
每个构造是引擎中的一个状态,就像一个管道。
增加复杂性肯定会注定失败。

顺便说一句,.*a 真的从头开始检查每个字符吗?
否。.* 立即从字符串(或行,视情况而定)的末尾开始并开始
回溯。

又一件有趣的事。我看到很多新手在
末尾使用.*? 正则表达式,认为它将从字符串中获取所有剩余的 kruft。
它没用,它永远不会匹配任何东西。
即使是独立的 .*? 正则表达式也永远不会匹配尽可能多的字符
有在字符串中。

祝你好运!别担心,正则表达式引擎只是......好吧,愚蠢

"Void at the end of the string" 是正则表达式引擎的单独位置,因为 正则表达式引擎处理 输入字符之间的位置:

|a|b|c|   <- input line

^ ^ ^ ^
positions at which a regex engine can "currently be"

其他位置都可以用"before Nth character"来形容,但是到最后就没有字符可以参考了

根据 Zero-Length Regex Matches -- Regular-expressions.info,还需要支持零长度匹配(并非所有正则表达式都支持):

  • 例如字符串 abc 上的正则表达式 \d* 将匹配 4 次:在每个字母之前和结尾。

$ 允许在正则表达式中的任何地方保持一致: 它被视为相同的 as any other token 并匹配那个神奇的 "end of string" 位置。使 "finalize" 正则表达式工作会导致引擎工作中不必要的不​​一致,并阻止可以在那里匹配的其他有用的东西,例如回顾或 \b(基本上,任何可以是零长度匹配的东西)——即既是设计复杂化又是功能限制,没有任何好处。


最后,要回答 为什么正则表达式引擎可能会或可能不会尝试在同一位置匹配 "again", 让我们参考 Advancing After a Zero-Length Regex Match -- Zero-Length Regex Matches -- Regular-expressions.info

Say we have the regex \d*|x, the subject string x1

第一个匹配项是字符串开头的空白匹配项。现在,我们如何在不陷入无限循环的情况下给其他令牌一个机会?

The simplest solution, which is used by most regex engines, is to start the next match attempt one character after the end of the previous match

这可能会产生违反直觉的结果 -- 例如上面的正则表达式将在开始时匹配 '',在结尾匹配 1''——但不匹配 x.

The other solution, which is used by Perl, is to always start the next match attempt at the end of the previous match, regardless of whether it was zero-length or not. If it was zero-length, the engine makes note of that, as it must not allow a zero-length match at the same position.

其中 "skips" 以一些额外的复杂性为代价匹配较少。例如。上面的正则表达式将在最后产生 ''x1''

文章继续表明,这里没有既定的最佳实践,各种正则表达式引擎正在积极尝试新方法 以尝试产生更多 "natural" 结果:

One exception is the JGsoft engine. The JGsoft engine advances one character after a zero-length match, like most engines do. But it has an extra rule to skip zero-length matches at the position where the previous match ended, so you can never have a zero-length match immediately adjacent to a non-zero-length match. In our example the JGsoft engine only finds two matches: the zero-length match at the start of the string, and 1.

Python 3.6 and prior advance after zero-length matches. The gsub() function to search-and-replace skips zero-length matches at the position where the previous non-zero-length match ended, but the finditer() function returns those matches. So a search-and-replace in Python gives the same results as the Just Great Software applications, but listing all matches adds the zero-length match at the end of the string.

Python 3.7 changed all this. It handles zero-length matches like Perl. gsub() does now replace zero-length matches that are adjacent to another match. This means regular expressions that can find zero-length matches are not compatible between Python 3.7 and prior versions of Python.

PCRE 8.00 and later and PCRE2 handle zero-length matches like Perl by backtracking. They no longer advance one character after a zero-length match like PCRE 7.9 used to do.

The regexp functions in R and PHP are based on PCRE, so they avoid getting stuck on a zero-length match by backtracking like PCRE does. But the gsub() function to search-and-replace in R also skips zero-length matches at the position where the previous non-zero-length match ended, like gsub() in Python 3.6 and prior does. The other regexp functions in R and all the functions in PHP do allow zero-length matches immediately adjacent to non-zero-length matches, just like PCRE itself.