arbre

Les expressions régulières PCRE (Perl Compatible Regular Expressions)

Remarques préliminaires

Ce tutoriel est destiné aux développeurs qui maîtrisent déjà, un tant soit peu, la syntaxe de base des regex et des fonctions PHP qui les utilisent. Il mettra principalement l'accent sur différentes méthodes d'optimisation. Pour une présentation détaillée de la syntaxe élémentaire, il existe de nombreux tutoriaux sur le net notamment regular-expressions.info ou expreg.com.

Les remarques qui vont suivre sur l'implémentation des PCRE par PHP ainsi que les conseils d'optimisation des motifs valent pour tous les langages qui utilisent les regex PCRE (Perl, bien sûr, mais aussi Python, Java, .NET etc...), avec des petites variations mineures. Tous ces langages partagent un même moteur, de type NFA (Nondeterministic Finite Automation). MySQL, awk et egrep, par contre, utilisent un autre type de moteur (DFA - Deterministic Finite Automation) plus simple, moins puissant mais plus rapide pour les cas (très) simples, voir simplistes ("Talking about DFA matching is very boring!" Jeffrey Friedl).

Les bibliothèques d'expressions régulières de PHP

Le support des expressions régulières dans PHP est assuré par deux bibliothèques: POSIX et PCRE.

A première vue, les fonctions PCRE (preg_match...) ne diffèrent pas beaucoup des fonctions POSIX (ereg....). Le prototype des fonctions ainsi que la syntaxe des motifs se ressemblent fort. Mais c'est à première vue seulement!

Les fonctions POSIX telles que ereg ou ereg_replace sont obsolètes à partir de la version PHP 5.3.0 et leur utilisation dans vos scripts produira une erreur.

Il ne sera, dès lors, question dans ce tutoriel que d'optimisation des fonctions PCRE.

Convention typographique

Afin de distinguer les concordances (match) des captures, le code couleur suivant sera utilisé dans le texte:

Le texte en concordance avec le motif sera surligné et les captures sont encadrées de lignes rouges. Le texte non concordant restera non surligné.

La gourmandise est un vilain défaut

Certes, mais pas dans le cas des expressions rationnelles, ce comportement est même recherché dans certains cas. Les quantificateurs +, * ou {1,} et {0,} sont gourmands par défaut dans les PCRE.

Exemple: si on cherche à capturer l'url contenue dans l'ancre suivante

<a href="http://ceci-est-une-url.com">lien</a>

Dans ce motif preg_match('#<a href=(.*)>#', $txt, $out) la fonction capturera tout jusqu'au dernier > rencontré. Voyez plutôt:

<a href="http://ceci-est-une-url.com">lien</a>

Par contre, dans les PCRE on peut demander au dot de s'arrêter au premier > rencontré grâce au point d'interrogation.

Ainsi, la regex suivante preg_match('#<a href=(.*?)>#', $txt, $out) , retournera ceci:

<a href="http://ceci-est-une-url.com">lien</a>

Démo

Oui, oui, je sais qu'ici on aurait pu utiliser utilement une classe négative de caractères telle que '[^>]*' pour obtenir le même le résultat plus rapidement encore (voir discussion plus bas), mais c'est juste pour l'exemple!

retour menu principal

Les références arrières (back reference)

Dans un motif PCRE, on peut insérer une référence arrière. C'est à dire qu'on peut capturer quelque-chose dans un texte et l'utiliser plus loin dans le motif comme référence. Une utilisation appropriée des références arrières permet de construire des motifs intelligents.

Exemple, dans la chaîne :

<i>Texte italique</i> texte normal <b> et texte gras</b> fin du texte.

Imaginons que nous souhaitions capturer tout ce qui est contenu dans les balises <b>ou <i>,
La regex suivante est une des solutions possibles preg_match_all('#<([ib])>(.*?)</\1>#', $txt, $out)

La première parenthèse capturante mémorisera le i ou le b des balises ouvrantes <b>ou <i> et l'utilisera plus loin dans les balises fermantes </b>ou </i> grâce au motif </\1> où \1, la référence arrière, sera égal à b ou i selon ce qui aura été capturé plus avant. Résultat:

<i>Texte italique</i> texte normal <b> et texte gras</b> fin du texte.

Et le tableau de sortie $out contiendra les éléments suivants:

Démo

Chaque parenthèse capturante rencontrée stockera la capture dans une référence arrière numérotée en fonction de la position de la parenthèse dans le motif. Ainsi avec le motif #((A)BCD)# on retrouvera ABCD dans la référence arrière \1 et A dans \2 et ainsi de suite. Il peut y avoir jusqu'à 99 références arrières. Le jour où vous vous retrouvez avec 99 groupes de parenthèses capturantes, vous avez du souci à vous faire pour l'optimisation de votre motif!

Dans certains cas, il peut s'avérer intéressant de ne pas incrémenter les références arrières pour en faciliter le traitement. Dans l'exemple qui suit, vous souhaitez capturer tous les jours de la semaine dans ce texte:

Le premier jour de la semaine est le lundi suivi par mardi, le mercredi puis le jeudi qui précède le vendredi.

Dans le but d'éviter une énumération de tous les jours de la semaine dans votre motif, vous pourriez écrire: #((lun|mar|jeu)|(mer)cre|(ven)dre)di\b# ce motif aura 4 groupes de captures puisqu'il y a 4 groupes de parenthèses avec un niveau d'imbrication. Voyez:

Démo

La version Perl 5.10 a introduit la possiblilité de ne pas incrémenter la numérotation des captures dans une série d'alternatives (Duplicate Subpattern Numbers). Il est dès lors possible de donner le même numéro de capture aux parenthèse capturantes. A cette fin, le motif ci dessus s'écrira: #(?|(lun|mar|jeu)|(mer)cre|(ven)dre)di\b#. Remarquez le ?| de la première parenthèse. Ce motif produira un seul groupe de capture:

Il est maintenant très facile d'utiliser cette unique référence arrière pour faire un remplacement par exemple. Voyez dans le testeur ce que donne par exemple:

preg_replace('#(?|(lun|mar|jeu)|(mer)cre|(ven)dre)di\b#''\1'$texte)

retour menu principal

Les parenthèses non capturantes

Les parenthèses sont principalement utilisées pour capturer une série de caractères correspondant à un motif. Dans certains cas, elles seront utilisées pour délimiter les alternatives ou bien une chaîne alternative. Ainsi dans le texte suivant:

Astuce : PHP supporte aussi des expressions rationnelles compatibles Perl, avec l'extension PCRE functions. Ces fonctions supportent des recherches non-gourmandes, des assertions, des sous-masques conditionnels et toute une gamme de fonctionnalités absentes des expressions rationnelles POSIX.

Admettons qu'on cherche à capturer tous les mots précédés de 'des ou 'de', on pourrait faire preg_match_all('#\b(le|les|la|de|des|du)\s(\w+)\b#', $txt, $out) . Ce qui donne:

Astuce : PHP supporte aussi des expressions rationnelles compatibles Perl, avec l'extension PCRE functions. Ces fonctions supportent des recherches non-gourmandes, des assertions, des sous-masques conditionnels et toute une gamme de fonctionnalités absentes des expressions rationnelles POSIX.

Et le tableau de résultat $out retournera

On voit que les parenthèses du groupe de l'alternative ont effectivement capturé tous les déterminants (de, des etc...). Mais seul le deuxième groupe capturant, celui de $out[2], nous intéresse. Par souci de clarté, mais surtout d'efficacité, on peut rendre les premières parenthèses non capturantes en rajoutant simplement ?: après la parenthèse ouvrante: preg_match_all('#\b(?:le|les|la|de|des|du)\s(\w+)\b#', $txt, $out) .

Astuce : PHP supporte aussi des expressions rationnelles compatibles Perl, avec l'extension PCRE functions. Ces fonctions supportent des recherches non-gourmandes, des assertions, des sous-masques conditionnels et toute une gamme de fonctionnalités absentes des expressions rationnelles POSIX.

Le tableau de résultat $out retournera cette fois ceci:

"So what", vous allez dire! Eh bien, le tableau de sortie n'est pas encombré d'éléments inutiles et il sera ainsi plus facile à exploiter, de plus, les indices des références arrières (backreference) seront plus facile à gérer et, enfin, la regex consommera moins de ressources et sera à peu près 10% plus véloce! Une nano-seconde de gagnée par-ci, une micro seconde par-là, mulipliées par le nombre d'accès à votre script finiront bien par faire des secondes.

La même remarque vaut pour une chaîne alternative du genre #PHP est un (excellent)? langage de programmation# où le mot excellent est facultatif. Si vous ne devez pas capturer ce mot, il vaut mieux écrire #PHP est un (?:excellent)? langage de programmation#.

Démo

retour menu principal

Les fonctions du type "callback"

Nous abordons ici une des fonctionnalités les plus utiles des fonctions PCRE. Utiles car elles permettent d'utiliser, au sein d'une expression régulière, n'importe quelle fonction php ou perso.

Il y en a deux: preg_replace() avec l'option e ou bien preg_replace_callback()

Callback veut simplement dire qu'on demande simplement à PHP d'exécuter du code PHP ou une fonction au moment du remplacement. Imaginez, par exemple, que vous souhaitiez mettre un mot sur deux en majuscule avec la fonction php strtoupper() et le troisième mot à l'envers avec la fonction strrev() dans le texte suivant:

php supporte aussi des expressions rationnelles compatibles Perl, avec l'extension PCRE functions. Ces fonctions supportent des recherches non-gourmandes, des assertions, des sous-masques conditionnels et toute une gamme de fonctionnalités absentes des expressions POSIX

Le code php suivant:

echo preg_replace('#(\w+)([^\w]*)(\w+)#e','strtoupper("\1")."\2".strrev("\3")' , $txt);

retournera le texte ceci:

PHP etroppus AUSSI sed EXPRESSIONS sellennoitar COMPATIBLES lreP, AVEC l'EXTENSION ERCP FUNCTIONS. seC FONCTIONS tnetroppus DES sehcrehcer NON-sednamruog, DES snoitressa, DES suos-MASQUES slennoitidnoc ET etuot UNE emmag DE sétilannoitcnof ABSENTES sed EXPRESSIONS xisop

Vous percevez certainement la puissance de l'option e. Mais, dans le motif de remplacement, il ne faut pas se tromper dans les simples et doubles quotes sinon...

J'avoue que je dois chaque fois réfléchir sur ce coup là. C'est ici que la fonction preg_replace_callback() montre un de ses deux avantages. On obtient exactement le même résultat avec ceci:

function maFonctionTordue($capture){
   
$txt = strtoupper($capture[1]).$capture[2].strrev($capture[3]);
   return
$txt;
}

echo
preg_replace_callback('#(\w+)([^\w]*)(\w+)#', "maFonctionTordue", $txt);

C'est plus clair il me semble. Plus de valses de guillemets! Ici, la fonction callback "invoque" une fonction perso et lui passe implicitement les captures, en argument, sous forme de tableau. Ce tableau est récupéré dans la fonction en question et les différentes captures sont alors simplement accessibles par leur indice.

L'autre avantage de preg_replace_callback() est qu'elle est souvent plus rapide à l'exécution. Dans cet exemple, environ 3 fois plus rapide.

Son seul désavantage à mes yeux est qu'on ne peut passer d'autres arguments que les captures. Si, pour les besoins de l'application, il est nécessaire de passer d'autres arguments à la fonction perso, on peut soit déclarer ces arguments en GLOBAL au niveau de la fonction callback, soit il faut en revenir à preg_replace() avec l'option e mais cette fois de manière, disons adaptée...

Voici une illustration du premier cas. Les deux arguments passés à la fonction appelée sont de simples balises <b> et </b>

function maFonctionTordue2($capture){
  global
$autreArgument1, $autreArgument2;
  
$txt = strtoupper($capture[1]).$capture[2].$autreArgument1.strrev($capture[3]).$autreArgument2;
  return
$txt;
}

$autreArgument1 = '<b>';
$autreArgument2 = '</b>';
echo
preg_replace_callback('#(\w+)([^\w]*)(\w+)#', "maFonctionTordue2", $txt);  

Si, pour des raisons de cohésion de portée de variable, on répugne à utiliser GLOBAL, il faut en revenir à preg_replace() avec l'option e mais, cette fois, adaptée pour que la manipulation des captures soit simplifiée:

function maFonctionTordue2($capture1, $capture2, $capture3, $autreArgument1, $autreArgument2){
   
$txt = strtoupper($capture1).$capture2.$autreArgument1.strrev($capture3).$autreArgument2;
   return
$txt;
}

$autreArgument1 = '<b>';
$autreArgument2 = '</b>';
echo
preg_replace('#(\w+)([^\w]*)(\w+)#e','maFonctionTordue2("\1","\2","\3", "$autreArgument1", "$autreArgument2")' , $txt);

Les captures sont passées comme de simples arguments à la fonction perso afin d'y être manipulées plus simplement. Dans cette manière hybride, on gagne en clarté dans la manipulation des captures tout en ayant la possibilité de passer des arguments supplémentaires à la fonction invoquée.

Dans les deux cas, ce code retournera:

PHP etroppus AUSSI sed EXPRESSIONS sellennoitar COMPATIBLES lreP, AVEC l'EXTENSION ERCP FUNCTIONS. seC FONCTIONS tnetroppus DES sehcrehcer NON-sednamruog, DES snoitressa, DES suos-MASQUES slennoitidnoc ET etuot UNE emmag DE sétilannoitcnof ABSENTES sed EXPRESSIONS xisop

Astuce: on peut pour également passer des tableaux en argument comme ceci:

function maFonctionTordue2($capture1, $capture2, $capture3, $autreArgument){
   
$txt = strtoupper($capture1).$capture2.$autreArgument[0].strrev($capture3).$autreArgument[1];
   return
$txt;
}

$autreArgument = array('<b>','</b>');
echo
preg_replace('#(\w+)([^\w]*)(\w+)#e','maFonctionTordue2("\1","\2","\3", $autreArgument)' , $txt);

retour menu principal

Les classes de caractères et types génériques

Comme pour les POSIX, il est possible de définir des classes de caractères. La notion de classe de caractères est bien connue. Quelques rappels:

ClasseDescription
[a-p]défini tout alphabétique compris entre a et p.
[^a-p]tout caractère qui n'est pas compris dans la fourchette a-p
[a-p0-6]toutes les lettres de a à p et chiffres de 0 à 6

Les méta classes POSIX [:classe:] sont acceptées au sein de classes PCRE. Mais PCRE ajoute quelques classes supplémentaires appelées types génériques.

Types génériques PCRE
\dtout caractère décimal
\Dtout caractère qui n'est pas un caractère décimal
\stout caractère blanc
\Stout caractère qui n'est pas un caractère blanc
\wtout caractère de "mot" [A-Za-z0-9_] plus les accentués
\Wtout caractère qui n'est pas un caractère de "mot"

Les méta POSIX comme [:alpha:] ne sont acceptées que si elles sont intégrées dans une classe de caractères comme, par exemple, dans preg_match('#[0-5[:alpha:]]#', $txt, $out) .

Située en dehors d'une classe, le moteur regex renverra l'erreur Compilation failed: POSIX named classes are supported only within a class.

On pourrait remplacer cette classe POSIX par '#[0-5a-z]#' . Mais il y a une différence: selon le setlocale(), la classe méta [:alpha:] comprendra les accentués alors que [a-z] ne les comprend pas. On pourrait mettre le type générique \w (qui comprend les accentués) comme dans: '#[0-5\w]#' mais alors tous les numériques seront également compris. Et le filtrage 0-5 ne marchera pas. A méditer.

Un petit rappel des méta classes POSIX s'impose donc

PosixDescriptionPcreCommentaire
[:digit:]décimal\dpas de différence
[:alnum:]tout caractère alphanumérique\widem \w sauf _
[:word:]tout caractère de mot\widentique à [:alnum:] plus _
[:alpha:]tout caractère alphabétique[a-zA-Z] plus accentués
[:blank:]espace et TAB\s\s comprend également le retour ligne
[:punct:]tout caractère imprimable sauf lettres et chiffres\Widem [:punct:] plus l'espace
[:print:]tout caractère imprimable (octal supérieur à 037)[^\000-\037]pas de différence
[:space:]espace, H.tab, V.tab, retour ligne\s\s ne comprend pas le vertical tab
[:upper:]tout alphabétique majuscule[A-Z]
[:lower:]tout alphabétique minuscule[a-z][a-z] ne comprend pas les accentués]

Remarque importante: on a vu plus haut que les classes POSIX [:alpha:], [:alnum:], [:word:] ou PCRE \w comprennent les accentués, mais seulement si le bon jeu de caractères est installé sur le serveur, sinon, un  setlocale(LC_CTYPE, 'fr_FR.ISO-8859-1' devrait résoudre le problème. Vérifiez sur votre serveur quelles sont les locales installées, elle peuvent prendre des formes différentes selon votre système: 'fr_FR', 'fra_fra' ...

Sur un système Linux utilisez la commande $ locale -a pour les afficher. En hébergement mutualisé, renseignez-vous auprès de votre FAI.

Pour les mordus des specs, voir le détail des différences dans les pages man de la librairie C du moteur PCRE (mise à jour par l'Université de Cambridge). Attention que l'implémentation de cette librairie dans PHP présente parfois de très légères différences.

retour menu principal

Les assertions PCRE

La bibliothèque PCRE permet l'utilisation d'assertions simples qui présentent la particularité de ne pas consommer de caractères. Un peu comme les ancrages ^ et $.

Assertions simples
\blimite de mot
\Bpas limite de mot
\Adébut de la chaîne sujet (indépendant du mode multi-lignes)
\Zfin de la chaîne sujet ou nouvelle ligne à la fin de la chaîne sujet (indépendant du mode multi-lignes)
\zfin de la chaîne sujet (indépendant du mode multi-lignes)

Pour bien comprendre la particularité de non-consommation de caractères de ce type d'assertion, voici un exemple détaillé. Imaginons le texte:

Voici simplement un simple exemple, très simple, mais pas simplet sur les assertions PCRE pourtant pas si simples!

Je souhaite en extraire tous les mots simple ou simples. Pour éviter d'extraire le 'simple' de simplet ou de simplement, je décide d'encadrer ce mot recherché avec des espaces comme par exemple dans ce motif: preg_match_all('# simples? #', $txt, $out). La concordance (match), contenue dans le tableau de résultat $out[0] ne renverra qu'un seul 'simple', le seul qui ne soit pas suivi de ponctuation:

Voici simplement un simple exemple, très simple, mais pas simplet sur les assertions PCRE pourtant pas si simples!

Raté! Bon, on va alors rajouter la ponctuation dans une classe de caractères preg_match_all('# simples?[ ,!.;:]#', $txt, $out) pour trouver une concordance sur les simple ou simples suivis d'une ponctuation éventuelle. Essayons:

Voici simplement un simple exemple, très simple, mais pas simplet sur les assertions PCRE pourtant pas si simples!

Voilà qui est mieux. Mais les concordances (matches) sont encadrées soit par des espaces, soit par de la ponctuation. Pas de problème, on va capturer ce qui nous intéresse avec des parenthèses: preg_match_all('# (simples?)[ ,!.;:]#', $txt, $out). Voici ce que ça donne:

Voici simplement un simple exemple, très simple, mais pas simplet sur les assertions PCRE pourtant pas si simples!

On y est presque. Mais est-ce vraiment nécessaire de consommer de la mémoire avec des parenthèses capturantes (voir la discussion sur les parenthèses non capturantes)? C'est ici que l'utilisation de l'assertion simple \b va nous être utile

Essayons ceci: preg_match_all('#\bsimples?\b#', $txt, $out)

Voici simplement un simple exemple, très simple, mais pas simplet sur les assertions PCRE pourtant pas si simples!

Et voilà le travail. Cette dernière version est plus rapide que la version capturante. Et dont le tableau de résultat $out est plus facile à utiliser puisqu'il comporte deux fois moins d'éléments. Pensez aux assertions simples comme des indicateurs de position dans la chaîne cible. Cet indice de position ne pointera pas sur un caractère mais bien entre deux caractères.

Voir ici une démo de tout ceci. Essayez aussi l'option "timing" dans le simulateur.

retour menu principal

Les options

Il est possible de modifier le comportement par défaut d'une fonction PCRE grâce aux options. Celles-ci se placent derrière le délimiteur de fin de motif. Voici les options les plus utiles:

retour menu principal

i - Insensible à la casse

C'est l'option la plus facile à comprendre...et à expliquer.

preg_replace('#les?#i', '***', $txt) remplacera toutes les occurrences de 'le', 'les', 'LE' ou 'LES' par '***'. Tout simplement.

Voici l'occasion de présenter une particularité de syntaxe souvent ignorée. Il est possible de d'activer ou de désactiver, à la demande, une option sur une partie de regex. Un exemple vaut mieux qu'un long discours:

Dans la regex : preg_match_all('#concor(?-i)dance#i', $txt, $out), l'option i est activée pour l'ensemble du motif de manière classique derrière le délimiteur mais désactivée ponctuellement pour une partie du motif, grâce à l'utilisation de «l'interrupteur» d'option (?-i). Voici le résultat:

Cette regex doit matcher concordance et CONCORdance, mais pas concorDANCE ou CONCORDAncE

Inversement, on peut bien sûr activer et désactiver une option à la demande. Le motif '#(?i)concor(?-i)dance#' aura exactement le même effet que le précédent: l'insensibilité à la casse est activée pour la première parie du masque et désactivé pour la deuxième.

Démo

retour menu des options

s - Le dot comprend le retour ligne

Le dot remplace n'importe quel caractère. Même s'il est souvent préférable d'utiliser des classes de caractères négatives (voir plus loin), l'utilisation du dot est fort répandue. Mais seulement, le dot ne prendra pas le retour ligne. Et un motif utilisant le dot risque de ne pas retourner le résultat escompté.

Petit exemple pour capturer l'url de ceci: <a href="http://url.com">lien</a>

La regex la plus utilisée pourrait ressembler à ceci: preg_match('#<a href="(.*?)">#', $txt, $out) Le résultat est conforme aux attentes:

Petit exemple pour capturer l'url de ceci: <a href="http://url.com">lien</a>

Mais imaginons qu'il y ait un retour ligne \r\n (windows) ou \n (linux) dans le texte.

Petit exemple pour capturer l'url de ceci: <a href="http://
url.com">lien</a>

Le parser html d'un navigateur n'y verra que du feu, mais pas la regex! Le dot s'arrêtera au retour ligne et n'ira pas plus loin. Elle ne trouvera donc ni le ", ni le > pourtant nécessaires pour qu'il y aie concordance! Elle ne trouvera aucune occurrence du motif et le tableau de résultat $out restera désespérément vide!

Solution: rajouter l'option s derrière le délimiteur preg_match('#<a href="(.*?)">#s', $txt, $out) et l'affaire est dans le sac... et l'occurrence dans le tableau $out[0]!

Mais, il y a un moyen plus simple et plus rapide de contourner le problème du dot et du retour ligne. Simplement en remplaçant le (.*?) par ([^"]*), une classe de négation de caractères qui prendra tout caractère (même le retour ligne) à l'exception du ".

Démo

retour menu des options

m - Multiligne

L'utilisation des ancrages ^ (début de chaîne) et $ (fin de chaîne) accélèrent considérablement le traitement d'une regex (voir partie astuce) mais quand on a une chaîne qui comprend des retours lignes (comme dans un fichier plat de données) l'ancrage de début ^ se trouvera au début de la première ligne de ce fichier et l'ancrage de fin $ après la toute dernière lettre de la dernière ligne. Imaginons que je recherche l'adresse (rue et numéro) de tous les "HADDOCK" du fichier plat suivant (j'y ai rajouté les ancrages afin d'illustrer mon propos):

^000028;Mr;Chevalier;HADDOCK;rue de Rackam Le Rouge;201;
000020;Mr;Marc;DUPONT;Avenue Roosens;253;
000021;Mme;Lucienne;MARTINO;rue Joseph Berger;25;
000022;Mr;Aucoin;DUBOIS;avenue des Genêts;546;
000024;Mme;Marie;DUPOND;rue de Dinant;58;
000025;Mr;Reporter;TINTIN;clos des Sorbiers;111;
000026;Mr;Lechien;MILOU;rue du Chenil;215;
000027;Mr;Typhon;TOURNESOL;boulevard Transgénique;484;
000028;Mr;Archibald;HADDOCK;rue Whiskey;201;
000029;Mr;Marc;DUCHEMIN;clos de la Route;9;
000030;Mr;Philippe;DELAROUTE;chemin d'en Haut;254;$

Je pourrais utiliser le motif '#^\d+;[^;]+;[^;]+;HADDOCK;([^;]+);([^;]+);.*#'. L'ancrage ^ me permet d'écrire ce motif de manière simple, en décomposant tous les champs d'une ligne de fichier, séparés par un point-virgule. En voici le résultat:

000028;Mr;Chevalier;HADDOCK;rue de Rackam Le Rouge;201;
000020;Mr;Marc;DUPONT;Avenue Roosens;253;
000021;Mme;Lucienne;MARTINO;rue Joseph Berger;25;
000022;Mr;Aucoin;DUBOIS;avenue des Genêts;546;
000024;Mme;Marie;DUPOND;rue de Dinant;58;
000025;Mr;Reporter;TINTIN;clos des Sorbiers;111;
000026;Mr;Lechien;MILOU;rue du Chenil;215;
000027;Mr;Typhon;TOURNESOL;boulevard Transgénique;484;
000028;Mr;Archibald;HADDOCK;rue Whiskey;201;
000029;Mr;Marc;DUCHEMIN;clos de la Route;9;
000030;Mr;Philippe;DELAROUTE;chemin d'en Haut;254;

La première ligne a bien été trouvée, mais pas l'autre ligne, plus loin dans le fichier! Tonnerre de Brest!

C'est ici que l'option m intervient.

L'option m (PCRE MULTILINE) demande au moteur regex de considérer chaque retour ligne comme la fin de chaîne et le début de la suivante. Un peu comme si ce moteur voyait le fichier plat comme ceci:

^000028;Mr;Chevalier;HADDOCK;rue de Rackam Le Rouge;201;$
^000020;Mr;Marc;DUPONT;Avenue Roosens;253;$
^000021;Mme;Lucienne;MARTINO;rue Joseph Berger;25;$
^000022;Mr;Aucoin;DUBOIS;avenue des Genêts;546;$

De cette manière, avec l'option m, le motif '#^\d+;[^;]+;[^;]+;HADDOCK;([^;]+);([^;]+);.*#m' donnera:

000028;Mr;Chevalier;HADDOCK;rue de Rackam Le Rouge;201;
000020;Mr;Marc;DUPONT;Avenue Roosens;253;
000021;Mme;Lucienne;MARTINO;rue Joseph Berger;25;
000022;Mr;Aucoin;DUBOIS;avenue des Genêts;546;
000024;Mme;Marie;DUPOND;rue de Dinant;58;
000025;Mr;Reporter;TINTIN;clos des Sorbiers;111;
000026;Mr;Lechien;MILOU;rue du Chenil;215;
000027;Mr;Typhon;TOURNESOL;boulevard Transgénique;484;
000028;Mr;Archibald;HADDOCK;rue Whiskey;201;
000029;Mr;Marc;DUCHEMIN;clos de la Route;9;
000030;Mr;Philippe;DELAROUTE;chemin d'en Haut;254;

Et voilà, toutes les lignes contenant le mot 'HADDOCK' sont trouvées!

Ne vous privez pas d'utiliser les ancrages sur les fichiers plats (avec l'option m s'il y a plusieurs lignes). Ils accélèrent sensiblement le traitement. Le même motif sans l'ancrage ^ donne le résultat escompté mais est plus de 4 fois plus lent sur cet exemple! Alors imaginez sur un fichier de plusieurs milliers de lignes!

Démo avec highlight des captures
Démo avec timing comparatif

Note: On voit souvent cette option dans des motifs sans ancrage. C'est inutile et sans effet. C'est comme mettre l'option s derrière un motif qui ne comporte pas de dot. On ne met pas de parachute pour faire du pédalo!

retour menu des options

e - Evaluation de code PHP

Cette option a été abordée plus haut dans le cadre des fonctions du type "callback" . Elle n'est utilisée que pour la fonction preg_replace(), retour menu des options

U - Option non gourmande

On a vu plus haut comment rendre un ou plusieurs quantificateur non-gourmand dans une regex. L'option U permet de rendre tous les quantificateurs d'une regex non gourmands. Ces deux motifs sont parfaitement identiques :

Maintenant, imaginons que vous ayez une loooongue série de quantificateurs et que tous doivent être non-gourmands, sauf un, utilisez l'option globale U pour l'ensemble du motif, et les "interrupteurs" d'options décrits plus haut pour désactiver cette option sur une portion du motif.
retour menu des options

Option x

Le texte de la doc dit:

"Avec cette option, les caractères d'espacement sont ignorés, sauf lorsqu'ils sont échappés, ou à l'intérieur d'une classe de caractères, et tous les caractères entre # non échappés et en dehors d'une classe de caractères, et le prochain caractère de nouvelle ligne sont ignorés. C'est l'équivalent Perl de l'option /x : elle permet l'ajout de commentaires dans les masques compliqués."

Tout est dit. En gros, cette option introduite dans Perl 5 par Larry Wall, lui-même inspiré par une note de Jeffrey Friedl, permet d'écrire des motifs complexes de manière indentée et, éventuellement, commentée. Bref, ça permet d'écrire des motifs de manière plus lisible. Sachant que les regex sont plus faciles à écrire qu'à (re)lire, ce n'est pas un luxe!

Ainsi, ce motif :

$motif = '/^\d+;[^;]+;[^;]+;HADDOCK;([^;]+);([^;]+);.*/m';
preg_match_all($motif, $txt, $out);

peut s'écrire de manière plus lisible:

$motif = "
/         # délimiteur (ici je n' ai pas pris #, réservé pour les commentaires)
    ^         # ancrage de début de chaîne
    \d+;      # tout décimal 1 fois ou plus, suivi par ;
    [^;]+;    # tout caractère sauf ; 1 fois ou plus, suivi par ;
    [^;]+;    # idem
    HADDOCK;  # les caractères HADDOCK suivi par ;
    ([^;]+);  # capture tout caractère sauf ; 1 fois ou plus, suivi par ;
    ([^;]+);  # idem
    .*        # tout caractère sauf retour ligne 0 fois ou plus
/xm"
;
preg_match_all($motif, $txt, $out);

Attention! Les espaces du motif seront ignorés. Si vous devez y mettre un espace ou un # il FAUT les échapper sinon ils ne seront pas interprétés!

retour menu des options

Autres options

Ces options sont moins utilisées. Je reprends donc la doc in extenso:


retour menu des options

retour menu principal

Les assertions

On a vu plus haut les assertions simples simples (telle que \b) qui ne consomment pas de caractères. Vous vous rappelez qu'il convenait de les voir comme un pointeur qui se trouverait entre deux caractères et pas sur un caractère. Ils ne cherchent pas une concordance de caractère mais bien de position. Et bien, les assertions font exactement la même chose: elles se placent entre deux caractères et testent les caractères suivants (lookahead) ou précédents (lookbehind). Il y en a 4:

Types d'assertionMotifSuccès si le motif dans l'assertion...
Les assertions arrières positives (positive lookbehind)(?<=motif)...trouve une concordance à gauche
Les assertions arrières négatives (negative lookbehind)(?<!motif)...ne trouve pas de concordance à gauche
Les assertions avant positives (positive lookahead)(?=motif)...trouve une concordance à droite
Les assertions avant négatives (negative lookahead)(?!motif)...ne trouve pas de concordance à droite

Bon, c'est très bien, mais à quoi ça peut servir ces trucs là? Imaginons, une fois encore, que l'on veuille extraire du texte suivant le déterminant de, mais uniquement s'il est suivi du mot caractère

L'implémentation des assertions arrières déplace temporairement le pointeur de position vers l'arrière, et cherche à vérifier l'assertion. Si le nombre de caractères est différent, la position ne sera pas correcte, et l'assertion échouera. La combinaison d'assertions arrières avec des sous-masques peut être particulièrement pratique à fin des chaînes. Un exemple est donné à la fin de cette section.

Essayons ceci preg_match_all('#\bde\b(?=\scaractères)#', $txt, $out);. Ce motif placera son pointeur juste après le e du mot de (mais avant le caractère suivant, un espace) et vérifiera si ce qui suit est bien  caractère (avec un espace \s).

Résultat:

L'implémentation des assertions arrières déplace temporairement le pointeur de position vers l'arrière, et cherche à vérifier l'assertion. Si le nombre de caractères est différent, la position ne sera pas correcte, et l'assertion échouera. La combinaison d'assertions arrières avec des sous-masques peut être particulièrement pratique à fin des chaînes. Un exemple est donné à la fin de cette section.

On pourrait objecter qu'on aurait pu obtenir la même chose avec preg_match_all('#\b(de)\b\scaractères#', $txt, $out) où le résultat sera capturé dans $out[1][0]. Même résultat en effet mais, plus gourmand (plus lent qu'avec le lookahead!)

Démo

Mais imaginons maintenant que l'on cherche tous les de SAUF ceux qui sont suivis par le mot  caractères. Là on ne s'en tirera pas sans une assertion, négative cette fois.

La fonction suivante php preg_match_all('#\bde\b(?!\scaractères)#', $txt, $out) placera à nouveau son pointeur après le e du mot de et vérifiera, cette fois, si ce qui suit N'EST PAS la chaîne  caractère (avec un espace \s).

L'implémentation des assertions arrières déplace temporairement le pointeur de position vers l'arrière, et cherche à vérifier l'assertion. Si le nombre de caractères est différent, la position ne sera pas correcte, et l'assertion échouera. La combinaison d'assertions arrières avec des sous-masques peut être particulièrement pratique à fin des chaînes. Un exemple est donné à la fin de cette section.

Je suppose que vous avez compris l'intérêt des assertions. Les assertions arrières fonctionnent exactement de la même manière mais... dans l'autre direction.

Cherchons dans le même texte, par exemple, toutes les occurrences de assertion ou assertions sauf celles précédées de l'.

La fonction: preg_match_all("#(?<!l')assertions?#", $txt, $out) devrait marcher. Essayez vous-même dans le testeur pour en voir le résultat.

Démo

retour menu principal

Les masques conditionnels

On aborde ici une structure un peu plus complexe. Qui fait appel, entre-autres, aux assertions (avant ou arrières, positives ou négatives). De plus, la doc PHP en français est assez mal traduite.

Le prototype d'un sous-masque conditionnel (conditional sub-pattern) est le suivant

(?(condition) masque_si_vrai | masque_sinon).

Il s'agit en fait d'une structure de contrôle du type if-then-else. Lorsque nous avons deux masques possibles, l'évaluation de la condition déterminera l'utilisation de l'un ou de l'autre.

La condition est soit une assertion soit un décimal se référant à une référence arrière (capture).

Rien ne vaut un exemple. Nous avons une entête de courriel dans laquelle on souhaite capturer les différents blocs to/from/subject/:

To: destinataire@example.com (commentaire inutile à ne pas capturer)
From: moi@example.net (commentaire encore plus inutile)
Subject: Ces regex commencent sérieusement à me gonfler!

La regex qui tombe sous le sens pourrait ressembler à ceci (avec l'option x pour commenter):

$txt="
To: destinataire@example.com (commentaire inutile)
From: me@example.net (commentaire encore plus inutile)
Subject: Ces regex commencent sérieusement à me gonfler!
"
;

$motif = '/^(From|To|Subject):\s(.*)/m';

// le même motif avec commentaires (option x)
$motif='/
^(From|To|Subject)      # début de chaîne (ligne puisque option m) suivi par From ou To ou Subject
:\s                     # suivi par un : et un espace
(.*)                    # suivi par la capture de tout caractère sauf retour ligne
                        # option m multiligne et x pour les commentaires
/xm'
;

preg_match_all($motif, $txt, $out);

Retournera:

Todestinataire@example.com (commentaire inutile)
Fromme@example.net (commentaire encore plus inutile)
SubjectCes regex commencent sérieusement à me gonfler!

Damned! Il a capturé les "commentaires inutiles"! Il faudrait, idéalement, appliquer un masque spécifique pour capturer l'adresse email, et pas plus loin. Par exemple un masque d'email comme "#\w+@\w+\.[a-z]+#" capturera bien toute adresse email. Mais il ne capturera rien dans la ligne Subject car il n'y a aucune adresse email. Comment faire?

Vous aurez deviné que c'est ici que les sous-masques conditionnels interviennent.


$motif
= '/^((From|To)|Subject): ((?(2)\w+@\w+\.[a-z]+|.+))/m';

// le même motif, éclaté, avec commentaires (option x)
$motif='/
^                        # ancrage début de chaîne (ligne puisque option m)
((From|To)|Subject):\s   # soit From ou To (capturé en \2) soit Subject suivi par : et espace
(                        # paranthèse capturante (capture \3)
  (?(2)                    # if (From ou To) (2 est la référence arrière à la capture plus haut)
    \w+@\w+\.[a-z]+          # then toute chaine alphanum suivie par @ suivie par aplphanum
                             # suivie par un point et une chaîne alpha
   |                       # else
     .+)                     # tout caractère une ou plusieurs fois
)                        # fin capture \3 et options m multiline et x pour commentaires
/xm'
;


preg_match_all($motif, $txt, $out);

Retournera

Todestinataire@example.com (commentaire inutile)
Fromme@example.net (commentaire encore plus inutile)
SubjectCes regex commencent sérieusement à me gonfler!

Une extraction du tableau $out permettra de réassortir les couples To/From -> adresse email et Subject -> texte sujet.

Démo

retour menu principal

Les données binaires

Je n'ai jamais dû l'utiliser à ce jour mais la possibilité de comparer des données binaires existe. On peut introduire des caractères binaires dans des motifs de masques. Ces caractères peuvent s'introduire de deux manières:

Pour plus de détails, il y a une description détaillée sur les binaires dans la section "Non-printing characters" dans les pages man.

retour menu principal

Astuces et optimisation

Une regex peut s'écrire de nombreuses manières différentes. Certains motifs seront beaucoup plus efficaces que d'autres. S'il s'agit de valider une courte chaîne, pas de souci. La différence de vitesse d'exécution ne se verra presque pas. Quoique, une mico seconde par-ci, une nano seconde par-là...(chanson connue!). Mais sur des fichiers volumineux, une regex mal écrite pourra même mettre un serveur à genoux!

Le moteur regex interne dispose de puissantes routines d'optimisation qui vont d'abord analyser votre motif avant de se lancer dans le traitement proprement dit, mais on peut toujours lui donner un sérieux coup de main en soignant ses motifs dès le départ.

Comment faire pour les optimiser?

retour menu principal

Utilisez le dot avec parcimonie

On est souvent tenté d'utiliser le dot qui prend tout caractère sauf le retour ligne. Ce qui impose au moteur regex un nombre très important de combinaisons possibles (puisque le dot prend tout). On a vu, plus haut, qu'utiliser une classe de négation de caractères en lieu et place du dot était beaucoup plus efficace. Ne vous en privez pas. En plus, ça vous évitera de tomber dans le piège du dot qui s'arrête à un éventuel retour ligne. Enfin, il n'est pas nécessaire de rendre le quantificateur d'une telle classe non-gourmand. Le quantificateur d'une classe de négation de caractères s'arrêtera toujours au premier caractère ("négativé") rencontré.

Par contre, si votre motif commence par un .* (trèèès mauvaise idée ça, surtout s'il n'y a pas d'ancrage ou de lettres fixes en début de motif!), le moteur regex va pédaler dans la choucroute. Voyez:

On recherche une occurrence d'une chaîne se terminant par mot recherché dans un texte de deux lignes:

première ligne suivie par un retour ligne
et mot recherché

Si on cherche le mot cible avec le motif #(.*)mot recherché# impose au moteur regex un grand nombre d'allers-retours dans la chaîne avant de trouver une concordance. Il essayera d'abord toutes les combinaisons possibles sur la première ligne. Il ne trouvera rien. Ensuite seulement, il attaquera la seconde ligne où il trouvera le motif recherché.

Si, maintenant on lui met l'option s #(.*)mot recherché#s, on l'aide en diminuant sensiblement le nombre de backtracking. Il ira directement jusqu'à la fin de la deuxième ligne. Commencera son backtracking et trouvera tout de suite le motif recherché.

Conséquence: le deuxième motif est à peu près 45% plus rapide.

Démo

Donc, attention au dot. Mal utilisé il pourra planter votre regex!

retour menu astuces

Supprimez les parenthèses capturantes inutiles

On a vu plus haut que si vous utilisez les parenthèses pour délimiter une alternative, rendez-les non capturantes si vous n'avez pas besoin de les capturer. Ainsi #(le|la|les|un|une)\s(\w+)# qui cherche à capturer tous les mots ((\w+)) sera 30% plus rapide si vous faites #(?:le|la|les|un|une)\s(\w+)#.

Vous soulagerez le traitement de votre regex et simplifierez également l'utilisation des références arrières et le contenu du tableau de résultats.

retour menu astuces

Optimisez vos alternatives

Tout d'abord, si possible, remplacez les alternatives par des classes. Il est plus efficace d'écrire [akpi] plutôt que (?:a|k|p|i). De la même manière il vaut mieux faire (?:les?|des?|aux?) que (?:le|les|de|des|au|aux)

Enfin, sachez que lorsque le moteur regex aborde une alternative il prendra les alternatives une par une en commençant par la première. Dés qu'il trouve une concordance, il s'arrêtera là et n'analysera pas les autres alternatives. Si vous commencez l'alternative par le choix le plus probable, votre moteur regex à s'arrêtera plus tôt. Exemple:

Pour accélérer le traitement de l'expression régulière, ordonnez vos alternatives !

Le motif non ordonné #(?:le|la|les|de|du|des|au|aux|mes|tes|ses|nos|vos) alternatives# sera plus lent que si vous placiez le choix le plus probable devant: #(?:vos|la|les|de|du|des|au|aux|mes|tes|ses|nos) alternatives#

Démo

retour menu astuces

Scindez vos regex trop complexes

Ne faites pas de regex trop compliquées, avec de nombreux niveaux d'alternatives. Il est souvent plus efficace de faire plusieurs petites regex plutôt qu'une longue. Songez au moteur regex qui doit analyser toutes les combinaisons possibles d'occurrence d'un masque complexe. Et ce nombre de combinaisons augmente de manière exponentielle avec la complexité du motif.

Exemple: vous cherchez à savoir si le nom d'un jour de semaine (Lundi, Mardi etc...) se trouve dans un fichier. Vous serez tenté de faire ceci:

// tous les noms de semaine avec un séparateur de mot (assertion simple \b)
$mois = '#\b(?:Lundi|Mardi|Mercredi|Jeudi|Vendredi|Samedi|Dimanche)\b#';
if (
preg_match($mois, $txt, $out)){
  
print_r($out);
}

C'est propre, c'est net. Mais est-ce efficace? On pourrait essayer de travailler en boucle, en testant chaque nom de jour séparément et en quittant la boucle dès qu'un de ceux-ci est trouvé. Exactement le même mécanisme qu'une alternative regex. Essayons ceci:

// tableau avec un motif par jour de semaine
$mois = array('#\bLundi\b#', '#\bMardi\b#', '#\bMercredi\b#', '#\bJeudi\b#',
              
'#\bVendredi\b#', '#\bSamedi\b#', '#\bDimanche\b#');

// boucle sur les jours de la semaine et teste si ce jour se trouve dans le fichier
foreach ($mois as $v){
  if (
preg_match($v, $txt, $out)){
    
print_r($out);
    break;
  }
}

C'est un peu plus compliqué que de le faire en une seule passe, mais tellement plus efficace! Près de 10 fois plus rapide!

Et il y a encore certainement moyen d'améliorer la boucle avec une structure de contrôle while() plutôt que la structure foreach() - break que j'ai utilisée pour clarifier l'exemple.
retour menu astuces

Supprimez les options inutiles

Il est inutile de demander au moteur de regex de chercher des possibilités de concordances superflues. Comprenez bien l'utilité des options et supprimez les si elles ne sont pas strictement nécessaires. Pas d'option s s'il n'y a pas de dot dans le motif, ou d'option m s'il n'y a pas d'ancrage. L'impact d'un "nettoyage" d'option sur la performance n'est pas énorme mais ça aide parfois. Et puis ces options inutiles font un peu désordre...
retour menu astuces

Choisissez la fonction callback appropriée

Pour rappel, dans la plupart des cas, la fonction preg_replace_callback() sera plus rapide que preg_replace() avec l'option e. A tester au cas par cas. Voir la discussion sur ces fonctions.
retour menu astuces

Ancrez vos motifs

Si votre motif n'est pas ancré (^ ou $), ou s'il ne commence pas par un caractère fixe, le moteur de regex va devoir stocker toute une série d'états intermédiaires et devoir faire un sérieux nombre de retours sur ces états (backtracking). Si vous fixez le début ou/et la fin d'une chaîne vous diminuerez sensiblement ce backtracking inutile. Vous rappelez-vous l'exemple du Capitaine Haddock plus haut et sa discussion sur l'utilité des ancrages?

L'autre manière de faire est d'utiliser l'option S (majuscule) qui lancera une optimisation du motif avant que le moteur ne commence la recherche proprement dite. Faites l'essai de cette option dans le simulateur-testeur sur le motif 2. Vous verrez qu'il fonctionnera à peine plus rapidement avec cette option. Le gain en vitesse d'exécution beaucoup plus marqué si vous utilisez les ancrages.

Démo avec l'option S
Démo avec ancrage

retour menu astuces

Testez!

Après avoir utilisé toutes les astuces ci-dessus, mettez-les à l'épreuve sur un fichier grandeur nature et chronométrez! Utilisez ce petit simulateur de regex en mode timing. Ou bien faites-vous une petite fonction de chronométrage et exécutez votre regex dans une boucle (quelques centaines au maximum) pour lisser les résultats.

$timeStart microtime(true);
$nbLoops 200;
while (
$nbLoops--) {
    
/*
     *  code à tester
     */
}
echo 
number_format(microtime(true) - $timeStart4);

retour menu astuces

retour menu principal

Conclusion

Voilà. Il y a encore tellement, tellement à dire sur ce sujet... J'espère que ce petit survol pourra contribuer à une meilleure utilisation des regex dans vos projets.

Les expressions régulières PCRE sont peut-être rébarbatives d'un premier abord mais quelle puissance!

Alors, commencez sur de petites expressions simples et, lancez-vous! Elles vous le rendront bien!

Liens et bibliographie

Tuto et testeur par Jean-Luc Lacroix (regains scs) qui accueillera vos commentaires et/ou critiques avec plaisir. Surtout si vous avez de bonnes idées pour illustrer l'une ou l'autre fonctionnalité sympa des PCRE. Merci à Nicolas Chambrier pour sa relecture et ses conseils. Contact: adresse