Localizar e substituir: as armadilhas das expressões regulares, demonstradas uma a uma
Publicado a 30/05/2025 · 14 min de leitura · Ferramentas de texto e idioma
Daniel Okonkwo — Programador front-end e redator de Tecnologia na Allin
Desempenho web · Formatos de ficheiro
Verificado a partir de 5 fontes
As maneiras como um localizar-e-substituir falha são concretas e reproduzíveis, por isso podem ser demonstradas em vez de avisadas. Os quantificadores são gananciosos por omissão: sobre «<b>bold</b> and <i>italic</i>», substituir /<.+>/g por nada devolve uma cadeia vazia, porque .+ corre até ao último > da linha; o preguiçoso /<.+?>/g e o negado /<[^>]+>/g devolvem ambos «bold and italic». O ponto nunca reconhece uma quebra de linha sem a bandeira s: /Preço.*unidades/ é falso em duas linhas e /Preço.*unidades/s é verdadeiro. A cadeia de substituição tem a sua própria sintaxe, alheia ao padrão: $& insere a correspondência inteira, $1 um grupo, e $$ é a única forma de emitir um cifrão literal — «$$$1» sobre «Total: 42 EUR» dá «Total: $42», enquanto «$$&» dá o literal «$&». Um objeto regex com a bandeira g guarda um lastIndex entre chamadas, por isso reutilizá-lo de linha em linha salta correspondências: a mesma /\d+/g testada sobre três linhas de encomenda devolveu verdadeiro, falso, verdadeiro. E /i é puro dobramento de caixa, não conhecimento linguístico: /i/i não reconhece İ, e /I/i não reconhece ı. Conte as correspondências primeiro, substitua depois.
Ganancioso contra preguiçoso sobre a mesma cadeia, o ponto que salta as quebras de linha, $& e $$ na substituição, uma regex /g reutilizada que salta uma linha em silêncio, e porque /i nada sabe do i turco. Cada falha executada no Node, com uma rotina contar-e-depois-substituir que as apanha.
Ganancioso e preguiçoso sobre a mesma entrada
Tome a cadeia <b>bold</b> and <i>italic</i> e tente apagar as etiquetas com /<.+>/g. O resultado é uma cadeia vazia. O quantificador + é ganancioso: toma tudo o que pode e só devolve caracteres quando o resto do padrão falha, por isso .+ engole tudo até ao último > e uma só correspondência cobre a linha inteira. A primeira correspondência de /<.+>/ é literalmente a entrada completa.
Duas correções funcionam, e não são equivalentes. Acrescentar um ponto de interrogação torna o quantificador preguiçoso: /<.+?>/g toma o menos possível, a sua primeira correspondência é <b>, e a substituição devolve bold and italic. Substituir o ponto por uma classe negada faz o mesmo trabalho de outro modo: /<[^>]+>/g não pode atravessar um > de todo, por isso também devolve bold and italic. Prefira a classe negada quando exista: diz onde está a fronteira em vez de confiar no retrocesso do motor para parar no sítio certo, e não volta a ficar ganancioso em silêncio quando mais tarde acrescenta uma parte opcional ao padrão.
O ponto para no fim da linha
Em JavaScript, . reconhece qualquer caráter salvo um terminador de linha. Tome uma ficha de duas linhas: «Preço: 12,50 €», uma quebra de linha, e depois «Stock: 3 unidades». O teste /Preço.*unidades/ devolve falso. O mesmo padrão com a bandeira s, /Preço.*unidades/s, devolve verdadeiro, porque s — dotAll — elimina a exceção do terminador de linha. O remendo antigo, uma classe de caracteres que cobre tudo, /Preço[\s\S]*unidades/, também devolve verdadeiro e funciona em motores anteriores à bandeira.
É aqui que uma substituição falha em silêncio e não aos gritos. Um padrão pensado para abranger um parágrafo simplesmente não encontra nada, a contagem de substituições volta como zero, e um pipeline que não verifica essa contagem anuncia sucesso. É também a razão pela qual /.+/g serve de divisor de linhas: sobre a mesma ficha de duas linhas devolve duas correspondências, uma por linha, o que é uma funcionalidade quando se quer e uma surpresa quando não.
A cadeia de substituição tem a sua própria sintaxe
O segundo argumento de replace não é texto simples. É uma pequena linguagem, e o seu único metacaráter é $. Sobre «Total: 42 EUR» com /(\d+) (USD|EUR)/, a substituição [$&] dá «Total: [42 EUR]», $1 dá «Total: 42», e $<amount> — com o grupo nomeado no padrão — dá «Total: [42]». A lista completa é curta: $&, $1 a $99, $<nome>, $` para tudo o que precede a correspondência, $' para tudo o que segue, e $$ para um cifrão literal.
Daí decorrem duas armadilhas. A primeira: o $ de uma substituição não é o $ de um padrão. Num padrão, $ é uma âncora que significa fim de entrada; numa substituição nunca significa isso. Escrever $ onde se quer um símbolo monetário produz um $ literal só por sorte — replace(/EUR/, '$') dá de facto «Total: 42 $», porque um $ solitário seguido de nada especial passa tal e qual, mas assim que lhe segue um dígito ou um e comercial o significado muda. Escreva $$ e deixe de pensar nisso.
A segunda armadilha é uma ambiguidade de dígito. $10 significa o grupo 10 quando o padrão tem dez grupos de captura, e o grupo 1 seguido do caráter 0 quando não tem: sobre um padrão com dez grupos, a substituição [$10] devolveu [j], e sobre um padrão com um só grupo devolveu [a0]. O significado da sua cadeia de substituição depende portanto de quantos grupos o padrão contém: acrescente um grupo e a substituição muda de comportamento sem ter sido editada. Os grupos com nome eliminam a ambiguidade, e se a substituição for um dado e não código, passe uma função: o seu valor de retorno é inserido tal e qual, de modo que replace(/\d+/, () => '$&') sobre «Total: 42 EUR» produz o literal «$&» em vez do número encontrado.
Uma regex /g lembra-se de onde parou
Um objeto RegExp com a bandeira g carrega uma propriedade mutável lastIndex, que .test() e .exec() leem e escrevem. Reutilize o mesmo objeto sobre várias cadeias e cada pesquisa começa onde a anterior acabou. Tome const re = /\d+/g e teste-a sobre três linhas — «order 12», «order 7», «order 349» — e os resultados são verdadeiro, falso, verdadeiro. A linha do meio tem mesmo um número. Foi saltada porque lastIndex valia 8 quando a pesquisa nela começou, e depois da terceira chamada lastIndex valia 9.
O mesmo efeito aparece sobre uma só cadeia. Quatro chamadas consecutivas re.test('banana') com /a/g devolvem verdadeiro, verdadeiro, verdadeiro, falso: três aes, e depois uma pesquisa falhada que repõe lastIndex a 0, de modo que uma quinta chamada voltaria a dar verdadeiro. Nada disto é um erro — é o que torna exec() utilizável num ciclo while — mas transforma uma constante regex ao nível do módulo numa variável mutável partilhada disfarçada.
Três correções, por ordem de preferência. Não ponha g numa regex que use com .test(): a bandeira nada acrescenta aí e provoca tudo o que ficou acima. Use matchAll ou match com g quando quiser todas as ocorrências, já que ambos gerem o índice por si. Ou, se tiver de reutilizar um objeto, ponha re.lastIndex = 0 antes de cada pesquisa, sabendo que escolheu a opção frágil.
A insensibilidade a maiúsculas não sabe de línguas
A bandeira i aplica o dobramento de caixa Unicode, que é uma tabela fixa e não uma regra de locale. O contraexemplo mais claro é o turco, cujo alfabeto tem dois is: o i com ponto i/İ e o i sem ponto ı/I. Execute os testes: /i/i não reconhece İ, e /I/i não reconhece ı — ambas devolvem falso. Entretanto, 'I'.toLowerCase() devolve 'i' mas 'I'.toLocaleLowerCase('tr') devolve 'ı', e 'i'.toLocaleUpperCase('tr') devolve 'İ'. Às funções de caixa pode indicar-se um locale. À bandeira da regex, não.
Mais três resultados da mesma execução, cada um dos quais custou uma manhã a alguém. /ss/i não reconhece ß e /ß/i não reconhece SS, porque o dobramento de caixa é uma correspondência caráter a caráter e o eszett alemão expande-se para duas letras. /k/i não reconhece o sinal kelvin K, mas /k/iu — com a bandeira Unicode — reconhece, porque u ativa o dobramento de caixa simples: acrescentar uma bandeira de aparência puramente sintática muda que caracteres são considerados iguais. E /é/i não reconhece um É decomposto, um e seguido de um acento agudo combinante: o dobramento de caixa não é a normalização, e ambos são decisões independentes, como detalha o artigo desta série dedicado aos acentos.
A ancoragem, e porque \b mente sobre as palavras acentuadas
\b não é um caráter. É uma asserção de largura zero que triunfa onde um caráter de palavra (em JavaScript: [A-Za-z0-9_]) está exatamente de um lado. Essa definição é puro ASCII, e produz um resultado exatamente ao contrário para qualquer língua com diacríticos. /\bcafé\b/ não reconhece «un café noir», porque depois do é — que não é caráter de palavra — vem um espaço, que também não é, por isso não há fronteira. O mesmo padrão reconhece «un cafés», porque o é é seguido de s e isso é uma fronteira. A asserção dispara onde a palavra continua e falha onde acaba.
O substituto de \b é um par de asserções Unicode: /(?<![\p{L}\p{N}])café(?![\p{L}\p{N}])/u reconhece «un café noir» e recusa «un cafés», que é o que uma pessoa entende por palavra inteira. A bandeira u é indispensável para que \p{…} seja sequer reconhecido. Quando a palavra substituída é puro ASCII, \b continua a servir — /\bmar\b/ porta-se bem — mas assim que o padrão contém uma letra fora de A–Z, verifique as duas pontas à mão.
Contar e depois substituir: um exemplo resolvido
Tome a linha: mar, marca, marisco, o mar está. Trata-se de substituir a água salgada por rio. Conte primeiro: a linha.match(/mar/g).length vale 4. Esperava 2. Esse único número, lido antes de escrever seja o que for, é toda a proteção — e se tivesse substituído em vez de contar, a saída teria sido «rio, rioca, rioisco, o rio está».
Ancore e volte a contar: /\bmar\b/g encontra 2, o número que esperava, e só então é seguro substituir. O resultado é «rio, marca, marisco, o rio está». Depois, conte outra vez: /\bmar\b/g deve agora encontrar 0 e /\brio\b/g deve encontrar 2. Três contagens e uma substituição, quatro linhas ao todo, e cada uma é uma linha que pode mostrar a quem pergunte o que fez a alteração.
Um detalhe da versão alemã do mesmo exercício merece ser guardado. Substituir Bahn por Zug em «Bahn, Bahnhof, Autobahn, die Bahn fährt» sem âncoras dá 3 correspondências, não 4: o padrão distingue maiúsculas, por isso atinge Bahnhof mas falha o bahn minúsculo dentro de Autobahn. Uma contagem abaixo do esperado informa tanto como uma acima, e aponta para um erro diferente.
| Ficha | O que insere | Substituição escrita | Resultado |
|---|---|---|---|
| $& | A correspondência inteira | [$&] | Total: [42 EUR] |
| $1 | Grupo de captura 1 | $1 | Total: 42 |
| $$ | Um cifrão literal | $$$1 | Total: $42 |
| $10 | O grupo 10 se existir, senão o grupo 1 e depois o caráter 0 | $10 | Total: 420 |
| $` e $' | Tudo o que precede, tudo o que segue a correspondência | <$`> | Total: <Total: > |
| $<nome> | Um grupo com nome; fica literal se o padrão não tiver nenhum | [$<amount>] | Total: [42] |
Perguntas frequentes
- Porque é que a minha substituição apagou uma linha inteira?
- Quase sempre um quantificador ganancioso entre dois delimitadores que aparecem mais de uma vez. /<.+>/ sobre uma linha com duas etiquetas vai do primeiro < ao último >, por isso a única correspondência é a linha inteira. Acrescente um ponto de interrogação para o tornar preguiçoso, /<.+?>/, ou melhor, proíba o delimitador de fecho dentro da correspondência com uma classe negada, /<[^>]+>/.
- Porque é que a mesma regex dá outra resposta na segunda chamada?
- Porque tem a bandeira g e um lastIndex que sobrevive entre chamadas. /a/g testada quatro vezes sobre «banana» devolve verdadeiro, verdadeiro, verdadeiro, falso. Retire a bandeira g quando só quiser uma resposta de sim ou não, use matchAll quando quiser todas as ocorrências, ou ponha re.lastIndex = 0 antes de cada pesquisa se o objeto tiver mesmo de ser partilhado.
- Como ponho um cifrão literal na substituição?
- Escreva $$. Um $ solitário só funciona se nada de especial o seguir, o que faz dele um erro à espera da próxima edição: «$$$1» sobre «Total: 42 EUR» dá «Total: $42», enquanto o intuitivo «$$&» dá o literal «$&» em vez da correspondência. Se o texto de substituição for um dado — um valor de formulário, uma tradução, algo que não escreveu —, passe uma função em vez de uma cadeia: o seu valor de retorno é inserido sem qualquer processamento de $.
- A bandeira i entende os acentos e outros alfabetos?
- Entende o dobramento de caixa e nada mais. Igualará a com A em quase todo o Unicode, mas não normaliza: /é/i falha sobre um É decomposto; não expande: /ss/i falha sobre ß; e não conhece nenhum locale: /i/i falha sobre o İ turco. Se precisar de algum desses comportamentos, normalize o texto primeiro e use um colador com sensibilidade «base» para a comparação, em vez de esperar que a bandeira ganhe um conhecimento linguístico que nunca teve.
- Como faço uma correspondência através de duas linhas?
- Acrescente a bandeira s, que faz o ponto reconhecer também os terminadores de linha: /Preço.*unidades/ é falso em duas linhas e /Preço.*unidades/s é verdadeiro. Não a confunda com m, que trata de âncoras e não do ponto: m faz ^ e $ corresponderem ao início e ao fim de cada linha em vez da entrada inteira, e deixa o ponto tal e qual. Muitas vezes quer as duas, e são independentes.
- Porque é que \b falha numa palavra terminada em letra acentuada?
- Porque \b está definido contra a classe de palavra ASCII [A-Za-z0-9_], e uma letra acentuada não pertence a ela. Uma fronteira exige um caráter de palavra de um só lado, e entre o é e o espaço seguinte não há nenhum, por isso /\bcafé\b/ não reconhece «un café noir» — enquanto reconhece «un cafés», onde o é é seguido de um s ASCII. Substitua as asserções por asserções Unicode: /(?<![\p{L}\p{N}])café(?![\p{L}\p{N}])/u, lembrando que \p{…} exige a bandeira u.
Artigos que podem interessar-lhe
Todos os guias →Ferramentas relacionadas
Fontes
- Ecma International — ECMAScript Language Specification — RegExp objects, the lastIndex property, and String.prototype.replace (the GetSubstitution algorithm)
- MDN Web Docs — String.prototype.replace() — specifying a string or a function as the replacement
- MDN Web Docs — Regular expressions — quantifiers, assertions and the d, g, i, m, s, u, v, y flags
- Unicode Consortium — Unicode Technical Standard #18: Unicode Regular Expressions
- Unicode Consortium — Unicode Standard Annex #44: Unicode Character Database — case folding and the SpecialCasing data for Turkish i
Detetaste um erro neste artigo?