C# – Introduction aux expressions λ

C# offre un support limité aux foncteurs anonymes sous la forme d'expressions lambda, qu'on écrit habituellement λ. Bien que limitée, cette syntaxe permet d'exprimer simplement certaines fonctions, et de les associer à des variables ou de les passer en paramètre à des fonctions, ce qui simplifie grandement l'écriture de programmes dans ce langage.

Ce texte présume que vous avez déjà une base de programmation générique avec C#. En savoir un peu sur les threads aidera aussi à comprendre certains exemples.

Supposons que nous souhaitions écrire un programme C# capable d'afficher la somme de deux entiers. Une implémentation correcte serait :

using System;

Console.WriteLine(Somme(2,3));

static int Somme(int x, int y)
{
   return x + y;
}

Une version similaire, mais plus concise et un peu plus moderne serait :

using System;

Console.WriteLine(Somme(2,3));

static int Somme(int x, int y) => x + y;

Si nous ajoutons d'autres opérations, par exemple le calcul d'un produit, nous pourrions élargir le tout ainsi :

using System;

      Console.WriteLine(Somme(2,3));
      Console.WriteLine(Produit(2,3));

static int Somme(int x, int y) => x + y;
static int Produit(int x, int y) => x * y;

Ceci résulte en une classe contenant trois méthodes nommées (Somme, Produit et Main), ce qui est tout à fait convenable si les fonctions en question doivent être utilisées à plusieurs reprises. Cependant, il arrive parfois que certains calculs n'aient qu'une utilité limitée, ou circonscrite dans le temps. Il arrive aussi que certaines opérations dépendent d'autres fonctions (écrire des fonctions acceptant des fonctions en paramètre, ou encore retournant des fonctions), et que nous souhaitions généraliser ces calculs qu'on dit « d'ordre supérieur ». Dans ces cas, exprimer à chaque fois une fonction à part entière (nom, signature, corps) devient rapidement fastidieux.

Revenant à notre programme initial, en voici une nouvelle version :

using System;

Func<int,int,int> somme = (x,y) => x+y; // <-- ICI
Console.WriteLine(somme(2,3));

Plutôt qu'écrire une fonction Somme à part entière, nous avons ici une variable somme de type Func<int,int,int>, ce qui signifie fonction acceptant deux int et retournant un int (dans cet ordre). Cette variable est locale à Main(), et mène vers ce qu'on appelle une expression λ, donc un objet dont le type est anonyme et qui se comporte comme une fonction.

Une autre manière (plus habituelle) d'utiliser des expression λ est de les passer en paramètre à des fonctions. Par exemple :

using System;

Console.WriteLine(Appliquer(2, 3, (x,y) => x + y)); // Somme(x,y)
Console.WriteLine(Appliquer(2, 3, (x,y) => x * y)); // Produit(x,y)

static int Appliquer(int x, int y, Func<int, int, int> f)
{
   return f(x,y);
}

Le potentiel de ces expressions devient alors beaucoup plus visible.

Introduction aux expressions λ

L'article sur les bases de programmation générique avec C# présente un comparatif simple de deux classes : ListeEntiers, qui modélise une liste chaînée d'entiers, et Liste<T>, qui modélise une liste chainée d'un certain type T.

Dans certains cas, passer de ListeEntiers à Liste<T> ne demande presque aucun effort du côté du code client. Un cas typique est celui de la méthode Afficher(), présentée dans l'article en question, qui est aussi simple à rédiger pour une Liste<T> que pour une ListeEntiers :

Afficher ListeEntiersAfficher Liste<T>
static void Afficher<T>(ListeEntiers lst)
{
   var e = lst.GetÉnumérateur();
   while (e.HasNext)
   {
      e.MoveNext();
      Console.Write("{0} ", e.Valeur);
   }
   Console.WriteLine();
}
static void Afficher<T>(Liste<T> lst)
{
   var e = lst.GetÉnumérateur();
   while (e.HasNext)
   {
      e.MoveNext();
      Console.Write("{0} ", e.Valeur);
   }
   Console.WriteLine();
}

Dans d'autres cas, en C#, il est beaucoup moins évident de généraliser le code (c'est plus simple en C++ – voir ../Divers--cplusplus/templates.html pour des détails). Par exemple, imaginons une méthode capable de calculer la sommes des nombres impairs dans une liste… Est-ce une opération raisonnable sur des float? Sur des string? Pour cette raison, C# ne permet pas d'écrire directement quelque chose comme ce qui suit, à moins que l'on n'ajoute un peu d'information sémantique :

// ...
static T Somme<T>(T x, T y) // non, malheureusement, ceci n'est pas légal tel quel
{
   return x + y;
}
// ...

Heureusement, il y a de l'espoir! Pour expliquer une manière de généraliser les programmes sans trop de douleur, procédons par étapes.

Représenter une méthode par sa signature – le type Func<T0, T1, ..., R>

Examinez le code suivant :

// ...
static T Appliquer<T>(T x, T y, Func<T, T, T> oper)
{
   return oper(x, y);
}
// ...

Un Func<T0,T1,...,R> représente une méthode qui :

C'est abstrait, mais... Est-ce utile? Examinons le code suivant :

// ...
using System;

Console.WriteLine(Appliquer(2, 3, Somme));

static int Somme(int x, int y)
{
   return x + y;
}
// ...

Cela peut sembler abusif pour afficher le résultat de l'expression 2 + 3, mais l'idée est de nous aider à mieux comprendre nos options.

Le cas particulier des fonctions void – le type Action<T0,T1,...>

C# distingue les fonctions dont le type de retour est void des fonction dont le type de retour est non-void. Pour fins de programmation générique, les fonctions void ne sont pas modélisées par des Func mais bien par des Action.

Un Action<T0,T1,...> représente une méthode qui :

C'est abstrait, mais... Est-ce utile? Examinons le code suivant :

// ...
using System;

Appliquer(new []{ 2,3,5,7,11 }, AfficherUn); // 2 3 5 7 11

static void AfficherUn<T>(T obj)
{
   Console.Write($"{obj} ");
}
static void Appliquer<T>(T [] tab, Action<T> oper)
{
   foreach(T x in tab) oper(x);
}
// ...

Cela peut sembler abusif pour afficher les éléments d'un tableau, mais l'idée est de réfléchir à l'opération à appliquer aux éléments seulement, et de considérer le problème de la répétitive comme étant résolu.

Retourner une fonction d'une fonction

Il est possible pour une fonction de retourner une fonction. Par exemple :

Soit le programme de test visible à droite, et qui utilise la classe X ci-dessous :

  • Un appel direct à X.F() serait illégal dans Main(), X.F étant inaccessible au code client (elle est privée, après tout)
  • Toutefois, il est possible de prendre la valeur de retour de X.Oups dans un Func<int>, comme c'est le cas avec la variable f à droite
  • Enfin, f étant de type Func<int>, il est possible de l'appeler comme le fait Main(), et d'utiliser (ici, d'afficher) sa valeur de retour

Ceci donne un aperçu de l'utilisation d'une fonction comme type de retour d'une fonction, et montre qu'il est possible d'utiliser ce mécanisme de manière... créative.

// X.F();
Func<int> f = X.Oups();
Console.WriteLine(f());

La classe X à droite offre deux méthodes :

  • La méthode F(), qui ne prend pas de paramètre et retourne un int, et
  • La méthode Oups(), qui ne prend pas de paramètre et retourne un Func<int>

Notez au passage que Func<int> correspond à la signature de X.F.

Notez aussi, car c'est intéressant dans cet exemple, que X.F est privée (inaccessible au code client, règle générale) alors que X.Oups est publique. Cest peut surprendre : si X.Oups() retourne X.F, comme c'est le cas ici (et X.Oups a bel et bien accès à X.F, étant une méthode de X) cela signifie-t-il que le code client puisse, par ce mécanisme détourné, accéder à X.F?

class X
{
   static int F() { return 3; }
   static public Func<int> Oups() { return F; }
}

Créer des fonctions au besoin – les λ

C# offre une syntaxe concise pour des objets qui se manipulent comme des fonctions. On nomme de tels objets des lambdas (lettre grecque λ). Avec une λ, on peut écrire :

Console.WriteLine(Appliquer(2, 3, (int x, int y) => x + y) );

... ou même, quand ce n'est pas ambigu :

Console.WriteLine(Appliquer(2, 3, (x, y) => x + y)); // habituellement, les types peuvent être déduits du contexte

En C#, l'écriture suivante :

(int x, int y) => x + y

... est équivalente à quelque chose comme :

static int NomInconnu(int x, int y) 
{
   return x + y;
}

Avec cette écriture compacte, le type de retour dépend du type de l'expression évaluée par la λ (donc, ici, le type de la somme de deux int).

La λ n'a pas de nom officiellement connu du programme. Pour cette raison, il est d'usage, pour profiter d'une λ, de la déposer dans un Func de signature appropriée

Func<int,int,int> somme = (x,y) => x+y;
Console.WriteLine(Appliquer(2, 3, somme));

Examinons, muni de ces nouveaux outils, un exemple de code client pour la version générique et la version non-générique d'une liste.

À titre de rappel, notez que les classes ListeEntiers et Liste<T> utilisées ci-dessous ne sont pas conformes aux interfaces IEnumerable<int> ou IEnumerable<T>... mais elles en sont tout près.

Pour des exemples conformes à ces interfaces, voir ceci opur ListeEntiers et ceci pour Liste<T>.

Classe ListeEntiers (code client) RemarquesClasse Liste<T> (code client)
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

var lst = new ListeEntiers();
var random = new Random();
for (int i = 0; i < 10; ++i)
   lst.Ajouter(random.Next(1, 10));
Afficher(lst);
Console.WriteLine("Plus petite valeur: {0}", TrouverPlusPetit(lst));
Console.WriteLine("Somme des valeurs impaires: {0}", CalculerSommeImpairs(lst));

De prime abord, les outils sont les mêmes de part et d'autre.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

var lst = new Liste<int>();
var random = new Random();
for (int i = 0; i < 10; ++i)
   lst.Ajouter(random.Next(1, 10));
Afficher(lst);
Console.WriteLine("Plus petite valeur: {0}", TrouverMeilleur(lst, Math.Min));
Console.WriteLine("Somme des valeurs impaires: {0}", AccumulerSi(lst, 0, (i) => i % 2 != 0, (i, j) => i + j));
Console.WriteLine("Produit des valeurs impaires: {0}", AccumulerSi(lst, 1, (i) => i % 2 != 0, (i, j) => i * j));
static void Afficher(ListeEntiers lst)
{
   for(var e = lst.GetÉnumérateur(); e.MoveNext(); )
   {
      Console.Write("{0} ", e.Valeur);
   }
   Console.WriteLine();
}

Tel que mentionné plus haut, pour une fonction se limitant à afficher les éléments d'une liste, les versions génériques et non-génériques sont à peu près identiques.

static void Afficher<T>(Liste<T> lst)
{
   for(var e = lst.GetÉnumérateur(); e.MoveNext(); )
   {
      Console.Write("{0} ", e.Valeur);
   }
   Console.WriteLine();
}
static int TrouverPlusPetit(ListeEntiers lst)
{
   var e = lst.GetÉnumérateur();
   if (!e.MoveNext())
      throw new ListeVideException();
   int résultat = e.Valeur;
   while (e.MoveNext())
      résultat = Math.Min(résultat, e.Valeur);
   return résultat;
}

Muni de ces outils, on peut aisément généraliser TrouverPlusPetit() pour obtenir une méthode TrouverMeilleur() qui, sur la base d'un critère qui prend deux T et retourne le « meilleur » des deux, applique ce critère à tous les éléments de la liste et retourne le« meilleur » du lot en fin de parcours.

Pour faire en sorte que TrouverMeilleur() ait un comportement équivalent à celui de TrouverPlusPetit(), une critère convenable serait Math.Min.

Notez que la solution générique sera plus abstraite et plus générale qu'auparavant, mais pas moins rapide.

static T TrouverMeilleur<T>(Liste<T> lst, Func<T,T,T> meilleur)
{
   var e = lst.GetÉnumérateur();
   if (!e.MoveNext())
      throw new ListeVideException();
   T résultat = e.Valeur;
   while (e.MoveNext())
      résultat = meilleur(résultat, e.Valeur);
   return résultat;
}
static int CalculerSommeImpairs(ListeEntiers lst)
{
   int résultat = 0;
   for(var e = lst.GetÉnumérateur(); e.MoveNext(); )
      if (e.Valeur % 2 != 0)
         résultat += e.Valeur;
   return résultat;
}

Muni de λ, on peut généraliser CalculerSommeImpairs() (à gauche) pour obtenir une méthode AccumulerSi() (à droite) qui prend en paramètre :

  • Une valeur initiale init de type T. On veut 0 pour une somme, 1 pour un produit, "" pour une concaténation, etc.
  • Un prédicat (fonction booléenne) pred applicable à un T. Pensez ici à (n)=>(n % 2 != 0) pour un prédicat qui s'avère seulement si n est impair
  • Une fonction cumul applicable à deux T et qui retourne un T. Par exemple : somme de deux T, produit de deux T, minimum de deux T, etc., et
  • Cumule les éléments de la Liste<T> pour lequel pred s'avère étant donné init et cumul, et retourne le cumul ainsi calculé
static T AccumulerSi<T>(Liste<T> lst, T init, Func<T,bool> pred, Func<T,T,T> cumul)
{
   T résultat = init;
   for(var e = lst.GetÉnumérateur(); e.MoveNext();)
      if (pred(e.Valeur))
         résultat = cumul(résultat, e.Valeur);
   return résultat;
}
class ListeVideException : ApplicationException { }

Les programmes principaux de part et d'autre sont semblables, mais celui qui manipule une liste générique est plus flexible, donc plus utile.

class ListeVideException : ApplicationException { }

Expressions λ et captures

En C#, les λ capturent par référence les variables de leur environnement qu'elles utilisent pour leur traitement. Par exemple :

using System;

int i = 3;
Console.Write(Exécuter(() => return i)); // affichera 3

static T Exécuter<T>(Func<T> f) => f();

Une λ n'est qu'une fonction anonyme. Une λ munie de l'ensemble des variables qu'elle capture est une fermeture. Le fait que les fermetures en C# capturent les états par référence peut surprendre; en effet, ceci :

using System;
using System.Threading;

Thread [] th = new Thread[5];
for(int i = 0; i != th.Length; ++i)
   th[i] = new Thread(() => Console.WriteLine(i));
foreach(var thr in th) thr.Start();
foreach(var thr in th) thr.Join();

... affichera, en pratique :

5
5
5
5
5

... car toutes les λ réfèrent au même i, dont la durée de vie est étendue (au besoin) dû à la capture. Pour contourner ceci, il est possible de générer des copies manuellement à l'aide d'une classe :

using System;
using System.Threading;

Thread [] th = new Thread[5];
for(int i = 0; i != th.Length; ++i)
   th[i] = new Thread(new Affichable(i).Exécuter); // on copie i dans le Affichable nouvellement créé
foreach(var thr in th) thr.Start();
foreach(var thr in th) thr.Join();

class Affichable
{
   int Id { get; }
   public Affichable(int id)
   {
      Id = id;
   }
   public void Exécuter()
   {
      Console.WriteLine(Id);
   }
}

... ce qui fonctionne, mais est fastidieux. Une alternative plus simple est d'utiliser des variables locales à la portée, donc ce copier délibérément les variables des portées englobantes et de laisser le processus de fermeture traiter chacune comme une espèce de variable distincte des autres par voie d'extension de vie :

using System;
using System.Threading;

Thread [] th = new Thread[5];
for(int i = 0; i != th.Length; ++i)
{
   int indice = i; // copie délibérée
   th[i] = new Thread(() => Console.WriteLine(indice)); // chaque indice est vu comme venant d'un contexte distinct
                                                        // et capturé comme un objet différent des autres
}
foreach(var thr in th) thr.Start();
foreach(var thr in th) thr.Join();

Voilà pour un (très) bref survol du sujet.

Lectures complémentaires

Quelques liens pour en savoir plus.

L'implémentation des méthodes anonymes en C#, texte portant plus sur les délégués que sur les λ mais les implémentations sont connexes, par Raymond Chen en 2006 :

Pour la perspective du langage C++ sur les expressions λ, voir :


Valid XHTML 1.0 Transitional

CSS Valide !