La programmation fonctionnelle en Perl

Partie 2 : les fonctions d'ordre supérieur

Ce document est le deuxième d'une série de tutoriels visant à montrer comment utiliser certaines techniques de la programmation fonctionnelle en Perl et acquérir ainsi une bien meilleure expressivité. Cette deuxième partie aborde en particulier les fonctions d'ordre supérieur (fonctions de rappel, fermetures, itérateurs, etc.) permettant de résoudre simplement et élégamment des problèmes assez complexes. Il n'est pas indispensable d'avoir lu la première partie de ce tutoriel pour lire cette deuxième partie.

Article lu   fois.

L'auteur

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

1. Introduction

Ce tutoriel n'est pas destiné à enseigner la programmation Perl à des débutants, mais à introduire des techniques de programmation Perl relativement avancées qui supposent une assez bonne connaissance et une certaine expérience de la syntaxe de base de Perl. Les fonctionnalités un peu avancées font l'objet de rappels de base permettant la compréhension des techniques employées, mais ne dispensent pas de consulter la documentation officielle. Cette seconde partie nécessite en particulier de connaître et comprendre au moins un peu les références Perl (même si elles font l'objet d'un bref rappel).

Comme je le disais déjà dans l'introduction de la première partie de ce tutoriel, Mark-Jason Dominus remarque dans la préface de son excellent livreImage non disponibleHigher Order Perl (HOP)Higher Order Perl (HOP) que les développeurs Perl tendent à écrire du Perl comme si c'était du C. C'est une honte, ajoute-t-il, car Perl est bien plus expressif que le C. Nous pourrions faire bien mieux et utiliser Perl d'une façon dont les programmeurs C n'oseraient même pas rêver, mais nous ne le faisons pas la plupart du temps. J'étais personnellement dans une large mesure dans ce cas jusqu'à ce que la lecture du livre de Dominus m'ouvre les yeux sur des techniques utilisables en Perl dont, pour certaines au moins, je ne soupçonnais même pas ou à peine l'existence. Cette lecture a tout simplement changé ma façon de concevoir la programmation.

Perl possède de nombreux concepts et paradigmes avancés empruntés à la programmation fonctionnelle (notamment au langage Lisp) permettant bien souvent d'écrire facilement des programmes bien plus courts, bien plus expressifs, moins bogués et souvent plus lisibles que leurs équivalents en programmation procédurale ou orientée objet. En fait, Perl est bien plus proche du Lisp que du C (même si la syntaxe de base du Perl ressemble évidemment plus au C). À peu près n'importe quel livre sur le Lisp a un chapitre ou passage présentant les avantages du Lisp (sur d'autres langages). Par exemple, dans son livre Paradigms of Artificial Intelligence, Peter Norvig a une section intitulée « Qu'est-ce qui rend Lisp différent ? », qui énumère sept caractéristiques de Lisp rendant ce langage à ses yeux supérieur à d'autres. Sur ces sept caractéristiques, Perl en possède six, C aucune. Ce sont des caractéristiques capitales, comme les fonctions d'ordre supérieur, l'accès dynamique à la table de symboles, etc. C'est dans ce sens que Perl est plus proche du Lisp que du C.

Je conseillerais au lecteur connaissant l'anglais de lire le livre Higher Order Perl (il est disponible gratuitement au format PDF sur le site de l'auteur, ce qui permet de se faire une bonne idée de son contenu, je conseillerais cependant à quiconque désirant approfondir ces notions d'acquérir une version papier, c'est en tous cas ce que j'ai fait, et je ne le regrette vraiment pas), mais comme il n'a pas été traduit, qu'il n'est pas d'un abord très facile et qu'il existe très peu de textes en français sur le sujet, je désire dans ce tutoriel introduire quelques-uns de ces concepts qui peuvent améliorer considérablement votre productivité. Beaucoup des exemples que je donne ne sont pas des exemples que j'ai créés pour illustrer la programmation fonctionnelle en Perl, mais sont des versions simplifiées de programmes de la vie professionnelle réelle dans lesquels j'ai trouvé plus simple et plus efficace d'utiliser ces techniques, et y ai gagné beaucoup.

La première partie de ce tutoriel abordait les techniques d'utilisation des opérateurs de listes, qui ne sont pas à proprement parler des concepts de la programmation fonctionnelle, mais sont néanmoins dans une large mesure héritées des langages fonctionnels tels que Lisp. Cette seconde partie aborde réellement les concepts de la programmation fonctionnelle en Perl.

Je précise que, même si j'ai utilisé Caml pendant mes études et je me suis intéressé à titre personnel et à des degrés divers aux bases de langages tels que Common Lisp, Scheme, OCaml et Haskell, je n'ai jamais réellement pratiqué ces langages pour le développement d'applications réelles et ne suis donc nullement un spécialiste de la programmation fonctionnelle, et encore moins de ses aspects théoriques (vous pouvez donc vous rassurer, je ne parlerai pas des finesses du lambda-calcul). Mon but est seulement d'illustrer comment utiliser certains concepts puissants de la programmation fonctionnelle en Perl.

Perl n'est pas à la base un langage fonctionnel, ses fonctions ne sont pas nécessairement pures (même s'il n'y a aucune difficulté à écrire des fonctions pures), de même qu'il n'est pas non plus à la base un langage orienté objet. Mais il permet d'utiliser de nombreuses notions empruntées aux langages fonctionnels, tout comme il permet aussi de faire de la programmation orientée objet. Le point essentiel est que c'est vous qui décidez du modèle de programmation que vous désirez choisir, ou même d'une éventuelle combinaison de différents modèles de programmation.

Lisp vous « impose » un modèle fonctionnel, Haskell un modèle fonctionnel pur, C un modèle impératif pur, C++ un modèle impératif ou plutôt objet, Java, Python ou Ruby un modèle purement objet (bon, je sais, je simplifie un peu , mais l'idée est là). Perl vous permet de choisir le modèle qui vous convient.

Ce document est un simple tutoriel, le seul but est de montrer comment l'application de certains paradigmes hérités de la programmation fonctionnelle (en particulier de Lisp et ses dialectes plus récents) permet d'obtenir une bien meilleure expressivité dans des programmes Perl.

2. Quelques préliminaires : les références

2-1. Très bref rappel sur les références

Les références furent la principale nouveauté de Perl 5 par rapport à Perl 4. Comme Perl 5 est sorti en 1994, on ne peut pas vraiment dire que ce soit une nouveauté. Les références, avec tout ce qu'elles permettent, ont transformé ce qui était essentiellement un langage de script en un vrai langage de programmation particulièrement riche.

Pour donner une première idée approximative, une référence est une variable scalaire qui contient non pas une donnée ou un groupe de données comme les autres variables, mais l'adresse mémoire de la donnée ou du groupe de données. Pour les programmeurs habitués au langage C, c'est un peu l'équivalent d'un pointeur (mais ça se manipule de façon différente, en particulier Perl n'a pas d'équivalent de l'« arithmétique des pointeurs » qui existe en C).

2-1-1. Les références sur des variables scalaires

Voici un petit programme rappelant très brièvement quelques propriétés fondamentales des références (j'explique ce qui se passe, mais n'entrerai pas dans les détails de la syntaxe) :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
#!/usr/bin/perl
use strict;
use warnings;
sub pri {print @_, "\n"}; # simule la fonction say qui existe à partir de Perl 5.10
my $a = 5;
say $a;         # imprime 5
my $a_ref = \$a;  # \ prend une référence. $a_ref est une référence vers $a
pri $a_ref;     # imprime: SCALAR(0x80359e18), l'adresse mémoire de $a
pri $$a_ref;    # imprime la valeur référencée par $a_ref, soit 5
$$a_ref ++;     # incrémente  la valeur référencée par $a_ref, soit 6
pri  $$a_ref;   # imprime 6
pri $a;         # imprime 6: le contenu de la variable $a a été modifié
                # par l'incrémentation de $$a_ref.

On voit que $a_ref contient l'adresse mémoire de la variable $a, permet d'accéder au contenu de $a et même de le modifier.

2-1-2. Les références sur des variables de tableaux

La suite du programme prend une référence sur une variable de tableau (ce pourrait aussi être un hachage) :

 
Sélectionnez
13.
14.
15.
16.
17.
18.
19.
20.
21.
my @c = qw / 5 6 7/;
pri "@c";        # imprime 5 6 7
my $c_ref= \@c;  # $c_ref est une référence vers le tableau @c
pri $c_ref;      # imprime ARRAY(0x80355bb0), l'adresse mémoire de @c
pri "@$c_ref";   # imprime le contenu du tableau référencé par $c_ref,
                 # soit 5, 6, 7
$$c_ref[1] = 18; # modifie la 2e valeur du tableau référencé par $c_ref
pri "@$c_ref";   # imprime 5 18 7
pri "@c";        # le tableau @c a bien été modifié, imprime 5 18 7

On voit que la variable $c_ref est une référence, donc une variable scalaire (variable simple), mais qu'elle permet cependant d'accéder aux valeurs du tableau @c. Ce point est essentiel, car il est à l'origine de toute la puissance des références : ce sont de simples variables scalaires, mais elles permettent de se référer à d'autres variables simples, à des tableaux, à des hachages et à toutes sortes d'objets plus compliqués.

Si l'on désire passer un tableau en paramètre à une fonction, on peut passer l'ensemble du tableau lui-même, ou passer une référence à ce tableau. La syntaxe d'accès aux éléments du tableau dans la fonction sera légèrement différente, mais toutes les données du tableau seront accessibles. On peut penser que, si le tableau est très grand, cette seconde solution a un gros avantage sur le plan des performances (durée d'exécution et occupation mémoire) : elle évite de recopier tous les éléments du tableau. En fait, c'est certes vrai, mais seulement dans une mesure relativement limitée, car le tableau @_ des arguments passés à la fonction contient en fait des alias de ces arguments (et non réellement une copie), et prendre ces alias est nettement moins coûteux que de copier le tableau (voir l'encart ci-contre).

Passage d'arguments par valeur, par alias ou par référence : un benchmark

Cette question est un peu hors sujet (et peut être laissée de côté en première lecture), mais il est cependant intéressant de comparer les performances des trois méthodes de passage d'arguments à une fonction. Utilisons à cette fin la fonction timethese du module Perl Benchmark (livré en standard avec Perl). Cette fonctionnalité permet de lancer plusieurs versions d'une fonction un nombre élevé de fois et de mesurer la durée moyenne d'exécution. Notre fonction reçoit un tableau de 100 000 entiers en paramètre et imprime l'élément d'indice 10 000 si celui-ci est égal à 17 (ce qui ne peut pas arriver, mais cela permet d'éviter le risque que le compilateur n'optimise la fonction en supprimant toute copie des arguments s'il se rend compte que la fonction ne fait rien avec le tableau ; le compilateur ne peut connaître la valeur de l'élément d'indice 10 000 puisque cet élément n'est fixé qu'à l'exécution). Voici le code du benchmark des trois versions de la fonction :

Benchmark du passage d'arguments à une fonction
Sélectionnez
use strict;
use warnings;
use Benchmark;

my @tableau = 0..100000;

sub valeur { my @array = @_; my $c = $array[10000]; print $c if $c == 17; }
sub alias {my $c = $_[10000]; print $c if $c == 17; };
sub reference { my $a_ref = shift; my $c = $a_ref->[10000]; print $c if $c == 17;  }

timethese ( 10000, {
                "valeur"         => sub { valeur(@tableau); },
                "alias"         => sub { alias(@tableau) },
                "reference"      => sub { reference(\@tableau) }
} );

Dans un premier temps, le benchmark exécute chaque fonction 10 000 fois. Voici les résultats :

Benchmark, 10000 itérations
Sélectionnez
$ perl  pass_array_bench.pl
Benchmark: timing 10000 iterations of alias, reference, valeur...
     alias:  3 wallclock secs ( 2.92 usr +  0.00 sys =  2.92 CPU) @ 3428.18/s (n=10000)
 reference:  0 wallclock secs ( 0.00 usr +  0.00 sys =  0.00 CPU)
            (warning: too few iterations for a reliable count)
    valeur: 52 wallclock secs (51.32 usr +  0.00 sys = 51.32 CPU) @ 194.84/s (n=10000)

Nous obtenons : 0 seconde pour le passage par référence, 3 secondes pour le passage par alias et 52 secondes pour le passage par valeur. Le passage par alias est environ 18 fois plus rapide que le passage par valeur. Nous pouvons voir que le passage par référence est très largement plus rapide, mais ne pouvons dire dans quelle mesure puisque sa durée d'exécution est trop faible pour être mesurée. Cela dit, 3 secondes pour passer 10 000 fois un tableau de 100 000 éléments (soit au total un milliard de paramètres passés en argument), c'est rarement vraiment pénalisant en situation réelle.

Essayons avec 100 000 itérations :

Benchmarck, 100000 itérations
Sélectionnez
Benchmark: timing 100000 iterations of alias, reference, valeur...
     alias: 27 wallclock secs (28.84 usr +  0.00 sys = 28.84 CPU) @ 3466.93/s (n=100000)
 reference:  1 wallclock secs ( 0.06 usr +  0.00 sys =  0.06 CPU) @ 1612903.23/s (n=100000)
            (warning: too few iterations for a reliable count)
    valeur: 556 wallclock secs (556.52 usr +  0.02 sys = 556.53 CPU) @ 179.68/s (n=100000)

Là, on voit que le passage par référence est environ 466 fois plus rapide que le passage par alias, mais nous avons toujours un warning que le nombre d'itérations est trop faible pour que la mesure soit réellement fiable.

Passons donc à 1 000 000 itérations, mais éliminons cette fois le passage par valeur pour ne pas plomber le test par des durées excessives.

Benchmark, 1000000 itérations
Sélectionnez
$ perl  pass_array_bench.pl
Benchmark: timing 1000000 iterations of alias, reference...
     alias: 289 wallclock secs (288.74 usr +  0.00 sys = 288.74 CPU) @ 3463.29/s (n=1000000)
 reference:  1 wallclock secs ( 0.58 usr +  0.00 sys =  0.58 CPU) @ 1733102.25/s (n=1000000)

Il n'y a plus de warning et le passage par référence est 497 fois plus rapide.

Presque 500 fois plus rapide, c'est certes énorme, mais nous avons totalisé 100 milliards de paramètres passés (vraiment ou virtuellement) à la fonction, c'est énorme. Dans l'immense majorité des cas courants, la question ne se pose sans doute pas vraiment.

Si l'on passe deux tableaux à une fonction, ces deux tableaux sont en fait fusionnés en une seule liste de paramètres et la fonction ne voit qu'un seul tableau résultant de la concaténation des valeurs des deux tableaux. Si on passe une référence à chaque tableau, la fonction est capable d'accéder séparément aux éléments de chaque tableau grâce aux références individuelles. Tout ce que nous venons de dire sur le passage de tableaux en paramètre à des fonctions s'applique aussi aux valeurs renvoyées par des fonctions à la fonction appelante si ces valeurs renvoyées sont des tableaux. Voilà déjà quatre avantages importants des références. Celui qui vient est encore plus crucial.

2-1-3. Les références sur des variables anonymes pour créer des structures de données complexes

Notre programme a déjà créé $c_ref. La suite du programme crée directement deux autres références, cette fois vers des tableaux anonymes (des tableaux sans nom, des tableaux qui ne sont pas prédéclarés comme tels), puis créons un tableau contenant nos trois références vers des tableaux :

 
Sélectionnez
22.
23.
24.
25.
my @$d_ref = qw / 14 13 12/; # peut s'écrire: my $d_ref = [14, 13, 12];
my @$e_ref = qw / 34 35 36/;   # ou: my $e_ref = [34, 35, 36];
my @f = ($c_ref, $d_ref, $e_ref); # @f est un tableau de refs vers des tableaux
pri "@f"; # imprime ARRAY(0x80355bb0) ARRAY(0x80355a30) ARRAY(0x8034f508)

Le tableau @f est maintenant un tableau contenant trois scalaires, qui sont tous trois des références vers d'autres tableaux aux éléments desquels il est possible d'accéder. Ce tableau de références à d'autres tableaux est appelé plus couramment (c'est un raccourci légèrement abusif, mais très parlant) un tableau de tableaux (array of arrays, ou AoA) ayant la structure suivante :

 
Sélectionnez
0  ARRAY(0x803558e0)
   0  ARRAY(0x80355bb0)
      0  5
      1  18
      2  7
   1  ARRAY(0x80355a30)
      0  14
      1  13
      2  12
   2  ARRAY(0x8034f508)
      0  34
      1  35
      2  36

Ceci nous dit que le tableau @f est situé à l'adresse mémoire 0x803558e0 et qu'il contient trois références à des tableaux, dont le premier est à l'adresse 0x80355bb0 et contient les éléments 5, 18 et 7, et ainsi de suite pour les deux autres

Perl offre diverses manières syntaxiques d'accéder aux éléments de ce tableau de tableaux :

 
TéléchargerSélectionnez
1.
2.
3.
4.
5.
6.
pri $f[0][1]; # imprime le deuxième élément du premier tableau, soit 18
pri $f[2][0]; # imprime 34
pri $f[2]->[0]; # notation "fléchée", affiche également 34
my $i_ref = $f[2]; # $i_ref est une nouvelle référence contenant ARRAY(0x8034f508)
pri $$i_ref[0]; # affiche encore 34
$f[3][1] = 87; # crée un nouveau tableau dans le AoA et met 89 à l'indice 1

Cette dernière commande a créé directement un nouveau tableau dans le tableau de tableaux et mis la valeur 87 en deuxième position.

 
Sélectionnez
0  ARRAY(0x803558e0)
   0  ARRAY(0x80355bb0)
      0  5
      1  18
      2  7
   1  ARRAY(0x80355a30)
      0  14
      1  13
      2  12
   2  ARRAY(0x8034f508)
      0  34
      1  35
      2  36
   3  ARRAY(0x803f81c0)
      0  empty slot
      1  87

Ce tableau @f aurait également pu être déclaré comme suit (en utilisant la syntaxe [] déjà mentionnée) :

 
Sélectionnez
my @f = ([5, 18, 7], [34, 35, 36], [34, 35, 36], [undef, 87]);

La syntaxe Perl offre plusieurs autres méthodes de définition ou d'accès à des tableaux de tableaux, ce résumé est très succinct.

Mais, de même que notre programme a créé un tableau de tableaux, il est possible de créer des tableaux de tableaux de tableaux (AoAoA), des hachages de hachages (HoH), des hachages de hachages de hachages (HoHoH), des tableaux de hachages (AoH), des hachages de tableaux (HoA), etc. On peut en fait créer des structures imbriquées arbitrairement complexes, selon les besoins.

Tout ceci est rendu possible par l'introduction des références. Celles-ci ont d'autres tours dans leur sac.

2-2. Les références vers des fonctions

Perl permet non seulement de prendre des références vers des éléments de données (scalaires, tableaux, hachages), mais aussi vers des entités plus abstraites (descripteurs de fichier, typeglobs, etc.), et tout particulièrement vers des fonctions (ou des blocs de code).

Mais pourquoi aurait-on besoin d'une référence sur une fonction ? C'est en fait tout le sujet de ce tutoriel, nous en verrons donc de nombreuses applications, mais l'exemple le plus immédiat a été abordé dans la première partie de ce tutoriel : il existe au moins trois fonctions internes Perl, sort, grep et map, auxquelles il faut passer un bloc de code ou une fonction pour définir leur comportement.

2-2-1. Les références à des fonctions passées aux opérateurs sort et map

La fonction sort, par exemple, effectue par défaut un tri lexicographique. Pour effectuer un tri numérique, il faut lui passer un bloc de code ou une fonction effectuant une comparaison numérique entre deux éléments de la liste d'éléments à trier. Voici un exemple d'un fragment de script Perl montrant cette différence :

 
Sélectionnez

my @tableau = qw / 1 7 17 12 15 3 10/;
my @tri_1 = sort @tableau;
print "@tri_1 \n"; # imprime: 1 10 12 15 17 3 7
my @tri_2 = sort {$a <=> $b} @tableau;
print "@tri_2 \n"; # imprime: 1 3 7 10 12 15 17

Dans le premier cas, le 3 et le 7 viennent après le 10, le 12, le 15 et le 17, ce qui correspond à un tri lexicographique (on compare le premier caractère, puis, s'ils sont égaux, le second, et ainsi de suite). Ce n'est bien sûr pas satisfaisant pour des nombres que l'on veut trier numériquement. La seconde version trie bien les nombres selon notre désir. Pour cela, il a fallu ajouter entre la fonction sort et la liste des nombres à trier un bloc de code spécifiant comment doit s'effectuer la comparaison entre deux nombres successifs. Le bloc de tri numérique, {$a <=> $b}, constitue, dans le cas particulier de la fonction sort, une sorte de référence vers la fonction de comparaison nécessaire. (1)

Soit à écrire une fonction écrivant les carrés des dix premiers nombres entiers. La fonction map est bien adaptée :

 
Sélectionnez
$ perl -e 'print join " ", map {$_ * $_} 1..10;'
1 4 9 16 25 36 49 64 81 100

Comme nous l'avons vu dans le premier volet de ce tutoriel, l'opérateur map prend en entrée la liste de nombres générée par 1..10, soit les nombres de 1 à 10, affecte chacun de ces nombres à la variable $_, effectue l'opération demandée dans le bloc (ici, multiplier $_ par lui-même), et renvoie la liste des résultats. Ici encore, le bloc {$_ * $_} est (dans ce contexte) une sorte de référence à la fonction à appliquer à chaque élément.

Les opérateurs sort et map sont des fonctions qui acceptent une fonction comme paramètre. Quand une fonction peut être le paramètre d'appel ou la valeur de retour d'une autre fonction, on parle de fonctions d'ordre supérieur (ou parfois de fonctions première classe). Toute la puissance de sort et de map découle de cette capacité à définir leur comportement détaillé en leur passant un bloc de code ou une fonction. Nous allons maintenant voir comment faire ce genre de choses en Perl pour obtenir la même puissance pour nos propres fonctions, et combien cela peut être utile.

2-2-2. L'exemple du parcours d'un répertoire

Supposons que nous voulions parcourir toute la profondeur d'un répertoire sur notre disque dur, pour déterminer la taille prise par ce répertoire et l'ensemble de ses sous-répertoires. Pour ce faire, on peut écrire une procédure, éventuellement récursive, qui explore le répertoire racine. Si elle trouve un fichier, elle ajoute sa taille à une variable globale. Si le programme trouve un répertoire, il parcourt ce nouveau répertoire avec les mêmes règles. À la fin, il affiche le total de l'espace disque pris par le répertoire et ses sous-répertoires. (Le code de ce programme viendra plus loin, soyez un peu patient pour l'instant.)

Quelques jours plus tard, nous devons écrire un programme qui recherche tous les fichiers modifiés depuis la dernière sauvegarde. Pour ce faire, il est possible d'écrire une procédure, éventuellement récursive, qui explore le répertoire racine. Si elle trouve un fichier, elle l'ajoute à une liste de fichier à sauvegarder si sa date de dernière modification est supérieure à la date pivot. Si la procédure trouve un répertoire, elle parcourt ce nouveau répertoire avec les mêmes règles. À la fin, le programme affiche les fichiers à sauvegarder du répertoire choisi et de ses sous-répertoires.

La semaine suivante, nous désirons effacer tous les fichiers *.tmp ou *.TMP d'un répertoire et de ses sous-répertoires. Pour ce faire, on peut écrire un programme, éventuellement récursif, qui explore le répertoire racine. S'il trouve un fichier de type *.tmp ou *.TMP, il l'efface. S'il trouve un répertoire, il parcourt ce nouveau répertoire avec les mêmes règles.

Stop ! Là, n'importe quel développeur digne de ce nom doit bondir et se poser la question : « Je fais trois fois presque exactement la même chose, parcourir l'arbre des répertoires et sous-répertoires, seule l'action individuelle finale sur les fichiers change à chaque fois. N'est-il pas possible d'écrire une seule fois le parcours des répertoires et sous-répertoires ? »

Bien sûr, on peut écrire un programme de parcours, puis, selon un paramètre, un flag ou une variable globale, décider que faire pour chaque fichier rencontré. Mais ce genre de stratégie ne fonctionne généralement pas bien. La prochaine fois, c'est encore autre chose qu'il faudra faire, et il faudra modifier une nouvelle fois le programme.

La bonne solution est généralement d'écrire un programme réellement générique (ou abstrait) de parcours purement technique de l'arbre de répertoires, sans préjuger en aucune façon (ou le moins possible) de ce qu'il devra faire de ce qu'il trouve. Quand il trouve quelque chose, il appelle la fonction assurant le contenu fonctionnel : calculer l'espace disque, sauvegarder les fichiers ou effacer les fichiers temporaires (ou, peut-être, demain, compresser les fichiers vieux de plus de trois mois, effacer ceux vieux de plus d'un an, archiver les fichiers de type *.ARC, etc.). Si nous avons écrit une fonction vraiment générique de parcours et avons décidé que l'action à entreprendre fait partie du paramétrage de cette fonction, il y a une bonne chance de ne plus devoir toucher à l'algorithme de parcours.

Comme dans le cas des fonctions map et sort vues précédemment, la solution la plus efficace (et élégante) consiste à passer en paramètre à la fonction technique générique (abstraite) une autre fonction chargée des actions particulières à entreprendre. Une fonction générique comme celle que nous venons de décrire sera souvent incluse dans un module, car elle peut être réutilisée par d'autres personnes ou dans d'autres projets.

Nous allons réaliser cette fonction générique de parcours de répertoires très bientôt, quand nous saurons comment faire. Rien de bien compliqué, elle prendra moins de 10 lignes de code Perl. Clarifions juste un petit point de vocabulaire avant de nous lancer dans le grand bain.

Supposons qu'il faille écrire un programme espace_disque.pl pour mesurer l'espace disque. À un moment donné, ce programme appelle la procédure parcourt_repertoire du module DiskExploreTools. Cette procédure parcourt le disque et, pour chaque fichier trouvé, appelle la fonction totalise_espace_disque qui lui a été passée en paramètre et qui est quant à elle définie dans le programme principal espace_disque.pl. Le programme principal appelle une fonction d'un module qui, a un moment donné, rappelle à son tour une fonction définie dans le programme principal. C'est à cause de ce mécanisme d'aller et retour entre le programme principal et le module que l'on appelle ce genre de fonction une fonction de rappel (callback function) : le programme appelle une fonction générique abstraite qui effectue un certain travail, et celle-ci rappelle la fonction concrète qui effectue la tâche recherchée.

Les références sur des fonctions vont nous permettre d'actionner ce genre de mécanisme (et de faire beaucoup d'autres choses que nous verrons plus loin).

2-2-3. Réaliser une fonction de rappel

Supposons que, dans le parcours de répertoire, l'on désire utiliser une fonction de rappel qui se contente dans un premier temps d'imprimer à l'écran la liste des fichiers rencontrés. Cette fonction doit simplement imprimer le paramètre reçu (le nom du fichier) et passer à la ligne.

Cette fonction très simple peut s'écrire comme suit :

 
Sélectionnez
sub imprime_fichier {
     my $fichier = shift;
     print $fichier, "\n";
}

La fonction de parcours récursif de répertoire peut ensuite s'écrire comme suit :

 
Sélectionnez
sub parcours_dir {
    my ($path) = @_;
    my @dir_entries = glob("$path/*");
    foreach my $entry (@dir_entries) {
        imprime_fichier($entry) if -f $entry;
        parcours_dir($entry) if -d $entry;
    }
}

L'appel de la fonction se fait normalement :

 
Sélectionnez
parcours_dir($chemin_de_depart);

La fonction parcours_dir est une fonction récursive simple qui lit le contenu du répertoire reçu en paramètre. Pour chaque objet trouvé dans le répertoire, elle appelle imprime_fichier si c'est un fichier, et elle s'appelle récursivement avec le nom du répertoire si c'est un répertoire. On pourrait ajouter quelques contrôles (par exemple, on ne fait rien si l'objet rencontré n'est ni un fichier, ni un répertoire, ce qui peut arriver sur certains systèmes d'exploitation), mais cela fonctionne au moins sous Unix et Windows/Dos (mais sans doute pas sous d'autres OS, mais cela nous suffira pour le moment).

Le principal problème est que l'appel à imprime_fichier est codé en dur dans le code de parcours_dir. Si l'on désire faire autre chose avec les fichiers trouvés, il faut changer parcours_dir. Ce n'est pas ce qui était voulu, la fonction parcours_dir devait être générique. Il serait souhaitable, par exemple, pouvoir la mettre dans un module et ne plus y toucher.

Les changements à faire sont très simples : il faut passer à la fonction parcours_dir le code de la fonction imprime_fichier en paramètre, du même que nous avons précédemment passé à sort ou à map des blocs de code.

Nul besoin de changer la fonction imprime_fichier, il faut juste créer une variable contenant une référence vers cette fonction, ce qui peut se faire de la façon suivante :

 
Sélectionnez
sub imprime_fichier {
     my $fichier = shift;
     print $fichier, "\n";
}
my $imprime_fic_ref = \&imprime_fichier;  # crée une référence sur la fonction

La variable $imprime_fic_ref est une variable scalaire ordinaire que nous allons pouvoir passer en argument à une fonction comme n'importe quelle autre variable. Sa seule particularité est qu'elle est une référence vers une fonction, vers du code Perl. On appelle souvent ce genre de variable une « code_ref » ou « coderef ». Nous utiliserons souvent le vocable coderef dans la suite pour désigner plus simplement une référence vers une fonction.

Il faut ensuite passer cette référence en paramètre à parcours_dir lors de l'appel de la fonction. Rien de plus simple :

 
Sélectionnez
parcours_dir($imprime_fic_ref, $chemin_de_depart);

Et la fonction parcours_dir doit maintenant utiliser ce nouveau paramètre au lieu de la fonction imprime_fichier utilisée antérieurement :

 
Sélectionnez
sub parcours_dir {
    my ($code_ref, $path) = @_;
    my @dir_entries = glob("$path/*");
    foreach my $entry (@dir_entries) {
        $code_ref->($entry) if -f $entry;
        parcours_dir($code_ref, $entry) if -d $entry;
    }
}

Trois petits changements : la fonction parcours_dir reçoit un nouveau paramètre, la référence à la fonction de rappel ($code_ref) et utilise la syntaxe $code_ref->($entry), au lieu de imprime_fichier($entry), pour appeler la fonction. Et comme la fonction parcours_dir est récursive, il faut encore ajouter le paramètre de la référence vers la fonction de rappel dans l'appel récursif.

Voilà, ça y est, nous avons maintenant une fonction générique (abstraite) : la fonction parcours_dir reçoit en paramètre une référence sur la fonction qu'elle doit appeler pour chaque fichier. Il n'y a plus besoin de modifier parcours_dir pour obtenir une fonctionnalité différente, il suffira de lui passer une référence à une autre fonction. Voici le programme complet :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
#!/usr/bin/perl
use strict;
use warnings;

my $dir_depart = $ARGV[0];
chomp $dir_depart;

sub imprime_fichier {
     my $fichier = shift;
     print $fichier, "\n";
}
my $code_ref = \&imprime_fichier; # crée une référence sur la fonction

parcours_dir($code_ref, $dir_depart);

sub parcours_dir {
    my ($code_ref, $path) = @_;
    my @dir_entries = glob("$path/*");
    foreach my $entry (@dir_entries) {
        $code_ref->($entry) if -f $entry;
        parcours_dir($code_ref, $entry) if -d $entry;
    }
}

Par rapport à ce qui précédait, la référence à la fonction de rappel a été renommée $code_ref, plutôt que $imprime_fic_ref, pour refléter le fait que la fonction pourra selon les besoins faire une tout autre chose qu'imprimer la liste des fichiers.

Si maintenant nous désirons connaître la taille de l'ensemble des fichiers dans le répertoire et les sous-répertoires, il suffit de remplacer imprime_fichier par une autre fonction de rappel.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
my $glob_size;
my $code_ref = \&find_total_size;
parcours_dir($code_ref, $dir_depart);
print "Taille totale: $glob_size \n";

sub find_total_size {
    my $fichier = shift;
    my $size = -s $fichier;
    print $fichier, ' ', $size, "\n";
    $glob_size += $size;
}

La fonction centrale, parcours_dir, reste inchangée. Le but est atteint, on a réellement une fonction générique réutilisable à volonté.

On pourrait critiquer, non sans raison, l'utilisation de la variable globale $glob_size, mais cela permet d'éviter pour l'instant de compliquer inutilement le code en devant passer en argument et renvoyer cette variable à chaque appel de la fonction de rappel. Nous verrons plus loin des techniques (voir notamment le chapitre sur les fermeturesLes fermetures (closures)) permettant d'éviter ce genre de problème et présenterons dans le troisième volet de ce tutoriel une nouvelle version de parcours_dir faisant appel à la technique dite de la curryfication.

Si vous trouvez la fonction parcours_dir pratique et désirez vous en servir, sachez qu'il existe un module standard, File::Find, nettement plus perfectionné, faisant à peu près la même chose et bien d'autres choses en plus. De plus, comme indiqué plus haut, la fonction simpliste ci-dessus ne marchera peut-être pas sous tous les systèmes d'exploitation. C'est donc plutôt ce module que vous devriez utiliser si vous avez besoin de parcourir un répertoire à la recherche de certains fichiers. Ce module attend comme premier paramètre une référence vers une fonction (notée \&wanted dans la documentation) analogue à ce que nous avons nommé $code_ref ci-dessus.

Les fonctions de rappel sont très souvent utilisées dans les modules, lorsque l'on souhaite réaliser une fonction ou un ensemble de fonctions génériques tout en conservant la possibilité de personnaliser le comportement (de même que, comme nous l'avons vu, la fonction sort met en œuvre un algorithme de tri efficace, mais qu'il faut lui passer en paramètre le code chargé de comparer les données entre elles).

2-2-4. D'autres moyens syntaxiques de prendre des références vers des fonctions

Revenons à notre première référence vers une fonction. Nous l'avons écrite comme suit :

 
Sélectionnez
sub imprime_fichier {
     my $fichier = shift;
     print $fichier, "\n";
}
my $imprime_fic_ref = \&imprime_fichier; # crée une référence sur la fonction
parcours_dir($imprime_fic_ref, $dir_depart);

Nous avons d'abord défini une fonction ordinaire, imprime_fichier, puis avons utilisé la syntaxe \&imprime_fichier pour stocker dans la variable scalaire $imprime_fic_ref la référence vers cette fonction et passé cette variable en paramètre à la fonction parcours_dir. Il est possible d'utiliser une autre façon de faire consistant à créer la référence directement dans l'appel de parcours_dir :

 
Sélectionnez
sub imprime_fichier {
     my $fichier = shift;
     print $fichier, "\n";
}
parcours_dir(\&imprime_fichier, $dir_depart);

Une simplification inverse de la syntaxe d'origine consiste à remarquer que le nom de la fonction imprime_fichier ne sert plus à aucun moment, nous ne nous servons plus que de la référence ailleurs dans le programme.

Il existe un moyen plus simple de définir directement une référence vers une fonction :

 
Sélectionnez
my $imprime_fic_ref = sub {
     my $fichier = shift;
     print $fichier, "\n";
}

La fonction n'a plus de nom, elle est devenue ce que l'on appelle une fonction anonyme. La référence $imprime_fic_ref est maintenant la seule manière de l'utiliser. Mais cette référence est une simple variable scalaire, nous pouvons en faire tout ce que nous pouvons faire avec des scalaires : passer la fonction en argument à une autre fonction, la renvoyer comme une valeur de retour d'une fonction (nous y reviendrons longuement), construire un tableau ou une table de hachage de références à des fonctions, etc. De même que l'utilisation de références à des tableaux ou des tables de hachage anonymes permet la création de structures de données complexes comme des tableaux de tableaux, des tableaux de hachage, des hachages de tableaux, des hachages de hachages, etc., la possibilité de créer des références vers des fonctions anonymes offre une extraordinaire puissance expressive supplémentaire à qui sait comment s'en servir. Il devient également possible de définir des fonctions dans des fonctions et de créer des fonctions lexicales privées.

Les fonctions deviennent ce que l'on appelle en programmation fonctionnelle des citoyens de premier ordre ou d'ordre supérieur.

Il est même possible d'aller un cran plus loin dans la simplification syntaxique. Pour passer le code d'une fonction de rappel à une fonction, il n'est même pas besoin de donner un nom à la référence à cette fonction. Il suffit de passer directement la référence au code en paramètre. Au lieu d'écrire :

 
Sélectionnez
my $code_ref = sub { print "$_[0]\n"};
parcours_dir($code_ref, $dir_depart);

On peut écrire :

 
Sélectionnez
parcours_dir(sub { print "$_[0]\n"}, $dir_depart);

Nous verrons dans la partie 3 de ce tutoriel une simplification supplémentaire grâce à l'utilisation des prototypes.

Dans certains cas, on désire passer à une fonction non seulement une coderef, mais aussi les arguments à passer à cette coderef, qu'il convient de distinguer des arguments passés à la fonction. Une coderef étant une variable scalaire ordinaire, elle peut être incluse dans une structure de données (hachage ou tableau) contenant la coderef et les données dont elle a besoin. C'est par exemple le cas quand on utilise certains modules comme Perl/Tk (toolkit graphique) : on passe à la fonction appelée une référence à un tableau contenant la coderef et les arguments dont elle a besoin avec un appel du style :

 
Sélectionnez
my_function( [\&callback_func, $cb_param1, $cb_param2], $arg2, $arg3);

Ici, la fonction my_function prend trois arguments : une référence à un tableau anonyme (ce qui figure entre les crochets […]), $arg2 et $arg3. Le tableau anonyme contient lui-même trois éléments : la coderef \&callback_func, et les deux paramètres qui devront être passés à cette fonction de rappel, $cp_param1 et $cb_param2. Il faut bien sûr que la fonction my_function soit codée de façon à traiter correctement les arguments qu'elle reçoit (cela n'a rien de compliqué, mais traiter cet aspect ici sortirait du cadre de ce tutoriel).

Un dernier point de syntaxe. Jusqu'à présent, pour appeler la fonction référée par $toto, nous avons utilisé la syntaxe suivante :

 
Sélectionnez
$toto->($param1);

Il est également possible d'utiliser le « sigil » & pour déréférencer le coderef et d'appeler la fonction de rappel comme suit :

 
Sélectionnez
&$toto($param1);

Cette méthode plus classique (ou moins moderne) reste parfaitement valide. Choisissez celle que vous préférez ou qui vous paraît la plus claire, mais il est utile de savoir que les deux existent et qu'elles font la même chose. Personnellement, j'ai d'abord utilisé assez longtemps la seconde et ne suis passé à la première qu'assez récemment, la trouvant finalement un peu plus lisible.

2-3. Les tables de distribution

Les tables de distribution (dispatch tables) n'ont pas directement à voir avec la programmation fonctionnelle (on peut en faire en C avec des pointeurs sur des fonctions, même si c'est de façon un peu moins pratique), mais il est utile de montrer brièvement leur intérêt dans le cadre des références à des fonctions et de montrer combien il est facile de les mettre en œuvre en Perl.

Je travaille occasionnellement sur une grosse et complexe application multiserveur et multienvironnement dont le fonctionnement nécessite que de nombreux processus ou démons fonctionnent. Par exemple, les programmes applicatifs ne font pas directement des requêtes dans les bases de données Oracle, mais passent par un « Connection Manager » (CM) ; on arrête rarement les bases de données, mais il faut plus souvent arrêter et redémarrer le CM pour diverses raisons (mise à jour de paramétrage, etc.). Il existe d'autres serveurs intermédiaires du même type. De même, plusieurs moteurs de traitement temps réel (ou semi-temps réel) chargent une partie de la base de données en mémoire pour assurer des performances satisfaisantes sur des traitements intensifs. En fonctionnement normal, la base est mise à jour par les données générées par le moteur de traitement, mais il faut assez régulièrement resynchroniser les données entre la base et la mémoire de travail. Tout ceci nécessite d'un mécanisme permettant d'arrêter proprement et de relancer les divers processus d'arrière-plan (daemons Unix) impliqués.

Un outil, operation_control, permet de gérer toutes opérations de maintenance. On lui passe comme premier argument le nom de l'opération à effectuer, puis la liste des processus sur lesquels doit porter l'opération. La liste des opérations est assez longue, mais nous allons ici la limiter à cinq : stop, force_stop, start, force_start, clean_up. Lorsqu'il s'agit d'arrêter des process, la liste des processus à arrêter est souvent simplement « all », ce qui signifie que l'on désire arrêter tous les processus applicatifs d'arrière-plan.

Pour coder cet outil en Perl, nous pourrions écrire quelque chose comme ceci(2) :

 
Sélectionnez
my ($oper, $rest) = shift, join ' ', @ARGV;
my $stop = 'stop';
my $force_stop = 'force_stop';
# ...
if ($oper eq $stop) {
    run_stop ($rest);
    cleanup();
}
elsif ($oper eq $force_stop) {
    run_force_stop ($rest);
    cleanup();
}
elsif ($oper eq 'start') {
    run_start ($rest);
}
elsif   ($oper eq 'force_start') {
     run_stop ($rest);
    cleanup();
    run_start ($rest);
}else {
    die "Opération $oper inconnue";
}

En principe, cela marche, mais n'est pas très élégant, ni très pratique si je dois ajouter une nouvelle opération. Et puis c'est beaucoup de frappes, le risque d'erreur n'est pas négligeable.

Il existe un module Switch (avec une lettre capitale initiale) qui a été distribué avec Perl pendant quelque temps, mais son usage a été déprécié en version 5.12 et il ne fait plus partie de la distribution standard depuis la version 5.14 (même si on peut toujours le trouver sur le CPAN, il n'a pas été maintenu et il faut absolument l'éviter avec les versions récentes de Perl).

Une fonctionnalité switch (sans capitale initiale), utilisant les mots-clefs given, when et default, a été introduite à titre expérimental en 5.10. Sa sémantique très riche la rend à première vue attrayante, mais elle est considérée en 5.18 comme hautement expérimentale, ce qui veut dire qu'elle est susceptible d'être profondément remaniée, voire supprimée, lors d'une nouvelle distribution de Perl. On peut à la rigueur envisager de l'utiliser pour un programme ponctuel dont on a la certitude qu'il ne servira qu'une seule fois ou que pendant quelques semaines, il faut certainement l'éviter pour tout programme de production destiné à devenir pérenne (ou simplement susceptible de le devenir). En bref, Perl n'a pas vraiment de fonctionnalité switch utilisable en production.

L'alternative consiste à considérer ces différentes fonctions comme de simples données, des variables ordinaires, grâce à la magie des références vers des fonctions. Créons une table de références vers des coderefs :

 
Sélectionnez
my %dispatch_table = (
    stop => sub {run_stop($rest); cleanup();},
    force_stop => sub {run_force_stop($rest); cleanup();},
    start => /&run_start(@rest),
    force_start => sub  {run_stop ($rest); cleanup() ; run_start ($rest);},
    cleanup => sub {unlink $ENV{$flags}}
);

Maintenant, pour analyser la ligne de commande, on peut faire ce qui suit :

 
Sélectionnez
my ($oper, $rest) = shift, join ' ', @ARGV;
if (defined $dispatch_table{$oper}) {
    $dispatch_table{$oper}->($rest); 
} else {
    die "Opération $oper inconnue";
}

Le code est un peu plus concis, sa structure est plus claire, mais l'intérêt est plus profond : les traitements appelés se sont transformés en simples données. Pour créer de nouvelles options, il faut juste ajouter une ligne dans la table de distribution. Par exemple, pour ajouter une option d'aide (help) :

 
Sélectionnez
my %dispatch_table = (
    stop => sub {run_stop($rest); cleanup();},
    force_stop => sub {run_force_stop($rest); cleanup();},
    start => /&run_start(@rest),
    force_start => sub  {run_stop ($rest); cleanup() ; run_start ($rest);},
    help => sub { print $help;},
    cleanup => sub {unlink $ENV{$flags}};
);

Ce simple ajout d'une seule ligne dans la table suffit. Il n'est pas nécessaire de modifier le code de la fonction, ça marche.

Mieux, il est même possible de modifier dynamiquement la table de distribution (et donc la sémantique de fonctionnement) pendant l'exécution même du programme en fonction des conditions rencontrées. Autrement dit, on peut facilement créer un programme qui se modifie lui-même pendant sa propre exécution, alors même que la compilation est achevée ! On n'utilise peut-être pas très fréquemment ce genre de possibilité, mais cela offre un immense gain en expressivité quand on en a besoin. C'est vraiment une corde qu'il est utile d'avoir pour son arc.

Un exemple d'utilisation pratique d'une table de distribution pour une utilisation professionnelle réelle est donné plus loin dans ce tutoriel, dans le chapitre relatif aux usines à fonctionsDes fonctions qui fabriquent des fonctions (ou usines à fonctions).

Après cette parenthèse, revenons au cœur de la programmation fonctionnelle.

3. Les fermetures (closures)

Les fermetures (parfois également appelées clôtures), ou closures en anglais, sont un concept essentiel de la programmation fonctionnelle. L'idée est en fait extrêmement simple, mais, combinée à deux ou trois autres idées tout aussi simples, elle est la source d'une très grande puissance expressive.

Une fermeture est simplement une fonction qui contient en elle-même (ou plus exactement, embarque avec elle) une partie de son environnement d'exécution, ou des données qu'elle utilise. Ces données sont généralement réellement inaccessibles à l'extérieur de la fonction, mais sont persistantes d'appel en appel à l'intérieur de celle-ci.

3-1. Un premier exemple simple de fermeture

3-1-1. Créer un itérateur sur les carrés des nombres naturels

Prenons pour commencer un exemple un peu simpliste. Supposons dans un premier temps que nous désirions réaliser une fonction qui renvoie à la demande les carrés des nombres entiers successifs. Supposons en outre que cette fonction doive pouvoir être appelée de plusieurs endroits du programme, y compris à des endroits dans lesquels le programme ne sait pas quel a été le dernier nombre utilisé ou le dernier carré obtenu. Il faut donc que la fonction « se souvienne » du dernier nombre utilisé.

Ce genre de fonction s'appelle un itérateur, elle ne fournit pas une liste bornée de nombres comme le faisaient les fonctions map et grep examinées dans la première partie de ce tutoriel, elle ne fournit pas non plus le carré d'un nombre qui lui est passé en paramètre, mais elle fournit à la demande et un par un une liste, potentiellement infinie (en théorie), de carrés d'entiers successifs. Bien sûr, un ordinateur a une mémoire finie et ne peut gérer une liste réellement infinie, mais un itérateur fournit un moyen de générer une liste potentiellement illimitée de valeurs, grâce au mécanisme d'évaluation dite paresseuse ou retardée (lazy evaluation ou call-by-need) qu'il met en place : il ne crée pas la liste illimitée, il se contente de fournir à chaque demande le prochain élément de la liste.

La plupart des développeurs utilisent régulièrement des itérateurs, parfois sans le savoir : ainsi, la fonction Perl readline ou l'opérateur <$FH> sont des itérateurs sur des fichiers : en contexte scalaire, ils retournent une ligne du fichier à chaque demande. Même si le fichier est gigantesque et beaucoup trop gros pour la mémoire vive de l'ordinateur, le fait d'utiliser un itérateur pour le lire ligne par ligne permet de traiter le fichier sans aucun problème de mémoire.

Nous reviendrons plus loin assez longuement sur les itérateurs et les façons d'en construire.

3-1-2. Premières tentatives erronées

Pour générer des carrés à la demande, essayons ceci :

carre1
Sélectionnez
#!/usr/bin/perl
use strict;
use warnings;

my $square = fournit_carre();
print $square, "\n";

# ...
# tout à fait ailleurs dans le code
my $new_square = fournit_carre();
print $new_square, "\n";

sub fournit_carre {
    my $premier_nombre = 0;
    $premier_nombre ++;
    return $premier_nombre ** 2;
}

Cela ne marche évidemment pas et affiche :

 
Sélectionnez
$ perl carre1.pl
1
1

La variable $premier_nombre est réinitialisée à 0 à chaque appel, et la fonction retourne à chaque fois le carré de 1 (donc 1).

Essayons de ruser et de réécrire la fonction comme suit :

carre2
Sélectionnez
sub fournit_carre {
    my $premier_nombre = 0;
    my $nombre = $premier_nombre unless defined $nombre;
    $nombre ++;
    return $nombre ** 2;
}

C'est encore pire, le programme ne compile même plus (du moins en utilisant les pragmas strict et warnings):

 
Sélectionnez
$ perl carre2.pl
Global symbol "$nombre" requires explicit package name at carre1.pl line 14.
Execution of carre1.pl aborted due to compilation errors.

Essayons d'éliminer cette erreur de compilation en déclarant $nombre avant de l'utiliser :

carre3
Sélectionnez
sub fournit_carre {
    my $premier_nombre = 0;
    my $nombre;
    $nombre = $premier_nombre unless defined $nombre;
    $nombre ++;
    return $nombre ** 2;
}

Le programme compile à nouveau, mais le résultat n'est toujours pas ce qui est recherché :

 
Sélectionnez
$ perl carre3.pl
1
1

La variable $nombre est réinitialisée à chaque appel et n'est donc pas définie quand on la teste, et elle reprend donc à chaque fois la valeur de $premier_nombre.

3-1-3. La solution qui marche : premier exemple de fermeture

La solution est en fait très simple, mais pas forcément évidente à deviner pour qui ne la connaît pas ou ne l'a pas déjà vue au moins une fois :

Première fermeture
Sélectionnez
{    my $nombre = 0;
    sub fournit_carre {
        $nombre ++;
        return $nombre ** 2;
    }
}

Ce qui donne le résultat recherché :

 
Sélectionnez
$ perl carre_closure.pl
1
4

Comment cela fonctionne-t-il ? Les accolades au début et à la fin forment un bloc lexical. La variable lexicale $nombre déclarée au début de ce bloc est visible lexicalement jusqu'à la fin du bloc, donc, en particulier, dans le corps de la fonction carre_closure. Cette variable est initialisée à 0 lors de la compilation, mais le code de cette initialisation n'est pas exécuté lorsque la fonction est appelée. Résultat : la variable $nombre conserve sa valeur entre deux appels de la fonction ; elle prend la valeur 1 lors du premier appel, 2 lors du second appel, puis 3, et ainsi de suite.

« Attendez une minute, la variable $nombre n'est plus dans sa portée lexicale, celle-ci est perdue quand on sort de la fonction fournit_carre , non ? », pourriez-vous vous demander. Eh bien non, parce que la fonction donnée en exemple est une fermeture. « OK, c'est une fermeture, et alors ? », peut-on demander non sans raison. Les fonctions en Perl ont un « cahier de brouillon » (scratchpad) dans lequel elles gardent une référence sur toute variable lexicale utilisée à l'intérieur de la fonction. L'existence de ces références implicites signifie que l'on peut accéder aux valeurs des variables lexicales dans ces fonctions même si la variable elle-même n'est plus dans la portée lexicale.

Si cette explication ne vous convainc pas, considérez le code suivant :

 
Sélectionnez
my $c_ref;
sub initie_c {
     my $c = 10;
     $c_ref = \$c;
}
print $$c_ref; # imprime 10

Dans la dernière ligne du code ci-dessus, le programme imprime la valeur de $c, bien que $c ne soit plus dans la portée, parce que l'existence de la référence vers $c, dont la portée lexicale est plus large que celle de $c, « prolonge la durée de vie » de $c par l'intermédiaire de la référence $c_ref (même si la variable $c elle-même n'est plus accessible).

Dans notre exemple de fermeture, l'existence de la fermeture crée une référence implicite sur la variable $nombre qui redevient accessible à chaque fois que l'on exécute le code de la fermeture.

J'ai bien ici une fermeture, puisque la fonction fournit_carre utilise et met à jour la variable lexicale $nombre qui lui est propre. Aucun autre endroit du programme ne connaît cette variable ni ne peut y accéder, que ce soit en lecture ou en écriture. La donnée constitue un objet persistant et elle est réellement complètement privée au bloc lexical qui encadre la fonction.

Un développeur C pourrait dire que $nombre est une variable statique (une variable déclarée au sein d'une fonction avec le mot-clef static et qui conserve sa valeur entre deux appels à la fonction). Depuis la version 5.14 de Perl, un mécanisme analogue existe en Perl : le déclarateur state qui crée une variable lexicale dont le contenu persiste entre deux appels successifs à la fonction. L'utilisation de cette fonctionnalité nécessite le pragma use feature "state";. Ceci nous donne par exemple le programme suivant :

Utilisation du déclarateur state
Sélectionnez
#!/usr/bin/perl
use strict;
use warnings;
use feature "state";

my $square = fournit_carre();
print $square, "\n";

my $new_square = fournit_carre();
print $new_square, "\n";

sub fournit_carre {
    state $nombre = 0;
    return ++$nombre ** 2;
}

Ce qui imprime bien :

 
Sélectionnez
$ perl carre_state.pl
1
4

À noter que l'instruction « state $nombre = 0 ;» n'est exécutée qu'une seule fois, lors du premier appel à la fonction, ce qui peut paraître contre-intuitif à première vue. Ce mécanisme existe depuis la version 5.14 de Perl (sortie en mai 2011), alors que le mécanisme de fermeture étudié antérieurement a vu le jour avec la version 5.0 de Perl (datant de 1994). Nous nous en tiendrons pour la suite à la version traditionnelle, ne serait-ce que parce que, dans le monde professionnel, beaucoup de gens doivent encore travailler avec des versions plus anciennes de Perl (beaucoup de serveurs de production fonctionnent avec des versions de systèmes d'exploitation datant de 5 ou 10 ans, et les versions les plus récentes de Perl n'ont pas forcément été portées pour des versions d'AIX, de Solaris ou d'HP-UX aussi anciennes). Nous verrons aussi bientôt d'autres avantages des fermetures pour lesquels le déclarateur state n'apporterait rien.

Dans leur utilisation la plus fréquente, les fermetures sont souvent des fonctions anonymes, au point que certains pensent parfois qu'une fermeture doit être une fonction anonyme. Il est vrai que toute la puissance des fermetures se révèle dans le cadre de fonctions anonymes, comme nous le verrons plus loin, mais une fonction nommée peut parfaitement « se fermer » sur une ou plusieurs variable(s). La fonction fournit_carre est un exemple de fermeture nommée. Avant d'en venir aux fermetures anonymes, examinons brièvement un exemple réel pratique (c'est-à-dire non théorique) de fermeture nommée.

3-2. Exemple réel de fermeture pour une API d'accès à une base de données

Je travaille occasionnellement sur une application reposant sur une base Oracle surmontée d'une couche objets dépositaire de l'essentiel de la logique « métier » ou « business » de la base. Sauf cas très simple, il n'est pas recommandé d'accéder aux tables Oracle, mais il faut utiliser l'API Perl de manipulation des objets. Cette API propriétaire est de relativement bas niveau et nécessite de faire de nombreux appels répétitifs aux mêmes actions.

3-2-1. Création d'une interface d'accès à la base

J'ai donc décidé d'écrire une interface de plus haut niveau simplifiant l'accès aux objets de la base. Par exemple, une simple recherche en base nécessite l'appel successif des actions suivantes de l'API : transformation de la chaîne de recherche en objet de recherche interne à la base, activation de l'API avec cet objet de recherche, récupération de l'objet résultat, transformation de l'objet interne résultat en une chaîne de caractères utilisable, libération de la mémoire utilisée par les objets internes (sans parler des vérifications d'erreur à chacune des étapes). L'interface que j'ai écrite permet de lancer au niveau du programme applicatif une seule opération se chargeant derrière la scène d'enchaîner les différentes actions nécessaires. Cette interface prend la forme d'un module Perl réutilisable assez simple (quelques centaines de lignes de code). Cette interface utilise incidemment certaines des techniques précédemment décrites dans le présent document (fonctions de rappel, tables de distribution, etc.), mais ce n'est pas ici le sujet, je n'entrerai pas dans ces détails.

L'utilisation de ce module d'interface commence nécessairement par l'appel à une fonction, init_connection, qui se charge d'enchaîner quelques actions (connexion à la base, ouverture d'une transaction, etc.) et retourne trois identifiants : pointeur vers un buffer d'erreur, identifiant de connexion, identifiant de transaction. Chaque fonction de l'API de base nécessite de passer en paramètres ces identifiants, en sus de la structure de données relative à l'action à effectuer en base. Devoir passer en paramètre chacun de ces trois paramètres (toujours les mêmes) à chaque appel aurait compliqué notablement l'utilisation de cette interface.

En simplifiant quelque peu, j'ai donc simplement déclaré ces trois paramètres globaux à ce module. Ceux-ci sont initialisés lors de l'appel à la fonction init_connection, et le module forme une fermeture qui se souvient ensuite de ces trois paramètres. Ils forment maintenant un objet persistant réutilisable. Il n'est donc plus nécessaire de transmettre ces paramètres en arguments aux appels des fonctions du module, qui sont capables de les récupérer elles-mêmes, ce qui simplifie notablement l'utilisation de cette interface.

3-2-2. Une version simplifiée de l'interface d'accès

Voici le pseudocode d'une version abstraite très simplifiée de l'interface. Cette version élimine notamment une bonne partie de code de gestion des éventuelles erreurs d'accès à la base.

Interface d'accès à la base de données
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
package Mon_interface;
use strict;
use warnings;
use Low_level_api;

our @ISA = qw/Exporter/;
our @EXPORT = qw (init_connection);
our @EXPORT_OK = qw (recherche_objet …);

my ($error_buff_p, $connexion_id, $transaction_id);
my $init_done = 0;

sub init_connection {
    my ($param1, $param2 …) = @_;
    my $ebuf_p = API_create_buff($some_param);
    my $connect_id = API_connect($some_other_param …);
    my $trans_id = open_transaction($ebuf_p, $connect_id, $autres_params …);
    # quelques autres opérations
    ($error_buff_p, $connexion_id, $transaction_id) = ($ebuf_p, $connect_id, $trans_id);
    $init_done = 1;
    return ($ebuf_p, $connect_id, $trans_id);
}

sub  open_transaction {
    my ($ebuf_p, $connect_id, $param1, $param2 …) = @_;
    # ...fait quelques petites choses supplémentaires
    my $transaction_id = API_create_transaction($ebuf_p, $connect_id, #various_params...);
    #  fait d'autres petites choses
    return  $transaction_id;
} 

#  nombreuses fonctions diverses 

sub recherche_objet {
    my $search_string_ref = shift;
    die_error ("Appelez d'abord la fonction init_connection\n" unless $init_done);
    my $search_struct_p = string_to_struct_convert ($error_buff_p, $search_string_ref);
    die_error($search_string) if  error_detected($error_buff_p);
    my $return_struct_p = API_object_search ($error_buff_p, $transaction_id, $connexion_id, $search_struct_p);
    die_error (…) if error_detected($error_buff_p);
    my $return_string = struct_to_string_convert($error_buff_p, $return_struct_p);
    die_error( $search_string) if  error_detected($error_buff_p);
    free_memory($_) for ($search_struct_p, $return_struct_p);
    return \$return_string;
}
1;

Le programme principal appelant ce module pourra contenir notamment le code suivant :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
use strict;
use warnings;
use Mon_interface;

chomp @ARGV;
my $config_file = shift;
my %config = read_config($config_file);
#...
# les paramètres $ebuf, $connect_id, $trans_id sont en principe inutiles ici, mais sont tout 
# de même récupérés au cas  l'on voudrait appeler une fonction de l'API de bas niveau
my ($ebuf, $connect_id, $trans_id) =  init_connection($some_params);
# 
my $search_string = "...";
my $result_ref =  recherche_objet($search_string);
# exploitation de $result_ref, etc.
# ...

Le module Mon_interface (ce n'est pas son vrai nom) se charge de toutes les actions de bas niveau. Comme il constitue une fermeture, il stocke notamment comme objets persistants les trois identifiants de connexion. Le programme principal peut se contenter d'appeler la fonction init_connexion, puis par exemple la fonction recherche_objet, avec un seul argument, la chaîne de caractères constituant la liste de recherche en base.

Utiliser ainsi des fonctions intermédiaires pour simplifier l'appel de fonction principal à un seul argument (les autres arguments étant ici stockés comme objets persistants dans une fermeture) est une forme manuelle simple et minimaliste d'une autre technique importante de la programmation fonctionnelle sur laquelle nous reviendrons dans la troisième partie de ce tutoriel, la curryfication.

3-2-3. Variables globales, variables locales, fonctions globales, fonctions locales…

Le module ci-dessus utilise des variables lexicales globales au module pour des raisons de simplicité, afin de partager ces trois variables entre les différentes fonctions du module. Cela ne pose pas de problème parce que ces variables sont parfaitement encapsulées dans le module, qui est lui-même simple et ne fait pas grand-chose de plus que de mettre à la disposition du programme appelant une bibliothèque de fonctions presque indépendantes les unes des autres permettant d'accéder en lecture ou en écriture aux objets de la base. Le programme appelant n'a aucun moyen d'accéder directement à ces variables totalement privées, et il n'y a donc aucun risque de collision de noms. L'utilisation de ces variables globales au module (il faudrait plutôt dire partagées entre les fonctions du module) ne pose donc pas de problème à mes yeux.

Certains puristes pourraient cependant protester (le terme puriste n'a ici rien de péjoratif, je suis moi-même certainement le puriste de certains). Surtout, le module pourrait être (voire devenir) plus compliqué, auquel cas l'utilisation de variables globales au module pourrait commencer à être nettement plus difficile à justifier. Qu'à cela ne tienne, nous pouvons encapsuler nos variables bien plus étroitement.

Il suffit de supprimer la déclaration des quatre variables globales au module et de créer deux fermetures de portée plus limitée, dont le rôle correspond à ce que l'on appelle en programmation orientée objet un accesseur et un mutateur :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
{
    my ($error_buff_p, $connexion_id, $transaction_id);
    my $init_done = 0;
    sub get_val {return ($error_buff_p, $connexion_id, $transaction_id, $init_done);};
    sub set_val { ($error_buff_p, $connexion_id, $transaction_id, $init_done) = @_;};
}

Dans la fonction $init_connexion, les lignes suivantes :

 
Sélectionnez
    ($error_buff_p, $connexion_id, $transaction_id) = ($ebuf_p, $connect_id, $trans_id);
    $init_done = 1;

sont remplacées par :

 
Sélectionnez
    set_val($ebuf_p, $connect_id, $trans_id, 1);

De même, les autres fonctions de l'interface peuvent accéder aux valeurs de ces variables en appelant la fonction get_val. Par exemple, la fonction recherche_objet peut être modifiée comme suit :

 
Sélectionnez
sub recherche_objet {
    my $search_string_ref = shift;
    my ($error_buff_p, $connexion_id, $transaction_id, init_done) = get_val();
    die_error ("Appelez d'abord la fonction init_connection\n" unless $init_done);
    # 
}

Et voilà, le tour est joué, il n'y a plus de variables globales. C'était très simple finalement. Oui, mais… Est-il bien certain que l'on a vraiment gagné au change ? Pas si sûr… En effet, le programme appelant n'avait jusqu'à présent aucun moyen d'accès direct à ces données privées du module. Il en a maintenant un : il peut appeler les fonctions get_val et set_val. Même si ces fonctions ne sont pas exportées par le module, le programme appelant peut toujours les appeler en préfixant leur nom par celui du module et éventuellement faire n'importe quoi :

 
Sélectionnez
Mon_interface::set_val(1, 2, 3, 4); # aïe, pas bon du tout

Cette modification a certes permis de supprimer les variables globales, mais elle a offert au programme appelant une porte dérobée d'accès à des données qui lui étaient jusqu'ici parfaitement inaccessibles. L'accès en lecture par la fonction get_val ne paraît sans doute pas trop gênant, mais l'accès en écriture par la fonction set_val peut-être plus fâcheux. En fait, les deux cas peuvent être problématiques : si le développeur du programme appelant utilise ce moyen pour contourner l'interface qui lui a été offerte, le risque est que plus rien ne marche le jour où l'implémentation interne du module change, même si le développeur auteur de ces changements du module a pris la peine de respecter scrupuleusement l'interface existante.

Le problème provient du fait qu'une fonction ne peut pas être privée en Perl (du moins c'était vrai jusqu'à très récemment, la version 5.18 de Perl introduit à titre expérimental des fonctions de portée lexicale). Plus exactement, on ne pouvait pas avoir jusqu'à la version 5.18 de fonctions nommées de portée lexicale. Mais, alors, ne peut-on pas contourner le problème avec des références lexicales sur des fonctions anonymes, par exemple en ajoutant au début du module les coderefs suivantes :

 
Sélectionnez
my $get_var_ref = sub {return ($error_buff_p, $connexion_id, $transaction_id, $init_done)};
my $set_val_ref = sub {($error_buff_p, $connexion_id, $transaction_id, $init_done) = @_;};

3-2-4. Fonctions locales ou globales, un exemple plus dépouillé

L'utilisation de références sur des fonctions anonymes présentée au paragraphe précédent peut marcher, moyennant quelques autres changements ailleurs dans le code, mais, pour simplifier la discussion, nous allons maintenant abandonner ce module et considérer le module Essai_loc_func, une version bien plus dépouillée de la même problématique, avec une seule variable, un accesseur et un mutateur :

 
Sélectionnez
use strict;
use warnings;
package Essai_loc_func;
require Exporter;
our @ISA = qw/Exporter/;
our @EXPORT_OK = qw (init read_val);

my ($set_val_ref, $get_val_ref);
{
    my $val;
    $set_val_ref = sub { $val = shift; };
    $get_val_ref = sub { return $val ; };
}
sub init     { $set_val_ref->(shift);   };
sub read_val { return $get_val_ref->(); };
1;

Le programme utilisateur minimal peut avoir cette forme :

 
Sélectionnez
use strict;
use warnings;
use Essai_loc_func qw (init read_val) ;

init(7);
my $ret_val = read_val();
print $ret_val, "\n";    # imprime 7

Ces coderefs $set_val_ref et $get_val_ref sont des fermetures anonymes. Elles ne sont plus accessibles au programme appelant, on retrouve l'encapsulation forte du point de départ. Mais, si nous examinons le chemin parcouru, on peut aussi considérer que nous n'avons fait que tourner en rond : nous nous retrouvons avec deux nouvelles variables globales au module, les références aux fonctions anonymes, alors que tout le but était précisément de se débarrasser des variables globales.

Et la programmation objet dans tout cela ?

Lorsque nous parlons d'accesseur et de mutateur, et plus encore d'objets persistants, le lecteur perspicace aura sans doute compris que nous utilisons à dessein une terminologie généralement associée à la programmation orientée objet (POO). Ce n'est pas un hasard, il existe un vrai lien entre les fermetures et les objets. Plusieurs langages fonctionnels comme Scheme implémentent une possibilité de POO en utilisant les fermetures.

Il est possible de créer assez simplement en Perl un système à objets complets en utilisant les fermetures (y compris les notions de polymorphisme, de surcharge, d'héritage et même d'héritage multiple). Et cela a été fait à titre plutôt expérimental. Mais Perl offre de façon native un système d'objets plus simple, il paraît donc superflu d'utiliser les fermetures à cet effet (sauf qu'un document célèbre de Tom Christiansen, que l'on peut considérer comme le deuxième auteur de Perl après Larry Wall, montre comment améliorer l'encapsulation des objets Perl en utilisant les fermetures… Comme quoi il n'y a pas d'opposition entre les deux concepts, mais plutôt une réelle complémentarité.). Il existe aussi en Perl des systèmes d'objets plus modernes et très efficaces, comme Moose, Moo, Mouse, etc.

Ce document n'a pas pour but de traiter de la POO et nous n'irons donc pas plus loin sur le sujet, si ce n'est pour dire que les fermetures fournissent un bon moyen de créer des objets légers offrant naturellement, à défaut d'autre chose, la persistance et l'encapsulation des données.

Il peut sembler que nous sommes revenus au point de départ, puisque nous n'avons toujours pas la solution permettant d'éliminer les variables globales. D'un autre côté, nos nouvelles variables globales représentent des fonctions, qui sont généralement globales de toute façon, alors que les variables relatives à l'état de l'application sont maintenant bien locales et privées. Si le module était plus complexe, ce serait sans doute une réelle avancée. Peut-être avons-nous également progressé dans notre compréhension des grandes forces et menues faiblesses des fermetures. Nous allons voir que ces fermetures anonymes nous permettent de faire beaucoup de nouvelles choses.

3-3. Utilisation de fermetures anonymes pour renvoyer des fonctions

Reprenons le module Essai_loc_func du chapitre précédent. Nous pouvons le modifier comme suit (on ne répète plus le code bateau, use strict, Exporter, etc. et ne reprend que le code effectivement exécuté). Dans un premier temps, nous définissons l'accesseur get_val_ref comme une fonction renvoyée au passage par le mutateur set_val_ref :

Fonction retournant une fonction
Sélectionnez
my $set_val_ref;
my $get_val_ref;
{
    my $val;
    $set_val_ref = sub {
        $val = shift;
        return sub { return $val ; };
    };
}

sub init     { $get_val_ref = $set_val_ref->(shift);   }
sub read_val { return $get_val_ref->(); }

Le programme utilisateur du module reste le même.

Jusqu'à présent, nous avions utilisé des références à des fonctions pour passer une fonction à une nouvelle fonction. Ce que nous venons de faire est radicalement nouveau : la fonction référencée par $set_val_ref est appelée par la fonction init, elle donne à la variable $val la valeur reçue en paramètre de l'appelant, et elle renvoie une référence à l'accesseur, $get_val_ref. La référence scalaire $get_val_ref est déclarée au niveau du module, mais la fonction fermeture (accesseur) référencée par cette référence n'est définie qu'à l'exécution du mutateur, la fermeture référencée par $set_val_ref. Autrement dit, nous avons maintenant une fonction qui crée une nouvelle fonction. Une précision : il n'est pas possible en Perl (pas plus qu'en C) de créer une fonction nommée à l'intérieur d'une autre fonction, mais l'on vient de voir que cela est possible avec des fonctions anonymes.

Nous pouvons simplifier quelque peu la syntaxe : nous n'avons en fait plus besoin d'un bloc de code pour définir une portée lexicale pour la variable $val :

 
Sélectionnez
my $get_val_ref;
my $set_val_ref = sub {
    my $val = shift;
    return sub { return $val ; };
};

sub init     { $get_val_ref = $set_val_ref->(shift); }
sub read_val { return $get_val_ref->(); }

Le mutateur référencé par $set_val_ref est toujours une fonction anonyme, mais on constatera à l'examen que ce n'est plus une fermeture (il n'embarque pas avec lui un environnement d'exécution). En fait, cette fonction n'avait jamais eu vraiment besoin d'être une fermeture pour son fonctionnement interne, puisqu'elle n'est appelée qu'une seule fois et n'a donc pas besoin de « se souvenir » de la variable $val ; mais il était nécessaire jusqu'à présent qu'elle soit une fermeture pour qu'elle puisse partager la variable $val avec l'accesseur $get_val_ref. Maintenant que l'accesseur est défini au sein même de cette fonction, il suffit que la variable $val soit définie au sein de la même fonction avant la définition de l'accesseur pour que l'accesseur puisse « se fermer » sur cette valeur.

Oublions maintenant les raisons qui nous ont conduit initialement à faire du mutateur une fonction anonyme et nous pouvons écrire la forme pratiquement canonique d'une fonction ordinaire qui crée et renvoie une autre fonction :

 
Sélectionnez
sub cree_fermeture {
     my $val = shift;
    # modifie éventuellement $val
    # ...
      return sub { 
        # utilise $val pour générer une valeur $valeur
        # 
        return $valeur ;
        }
}
my $fonction_ref = cree_fermeture("some_initial_value");
# utilisation possible de $fonction_ref

Rien ne nous empêche d'appeler plusieurs fois la fonction cree_fermeture pour créer plusieurs fermetures ayant le même code, mais des environnements d'exécution différents.

3-4. Retour sur les itérateurs

Vous vous souvenez de l'itérateur qui retournait à la demande des carrés de nombres entiers successifs ? Nous pouvons maintenant écrire une fonction qui retourne plusieurs itérateurs différents. Supposons que nous voulions un itérateur de nombres pairs et un itérateur de nombres impairs.

Deux itérateurs
Sélectionnez
sub cree_fermeture {
     my $val = shift;
      return sub { return $val += 2;}
}
my $iter_pair = cree_fermeture(0);
my $iter_impair = cree_fermeture(1);

Appelons maintenant 5 fois l'itérateur de nombres pairs, 15 fois celui de nombres impairs et 5 fois celui des nombres pairs :

 
Sélectionnez
print $iter_pair->(), " " for 1..5;          print "\n";
print $iter_impair->(), " " for 1..15;       print "\n";
print $iter_pair->(), " " for 1..5;          print "\n";

À l'exécution, cela donne :

 
Sélectionnez
$ perl iter_pair_impair.pl
2 4 6 8 10
3 5 7 9 11 13 15 17 19 21 23 25 27 29 31
12 14 16 18 20

On constate que nous avons bien deux itérateurs indépendants qui « ne se mélangent pas les pinceaux » et se souviennent bien du nombre auquel ils sont arrivés. La fonction cree_fermeture a été appelée deux fois et a créé dynamiquement deux fonctions (des fermetures) différentes et indépendantes. Cette fonction crée_fermeture est devenue ce que l'on appelle parfois une « usine à fonctions » (function factory).

On peut sans doute trouver d'autres moyens simples de gérer des nombres pairs et des nombres impairs successifs, mais nous allons voir combien l'idée d'usine à fonctions peut se montrer féconde et offrir un degré d'abstraction et une capacité expressive supplémentaires.

Notons qu'une fonction Perl retourne par défaut la dernière expression évaluée. Il en résulte que les deux opérateurs return de la dernière ligne de la fonction cree_fermeture ne sont en fait pas nécessaires. J'ai préféré les garder dans les exemples précédents parce que cela explicite mieux ce qui se passe, mais cette fonction pourrait donc s'écrire plus concisément :

 
Sélectionnez
sub cree_fermeture {
     my $val = shift;
      sub { $val += 2;}
}

La fonction cree_fermeture renvoie (une référence vers) la fonction anonyme, et la fonction anonyme, quand elle est appelée, renvoie la valeur locale de fermeture incrémentée de 2.

Cette syntaxe est plus concise et peut séduire par son élégance, mais l'on peut aussi considérer qu'il est très souvent préférable de garder les appels à return qui expliquent plus clairement ce que renvoie chaque fonction. Chacun jugera selon ses affinités et les circonstances, mais il faut tout de même éviter que l'élégance syntaxique prime sur la clarté.

3-5. Des fonctions qui fabriquent des fonctions (ou usines à fonctions)

L'usine à fonctions ci-dessus est très simple, elle tient en trois lignes simples de code et ne présente aucune difficulté syntaxique ; la seule petite difficulté, pour un développeur impératif procédural traditionnel (ou même objet) non habitué à ce genre de construction, est au départ d'appréhender conceptuellement ce qui se passe. Nous ne pouvons qu'encourager le lecteur à essayer de développer lui-même ce type de construction. Le principe deviendra très vite lumineux et le lecteur découvrira sans doute très rapidement au cours de son activité de très nombreux cas où ce modèle de programmation lui simplifiera la vie.

Nous allons maintenant examiner quelques exemples concrets où j'ai utilisé des usines à fonctions pour résoudre des problèmes réels ; je les ai tous choisis très récents (moins de trois mois au jour où j'écris ces lignes), pour bien montrer combien cela peut servir régulièrement.

3-5-1. Un itérateur de fichier personnalisé

Considérons le problème professionnel réel suivant. Il s'agit de comparer les données de deux fichiers provenant de deux applications différentes concernant les mêmes clients. Les clients sont présents par ordre croissant de numéros de client. Un client est constitué de plusieurs enregistrements de types 0 à 6 :

  • Ligne de type 0 : une seule ligne de données propres à l'entité client ;
  • Lignes de type 1 : 0, 1 ou plusieurs lignes correspondant à des prestations souscrites par le client ;
  • Lignes de type 2 : 1 ou plusieurs lignes correspondant aux abonnements téléphoniques souscrits par le client ;
  • Lignes de type 3 : pour chaque ligne de type 2, une ou plusieurs lignes correspondant aux prestations associées à chaque abonnement téléphonique ;
  • etc.

Les données se présentent approximativement de la façon suivante :

 
Sélectionnez
0;cli1;billday_cli1;last_bill_date; next_bill_date;
1;cli1;prest_cli1_1; …
1;cli1;prest_cli1_2; …
2;cli1;dos1;caractéristiques cli1/dos1;
2;cli1;dos2;caractéristiques cli1/dos2;
3;cli1;dos1;prest1,caractéristiques cli1/dos1/prest1;
3;cli1;dos1;prest1,caractéristiques cli1/dos1/prest1;
3;cli1;dos2;prest1,caractéristiques cli1/dos2/prest1;
3;cli1;dos2;prest1,caractéristiques cli1/dos2/prest1;
…
0;cli2;billday_cli1;last_bill_date; next_bill_date;
1;cli2;prest_cli1_2; …
2;cli2;dos1;caractéristiques cli1/dos1;
2;cli2;dos2;caractéristiques cli1/dos2;
3;cli2;dos1;prest1,caractéristiques cli1/dos1/pr

Le programme doit lire les deux fichiers en parallèle et désire stoker dans un tableau toutes les lignes relatives à un client pour chacune des deux applications. Lire tous les enregistrements relatifs à un client dans ce genre de fichier présente une petite difficulté : on ne sait que l'on a lu toutes les lignes associées à un client donné que quand on trouve une ligne de type 0 relative à un nouveau client (ou quand on arrive à la fin du fichier), qu'il faut mettre de côté le temps de traiter les données relatives au client courant, puis reprendre la ligne mise de côté avant de recommencer à lire les fichiers. Rien de bien compliqué, mais comme nous lisons deux fichiers en parallèle, le code pour traiter ces cas particuliers risque de se trouver dupliqué, ce qui n'est pas très satisfaisant. Voici donc une fonction retournant un itérateur sur chacun des fichiers en entrée.(3)

 
Sélectionnez
sub return_iter_cust {
    my $file_in = shift;
    my $line;
    open my $IN,  "<", $file_in or die "cannot open input file $file_in $!";
    return sub {
        my @cust_lines;
        $line = <$IN> unless defined $line;
        chomp $line;
        push @cust_lines, [split /;/, $line];
        while ($line = <$IN>) {
            chomp $line;
            if ($line =~ /^0;/) {   #   le type de ligne est le premier caractère de la ligne
                    return \@cust_lines;
            } else {
                push @cust_lines, [split /;/, $line];
            }
        }
    }
}

Voici le code utilisant ces fonctions :

 
Sélectionnez
#    création des deux itérateurs sur les fichiers A et V
    my $a_iter = return_iter_cust ($file_a);
    my $v_iter = return_iter_cust ($file_v);
    #    utilisation des itérateurs
    while (my $full_a_cli_ref = $a_iter->()) {
        my $full_v_cli_ref = $v_iter->();
        # ...
    }

Les deux itérateurs « se ferment » sur deux valeurs, le descripteur de fichier (file handler) et la ligne du client suivant à mettre de côté pour l'utiliser à l'itération suivante.

3-5-2. Parcours de deux arbres binaires en parallèle

En été 2013, quelqu'un a proposé un défi sur le forum PerlMonks (sans doute le forum Perl le plus connu sur le plan international) : proposer une solution à un problème posé sur le site Rosetta Code (http://rosettacode.org/wiki/Same_Fringe). Il s'agit de comparer les feuilles de deux arbres binaires pour déterminer s'ils ont la même liste de feuilles lors d'un parcours de gauche à droite. La structure (et l'équilibre) des arbres n'a pas d'importance, seuls comptent le nombre, l'ordre et la valeur des feuilles. Il était précisé dans le défi que la solution devait être de préférence « paresseuse », c'est-à-dire qu'il fallait lire une par une chaque feuille de chaque arbre binaire pour les comparer, et arrêter la comparaison dès qu'une différence était trouvée ; autrement dit, la solution simple consistant à parcourir récursivement chaque arbre complètement pour établir deux listes de feuilles puis comparer les listes n'était pas jugée acceptable. Je précise qu'il n'y avait aucune solution proposée en Perl à l'époque sur le site Rosetta Code.

J'ai essayé de répondre à ce défi en essayant d'abord un parcours récursif des arbres (un peu comme le parcours des répertoires au début du présent document), mais me suis vite aperçu qu'il était au mieux très difficile d'effectuer deux parcours récursifs en parallèle. Il m'est donc apparu assez rapidement qu'il fallait implanter deux itérateurs, un pour chaque arbre. Chaque itérateur récupère la feuille suivante de l'arbre sur lequel il travaille et la comparaison s'arrête si les feuilles récupérées ne sont pas identiques. Si on arrive à la fin de la comparaison, les listes des feuilles étaient bien identiques.

Voici le programme auquel je suis arrivé (j'ai élagué ci-dessous une partie du jeu de tests) :

 
Sélectionnez
#!/usr/bin/perl
use strict;
use warnings;

my $a = [ 1, [ 2, [ 3, [ 4, 5 ] ] ] ];
my $b = [ 1, [ [ 2, 3 ], [ 4, 5 ] ] ];
my $c = [ [ [ [ 1, 2 ], 3 ], 4 ], 5 ];
sameFringe( $a, $a );
sameFringe( $a, $b );
sameFringe( $a, $c );

sub sameFringe {
    my $next_el1 = create_iterator(shift);
    my $next_el2 = create_iterator(shift);
    my $match = 1;
    while (1) {
        my $left = $next_el1->();
        my $right = $next_el2->();
        no warnings 'uninitialized';
        print $left, " ", $right, "\n";
        unless ($left eq $right) {$match = 0 ; last} ;
        last unless defined $left;
    }
    $match ? print "the trees match\n": print "the trees don't match\n";
}

sub create_iterator {
    my $ref = shift;
    my @ref_list;
    return sub {
        while (ref $ref eq 'ARRAY') {
            unshift @ref_list, @$ref;
            $ref = shift @ref_list;
        } 
        my $leaf = $ref;
        $ref = shift @ref_list;
        return $leaf;
    }
}

Le code de la solution proprement dite (sans les jeux de test) tient en moins de 30 lignes de code (je n'ai pas fait d'effort particulier de concision), ce qui est à peu près l'ordre de grandeur des solutions présentées dans les langages fonctionnels (Ocaml ou Clojure, par exemple). Les langages objet comme Java nécessitent environ 5 fois plus de code. Ada ou C sont encore plus gourmands en lignes de code, mais il est vrai qu'Ada a une structure syntaxique un peu bavarde rendant ce genre de comparaison un peu injuste à son égard. Haskell et Picolisp font légèrement mieux que la version Perl (environ 20 lignes de code). À signaler que le gagnant toutes catégories (de très loin) est Perl 6 : sa solution tient en 4 lignes ! Il est assez courant aujourd'hui de voir beaucoup de gens (peut-être blasés, mais sans doute très bien informés pour certains) douter qu'une version de production de Perl 6 voie finalement le jour ; sans prendre parti dans ce débat, je dirais seulement qu'au vu de la puissance expressive de Perl 6, il y a tout lieu d'espérer que l'avenir leur donnera tort.

Pour la petite histoire, 3 solutions réellement valables (à mes yeux) ont été présentées sur le forum PerlMonks. Deux très similaires fondées sur des itérateurs (celle qui se trouve actuellement sur le site Rosetta Code à l'adresse indiquée et la mienne présentée ci-dessus) et une troisième, assez différente et originale, utilisant des threads en parallèle pour parcourir les arbres.

3-5-3. Un itérateur générique ?

Au début de ce document, nous avons ajouté des fonctions de rappel pour rendre générique la fonction récursive parcours_dir(). Ne peut-on pas faire la même chose avec les fermetures pour produire par exemple un itérateur générique ?

L'itérateur générique pourrait avoir par exemple la forme suivante :

 
Sélectionnez
sub create_iter {
     my ($code_ref, @rest) = @_;
     return sub {$code_ref->();}
}

Il reçoit deux catégories de paramètres, une référence sur une fonction et la liste des variables sur lesquelles la fermeture doit se fermer.

On veut ensuite créer l'itérateur renvoyant une suite de nombres pairs en appelant la fonction create_iter :

 
Sélectionnez
my $pair = create_iter(sub {my $c; $c += 2; return $c;},  0);   #  ne marche pas

Et là, ça ne marche pas : si la variable $c est définie à cet endroit, la fonction ne se fermera pas dessus, puisqu'elle est définie ailleurs (pas dans la même portée). Cela ne marchera guère mieux si l'on essaie d'utiliser le tableau @rest défini là où il faut :

 
Sélectionnez
my $pair = create_iter(sub {$rest[0] += 2; return $rest[0] ;},  0) ;   #  ne marche toujours pas

Cela ne compilera même pas si le pragma 'use strict;' est activé, parce que la variable @rest n'est pas définie à cet endroit (et la transformer en variable lexicale et l'affublant d'une déclaration de type my ne résout rien puisque l'on retombe dans le problème précédent). Sans le 'use strict;', le programme pourra sans doute compiler, mais ne fonctionnera pas pour autant, les variables sont hors de portée.

En fait, nous avons des problèmes, car nous ne savons pas trop comment gérer les variables, mais le fond du problème est que cela ne peut tout simplement pas marcher parce que la fonction de rappel n'est pas définie dans le contexte voulu, dans l'environnement d'exécution qui en ferait une fermeture.

Est-il donc impossible de réaliser un itérateur générique ? Avec le genre de solutions essayées, il semble bien que oui.

Mais il y a moyen de contourner le problème au moyen d'une petite ruse permettant de passer passer en paramètre le code de la fonction de rappel et de faire en sorte qu'il ne soit réellement défini qu'au sein de l'environnement de la fermeture. Cette ruse est l'utilisation de la fonction Perl eval, qui prend en paramètre une chaîne de caractères et l'exécute comme du code Perl (à condition que le contenu de la chaîne de caractères soit du code Perl valide).

Voici donc une usine à itérateurs générique créant 4 itérateurs différents. Le premier paramètre passé à create_iter() n'est plus une coderef, mais une chaîne de caractères représentant le code de la fonction à implémenter comme fermeture. Les fonctions associées à fibo et fIbo2 sont toutes les deux des itérateurs sur la suite de Fibonacci, mais l'une utilise des variables locales, l'autre utilise directement le tableau variable de fermeture (le but étant d'illustrer deux syntaxes légèrement différentes). La fonction fact itère sur les factorielles successives d'un nombre, et l'itérateur sur les carrés des nombres entiers montre que cela fonctionne aussi avec un nombre différent de paramètres (un au lieu de deux).

 
Sélectionnez
use strict;
use warnings;

sub create_iter {
     my ($code_string, @rest) = @_;
     eval $code_string;
}

my $fibo1 = create_iter (
    ' return sub {my ($c, $d) = @rest;
             my $e = $c + $d; @rest = ($d, $e); 
             return $e;}
    ',
    1, 1);
print "Fibo 1: \n";
print $fibo1->(), " ", for 1..7; # 

my $fibo2 = create_iter (
    q {
        return sub {@rest[0,1] = ($rest[1], $rest[0] + $rest[1]);
         return $rest[1];}
    },
     1, 1);
print "\n\nFibo2, premier appel: \n";
print $fibo2->(), " " for 1..5;

my $fact = create_iter (<<'EOS'
    return sub { $rest[0]++; $rest[1] *= $rest[0];};
EOS
    , 1, 1);
print "\n\nFact: \n";
print $fact->(), " ", for 1..5;

print "\n\nFibo2, second appel: \n";
print $fibo2->(), " " for 1..5;

my $carres = create_iter ('sub { (++$rest[0])**2 }', 1);
print "\n\nCarres: \n";
print $carres->(), " " for 1..5;

Ce programme imprime ce qui suit :

 
Sélectionnez
$ perl  generic_iter.pl
Fibo 1:
2 3 5 8 13 21 34

Fibo2, premier appel:
2 3 5 8 13

Fact:
2 6 24 120 720

Fibo2, second appel:
21 34 55 89 144

Carres:
4 9 16 25 36

Je n'ai jusqu'à présent pas encore utilisé d'itérateur générique de ce type dans la vie réelle, mais, à vrai dire, je n'ai imaginé cette solution qu'assez récemment (je ne prétends en aucun cas en être l'inventeur, mais comme je ne l'ai jamais vue décrite ou utilisée ailleurs, je peux du moins dire que je l'ai imaginée personnellement). Je ne sais donc pas vraiment si ce genre d'itérateur a une grande utilité dans la pratique, mais l'utilisation de la fonction eval est à retenir comme un moyen supplémentaire de passer une fonction à une fonction sous la forme d'une chaîne de caractères, dans des cas un peu compliqués où des coderef ne fonctionneraient pas correctement. Plusieurs modules du CPAN utilisent cette possibilité. Par exemple, le module Benchmark de Perl offre la possibilité de passer en paramètre la fonction dont on désire mesurer les performances soit sous la forme d'une coderef, soit sous celle d'une chaîne de caractères qui est alors testée avec eval.

3-5-4. Une file d'attente (FIFO) et une pile (LIFO)

Deux classiques de l'algorithmique : créer une file d'attente ou queue FIFO (first in, first out : premier entré, premier sorti) ou une pile (stack) LIFO (last in, first out : dernier entré, premier sorti). Chaque structure de données nécessite au moins deux fonctions, une pour ajouter un élément et une pour en retirer (et récupérer) un. La syntaxe permettant à l'usine à fonctions de retourner deux fonctions ne pose aucune difficulté particulière :

 
Sélectionnez
use strict;
use warnings;

sub create_fifo {
    my @fifo_arr;
     return (sub {return shift @fifo_arr;}, sub {push @fifo_arr, shift;}) ;
}
my ($fifo_get, $fifo_put) = create_fifo ();
$fifo_put->($_) for 1..10;
print $fifo_get->() for 1..5;
print "\n";

sub create_lifo {
     my @lifo_arr;
     return (sub {return pop @lifo_arr;}, sub {push @lifo_arr, shift;}) ;
}
my ($lifo_get, $lifo_put) = create_lifo ();
$lifo_put->($_) for 1..10;
print $lifo_get->() for 1..5;

Bien entendu, les implémentations ci-dessus d'une file et d'une pile sont réduites à leur plus simple expression. Il faudrait au minimum ajouter du code de gestion des erreurs (traiter le cas d'un get sur une file ou une pile vide, par exemple).

3-5-5. Exemple réel : chargement de hachages de paramétrage

Une application professionnelle réelle : je dois écrire un programme comparant les données de clients entre deux applications. Ce sont les mêmes clients, mais la modélisation des offres commerciales diffère entre les deux applications. Pour comparer les données, il faut d'abord effectuer une transcodification des offres de l'application A en offres de l'application B (ou l'inverse), pour vérifier que ce sont bien les mêmes. Pour ce faire, il existe six fichiers de correspondance entre les deux systèmes. Chaque fichier est constitué de façon identique : un champ donnant le code de l'application A et un champ donnant la correspondance dans l'application B, séparés par des points-virgules. Il suffit donc, avant de commencer la comparaison, de lire ces six fichiers et de les charger dans des tables de hachage permettant d'effectuer la transcodification entre les deux systèmes à comparer.

Plutôt que d'écrire six fonctions lisant chacune un fichier en entrée et alimentant chacune un hachage global utilisé par la suite, j'ai écrit une seule usine à fonctions fabriquant des fermetures anonymes. Cette usine à fonctions est appelée six fois avec à chaque fois le nom du fichier en entrée, alimente un hash local privé avec le contenu du fichier et retourne une fonction dont le seul rôle est d'effectuer la transcodification en lisant son propre hash privé.

Cette fonction génératrice de fonctions anonymes est écrite comme suit :

Génératrices de fonction de paramétrage
Sélectionnez
sub load_params {
     my $file_name = shift;
     my @files = glob ("$data_path${file_name}*.txt");
     my $file = pop @files;
     my %param;
     open my $IN, "<", $file or die "Ouverture impossible de $file $!";
     while (<$IN>) {
         chomp;
         next if /^\s*$/;
         my ($key, $val) = split /;/, $_;
         $param{$key} = $val
     }
     close $IN;
     return sub {
         my $key = shift;
         return unless defined $param{$key};
         return $param{$key};
     }
}

La première partie reçoit un nom incomplet du fichier à charger, recherche le fichier, l'ouvre et le charge dans le hash %param. La fonction renvoie ensuite une fonction anonyme chargée d'effectuer la transcodification en renvoyant la valeur du hash correspondant à la clé reçue en paramètre. Le hash de paramétrage porte le même nom dans chacun des six cas, mais c'est dans chaque cas un hash différent qui est privé à la fonction anonyme renvoyée. En appelant la bonne fonction anonyme, on a la certitude de consulter le hash voulu. Il suffit maintenant d'appeler six fois la fonction load_params pour obtenir six coderefs spécialisées.

Voici la génération des fonctions anonymes pour deux des six fichiers de paramétrage :

Création des fonctions anonymes
Sélectionnez
my $offer_mapping_ref        = load_params("B_mapping_offer-mapping_");
my $migration_mapping_ref    = load_params("B_mapping_migration-status_");

La transcodification proprement dite est pratiquement aussi simple que s'il fallait consulter une table de hachage :

Transcodification d'une offre
Sélectionnez
my $ot_B = $offer_mapping_ref->($offre_A);

La mise en place de ce mécanisme d'usine à fonctions a permis de réduire à moins de 25 lignes de code des opérations qui en auraient demandé sans doute entre 130 et 150 avec des méthodes plus classiques. Cela peut paraître un peu plus compliqué la première fois que l'on utilise ce genre de procédé, mais c'est très clair dès la seconde fois qu'on l'utilise. Et s'il y a une anomalie quelque part, il n'y a besoin de la corriger qu'une seule fois (et non six fois), ce qui simplifie considérablement la maintenance.

Une évolution nécessitant un nouveau fichier de paramétrage (pour autant qu'il ait la même structure) demande a priori l'ajout de deux lignes de code, pas plus. La fonction centrale loads_params ne nécessite pas le moindre changement.

Cela dit, il y a aussi moyen d'obtenir un résultat similaire en à peu près aussi peu de lignes de codes sans recourir aux fermetures : on peut aussi créer une fonction générique qui lit le fichier concerné et renvoie le hash voulu :

 
Sélectionnez
sub load_params {
     my $file_name = shift;
     my @files = glob ("$data_path${file_name}*.txt");
     my $file = pop @files;
     my %param;
     open my $IN, "<", $file or die "Ouverture impossible de $file $!";
     while (<$IN>) {
         chomp;
         next if /^\s*$/;
         my ($key, $val) = split /;/, $_;
         $param{$key} = $val
     }
     close $IN;
     return %param;
}

La différence importante entre les deux techniques est que la version avec fermeture permet d'encapsuler les données (il n'y a vraiment aucun autre moyen d'y accéder que par l'interface offerte, et il est impossible de les modifier), alors que la dernière version ci-dessus laisse à l'utilisateur le moyen de les modifier (ce qui est généralement une mauvaise idée), même si le code se trouve dans un module séparé.

3-5-6. Écrire dans des fichiers multiples (répartir des données)

3-5-6-a. Premier exemple d'écriture dans des fichiers multiples

Quand j'écrivais il y a une dizaine de jours (quelques pages plus haut) que je ne prendrais que des exemples récents datant de moins de 3 mois, je ne savais pas encore que j'allais finalement fournir un exemple que je n'avais alors pas encore écrit. C'est le cas de l'exemple ci-dessous.

Soit une application générant un assez gros fichier (environ 400 000 lignes) de modifications à apporter à une application répartie sur sept serveurs gérant les boîtes vocales (BV) associées à des abonnements téléphoniques. Il faut répartir ce fichier en plusieurs fichiers de sortie. En simplifiant, disons qu'il y a un seul type de fichier de sortie pour les mouvements de suppression de BV, et que les mouvements de modification ou de création de BV doivent être répartis par serveur, soit huit types de fichiers en tout. Pour des raisons d'exploitabilité, aucun fichier ne doit dépasser 20 000 mouvements, il faut donc créer plusieurs fichiers de sortie en séquence si le nombre de mouvements d'un certain type dépasse cette limite. De plus, un client donné peut donner lieu à deux ou trois mouvements ; dans ce cas, tous les mouvements relatifs au même client doivent être conservés dans le même fichier. Les noms des fichiers doivent répondre à une nomenclature particulière, qui utilise la date du jour et précise notamment que les fichiers pour une même plateforme doivent être numérotés de 'aa', 'ab', 'ac'… jusqu'à 'zz'. Passons sur deux ou trois petites complications complémentaires, disons pour résumer que la répartition des données, qui pourrait être très simple, est finalement assez complexe et regorge de cas particuliers.

J'ai écrit une usine à fonctions d'écriture pour créer huit fermetures anonymes (une par serveur et une pour les suppressions). Le code principal lit chaque ligne du fichier en entrée, détermine le type de fichier de sortie et utilise une table de répartition pour appeler la fermeture voulue. La gestion des noms de fichiers, des descripteurs de fichiers, de la limite des 20 000 dossiers et des dossiers à ne pas dégrouper est confiée à chaque fermeture. Dans le programme ci-dessous, je simplifie un certain nombre de cas particuliers et limite les fichiers de sortie à 3 serveurs (au lieu de 7 en réalité) plus le fichier de suppressions. Ce qui nous donne le code (allégé et anonymisé) suivant :

 
Sélectionnez
#!/usr/bin/perl
use strict;
use warnings;

my ($jour, $mois) =  (localtime time)[3, 4];
my $date = sprintf  "%02d%02d", ++$mois, $jour;
my $root_name = "99$date-";
my $chemin = './RESULT';

my $write_od1 = create_file_writer("$chemin/B$root_name");
my $write_od2 = create_file_writer("$chemin/E$root_name");
my $write_od3 = create_file_writer("$chemin/H$root_name");
# fichiers de suppression de BV
my $write_suppr = create_file_writer("$chemin/majmv-");

my %dispatch_table = (
    'COD1' => $write_od1,
    'COD2' => $write_od2,
    'COD3' => $write_od3,
    'SUPP' => $write_suppr 
);

# note: on aurait pu écrire directement les fonctions dans la table:
# $dispatch_table{'COD1'} = create_file_writer("$chemin/B$root_name");
# et ainsi de suite. Nous l'avons laissé en deux étapes pour des 
# raisons pédagogiques, afin de rester simple et clair. 

my $file_in = "./TMP/CORRECTION_MV.TMP";
open my $INFILE, "<", $file_in or die "Ouverture impossible du fichier $file_in - $!";
while (my $line = <$INFILE>) {
    my $plateforme = substr $line, 52, 4;
    $dispatch_table{$plateforme}->($line);
}

sub create_file_writer {
    my $root_file_name = shift;
    my $line_count = 0;
    my (@count_file, $fh);
    my $old_dos = "";
    return sub {
        my $line = shift;
        if ($line_count == 0) {  #   cas  il va falloir changer de fichier
            my $new_dos = substr $line, 4, 10;
            # on ne change de fichier que si c'est un nouveau dossier
            print $fh and return if $new_dos eq $old_dos; 
            close $fh if defined $fh;
            @count_file = incremente_compteur(@count_file);
            my $sequence = join '', @count_file;
            my $file_name = "$root_file_name$sequence.txt";
            open $fh , ">", $file_name or die "Ne peut ouvrir $file_name$!";
        }
        print $fh $line;
        $line_count ++;
        if ( $line_count >= 19990) {   # max = 20000, mais il ne faut pas dégrouper 
            $line_count = 0;
            $old_dos = substr $line, 4, 10;
        }
    }
}    
            
sub incremente_compteur {
    # la gestion des numéros de séquence alphabétiques n'est pas si simple
    # et mérite une procédure séparée, mais ne présente pas d'intérêt 
    # particulier dans notre exemple pour ce tutoriel
    # 
     return @new_counter;
}

Le programme réel comprend environ 80 lignes de code. Il remplace un autre programme écrit avec des techniques plus traditionnelles qui prenait environ 220 lignes, alors que les règles de gestion étaient alors plus simples.(4)

3-5-6-b. Second exemple d'écriture dans des fichiers multiples

En novembre 2013, quelqu'un demanda sur le forum Perlguru comment simplifier le code de son programme. Voici une version un peu abrégée de son programme :

Code à simplifier
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
my $GB_BACKFILL = "$pwd/BADDEBT-BACKFILL_FILE-GB";
my $DE_BACKFILL = "$pwd/BADDEBT-BACKFILL_FILE-DE";
my $FR_BACKFILL = "$pwd/BADDEBT-BACKFILL_FILE-FR";
my $ES_BACKFILL = "$pwd/BADDEBT-BACKFILL_FILE-ES";
my $IT_BACKFILL = "$pwd/BADDEBT-BACKFILL_FILE-IT";

open (GB_BACKFILL, "> $GB_BACKFILL") or die "unable to open output file: $!";
open (DE_BACKFILL, "> $DE_BACKFILL") or die "unable to open output file: $!";
open (FR_BACKFILL, "> $FR_BACKFILL") or die "unable to open output file: $!";
open (ES_BACKFILL, "> $ES_BACKFILL") or die "unable to open output file: $!";
open (IT_BACKFILL, "> $IT_BACKFILL") or die "unable to open output file: $!";

while (my $line=<BADDEBT>) {
    my @row = split (",",$line);

    $date = $row[1];
    $esid = $row[4];
    $amount = $row[5];
    $ppcl_id=$row[8];
    $cur=$row[6];
    $cust_id =$row[9]; 
    if ($custid)
    {
       if($ppcl_id == 3)
       {
           print GB_BACKFILL "$custid|$date|391|$esid|-$amount|$cur|$ppcl_id|8\n ";
       }
       elsif($ppcl_id==4)
       {
           print DE_BACKFILL "$custid|$date|391|$esid|-$amount|$cur|$ppcl_id|8\n";
       }
       elsif($ppcl_id==5)
       {
           print FR_BACKFILL "$custid|$date|391|$esid|-$amount|$cur|$ppcl_id|8\n ";
       }
       elsif ($ppcl_id==44551)
       {
           print ES_BACKFILL "$custid|$date|391|$esid|-$amount|$cur|$ppcl_id|8\n";
       }
       elsif($ppcl_id == 35691)
       {
           print IT_BACKFILL "$custid|$date|391|$esid|-$amount|$cur|$ppcl_id|8\n ";
       }
    }
}

On voit que la longue suite d'opérateurs if/elsif sert uniquement à répartir les données dans des fichiers différents, les données à imprimer sont toujours les mêmes. Pour pouvoir simplifier ce code, il est judicieux d'utiliser des descripteurs de fichier (file handlers) lexicaux, car cela permet de les manipuler comme de simples variables.

Une première solution proposée consistait à utiliser une série d'opérateurs ternaires ? : imbriqués pour déterminer le descripteur de fichier à utiliser. Cela donnait quelque chose comme ceci (la solution proposée était abrégée) :

Opérateurs ternaires
Sélectionnez
open my $US_BACKFILL, '>', 'usfile.txt' or die "$!"; 
open my $CA_BACKFILL, '>', 'cafile.txt' or die "$!"; 
open my $BR_BACKFILL, '>', 'brfile.txt' or die "$!"; 
# etc. pour les autres fichiers
# ...
 
my $BACKFILL = $ppcl_id ==      1 ? $US_BACKFILL 
             : $ppcl_id ==      7 ? $CA_BACKFILL 
             : $ppcl_id == 526970 ? $BR_BACKFILL 
             # : etc.
             :                      undef 
             ; 
if (defined $BACKFILL) { 
   print {$BACKFILL} "$custid|$date|391|$esid|-$amount|$cur|$ppcl_id|8\n ";  
}

Une seconde solution (également abrégée) consistait en l'utilisation d'une table de hachage pour stocker les descripteurs de fichiers, comme ci-dessous :

Hachage de descripteurs de fichiers
Sélectionnez
my ?CKFILL; 
 
open $BACKFILL{1}, '>', 'US_backfill.txt' or die "failed to open 'US_BACKFILL.txt' $!"; 
open $BACKFILL{6}, '>', 'JP_BACKFIll.txt' or die "failed to open 'JP_BACKFILL.txt' $!"; 
open $BACKFILL{7}, '>', 'CA_backfill.txt' or die "failed to open 'CA_BACKFILL.txt' $!"; 
# ...
 
# Des accolades sont nécessaires autour du descripteur de fichier 
print {$BACKFILL{$ppcl_id}} "$custid|$date|391|$esid|-$amount|$cur|$ppcl_id|8\n ";

Voici maintenant une solution complète utilisant une table de répartition et une usine à fonctions produisant des fermetures anonymes :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
my %dispatch; 
$dispatch{3}      = create_func( "$pwd/BADDEBT-BACKFILL_FILE-GB"); 
$dispatch{4}      = create_func( "$pwd/BADDEBT-BACKFILL_FILE-DE"); 
$dispatch{5}      = create_func( "$pwd/BADDEBT-BACKFILL_FILE-FR"); 
$dispatch{44551}  = create_func( "$pwd/BADDEBT-BACKFILL_FILE-ES"); 
$dispatch{35691}  = create_func( "$pwd/BADDEBT-BACKFILL_FILE-IT"); 

while (my $line = <BADDEBT>) {
    my ($date, $esid, $amount, $ppcl_id, $cur, $custid) = (split ",", $line)[1,4,5,8,6,9];
    $dispatch{$ppcl_id}->("$custid|$date|391|$esid|-$amount|$cur|$ppcl_id|8") if $custid; 
}
sub create_func { 
     open my $FH, ">", $_[0] or die "could not open $_[0] $!"; 
     return sub { print $FH shift, "\n"; } 
}

On remarque que la taille du code est divisée par 3.

Il faut noter que ce code utilise implicitement le fait que Perl ferme automatiquement tout descripteur de fichier sortant de la portée lexicale. En général, je préfère fermer explicitement mes descripteurs de fichiers dès que je n'en ai plus besoin, même quand cela n'est pas vraiment utile, mais cela compliquerait ici notablement le code sans apporter de valeur ajoutée. S'il est nécessaire d'effacer l'un des fichiers, ou de changer ses droits d'accès, il faut d'abord que le descripteur soit bien fermé. Cette technique oblige donc, dans ce genre de cas, à gérer finement la portée lexicale des descripteurs pour assurer que les fichiers soient bien fermés quand nous voulons effectuer une nouvelle opération système sur eux.

4. Conclusion de cette partie

La première partie de ce tutoriel avait montré comment l'utilisation des opérateurs de listes inclus dans Perl permettait d'élargir les fonctionnalités du langage grâce à la magie des fonctions de rappel incluse dans la syntaxe de base de fonctions comme sort, map ou grep.

Nous avons montré ici comment utiliser des références à des fonctions pour créer nos propres fonctions d'ordre supérieur pour résoudre quantité de problèmes particuliers. Nous avons ainsi créé des fonctions de rappel, des tables de distribution, des fonctions retournant des fonctions, des fermetures, des itérateurs, des générateurs de fonctions, etc.

Dans la troisième et dernière partie de ce tutoriel, nous montrerons comment utiliser ces concepts pour étendre le langage et, notamment, créer nos propres nouveaux opérateurs de listes et, plus généralement, comment créer des fonctions génériques abstraites permettant d'enrichir le langage Perl.

5. Remerciements

Je remercie Djibril et Claude Leloup pour leur relecture attentive et leurs conseils avisés dans l'amélioration de ce tutoriel.

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   


Ce bloc a été expliqué dans la première partie de ce tutoriel, nous n'y reviendrons pas.
À noter que le code d'exemple proposé ici fait une analyse manuelle sommaire des arguments passés au programme pour ne pas encombrer ce tutoriel de considérations n'ayant rien à voir, mais il serait plus judicieux dans un cas réel d'utiliser l'un des modules d'analyse des arguments de la ligne de commande, par exemple Getopt::Long.
Les fichiers étant des CSV (données séparées par des points-virgules : « ; »), il pourrait être recommandable d'utiliser le module Text::CSV. En l'occurrence, ayant la maîtrise complète de la génération des fichiers en entrée, je sais qu'un simple split sur le « ; » suffit.
Entre le moment où ces lignes ont été écrites (mi-novembre 2013) et celui où je m'apprête à publier ce tutoriel (début janvier 2014), nous avons découvert qu'il serait plus pratique de générer un type de fichier supplémentaire pour certaines actions correctives particulières. Hormis le code nécessaire pour détecter ce cas de figure particulier, cette modification a demandé l'ajout d'une seule ligne de code à l'architecture du programme : un nouvel appel à la fonction create_file_writer avec les nouveaux paramètres voulus. Difficile de faire plus simple, non ?

  

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2014 Laurent Rosenfeld. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.