Expressions lambda: limites et dangers.

Introduites avec le compilateur C#3.0 (Visual Studio 2008), les expressions lambda avaient pour ambition principale d’offrir aux utilisateurs de la librairie LINQ une syntaxe fluide pour passer aux nouvelles méthodes d’extensions (Where(), Any() et cie) les prédicats nécessaires à leur exécution. Se rapprochant, dans la philosophie, du paradigme de la programmation fonctionnelle, ce nouvel outil syntaxique -pas toujours bien compris- porte son lot de contraintes et de limitations, qu’il convient de connaître et comprendre pour éviter de l’utiliser à tort.

Tout d’abord, qu’est-ce qu’une expression lambda? Ce n’est ni plus ni moins qu’un bout de code représentant une fonction logique du programme. Si la syntaxe:

(a, b) => a < b;

peut porter à confusion, l’expression lambda n’est pas si effrayante quand on la considère simplement comme une méthode. Une méthode qui n’a pas de nom, et dont la signature est inférée par le compilateur en fonction du contexte. Dans l’exemple ci-dessus, il s’agit d’une méthode qui prend « a » et « b » en paramètre, et qui retourne le résultat de l’opération « a inférieur à b ». Une expression lambda peut ne pas prendre d’argument:

() => Math.Random.Next();

Et/ou exécuter plus d’une instruction:

a => { ++a; return a*2; };

Dans ce dernier cas, on parle de « fonction lambda », par opposition à l’expression lambda qui ne contient qu’une seule instruction.

Le type des paramètres d’une expression lambda ainsi que le retour de la méthode sans nom sont aisément devinés par le compilateur car l’expression lambda n’est jamais déclarée en dehors d’un contexte bien spécifique. En l’occurrence, l’expression lambda ne peut être utilisée que dans deux cas précis:

  1. A la place d’un délégué (dans une assignation ou comme argument d’une méthode par exemple).
  2. Afin d’initialiser un arbre d’expression (variable du type Expression<TDelegate>).

A chaque fois, c’est le type délégué qui détermine la signature de la méthode représentée par la fonction lambda.

Dans le cas 2, votre expression lambda sera traduite en une structure de données composée d’objets représentant les opérateurs et les opérants de votre fonction. Cette structure de données sera traitée comme n’importe quelle collection d’objets managés, et le ramasse miette fera son travail. Rien de particulier à signaler dans ce contexte. Notez toutefois que cette utilisation n’est pas possible avec les « fonctions lambda » qui contiennent plus d’une instruction.

Mais la plupart du temps, c’est le cas 1 que vous rencontrerez. Et manque de chance, c’est dans ce premier cas de figure que les problèmes se produisent. Que se passe-t-il donc lorsque votre expression lambda est utilisée pour initialiser un délégué (inscription d’un handler à un évènement par exemple) de la manière suivante:

button1.Click += (s, ea) =>
{
MessageBox.Show(« Vous avez cliqué sur le bouton! »);
};

Une expression lambda utilisée de la sorte n’est ni plus ni moins qu’un délégué anonyme. Du point de vue du compilateur, l’exemple précédent donne exactement le même code MSIL que:

button1.Click += delegate(Object s, EventArgs ea)
{
MessageBox.Show(« Vous avez cliqué sur le bouton! »);
};

En effet, à quelques très subtiles différences près, l’expression lambda dans ce contexte n’est qu’une surcouche syntaxique aux délégués anonymes. Or, si les délégués anonymes ont apporté une flexibilité supplémentaire dans l’écriture du code, ils ont introduit leur lot de problèmes lorsqu’on les utilise à mauvais escient. Comment le compilateur traite-t-il donc ce code « inline » exécutable a posteriori?

Comme toujours avec les syntaxes « magiques » de C#, la complexité épargnée au développeur est prise en charge par le compilateur. Si la promesse du délégué anonyme est de « réduire la surcharge de code induite par initialisation d’un délégué avec une méthode séparée que vous auriez dû créer », c’est bien parce que c’est le compilateur qui se charge lui-même de créer cette méthode. En l’occurrence, tout délégué anonyme (et à fortiori toute expression lambda utilisée en tant que délégué) est traduite par une nouvelle méthode dont le nom est générée presque-aléatoirement lors de la compilation, exactement de la même manière que les champs sous-jacents des propriétés ou des évènements auto-implémentés.

Comme ce nom n’existe pas lors de la phase d’écriture du code (et pour cause) le délégué anonyme ainsi créé ne peut pas être désigné par votre code. Et c’est là que le bât blesse. En effet, l’instance du délégué anonyme n’est détruite que lorsqu’elle est éligible au ramasse miette, c’est à dire si plus aucune référence active ne la désigne. Or, si vous avez utilisé votre délégué anonyme pour composer (par exemple) un handler d’évènement avec la syntaxe « button1.Click += … », vous ne disposez plus d’aucun moyen pour décomposer le susdit délégué. La syntaxe « button1.Click -= … » exigeant que vous ayez un moyen de référencer la même méthode que celle utilisée pour composer le délégué.

En d’autres termes, tant que l’instance de « button1 » n’est pas détruite, votre délégué anonyme ne peut pas être déréférencée, et encore moins détruit. Or, comme tout délégué a le bon goût de maintenir une référence vers l’instance de sa classe, c’est votre classe entière qui se retrouve inéligible au ramasse miette. La finalité de tout ceci, c’est que si la durée de vie de l’objet au sein duquel vous inscrivez votre délégué anonyme n’est pas liée à la durée de vie de votre propre objet (relation de composition au sens UML) alors vous avez créé une fuite mémoire!

Le second problème apparaît lorsque votre délégué anonyme/expression lambda s’amuse à capturer les variables locales de la méthode qui les déclare: un délégué anonyme peut en effet utiliser les variables locales de la méthode au sein de laquelle il est écrit, on dit alors qu’il les « capture ». Du point de vue du compilateur, ces variables locales sont (grosso-modo) converties en champ de la classe afin d’être disponibles jusqu’à l’exécution du délégué. Pour peu que le délégué soit exécuté dans un thread séparé, ce que vous pensiez être une variable locale devient soudain sujet aux problématiques d’accès concurrentiels et le code que vous pensiez blinder n’est plus du tout « thread-safe ».

Les expressions lambda sont-elles à mettre à la poubelle pour autant? Évidemment que non. Fuites mémoires et problèmes d’accès concurrents peuvent être engendrés avec n’importe quelle méthode non-anonyme. La seule différence, c’est que la nature extrêmement concise de l’expression lambda laisse à penser qu’il ne s’agit que d’un petit bout de code sans conséquence et n’incite pas à la vigilance habituelle.

Ce qu’il faut toujours garder à l’esprit, c’est que l’expression lambda est une facilité syntaxique parfaitement appropriée dans les situations où le délégué à exécuter est:

  • Simple: une expression lambda de plus de quelques lignes rendra votre code illisible.
  • Non réutilisable: tout code réutilisable a sa place dans une méthode.
  • Monocast: tout délégué composé avec des expressions lambda ne pourra pas être décomposé.

La prochaine fois que vous tombez sur une expression lambda de plus de 300 lignes, dupliquée dans une quinzaines de classes différentes, et réinscrite en boucle sur le même évènement à chaque interaction utilisateur; ne cherchez pas plus longtemps pourquoi l’application dont on vous a confié la maintenance présente des temps de réponse exponentiels et termine en débordement mémoire après quelques heures d’utilisation…

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

un × 4 =