Por que "asdf" .replace (/.*/g, "x") == "xx"?

132

Eu me deparei com um fato surpreendente (para mim).

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

Por que duas substituições? Parece que qualquer sequência não vazia sem novas linhas produzirá exatamente duas substituições para esse padrão. Usando uma função de substituição, posso ver que a primeira substituição é para toda a cadeia e a segunda é para uma cadeia vazia.

recursivo
fonte
9
exemplo mais simples: "asdf".match(/.*/g)return ["asdf", ""]
Narro 17/04
32
Por causa da bandeira global (g). O sinalizador global permite que outra pesquisa comece no final da correspondência anterior, encontrando assim uma sequência vazia.
Celsiuss 17/04
6
e vamos ser honestos: provavelmente ninguém queria exatamente esse comportamento. provavelmente era um detalhe de implementação do desejo "aa".replace(/b*/, "b")de resultar babab. E em algum momento padronizamos todos os detalhes de implementação dos navegadores da web.
Lux
4
As versões mais antigas do @ Joshua do GNU sed (não outras implementações!) Também estavam exibindo esse bug, que foi corrigido em algum lugar entre as versões 2.05 e 3.01 (há mais de 20 anos). Eu suspeito que é aí que esse comportamento se originou, antes de entrar no perl (onde se tornou um recurso) e de lá no javascript.
mosvy 18/04
11
@recursive - justo o suficiente. Acho os dois surpreendentes por um segundo, depois percebo "correspondência de largura zero" e não me surpreendo mais. :-)
TJ Crowder

Respostas:

98

De acordo com o padrão ECMA-262 , String.prototype.replace chama RegExp.prototype [@@ replace] , que diz:

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).

onde rxestá /.*/ge Sestá 'asdf'.

Ver 11.c.iii.2.b:

b. Seja nextIndex AdvanceStringIndex (S, thisIndex, fullUnicode).

Portanto, na 'asdf'.replace(/.*/g, 'x')verdade é:

  1. resultado (indefinido), resultados = [], lastIndex =0
  2. resultado = 'asdf', resultados = [ 'asdf' ], lastIndex =4
  3. resultado = '', = resultados [ 'asdf', '' ], lastIndex = 4, AdvanceStringIndex, ajustado para lastIndex5
  4. resultado = null, resultados = [ 'asdf', '' ], retorno

Portanto, existem 2 correspondências.

Alan Liang
fonte
42
Essa resposta exige que eu estude para entender.
Felipe
O TL; DR é que ele corresponde a uma 'asdf'string vazia ''.
jimh 24/04
34

Juntos, em um bate-papo offline com o yawkat , encontramos uma maneira intuitiva de ver por que "abcd".replace(/.*/g, "x")exatamente produz duas correspondências. Observe que não verificamos se é totalmente igual à semântica imposta pelo padrão ECMAScript, portanto, tome isso como uma regra geral.

Regras de ouro

  • Considere as correspondências como uma lista de tuplas (matchStr, matchIndex)em ordem cronológica que indica quais partes e índices da string de entrada já foram consumidos.
  • Essa lista é criada continuamente a partir da esquerda da sequência de entrada do regex.
  • As peças já consumidas não podem mais ser correspondidas
  • A substituição é feita nos índices fornecidos matchIndexsubstituindo a substring matchStrnessa posição. Se matchStr = "", então a "substituição" é efetivamente inserção.

Formalmente, o ato de correspondência e substituição é descrito como um loop, como visto na outra resposta .

Exemplos fáceis

  1. "abcd".replace(/.*/g, "x")saídas "xx":

    • A lista de correspondências é [("abcd", 0), ("", 4)]

      Notavelmente, ele não inclui as seguintes correspondências pelas quais se poderia pensar pelas seguintes razões:

      • ("a", 0), ("ab", 0): o quantificador *é ganancioso
      • ("b", 1), ("bc", 1): devido à partida anterior ("abcd", 0), as cordas "b"e "bc"já estão comidas
      • ("", 4), ("", 4) (ou seja, duas vezes): a posição 4 do índice já foi consumida pela primeira correspondência aparente
    • Portanto, a cadeia de "x"substituição substitui as cadeias de correspondência encontradas exatamente nessas posições: na posição 0 substitui a cadeia "abcd"e na posição 4 substitui "".

      Aqui você pode ver que a substituição pode atuar como uma verdadeira substituição de uma string anterior ou apenas como a inserção de uma nova string.

  2. "abcd".replace(/.*?/g, "x")com saídas de um quantificador preguiçoso*?"xaxbxcxdx"

    • A lista de correspondências é [("", 0), ("", 1), ("", 2), ("", 3), ("", 4)]

      Em contraste com o exemplo anterior, aqui ("a", 0), ("ab", 0), ("abc", 0), ou mesmo ("abcd", 0)não estão incluídos devido à preguiça do quantificador que estritamente limita-lo para encontrar a correspondência mais curta possível.

    • Como todas as seqüências de correspondência estão vazias, nenhuma substituição real ocorre, mas inserções xnas posições 0, 1, 2, 3 e 4.

  3. "abcd".replace(/.+?/g, "x")com saídas de um quantificador preguiçoso+?"xxxx"

    • A lista de correspondências é [("a", 0), ("b", 1), ("c", 2), ("d", 3)]
  4. "abcd".replace(/.{2,}?/g, "x")com saídas de um quantificador preguiçoso[2,}?"xx"

    • A lista de correspondências é [("ab", 0), ("cd", 2)]
  5. "abcd".replace(/.{0}/g, "x")saídas "xaxbxcxdx"pela mesma lógica do exemplo 2.

Exemplos mais difíceis

Podemos explorar consistentemente a ideia de inserção em vez de substituição, se sempre correspondermos a uma string vazia e controlar a posição em que essas correspondências acontecem em nossa vantagem. Por exemplo, podemos criar expressões regulares correspondentes à string vazia em todas as posições pares para inserir um caractere:

  1. "abcdefgh".replace(/(?<=^(..)*)/g, "_"))com um lookbehind positivos(?<=...) saídas "_ab_cd_ef_gh_"(somente suportadas em cromo até agora)

    • A lista de correspondências é [("", 0), ("", 2), ("", 4), ("", 6), ("", 8)]
  2. "abcdefgh".replace(/(?=(..)*$)/g, "_"))com saídas positivas à frente(?=...)"_ab_cd_ef_gh_"

    • A lista de correspondências é [("", 0), ("", 2), ("", 4), ("", 6), ("", 8)]
ComFreek
fonte
4
Eu acho que é um pouco exagerado chamá-lo de intuitivo (e em negrito). Para mim, parece mais a síndrome de Estocolmo e a racionalização post-hoc. Sua resposta é boa, BTW, eu apenas reclamo sobre o design do JS, ou a falta de design para esse assunto.
Eric Duminil 18/04
7
@EricDuminil Eu também pensava assim no início, mas depois de escrever a resposta, o algoritmo esboçado de substituição de regex global parece ser exatamente da maneira que alguém o criaria se começasse do zero. É como while (!input not eaten up) { matchAndEat(); }. Além disso, os comentários acima indicam que o comportamento se originou há muito tempo antes da existência do JavaScript.
ComFreek 18/04
2
A parte que ainda não faz sentido (por qualquer outro motivo que não seja "é o que o padrão diz") é que a correspondência de quatro caracteres ("abcd", 0)não ocupa a posição 4 onde o personagem seguinte iria, mas a correspondência de zero caracteres ("", 4)não coma a posição 4 onde o personagem a seguir iria. Se eu estivesse projetando isso do zero, acho que a regra que eu usaria é a que (str2, ix2)segue o (str1, ix1)iff ix2 >= ix1 + str1.length() && ix2 + str2.length() > ix1 + str1.length(), o que não causa esse erro.
Anders Kaseorg
2
O @AndersKaseorg ("abcd", 0)não ocupa a posição 4, pois "abcd"possui apenas 4 caracteres e, portanto, apenas come os índices 0, 1, 2, 3. Posso ver de onde seu raciocínio pode vir: por que não podemos ter ("abcd" ⋅ ε, 0)uma correspondência de 5 caracteres em que ⋅ é concatenação e εa correspondência de largura zero? Formalmente porque "abcd" ⋅ ε = "abcd". Pensei em uma razão intuitiva para os últimos minutos, mas não consegui encontrar uma. Eu acho que é preciso sempre tratar εcomo apenas ocorrendo por conta própria "". Eu adoraria jogar com uma implementação alternativa sem esse bug ou façanha. Sinta-se à vontade para compartilhar!
ComFreek 20/04
11
Se a cadeia de quatro caracteres comer quatro índices, a cadeia de zero caracteres não comerá índices. Qualquer raciocínio que você possa fazer sobre um deve aplicar-se igualmente ao outro (por exemplo "" ⋅ ε = "", embora eu não tenha certeza de qual distinção você pretende fazer ""e εque significa a mesma coisa). Portanto, a diferença não pode ser explicada como intuitiva - é simplesmente.
Anders Kaseorg
26

A primeira partida é obviamente "asdf"(posição [0,4]). Como o sinalizador global ( g) está definido, ele continua pesquisando. Nesse ponto (posição 4), ele encontra uma segunda correspondência, uma string vazia (posição [4,4]).

Lembre-se de que *corresponde a zero ou mais elementos.

David SK
fonte
4
Então, por que não três partidas? Pode haver outra partida vazia no final. Existem precisamente dois. Esta explicação explica por que pode haver dois, mas não por que deveria haver em vez de um ou três.
recursivo
7
Não, não há outra string vazia. Porque essa cadeia vazia foi encontrada. uma string vazia na posição 4,4, é detectada como um resultado único. Uma correspondência denominada "4,4" não pode ser repetida. provavelmente você pode pensar que há uma string vazia na posição [0,0], mas o operador * retorna o máximo possível de elementos. esta é a razão pela qual apenas 4,4 é possível
David SK
16
Temos que lembrar que expressões regulares não são expressões regulares. Nas expressões regulares, existem infinitas seqüências de caracteres vazias entre cada dois caracteres, bem como no início e no final. Nas regexes, existem exatamente tantas cadeias vazias quanto a especificação para o sabor específico do mecanismo de regex diz que existem.
Jörg W Mittag 17/04
7
Isso é apenas racionalização post-hoc.
mosvy 17/04
9
@mosvy, exceto que é a lógica exata que realmente é usada.
hobbs 18/04
1

simplesmente, o primeiro xé para a substituição da correspondência asdf.

segundo xpara a sequência vazia depois asdf. A pesquisa termina quando vazia.

Nilanka Manoj
fonte