Vai al contenuto
Allin

Trova e sostituisci: le trappole delle espressioni regolari, dimostrate una per una

Pubblicato il 30/05/2025 · 14 min di lettura · Strumenti testo e lingua

Daniel Okonkwo

Daniel OkonkwoSviluppatore front-end e redattore Tech presso Allin

Performance web · Formati di file

Verificato su 5 fonti

Vedi il profilo
In breve

I modi in cui un trova-e-sostituisci va storto sono precisi e riproducibili, quindi si possono dimostrare invece che temere. I quantificatori sono golosi per impostazione predefinita: su «<b>bold</b> and <i>italic</i>», sostituire /<.+>/g con niente restituisce una stringa vuota, perché .+ corre fino all'ultimo > della riga; il pigro /<.+?>/g e il negato /<[^>]+>/g restituiscono entrambi «bold and italic». Il punto non riconosce mai un a capo senza il flag s: /Prezzo.*unità/ è falso su due righe e /Prezzo.*unità/s è vero. La stringa di sostituzione ha una sintassi propria, estranea al pattern: $& inserisce l'intera corrispondenza, $1 un gruppo, e $$ è l'unico modo di emettere un dollaro letterale — «$$$1» su «Totale: 42 EUR» dà «Totale: $42», mentre «$$&» dà il letterale «$&». Un oggetto regex con il flag g conserva un lastIndex fra le chiamate, quindi riusarlo di riga in riga salta corrispondenze: la stessa /\d+/g provata su tre righe d'ordine ha restituito vero, falso, vero. E /i è puro ripiegamento di maiuscole, non conoscenza linguistica: /i/i non riconosce İ, e /I/i non riconosce ı. Conta prima le corrispondenze, sostituisci dopo.

Goloso contro pigro sulla stessa stringa, il punto che salta gli a capo, $& e $$ nella sostituzione, una regex /g riusata che salta una riga in silenzio, e perché /i non sa nulla della i turca. Ogni guasto eseguito in Node, con una routine conta-poi-sostituisci che li intercetta.

Goloso e pigro sullo stesso input

Prendi la stringa <b>bold</b> and <i>italic</i> e prova a cancellare i tag con /<.+>/g. Il risultato è una stringa vuota. Il quantificatore + è goloso: prende tutto ciò che può e restituisce caratteri solo quando il resto del pattern fallisce, quindi .+ inghiotte tutto fino all'ultimo > e una sola corrispondenza copre l'intera riga. La prima corrispondenza di /<.+>/ è letteralmente l'input intero.

Due correzioni funzionano, e non sono equivalenti. Aggiungere un punto interrogativo rende pigro il quantificatore: /<.+?>/g prende il meno possibile, la sua prima corrispondenza è <b>, e la sostituzione restituisce bold and italic. Sostituire il punto con una classe negata fa lo stesso lavoro in altro modo: /<[^>]+>/g non può attraversare un > affatto, quindi restituisce anch'esso bold and italic. Preferisci la classe negata dove esiste: dice dov'è il confine invece di affidarsi al backtracking del motore per fermarsi al punto giusto, e non torna golosa in silenzio quando più tardi aggiungi una parte opzionale al pattern.

Il punto si ferma a fine riga

In JavaScript, . riconosce qualsiasi carattere tranne un terminatore di riga. Prendi una scheda su due righe: «Prezzo: 12,50 €», un a capo, poi «Scorta: 3 unità». Il test /Prezzo.*unità/ restituisce falso. Lo stesso pattern con il flag s, /Prezzo.*unità/s, restituisce vero, perché s — dotAll — rimuove l'eccezione del terminatore di riga. Il vecchio rimedio, una classe di caratteri che copre tutto, /Prezzo[\s\S]*unità/, restituisce anch'esso vero e funziona in runtime precedenti al flag.

È qui che una sostituzione fallisce in silenzio anziché a voce alta. Un pattern pensato per coprire un paragrafo semplicemente non trova nulla, il conteggio delle sostituzioni torna a zero, e una pipeline che non controlla quel conteggio dichiara successo. È anche il motivo per cui /.+/g è un divisore di righe utilizzabile: sulla stessa scheda di due righe restituisce due corrispondenze, una per riga — una funzionalità quando la vuoi e una sorpresa quando no.

La stringa di sostituzione ha una sintassi propria

Il secondo argomento di replace non è testo semplice. È un piccolo linguaggio, e il suo unico metacarattere è $. Su «Totale: 42 EUR» con /(\d+) (USD|EUR)/, la sostituzione [$&] dà «Totale: [42 EUR]», $1 dà «Totale: 42», e $<amount> — con il gruppo nominato nel pattern — dà «Totale: [42]». L'elenco completo è corto: $&, da $1 a $99, $<nome>, $` per tutto ciò che precede la corrispondenza, $' per tutto ciò che segue, e $$ per un dollaro letterale.

Ne seguono due trappole. La prima: il $ di una sostituzione non è il $ di un pattern. In un pattern, $ è un'ancora che significa fine input; in una sostituzione non significa mai quello. Scrivere $ dove si vuole un simbolo di valuta produce un $ letterale solo per fortuna — replace(/EUR/, '$') dà davvero «Totale: 42 $», perché un $ isolato seguito da nulla di speciale passa tale e quale, ma appena lo segue una cifra o una e commerciale il significato cambia. Scrivi $$ e smetti di pensarci.

La seconda trappola è un'ambiguità di cifra. $10 significa il gruppo 10 quando il pattern ha dieci gruppi di cattura, e il gruppo 1 seguito dal carattere 0 quando non li ha: su un pattern con dieci gruppi la sostituzione [$10] ha restituito [j], e su un pattern con un solo gruppo ha restituito [a0]. Il significato della tua stringa di sostituzione dipende quindi da quanti gruppi contiene il pattern: aggiungi un gruppo e la sostituzione cambia comportamento senza essere stata modificata. I gruppi con nome eliminano del tutto l'ambiguità, e se la sostituzione è un dato e non codice, passa una funzione: il suo valore di ritorno è inserito tale e quale, così replace(/\d+/, () => '$&') su «Totale: 42 EUR» produce il letterale «$&» invece del numero trovato.

Una regex /g ricorda dove si è fermata

Un oggetto RegExp con il flag g porta una proprietà mutabile lastIndex, che .test() ed .exec() leggono e scrivono entrambi. Riusa lo stesso oggetto su più stringhe e ogni ricerca parte da dove è finita la precedente. Prendi const re = /\d+/g e provalo su tre righe — «order 12», «order 7», «order 349» — e i risultati sono vero, falso, vero. La riga di mezzo un numero ce l'ha eccome. È stata saltata perché lastIndex valeva 8 quando è iniziata la ricerca su di essa, e dopo la terza chiamata lastIndex valeva 9.

Lo stesso effetto compare su una sola stringa. Quattro chiamate consecutive re.test('banana') con /a/g restituiscono vero, vero, vero, falso: tre a, poi una ricerca fallita che riporta lastIndex a 0, così una quinta chiamata tornerebbe vera. Nulla di tutto ciò è un bug — è quello che rende exec() utilizzabile in un ciclo while — ma trasforma una costante regex a livello di modulo in una variabile mutabile condivisa travestita.

Tre rimedi, in ordine di preferenza. Non mettere g su una regex che usi con .test(): il flag lì non aggiunge nulla e provoca tutto quanto sopra. Usa matchAll o match con g quando vuoi ogni occorrenza, dato che entrambi gestiscono l'indice per te. Oppure, se devi riusare un oggetto, imposta re.lastIndex = 0 prima di ogni ricerca, sapendo di aver scelto l'opzione fragile.

L'insensibilità alle maiuscole non conosce le lingue

Il flag i applica il ripiegamento di maiuscole Unicode, che è una tabella fissa e non una regola di locale. Il controesempio più netto è il turco, il cui alfabeto ha due i: la i puntata i/İ e la i senza punto ı/I. Esegui i test: /i/i non riconosce İ, e /I/i non riconosce ı — entrambi restituiscono falso. Nel frattempo 'I'.toLowerCase() restituisce 'i' ma 'I'.toLocaleLowerCase('tr') restituisce 'ı', e 'i'.toLocaleUpperCase('tr') restituisce 'İ'. Alle funzioni di maiuscolo si può indicare un locale. Al flag della regex no.

Altri tre risultati dallo stesso giro, ognuno dei quali è costato a qualcuno una mattinata. /ss/i non riconosce ß e /ß/i non riconosce SS, perché il ripiegamento di maiuscole è una corrispondenza carattere per carattere e la esse tagliata tedesca si espande in due lettere. /k/i non riconosce il segno kelvin K, ma /k/iu — con il flag Unicode — sì, perché u attiva il ripiegamento semplice: aggiungere un flag apparentemente solo sintattico cambia quindi quali caratteri sono considerati uguali. E /é/i non riconosce una É decomposta, una e seguita da un accento acuto combinante: il ripiegamento non è la normalizzazione, e le due sono decisioni indipendenti, come approfondisce l'articolo di questa serie dedicato agli accenti.

L'ancoraggio, e perché \b mente sulle parole accentate

\b non è un carattere. È un'asserzione a larghezza zero che riesce dove un carattere di parola (in JavaScript: [A-Za-z0-9_]) sta esattamente da un lato. Quella definizione è puro ASCII, e produce un risultato esattamente rovesciato per ogni lingua con segni diacritici. /\bcafé\b/ non riconosce «un café noir», perché dopo la é — che non è un carattere di parola — viene uno spazio, che non lo è nemmeno, quindi non c'è confine. Lo stesso pattern riconosce «un cafés», perché la é è seguita da s e quello è un confine. L'asserzione scatta dove la parola continua e fallisce dove finisce.

Il sostituto di \b è una coppia di asserzioni Unicode: /(?<![\p{L}\p{N}])café(?![\p{L}\p{N}])/u riconosce «un café noir» e rifiuta «un cafés», che è ciò che una persona intende per parola intera. Il flag u è indispensabile perché \p{…} sia solo riconosciuto. Quando la parola sostituita è puro ASCII, \b va ancora bene — /\bcane\b/ si comporta — ma appena il pattern contiene una lettera fuori da A–Z, controlla entrambe le estremità a mano.

Contare, poi sostituire: un esempio svolto

Prendi la riga: cane, canevaccio, cannella, il cane dorme. Il compito è sostituire l'animale con gatto. Conta prima: la riga.match(/cane/g).length vale 3. Te ne aspettavi 2. Quel solo numero, letto prima di scrivere qualsiasi cosa, è tutta la protezione — e se avessi sostituito invece di contare, l'output sarebbe stato «gatto, gattovaccio, cannella, il gatto dorme».

Ancora e riconta: /\bcane\b/g trova 2, il numero che ti aspettavi, e solo allora è sicuro sostituire. Il risultato è «gatto, canevaccio, cannella, il gatto dorme». Dopo, conta di nuovo: /\bcane\b/g deve ora trovare 0 e /\bgatto\b/g deve trovare 2. Tre conteggi e una sostituzione, quattro righe in tutto, e ognuna è una riga che puoi mostrare a chi chiede che cosa ha fatto la modifica.

Un dettaglio della versione tedesca dello stesso esercizio merita di essere tenuto. Sostituire Bahn con Zug in «Bahn, Bahnhof, Autobahn, die Bahn fährt» senza ancore dà 3 corrispondenze, non 4: il pattern distingue le maiuscole, quindi colpisce Bahnhof ma manca il bahn minuscolo dentro Autobahn. Un conteggio sotto le attese informa quanto uno sopra, e indica un bug diverso.

Che cosa significa ogni token in una stringa di sostituzione, eseguito su «Totale: 42 EUR» con il pattern /(\d+) (USD|EUR)/. Il $ di una sostituzione non ha nulla a che vedere con il $ di un pattern: qui non significa mai fine stringa.
TokenCosa inserisceSostituzione scrittaRisultato
$&L'intera corrispondenza[$&]Totale: [42 EUR]
$1Gruppo di cattura 1$1Totale: 42
$$Un dollaro letterale$$$1Totale: $42
$10Il gruppo 10 se esiste, altrimenti il gruppo 1 e poi il carattere 0$10Totale: 420
$` e $'Tutto ciò che precede, tutto ciò che segue la corrispondenza<$`>Totale: <Totale: >
$<nome>Un gruppo con nome; resta letterale se il pattern non ne ha[$<amount>]Totale: [42]
Trova e sostituisci testoSostituisci in un colpo ogni occorrenza di una parola o frase nel testo.Prova lo strumento

Domande frequenti

Perché la mia sostituzione ha cancellato un'intera riga?
Quasi sempre un quantificatore goloso fra due delimitatori che compaiono più di una volta. /<.+>/ su una riga con due tag va dal primo < all'ultimo >, quindi l'unica corrispondenza è l'intera riga. Aggiungi un punto interrogativo per renderlo pigro, /<.+?>/, o meglio, vieta il delimitatore di chiusura dentro la corrispondenza con una classe negata, /<[^>]+>/.
Perché la stessa regex dà una risposta diversa alla seconda chiamata?
Perché ha il flag g e un lastIndex che sopravvive fra le chiamate. /a/g provata quattro volte su «banana» restituisce vero, vero, vero, falso. Togli il flag g quando vuoi solo una risposta sì o no, usa matchAll quando vuoi ogni occorrenza, o imposta re.lastIndex = 0 prima di ogni ricerca se l'oggetto deve davvero essere condiviso.
Come metto un dollaro letterale nella sostituzione?
Scrivi $$. Un $ isolato funziona solo se non lo segue nulla di speciale, il che ne fa un bug in attesa della prossima modifica: «$$$1» su «Totale: 42 EUR» dà «Totale: $42», mentre l'intuitivo «$$&» dà il letterale «$&» invece della corrispondenza. Se il testo di sostituzione è un dato — un valore di modulo, una traduzione, qualcosa che non hai digitato tu —, passa una funzione invece di una stringa: il suo valore di ritorno è inserito senza alcuna elaborazione dei $.
Il flag i capisce gli accenti e gli altri alfabeti?
Capisce il ripiegamento delle maiuscole e nient'altro. Equipara a e A su quasi tutto Unicode, ma non normalizza: /é/i fallisce su una É decomposta; non espande: /ss/i fallisce su ß; e non conosce alcun locale: /i/i fallisce sulla İ turca. Se ti serve uno di questi comportamenti, normalizza prima il testo e usa un collatore con sensibilità «base» per il confronto, invece di aspettarti dal flag una conoscenza linguistica che non ha mai avuto.
Come faccio a far corrispondere su due righe?
Aggiungi il flag s, che fa riconoscere al punto anche i terminatori di riga: /Prezzo.*unità/ è falso su due righe e /Prezzo.*unità/s è vero. Non confonderlo con m, che riguarda le ancore e non il punto: m fa corrispondere ^ e $ all'inizio e alla fine di ogni riga invece che dell'intero input, e lascia il punto com'era. Spesso li vuoi entrambi, e sono indipendenti.
Perché \b fallisce su una parola che finisce con una lettera accentata?
Perché \b è definito sulla classe di parola ASCII [A-Za-z0-9_], e una lettera accentata non ne fa parte. Un confine richiede un carattere di parola esattamente da un lato, e fra la é e lo spazio successivo non ce n'è nessuno, quindi /\bcafé\b/ non riconosce «un café noir» — mentre riconosce «un cafés», dove la é è seguita da una s ASCII. Sostituisci le asserzioni con asserzioni Unicode: /(?<![\p{L}\p{N}])café(?![\p{L}\p{N}])/u, ricordando che \p{…} richiede il flag u.

Articoli che potrebbero interessarti

Tutte le guide
TutorialFiltrare righe secondo uno schema, senza riga di comandoQuesto è grep per chi non usa grep, con una differenza importante: la ricerca è una sottostringa letterale, quindi una vera espressione regolare restituisce un riquadro vuoto e nessun errore. Ogni affermazione è stata verificata eseguendo lo strumento.TutorialBasi delle regex: guida per principiantiUn'espressione regolare è un modello per cercare testo. Ecco i mattoni — classi di caratteri, quantificatori e ancore — con un esempio.TutorialNumerare le righe di un testo per una revisione a più maniLa numerazione parte da 1 e non si può impostare a 0, l'allineamento usa spazi e non zeri, e lo strumento che li toglie annulla otto degli undici separatori senza toccare l'indentazione. Quello che ancora non sa fare è distinguere i tuoi numeri dai suoi.GuidaFormattare i numeri per sei lingue: separatori, valuta e il ritorno al valore1.234,56 e 1,234.56 sono lo stesso numero, e confonderli cambia il valore che un lettore legge. Abbiamo eseguito Intl.NumberFormat per tutte e sei le lingue del sito e stampato ogni separatore — compreso quello invisibile usato dal francese — e poi misurato perché parseFloat non può disfare nulla di tutto ciò.SpiegazioneLe emoji sono più difficili di quanto sembri: perché «basta toglierle» non ha una risposta in una rigaUna emoji visibile può valere un punto di codice o quattordici unità UTF-16. Abbiamo lanciato tre espressioni regolari diffuse su una frase vera e ognuna si è rotta in modo diverso; una ha cancellato le cifre. Ecco perché, quale proprietà Unicode risponde a quale domanda, e la regola a gruppi di grafemi che funziona davvero.SpiegazioneDove una riga può andare a capo: l'algoritmo Unicode dietro ogni paragrafo mandato a capo«Andare a capo sugli spazi» fallisce nella maggior parte dei sistemi di scrittura. UAX #14 assegna a ogni carattere una classe di interruzione di riga; abbiamo cercato le nostre in Unicode 17.0.0 ed eseguito un'implementazione conforme su spazi unificatori, trattini morbidi, spazi a larghezza zero, URL, giapponese e thai.

Strumenti correlati

Fonti

Hai notato un errore in questo articolo?