【问题标题】:Why is "asdf".replace(/.*/g, "x") == "xx"?为什么 "asdf".replace(/.*/g, "x") == "xx"?
【发布时间】:2020-07-30 10:54:38
【问题描述】:

我偶然发现了一个(对我来说)令人惊讶的事实。

console.log("asdf".replace(/.*/g, "x"));

为什么要两个替换?似乎任何没有换行符的非空字符串都会为此模式产生两个替换。使用替换函数,我可以看到第一个替换是针对整个字符串,第二个是针对空字符串。

【问题讨论】:

  • 更简单的例子:"asdf".match(/.*/g) return [ "asdf", ""]
  • 因为全局 (g) 标志。全局标志允许在前一个匹配的末尾开始另一个搜索,从而找到一个空字符串。
  • 说实话:可能没有人想要这种行为。这可能是希望"aa".replace(/b*/, "b") 产生babab 的实现细节。在某些时候,我们标准化了网络浏览器的所有实现细节。
  • @Joshua 旧版本的 GNU sed(不是其他实现!)也出现此错误,该错误已在 2.05 和 3.01 版本之间(20 多年前)修复.我怀疑这种行为起源于那里,然后才进入 perl(它成为一个特性)并从那里进入 javascript。
  • @recursive - 够公平的。我发现他们都惊讶了一秒钟,然后意识到“零宽度匹配”并且不再感到惊讶。 :-)

标签: javascript regex


【解决方案1】:

根据ECMA-262 标准,String.prototype.replace 调用RegExp.prototype[@@replace],它表示:

11. Repeat, while done is false
  a. Let result be ? RegExpExec(rx, S).
  b. If result is null, set done to true.
  c. Else result is not null,
    i. Append result to the end of results.
    ii. If global is false, set done to true.
    iii. Else,
      1. Let matchStr be ? ToString(? Get(result, "0")).
      2. If matchStr is the empty String, then
        a. Let thisIndex be ? ToLength(? Get(rx, "lastIndex")).
        b. Let nextIndex be AdvanceStringIndex(S, thisIndex, fullUnicode).
        c. Perform ? Set(rx, "lastIndex", nextIndex, true).

其中rx/.*/gS'asdf'

参见 11.c.iii.2.b:

b.让 nextIndex 为 AdvanceStringIndex(S, thisIndex, fullUnicode)。

因此在'asdf'.replace(/.*/g, 'x')实际上是:

  1. 结果(未定义),结果 = [],lastIndex = 0
  2. 结果 = 'asdf',结果 = [ 'asdf' ],lastIndex = 4
  3. result = '', results = [ 'asdf', '' ], lastIndex = 4, AdvanceStringIndex, 将 lastIndex 设置为 5
  4. 结果 = null,结果 = [ 'asdf', '' ],返回

因此有 2 个匹配项。

【讨论】:

  • 这个答案需要我去研究才能理解。
  • TL;DR 是它匹配'asdf' 和空字符串''
【解决方案2】:

在与yawkat 的离线聊天中,我们发现了一种直观的方式,可以了解"abcd".replace(/.*/g, "x") 为何恰好产生两个匹配项。请注意,我们尚未检查它是否完全等同于 ECMAScript 标准强加的语义,因此仅将其作为经验法则。

经验法则

  • 将匹配视为元组(matchStr, matchIndex) 的列表 指示输入字符串的哪些字符串部分和索引已被吃掉的时间顺序。
  • 此列表从正则表达式的输入字符串的左侧开始不断构建。
  • 已经吃光的部分不能再匹配了
  • matchIndex 给出的索引处进行替换,覆盖该位置的子字符串matchStr。如果matchStr = "",那么“替换”实际上就是插入。

在形式上,匹配和替换的行为被描述为一个循环,如 in the other answer 所示。

简单示例

  1. "abcd".replace(/.*/g, "x") 输出"xx"

    • 匹配列表是[("abcd", 0), ("", 4)]

      值得注意的是,它确实包括以下人们可能想到的匹配,原因如下:

      • ("a", 0), ("ab", 0): 量词 * 是贪婪的
      • ("b", 1)("bc", 1):由于之前的匹配("abcd", 0),字符串"b""bc"已经被吃掉了
      • ("", 4), ("", 4)(即两次):索引位置 4 已经被第一个明显的匹配吃掉了
    • 因此,替换字符串 "x" 会在这些位置准确替换找到的匹配字符串:在位置 0 替换字符串 "abcd",在位置 4 替换 ""

      在这里您可以看到替换可以作为前一个字符串的真正替换,也可以作为新字符串的插入。

  2. "abcd".replace(/.*?/g, "x")lazy quantifier *? 输出 "xaxbxcxdx"

    • 匹配列表是[("", 0), ("", 1), ("", 2), ("", 3), ("", 4)]

      与前面的示例相比,这里没有包括 ("a", 0)("ab", 0)("abc", 0) 甚至 ("abcd", 0),因为量词的惰性会严格限制它找到可能的最短匹配项。

    • 由于所有匹配字符串均为空,因此不会发生实际替换,而是在位置 0、1、2、3 和 4 处插入 x

  3. "abcd".replace(/.+?/g, "x")lazy quantifier +? 输出 "xxxx"

    • 匹配列表为[("a", 0), ("b", 1), ("c", 2), ("d", 3)]
  4. "abcd".replace(/.{2,}?/g, "x")lazy quantifier [2,}? 输出 "xx"

    • 匹配列表为[("ab", 0), ("cd", 2)]
  5. "abcd".replace(/.{0}/g, "x") 输出 "xaxbxcxdx" 的逻辑与示例 2 相同。

更难的例子

如果我们总是匹配一个空字符串并控制这种匹配发生的位置,我们可以始终如一地利用插入而不是替换的想法。例如,我们可以在每个偶数位置创建匹配空字符串的正则表达式,以便在其中插入一个字符:

  1. "abcdefgh".replace(/(?<=^(..)*)/g, "_"))positive lookbehind (?<=...) 输出 "_ab_cd_ef_gh_"(目前仅在 Chrome 中支持)

    • 匹配列表为[("", 0), ("", 2), ("", 4), ("", 6), ("", 8)]
  2. "abcdefgh".replace(/(?=(..)*$)/g, "_"))positive lookahead (?=...) 输出 "_ab_cd_ef_gh_"

    • 匹配列表为[("", 0), ("", 2), ("", 4), ("", 6), ("", 8)]

【讨论】:

  • 我认为将其称为直观(并且用粗体表示)有点牵强。在我看来,它更像是斯德哥尔摩综合症和事后合理化。顺便说一句,你的回答很好,我只抱怨 JS 设计,或者缺乏设计。
  • @EricDuminil 一开始我也是这么想的,但是在写完答案之后,如果从头开始,草拟的 global-regex-replace 算法似乎正是人们想出的方法。就像while (!input not eaten up) { matchAndEat(); }。此外,comments above 表明该行为起源于很久以前 JavaScript 存在之前。
  • 仍然没有意义的部分(除了“这就是标准所说的”之外的任何其他原因)是四字符匹配 ("abcd", 0) 不吃位置 4,其中以下字符会去,但零字符匹配 ("", 4) 确实吃掉了下一个字符将去的位置 4。如果我从头开始设计这个,我想我会使用的规则是 (str2, ix2) 可以遵循 (str1, ix1) iff ix2 >= ix1 + str1.length() && ix2 + str2.length() > ix1 + str1.length(),这不会导致这个错误。
  • @AndersKaseorg ("abcd", 0) 不吃位置 4,因为 "abcd" 只有 4 个字符长,因此只吃索引 0、1、2、3。我可以看到你的推理可能来自哪里:为什么我们不能将 ("abcd" ⋅ ε, 0) 作为 5 个字符长的匹配,其中 ⋅ 是连接,ε 是零宽度匹配?正式因为"abcd" ⋅ ε = "abcd"。我在最后几分钟想了一个直观的原因,但没有找到。 我想人们必须始终将ε 视为"" 本身发生。我很想尝试没有该错误或壮举的替代实现。请随时分享!
  • 如果四个字符串应该吃四个索引,那么零字符串应该不吃任何索引。您可能对其中一个做出的任何推理都应该同样适用于另一个(例如"" ⋅ ε = "",尽管我不确定您打算在""ε 之间做出什么区分,这意味着同样的事情)。所以这种差异不能用直观的方式来解释——它就是这样。
【解决方案3】:

第一个匹配显然是"asdf"(位置[0,4])。因为设置了全局标志 (g),所以它会继续搜索。此时(位置 4),它找到了第二个匹配项,一个空字符串(位置 [4,4])。

请记住,* 匹配零个或多个元素。

【讨论】:

  • 那么为什么不是三场比赛呢?最后可能会有另一个空匹配。正好有两个。这个解释解释了为什么可能有两个,但不能解释为什么应该有而不是一三个。
  • 不,没有其他空字符串。因为已经找到了那个空字符串。位置 4,4 上的空字符串,它被检测为唯一结果。标记为“4,4”的匹配不能重复。您可能会认为 [0,0] 位置有一个空字符串,但 * 运算符返回最大可能的元素。这就是只有 4,4 是可能的原因
  • 我们必须记住正则表达式不是正则表达式。在正则表达式中,每两个字符之间以及开头和结尾都有无限多个空字符串。在正则表达式中,空字符串的数量与特定正则表达式引擎的规范所说的一样多。
  • 这只是事后合理化。
  • @mosvy 除了它是实际使用的确切逻辑。
【解决方案4】:

简单地说,第一个x是用来替换匹配的asdf

第二个x 表示asdf 之后的空字符串。搜索在空时终止。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2011-01-29
    • 1970-01-01
    • 1970-01-01
    • 2012-08-27
    • 1970-01-01
    • 2015-09-20
    • 2011-01-17
    • 2019-02-19
    相关资源
    最近更新 更多