Les aléas du découpage des données.

Par Hal Pomeranz, traduction Philippe Bereski

Découper des données

La tâche courante consistant à découper en enregistrements une donnée pour en extraire certains champs a généré de nombreuses questions dans comp.lang.perl. Le premier réflexe est d'utiliser split(), mais comme toujours, il y a bien d'autres façons de le faire en Perl. De plus split() peut ne pas être toujours le meilleur choix.

Par exemple, split() n'est pas très adapté aux données de taille fixe. Parfois la coupure doit avoir lieu au niveau de caractères particuliers, mais dans ce cas il arrive que les champs eux-mêmes contiennent le séparateur. Enfin, dans le cas du séparateur "blanc", il peut également être utilisé simplement pour aligner les champs. On peut envisager d'utiliser substr(), mais cette fonction ne donne accès qu'à un seul champ à la fois et de toutes façons vous aurez toujours à traiter le cas des caractères blancs superflus. Dans ces cas où les données ont une taille fixe, pensez à utiliser pack(). C'est la fonction la mieux adaptée.

Les données de taille fixe

A titre d'exemple, voici une implémentation de "ls -n" (i.e. "ls -lg" dans laquelle les numéros d'utilisateur et de groupe sont remplacés par leur valeur littérale). Elle utilise pack() et unpack() pour manipuler la sortie d'un ls à la BSD.
     $template = "a14 A9 A9 a*"; 
     open(LS, "ls -lg |") || die "Can't ls!\n"; 
     while (<LS>) 
     { 
          ($first, $uid, $gid, $last) = unpack($template, $_); 	   
               $uids{$uid} = (getpwnam($uid))[2] unless ($uids{$uid});
               $gids{$gid} = (getgrnam($gid))[2] unless ($gids{$gid});
          (getgrnam($gid))[2]unless($gids{$gid}); 	
     print pack($template, $first, $uids{$uid},$gids{$gid},$last); }
Ce code contient un bug subtile. Un cadeau et une surprise à celui qui le trouve.

Le premier argument à unpack() est un modèle décrivant le type et la taille de chaque champ. Les blancs contenus dans ce modèle ne servent qu'à améliorer sa lisibilité, ils sont totalement ignorés par unpack(). Le premier, "a14" indique que le premier champ est composé de 14 caractères ASCII (dans le cas de notre ls, il correspond aux protections du fichier ainsi qu'au nombre de ses liens durs). Il est suivi par 2 chaînes de 9 caractères (le nom de l'utilisateur et du groupe), le "A" majuscule indique que pack() doit supprimer les blancs à la fin des chaînes. Ainsi pourrons nous passer directement ce champ à n'importe laquelle des fonctions get*nam(). Le modèle "a*" indique que le reste de la chaîne doit être renvoyé dans le dernier champ.

Remarquez que nous pouvons utiliser le même modèle si nous souhaitons recréer la ligne par pack(). L'interprétation que Perl fait de "a" et "A" dans pack() a été programmée à cet effet. Le nombre après chaque opérateur dans le modèle, indique la taille du champ : "a" le complète avec des caractères nuls, "A" avec des blancs. "*" utilisé à la place d'un nombre donne au champ la taille de la donnée traitée, ou de ce qu'il reste à traiter.

Les données irrégulières ...

La fonction split() peut également ne pas être le meilleur choix si vos champs sont particulièrement irréguliers. Par exemple, le précèdant artile de cette série a introduit les expressions régulières pour traiter les champs d'enregistrements du type :
     Pomeranz, Hal   (pomeranz) 	    x409
Souvenez vous que les 2 derniers champs sont optionnels, que les séparateurs peuvent être des blancs, des tabulations ou les deux, et que la ligne peut également se terminer par un nombre quelconque de blancs. Le traitement devait en outre supprimer la virgule, le "x" devant le numéro de téléphone et les parenthèses autour de l'adresse E-Mail.

J'aurais pu utiliser split() pour analiser la ligne (l'exemple ci-dessous montre que le premier argument de split est une expression régulière tout ce qu'il y a de plus normale)

     @fields = split(/[\s,()]+/);
Cependant j'aurais quand même eu a me débarasser du "x" dans le numéro de téléphone (il ne peut être traité comme un séparateur car il figure certainement dans un des noms des lignes à traiter). Je peux éliminer les caractères indésirables par :
     s/\s+$//;
De plus, que se serait-il passé si split() ne m'avait retourné que 3 champs, le dernier aurait-il été l'adresse E-mail ou le numéro de téléphone ? Bien sur on peut analyiser ce 3ème champ et savoir s'il correspond a /x\d{3}/, mais il serait tellement plus commode de pouvoir écrire :
     ($last, $first, $email, $ext) = some_expression
et de récupérer $email ou $ext vide si cette information ne figure pas dans la ligne.

... Et la recherche de motifs

L'opérateur de recherche de motifs, lorsqu'il est utilisé dans le contexte d'une liste, retourne une liste dont chaque élément correspond à une des sous-expressions rencontrées. Une sous-expression est définie par l'ensemble des symboles circonscrits entre une parenthèse ouvrante et une parenthèse fermante. Les sous-expressions sont retournées dans l'ordre d'ouverture des parenthèses les décrivant. Soit par exemple l'expression suivante qui extrait l'heure d'une date littérale :
     $_ = "Wed Apr 20 20:39:34 PDT 1994"; 	
     @fields = / ((\d+):(\d+):(\d+)) /;
Il y a 4 sous-expressions, l'une contenant les 3 autres. La parenthèse ouvrante à l'extrême gauche définit l'expression englobant les 3 autres. Ainsi $fields[0] contient "20:39:34" et les 3 autres éléments de la liste contiennent respectivement "20", "39" et "34".

Le comportement de la recherche dans ce contexte de liste en fait un outil de découpage particulièrement souple. Inutile d'ajouter que dans ce cas où le résultat de la recherche est assigné à une liste, Perl n'instancie pas les variables $1,$2,...,$9.

Appliquons ce principe (*) à l'expression introduite dans notre précédent article :

bash$ cat essai.pl
#!/usr/bin/perl

$_="Pomeranz, Hal      (pomeranz)       x409";
($first,$last,$junk1,$email,$ext) = /^(.+),\s*(\w+)\s*(\((\w+)\))?\s*(x(\d+))?\s*$/;
print "\$first=",$first," \$last=",$last," \$email=",$email," \$ext=",$ext,"\n";
print "\$junk1=",$junk1,"\n";
bash$ essai.pl
$first=Pomeranz $last=Hal $email=pomeranz $ext=x409
$junk1=(pomeranz)
bash$ 
Le champ junk1 est nécessaire parce que nous avons dû inclure dans des parenthèses le champ optionnel de l'adresse E-mail. Il y a donc eu génération d'une sous-expression supplémentaire. Larry Wall travaille à l'éviter, mais la solution n'arrivera sans doute pas avant une nouvelle release de Perl5.

On peut éviter ce champ intermédiaire en traitant les () du champ E-Mail comme des caractères optionnels hors du champ E-Mail optionnel. Ceci complexifie cependant quelque peu l'expression :

$bash cat essai1.pl
#!/usr/bin/perl
 
$_="Pomeranz,Hal	 (pomeranz)	 x409";
($first,$last,$email,$ext) = /^(.+),\s*(\w+)\s*\(?(\w+)?\)?\s*(x(\d+))?\s*$/;
print "\$first=",$first," \$last=",$last," \$email=",$email," \$ext=",$ext,"\n";
$bash essai1.pl
$first=Pomeranz $last=Hal $email=pomeranz $ext=x409
bash$
Outre l'avantage de supprimer les variables intermédiaires, cette expression n'utilise que les séparateurs naturels de la ligne qui sont, ",", "(" et ")" sans qu'il soit nécessaire d'avoir des blancs. En revanche elle accepte des expressions mal formées du type :
$_="Pomeranz,Hal	(pomeranz	 x409";

Le cas des apostrophes

Il est tres difficile de découper un enregistrement dont les champs contiennent les délimiteurs. Prenons cet exemple :
     "Pomeranz, Hal", Support, "Saratoga, CA, USA"
où certains champs contiennent des apostrophes et d'autres non. Du fait que les expressions régulières de Perl ne sont pas régulières au sens strictement mathématique du terme, on ne peut pas les utiliser pour résoudre les cas de caractères ouvrant et refermant une expression. Ceci est encore plus difficile lorsque le délimiteur est composé de plusieurs caractères ou lorsqu'ils peuvent être imbriqués. L'expression d'une ligne correspondant aux commentaires du C représente la queste du Saint Grâal dans le monde de comp.lang.perl. En fait trouver cette expression tient de la résolution de la quadrature du cercle.

Un solution simple serait d'utiliser split() pour découper chaque enregistrement, en utilisant le séparateur ad-hoc, puis de reconstruire les champs avec leur séparateur. Bien sur avec cette approche, vous aurez à préserver les délimiteurs. Heureusement, split() facilite cette opération en utilisant une parenthèse comme premier argument de façon à créer des sous-expressions :

     @list = split(/(,\s+)/);
En supposant que la donnée
      "Pomeranz, Hal", Support, "Saratoga, CA, USA"
soit contenue dans la variable $_,, $list[0] contiendra `"Pomeranz', $list[1] ',' etc. Il ne reste plus qu'à rechercher les guillemets dans les champs pour les réassembler.

L'autre approche serait de rechercher un expression qui corresponde à chaque champ :

     @fields = /("[^"]+"|[^,]+), \s+("[^"]+"|[^,]+), \s+("[^"]+"|[^,]+)/;
Cette expression stipule que chaque champ est enclos dans des guillemets, (un guillemet, des caractères différents d'un guillement puis un guillemet), ou séparé par une virgule. Cette approche fonctionne tant qu'il n'y a pas d'imbrication des guillemets. De toutes façons, aussi bien split() que l'expression régulière échoueront avec le cas pathologique suivant :
     This ", would be" nasty
Une solution universelle à ce type de problème nécessite une petite fonction. La distribution de Perl inclut le module shellworks.pl qui contient une fonction analisant des lignes dont les séparateurs sont des blancs et dont certains champs sont enclos dans des guillemets. J'en ai écris une version modifiée, coteworks.pl, qui accepte une expression régulière quelconque pour délimiteur. Vous pouvez obtenir shellworks.pl d'une archive Perl ou en m'envoyant un E-mail.

Conclusion

Le découpage d'enregistrements est une tâche fréquente en Perl. La méthode pour la réaliser doit être choisie avec attention. La fonction split() donne de bons résultats pour les enregistrements dont les champs ne contiennent pas le séparateur (un peu comme le fichier /etc/passwd d'UNIX). Pour les données organisées en champs de taille fixe, utilisez pack() et unpack() ou substr() si vous n'avez qu'un seul champ à extraire. Enfin la recherche de motifs est toujours un bon outil généric pour le découpage des données surtout si elles sont particulièrement irrégulières. Souvenez vous que la gestion des apostrophes et des guillemets est toujours difficile mais que ce problème a déjà été traité. Ne cherchez pas à réinventer la roue.

Traduction de ;login: Vol. 19 No. 3, June 1994


* L'expression originale
($first,$last,$junk1,$email,$junk2,$ext) = /^(.+),\s+(\w+)(\s+(\((\w+)\))?(\s+x(\d+))?\s*$/;
contenait une erreur de syntaxe. En la recherchant, nous avons trouvé une expression plus simple ne nécessitant pas le champ "junk2". Aussi avons nous pris la liberté de changer l'exemple de l'auteur. (ndtr)


Dernière édition: 16 mars 1997 phb
Original 11/26/96ah
Back to the original
Retour à l'index