<.+> contro <b>bold</b> and <i>italic</i> non trova <b>. Trova l’intera stringa, dal primo < all’ultimo >. Non c’è nessun bug. È esattamente quello che un quantificatore greedy deve fare, ed è anche il bug più comune in assoluto quando “il regex matcha troppo”. Una volta capito cosa fa il motore mentre legge il pattern, questo bug e i suoi parenti smettono di essere misteriosi.
Come un Motore Regex Legge il Pattern
Un motore regex non confronta il pattern con la stringa come si confrontano due stringhe per uguaglianza. I motori che usi ogni giorno (re di Python, RegExp di JavaScript, PCRE, .NET, Java) sono regex-directed: scorrono il pattern da sinistra a destra, token per token, e per ogni token provano a consumare caratteri dalla stringa nella posizione attuale. Quando un token trova corrispondenza, il motore avanza sia il puntatore del pattern che quello della stringa. Quando un token fallisce, il motore fa backtracking: torna all’ultimo punto in cui aveva una scelta, come un quantificatore che poteva fermarsi prima o un ramo di un’alternanza non ancora provato, e prova l’altra opzione.
Per questo <.+> si mangia l’intera riga. .+ è greedy, quindi afferra subito tutti i caratteri rimanenti nella stringa, < e > compresi. Il motore ha ancora bisogno di un > letterale per chiudere, quindi restituisce un carattere alla volta dalla fine finché l’ultimo > della stringa non lo soddisfa. Il motore restituisce la prima corrispondenza che trova in questo ordine greedy-first. Per questo pattern, è la corrispondenza più lunga possibile, non quella che sembra corretta a te.
Prova prima l’opzione greedy, fai un passo indietro al primo fallimento: questa regola singola spiega sia il matching quotidiano normale sia i rallentamenti patologici che vedi più avanti. È la stessa dinamica di esplorazione-e-ripristino di una ricerca ricorsiva senza memoizzazione, il tipo di esplosione che la ricorsione spiegata tratta dal lato delle chiamate di funzione.
Sintassi Regex Fondamentale: Ancore, Quantificatori, Gruppi, Lookaround
Ancore e Classi di Caratteri
^ e $ ancorano una corrispondenza all’inizio e alla fine della stringa (o della riga, con il flag multiline). \d, \w e \s sono abbreviazioni per le classi di caratteri cifra, carattere di parola e spazio, e [...] permette di scrivere una classe esplicitamente.
| |
Senza le ancore, \d{3}-\d{4} troverebbe corrispondenza anche dentro "call 555-0199 now". Le ancore trasformano “contiene” in “è esattamente”.
Le classi di caratteri operano su quello che il motore considera un singolo carattere, e fuori dall’ASCII puro non è sempre quello che ti aspetti. \w trova una lettera, una cifra o un underscore secondo la nozione di carattere del motore, che è la stessa distinzione code-point-contro-byte trattata in cosa sono Unicode e la codifica UTF-8. Esegui un pattern sui byte UTF-8 grezzi invece che sul testo decodificato e un carattere multi-byte viene diviso in quello che il motore crede siano caratteri separati.
Quantificatori Greedy e Lazy
+, * e {m,n} sono greedy di default: prendono quanto possono, poi fanno backtracking se questo rompe il resto del pattern. Metti un ? dopo uno di questi e il quantificatore diventa lazy. Prende il minimo possibile e si espande solo quando è costretto.
| |
Un carattere di differenza, un risultato completamente diverso. Quando vuoi la cosa più corta che soddisfa il resto del pattern, usa la forma lazy. Se quello che stai matchando è markup, leggi prima l’ultima sezione.
Gruppi con Catturazione, Senza Catturazione e con Nome
(...) cattura quello che trova così puoi estrarlo dopo. (?:...) raggruppa per struttura, ad esempio per applicare un quantificatore a un intero sottopattern, senza creare una catturazione. (?P<name>...) in Python ((?<name>...) in JavaScript, Java e .NET) assegna un nome a un gruppo così non devi contare le parentesi per ritrovarlo dopo.
| |
| |
Leggi il risultato per nome, da groupdict() in Python o .groups in JavaScript (i gruppi con nome sono arrivati lì con ES2018), invece di match[1], match[2]. Appena un pattern ha più di due gruppi, i caratteri extra si ripagano da soli.
Lookahead e Lookbehind
Un lookaround verifica una posizione, non caratteri. Controlla che qualcosa ci sia (o non ci sia) senza consumarlo. (?=...) è un lookahead positivo, (?!...) negativo; (?<=...) e (?<!...) sono gli equivalenti per il lookbehind.
| |
Il modulo re di Python richiede che il lookbehind sia a larghezza fissa: (?<=\$) va bene, (?<=\$+) genera un errore, perché il motore deve sapere esattamente quanto tornare indietro prima di verificare. JavaScript, che ha aggiunto il lookbehind in ES2018 insieme ai gruppi con nome, non ha questo limite, quindi (?<=\$+) funziona lì. In Python, il package di terze parti regex su PyPI rimuove il vincolo se ti serve un lookbehind a larghezza variabile.
Perché Alcuni Regex si Bloccano: Catastrophic Backtracking
Prendi la regola del backtracking vista all’inizio e dalle un caso da cui non può recuperare. Annida due quantificatori così il motore ha molti modi per dividere la stessa sequenza di caratteri tra loro, poi fai fallire il match proprio alla fine. Il motore non fallisce rapidamente. Prova ogni divisione possibile prima di arrendersi.
| |
| |
Ogni a in più raddoppia circa il tempo: due caratteri di più, quattro volte più lento. Questa è crescita esponenziale, la curva che la notazione Big O mette dall’altra parte di O(n^2). (a+)+ può dividere una sequenza di 28 a in gruppi in 2^27 modi diversi, e senza una b nella stringa il motore li prova uno dopo l’altro prima di poter segnalare “nessuna corrispondenza”. Segui la stessa curva fino a 40 caratteri e aspetti circa nove ore.
Quando l’input arriva da un utente invece che dai tuoi fixture di test, questa diventa una vulnerabilità sfruttabile: ReDoS, regular expression denial of service. L’innesco tipico è esattamente questa forma, due quantificatori impilati senza niente in mezzo che forzi una divisione univoca.
Tre modi per fermarlo, dal più economico:
Elimina l’ambiguità.
(a+)+bea+btrovano corrispondenza sulle stesse stringhe valide qui; il+esterno non ti dà nulla in più. La maggior parte dei pattern catastrofici ha uno strato ridondante come questo, una volta che lo cerchi.Rendi il quantificatore interno possessivo, oppure racchiudilo in un gruppo atomico. Entrambi dicono al motore “una volta che questo ha trovato corrispondenza, non fare backtracking al suo interno.” Questo elimina l’esplosione esponenziale, al costo di rifiutare occasionalmente una stringa che la versione con backtracking avrebbe accettato. Il
redi Python supporta entrambi dalla 3.11:1 2 3 4 5 6 7pattern = re.compile(r"(?>a+)+b") # gruppo atomico # oppure: re.compile(r"a++b") # quantificatore possessivo, Python 3.11+ for n in (28, 1_000, 100_000): s = "a" * n start = time.perf_counter() pattern.match(s) print(n, f"{(time.perf_counter() - start) * 1000:.3f} ms")1 2 328 0.002 ms 1000 0.001 ms 100000 0.026 msLa stessa corrispondenza fallita su 100.000 caratteri impiega 26 microsecondi. Il
RegExpnativo di JavaScript non ha nessuna delle due funzionalità. Il package npmregexaggiunge gruppi atomici e quantificatori possessivi come sintassi estesa se ti servono senza cambiare motore.Limita i quantificatori senza limite.
{1,50}invece di+limita quanto può peggiorare il caso peggiore, anche senza riscrivere la logica.
Se la stringa può arrivare da fuori il tuo codice, come il corpo di una richiesta o un valore di configurazione scritto da qualcun altro, tratta i quantificatori annidati come tratteresti un loop senza limite su input esterno. Testa il pattern contro una sequenza lunga del carattere che scatena l’ambiguità, non solo contro i tuoi fixture del percorso felice.
Quando Non Usare il Regex
Un regex non ha memoria di quanto è profondo. Va bene per testo piatto e a forma fissa come una data o una riga di log con campi noti. È anche per questo che <.+> non riesce a saltare in modo affidabile un singolo tag HTML: non ha il concetto di “questo < apre un tag che il prossimo > corrispondente chiude”, solo “prendi fino all’ultimo > che trovi”. Strutture annidate come HTML, JSON, codice sorgente e parentesi bilanciate hanno bisogno di una grammatica, non di un pattern. Il fallimento tipico è un regex che passa ogni caso di test che hai scritto, poi incontra un file reale con un commento che contiene un > e trova silenziosamente la porzione sbagliata invece di generare un errore.
Usa il parser che già esiste: un parser HTML per l’HTML, json.loads per il JSON, il modulo AST del tuo linguaggio per il codice. Tieni il regex per validare o estrarre testo a forma piatta, e passa a un parser nel momento in cui “bilanciato” o “annidato” compare nella descrizione di quello che stai matchando.