Article écrit par François Vantomme
Encore un article sur les regex me direz-vous !? Effectivement, après avoir traité des quantificateurs, des propriétés Unicode, et même des emojis, que pourrais-je encore raconter que vous ne sachiez déjà ? Les groupes de capture vous connaissez ? On parle de group constructs en anglais, et il en existe plus d’une vingtaine ! Je vous invite cette semaine à en découvrir les principaux !
Des groupes, pour quoi faire ?
Les groupes dans une regex servent à isoler un sous-ensemble de la regex pour lui appliquer des quantificateurs ou un traitement particulier par le moteur de regex. Il existe des groupes capturants ou non capturants, conditionnels, exploratoires, récursifs, etc. Syntaxiquement, ils ont tous en commun l’utilisation de parenthèses ()
pour délimiter leur portée. Voici comment se notent les plus couramment utilisés et leur signification :
(...)
capture le contenu des parenthèses(a|b)
capture soita
, soitb
(?:...)
est un groupe sans mémoire(?>...)
est un groupe possessif sans mémoire(?#...)
représente un commentaire(?<name>...)
est un groupe capturant nommé(?=...)
déclare une exploration positive(?!...)
déclare une exploration négative(?<=...)
déclare une exploration arrière positive(?<!...)
déclare une exploration arrière négative
Voyons ensemble leur fonctionnement général et quelques cas d’usage.
Capturé ! J’en fais quoi maintenant ?
Le cas le plus simple est celui où l’on souhaite extraire un sous-ensemble de la recherche d’un certain motif au sein d’une chaîne de caractères. Coiffons-nous de notre casquette rouge et prenons pour exemple la phrase « Pikachu est une espèce de Pokémon ». Et voici la regex qui nous permettra d’attraper cette petite bête :
/(Pika)chu/
Nous obtenons ainsi la concordance suivante :
Concordance Complète (0-6) : Pikachu Groupe 1 (0-3) : Pika
En Ruby, pour obtenir ce résultat, nous utiliserions la méthode Regexp#match
qui nous renverrait un objet MatchData
, qu’il nous serait alors possible de parcourir à la manière d’un Array
: le premier élément serait la concordance complète et les suivants les groupes capturés. Voici un exemple :
text = "Pikachu est une espèce de Pokémon" match = /(Pika)chu/.match(text) # => #<MatchData "Pikachu" 1:"Pika"> puts match[0] # => "Pikachu" puts match[1] # => "Pika"
Évolution
Faisons évoluer notre regex. Non content de capturer des Pikachu, on aimerait aussi attraper des Raichu et des Pichu ! Pour cela, rien de plus simple, il nous suffit d’utiliser la syntaxe (a|b)
. Partons du texte suivant :
Dans la seconde génération de jeux Pokémon apparaît Pichu, la pré-évolution de Pikachu, qui est issu d'un œuf de Pikachu ou de Raichu, son évolution.
Et voici notre nouvelle regex :
/(Pi|Pika|Rai)chu/g
On a à présent quatre concordances : un Pichu, deux Pikachu et un Raichu. Notez la présence du modificateur g
pour effectuer une recherche globale. Cela nous permet de trouver toutes les concordances dans le texte.
Concordance 1 (52-57) : Pichu Groupe 1 (52-54) : Pi Concordance 2 (79-86) : Pikachu Groupe 1 (79-83) : Pika Concordance 3 (113-120) : Pikachu Groupe 1 (113-117) : Pika Concordance 4 (127-133) : Raichu Groupe 1 (127-130) : Rai
Et là, en Ruby ça se complique un peu ! On se serait attendu à une méthode comme Regexp#match_all
ou quelque chose de similaire, mais j’ai beau chercher, je n’en trouve aucune trace. Ce que l’on a de plus approchant, c’est la méthode String#scan
. La voici à l’œuvre :
text = <<~TXT Dans la seconde génération de jeux Pokémon apparaît Pichu, la pré-évolution de Pikachu, qui est issu d'un œuf de Pikachu ou de Raichu, son évolution. TXT matches = text.scan(/(Pi|Pika|Rai)chu/) # => [["Pi"], ["Pika"], ["Pika"], ["Rai"]]
Bien mais pas top. En effet, nous avons là ce qui a été capturé, mais aucune trace de notre concordance complète ! Pour y remédier, nous avons deux options. La première consiste à capturer l’entièreté de notre regex à l’aide de parenthèses englobantes :
text.scan(/((Pi|Pika|Rai)chu)/) # => [["Pichu", "Pi"], ["Pikachu", "Pika"], ["Pikachu", "Pika"], ["Raichu", "Rai"]]
La seconde est plus subtile et nous permet de manipuler in fine des objets MatchData
. La voici :
text.to_enum(:scan, /(Pi|Pika|Rai)chu/).map { Regexp.last_match } # => [#<MatchData "Pichu" 1:"Pi">, # #<MatchData "Pikachu" 1:"Pika">, # #<MatchData "Pikachu" 1:"Pika">, # #<MatchData "Raichu" 1:"Rai">]
Pi ou Pika ?
Vous aurez noté que Pichu
et Pikachu
sont très similaires. N’aurions-nous pas une autre manière d’écrire notre regex ? Si, tout à fait ! Et c’est le moment choisi pour utiliser un quantificateur de groupe ! Voyons ça. Ce que l’on veut c’est Pi
, suivi éventuellement de ka
, suivi de chu
. On le note ainsi :
/Pi(ka)?chu/
Le point d’interrogation ?
a sur un groupe le même effet que sur un caractère seul : il permet d’indiquer qu’on s’attend à le rencontrer une ou zéro fois. Nous pouvons donc réécrire notre regex comme ceci :
/(Pi(ka)?|Rai)chu/
Concordance 1 (52-57) : Pichu Groupe 1 (52-54) : Pi Concordance 2 (79-86) : Pikachu Groupe 1 (79-83) : Pika Groupe 2 (81-83) : ka Concordance 3 (113-120) : Pikachu Groupe 1 (113-117) : Pika Groupe 2 (115-117) : ka Concordance 4 (127-133) : Raichu Groupe 1 (127-130) : Rai
Cela nous donne quasiment le même résultat. Et ce « quasiment » tient à la présence d’un second groupe capturant dans notre premier groupe. Ce n’est pas bien gênant, il nous suffit de ne pas en tenir compte à l’usage, mais c’est toutefois l’occasion rêvée d’introduire la notion de groupe non capturant.
Relâchez-le !
Pour faire disparaitre le second groupe qui se manifeste lorsqu’on trouve une occurrence de Pikachu
dans notre texte, nous allons déclarer notre groupe comme non capturant. Pour cela, la syntaxe appropriée se trouve être (?:)
. Ce qui donne ceci :
/(Pi(?:ka)?|Rai)chu/
Cela fonctionne à l’instar d’un groupe classique ()
, à ceci près qu’aucune capture ne sera faite. On obtient ainsi exactement le résultat attendu :
Concordance 1 (52-57) : Pichu Groupe 1 (52-54) : Pi Concordance 2 (79-86) : Pikachu Groupe 1 (79-83) : Pika Concordance 3 (113-120) : Pikachu Groupe 1 (113-117) : Pika Concordance 4 (127-133) : Raichu Groupe 1 (127-130) : Rai
NOTE : Nous aurions pu tout aussi bien utiliser un groupe atomique (?>)
dans ce cas précis, cela n’aurait fait aucune différence. La particularité d’un groupe atomique, en plus d’être non capturant, est qu’il est possessif. C’est-à-dire que tout caractère qu’il consomme ne sera pas restitué, ce qui dans certains cas peut empêcher le moteur de regex à trouver une concordance. Le caractère possessif d’un groupe — comme d’un quantificateur — a pour objectif principal d’optimiser la performance d’une regex en lui permettant d’échouer plus rapidement.
Pika Pika
text = <<~TXT Dans la sixième génération, les cris des anciens Pokémon ont presque tous été modifiés, afin de les adapter aux capacités sonores de la Nintendo 3DS. Pikachu, quant à lui, a eu son cri totalement refait, et a désormais la voix d'Ikue Ōtani, sa doubleuse du dessin animé. Dans le dessin animé, au lieu des cris des jeux, les acteurs ont doublé la voix des Pokémon rien qu'en prononçant une partie de leur nom, conduisant au fameux "Pika !" du Pikachu de Sacha. TXT
source : pokedia.fr
Dans le texte ci-dessus, nous aimerions à présent relever toutes les occurrences du cri de Pikachu. Pour cela, il faut être en mesure de distinguer le nom du Pokémon Pikachu
de son cri Pika
. Pour complexifier un peu l’exercice, on va accepter l’idée que le cri de Pichu
est Pi
et que c’est aussi une réponse acceptable. Il y a bien entendu de nombreuses techniques pour arriver à nos fins. Celle que je vous présente ici n’est certainement pas la plus simple, mais a le mérite d’introduire le concept d’exploration, et plus précisément d’exploration avant négative (?!)
, appelée negative lookahead en anglais.
Ainsi, nous recherchons Pi
ou Pika
, mais nous n’acceptons de concordance que si le terme ainsi trouvé n’est pas directement suivi de chu
. Ce qui nous donne :
/Pi(?:ka)?(?!chu)/
Concordance 1 (150-152) : Pi Concordance 2 (432-436) : Pika Concordance 3 (443-445) : Pi
Aïe ! Nous n’aurions dû trouver qu’une occurrence de Pika
et c’est tout. Que s’est-il passé ? Eh bien tout simplement, notre moteur de regex a considéré, à juste titre, que Pi
suivi de kachu
était tout à fait acceptable ! Comment nous sortir de là ? Avec une petite pirouette dont nous avons fait mention quelques paragraphes plus haut : un quantificateur possessif ?+
.
/Pi(?:ka)?+(?!chu)/
Concordance 1 (432-436) : Pika
Et voilà le travail ! Notez que les groupes exploratoires ne sont pas capturants eux non plus, c’est pourquoi nous n’avons qu’un seul groupe par concordance.
NOTE: Je ne m’attarderai pas dessus ici, mais sachez qu’il existe aussi des groupes exploratoires positifs (?=)
, ainsi que des groupes exploratoires arrières positifs (?<=)
et négatifs (?<!)
.
Pour les curieux, une autre approche aurait été d’employer le caractère b
qui n’est rien d’autre qu’un délimiteur de début ou fin de mot. Ce qui donnerait :
/bPi(?:ka)?+b/
Nommez-les tous !
Arrive un moment où faire référence aux groupes de nos regex à l’aide d’indices rend la chose très confuse, et où il serait bienvenu de pouvoir les nommer. Qu’à cela ne tienne, il suffit de demander ! La syntaxe pour nommer un groupe est (?<name>)
où name
sera le nom du groupe. On peut parfois trouver, selon les langages, la notation (?'name')
ou encore (?P<name>)
.
Si l’on reprend le texte de notre dernier exemple, et sachant que le cri de notre Pokémon correspond à la première partie de son nom (sans chu
, donc), voici ce que l’on obtiendrait :
/(?<pokemon>(?<cri>Pi(?:ka)?)chu)/
Concordance 1 (150-157) : Pikachu pokemon (150-157) : Pikachu cri (150-154) : Pika Concordance 2 (443-450) : Pikachu pokemon (443-450) : Pikachu cri (443-447) : Pika
Ainsi, en Ruby nous pouvons à présent y faire référence comme ceci :
match = text.match(/(?<pokemon>(?<cri>Pi(?:ka)?)chu)/) # => #<MatchData "Pikachu" pokemon:"Pikachu" cri:"Pika"> puts match[:pokemon] # "Pikachu" puts match[:cri] # "Pika"
S’envoler vers d’autres cieux
Des groupes, il en existe encore de nombreux que je vous laisse découvrir par vous-même. Attention cependant, certains ne sont supportés que par un ensemble très restreint de langages ; le plus abouti en la matière étant Perl. Tout ceci est très bien documenté sur regex101.com, un outil interactif extrêmement évolué et intuitif, ainsi que sur regular-expressions.info (en anglais), pour ne citer qu’eux. Notez que pour les rubyistes, il existe aussi rubular comme alternative à regex101.