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#.

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

using System;
public class Program
{
   static int Somme(int x, int y)
   {
      return x + y;
   }
   public static void Main()
   {
      Console.WriteLine(Somme(2,3));
   }
}

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

using System;
public class Program
{
   static int Somme(int x, int y) => x + y;
   public static void Main()
   {
      Console.WriteLine(Somme(2,3));
   }
}

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

using System;
public class Program
{
   static int Somme(int x, int y) => x + y;
   static int Produit(int x, int y) => x & y;
   public static void Main()
   {
      Console.WriteLine(Somme(2,3));
      Console.WriteLine(Produit(2,3));
   }
}

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;
public class Program
{
   public static void Main()
   {
      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;
public class Program
{
   static int Appliquer(int x, int y, Func<int, int, int> f)
   {
      return f(x,y);
   }
   public static void Main()
   {
      Console.WriteLine(Appliquer(2, 3, (x,y) => x + y)); // Somme(x,y)
      Console.WriteLine(Appliquer(2, 3, (x,y) => x * y)); // Produit(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 :

// ...
static int Somme(int x, int y)
{
   return x + y;
}
static void Main(string[] args)
{
   Console.WriteLine(Appliquer(2, 3, Somme));
}
// ...

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.

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 :

static void Main(string[] args)
{
   Console.WriteLine(Appliquer(2, 3, (int x, int y) => x + y) );
}

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

static void Main(string[] args)
{
   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;

namespace ExerciceListeEntiers
{
   class Program
   {
      class ListeVideException : ApplicationException { }

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;

namespace ExerciceListeGénérique
{
   class Program
   {
      class ListeVideException : ApplicationException { }
      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;
      }
      static void Main(string[] args)
      {
         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));
      }
   }
}

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.

      static void Main(string[] args)
      {
         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));
      }
   }
}

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 !