Traduction

Ceci est la traduction la plus fidèle possible de l'article de Jon Skeet, The Beauty of Closures.

1. Qu'est-ce que les fermetures

Pour dire les choses très simplement, les fermetures (aussi appelées clôtures, « closures » en anglais, NdT) permettent d’encapsuler un comportement et de le manipuler et le balader comme n’importe quel objet, tout en continuant à avoir accès au contexte dans lequel il a été déclaré initialement. Cela permet de séparer les structures de contrôle, les opérateurs logiques, etc., des détails de comment ils vont être utilisés. La capacité à accéder au contexte d’origine est-ce qui distingue les fermetures des objets normaux, bien que les implémentations des fermetures arrivent typiquement à ce résultat en utilisant des objets normaux et des astuces du compilateur.

Le plus facile pour appréhender la plupart des avantages (et des implémentations) des fermetures est d’étudier un exemple. J’utiliserai un seul exemple pour la quasi-totalité du reste de l’article. Je montrerai le code en Java et en C# (pour différentes versions) pour illustrer différentes approches. Tout le code est également disponible en téléchargement pour que vous puissiez expérimenter avec.

2. Exemple de situation : filtrer une liste

Il est assez fréquent de vouloir filtrer une liste selon un certain critère. C’est assez facile à faire « en ligne », en créant une nouvelle liste, en énumérant les éléments de la liste originale et en ajoutant les éléments appropriés à la nouvelle liste. Cela ne prend que quelques lignes de code, mais il est toujours bon d’isoler cette logique quelque part. La difficulté est d’encapsuler selon quel critère les éléments doivent être inclus ou non. C’est là que les fermetures entrent en jeu.

Bien que j’aie utilisé le mot « filtre » dans la description, il est quelque peu ambigu, car il n’indique pas clairement si ce filtre inclut ou exclut les éléments. Par exemple, est-ce qu’un « filtre de nombres pairs » conserve ou rejette les nombres pairs ? On utilisera donc un terme différent : un prédicat. Un prédicat est simplement quelque chose qui peut être vérifié, ou non, pour un élément donné. Notre exemple produira une nouvelle liste contenant tous les éléments de la liste originale qui vérifient le prédicat fourni.

En C#, la façon naturelle de représenter un prédicat est un délégué, et en effet .NET 2.0 contient un type Predicate<T> (note: pour une raison ou une autre, Linq utilise Func<T, bool> ; je ne sais pas trop pourquoi, vu que c’est moins descriptif. Les deux sont fonctionnellement équivalents). En Java, il n’y a rien d’équivalent à un délégué, on utilisera donc une interface avec une seule méthode. Bien sûr, on pourrait utiliser une interface en C# aussi, mais ce serait nettement moins propre et cela ne nous permettrait pas d’utiliser les méthodes anonymes ou les expressions lambda – qui sont précisément les fonctionnalités qui implémentent les fermetures en C#. Voilà l’interface et le délégué, pour référence :

Déclaration de System.Predicate<T>
Sélectionnez
public delegate bool Predicate<T>(T obj)
Predicate.java
Sélectionnez
public interface Predicate<T>
{
    boolean match(T item);
}

Le code utilisé pour filtrer la liste est assez simple dans les deux langages. À ce stade, je me dois de préciser que je vais laisser de côté les méthodes d’extension en C#, juste pour rendre l’exemple plus simple – mais cela devrait rappeler la méthode d’extension Where à quiconque ayant utilisé Linq (il y a des différences en termes d’exécution différée, mais je vais laisser ça de côté pour le moment).

ListUtil.cs
Sélectionnez
static class ListUtil
{
    public static IList<T> Filter<T>(IList<T> source, Predicate<T> predicate)
    {
        List<T> ret = new List<T>();
        foreach (T item in source)
        {
            if (predicate(item))
            {
                ret.Add(item);
            }
        }
        return ret;
    }
}
ListUtil.java
Sélectionnez
public class ListUtil
{
    public static <T> List<T> filter(List<T> source, Predicate<T> predicate)
    {
        ArrayList<T> ret = new ArrayList<T>();
        for (T item : source)
        {
            if (predicate.match(item))
            {
                ret.add(item);
            }
        }
        return ret;
    }
}

(Dans les deux langages j’ai inclus une méthode Dump qui écrit la liste donnée sur la console).

Maintenant qu’on a défini notre méthode de filtrage, il faut l’appeler. Pour montrer l’importance des fermetures, on va commencer avec un cas simple qui peut être résolu sans les utiliser, puis on passera à quelque chose de plus difficile.

3. Filtre 1 : accepter les chaînes courtes (pour une longueur fixée)

Notre exemple va être vraiment basique, mais j’espère que vous arriverez quand même à voir son importance. On va prendre une liste de chaînes, et produire une autre liste qui contiendra seulement les chaînes « courtes » de la liste d’origine. Construire une liste est, bien sûr, très simple – c’est la création du prédicat qui est plus difficile.

En C# 1, on doit avoir une méthode pour représenter la logique du prédicat. L’instance de délégué est ensuite créée en spécifiant le nom de la méthode. (Bien sûr ce code n’est pas vraiment du C# 1 valide à cause de l’utilisation des génériques, mais concentrons-nous sur comment créer l’instance de délégué – c’est la partie importante).

Exemple1a.cs
Sélectionnez
static void Main()
{
    Predicate<string> predicate = new Predicate<string>(MatchFourLettersOrFewer);
    IList<string> shortWords = ListUtil.Filter(SampleData.Words, predicate);
    ListUtil.Dump(shortWords);
}

static bool MatchFourLettersOrFewer(string item)
{
    return item.Length <= 4;
}

En C# 2, nous avons trois options. On peut utiliser exactement le même code que précédemment, ou on peut le simplifier un tout petit peu en utilisant les conversions de groupe de méthode qui sont maintenant disponibles, ou on peut utiliser une méthode anonyme en spécifiant la logique du prédicat directement. L’option de la conversion améliorée de groupe de méthode ne mérite pas de s’y attarder beaucoup – il s’agit juste de changer new Predicate<string>(MatchFourLettersOrFewer) en MatchFourLetterOrFewer. C’est disponible dans le code téléchargeable (dans Example1b.cs). L’option « méthode anonyme » est beaucoup plus intéressante :

 
Sélectionnez
static void Main()
{
    Predicate<string> predicate = delegate(string item)
        {
            return item.Length <= 4;
        };
    IList<string> shortWords = ListUtil.Filter(SampleData.Words, predicate);
    ListUtil.Dump(shortWords);
}

On n’a plus besoin d’avoir une méthode supplémentaire, et le comportement du prédicat est évident au point où on l’utilise. Comment cela fonctionne-t-il en coulisses ? Eh bien, si vous utilisez ildasm ou Reflector pour regarder le code généré, vous verrez qu’il est essentiellement identique à l’exemple précédent : le compilateur a juste fait une partie du travail pour nous. Nous verrons plus tard qu’il est capable d’en faire beaucoup plus…

En C# 3, nous avons les mêmes options que précédemment, mais aussi les expressions lambda. Dans le cadre de cet article, on peut considérer que les expressions lambda sont juste des méthodes anonymes sous une forme plus concise (la grande différence entre les deux en ce qui concerne Linq est que les expressions lambda peuvent être converties en arbres d’expressions, mais ça n’a pas d’importance ici). Le code ressemble à ceci si on utilise une expression lambda :

 
Sélectionnez
static void Main()
{
    Predicate<string> predicate = item => item.Length <= 4;
    IList<string> shortWords = ListUtil.Filter(SampleData.Words, predicate);
    ListUtil.Dump(shortWords);
}

Ignorez le fait qu’en utilisant <= on dirait qu’il y a une grosse flèche qui pointe vers item.Length – je l’ai laissé comme ça pour la cohérence, mais j’aurais aussi bien pu l’écrire Predicate<string> predicate = item => item.Length < 5;.

En Java, on ne doit pas créer de délégué, mais implémenter une interface. Le moyen le plus simple est de créer une nouvelle classe qui implémente l’interface, comme ceci :

FourLetterPredicate.java
Sélectionnez
// In FourLetterPredicate.java
public class FourLetterPredicate implements Predicate<String>
{
    public boolean match(String item)
    {
        return item.length() <= 4;
    }
}

// In Example1a.java
public static void main(String[] args)
{
    Predicate<String> predicate = new FourLetterPredicate();
    List<String> shortWords = ListUtil.filter(SampleData.WORDS, predicate);
    ListUtil.dump(shortWords);
}

Ça n’utilise aucune fonctionnalité « cool » du langage, mais ça implique toute une classe séparée juste pour exprimer un petit bout de logique. En suivant les conventions Java, cette classe sera probablement dans un autre fichier, ce qui rend plus difficile la lecture du code qui l’utilise. On pourrait utiliser une classe imbriquée à la place, mais la logique est quand même séparée du code qui l’utilise – c’est une version plus verbeuse de la solution C# 1, en pratique (là encore, je ne montrerai pas le code avec la classe imbriquée, mais il est dans le code téléchargeable dans Example1b.Java). Java permet cependant d’exprimer le code en ligne, en utilisant des classes anonymes. Voilà le code dans toute sa gloire :

Example1c.java
Sélectionnez
// Dans Example 1c.java
public static void main(String[] args)
{
    Predicate<String> predicate = new Predicate<String>()
    {
        public boolean match(String item)
        {
            return item.length() <= 4;
        }
    };
    
    List<String> shortWords = ListUtil.filter(SampleData.WORDS, predicate);
    ListUtil.dump(shortWords);
}

Comme vous pouvez le voir, il y a beaucoup de bruit syntaxique comparé aux solutions C# 2 et 3, mais au moins le code est visible au bon endroit. C’est le support actuel des fermetures en Java… ce qui nous amène à point au second exemple.

4. Filtre 2 : accepter les chaînes courtes (pour une longueur variable)

Jusqu’ici, notre prédicat n’avait pas besoin d’un contexte – la longueur était codée en dur, et la chaine à vérifier lui était passée en tant que paramètre. Changeons la situation pour que l’utilisateur puisse spécifier la longueur maximum des chaînes à accepter.

D’abord, revenons à C# 1. Celui-ci n’a aucun réel support pour les fermetures – il n’y a pas d’endroit simple pour stocker les informations dont on a besoin. Oui, on pourrait juste utiliser une variable dans le contexte courant de la méthode (par exemple une variable statique dans la classe principale de notre premier exemple), mais ce n’est clairement pas une belle solution – pour commencer, ça rend immédiatement le code fragile s’il est utilisé par plusieurs threads. La solution est de séparer l’état requis du contexte courant, en créant une nouvelle classe. À ce stade, ça ressemble beaucoup au code Java d’origine, avec juste un délégué au lieu d’une interface :

VariableLengthMatcher.cs
Sélectionnez
public class VariableLengthMatcher
{
    int maxLength;

    public VariableLengthMatcher(int maxLength)
    {
        this.maxLength = maxLength;
    }

    /// <summary>
    /// Méthode utilisée comme laction du délégué
    /// </summary>
    public bool Match(string item)
    {
        return item.Length <= maxLength;
    }
}
Example2a.cs
Sélectionnez
static void Main()
{
    Console.Write("Longueur maximum des chaines à inclure ? ");
    int maxLength = int.Parse(Console.ReadLine());

    VariableLengthMatcher matcher = new VariableLengthMatcher(maxLength);
    Predicate<string> predicate = matcher.Match;
    IList<string> shortWords = ListUtil.Filter(SampleData.Words, predicate);
    ListUtil.Dump(shortWords);
}

Le changement dans le code pour C# 2 et C# 3 est plus simple : on remplace juste la limite en dur par le paramètre dans les deux cas. Ne vous inquiétez pas de comment ça marche pour l’instant – on examinera ça quand nous aurons vu le code Java dans une minute.

Example2b.cs (C# 2)
Sélectionnez
static void Main()
{
    Console.Write("Longueur maximum des chaines à inclure ? ");
    int maxLength = int.Parse(Console.ReadLine());

    Predicate<string> predicate = delegate(string item)
    {
        return item.Length <= maxLength;
    };
    IList<string> shortWords = ListUtil.Filter(SampleData.Words, predicate);
    ListUtil.Dump(shortWords);
}
Example2b.cs (C# 3)
Sélectionnez
static void Main()
{
    Console.Write("Longueur maximum des chaines à inclure ? ");
    int maxLength = int.Parse(Console.ReadLine());

    Predicate<string> predicate = item => item.Length <= maxLength;
    IList<string> shortWords = ListUtil.Filter(SampleData.Words, predicate);
    ListUtil.Dump(shortWords);
}

Le changement dans le code Java (la version utilisant des classes anonymes) est similaire, mais avec une petite subtilité – il faut rendre le paramètre final. Ça semble bizarre, mais il y a une logique dans la folie de Java. Regardons le code avant d’essayer de comprendre ce qu’il fait :

Example2a.java
Sélectionnez
public static void main(String[] args) throws IOException
{
    System.out.print("Longueur maximum des chaines à inclure ? ");
    BufferedReader console = new BufferedReader(new InputStreamReader(System.in));
    final int maxLength = Integer.parseInt(console.readLine());
    
    Predicate<String> predicate = new Predicate<String>()
    {
        public boolean match(String item)
        {
            return item.length() <= maxLength;
        }
    };
    
    List<String> shortWords = ListUtil.filter(SampleData.WORDS, predicate);
    ListUtil.dump(shortWords);
}

Alors, quelle est la différence entre le code Java et le code C# ? En Java, la valeur de la variable a été capturée par la classe anonyme. En C#, la variable elle-même a été capturée par le délégué. Pour prouver que C# capture la variable, modifions le code C# 3 pour changer la valeur du paramètre après que la liste ait été filtrée une fois, puis filtrons-là à nouveau :

Example2d.cs
Sélectionnez
static void Main()
{
    Console.Write("Longueur maximum des chaines à inclure ? ");
    int maxLength = int.Parse(Console.ReadLine());

    Predicate<string> predicate = item => item.Length <= maxLength;
    IList<string> shortWords = ListUtil.Filter(SampleData.Words, predicate);
    ListUtil.Dump(shortWords);

    Console.WriteLine("Maintenant pour des mots avec <= 5 lettres:");
    maxLength = 5;
    shortWords = ListUtil.Filter(SampleData.Words, predicate);
    ListUtil.Dump(shortWords);
}

Notez qu’on change seulement la valeur de la variable locale. On ne recrée pas l’instance de délégué, ni rien de tel. Le délégué a accès à la variable locale, donc il peut voir qu’elle a changé. Allons encore un peu plus loin, et modifions le prédicat pour qu’il change lui-même la valeur de la variable :

Example2e.cs
Sélectionnez
static void Main()
{
    int maxLength = 0;

    Predicate<string> predicate = item => { maxLength++; return item.Length <= maxLength; };
    IList<string> shortWords = ListUtil.Filter(SampleData.Words, predicate);
    ListUtil.Dump(shortWords);
}

Je ne vais pas rentrer dans les détails de comment tout ça est accompli – lisez le chapitre 5 de C# in Depth pour tous les détails sanglants. Attendez-vous juste à ce que certaines de vos notions de ce que signifie « variable locale » soient un peu chamboulées.

Maintenant qu’on a vu comment C# réagit au changement d’une variable capturée, que se passe-t-il en Java ? Eh bien la réponse est simple : on ne peut pas changer la valeur d’une variable capturée. Elle doit être final, donc la question ne se pose pas. Cependant, si on pouvait d’une manière ou d’une autre changer la valeur de la variable, on verrait que le prédicat n’y réagit pas. Les valeurs des variables capturées sont copiées lors de la création du prédicat, et stockées dans une instance de la classe anonyme. Pour les variables référence, n’oubliez pas que la valeur de la variable est juste la référence, pas l’état courant de l’objet. Par exemple, si vous capturez un StringBuilder, et que vous lui ajoutez ensuite quelque chose, ces changements seront vus par la classe anonyme.

5. Comparaison des stratégies de capture : puissance contre complexité

Clairement, l’approche de Java est plus restrictive, mais ça simplifie aussi nettement les choses. Les variables locales se comportent de la même façon que d’habitude, et dans de nombreux cas le code est aussi plus facile à comprendre. Par exemple, regardez le code suivant, qui utilise l’interface Runnable de Java et le délégué Action de .NET – les deux représentent des actions ne prenant pas de paramètre et ne renvoyant pas de valeur. Voyons d’abord le code C# :

Example3a.cs
Sélectionnez
static void Main()
{
    // Construire d’abord une liste d’actions 
    List<Action> actions = new List<Action>();
    for (int counter = 0; counter < 10; counter++)
    {
        actions.Add(() => Console.WriteLine(counter));
    }

    // Puis les exécuter
    foreach (Action action in actions)
    {
        action();
    }
}

Quelle est la sortie ? Eh bien, on a en fait déclaré une seule variable counter, donc, la même variable counter est capturée par toutes les instances d’Action. Le résultat est que le nombre 10 est affiché sur chaque ligne. Pour « réparer » le code pour lui faire afficher le résultat auquel la plupart des gens s’attendent, il faut introduire une variable supplémentaire dans la boucle :

Example3b.cs
Sélectionnez
static void Main()
{
    // Construire d’abord une liste d’actions
    List<Action> actions = new List<Action>();
    for (int counter = 0; counter < 10; counter++)
    {
        int copy = counter;
        actions.Add(() => Console.WriteLine(copy));
    }

    // Puis les exécuter
    foreach (Action action in actions)
    {
        action();
    }
}

Chaque fois qu’on passe dans la boucle, on dit qu’on obtient une instance différente de la variable copy – chaque Action capture une variable différente. C’est parfaitement logique si vous regardez ce que fait réellement le compilateur en coulisses, mais au début ça va à l’encontre de l’intuition de la plupart des développeurs (dont moi).

Java interdit complètement la première version – vous ne pouvez pas du tout capturer la variable counter, parce qu’elle n’est pas final. Pour utiliser une variable final, vous finiriez avec un code comme ceci, qui est très similaire au code C# :

Example3a.java
Sélectionnez
public static void main(String[] args)
{
    // Construire d’abord une liste d’actions
    List<Runnable> actions = new ArrayList<Runnable>();        
    for (int counter=0; counter < 10; counter++)
    {
        final int copy = counter;
        actions.add(new Runnable()
        {
            public void run()
            {
                System.out.println(copy);
            }
        });
    }
    
    // Puis les exécuter
    for (Runnable action : actions)
    {
        action.run();
    }
}

La signification est assez claire avec la sémantique de « valeur capturée ». Le code est moins agréable à regarder que la version C#, à cause de la syntaxe plus verbeuse, mais Java impose d’écrire le code correct comme étant la seule option. L’inconvénient est que si vous voulez le comportement du code C# original (ce qui peut certainement arriver à l’occasion), c’est un peu lourd à accomplir en Java (vous pouvez utiliser un tableau d’un seul élément, et capturer une référence vers le tableau, puis changer la valeur de l’élément quand vous voulez, mais c’est un affreux bricolage).

6. En quoi est-ce important ?

Dans cet exemple, on n’a vu qu’une petite partie des avantages de l’utilisation des fermetures. Bien sûr, on a séparé la structure de contrôle de la logique du filtrage proprement dit, mais en soi, ça n’a pas rendu le code beaucoup plus simple. C’est une situation fréquente : une nouvelle fonctionnalité paraît souvent peu impressionnante quand on l’utilise dans une situation simpliste. L’avantage que les fermetures apportent souvent, cependant, est la composabilité. Si ça vous semble un peu tiré par les cheveux, je suis d’accord – et c’est une partie du problème. Une fois que vous serez familier avec les fermetures et éventuellement un peu accro à elles, la connexion vous semblera évidente. En attendant, ça semble obscur.

Les fermetures n’offrent pas la composabilité de façon inhérente. Tout ce qu’elles font est de simplifier l’implémentation de délégués (ou d’interfaces à une seule méthode – je m’en tiendrai au terme « délégués » pour plus de simplicité). Sans support pour les fermetures, il est plus facile d’écrire une petite boucle que d’appeler une autre méthode qui s’occupe de boucler, en fournissant un délégué pour une partie de la logique. Même avec la possibilité de créer un délégué sur une autre méthode de la même classe, on perd quand même la localité de la logique, et on a souvent besoin de plus d’informations de contexte qu’il n’est facile de fournir.

Les fermetures permettent donc de simplifier la création de délégués. Cela signifie qu’il devient plus intéressant de concevoir des API qui utilisent des délégués (je ne pense pas que ce soit une coïncidence si les délégués étaient presque exclusivement utilisés pour lancer des threads et gérer des événements en .NET 1.1). Une fois que vous commencez à penser en termes de délégués, les façons de les combiner deviennent évidentes. Par exemple, il est trivial de créer un Predicate<T> qui prend deux autres prédicats, et qui représente le ET ou le OU entre les deux (ou d’autres opérations booléennes, bien sûr).

Une autre façon de combiner consiste à passer le résultat d’un délégué à un autre délégué, ou à curryfier un délégué pour en créer un autre. Toutes sortes d’options se présentent quand on commence à considérer la logique comme étant juste un autre type de données.

L’utilisation de la composition ne s’arrête cependant pas là : tout Linq est basé dessus. Le filtre qu’on a créé en utilisant des listes est juste un exemple de comment une séquence peut être transformée en une autre. D’autres opérations possibles sont le tri, le groupement, la combinaison avec une autre séquence, et la projection. Historiquement, écrire manuellement ces opérations n’était pas trop difficile, mais la complexité augmente rapidement dès que le « pipeline de données » consiste en plus de quelques transformations. De plus, avec l’exécution différée et le traitement des données en flux fournis par Linq to Objects, l’utilisation de mémoire est nettement moins importante qu’avec l’implémentation directe consistant à juste exécuter une transformation quand la précédente est terminée. La complexité n’est pas supprimée par le fait que les transformations individuelles soient particulièrement astucieuses – elle est éliminée par la capacité à exprimer de petits morceaux de logique en ligne avec des fermetures, et la capacité à combiner les opérations avec une API bien conçue.

Conclusion

Les fermetures ne semblent pas très impressionnantes à première vue. Bien sûr, elles permettent d’implémenter une interface ou de créer un délégué (selon le langage) assez simplement. Leur puissance ne devient évidente que quand on les utilise avec des librairies qui en tirent parti, en permettant d’exprimer un comportement personnalisé juste au bon endroit. Quand les mêmes librairies permettent aussi de composer plusieurs étapes simples d’une manière naturelle pour implémenter un comportement complexe, on obtient un tout qui est aussi complexe que la somme de ses parties – plutôt qu’aussi complexe que le produit de ses parties. Bien que je n’adhère pas, comme certains, à l’idée que la composabilité est la panacée contre la complexité, ça reste néanmoins une technique puissante, que les fermetures permettent d’appliquer dans beaucoup plus de situations.

Une des caractéristiques clés des expressions lambda est leur brièveté. Quand on compare le code Java vu précédemment avec le code C#, Java semble extrêmement maladroit et lourd. C’est l’un des problèmes que les diverses propositions pour les fermetures en Java tentent de régler. Je donnerai ma perspective sur ces différentes propositions dans un billet de blog dans un futur pas trop lointain.

Téléchargements

Le code source associé à cet article est disponible ici : Closures.zip

Remerciements

Je tiens à remercier John Skeet pour son aimable autorisation de traduire son article, ainsi l'équipe .NET de Developpez.com pour la relecture technique, et zoom61 pour la correction orthographique.