Trois cas d'usage des fermetures

le 20/03/2007 par Christophe Thibaut
Tags: Software Engineering

Un des avantages souvent noté dans les langages "dynamiques" de la lignée de perl : python, ruby, groovy, etc. est la notion de fermeture. Qu'est-ce qu'une fermeture et à quoi cela sert-il ? Essayons cette définition (issue de ce post très intéressant de Neal Gafter, qui cherche à les introduire dans Java : a definition of closures ) :

Une fermeture est une fonction qui capture les liaisons à des variables libres dans son contexte lexical.

C'est une définition assez ingrate, mais plutôt que de tourner autour indéfiniment à la recherche d'une meilleure abstraction, prenons trois exemples, illustrant trois usages possibles des fermetures:

1) Cacher un état dans une fonction

Voici une fonction simple suivie d'un appel (en ruby) :

def montantTTC(montant)
montant * 1.196
end
mtFacture = montantTTC(48.07)

Ici le taux appliqué est constant. Supposons que nous voulions rendre le taux de TVA variable, et que pour une raison saugrenue nous ne voulions pas passer ce taux dans chaque expression où l'on calcule le montant TTC. Nous pourrions écrire :

def appliqueTVA(taux)
Proc.new { |montant| montant * (1 + taux) }
end

La fonction appliqueTVA retourne une fermeture, c'est à dire un bloc de code exécutable (délimité par les accolades) dans lequel les variables sont liées à un contexte d'exécution particulier.

  • La variable montant est un paramètre du bloc; elle sera liée à la valeur fournie par l'appelant du bloc de code.
  • La variable taux serait libre à l'intérieur du bloc, si elle n'était pas définie comme paramètre dans la fonction appliqueTVA. La valeur de cette variable sera donc fournie par l'appelant de la fonction appliqueTVA, et associée jusqu'à nouvel ordre au bloc de code.

montantTTC = appliqueTVA(0.196)
mtFacture = montantTTC.call(48.07)

Un appel à la fonction appliqueTVA avec la valeur 0.196 produit donc un bloc de code capable de calculer un montant TTC pour cette valeur précise de taux. On peut ranger ce bloc dans une variable afin de l'utiliser dans une autre partie du code de l'application, plus tard. L'exemple suivant montre l'intérêt de pouvoir séparer dans le temps la constitution d'un bloc de code et son exécution.

2) Différer l'évaluation d'une fonction :

On veut écrire, à l'aide d'une librairie spécialisée, un programme créant dynamiquement une IHM. Un code spécifique décrit les étapes de la construction de l'interface, ce que nous pourrions schématiser comme suit :

  1. construis un formulaire,
  2. inclus dans le formulaire trois champs d'édition,
  3. inclus des boutons libellés Valider et Annuler,
  4. lorsque le bouton Valider est cliqué, tu fais le traitement XYZ,
  5. lorsque le bouton Annuler est cliqué, tu refermes le formulaire.

Dans cette liste, les instructions 4 et 5 ont une portée fort différente des trois premières : elles n'instruisent plus la réalisation du formulaire, mais le comportement de celui-ci une fois réalisé et en cours d'utilisation. Pour définir ce comportement, le constructeur de l'IHM affecte à l'une des propriétés de l'IHM, un bloc de code, ce qui revient à dire :

  • sur l'évènement clic du bouton Valider tu appelleras ma procédure traitementXYZ

    d'où le terme de callback, utilisé pour désigner ce type de mécanisme: un objet de haut niveau qui "passe" une de ses procédures à un objet de plus bas niveau afin que celui-ci le rappelle plus tard.

    Voici un exemple d'utilisation de fermetures dans un programme ruby construisant une interface Tk

    controleur = Controleur.new
    ...
    traitementXYZ = Proc.new { controleur.valide(edMontant.text)
    controleur.calculeXYZ }
    annuler = Proc.new { exit }
    ...
    btValider = TkButton.new(top, text => 'Valider',
    command => traitementXYZ)
    btAnnuler = TkButton.new(top, text => 'Annuler',
    command => annuler)
    ...

    Ici, l'assignation du bloc de code au contrôle concerné se fait via sa propriété command.

    3) Créer des fonctions de haut-niveau :

    Les fermetures représentent un avantage majeur en termes de modularité du code, car elles permettent de créer des fonctions de haut-niveau, i.e. des fonctions ayant des fonctions comme paramètres en entrée ou en sortie.

    Imaginons une liste d'employés :

    class Employe
    attr_reader :nom, :salaire
    def initialize(nom, salaire, dateEntree)
    @nom,@salaire,@dateEntree = nom,salaire,dateEntree
    end
    def anciennete
    ((Time.now - @dateEntree) / 31557600).floor
    end
    end
    john = Employe.new("John", 40043, Time.mktime(2006,5,17))
    bill = Employe.new("Bill", 34500, Time.mktime(2005,10,7))
    maud = Employe.new("Maud", 35000, Time.mktime(2003,1,3))
    employes = [john, bill, maud]

    ..à partir de laquelle on souhaite effectuer des sélections, sur le salaire par exemple. Dans un monde sans fermeture, nous écririons :

    def selectionSalaire(employes, min, max)
    result = []
    for employe in employes
    result.push(employe) if employe.salaire >= min && employe.salaire < max
    end
    result
    end

    C'est un code particulièrement lourd pour du ruby, mais néanmoins correct comme le montre l'égalité suivante :

    [john, maud] == selectionSalaire(employes, 35000, 50000)

    Imaginons une autre fonction de sélection, sur l'ancienneté, cette fois :

    def selectionAnciennete(employes, min)
    result = []
    for employe in employes
    result.push(employe) if employe.anciennete >= min
    end
    result
    end

    Cette fonction marche également :

    [bill, maud] == selectionAnciennete(employes, 1)

    Si nous cherchons à généraliser cette conception, plusieurs problèmes se posent :

    • Le code d'extraction de la sélection est à chaque fois le même, à la condition près. Comment factoriser ce code ?
    • Le code définissant le critère de sélection est entièrement couplé à la méthode de parcours de la liste. Comment découpler la constitution d'un prédicat de sélection et son utilisation ?
    • Si nous enchaînons ensemble des sélections, nous ne pouvons obtenir que restrictions successives. Comment pourrions-nous combiner des critères entre eux?

    Voici une solution sans fermeture: nous créons une classe de base pour les prédicats sur les employes :

    class PredicatEmploye  
      def estValide(employe)  
        true  
      end  
    end  
    

    et nous modifions la fonction de sélection afin qu'elle utilise notre objet prédicat :

    def selection(employes, predicat)
    result = []
    for employe in employes
    result.push(employe) if predicat.estValide(employe)
    end
    result
    end

    Pour créer de nouveaux types de prédicats, il suffit de dériver cette classe :

    class PredicatEmployeSalaire < PredicatEmploye
    def initialize(min, max)
    @min,@max = min,max
    end
    def estValide(employe)
    employe.salaire >= @min && employe.salaire < @max
    end
    end

    class PredicatEmployeAnciennete < PredicatEmploye
    def initialize(min)
    @min = min
    end
    def estValide(employe)
    employe.anciennete >= @min
    end
    end

    Ces classes permettent de vérifier par exemple :

    pred1 = PredicatEmployeSalaire.new(35000, 50000)
    pred2 = PredicatEmployeAnciennete.new(1)
    [john, maud] == selection(employes, pred1)
    [bill, maud] == selection(employes, pred2)

    Pour combiner des prédicats, il suffit de créer une nouvelle classe de prédicat :

    class PredicatEmployeOU < PredicatEmploye
    def initialize(predicatA, predicatB)
    @predicatA, @predicatB = predicatA, predicatB
    end
    def estValide(employe)
    @predicatA.estValide(employe) || @predicatB.estValide(employe)
    end
    end

    Après vérification :

    pred3 = PredicatEmployeOU.new(pred1,pred2)
    [john, bill, maud] == selection(employes, pred3)

    Tout ceci fonctionne, mais c'est un peu lourd. Voici comment écrire un code plus concis, en tirant parti des fermetures:

    Tout d'abord nous pourrions exploiter les blocs de code à l'intérieur de la fonction de sélection, ce qui soulagerait déja celle-ci de quelques lignes :

    def selection(employes, predicat)
    employes.select { |employe| predicat.estValide(employe) }
    end

    La fonction intégrée select parcourt une liste en exécutant pour chaque élément un bloc chargé de décider si l'élément doit être extrait ou non. La variable predicat présente dans la fermeture, a été liée à l'argument passé à la fonction selection, à savoir un objet muni d'une fonction de validation.

    Mais allons plus loin : pourquoi écrire toute une classe, quand on peut écrire un simple bloc de code ?

    def predicatSalaire(min, max)
    Proc.new { |employe| employe.salaire >= min && employe.salaire < max }
    end
    def predicatAnciennete(min)
    Proc.new { |employe| employe.anciennete >= min }
    end

    Désormais la sélection est implémentée sans l'aide d'objet Predicat en passant simplement le bloc prédicat à la fonction filter (le préfixe & dénote un argument de type bloc) :

    def selection(employes, predicat)
    employes.select &predicat
    end

    Exemple d'utilisation :

    pred1 = predicatSalaire(35000, 50000)
    pred2 = predicatAnciennete(1)
    [john, maud] == selection(employes, pred1)
    [bill, maud] == selection(employes, pred2 )

    Pour combiner des prédicats, il suffit d'écrire une fonction créant un bloc de code dans lequel seront combinés les prédicats passés en arguments :

    def predicatOu(predA,predB)
    Proc.new { |employe| predA.call(employe) || predB.call(employe) }
    end

    ce qui permet la combinaison suivante :

    pred3 = predicatOu(pred1, pred2)
    [john, bill, maud] == selection(employes, pred3)

    Conclusion

    Dans ce dernier exemple, les fermetures sont utilisées pour résoudre un problème de généralisation et de paramétrage d'un traitement. Face à ce type de problème, la conception objet conduit habituellement à une "réification" du traitement en un système de classes: des séquences d'instructions ont été transposées vers des structures de données spécifiques, ce qui permet effectivement un couplage faible (la fabrication des critères est bien séparée de leur utilisation) et une certaine versatilité, permise par le polymorphisme. En revanche, la simplicité et la concision sont perdues au passage.

    Une design à base de fermetures atteint le même niveau de découplage, en transmettant, plutôt qu'une structure de données munie d'une fonction, une simple fonction munie d'un état. La liaison de variables au sein de la fermeture confère également à ce dispostif une très grande versatilité.

    La différence entre les deux solutions, et l'avantage des fermetures à mons sens, tient dans la concision et la simplicité préservées du code.

    Quand faut-il utiliser ces fonctions de haut-niveau ? A chaque fois que nous souhaitons :

    • encapsuler un état dans une fonction,
    • spécifier un traitement dont l'exécution doit être différée,
    • plus généralement, découpler un traitement de son exécution

    ..sans avoir à créer de classes ou de structures de données supplémentaires, alors les fermetures constituent une solution adéquate et élégante.

    Message = "merci pour les conseils sur ce post."
    %w{ Bernard
    Manu
    Pierre
    Gilles
    Marc-Antoine
    Philippe }.each { |prenom| print "#{prenom}, #{Message} "}