Regexes: Veelgemaakte fouten
Inhoud
Veel mensen gebruiken reguliere expressies van allerlei soorten validaties, voor het vervangen van bepaalde teksten enzovoorts.
Een groot mankement is helaas dat veel mensen onvoldoende kennis hebben van regexes, of van de betreffende taal an sich en dus vreemde dingen doen.
Gebruik ze waar ze nodig zijn
Regular expressions zijn bijzonder krachtig en flexibel maar dat betekent niet dat ze altijd de beste keus zijn.
Voorbeelden van overbodig regexgebruik:
Controleren of een string alleen uit cijfers, letters e.d. bestaat
Regular expressions kennen notaties als \d wat staat voor een cijfer en samen met de begin van string en eind van string elementen kun je daarmee afdwingen dat de hele string uit cijfers moet bestaan.
| 1 | if (preg_match('{^\d+$}', $var)) {/* ja */} else {/* nee */} |
Maar hiervoor heeft PHP z'n eigen functies en die zijn veel sneller dan een regexp: http://www.php.net/ctype en die zijn veel sneller en eenvoudiger in gebruik.
Bijvoorbeeld:
| 1 | if (ctype_digit((string)$var)) {/* ja */} else {/* nee */} |
Vreemde constructies
regular expressions vereisen een andere denkwijze dan wat je in normale programmeertalen gewend bent. Dat betekent dat je al snel uitkomt op oplossingen die logisch zijn, maar niet efficiënt.
Het matchen op `.*`
De punt staat in regular expressions voor elk willekeurig teken en wordt daardoor logischerwijs al snel gebruikt om bijvoorbeeld alles tussen twee tekens in te selecteren:
Zie bijvoorbeeld:
| 1 | $text = preg_replace('{\[b](.*?)\[/b]}si', '<strong>$1</strong>', $text); |
Dit matcht op "elk willekeurig teken tussen [b] en [/b]". Dit is hoogst inefficiënt, vooral als de [/b] niet voorkomt in de tekst.
Je moet altijd bedenken dat je wilt matchen wat je nodig hebt. In dit geval wil je niet "alles van [b] tot [/b]", maar "allesbehalve [/b] van [b] tot [/b]". Als je er even over nadenkt, kun je heel makkelijk uitkomen op een veel efficiëntere en nette notatie:
| 1 | $text = preg_replace('{\[b]((?:[^[]+|\[(?!/b])[^[]*)*+)\[/b]}i', '<strong>$1</strong>', $text); |
Dit ziet er natuurlijk vrij complex uit als je er niet vaak mee werkt, maar het is heel logisch:
- \[b]
- "[b]"
- (
- Begin van een capturing group
- (?
- Begin van een non-capturing group
- [^[]+
- Alles behalve een [, een of meerdere keren
- |\[(?!/b])[^[]+
- of een [, niet gevolgd door /b] en dan weer alles behalve een [, een of meerdere keren
- )
- Einde van de non-capturing group
- *+
- De non-capturing group mag 0 of meerdere keren voorkomen.
- De + betekent dat de groep "possessive" wordt gematcht. Dit betekent dat deze voor backtracking zijn hele resultaat direct opgeeft in plaats van teken voor teken terug te gaan. Hier behaal je in sommige gevallen grote winst op. Let altijd op dat je deze niet zomaar plaatst, altijd testen met een tool als de RegexBuddy of het werkt zoals je wilt.
- )
- Einde van de capturing group
- \[/b]
- "[/b]"
Het lijkt allemaal veel complexer, maar het gaat uiteindelijk om het idee dat je alleen matcht wat je wilt. Als je dus een HTML img-tag wilt matchen, doe je dat niet zo:
| 1 | preg_match('{<img src="(.*)">}', $input, $match); |
Maar zo:
| 1 | preg_match('{<img src=(?|"([^"]*)"|\'([^\']*)\')>}', $input, $match); |
N.b.: de (?|...) constructie zorgt dat capturing groups in iedere "or" conditie bij hetzelfde getal beginnen. De beide capturing groups komen dus in subpatroon 1 terecht, niet in 1 en 2.
Catastrophic backtracking
Het grootste gevaar dat in de `.` schuilt is dat als zo'n regex geen match kan maken deze bijzonder lang erover kan doen om te falen. Dit komt simpelweg doordat de `.` alles matcht en dus ook bijna alle permutaties die maar mogelijk zijn afgewerkt moeten worden.
Een oplossing hiervoor is hetzelfde als wat ik al noemde: een possessive quantifier (++, *+, ?+) zorgt ervoor dat binnen een patroon nooit andere mogelijkheden bekeken worden. Natuurlijk is dit lange niet altijd een oplossing maar meer een omweg, maar zeg nou zelf: wie wil alle mogelijke tekens die tussen twee bbcode-tags mogen komen allemaal letterlijk opschrijven?
Meer capturen dan nodig
| 1 | $string = preg_replace('{(bruin)}i', '<strong>$1</strong>', $string); |
Die capturing group is niet nodig, aangezien er alleen "bruin" wordt gematcht en dit al in $0 terecht komt:
| 1 | $string = preg_replace('{bruin}i', '<strong>$0</strong>', $string); |
Alle soorten letters herkennen
| 1 | preg_match('{[a-zA-ZâãäåāăąÁÂÃÄÅĀĂĄèééêëēĕėęěĒĔĖĘĚìíîïìĩīĭÌÍÎÏÌĨĪĬóôõöōŏőÒÓÔÕÖŌŎŐùúûüũūŭůÙÚÛÜŨŪŬŮ]}u', $string); |
Dit soort constructies zijn erg loos (let wel op de u-modifier die de regex als UTF-8 aanmerkt), vooral omdat dit standaard al kan:
| 1 | preg_match('{\pL}', $string); |
Nadeel hieraan is dat intern de complete codepoint-tabel moet worden doorgekeken, maar dat is alsnog sneller dan het patroon inlezen als UTF-8 en de input ook.
Onnodig veel modifiers
Op PHP.net vind je een lijst van alle mogelijke modifiers. We gaan ze allemaal even langs om het nut en mogelijke problemen te bespreken:
- i
- Deze zorgt dat de regex case-insensitive wordt uitgevoerd. Oftewel, als je `'{b}i'` uitvoert, worden zowel `b` als `B` gematcht. Dit is dus standaard niet zo!
- m
- Als deze gebruikt wordt matchen `^` en `$` ook aan respectievelijk het begin en einde van iedere regel. Erg handig als je bijvoorbeeld op iedere regel het eerste en laatste teken wilt weghalen:
1
2<?php
$output = preg_replace('{^.(.*).$}m', '$1', $input); - s
- Standaard matcht de `.` in een regex alles behalve newlines. Met deze optie aan matcht deze ook newlines. Vaak is de `.` al niet nodig, en in die gevallen dat je het zeker weet nodig te hebben, moet je er op letten deze alleen te gebruiken als je echt newlines wilt kunnen matchen.
- x
- Met deze optie aan kun je een regex heel vrij noteren. Whitespace tussen veel operators wordt genegeerd en vanaf een # tot einde-regel heb je comments.
- e
- Hiermee wordt je replacement als PHP uitgevoerd (eval). Dit wil je nooit, met preg_replace_callback kun je precies hetzelfde bereiken.
- A
- Deze optie zorgt dat je patroon alleen aan het begin van een string matcht. Nooit gebruiken, dit is makkelijk in de regex zelf aan te geven.
- D
- Hiermee matcht de `$` alleen aan het echte einde van de string. Normaal matcht deze ook voor de laatste newline indien dat het laatste teken in de string is.
- S
- Handig bij complexe patronen, waar PCRE bepaalde optimalisaties kan uitvoeren. Volgens de documentatie momenteel alleen bruikbaar bij patronen die niet verankerd zijn (^ en $), en die niet met maar 1 mogelijk teken beginnen.
- U
- Deze draait het gedrag van de lazy-modifier (bijvoorbeeld *?) om. Normaal is een * greedy, oftewel matcht zoveel mogelijk, en met de ? erbij zo weinig mogelijk. Hiermee is dat dus andersom. Dit is onhandig omdat dit gewoon niet standaard gedrag is. Niet gebruiken dus, het maakt alles alleen maar onoverzichtelijker.
- X
- Nutteloze modifier. Deze verbiedt alle "escape sequences" die niet bestaan voor forward compatibility.
- J
- Deze staat het meerdere malen gebruiken van namen voor subpatronen mogelijk (?P<naam>). Dit is handig als je deze gebruikt en bijvoorbeeld twee takken in een groep hebt die je dezelfde naam wil laten hebben.
- u
- Als je deze gebruikt wordt je patroon geïnterpreteerd als UTF-8. Heel handig als je deze wilt gebruiken in character classes.
Dat waren ze. Sommige zijn totaal nutteloos, sommige niet. Maar de grootste fout die mensen maken is modifiers gebruiken die niet nodig zijn. `'{\d+[a-z]}s'` is een voorbeeld hiervan. Het verbaast me hoeveel mensen dit soort dingen doen.
Teveel escapen
Waarom zou je een teken escapen als het niet nodig is? Het maakt een expressie alleen maar heel lastig te lezen omdat er zoveel backslashes in staan.
In principe hoef je alleen zogenaamde meta-tekens te escapen. Dit zijn de tekens die in het betreffende deel een speciaal effect hebben.
- [
- Buiten een character class. Daarbinnen hoeft het niet.
- ]
- Binnen een character class, als het niet het eerste teken of het eerste teken na de ^ is.
- ^
- Buiten een character class, daar betekent het begin van string of begin van regel in multiline mode.
- Binnen een character class als deze het eerste teken is. Maar dat is meestal niet zo, en anders kun je deze naar achteren verplaatsen.
Vervangen met het resultaat van een functie
Ondanks dat veel mensen denken dat iets als het volgende werkt:
| 1 | $output = preg_replace('{</?[hH]([1-6])>}', replaceHeading('$1'), $input); |
Is dit niet het geval. De functie replaceHeading wordt al uitgevoerd voordat er daadwerkelijk iets vervangen wordt. Er is maar een goede manier: preg_replace_callback. Vanaf PHP 5.3 kun je hier ook anonieme functies voor gebruiken wat het geheel een stuk makkelijker maakt:
| 1 2 3 | $output = preg_replace('{</?[hH][1-6]>}', function (array $match) {
return 'Hier stond een heading ' . $match[1];
}, $input); |
@todo:
- aanvullen met meer problemen, er zijn er genoeg
- lijst van metatekens aanvullen
- uitleg verhelderen

