mardi 20 juillet 2021

Les Expressions Régulières



Les Expressions Régulières

 

Aujourd'hui nous allons aborder un sujet inconnu de la plupart des amateurs : les expressions régulières.

Qu'est ce qu'une expression régulière ? C'est un motif, ou modèle, décrivant une chaîne de caractères.

Le but des expressions régulières est :

  • reconnaître la syntaxe d'une phrase
  • extraire des mots d'une phrase
  • remplacer des mots dans une phrase

1. Exemple

Que peut-on espérer des expressions régulières dans le monde ARDUINO, attendu que l'on a rarement l'occasion d'examiner des phrases ? En fait, une phrase peut très bien provenir d'un capteur, d'un dispositif intelligent, et dans ce cas on appelle cette phrase une trame.

Partons d'un exemple concret. Imaginons que nous ayons à implémenter l'analyse lexicale d'une chaîne de caractères provenant d'un système de mesure de tension et courant qui envoie périodiquement sur une ligne série des trames du genre de celle-ci :

<20/07/2021 17:55:04> 10.31V, 1.32A

Notre intention est d'extraire les valeurs de cette chaîne :

  • date
  • heure
  • tension
  • courant

La première idée qui vient à l'esprit est de rechercher les séparateurs :

  • < et >
  • espace et virgule
Il faut également examiner les caractères pour déterminer leur nature :
  • chiffre
  • point décimal
  • lettre minuscule, majuscule

1.1. strtok()

Voici la manière habituelle de découper une phrase en mots avec strtok() :

    char str[] = "- Voici une phrase avec quelques séparateurs ! -";
    const char * separators = " ,.-!";

    char * strToken = strtok(str, separators);
    while (strToken != NULL) {
        Serial.println(strToken);
        strToken = strtok(NULL, separators);
    }

On peut rapidement constater que la méthode classique qui consiste à utiliser la fonction strtok() n'est pas tout à fait adaptée à notre cas. Son prototype est le suivant :

char *strtok(char *str, const char *delim);

strtok() a besoin de délimiteurs, or au sein des mots 10.31V et 1.32A il n'y a pas de séparateur entre la valeur et l'unité.

Il faudrait donc considérer que 10.31V et 1.32A sont deux valeurs à extraire. Il ne restera plus qu'à utiliser atof() pour récupérer les valeurs 10.31 et 1.32 :

char phrase[] = "<20/07/2021 17:55:04> 10.31V, 1.32A";

void setup() {
  Serial.begin(115200);
  int day, month, year, hour, minute, second;
  char voltage[10], current[10];
  float volt, amp;
 
  char *p = strtok(phrase, "</");
  if (p == NULL) {
    Serial.println("manque </");
    return;
  }
  day = atoi(p);
  p = strtok(NULL, "/");
  if (p == NULL) {
    Serial.println("manque /");
    return;
  }
  month = atoi(p);
  p = strtok(NULL, "/ ");
  if (p == NULL) {
    Serial.println("manque / et espace");
    return;
  }
  year = atoi(p);
  p = strtok(NULL, ":");
  if (p == NULL) {
    Serial.println("manque :");
    return;
  }
  hour = atoi(p);
  p = strtok(NULL, ":");
  if (p == NULL) {
    Serial.println("manque :");
    return;
  }
  minute = atoi(p);
  p = strtok(NULL, ":>");
  if (p == NULL) {
    Serial.println("manque :>");
    return;
  }
  second = atoi(p);
  p = strtok(NULL, " ,");
  if (p == NULL) {
    Serial.println("manque virgule");
    return;
  }
  strcpy(voltage, p);
  voltage[strlen(voltage)-1] = '\0';
  volt = atof(voltage);
  p = strtok(NULL, " ");
  if (p == NULL) {
    Serial.println("manque espace");
    return;
  }
  strcpy(current, p);
  current[strlen(voltage)-1] = '\0';
  amp = atof(current);

  Serial.println(day);
  Serial.println(month);
  Serial.println(year);
  Serial.println(hour);
  Serial.println(minute);
  Serial.println(second);
  Serial.println(volt);
  Serial.println(amp);
}

void loop() {
}

Voici le résultat :

20
07
2021
17
55
04
10.31
1.32 

On remarque que la syntaxe de la phrase est vérifiée. Si elle n'est pas exactement celle prévue, rien ne sera affiché.

1.2. sscanf()

Il serait possible d'utiliser sscanf() :

char phrase[] = "<20/07/2021 17:55:04> 10.31V, 1.32A";

void setup() {
  Serial.begin(115200);
  int day, month, year, hour, minute, second;
  char voltage[10], current[10];
  float volt, amp;
  int n = sscanf(phrase, "<%d/%d/%d %d:%d:%d> %s %s>", &day, &month, &year, &hour, &minute, &second, voltage, current);
  if (n == 8) {
    volt = atof(voltage);
    amp = atof(current);
    Serial.println(day);
    Serial.println(month);
    Serial.println(year);
    Serial.println(hour);
    Serial.println(minute);
    Serial.println(second);
    Serial.println(volt);
    Serial.println(amp);
  }
}

void loop() {
}

Voici le résultat :

20
7
2021
17
55
4
10.31
1.32

sscanf() est incapable d'interpréter de manière simple certains séparateurs, par exemple la virgule. Par contre le code est un peu plus concis.

sscanf() est également capable de travailler sur des jeux de caractères : %[set].

char phrase[] = "<20/07/2021 17:55:04> 10.31V, 1.32A";

void setup() {
  Serial.begin(115200);
  char day[3], month[3], year[5], hour[3], minute[3], second[3];
  char voltage[10], current[10];
  float volt, amp;
  int n = sscanf(phrase, "<%[0-9]/%[0-9]/%[0-9] %[0-9]:%[0-9]:%[0-9]> %[0-9.]V, %[0-9.]A>", day, month, year, hour, minute, second, voltage, current);
  Serial.println(n);
  if (n == 8) {
    volt = atof(voltage);
    amp = atof(current);
    Serial.println(day);
    Serial.println(month);
    Serial.println(year);
    Serial.println(hour);
    Serial.println(minute);
    Serial.println(second);
    Serial.println(volt);
    Serial.println(amp);
  }
}

void loop() {
}

Dans cet exemple :

  • %[0-9] représente un nombre entier composé de N chiffres de 0 à 9
  • %[0-9.] représente un nombre flottant composé de N chiffres de 0 à 9 avec éventuellement un point

Il y a un inconvénient : les données extraites (day, month, year, hour, minute, second) sont rangées dans des chaînes de caractères. Si l'on a besoin de leur valeur numérique il faudra les convertir avec atoi().

Dans les deux cas, la valeur retournée de sscanf() est comparée à 8, le nombre de mots à extraire. Si la syntaxe n'est pas celle prévue, rien ne sera affiché.

1.3. Analyseur sur mesure

Pour analyser cette chaîne, classiquement, nous ferions les opérations suivantes en déplaçant un pointeur ou un index dans la chaîne :

char phrase[] = "<20/07/2021 17:55:04> 10.31V, 1.32A";

void setup() {
  Serial.begin(115200);
  int day, month, year, hour, minute, second;
  float voltage, current;
  char *p = phrase;
  if (*p == '<') {
    p++;
    day = atoi(p);
    while (isdigit(*p)) p++;
    if (*p == '/') {
      p++;
      month = atoi(p);
      while (isdigit(*p)) p++;
      if (*p == '/') {
        p++;
        year = atoi(p);
        while (isdigit(*p)) p++;
        if (*p == ' ') {
          p++;
          hour = atoi(p);
          while (isdigit(*p)) p++;
          if (*p == ':') {
            p++;
            minute = atoi(p);
            while (isdigit(*p)) p++;
            if (*p == ':') {
              p++;
              second = atoi(p);
              while (isdigit(*p)) p++;
              if (*p == '>') {
                p++;
                if (*p == ' ') {
                  p++;
                  voltage = atof(p);
                  while (isdigit(*p) || *p == '.') p++;
                  if (*p == 'V') {
                    p++;
                    if (*p == ',') {
                      p++;
                      if (*p == ' ') {
                        p++;
                        current = atof(p);
                        while (isdigit(*p)) p++;

                        Serial.println(day);
                        Serial.println(month);
                        Serial.println(year);
                        Serial.println(hour);
                        Serial.println(minute);
                        Serial.println(second);
                        Serial.println(voltage);
                        Serial.println(current);
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

void loop() {
}

Voici le résultat :

20
07
2021
17
55
04
10.31
1.32 

Cette fois-ci nous obtenons un contrôle parfait sur chaque caractère, mais quelle longueur de code !

On remarque aussi que la syntaxe de la phrase est vérifiée. Si elle n'est pas exactement celle prévue, rien ne sera affiché.

1.4. Expression régulière

N' y a t'il pas plus simple ?

Si, bien sûr. En utilisant une analyse lexicale on pourrait faire cette opération en une seule fois.

Une description de cette chaîne pourrait être celle-ci :

<chiffres,/,chiffres,/,chiffres,espace,chiffres,:,chiffres,:,chiffres>

chiffres&point,V,virgule,espace,chiffres&point,A

Ensuite il faudrait donner à manger cette description et la phrase à analyser à un bout de logiciel qui nous dirait si la phrase correspond à la description, et si oui, extraire les données.

2. La librairie

La librairie de Nick Gammon (un personnage connu), installable depuis le gestionnaire de bibliothèques de l'IDE ARDUINO, est celle-ci :

https://github.com/nickgammon/Regexp

C'est une librairie qui se rapproche fortement de celles que l'on a l'habitude d'utiliser sur PC, en C, C++, PYTHON, ou d'autres langages.

2.1. Les exemples

Les exemples de la librairie sont assez pauvres. A part extraire tous les mots d'une phrase comme "The quick brown fox jumps over the lazy wolf", ils ne sont pas d'une aide suffisante. Cela justifie cet article.

2.2. Allons un peu plus loin

2.2.1. Présentation

Ce qui suit est une explication de cette page :

http://www.gammon.com.au/scripts/doc.php?lua=string.find 

Tout d'abord présentons les caractères spéciaux utilisés, ceux qui ont une signification particulière dans la description de la grammaire :

  • ^ : le début de la phrase
  • $ : la fin de la phrase
  • ( ) : encadre une capture (une extraction de données)
  •  % : introduit une classe de caractères
    • %a : des lettres
    • %d : des chiffres
  • . : représente n'importe quel caractère
  • [ ] : encadre un jeu de caractères
    • [a-z] : une minuscule
    • [A-Z] : une majuscule
    • [0-9] : un chiffre
  • * : exprime une répétition
    • [a-z]* : plusieurs minuscules (y compris aucune)
    • [A-Z]* : plusieurs majuscules (y compris aucune) 
    • [0-9]* : plusieurs chiffres (y compris aucun)
  • + : exprime une répétition
    • [a-z]+ : plusieurs minuscules (au moins une)
    • [A-Z]+ : plusieurs majuscules (au moins une)
    • [0-9]+ : plusieurs chiffres (au moins un) 
  • - : exprime une répétition, comme + mais en extrayant le moins possible de caractères
  • ? : exprime une répétition (zéro ou une)

Le caractère % est utilisé également pour échapper un caractère spécial

  • . : représente n'importe quel caractère
  • %. : représente un point
  • %( : représente une parenthèse ouvrante

Comment pourrions-nous décrire notre phrase précédente ?

<20/07/2021 17:55:04> 10.31V, 1.32A

Comme ceci :

<([0-9]+/[0-9]+/[0-9]+) ([0-9]+:[0-9]+:[0-9]+)> ([0-9%.]+)V, ([0-9%.]+)A

Examinons chaque élément de cette description :

  • < : nous attendons un caractère <
  • ([0-9]+/[0-9]+/[0-9]+) : nous voulons extraire un mot composé de 3 groupes de plusieurs nombres séparés par un slash (/)
  • ([0-9]+:[0-9]+:[0-9]+) : nous voulons extraire un mot composé de 3 groupes de plusieurs nombres séparés par deux points (:)
  • > : nous attendons un caractère >
  • nous attendons un espace
  • ([0-9%.]+) : nous voulons extraire un mot composé de plusieurs chiffres et éventuellement d'un point, donc un nombre flottant
  • V : nous attendons une lettre V
  • , : nous attendons une virgule suivie d'un espace
  • ([0-9%.]+) : nous voulons extraire un mot composé de plusieurs chiffres et éventuellement d'un point, donc un nombre flottant
  • A : nous attendons une lettre A

2.2.2. Le code exemple

Le code présenté est inspiré de celui-ci :

https://github.com/nickgammon/Regexp/blob/master/examples/GlobalMatch/GlobalMatch.pde

Le voici :

#include <Regexp.h>

char phrase[] = "<20/07/2021 17:55:04> 10.31V, 1.32A";
char regexp[] = "<([0-9]+/[0-9]+/[0-9]+) ([0-9]+:[0-9]+:[0-9]+)> ([0-9%.]+)V, ([0-9%.]+)A";

void match_callback  (const char * match, const unsigned int length, const MatchState & ms)
{
  char cap [10];   // must be large enough to hold captures

  Serial.print ("Matched: ");
  Serial.write ((byte *) match, length);
  Serial.println ();
  for (byte i = 0; i < ms.level; i++) {
    Serial.print ("Capture");
    Serial.print (i);
    Serial.print ("=");
    ms.GetCapture (cap, i);
    Serial.println (cap);
  }
}

void setup() {
  MatchState ms (phrase);
  unsigned long count;

  Serial.begin(115200);
  count = ms.GlobalMatch(regexp, match_callback);
  Serial.print("Found ");
  Serial.print(count);
  Serial.println(" matches.");
}

void loop() {
}

La variable phrase représente la phrase à analyser. La variable regexp représente l'expression régulière.

Exécutons ce code :

Matched: <20/07/2021 17:55:04> 10.31V, 1.32A
Capture0=20/07/2021
Capture1=17:55:04
Capture2=10.31
Capture3=1.32
Found 1 matches.

1 correspondance a été trouvée (Found 1 matches), et 4 captures ont été réalisées :

  • la date
  • l'heure
  • la tension
  • le courant

La date est l'heure sont extraites en deux mots. Si l'on a besoin d'extraire le jour, le mois, l'année, l'heure, les minutes et les secondes séparément, il suffit d'encadrer chaque élément [0-9]+ par des parenthèses :

char regexp[] = "<([0-9]+)/([0-9]+)/([0-9]+) ([0-9]+):([0-9]+):([0-9]+)> ([0-9%.]+)V, ([0-9%.]+)A";

Le résultat sera :

Matched: <20/07/2021 17:55:04> 10.31V, 1.32A
Capture0=20
Capture1=07
Capture2=2021
Capture3=17
Capture4=55
Capture5=04
Capture6=10.31
Capture7=1.32
Found 1 matches. 

On pourrait corser la chose. Si le système de mesure est capable de travailler avec des unités autres que des Volts ou des Ampères, des millivolts ou de milliampères par exemple :

char phrase[] = "<20/07/2021 17:55:04> 10.31mV, 1.32mA";
char regexp[] = "<([0-9]+/[0-9]+/[0-9]+) ([0-9]+:[0-9]+:[0-9]+)> ([0-9%.]+)([a-zA-Z]+), ([0-9%.]+)([a-zA-Z]+)";

A la place de A ou V nous attendons plusieurs lettres, minuscule ou majuscule, et les parenthèses permettent de les extraire (capture) :

Matched: <20/07/2021 17:55:04> 10.31mV, 1.32mA
Capture0=20/07/2021
Capture1=17:55:04
Capture2=10.31
Capture3=mV
Capture4=1.32
Capture5=mA
Found 1 matches.

Notre résultat pourra facilement être exploité car nous avons à disposition toutes les informations nécessaires, mesures et unités.

La grammaire peut être rendue plus tolérante. dans l'exemple qui suit, les caractères < et > peuvent être remplacés par [ et ] :

char phrase[] = "[20/07/2021 17:55:04] 10.31mV, 1.32mA";
char regexp[] = "[<%[]([0-9]+/[0-9]+/[0-9]+) ([0-9]+:[0-9]+:[0-9]+)[>%]] ([0-9%.]+)([a-zA-Z]+), ([0-9%.]+)([a-zA-Z]+)";

explication de [<%[] et [>%]] :

On voit que les caractères [ et ] sont échapés à l'aide du caractère %, sinon ils seraient pris pour des caractères spéciaux.

Avec cette description, la date et l'heure peuvent être encadrées soit par < et > soit par [ et ].

Donc ces deux phrases donneront le même résultat :

char phrase[] = "<20/07/2021 17:55:04> 10.31mV, 1.32mA";
char phrase[] = "[20/07/2021 17:55:04] 10.31mV, 1.32mA";

Matched: [20/07/2021 17:55:04] 10.31mV, 1.32mA
Capture0=20/07/2021
Capture1=17:55:04
Capture2=10.31
Capture3=mV
Capture4=1.32
Capture5=mA
Found 1 matches.

Si l'information <> ou [] a une signification, et qu'elle doit être prise en compte il suffit d'encadrer par des parenthèses :

char regexp[] = "([<%[])([0-9]+/[0-9]+/[0-9]+) ([0-9]+:[0-9]+:[0-9]+)([>%]]) ([0-9%.]+)([a-zA-Z]+), ([0-9%.]+)([a-zA-Z]+)";

Matched: [20/07/2021 17:55:04] 10.31mV, 1.32mA
Capture0=[
Capture1=20/07/2021
Capture2=17:55:04
Capture3=]
Capture4=10.31
Capture5=mV
Capture6=1.32
Capture7=mA
Found 1 matches.

Un dernier mot à propos des caractères spéciaux :

  • %a : représente une lettre, minuscule ou majuscule (équivalent à [a-zA-Z])
  • %d : représente un chiffre (équivalent à [0-9])
  • %l : représente une lettre minuscule (équivalent à [a-z])
  • %u : représente une lettre majuscule (équivalent à [A-Z])
  • %w : représente une lettre ou un chiffre (équivalent à [a-zA-Z0-9])
  • etc.

La liste complète ici : http://www.gammon.com.au/scripts/doc.php?lua=string.find 

Notre dernière expression peut donc être réduite :

char regexp[] = "([<%[])([0-9]+/[0-9]+/[0-9]+) ([0-9]+:[0-9]+:[0-9]+)([>%]]) ([0-9%.]+)([a-zA-Z]+), ([0-9%.]+)([a-zA-Z]+)";

Est équivalent à :

char regexp[] = "([<%[])([%d]+\/[%d]+\/[%d]+) ([%d]+:[%d]+:[%d]+)([>%]]) ([%d%.]+)([%a]+), ([%d%.]+)([%a]+)";

Elle donnera exactement le même résultat.

2.2.3. Mise au point

Si la phrase à analyser est longue et complexe, il est difficile d'écrire l'expression régulière d'une seule traite sans se tromper. On peut le faire étape par étape. On peut commencer par la date seule :

char phrase[] = "<20/07/2021 17:55:04> 10.31mV, 1.32mA";

char regexp[] = "([<%[])([0-9]+/[0-9]+/[0-9]+)";

Matched: [20/07/2021
Capture0=[
Capture1=20/07/2021
Found 1 matches.

Ensuite, si tout va bien, ajoutons l'heure :

char regexp[] = "([<%[])([0-9]+/[0-9]+/[0-9]+) ([0-9]+:[0-9]+:[0-9]+)([>%]])";

Matched: [20/07/2021 17:55:04]
Capture0=[
Capture1=20/07/2021
Capture2=17:55:04
Capture3=]
Found 1 matches.

Et ainsi de suite ...

2.2.4. Mise au point en ligne

Des sites WEB permettent de tester une expression régulière, par exemple regex101.com.

Mais il faut savoir que les expressions régulières traditionnelles sont légèrement différentes des expressions régulières de Nick Gammon :

  • les caractères spéciaux sont plus nombreux (slash / en est un)
  • le caractère spécial % est remplacé par backslash : \

Pour notre exemple j'ai simplement remplacé :

  • %[ par \[ et %] par \]
  • %. par \.
  • / par \/

La version de test sur regex101 devient :

([<\[])([0-9]+\/[0-9]+\/[0-9]+) ([0-9]+:[0-9]+:[0-9]+)([>\]]) ([0-9\.]+)([a-zA-Z]+), ([0-9\.]+)([a-zA-Z]+)

Dans la fenêtre en bas à droite, on constate que la phrase est comprise et que les différents éléments sont extraits.

Après avoir testé la version WEB, Dans notre skecth il faut copier / coller l'expression, et faire les remplacements inverses :

char regexp[] = "([<%[])([0-9]+\/[0-9]+\/[0-9]+) ([0-9]+:[0-9]+:[0-9]+)([>%]]) ([0-9%.]+)([a-zA-Z]+), ([0-9%.]+)([a-zA-Z]+)";

Ce n'est pas une opération énorme, et avec un peu d'habitude on y parvient facilement.

2.2.5. Test avec un terminal

Si l'on désire faire des tests sur ARDUINO avec différentes phrases entrées dans un terminal série ou le moniteur serie, il suffit d'adapter un peu. Voici le premier exemple modifié :

#include <Regexp.h>

// enter a string like : <20/07/2021 17:55:04> 10.31V, 1.32A
char phrase[40];
char regexp[] = "<([0-9]+)/([0-9]+)/([0-9]+) ([0-9]+):([0-9]+):([0-9]+)> ([0-9%.]+)V, ([0-9%.]+)A";

void match_callback  (const char * match, const unsigned int length, const MatchState & ms)
{
  char cap [10];   // must be large enough to hold captures

  Serial.print ("Matched: ");
  Serial.write ((byte *) match, length);
  Serial.println ();
  for (byte i = 0; i < ms.level; i++) {
    Serial.print ("Capture");
    Serial.print (i);
    Serial.print ("=");
    ms.GetCapture (cap, i);
    Serial.println (cap);
  }
}

void setup() {
  Serial.begin(115200);
}

void loop() {
  if (Serial.available()) {
    Serial.readBytesUntil('\n', phrase, 40);
    MatchState ms (phrase);
    unsigned long count;

    count = ms.GlobalMatch(regexp, match_callback);
    Serial.print("Found ");
    Serial.print(count);
    Serial.println(" matches.");
  }
}

3. Conclusion

En matière de développement informatique on arrive rarement à réaliser de grandes chose en restant cantonné dans son petit code personnel et ses petites habitudes, sans apprendre des autres.

L'utilisation des librairies ARDUINO simplifient grandement la tâche d'écriture de pilotes matériels lorsque l'on doit exploiter des composants et des modules.

Il en va de même pour le code pur. Plutôt que de s'enfermer dans une solution personnelle, ce qui revient souvent à s'engager dans une voie sans issue lorsque la complexité est importante, il est souvent préférable d'utiliser le travail des autres, surtout lorsque ce travail existe depuis des dizaines d'années.

C'est le cas pour les expressions régulières : elles sont nées en 1940.

Les expressions régulières peuvent sembler rébarbatives, mais le gain qu'elles apportent en terme de temps de développement est inestimable par rapport au temps passé en apprentissage.

En deux mots : la curiosité est un excellent défaut. Soyez curieux !


Cordialement

Henri



Aucun commentaire:

Enregistrer un commentaire