Previous Up Next

Chapitre 6  Expressions régulières

6.1  Langages réguliers

6.1.1  Les mots

On se donne un ensemble Σ de caractères, parfois dénommé alphabet. L’ensemble des mots sur Σ, noté Σ*, est l’ensemble des suites finies de caractères. Un langage est un sous-ensemble de Σ*, c’est-à-dire un ensemble particulier de mots.

Par exemple si Σ est l’ensemble des lettres usuelles, on peut définir le langage L1 des mots français. Ou encore si Σ est l’ensemble des chiffres de 0 à 9, on peut définir le langage L2 des représentations en base 10 des entiers naturels. Dans le premier cas, on peut tout simplement tenter de définir L1 comme l’ensemble de tous les mots présents dans le dictionnaire de l’Académie française, dans le second cas, on peut recourir à une définition du style « un entier (décimal) est le chiffre zéro, ou une suite non vide de chiffres qui ne commence pas par zéro ». Les expressions régulières (ou rationnelles) sont une notation très commode pour définir des langages de mots du style de L1 et L2 ci-dessus.

Définissons tout de suite quelques notations sur les mots et les langages. Parmi les mots de Σ* on distingue le mot vide noté є. Le mot vide est l’unique mot de longueur zéro. La concaténation de deux mots m1 et m2 est le mot obtenu en mettant m1 à la fin de m2. On note · l’opérateur de concaténation, mais on omet souvent cet opérateur et on note donc souvent la concaténation m1 ·m2 par une simple juxtaposition m1m2. La concaténation est une opération associative qui possède un élément neutre : le mot vide є. Dans l’écriture m = m1m2, m1 est le préfixe du mot m, tandis que m2 est le suffixe du mot m.

La concaténation s’étend naturellement aux ensembles de mots, on note L1 · L2 le langage obtenu en concaténant tous les mots de L1 avec les mots de L2.

L1 · L2 = { m1 · m2 ∣ m1 ∈ L1 ∧ m2 ∈ L2}

Enfin on note L le langage obtenu en concaténant les mots de L.

L0 = { є }
Ln+1 = Ln · L
L = 
 
i ∈ ℕ
 Li

C’est-à-dire qu’un mot m de L* est la concaténation de n mots (n ≥ 0) m1, …mn, où les mi sont tous des mots de L.

6.1.2  Syntaxe des expressions régulières

Les expressions régulières ou motifs, notées p, sont définies ainsi :

On reconnaît ici la définition d’un arbre, et même d’un arbre de syntaxe abstraite. Il y a deux sortes de feuilles, mot vide et caractères ; deux sortes de nœuds binaires, concaténation et alternative ; et une sorte de nœud unaire, la répétition. Comme d’habitude pour lever les ambiguïtés dans l’écriture linéaire d’un arbre, on a recours à des priorités (à savoir, la répétition est plus prioritaire que la concaténation, elle-même plus prioritaire que l’alternative) et à des parenthèses.

Ainsi, pour Σ = {a, b, c}, le motif ab*|ba* est à comprendre comme ((a)(b*))|((b)(a*)), ou plus informatiquement comme l’arbre de la figure 6.1.


Figure 6.1: L’arbre de syntaxe abstraite de l’expression ab*|ba*

Pour se souvenir des priorités adoptées on peut noter l’analogie avec les expressions arithmétiques : les opérations de concaténation et d’alternative ont les même priorités respectives que la multiplication et l’addition, enfin la répétition se comporte comme la mise à la puissance.

Dans ce polycopié nous exprimons les expressions régulières sous la forme « programme » et non pas d’expression mathématiques Concrètement, nous avons écrit ab*|ba* et non pas abba. Ce parti pris entraîne que le motif vide ne peut plus être donné par є, si nécessaire nous le représenterons donc par exemple comme ().

Avec un peu d’habitude, on comprend nos écritures comme des arbres de syntaxe abstraites. Cela lève toutes les ambiguïtés d’entrée de jeu. On doit sans doute remarquer qu’en réalité, nos priorités ne lèvent pas toutes les ambiguïtés. En effet, on peut comprendre abc comme a(bc) ou comme (ab)c, c’est-à-dire comme l’arbre de gauche ou comme l’arbre de droite de la figure 6.2. Au fond cette ambiguïté n’a ici aucune importance, car l’opération de concaténation des mots est associative. Il en résulte que la concaténation des motifs est également associative.


Figure 6.2: Deux arbres possibles pour abc

Comme nous allons le voir immédiatement, il en va de même pour l’alternative.

6.1.3  Sémantique des expressions régulières

Une expression arithmétique a une valeur (plus généralement on dit aussi une sémantique) : c’est tout simplement l’entier résultat du calcul. De même, une expression régulière a une valeur qui est ici un ensemble de mots. Il est logique que la définition de la sémantique reprenne la structure de la définition des expressions régulières. C’est tout simplement une définition inductive, qui à chaque expression régulière p associe un sous-ensemble [[p]] de Σ*.

[[є]]={ є }
[[c]]=c }
[[p1|p2]]=[[p1]] ⋃ [[p2]]
[[p1·p2]]=[[p1]] · [[p2]]
[[p*]]=[[p]]*

Cette définition a le parfum usuel des définitions de sémantique, on a presque rien écrit en fait ! Tout a déjà été dit ou presque dans la section 6.1.1 sur les mots. Lorsque m appartient au langage défini par p, on dit aussi que le motif p filtre le mot m. Un langage qui peut être défini comme la valeur d’une expression régulière est dit langage régulier.

Exemple 6.1   Nous savons donc que le langage des mots du français selon l’Académie est régulier, puisque qu’il est défini par une grosse alternative de tous les mots du dictionnaire publié par cette institution. Les représentations décimales des entiers sont également un langage régulier défini par :
0|(1|2|3|4|5|6|7|8|9)(0|1|2|3|4|5|6|7|8|9)*
Exercice 6.1   Montrer qu’un langage qui contient un nombre fini de mots est régulier.
Solution. Si L = {m1, m2, …, mn} est un langage fini contenant n mots, alors L est exactement décrit par l’alternative de tous les mi. □

6.1.4  Filtrage

La relation de filtrage m ∈ [[p ]], également notée pm, peut être définie directement par les règles de la figure 6.3.


Figure 6.3: Définition de la relation p filtre m
Empty
є ≼ є
Char
c ≼ c
Seq
p1 ≼ m1           p2 ≼ m2
p1p2 ≼ m1m2
OrLeft
p1 ≼ m
p1|p2 ≼ m
OrRight
p2 ≼ m
p1|p2 ≼ m
StarEmpty
p* ≼ є
StarSeq
p ≼ m1           p* ≼ m2
p* ≼ m1m2

La définition de pm est faite selon le formalisme des règles d’inférence, qui est très courant. Il permet la définition inductive d’un prédicat. Les règles se décomposent en axiomes (par ex. Empty) qui sont les cas de base de la définition, et en règles d’inférence proprement dites qui sont les cas inductifs de la définition. Les règles sont à comprendre comme des implications : quand toutes les prémisses (au dessus du trait) sont vraies, alors la conclusion (au dessous du trait) est vraie. En enchaînant les règles on obtient une preuve effective du prédicat, appelée arbre de dérivation. Voici par exemple la preuve de ab*|ba*baa.

b ≼ b          
a ≼ a          
a ≼ a          a* ≼ є
a* ≼ a
a* ≺ aa
ba* ≼ baa
ab*|ba* ≼ baa

Nous disposons donc de deux moyens d’exprimer un langage régulier, m ∈ [[p]] ou pm. Selon les circonstances, il est plus facile d’employer l’un ou l’autre de des moyens. Par exemple, pour montrer qu’un motif filtre un mot, les règles d’inférences sont souvent commodes. En revanche, pour montrer des égalités de langages réguliers, il est souvent plus commode de se servir des opérations sur les langages.

Exercice 6.2   Prouver que pour tout motif p, p* et p** définissent le même langage.
Solution. Pour tout langage on a (L) = L par définition de l’opération de répétition sur les langages. Prouver l’équivalence p*m  ⇐⇒ p**m est plus pénible. □

6.2  Notations supplémentaires

En pratique on rencontre diverses notations qui peuvent toutes être exprimées à l’aide des constructions de base des expressions régulières. Autrement dit, l’emploi de ces notations n’augmente pas la classe des langages réguliers, mais permet simplement d’écrire des expressions régulières plus compactes.

Exemple 6.2   Ainsi une notation plus conforme à ce que les langages informatiques acceptent en fait d’entiers décimaux est :
(0|1|2|3|4|5|6|7|8|9)+
C’est-à-dire une suite non-vide de chiffres décimaux, sans chercher à éviter d’éventuels zéros initiaux. Une notation possible pour les entiers relatifs est
-?(0|1|2|3|4|5|6|7|8|9)+
C’est-à-dire un entier naturel précédé éventuellement d’un signe moins.

Il existe également toute une variété de notations pour des ensembles de caractères. Ces motifs sont des abréviations de l’alternative notées entre crochets []. Donc, au sein de ces crochets, on trouve une suite des classes de caractères suivantes :

La notation [^] se comprend comme le complémentaire des classes de caractères données. Il est clair que les notations de classes de caractères peuvent s’exprimer comme des alternatives de motifs caractères. Dans le cas du complémentaire, cela suppose un alphabet Σ fini, ce qui est bien évidemment le cas en pratique.

Exemple 6.3   Une notation compacte pour les entiers décimaux est [0-9]+. Cela fonctionne parce que dans tous les codages raisonnables les codes des chiffres sont consécutifs. De même, puisqu’en Java par exemple (en ASCII en fait) les codes des lettres sont consécutifs, l’expression régulière [a-zA-Z] représente toutes les lettres minuscules et majuscules (non-accentuées), Tandis que [^a-zA-Z] représente tous les caractères qui ne sont pas des lettres.
Exercice 6.3   Donner une expression régulière compacte qui décrit la notation adoptée pour les entiers par Java. À savoir, notation décimale (sans zéro initiaux), hexadécimale (introduite par 0x, les chiffres de 10 à 15 étant représentes par les lettres minuscules ou majuscules de a à f), et notation octale (introduite par un zéro).
Solution. Il y a peu à dire à part :
(0|[1-9][0-9]*)|0x[0-9a-fA-F]+|0[0-7]+
On note que, selon l’expression donnée, 0 est décimal, 00 octal, et 09 interdit. □

Enfin certains caractères spéciaux sont exprimables à l’aide de notations assez usuelles, du style \n est le caractère fin de ligne et \t est la tabulation etc. Le caractère barre oblique inverse (backslash) introduit les notations de caractères spéciaux, mais permet aussi d’exprimer les caractères actifs des expressions régulières comme eux-mêmes, c’est à dire de les citer. Par exemple le caractère étoile se note \* et, bien évidemment, le caractère barre oblique inverse se note \\. Il n’est pas l’usage de citer les caractères quand c’est inutile, ainsi [^*] représente tous les caractères sauf l’étoile.

Exemple 6.4   Nous sommes désormais en possession d’un langage de description des ensembles de mots assez puissant. Par exemple voici le motif qui décrit une des deux sortes de commentaires Java.
//[^\n]*\n
C’est-à dire qu’un commentaire commence par deux / et se poursuit jusqu’à la fin de la ligne courante. Notons que le motif //.*\n ne convient pas, car il filtre par exemple :
//Commentaire avant
i++ ;
Exercice 6.4   Donner une expression régulière qui décrit l’autre sorte de commentaires de Java. À savoir, un commentaire commence par /* et s’étend jusqu’à */.
Solution. On vise un motif de la forme /\*p\*/.Autrement dit, les commentaires commencent par le sous-mot /* et se terminent par le sous-mot */, et, comme on doit identifier le premier sous-mot */ pour fermer le commentaire, le motif p doit correspondre au langage P de tous les mots dont */ n’est pas un sous-mot. Notons tout de suite qu’un mot de P peut se terminer par une suite d’étoiles, on a donc p = q\**. Soit Q le langage du motif q, en définissant Q comme l’ensemble des mots dont aucun sous-mot n’est */ et qui ne se terminent pas par une suite non-vide d’étoiles. Nous donnons ensuite à q la forme q = r*, c’est un moyen simple de prendre en compte que Q contient des mots de longueur arbitraire. Il faut maintenant decomposer un mot (non-vide) m comme un préfixe appartenant à R (langage de r) et un suffixe m′, tels que m est dans Q, si et seulement si m′ est dans Q. Soit r = [^*]|\*+[^*/]. Au final, la solution est :
/\*([^*]|\*+[^*/])*\*+/
La syntaxe concrète est celle introduite dans les sections précédentes. L’arbre de syntaxe abstraite est donné à la figure 6.4.

Figure 6.4: Syntaxe abstraite de /\*([^*]|\*+[^*/])*\*+/

6.3  Programmation avec les expressions régulières

Dans cette section nous nous livrons à une description rapide de quelques outils qui emploient les expressions régulières de façon cruciale. Le sujet est extrêmement vaste, car le formalisme des expressions régulières est suffisamment expressif pour permettre de nombreuses recherches dans les fichiers textes, voire de nombreuses transformations de ces fichiers. Le livre de Jeffrey E. F. Friedl [3] traite des expressions régulière du point de vue des outils.

6.3.1  Dans l’interprète de commandes Unix

Le shell c’est à dire l’interprète de commandes Unix reconnaît quelques expressions régulières qu’il applique aux noms des fichiers du répertoire courant. La syntaxe concrète des motifs est franchement particulière. Notons simplement que ? représente un caractère quelconque (noté précédemment .), tandis que * représente un mot quelconque (noté précédemment .*), et que l’alternative se note à peu près {p1,p2} (pour p1|p2)…

Ainsi, on pourra effacer tous les fichiers objets Java par la commande :

% /bin/rm *.class

Dans le même ordre d’idée, on peut compter toutes les lignes des fichiers source du répertoire courant :

% wc -l *.java

La commande wc1 (pour word count) compte les lignes, mots et caractères dans les fichiers donnés en argument. L’option « -l » restreint l’affichage au seul compte des lignes. Enfin, on peut faire la liste de tous les fichiers du répertoire courant dont le nom comprend de un à trois caractères :

% ls {?,??,???}

6.3.2  Recherche de lignes dans un fichier, la commande egrep

La commande egrep motif fichier affiche toutes les lignes de fichier dont motif filtre un sous-mot (et non pas la ligne entière). La syntaxe des motifs est relativement conforme à ce que nous avons déjà décrit. Supposons, comme c’est souvent le cas, que le fichier /usr/share/dict/french de votre machine Unix est un dictionnaire français, donné sous forme d’un fichier texte, à raison d’un mot par ligne. On peut alors trouver les mots qui contiennent au moins six fois la lettre « i » de cette manière.

% egrep 'i.*i.*i.*i.*i.*i' /usr/share/dict/french 
indivisibilité

On notera que l’argument motif est donné entre simples quotes « ' », ceci afin d’éviter que le shell n’interprète les étoiles comme faisant partie d’un motif à appliquer aux noms de fichier. Ce n’est en fait pas toujours nécessaire, mais c’est toujours prudent.

6.3.3  En Java

Le support pour les expressions régulières est assuré par les classes Pattern et Matcher du package java.util.regexp, Les objets de la classe Pattern implémentent les motifs. et sont construits par la méthode statique Pattern.compile qui prend une chaîne représentant un motif en argument et renvoie un Pattern. On pourrait penser que la méthode compile interprète la chaîne donnée en argument et produit un arbre de syntaxe abstraite. En fait, par souci d’efficacité, elle procède à bien plus de travail, jusqu’à produire un automate (voir le chapitre suivant). Pour le moment, il suffit de considérer qu’un Pattern est la forme Java d’un motif.

Pour confronter un Pattern p à un mot m il faut fabriquer cette fois un Matcher en invoquant la méthode matcher(String text) de p, l’argument text étant le mot m. En simplifiant, le Matcher obtenu est donc la combinaison d’un motif p et d’un mot m, il offre (entre autres !) les méthodes matches() et find() qui renvoient des booléens. La première méthode matches teste si le motif p filtre le mot m, tandis que la seconde find teste si le motif p filtre un sous-mot du mot m. Ainsi, par exemple, un moyen assez compliqué de savoir si un mot mot contient le sous-mot sous au moins deux fois est d’écrire :

static boolean estSousMotDeuxFois(String sous, String mot) {
   return Pattern.compile(sous + ".*" + sous).matcher.().find(mot) ;
}

Nous en savons maintenant assez pour pouvoir écrire la commande egrep en Java, la classe Grep dont le code est donné à la figure 6.5 (source Grep.java).


Figure 6.5: La commande egrep en Java, source Grep.java
import java.io.* ;         // Pour  BufferedReader
import java.util.regex.* ; // Pour Pattern et Matcher

class Grep {

  // Affiche les lignes de in dont un sous-mot est filtré par le motif p  
  static void grep(String p, BufferedReader in) throws IOException {
    Pattern pat = Pattern.compile(p) ;
    String line = in.readLine() ;
    while (line != null) {
      Matcher m = pat.matcher(line) ;
      if (m.find()) {
        System.out.println(line) ;
      }
      line = in.readLine() ;
    }
  }

  public static void main(String [] arg) {
    if (arg.length != 2) {
      System.err.println("Usage: java Grep motif fichier") ;
      System.exit(2) ;
    }
    try {
      BufferedReader in = new BufferedReader (new FileReader (arg[1])) ;
      grep(arg[0], in) ;
      in.close() ;
    } catch (IOException e) {
      System.err.println("Malaise: " + e.getMessage()) ;
      System.exit(2) ;
    }
  }
}

Dans ce code, la méthode main se comporte principalement ainsi : elle ouvre le fichier dont le nom est donné comme second argument de la ligne de commande, par new FileReader(arg[1]) ; puis enveloppe le fichier comme le BufferedReader (voir B.6.2.2) in, ceci afin de pouvoir le lire ligne à ligne ; enfin le code appelle la méthode grep. Cette dernière, après construction du Pattern pat, l’applique à toutes les lignes du fichier. En cas de succès de l’appel à find, la ligne est affichée sur la sortie standard. Le comportement global est donc bien celui de la commande egrep.

L’architecture du package java.util.regex peut paraître bien compliquée, et c’est tout à vrai. Mais…

Nous ne décrirons pas plus en détail les expressions régulières de Java. La documentation du langage offre de nombreuses précisions. En particulier, la documentation de la classe Pattern comprend la description complète de la syntaxe des motifs qui est plus étendue que ce que nous avons vu ici. Attention tout de mêmes les descriptions de motifs sont données dans l’absolu, alors que les motifs sont souvent en pratique dans des chaînes. If faut donc en plus tenir compte des règles de citation dans les chaînes. Ainsi le motif \p{L} filtre n’importe quelle lettre (y compris les lettres accentuées) mais on le donne sous la forme de la chaîne "\\p{L}", car il faut citer un backslash avec un backslash !

6.4  Implémentation des expressions régulières

Le but de cette section est de décrire une technique d’implémentation possible des expressions régulières en Java. Il s’agit d’une première approche, beaucoup moins sophistiquée que celle adoptée notamment par la bibliothèque Java. Toutefois, on pourra, même avec des techniques simples, déjà aborder les problèmes de programmation posés et comprendre « comment ça marche ». De fait nous allons imiter l’architecture du package java.util.regexp et écrire nous aussi un package que nous appelons regex tout court.

Nous en profitons donc pour écrire un package. Tous les fichiers source du package regex commencent par la ligne package regex ; qui identifie leur classe comme appartenant à ce package. En outre, il est pratique de regrouper ces fichiers dans un sous-répertoire nommé justement regex. Le source du nouveau package est disponible comme l’archive regex.tar qui contient regex/Flux.java (non décrit), regex/Re.java (arbre de syntaxe abstraite, section 6.4.1), regex/Pattern.java (section 6.4.2) et regex/Matcher.java (section 6.4.3).

6.4.1  Arbres de syntaxe abstraite

Nous reprenons les techniques de la section 4.4 sur les arbres de syntaxe abstraite. À savoir nous définissons une classe Re des nœuds de l’arbre de syntaxe abstraite.

package regex ;

class Re {
  private final static int  EMPTY=0, CHAR=1, WILD=2, OR=3, SEQ=4, STAR=5 ;

  private int tag ;
  private char asChar ;
  private Re p1, p2 ;

  private Re() {}
  ⋮
}

Nous définissons cinq sortes de nœuds, la sorte d’un nœud étant identifiée par son champ tag. Des constantes nommées identifient les cinq sortes de nœuds. La correspondance entre constante et sorte de nœud est directe, on note la présence de nœuds « WILD » qui représentent les jokers. Ensuite nous définissons tous les champs nécessaires, un champ asChar utile quand le motif est un caractère (tag CHAR), et deux champs p1 et p2 utiles pour les nœuds internes qui ont au plus deux fils. Enfin, le constructeur par défaut est redéfini et déclaré privé.

On construira les divers nœuds en appelant des méthodes statiques bien nommées. Par exemple, pour créer un motif caractère, on appelle :

static Re charPat(char c) { // On ne peut pas nommer cette méthode « char »
  Re r = new Re() ;
  r.asChar = c ;
  return r ;
}

Pour créer un motif répétition, on appelle :

static Re star(Re p) {
  Re r = new Re() ;
  r.p1 = p ;
  return p ;
}

Les autres autres méthodes de construction sont évidentes.

Les méthodes statiques de construction ne se limitent évidemment pas à celles qui correspondent aux sortes de nœuds existantes. On peut par exemple écrire facilement une méthode plus qui construit un motif p+ comme pp*.

static Re plus(Re p) {
  return seq(p, star(p)) ;
}

Du point de vue de l’architecture, on peut remarquer que tous les champs et le constructeur sont privés. Rendre le constructeur privé oblige les utilisateurs de la classe Re appeler les méthodes statiques de construction, de sorte qu’il est garanti que tous les champs utiles dans un nœud sont correctement initialisés. Rendre les champs privés interdira leur accès de l’extérieur de la classe Re. Au final, la politique de visibilité des noms est très stricte. Elle renforce la sécurité de la programmation, puisque si nous ne modifions pas les champs dans la classe Re, nous pourrons être sûrs que personne ne le fait. En outre, la classe Re n’est pas publique, son accès est donc limité aux autres classes du package regex. La classe Re est donc complètement invisible pour les utilisateurs du package.

6.4.2  Fabrication des expressions régulières

Nous présentons maintenant notre classe Pattern, un modeste remplacement de la classe homonyme de la bibliothèque Java. Pour le moment nous évitons les automates et nous contentons de cacher un arbre Re dans un objet de la classe Pattern.

package regex ;

/* Une classe Pattern simple : encapsulage d'un arbre de syntaxe abstraite */

public class Pattern {
  private Re pat ;

  private Pattern(Re pat) { this.pat = pat ; }

  // Chaîne -> Pattern
  public static Pattern compile(String patString) {
    Re re = Re.parse(patString) ;
    return new Pattern(re) ;
  }

  // Fabriquer le Matcher
  public Matcher matcher(String text) { return new Matcher(pat, text) ; }
}

Comme dans la classe de la bibliothèque, c’est la méthode statique compile qui appelle le constructeur, ici privé. La partie la plus technique de la tâche de la méthode compile est le passage de la syntaxe concrète contenue dans la chaîne patString à la syntaxe abstraite représenté par un arbre Re, opération déléguée à la méthode Re.parse. Nous ne savons pas écrire cette méthode d’analyse syntaxique (parsing). (cours INF 431). Mais ne soyons pas déçus, nous pouvons déjà par exemple construire le motif qui reconnaît au moins k caractères c, en appelant la méthode atLeast suivante, à ajouter dans la classe Pattern.

public static Pattern atLeast(int k, char c) {
  return new Pattern(buildAtLeast(k, c)) ;
}

private static Re buildAtLeast(int k, char c) {
  if (k <= 0) {
    return Re.empty() ;
  } else if (k == 1) {
    return Re.charPat(c) ;
  } else {
    return
      Re.seq
       (Re.charPat(c),
        Re.seq(Re.star(Re.wild()), buildAtLeast(k-1, c)))
  }
}

Enfin, la méthode matcher de de la classe Pattern se contente d’appeler le constructeur de notre modeste classe Matcher, que nous allons décrire.

6.4.3  Filtrage


Figure 6.6: Notre classe Matcher
package regex ;

public class Matcher {
  private Re pat ;
  private String text ;

  // Les recherches commencent à cette position dans text
  private int regStart ;
  // La dernière sous-chaîne filtrée est text[mStart...mEnd[
  private int mStart, mEnd ;

  Matcher(Re pat, String text) {
    this.pat = pat ; this.text = text ;
    regStart = 0 ;       // Commencer à filtrer à partir du début
    mStart = mEnd = -1 ; // Aucun motif encore reconnu
  }

  // Renvoie la dernière sous-chaîne filtrée, si il y a lieu
  public String group() {
    if (mStart == -1) throw new Error("Pas de sous-chaîne filtrée") ;
    return text.substring(mStart, mEnd) ;
  }

  // Méthode de recherche des sous-chaînes filtrées a peu près
  // conforme à celle des Matcher de java.util.regex
  public boolean find() {
    for (int start = regStart ; start <= text.length() ; start++)
      for (int end = text.length() ; end >= start ; end--) {
        if (Re.matches(text, pat, start, end)) {
          mStart = start ; mEnd = end ;
          regStart = mEnd ; // Le prochain find commencera après celui-ci
          return true ;
        }
      }
    mStart = mEnd = -1 ; // Pas de sous-chaîne reconnue
    regStart = 0 ;       // Recommencer au début, bizarre
    return false ;
  }
}

Le source de la classe Matcher (figure 6.6) indique que les objets contiennent deux champs pat et text, pour le motif et le texte à filtrer. Comme on pouvait s’y attendre, le constructeur Matcher(Re pat, String text) initialise ces deux champs. Mais les objets comportent trois champs supplémentaires, mStart, mEnd et regStart.

La méthode find est la plus intéressante, elle cherche à identifier une sous-chaîne filtrée par pat, à partir de la position regStart et de la gauche vers la droite. La technique adoptée est franchement naïve, on essaie tout simplement de filtrer successivement toutes les sous-chaînes commençant à une positon donnée (start) des plus longues à la chaîne vide. On renvoie true (après mise à jour de l’état du Matcher), dès qu’une sous-chaîne filtrée est trouvée. Pour savoir si une sous-chaîne text[start…end[ est filtrée, on fait appel à la méthode statique Re.matches. Notons que c’est notre parti-pris de rendre privés tous les champs de l’arbre de syntaxe des expressions régulière qui oblige à écrire toute méthode qui a besoin d’examiner cette structure comme une méthode de la classe Re.

Exercice 6.5   Écrire la méthode matches de la classe Matcher. On suivra la spécification de la classe Matcher de la bibliothèque. À savoir, l’appel matches() teste le filtrage de toute l’entrée par le motif et on peut utiliser group() pour retrouver la chaîne filtrée.
Solution. C’est simple : un appel à Re.matches et on affecte les champs mStart et mEnd selon le résultat.
public boolean matches() {
  if (Re.matches(text, pat, 0, text.length())) {
     mStart = 0 ;
     mEnd = text.length() ;
     return true ;
  } else {
     mStart = mEnd = -1 ;
     return false ;
  }
}

Pour écrire la méthode matches de la classe Re, nous allons distinguer les divers motifs possibles et suivre la définition de pm de la figure 6.3.

// Test  de pat ≼ text[i…j[
static boolean matches(String text, Re pat, int i, int j) {
  switch (pat.tag) {
    ⋮
  }
  throw new Error ("Arbre Re incorrect") ;
}

Notons bien que text[i…j[ est la chaîne dont nous cherchons à savoir si elle est filtrée par pat. La longueur de cette chaîne est j-i. Nous écrivons maintenant le source du traitement des cinq sortes de motifs possibles, c’est à dire la liste des cas du switch ci-dessus. Le cas des motifs vide, des caractères et du joker est rapidement réglé.

    case EMPTY:
      return i == j ;
    case CHAR:
      return i+1 == j && text.charAt(i) == pat.asChar ;
    case WILD:
      return i+1 == j ;

En effet, le motif vide filtre la chaîne vide et elle seule (ji = 0), le motif caractère ne filtre que la chaîne composée de lui même une fois, et le joker filtre toutes les chaînes de longueur un.

Le cas de l’alternative est également assez simple, il suffit d’essayer les deux termes de l’alternative (regles OrLeft et OrRight).

    case OR:
      return matches(text, pat.p1, i, j) || matches(text, pat.p2, i, j) ;

La séquence (rule Seq) demande plus de travail. En effet il faut essayer toutes les décompositions en préfixe et suffixe de la chaîne testée, faute de quoi nous ne pourrions pas renvoyer false avec certitude.

    case SEQ:
      for (int k = i ; k <= j ; k++) {
        if (matches(text, pat.p1, i, k) && matches(text, pat.p2, k, j))
          return true ;
      }
      return false ;z

Et enfin, le cas de la répétition q* est un peu plus subtil, il est d’abord clair (règle StarEmpty) qu’un motif q* filtre toujours la chaîne vide. Si la chaîne text[i…j[ est non-vide alors on cherche à la décomposer en préfixe et suffixe et à appliquer la règle StarSeq.

    case STAR:
      if (i == j) {
        return true ;
      } else {
        for (int k = i+1 ; k <= j ; k++) {
          if (matches(text, pat.p1, i, k) && matches(text, pat, k, j))
            return true ;
        }
        return false ;
      }

On note un point un peu subtil, dans le cas d’une chaîne non-vide, on évite le cas k = j qui correspond à une division de la chaîne testée en préfixe vide et suffixe complet. Si tel n’était pas le cas, la méthode matches pourrait ne pas terminer. En effet, le second appel récursif matches(text, pat, k, j) aurait alors les mêmes arguments que lors de l’appel. Un autre point de vue est de considérer que l’application de la règle StarSeq à ce cas est inutile, dans le sens qu’on ne risque pas de ne pas pouvoir prouver q*m parce que l’on abstient de l’employer.

q ≼ є           q* ≼ m
q* ≼ m

L’inutilité de cette règle est particulièrement flagrante, puisqu’une des prémisses et la conclusion sont identiques.

6.4.4  Emploi de notre package regex

Nos classes Pattern et Matcher sont suffisamment proches de celles de la bibliothèque pour que l’on puisse, dans le source Grep.java (figure 6.5), changer la ligne import java.util.regex.* en import regex.*, ce qui nous donne le nouveau source ReGrep.java. Dès lors, à condition que le source des classes du package regex se trouve dans un sous-répertoire regex, nous pouvons compiler par javac ReGrep.java et nous obtenons un nouveau programme ReGrep qui utilise notre implémentation des expressions régulières à la place de celle de la bibliothèque.

Nous nous livrons ensuite à des expériences en comparant les temps d’exécution (par les commandes time java Grep … et time java ReGrep …).

  1. Dans le dictionnaire français, nous recherchons les mots qui contiennent au moins n fois la même voyelle non accentuée. Par exemple, pour n = 3 nous exécutons la commande :
    % java Grep '(a.*a.*a|e.*e.*e|i.*i.*i|o.*o.*o|u.*u.*u)' /usr/share/dict/french
    
     123456
    Grep2.72.51.91.71.61.5
    ReGrep3.19.716.417.918.618.0
    On voit que notre technique, sans être ridicule, est nettement moins efficace.
  2. Toujours dans le dictionnaire français, nous recherchons les mots qui contiennent n fois la lettre e, après effacement des accents. Par exemple, pour n = 3 nous exécutons la commande :
    % java Grep '(e|é|è|ê).*(e|é|è|ê).*(e|é|è|ê)' /usr/share/dict/french
    
     1234567
    Grep2.92.21.91.71.61.51.5
    ReGrep3.19.012.815.215.816.015.9
    Cette expérience donne des résultats similaires à la précédente. Plus précisément d’une part la bibliothèque est plus rapide en valeur absolue ; et d’autre part, nos temps d’exécution sont croissants, tandis que ceux de la bibliothèque sont décroissants. Mais et c’est important, il semble bien que les temps se stabilisent dans les deux cas.
  3. Nous recherchons une sous-chaîne filtrée par X(.+)+X dans la chaîne XX==, où == est le caractère = répété n fois. Cette reconnaissance doit échouer, mais nous savons [3, Chapitre 5] qu’elle risque de mettre en difficulté l’implémentation de la bibliothèque.
     1618202224
    Grep0.30.51.34.818.6
    ReGrep0.20.20.20.20.2
    Et effectivement l’emploi de la bibliothèque conduit à un temps d’exécution manifestement exponentiel. Il est fascinant de constater que notre implémentation ne conduit pas à cette explosion du temps de calcul.

6.5  Une autre approche du filtrage

6.5.1  Langage dérivé

Soit L un langage sur les mots et c un caractère. Nous notons c−1·L le langage dérivé, défini comme les suffixes m des mots de L qui sont de la forme c·m.

c−1·L ={ m ∣ c·m ∈ L }

Dans le cas d’un langage L régulier engendré par le motif p, nous allons montrer que le langage c−1·L est régulier en calculant le motif c−1·p qui l’engendre.

On observera d’abord que, en toute rigueur, le langage c−1·L peut être vide, par exemple si L = { c′ } avec c′ ≠ c. Or, selon notre définition des langages réguliers, le langage vide ∅ n’est pas régulier. Qu’à cela ne tienne, nous inventons immédiatement un nouveau motif ∅, qui ne filtre aucun mot. En considérant les valeurs [[p]], on étend facilement les trois opérateurs des expressions régulières.

·p = ∅
p·∅ = ∅
|p = p
p|∅ = p
* = є

Ces règles nous permettent d’éliminer les occurrences internes de ∅, de sorte qu’un motif est désormais ∅ ou un motif qui ne contient pas ∅.

Proposition 15   Si L = [[p]], alors le langage c−1·L est régulier, engendré par le motif c−1·p défini ainsi:
c−1·=
c−1·є=
c−1·c=є
c−1·c=pour cc
c−1·(p1|p2)=c−1·p1|c−1·p2
c−1·(p1·p2)=(c−1·p1)·p2si p1 ⊀є
c−1·(p1·p2)=(c−1·p1)·p2|c−1·p2si p1 ≼ є
c−1·p*=(c−1·p)·p*
Preuve. On montre, par induction sur la structure de p, que pour tout mot m, on a l’équivalence :
c−1·p ≼ m  ⇐⇒ p ≼ c·m
Seuls les trois derniers cas de la définition de c−1·p présentent un intérêt.

Par ailleurs, on décide de la validité de p ≼ є par une simple induction structurelle.

Lemme 16   On note N(p) le prédicat p ≼ є. Le prédicat N(p) se calcule inductivement ainsi :
N(∅)=faux
N(є)=vrai
N(c)=faux
N(p1|p2)=N(p1) ∨ N(P2)
N(p1·p2)=N(p1) ∧ N(P2)
N(p*)=vrai
Preuve. Induction facile sur la structure des motifs. □

6.5.2  Filtrage par la dérivation des motifs

Pour savoir si un motif p filtre un mot m, on peut tout simplement itérer sur les caractères du mot, en calculant les dérivations successives de p par les caractères consommés. Un fois atteint la fin du mot, il ne reste qu’à vérifier si le motif dérivé filtre le mot vide.

Plus précisément, les deux résultats de la section précédente (proposition 15 et lemme 16) nous permettent d’écrire deux nouvelles méthodes statiques dans la classe Re.

// Renvoie le motif c−1·p ou null si c−1·p est ∅
static Re derivate(char c, Re p) ;

// Calculer N(p)
static boolean nullable(Re p) ;
Exercice 6.6 (Non corrigé, vu en TP)   Programmer ces deux méthodes. On fera attention à la présence du motif ∅ qui sera représenté par null et ne se trouvera jamais à l’intérieur des arbres Re. C’est-à-dire que le résultat de Re.derivate est null ou un motif standard.

On peut alors écrire par exemple la méthode matches (simplifiée, sans tenir compte de mStart et mEnd) de la classe Matcher ainsi :

public boolean matches() {
  Re d = pat ;
  for (int k = 0 ; k < text.length() ; k++) {
    d = Re.derivate(text.charAt(k), d) ;
    if (d == nullreturn false ;
  }
  return Re.nullable(d) ;
}
Exercice 6.7   Récrire la méthode find de la classe Matcher (figure 6.6) en utilisant cette fois la dérivation de motifs. On s’efforcera de limiter le nombre des dérivations de motif effectuées, tout en identifiant la sous-chaîne filtrée la plus à gauche et la plus longue possible.
Solution. Une solution possible est d’introduire une méthode findLongestPrefix qui cherche le plus long préfixe filtré à partir de la position start. En cas de succès, la méthode renvoie la première position non-filtrée ; en cas d’échec, la méthode renvoie -1.
  private int findLongestPrefix(int start) {
    Re d = pat ;
    int found = -1 ;
    if (Re.nullable(d)) found = start ; // Trouvé le préfixe vide
    for (int k = start ; k < text.length() ; k++) {
      d = Re.derivate(text.charAt(k), d) ;
      if (d == nullreturn found ;
      if (Re.nullable(d)) found=k+1 ; // Trouvé le préfixe de longueur k+1-start
    }
    return found ;
  }
La méthode findLongestPrefix calcule simplement les motifs dérivés par tous les caractères de text à partir de la position start, en se souvenant (dans found) du filtrage le plus long. La recherche s’arrête quand la chaîne est lue en totalité ou quand le motif dérivé est ∅.

Écrire find est ensuite immédiat : chercher un préfixe filtré dans tous les suffixes possibles de text.

  public boolean find() {
    for (int start = regStart ; start <= text.length() ; start++) {
      int end = findLongestPrefix(start) ;
      if (end >= 0) {
        mStart = start ; mEnd = end ;
        regStart = mEnd ;
        return true ;
      }
    }
    mStart = mEnd = -1 ;
    regStart = 0 ;
    return false ;
  }

On observe que, pour une position start donnée, la nouvelle méthode find trouve bien la plus longue sous-chaîne filtrée. Une implantation qui donnerait la plus petite sous-chaîne filtrée est plus simple.

  private int findShortestPrefix(int start) {
    Re d = pat ;
    if (Re.nullable(d)) return start ; // Trouvé le préfixe vide
    for (int k = start ; k < text.length() ; k++) {
      d = Re.derivate(text.charAt(k), d) ;
      if (d == nullreturn -1 ;
      if (Re.nullable(d)) return k+1 ;
    }
    return -1 ; // Aucun préfixe ne convient
  }

La mesure des temps d’exécution de la nouvelle classe Matcher fait apparaître de meilleurs performances dans les deux premières expériences de la section 6.4.4 et une consommation très importante de la mémoire dans la dernière expérience.

Exercice 6.8   Calculer la succession des motifs dérivés pour le motif X(.+)+X et le mot XX== et justifier le résultat de la dernière expérience.
Solution. Notons p0, p1 etc. la suite des motifs dérivés Il faut d’abord dériver deux fois par X.
p0 = X(.+)+X
p1 = (.+)+X
p2 = .*(..*)*X
Pour calculer p2 on a exprimé p+ comme pp*. Si on suit la définition de la dérivation on a en toute rigueur des résultat différents — par exemple, p1 = ()(.+)+X. Mais on enlève les motifs () des séquences, les motifs sont déjà bien assez compliqués comme cela. Soit le motif q = .*(..*)*, on a p2 = qX. Par ailleurs la dérivation =−1·q vaut q|q. Le motif p filtre le mot vide, mais la dérivation de X par = vaut ∅. On a donc :
p3 = (q|q)·X
Et en posant q1 = q et qn+1 = qn|qn il est clair que l’on a :
pn+2 = qn+1·X
Motif dont la taille est exponentielle en n. □

1
On obtient le détail du fonctionnement d’une commande Unix cmd par man cmd.

Previous Up Next