Skip to content

7. Délégués, lambdas et événements

Dans le cours de 2008, ce chapitre suivait celui consacré aux interfaces graphiques Windows Forms : les événements y étaient d'abord rencontrés sur les boutons et les formulaires, puis on apprenait à créer des événements dans ses propres classes. Le chapitre sur les interfaces graphiques a été retiré de cette version du cours. Nous partons donc directement de la notion qui est à la base de tout le mécanisme : le délégué, c'est-à-dire un objet qui référence une ou plusieurs méthodes. Tous les exemples sont des programmes console.

Les délégués sont devenus en vingt ans l'un des outils les plus utilisés du langage. Leur écriture s'est considérablement allégée avec les expressions lambda (C# 3), que nous avons déjà utilisées sans les détailler avec LINQ au chapitre 5 : [Where(n => n > 5)] passe une lambda, donc un délégué, à la méthode [Where]. Ce chapitre explique ce qui se cache derrière cette écriture. Il se termine par les événements, qui reposent eux aussi sur les délégués. Les interfaces graphiques de .NET (Windows Forms, WPF, .NET MAUI) comme les frameworks web reposent sur ce mécanisme : un clic sur un bouton, l'arrivée d'une requête HTTP sont signalés à des méthodes abonnées.

Les programmes du chapitre sont des applications fichier unique, dans le dossier [07-delegues]. On les exécute par [dotnet run delegues-NN.cs].

7.1. Les types délégués prédéfinis

7.1.1. La notion de délégué

Un type délégué décrit un modèle (on dit aussi un prototype, ou une signature) de méthode : les types de ses paramètres et le type de son résultat. Une variable d'un type délégué peut alors référencer n'importe quelle méthode correspondant à ce modèle, et l'exécuter.

Le framework .NET définit de nombreux types délégués. Le plus ancien est [EventHandler], utilisé par tous les événements dont nous parlerons en fin de chapitre. Il est défini ainsi :

public delegate void EventHandler(object? sender, EventArgs e);

Le mot clé [delegate] fait de [EventHandler] un type délégué. Ce type désigne un modèle de méthode :

  • ayant pour 1er paramètre un objet de type [object?] (l'objet à l'origine de l'événement, éventuellement [null]) ;
  • ayant pour 2e paramètre un objet de type [EventArgs] (les informations sur l'événement) ;
  • ne rendant aucun résultat ([void]).

Une méthode correspondant au modèle défini par [EventHandler] pourrait être la suivante :

static void Afficher(object? sender, EventArgs e) { ... }

Pour créer un objet de type [EventHandler] qui référence cette méthode, on peut écrire :

EventHandler gestionnaire = new EventHandler(Afficher);

ou, plus simplement :

EventHandler gestionnaire = Afficher;

Dans cette seconde écriture, le nom de méthode [Afficher] (un groupe de méthodes, car une méthode peut avoir plusieurs surcharges) est converti automatiquement par le compilateur en un objet [EventHandler] : c'est la conversion de groupe de méthodes, possible depuis C# 2. Le compilateur choisit, parmi les surcharges de [Afficher], celle qui correspond à la signature du délégué. On exécute ensuite le délégué comme une méthode : [gestionnaire("programme", EventArgs.Empty)].

7.1.2. Les délégués génériques Func, Action et Predicate

Avant les génériques (C# 2), il fallait définir un type délégué pour chaque signature de méthode. Le framework fournit aujourd'hui des types délégués génériques qui couvrent la quasi-totalité des besoins :

Type délégué
Méthodes référencées
[Action]
aucun paramètre, aucun résultat : [void M()]
[Action<T1, ..., T16>]
1 à 16 paramètres, aucun résultat : [void M(T1 p1, ...)]
[Func<TResult>]
aucun paramètre, un résultat : [TResult M()]
[Func<T1, ..., T16, TResult>]
1 à 16 paramètres, un résultat de type [TResult] (toujours le dernier type)
[Predicate<T>]
un paramètre, un résultat booléen : [bool M(T p)]
[Comparison<T>]
deux paramètres de même type, un résultat entier : [int M(T x, T y)]

[Predicate<T>] et [Func<T, bool>] décrivent la même signature mais ce sont deux types différents : [Predicate<T>] date de .NET 2 et est utilisé par les méthodes de [List<T>] et de [Array] ([FindAll], [RemoveAll], [Exists]…), tandis que LINQ, apparu avec .NET 3.5, utilise [Func<T, bool>].

Le programme [delegues-01.cs] utilise ces types délégués :

// les types délégués prédéfinis de .NET

// 1 - le délégué EventHandler : void M(object? sender, EventArgs e)
EventHandler gestionnaire = new EventHandler(Afficher);
// forme abrégée : conversion de groupe de méthodes
gestionnaire = Afficher;
// exécution du délégué
gestionnaire("programme", EventArgs.Empty);

// 2 - Func<T1, T2, TResult> : deux paramètres int, un résultat int
Func<int, int, int> operation = Ajouter;
Console.WriteLine($"operation(2, 3)={operation(2, 3)}");
// Func<TResult> : aucun paramètre, un résultat
Func<string> saluer = Saluer;
Console.WriteLine($"saluer()={saluer()}");

// 3 - Action<T> : un paramètre, pas de résultat
Action<string> ecrire = Console.WriteLine;
ecrire("Action<string> a exécuté Console.WriteLine");

// 4 - Predicate<T> : un paramètre, un résultat bool
Predicate<int> estPair = EstPair;
List<int> nombres = [3, 8, 1, 6, 5, 4];
// FindAll et RemoveAll de List<T> attendent un Predicate<T>
Console.WriteLine($"pairs : {string.Join(" ", nombres.FindAll(estPair))}");
int nbRetires = nombres.RemoveAll(EstPair);
Console.WriteLine($"{nbRetires} nombres pairs retirés, reste : {string.Join(" ", nombres)}");

// 5 - Comparison<T> : deux paramètres T, un résultat int
List<string> noms = ["Paul", "Mélanie", "Jo", "Sébastien"];
noms.Sort(ComparerLongueurs);
Console.WriteLine($"triés par longueur : {string.Join(" ", noms)}");

// 6 - un délégué est un objet : son type et la méthode qu'il référence
Console.WriteLine($"type : {operation.GetType()}");
Console.WriteLine($"méthode : {operation.Method.Name}, cible : {operation.Target?.ToString() ?? "aucune (méthode statique)"}");

// les méthodes référencées par les délégués
static void Afficher(object? sender, EventArgs e)
{
    Console.WriteLine($"Afficher(sender={sender}, e={e.GetType().Name})");
}

static int Ajouter(int n1, int n2) => n1 + n2;

static string Saluer() => "Bonjour";

static bool EstPair(int n) => n % 2 == 0;

static int ComparerLongueurs(string s1, string s2) => s1.Length.CompareTo(s2.Length);

Résultats :

> dotnet run delegues-01.cs
Afficher(sender=programme, e=EventArgs)
operation(2, 3)=5
saluer()=Bonjour
Action<string> a exécuté Console.WriteLine
pairs : 8 6 4
3 nombres pairs retirés, reste : 3 1 5
triés par longueur : Jo Paul Mélanie Sébastien
type : System.Func`3[System.Int32,System.Int32,System.Int32]
méthode : <<Main>$>g__Ajouter|0_1, cible : aucune (méthode statique)

Commentaires :

  • ligne 4 : création d'un délégué [EventHandler] avec l'opérateur [new]. C'était l'écriture obligatoire en C# 1 ;
  • ligne 6 : la même chose avec une conversion de groupe de méthodes. C'est l'écriture usuelle aujourd'hui ;
  • ligne 8 : le délégué est exécuté comme une méthode. C'est la méthode [Afficher] de la ligne 39 qui est exécutée, avec les paramètres passés au délégué ;
  • ligne 11 : [Func<int, int, int>] désigne une méthode ayant deux paramètres [int] et rendant un [int] (le dernier type générique). La méthode [Ajouter] de la ligne 44 convient ;
  • ligne 14 : [Func<string>] désigne une méthode sans paramètre rendant une chaîne ;
  • ligne 18 : [Action<string>] désigne une méthode ayant un paramètre [string] et ne rendant rien. [Console.WriteLine] a de nombreuses surcharges ; le compilateur choisit [WriteLine(string)] ;
  • ligne 22 : un prédicat est une méthode qui rend un booléen. La méthode [FindAll] de la classe [List<T>] (ligne 25) attend un paramètre de type [Predicate<T>] : elle rend la liste des éléments pour lesquels le prédicat rend [true]. Ligne 26, on passe directement le nom de la méthode [EstPair] : le compilateur fait la conversion vers [Predicate<int>] ;
  • ligne 31 : la méthode [Sort] accepte un délégué [Comparison<T>] qui compare deux éléments (résultat négatif, nul ou positif, comme [CompareTo]). On trie ici les noms par longueur croissante ;
  • lignes 35-36 : un délégué est un objet. Son type, affiché ligne 9 des résultats, est [System.Func`3[System.Int32,System.Int32,System.Int32]] : le nom technique d'un type générique à trois paramètres de type. Tout type délégué dérive de la classe [System.Delegate], qui offre notamment les propriétés [Method] (la méthode référencée) et [Target] (l'objet sur lequel la méthode est appelée, [null] pour une méthode statique) ;
  • le nom de la méthode, ligne 10 des résultats, est surprenant : les méthodes des lignes 39-50 sont des fonctions locales du programme principal (cf. chapitre 3). Le compilateur les transforme en méthodes statiques d'une classe qu'il génère, avec un nom qu'aucun programme C# ne peut écrire.

7.2. Définir des types délégués

Les types prédéfinis suffisent souvent, mais on peut définir ses propres types délégués. L'instruction :

public delegate int Operation(int n1, int n2);

définit un type appelé [Operation] qui représente un modèle de méthode acceptant deux entiers et rendant un entier. Par rapport à [Func<int, int, int>], le type [Operation] a un nom qui dit à quoi il sert, et ses paramètres ont des noms ([n1], [n2]) que les outils de développement affichent.

Une variable [op] de type [Operation] a pour rôle d'enregistrer une liste de méthodes correspondant au modèle [Operation] : on parle de délégué multicast.

  • l'enregistrement d'une méthode [f] dans la variable [op] se fait par [op = new Operation(f)] ou plus simplement par [op = f] ;
  • pour ajouter une méthode [g] à la liste des méthodes déjà enregistrées, on écrit [op += g] ;
  • pour enlever une méthode [h] déjà enregistrée, on écrit [op -= h] ;
  • l'instruction [n = op(n1, n2)] exécute, dans l'ordre d'enregistrement, toutes les méthodes enregistrées dans [op], avec les paramètres [n1] et [n2]. Le résultat [n] récupéré est celui de la dernière méthode exécutée.

Le cours de 2008 indiquait qu'il n'est pas possible d'obtenir les résultats de toutes les méthodes. C'est possible, mais pas avec un simple appel : il faut parcourir la liste des méthodes, rendue par [GetInvocationList], et les appeler une à une. En pratique, les méthodes enregistrées dans un délégué multicast rendent le plus souvent [void].

Considérons l'exemple [delegues-02.cs] :

// définir son propre type délégué

// on définit un objet de type Operation pour y enregistrer des méthodes
// on enregistre la méthode statique Augmenter
Operation op = Calculs.Augmenter;
// on exécute le délégué
int n = op(4, 7);
Console.WriteLine($"n={n}");

// création d'un objet c1 de type Calculs
var c1 = new Calculs();
// on enregistre dans le délégué la méthode Ajouter de c1
op = c1.Ajouter;
// exécution de l'objet délégué
n = op(2, 3);
Console.WriteLine($"n={n}");
// on enregistre dans le délégué la méthode Soustraire de c1
op = c1.Soustraire;
n = op(2, 3);
Console.WriteLine($"n={n}");

// enregistrement de deux méthodes dans le délégué (délégué multicast)
op = c1.Ajouter;
op += c1.Soustraire;
// exécution de l'objet délégué : on récupère le résultat de la dernière méthode
n = op(10, 4);
Console.WriteLine($"n={n}");
// les résultats de toutes les méthodes avec la liste d'invocation
foreach (Operation methode in op.GetInvocationList().Cast<Operation>())
{
    Console.WriteLine($"{methode.Method.Name} -> {methode(10, 4)}");
}
// on retire une méthode du délégué : le résultat peut être null (type Operation?)
Operation? reste = op - c1.Soustraire;
// on exécute le délégué s'il n'est pas null
if (reste is not null)
{
    n = reste(1, 1);
    Console.WriteLine($"n={n}");
}
// on retire la dernière méthode : le délégué devient null
Operation? vide = reste - c1.Ajouter;
Console.WriteLine($"vide est null : {vide is null}");
// ?.Invoke n'exécute le délégué que s'il est non null
Console.WriteLine($"vide?.Invoke(1, 1)={vide?.Invoke(1, 1)}");

// une classe dont les méthodes ont la signature du délégué Operation
public class Calculs
{
    // deux méthodes d'instance
    public int Ajouter(int n1, int n2)
    {
        Console.WriteLine($"Ajouter({n1},{n2})");
        return n1 + n2;
    }

    public int Soustraire(int n1, int n2)
    {
        Console.WriteLine($"Soustraire({n1},{n2})");
        return n1 - n2;
    }

    // une méthode statique
    public static int Augmenter(int n1, int n2)
    {
        Console.WriteLine($"Augmenter({n1},{n2})");
        return n1 + 2 * n2;
    }
}

// définition d'un type délégué : un modèle de méthode
// acceptant deux entiers en paramètres et rendant un entier
public delegate int Operation(int n1, int n2);

Résultats :

> dotnet run delegues-02.cs
Augmenter(4,7)
n=18
Ajouter(2,3)
n=5
Soustraire(2,3)
n=-1
Ajouter(10,4)
Soustraire(10,4)
n=6
Ajouter(10,4)
Ajouter -> 14
Soustraire(10,4)
Soustraire -> 6
Ajouter(1,1)
n=2
vide est null : True
vide?.Invoke(1, 1)=

Commentaires :

  • ligne 73 : définition du type délégué [Operation]. Dans une application à instructions de haut niveau, les déclarations de types (classes, interfaces, délégués…) se placent après les instructions du programme principal ;
  • lignes 48-67 : la classe [Calculs] a deux méthodes d'instance, [Ajouter] et [Soustraire], et une méthode statique, [Augmenter], qui ont toutes trois la signature du type [Operation] ;
  • ligne 5 : la variable [op] est de type [Operation]. On lui affecte une première référence de méthode, celle de la méthode statique [Calculs.Augmenter] ;
  • ligne 7 : le délégué [op] est exécuté. Ce sont toutes les méthodes référencées par [op] qui sont exécutées, avec les paramètres passés à [op]. Ici, seule la méthode [Calculs.Augmenter] l'est ;
  • ligne 11 : on crée une instance [c1] de la classe [Calculs] ;
  • ligne 13 : la méthode d'instance [c1.Ajouter] est affectée au délégué [op]. [Augmenter] était une méthode statique, [Ajouter] est une méthode d'instance : cela n'a pas d'importance. Dans le second cas, le délégué mémorise aussi l'objet [c1] (sa propriété [Target]) sur lequel la méthode sera appelée ;
  • ligne 18 : on fait de même avec la méthode d'instance [Soustraire] ;
  • lignes 23-24 : on enregistre les deux méthodes [Ajouter] et [Soustraire] dans le délégué [op] ;
  • ligne 26 : les deux méthodes sont exécutées (lignes 8-9 des résultats). Le résultat obtenu, 6, est celui de [Soustraire], la dernière méthode exécutée ;
  • lignes 29-31 : [GetInvocationList] rend la liste des méthodes du délégué sous la forme d'un tableau [Delegate[]]. La méthode LINQ [Cast<Operation>()] convertit chacun de ses éléments en [Operation], ce qui permet de les appeler un par un et de récupérer chaque résultat ;
  • ligne 34 : on retire la méthode [Soustraire] du délégué. L'écriture [op -= c1.Soustraire] est équivalente à [op = op - c1.Soustraire]. Le résultat de l'opérateur [-] est de type [Operation?] : si on retire la dernière méthode d'un délégué, on obtient [null], et non un délégué vide. Le compilateur, qui suit les types nullables (cf. chapitre 3), émettrait des avertissements si l'on écrivait [op -= c1.Soustraire] puis [op(1, 1)] : [CS8601] sur l'affectation d'une valeur peut-être nulle à la variable non nullable [op], [CS8602] sur l'appel. Nous rangeons donc le résultat dans une variable de type [Operation?] et nous le testons avant de l'exécuter (ligne 36) ;
  • ligne 42 : on retire la dernière méthode : le délégué obtenu est [null] (ligne 17 des résultats) ;
  • ligne 45 : un délégué peut aussi être exécuté par sa méthode [Invoke] : [op(1, 1)] et [op.Invoke(1, 1)] sont équivalents. Avec l'opérateur [?.] (C# 6), [vide?.Invoke(1, 1)] n'exécute le délégué que s'il n'est pas [null] ; sinon l'expression vaut [null] et rien n'est exécuté. L'interpolation de chaîne affiche [null] comme une chaîne vide. Nous retrouverons cette écriture avec les événements.

Un délégué est un objet immuable : les opérations [+] et [-] (et donc [+=] et [-=]) ne modifient pas le délégué existant, elles en créent un nouveau. Cette propriété aura son importance pour les événements.

7.3. Méthodes anonymes et expressions lambda

7.3.1. Des méthodes anonymes aux lambdas

Dans les exemples précédents, chaque délégué référence une méthode nommée, écrite ailleurs dans le programme. Pour des méthodes courtes, utilisées une seule fois, c'est lourd. C# 2 a introduit les méthodes anonymes : le corps de la méthode est écrit à l'endroit même où l'on crée le délégué. C# 3 a introduit une écriture encore plus concise, les expressions lambda, qui ont pratiquement remplacé les méthodes anonymes. Le programme [delegues-03.cs] présente les différentes formes :

// des méthodes anonymes aux expressions lambda

// 1 - une méthode anonyme (C# 2) : le corps de la méthode est écrit sur place
Func<int, int, int> ajouter = delegate (int n1, int n2)
{
    return n1 + n2;
};
Console.WriteLine($"méthode anonyme : ajouter(2, 3)={ajouter(2, 3)}");

// 2 - la même chose avec une expression lambda (C# 3) : paramètres => corps
Func<int, int, int> soustraire = (int n1, int n2) =>
{
    return n1 - n2;
};
Console.WriteLine($"lambda à corps de bloc : soustraire(2, 3)={soustraire(2, 3)}");

// 3 - les types des paramètres sont déduits du type délégué cible
Func<int, int, int> multiplier = (n1, n2) => n1 * n2;
Console.WriteLine($"lambda à corps d'expression : multiplier(2, 3)={multiplier(2, 3)}");

// 4 - un seul paramètre : les parenthèses sont facultatives
Func<int, int> carre = x => x * x;
Predicate<string> estVide = s => s.Length == 0;
Console.WriteLine($"carre(7)={carre(7)}, estVide(\"\")={estVide("")}");

// 5 - aucun paramètre : parenthèses vides
Func<string> saluer = () => "Bonjour";
Action bip = () => Console.WriteLine("bip !");
Console.WriteLine(saluer());
bip();

// 6 - un corps de bloc avec plusieurs instructions
Action<string, int> repeter = (texte, fois) =>
{
    for (int i = 0; i < fois; i++)
    {
        Console.Write(texte);
    }
    Console.WriteLine();
};
repeter("ab", 3);

// 7 - paramètres ignorés (discards) : on n'a pas besoin de leurs valeurs
EventHandler gestionnaire = (_, _) => Console.WriteLine("événement reçu");
gestionnaire(null, EventArgs.Empty);

// 8 - une lambda passée directement en paramètre d'une méthode
List<int> nombres = [3, 8, 1, 6, 5, 4];
Console.WriteLine($"pairs : {string.Join(" ", nombres.FindAll(n => n % 2 == 0))}");
nombres.Sort((n1, n2) => n2.CompareTo(n1));
Console.WriteLine($"ordre décroissant : {string.Join(" ", nombres)}");

Résultats :

> dotnet run delegues-03.cs
méthode anonyme : ajouter(2, 3)=5
lambda à corps de bloc : soustraire(2, 3)=-1
lambda à corps d'expression : multiplier(2, 3)=6
carre(7)=49, estVide("")=True
Bonjour
bip !
ababab
événement reçu
pairs : 8 6 4
ordre décroissant : 8 6 5 4 3 1

Commentaires :

  • lignes 4-6 : une méthode anonyme. Le mot clé [delegate] est suivi de la liste des paramètres et du corps de la méthode. Le compilateur génère une méthode (avec un nom qu'il choisit) et crée un délégué [Func<int, int, int>] qui la référence. Il n'y a pas de nom de méthode ni de type de résultat : le type du résultat est déduit des instructions [return] ;
  • lignes 11-13 : la même chose avec une expression lambda. On écrit la liste des paramètres, l'opérateur [=>] (qui se lit « donne » ou « tel que »), puis le corps. Une lambda dont le corps est un bloc entre accolades est une lambda à corps de bloc (statement lambda) ;
  • ligne 18 : les types des paramètres ont disparu. Le compilateur les déduit du type délégué cible [Func<int, int, int>] : [n1] et [n2] sont de type [int]. Le corps est réduit à une expression, dont la valeur est le résultat de la lambda : c'est une lambda à corps d'expression (expression lambda). C'est la forme la plus courante ;
  • ligne 22 : avec un seul paramètre dont le type n'est pas précisé, les parenthèses sont facultatives. [x => x * x] se lit « x donne x fois x ». Ligne 23, la même lambda d'un paramètre, de type [string], rend un booléen : c'est un [Predicate<string>] ;
  • lignes 27-28 : sans paramètre, on écrit des parenthèses vides. Une lambda dont le corps est un appel de méthode [void] convient à un délégué [Action] ;
  • lignes 33-39 : une lambda à corps de bloc peut contenir n'importe quelles instructions : déclarations, boucles, tests… ;
  • ligne 44 : le caractère de soulignement [_] désigne un paramètre ignoré (discard, C# 9). On l'utilise quand la signature du délégué impose des paramètres dont la lambda n'a pas besoin, ce qui est fréquent pour les gestionnaires d'événements ;
  • ligne 49 : l'usage le plus fréquent des lambdas consiste à les passer directement en paramètre d'une méthode qui attend un délégué. Comparez avec [delegues-01.cs] : il n'est plus nécessaire d'écrire une méthode [EstPair] ;
  • ligne 50 : une lambda de type [Comparison<int>] qui inverse la comparaison usuelle : la liste est triée par ordre décroissant.

Retenons les formes d'une expression lambda :

Forme
Exemple
paramètres typés, corps de bloc
[(int a, int b) => { return a + b; }]
paramètres non typés, corps d'expression
[(a, b) => a + b]
un seul paramètre non typé
[x => x * x]
aucun paramètre
[() => "Bonjour"]
paramètres ignorés
[(_, _) => Console.WriteLine("reçu")]

Les paramètres sont tous typés ou tous non typés : on ne peut pas mélanger les deux. Dans le cours, nous utiliserons les lambdas plutôt que les méthodes anonymes, qui n'ont plus d'intérêt que dans de rares cas.

7.3.2. Compléments sur les lambdas

Les versions récentes de C# ont enrichi les lambdas. Le programme [delegues-04.cs] présente ces nouveautés :

// compléments sur les expressions lambda (C# 9 à C# 14)

// 1 - type naturel d'une lambda (C# 10) : var est possible si les paramètres sont typés
var doubler = (int x) => x * 2;
Console.WriteLine($"doubler(21)={doubler(21)}, type : {Nom(doubler)}");
var afficher = (string s) => Console.WriteLine($"[{s}]");
afficher("Action<string>");
Console.WriteLine($"type de afficher : {Nom(afficher)}");
// un groupe de méthodes a lui aussi un type naturel s'il n'a qu'une surcharge
var estPair = EstPair;
Console.WriteLine($"estPair(4)={estPair(4)}, type : {Nom(estPair)}");

// 2 - type de retour explicite (C# 10) : utile quand il ne peut pas être déduit
var choisir = object (bool b) => b ? 1 : "un";
Console.WriteLine($"choisir(true)={choisir(true)}, choisir(false)={choisir(false)}");

// 3 - paramètres avec valeur par défaut (C# 12)
var saluer = (string nom, string formule = "Bonjour") => $"{formule} {nom}";
Console.WriteLine(saluer("Pierre"));
Console.WriteLine(saluer("Pierre", "Bonsoir"));
// ainsi que params
var somme = (params int[] valeurs) => valeurs.Sum();
Console.WriteLine($"somme()={somme()}, somme(1, 2, 3)={somme(1, 2, 3)}");
// le type de ces lambdas est un délégué anonyme généré par le compilateur
Console.WriteLine($"type de saluer : {Nom(saluer)}");

// 4 - lambda static (C# 9) : elle ne peut utiliser aucune variable locale ni this
const int Coefficient = 3;
Func<int, int> tripler = static x => x * Coefficient; // une constante est permise
Console.WriteLine($"tripler(5)={tripler(5)}");

// 5 - modificateurs ref / out / in sans type (C# 14)
// le type délégué Incrementeur fixe les types : la lambda n'a pas besoin de les répéter
Incrementeur incrementer = (ref x) => x++;
int compteur = 10;
incrementer(ref compteur);
incrementer(ref compteur);
Console.WriteLine($"compteur={compteur}");
Analyseur analyser = (texte, out valeur) => int.TryParse(texte, out valeur);
Console.WriteLine($"analyser(\"12\") : {analyser("12", out int v1)} {v1}");
Console.WriteLine($"analyser(\"x\") : {analyser("x", out int v2)} {v2}");

// nom lisible d'un type délégué : Func<Int32, Int32> plutôt que Func`2
static string Nom(Delegate d)
{
    Type t = d.GetType();
    if (!t.IsGenericType)
    {
        return t.Name;
    }
    string nom = t.Name[..t.Name.IndexOf('`')];
    return $"{nom}<{string.Join(", ", t.GetGenericArguments().Select(a => a.Name))}>";
}

static bool EstPair(int n) => n % 2 == 0;

// des types délégués avec des paramètres ref et out
delegate void Incrementeur(ref int x);
delegate bool Analyseur(string texte, out int valeur);

Résultats :

> dotnet run delegues-04.cs
doubler(21)=42, type : Func<Int32, Int32>
[Action<string>]
type de afficher : Action<String>
estPair(4)=True, type : Func<Int32, Boolean>
choisir(true)=1, choisir(false)=un
Bonjour Pierre
Bonsoir Pierre
somme()=0, somme(1, 2, 3)=6
type de saluer : <>f__AnonymousDelegate0<String, String, String>
tripler(5)=15
compteur=12
analyser("12") : True 12
analyser("x") : False 0

Commentaires :

  • ligne 4 : jusqu'à C# 9, une lambda n'avait pas de type par elle-même : il fallait toujours indiquer le type délégué cible ([Func<int, int> doubler = x => x * 2]). Depuis C# 10, une lambda dont les paramètres sont typés a un type naturel : ici [Func<int, int>], comme le montre la ligne 2 des résultats. On peut donc écrire [var]. En revanche, [var doubler = x => x * 2;] est refusé par le compilateur, qui ne peut pas deviner le type de [x] ;
  • ligne 6 : le type naturel d'une lambda qui ne rend rien est un [Action<...>] ;
  • ligne 10 : un groupe de méthodes a lui aussi un type naturel, à condition qu'il n'y ait qu'une seule méthode de ce nom. [var ecrire = Console.WriteLine;] serait refusé : [WriteLine] a une vingtaine de surcharges ;
  • ligne 14 : depuis C# 10, on peut indiquer le type du résultat devant la liste des paramètres. C'est nécessaire ici : les deux branches de l'opérateur conditionnel ont des types différents ([int] et [string]) et le compilateur ne saurait pas quel type de résultat choisir ;
  • ligne 18 : depuis C# 12, les paramètres d'une lambda peuvent avoir une valeur par défaut, comme ceux d'une méthode. Les lignes 19-20 appellent la lambda avec et sans le second paramètre ;
  • ligne 22 : de même, le dernier paramètre d'une lambda peut être [params] ;
  • ligne 25 : aucun des types [Func] ou [Action] ne sait représenter un paramètre optionnel ou [params]. Le compilateur génère alors un type délégué anonyme, ici [<>f__AnonymousDelegate0] (ligne 10 des résultats). On ne peut l'utiliser que par [var] ;
  • ligne 29 : une lambda déclarée [static] (C# 9) ne peut utiliser ni les variables locales de la méthode qui l'entoure, ni [this]. Elle peut utiliser les constantes, comme [Coefficient] ligne 28. Nous verrons au paragraphe suivant ce que signifie « utiliser une variable locale » ; le mot clé [static] garantit qu'une lambda n'en capture aucune, ce qui évite une allocation mémoire et des erreurs ;
  • ligne 58 : un type délégué dont le paramètre est passé par référence ([ref], cf. chapitre 3). Les types [Func] et [Action] ne permettent pas les paramètres [ref] ou [out] : il faut définir son propre type délégué ;
  • ligne 34 : jusqu'à C# 13, une lambda dont un paramètre est [ref], [out] ou [in] devait typer tous ses paramètres : [(ref int x) => x++]. Depuis C# 14, le modificateur suffit, le type étant déduit du délégué cible. Les lignes 36-37 incrémentent deux fois la variable [compteur] ;
  • ligne 39 : la même chose avec un paramètre [out]. La lambda transmet son paramètre [out] à [int.TryParse] ;
  • lignes 44-52 : une fonction utilitaire qui affiche le nom d'un type délégué générique de façon lisible ([Func<Int32, Int32>] plutôt que [Func`2]). Elle utilise elle-même une lambda, ligne 52.

7.3.3. Capture de variables et fermetures

Une lambda peut utiliser les variables locales de la méthode dans laquelle elle est écrite. On dit qu'elle capture ces variables. Ce mécanisme, très puissant, réserve quelques surprises. Le programme [delegues-05.cs] les met en évidence :

// capture de variables et fermetures

// 1 - une lambda peut utiliser les variables locales qui l'entourent
int seuil = 5;
Func<int, bool> depasse = n => n > seuil;
Console.WriteLine($"seuil={seuil} : depasse(7)={depasse(7)}");
// la variable est capturée, pas sa valeur : la lambda voit la modification
seuil = 10;
Console.WriteLine($"seuil={seuil} : depasse(7)={depasse(7)}");

// 2 - une lambda peut aussi modifier une variable capturée
int total = 0;
Action<int> cumuler = n => total += n;
cumuler(3);
cumuler(4);
Console.WriteLine($"total={total}");

// 3 - une fermeture : la variable capturée survit à la méthode qui l'a créée
Func<int> compteurA = CreerCompteur();
Func<int> compteurB = CreerCompteur();
Console.WriteLine($"A : {compteurA()} {compteurA()} {compteurA()}");
Console.WriteLine($"B : {compteurB()} {compteurB()}");

// 4 - le piège de la capture dans une boucle for
List<Action> actions = [];
for (int i = 0; i < 3; i++)
{
    // les trois lambdas capturent la même variable i
    actions.Add(() => Console.Write($"{i} "));
}
Console.Write("for, capture de i : ");
foreach (Action action in actions)
{
    action();
}
Console.WriteLine();

// la correction : une copie locale, nouvelle à chaque tour de boucle
actions.Clear();
for (int i = 0; i < 3; i++)
{
    int copie = i;
    actions.Add(() => Console.Write($"{copie} "));
}
Console.Write("for, capture d'une copie : ");
actions.ForEach(action => action());
Console.WriteLine();

// foreach (depuis C# 5) crée une nouvelle variable à chaque tour
actions.Clear();
foreach (int j in new[] { 0, 1, 2 })
{
    actions.Add(() => Console.Write($"{j} "));
}
Console.Write("foreach : ");
actions.ForEach(action => action());
Console.WriteLine();

// une méthode qui rend une fonction
static Func<int> CreerCompteur()
{
    // variable locale capturée par la lambda rendue
    int valeur = 0;
    return () => ++valeur;
}

Résultats :

1
2
3
4
5
6
7
8
9
> dotnet run delegues-05.cs
seuil=5 : depasse(7)=True
seuil=10 : depasse(7)=False
total=7
A : 1 2 3
B : 1 2
for, capture de i : 3 3 3 
for, capture d'une copie : 0 1 2 
foreach : 0 1 2 

Commentaires :

  • ligne 5 : la lambda utilise la variable locale [seuil] déclarée ligne 4. [seuil] n'est pas un paramètre de la lambda : c'est une variable capturée ;
  • ligne 8 : on modifie [seuil] après la création de la lambda. Les résultats (lignes 2-3) montrent que la lambda voit la nouvelle valeur : [depasse(7)] rend [True] puis [False]. La lambda n'a pas recopié la valeur de [seuil] au moment de sa création, elle partage la variable elle-même avec la méthode qui l'entoure ;
  • ligne 13 : une lambda peut aussi modifier une variable capturée. Après les deux appels des lignes 14-15, la variable [total] vaut 7 ;
  • lignes 60-64 : la fonction [CreerCompteur] rend une lambda qui capture sa variable locale [valeur]. Normalement, une variable locale disparaît quand la méthode se termine. Ici, la variable [valeur] survit à [CreerCompteur] car la lambda rendue l'utilise encore. L'ensemble formé par la lambda et les variables qu'elle capture s'appelle une fermeture (closure). Pour la réaliser, le compilateur transforme les variables capturées en champs d'un objet qu'il génère, et la lambda en méthode de cet objet ;
  • lignes 19-20 : chaque appel de [CreerCompteur] crée une nouvelle variable [valeur], donc un nouveau compteur, indépendant des autres. Les résultats (lignes 5-6) le confirment ;
  • lignes 26-29 : le piège classique. On crée trois lambdas dans une boucle [for], en pensant qu'elles afficheront 0, 1 et 2. Elles affichent toutes 3 (ligne 7 des résultats). En effet, la boucle [for] n'a qu'une seule variable [i], partagée par les trois lambdas. Quand celles-ci sont exécutées, la boucle est terminée et [i] vaut 3 ;
  • lignes 42-43 : la correction. La variable [copie] est déclarée dans le corps de la boucle : une nouvelle variable est créée à chaque tour, et chaque lambda capture la sienne ;
  • lignes 51-53 : avec [foreach], le problème ne se pose pas. Jusqu'à C# 4, la variable d'une boucle [foreach] était unique, comme celle d'une boucle [for], et ce code affichait 2 2 2. C# 5 a changé cette règle (c'est l'un des rares changements incompatibles de l'histoire du langage) : la variable [j] est maintenant nouvelle à chaque tour. La boucle [for] n'a pas été modifiée ;
  • ligne 46 : la méthode [ForEach] de [List<T>] attend un [Action<T>] et l'exécute sur chaque élément. Ici chaque élément est lui-même un délégué [Action], que la lambda exécute.

Le piège de la boucle [for] se rencontre surtout avec les traitements différés : une lambda enregistrée comme gestionnaire d'événement ou lancée dans un autre thread (chapitre 8) s'exécute après la fin de la boucle qui l'a créée.

7.4. Délégués ou interfaces ?

Les notions de délégué et d'interface peuvent sembler assez proches et on peut se demander quelles sont exactement les différences entre les deux. Prenons l'exemple [delegues-06.cs], proche de [delegues-02.cs] :

// un paramètre de type délégué

// exécution du délégué Ajouter
Console.WriteLine(Executer(Ajouter, 2, 3));
// exécution du délégué Soustraire
Console.WriteLine(Executer(Soustraire, 2, 3));
// exécution d'un délégué multicast
Operation op = Ajouter;
op += Soustraire;
Console.WriteLine(Executer(op, 2, 3));
// exécution d'une lambda : aucune méthode nommée n'est nécessaire
Console.WriteLine(Executer((n1, n2) => n1 * n2, 2, 3));

// exécution d'un délégué
static int Executer(Operation op, int n1, int n2)
{
    return op(n1, n2);
}

// deux méthodes ayant la signature du délégué Operation
static int Ajouter(int n1, int n2)
{
    Console.WriteLine($"Ajouter({n1},{n2})");
    return n1 + n2;
}

static int Soustraire(int n1, int n2)
{
    Console.WriteLine($"Soustraire({n1},{n2})");
    return n1 - n2;
}

// le type délégué
delegate int Operation(int n1, int n2);

Résultats :

1
2
3
4
5
6
7
8
9
> dotnet run delegues-06.cs
Ajouter(2,3)
5
Soustraire(2,3)
-1
Ajouter(2,3)
Soustraire(2,3)
-1
6

Ligne 15, la méthode [Executer] attend une référence sur un objet du type délégué [Operation] défini ligne 34. Cela permet de passer à la méthode [Executer] différentes méthodes (lignes 4, 6, 10 et 12). La dernière ligne lui passe même une lambda : il n'a pas été nécessaire d'écrire une méthode de multiplication. Cette propriété de polymorphisme peut être obtenue également avec une interface, comme le montre [delegues-07.cs] :

// un paramètre de type interface

// exécution de l'opération Ajouter
Console.WriteLine(Executer(new Ajouter(), 2, 3));
// exécution de l'opération Soustraire
Console.WriteLine(Executer(new Soustraire(), 2, 3));

// exécution de la méthode unique de l'interface IOperation
static int Executer(IOperation op, int n1, int n2)
{
    return op.Operation(n1, n2);
}

// interface IOperation
public interface IOperation
{
    int Operation(int n1, int n2);
}

// classe Ajouter
public class Ajouter : IOperation
{
    public int Operation(int n1, int n2)
    {
        Console.WriteLine($"Ajouter({n1},{n2})");
        return n1 + n2;
    }
}

// classe Soustraire
public class Soustraire : IOperation
{
    public int Operation(int n1, int n2)
    {
        Console.WriteLine($"Soustraire({n1},{n2})");
        return n1 - n2;
    }
}

Résultats :

1
2
3
4
5
> dotnet run delegues-07.cs
Ajouter(2,3)
5
Soustraire(2,3)
-1

Commentaires :

  • lignes 15-17 : l'interface [IOperation] définit une méthode [Operation] ;
  • lignes 21-26 et 31-36 : les classes [Ajouter] et [Soustraire] implémentent l'interface [IOperation] ;
  • lignes 9-11 : la méthode [Executer] dont le 1er paramètre est du type de l'interface [IOperation]. Elle reçoit successivement comme 1er paramètre une instance de la classe [Ajouter] puis une instance de la classe [Soustraire].

On retrouve bien l'aspect polymorphique qu'avait le paramètre de type délégué de l'exemple précédent. Les deux exemples montrent en même temps des différences entre ces deux notions. Les types délégué et interface sont interchangeables :

  • si l'interface n'a qu'une méthode. En effet, le type délégué est une enveloppe pour une unique méthode alors que l'interface peut, elle, définir plusieurs méthodes ;
  • si l'aspect multicast du délégué n'est pas utilisé. Cette notion de multicast n'existe pas pour une interface.

Si ces deux conditions sont vérifiées, on a le choix entre les deux signatures suivantes pour la méthode [Executer] :

int Executer(IOperation op, int n1, int n2)
int Executer(Operation op, int n1, int n2)

La seconde, qui utilise le délégué, se montre plus souple d'utilisation. Dans la première signature, le premier paramètre doit implémenter l'interface [IOperation] : cela oblige à créer une classe pour y définir la méthode à passer à [Executer]. Dans la seconde, toute méthode existante ayant la bonne signature fait l'affaire, et une lambda permet même d'écrire cette méthode à l'endroit de l'appel. Il n'y a pas de construction supplémentaire à faire. Cette souplesse explique que les bibliothèques modernes (LINQ, la programmation asynchrone, ASP.NET Core) utilisent massivement les délégués.

L'interface reprend l'avantage dès que le comportement à passer comporte plusieurs méthodes liées entre elles, ou qu'il a besoin d'un état et d'une identité propres. C'est le cas de l'interface [IImpotDao] de la couche [dao] du chapitre 6 : bien qu'elle n'ait qu'un membre, ses implémentations ont un état (les tranches lues, le nom du fichier), sont créées par le conteneur d'injection de dépendances et représentent un rôle clairement nommé de l'application. Un délégué [Func<TrancheImpot[]>] aurait techniquement suffi, mais il aurait été beaucoup moins parlant.

7.5. Gestion d'événements

7.5.1. Le modèle des événements .NET

Les délégués servent à définir des événements. Une classe [C1] peut définir un événement [Evt1] de la façon suivante :

  • un type délégué est défini dans ou en dehors de la classe [C1] :
delegate void Evt(T1 param1, T2 param2, ...);
  • la classe [C1] définit un champ de ce type délégué :
public Evt? Evt1;
  • lorsqu'une instance [c1] de la classe [C1] veut signaler un événement, elle exécute son délégué [Evt1] en lui passant les paramètres définis par le type [Evt]. Toutes les méthodes enregistrées dans le délégué [Evt1] sont alors exécutées avec ces paramètres. On peut dire qu'elles ont été averties de l'événement [Evt1] ;
  • si un objet [c2] utilisant l'objet [c1] veut être averti de l'événement [Evt1] de l'objet [c1], il enregistre l'une de ses méthodes [c2.M] dans le délégué [c1.Evt1]. Sa méthode [c2.M] sera alors exécutée chaque fois que l'événement [Evt1] se produira sur l'objet [c1]. Il pourra également se désinscrire lorsqu'il ne voudra plus être averti ;
  • comme le délégué [c1.Evt1] peut enregistrer plusieurs méthodes, différents objets [ci] peuvent s'enregistrer auprès de [c1.Evt1] pour être prévenus de l'événement.

Dans ce scénario, on a :

  • une classe qui signale (on dit aussi publie, ou émet) un événement ;
  • des classes qui sont averties de cet événement. On dit qu'elles souscrivent, ou s'abonnent, à l'événement ;
  • un type délégué qui définit la signature des méthodes qui seront averties de l'événement. Ces méthodes sont appelées gestionnaires d'événement (event handlers).

C'est le patron de conception Observateur (Observer) : l'émetteur ne connaît pas ses abonnés, il se contente de les avertir.

Le framework .NET définit une signature standard pour le délégué d'un événement :

public delegate void MonEventHandler(object? sender, EventArgs e);
  • [sender] : l'objet qui a signalé l'événement ;
  • [e] : un objet de type [EventArgs] ou dérivé, qui apporte des informations sur l'événement ;
  • par convention, le nom du type délégué se termine par [EventHandler].

Il définit également une façon standard de déclarer un événement de type [MonEventHandler] dans une classe :

1
2
3
4
5
public class C1
{
    public event MonEventHandler? Evt1;
    ...
}

Le champ [Evt1] est de type délégué. Le mot clé [event] restreint les opérations qu'on peut faire sur lui :

  • de l'extérieur de la classe [C1], seules les opérations [+=] et [-=] sont possibles. Cela empêche de remplacer ou d'effacer (par erreur, par exemple en écrivant [=] au lieu de [+=]) les méthodes abonnées par d'autres objets. On peut seulement s'abonner ([+=]) ou se désabonner ([-=]) ;
  • seule la classe [C1] peut exécuter le délégué, c'est-à-dire déclencher l'événement : [Evt1?.Invoke(this, e)].

Si l'on essaie, de l'extérieur de la classe, d'écrire [c1.Evt1 = null;] ou [c1.Evt1(null, EventArgs.Empty);], le compilateur répond : [error CS0070: L'événement 'C1.Evt1' ne peut apparaître qu'à gauche de += ou -= (sauf quand il est utilisé à partir du type 'C1')] (en anglais : [The event 'C1.Evt1' can only appear on the left hand side of += or -= (except when used from within the type 'C1')]).

Plutôt que de définir un type délégué pour chaque événement, on utilise le délégué générique prédéfini :

public delegate void EventHandler<TEventArgs>(object? sender, TEventArgs e);

Le type [TEventArgs] est celui du second paramètre, l'objet qui décrit l'événement. Avec ce délégué générique, la déclaration d'un événement [X] dans une classe [C] suit le schéma conseillé suivant :

  • définir un type [XEventArgs] dérivé de [EventArgs] pour encapsuler les informations sur l'événement [X] ;
  • définir dans la classe [C] un événement de type [EventHandler<XEventArgs>] ;
  • définir dans la classe [C] une méthode protégée et virtuelle [protected virtual void OnX(XEventArgs e)] destinée à publier l'événement [X] aux abonnés. Elle est virtuelle pour qu'une classe dérivée puisse modifier la façon dont l'événement est publié.

En 2008, le type [TEventArgs] devait obligatoirement dériver de [EventArgs] (contrainte [where TEventArgs : EventArgs]). Cette contrainte a été supprimée dans .NET Framework 4.5 : n'importe quel type convient, y compris un [record]. On continue cependant, par convention, à dériver de [EventArgs]. Notons qu'un [record] ne peut pas dériver de [EventArgs] : un [record] ne peut hériter que d'un autre [record] (erreur [CS8864 : Les enregistrements peuvent uniquement hériter d'un objet ou d'un autre enregistrement]). Nous utiliserons donc une classe ordinaire.

7.5.2. Un exemple : la température trop haute

Considérons l'exemple suivant :

  • une classe [Emetteur] encapsule une température. Lorsque cette température dépasse un certain seuil, un événement doit être lancé. Nous appellerons cet événement [TemperatureTropHaute]. Les informations sur cet événement seront encapsulées dans un type [TemperatureTropHauteEventArgs] ;
  • une classe [Souscripteur] s'abonne à l'événement précédent. Lorsqu'elle est avertie de l'événement, elle affiche un message sur la console. Par rapport à la version de 2008, un souscripteur se désabonne de lui-même après avoir reçu un nombre donné d'alertes ;
  • un programme console crée un émetteur et deux souscripteurs. Il saisit des températures au clavier et les enregistre dans l'émetteur. Si l'une d'elles est trop haute, l'émetteur publie l'événement [TemperatureTropHaute].

Le cours de 2008 répartissait ces classes dans quatre fichiers d'un projet. Nous les regroupons dans l'application fichier unique [delegues-08.cs], que nous présentons en trois parties. Voici d'abord la classe des informations de l'événement et la classe [Emetteur] :

// les informations transportées par l'événement
public class TemperatureTropHauteEventArgs(decimal temperature) : EventArgs
{
    // température lors de l'événement
    public decimal Temperature { get; } = temperature;
    // heure de l'événement
    public DateTime Heure { get; } = DateTime.Now;
}

// la classe qui signale l'événement
public class Emetteur(string nom)
{
    // seuil de déclenchement de l'événement
    public const decimal Seuil = 19;
    // nom de l'émetteur
    public string Nom { get; } = nom;
    // l'événement signalé
    public event EventHandler<TemperatureTropHauteEventArgs>? TemperatureTropHaute;

    // température observée
    public decimal Temperature
    {
        get;
        set
        {
            field = value;
            if (value > Seuil)
            {
                // on signale l'événement aux abonnés
                OnTemperatureTropHaute(new TemperatureTropHauteEventArgs(value));
            }
        }
    }

    // signalement de l'événement
    protected virtual void OnTemperatureTropHaute(TemperatureTropHauteEventArgs e)
    {
        // émission de l'événement aux abonnés, s'il y en a
        TemperatureTropHaute?.Invoke(this, e);
    }
}

Commentaires :

  • lignes 38-43 : la classe [TemperatureTropHauteEventArgs] dérive de [EventArgs], comme le veut la convention. Elle utilise un constructeur primaire (C# 12, cf. chapitre 4) : le paramètre [temperature] initialise la propriété en lecture seule [Temperature], la température qui a provoqué l'événement. Nous avons ajouté l'heure de l'événement, [Heure]. Les informations d'un événement sont en lecture seule : un abonné ne doit pas pouvoir les modifier pour les abonnés suivants ;
  • ligne 50 : le seuil de température au-delà duquel l'événement [TemperatureTropHaute] est publié ;
  • ligne 52 : l'émetteur a un nom pour être identifié ;
  • ligne 54 : l'événement [TemperatureTropHaute], de type [EventHandler<TemperatureTropHauteEventArgs>]. Le type est nullable ([?]) : tant que personne ne s'est abonné, le délégué vaut [null] ;
  • lignes 57-66 : la propriété [Temperature]. Son accesseur [set] publie l'événement si la température enregistrée dépasse le seuil. Elle utilise le mot clé [field] de C# 14 (cf. chapitre 4), qui désigne le champ caché d'une propriété automatique : on n'a plus besoin de déclarer un champ privé [temperature] comme en 2008 ;
  • ligne 66 : la publication est confiée à la méthode [OnTemperatureTropHaute], à qui on passe un objet [TemperatureTropHauteEventArgs] contenant la température qui a dépassé le seuil ;
  • lignes 72-75 : l'événement est publié avec pour 1er paramètre l'émetteur lui-même ([this]) et pour 2e paramètre l'objet reçu par la méthode. L'opérateur [?.] est indispensable : s'il n'y a aucun abonné, [TemperatureTropHaute] vaut [null]. Le code de 2008 écrivait [TemperatureTropHaute(this, evt)], qui lançait une [NullReferenceException] en l'absence d'abonné. L'écriture [TemperatureTropHaute?.Invoke(this, e)] est de plus sûre dans un programme multithread (chapitre 8) : le délégué est lu une seule fois, puis testé et exécuté ; un désabonnement simultané dans un autre thread ne peut pas le rendre [null] entre le test et l'appel.

Voici la classe [Souscripteur] :

// une classe qui s'abonne à l'événement
public class Souscripteur(string nom, int nbAlertesMax)
{
    // nom du souscripteur
    public string Nom { get; } = nom;
    // nombre d'alertes reçues
    private int nbAlertes;

    // abonnement à l'événement d'un émetteur
    public void Abonner(Emetteur emetteur)
    {
        emetteur.TemperatureTropHaute += EvtTemperatureTropHaute;
    }

    // désabonnement
    public void Desabonner(Emetteur emetteur)
    {
        emetteur.TemperatureTropHaute -= EvtTemperatureTropHaute;
    }

    // gestionnaire de l'événement TemperatureTropHaute
    private void EvtTemperatureTropHaute(object? source, TemperatureTropHauteEventArgs e)
    {
        if (source is not Emetteur emetteur)
        {
            return;
        }
        Console.WriteLine($"Souscripteur [{Nom}] : la source [{emetteur.Nom}] a signalé une température trop haute : [{e.Temperature}]");
        // au-delà de nbAlertesMax alertes, le souscripteur se désabonne
        nbAlertes++;
        if (nbAlertes == nbAlertesMax)
        {
            Desabonner(emetteur);
            Console.WriteLine($"Souscripteur [{Nom}] : désabonné après {nbAlertes} alerte(s)");
        }
    }
}

Commentaires :

  • ligne 80 : chaque souscripteur est identifié par un nom ([Nom]) et reçoit le nombre maximal d'alertes qu'il accepte ([nbAlertesMax]) ;
  • lignes 88-90 : l'abonnement à l'événement d'un émetteur. On enregistre la méthode [EvtTemperatureTropHaute] du souscripteur dans l'événement : c'est une conversion de groupe de méthodes. En 2008, l'abonnement était fait par le programme principal ; il est ici à la charge du souscripteur, qui doit connaître la méthode à retirer pour se désabonner ;
  • lignes 94-96 : le désabonnement retire la même méthode avec [-=]. Pour le délégué, la méthode [EvtTemperatureTropHaute] de [s0] et celle de [s1] sont deux méthodes différentes, car elles n'ont pas la même cible ([Target]) : [-=] ne retire que celle du souscripteur concerné ;
  • ligne 100 : le gestionnaire de l'événement. Il a la signature imposée par le délégué [EventHandler<TemperatureTropHauteEventArgs>] : un [object?] et un [TemperatureTropHauteEventArgs]. Il est privé : seul le souscripteur peut l'enregistrer auprès d'un émetteur ;
  • ligne 102 : le paramètre [source] est de type [object?]. Le filtrage par motif vérifie qu'il s'agit bien d'un [Emetteur] et le récupère dans la variable [emetteur]. Le code de 2008 utilisait un transtypage [((Emetteur)source).Nom] ;
  • ligne 106 : le gestionnaire affiche le nom du souscripteur, le nom de l'émetteur qui a signalé l'événement et la température qui l'a déclenché ;
  • lignes 108-111 : après [nbAlertesMax] alertes, le souscripteur se désabonne de l'émetteur. Il le fait pendant la publication de l'événement, depuis son propre gestionnaire. C'est sans danger : nous avons vu qu'un délégué est immuable. [-=] crée un nouveau délégué et le range dans le champ de l'événement, mais la publication en cours continue de parcourir l'ancienne liste.

Voici enfin le programme principal :

// gestion d'événements : un émetteur et des souscripteurs

// création d'un émetteur d'événements
var e1 = new Emetteur("e");
// création de deux souscripteurs abonnés à l'événement TemperatureTropHaute de e1
Souscripteur[] souscripteurs = [new("s0", 3), new("s1", 1)];
foreach (Souscripteur souscripteur in souscripteurs)
{
    souscripteur.Abonner(e1);
}
// un troisième abonné : une lambda
e1.TemperatureTropHaute += (source, e) => Console.WriteLine($"Journal : {e.Temperature} °C à {e.Heure:HH:mm:ss}");

// on lit les températures au clavier
while (true)
{
    Console.Write("Température (rien pour arrêter) : ");
    string saisie = Console.ReadLine()?.Trim() ?? "";
    // fin de la saisie ?
    if (saisie == "")
    {
        break;
    }
    // la saisie est-elle un nombre décimal ?
    if (decimal.TryParse(saisie, out decimal temperature))
    {
        // température correcte - on l'enregistre dans l'émetteur
        e1.Temperature = temperature;
    }
    else
    {
        // on signale l'erreur
        Console.WriteLine("Température incorrecte");
    }
}

Résultats :

> dotnet run delegues-08.cs
Température (rien pour arrêter) : 17
Température (rien pour arrêter) : 21
Souscripteur [s0] : la source [e] a signalé une température trop haute : [21]
Souscripteur [s1] : la source [e] a signalé une température trop haute : [21]
Souscripteur [s1] : désabonné après 1 alerte(s)
Journal : 21 °C à 11:20:25
Température (rien pour arrêter) : abc
Température incorrecte
Température (rien pour arrêter) : 22,5
Souscripteur [s0] : la source [e] a signalé une température trop haute : [22,5]
Journal : 22,5 °C à 11:20:27
Température (rien pour arrêter) : 25
Souscripteur [s0] : la source [e] a signalé une température trop haute : [25]
Souscripteur [s0] : désabonné après 3 alerte(s)
Journal : 25 °C à 11:20:28
Température (rien pour arrêter) : 30
Journal : 30 °C à 11:20:28
Température (rien pour arrêter) : 

Commentaires :

  • ligne 4 : création de l'émetteur ;
  • lignes 6-9 : création de deux souscripteurs, [s0] (3 alertes au plus) et [s1] (1 alerte), qu'on abonne à l'événement [TemperatureTropHaute] de l'émetteur. L'expression de collection [new("s0", 3), new("s1", 1)] utilise l'opérateur [new] sans type (C# 9), le type étant connu par celui du tableau ;
  • ligne 12 : un troisième abonné, une lambda, qui joue le rôle d'un journal. Les types de [source] et [e] sont déduits du type de l'événement. Une lambda ne peut pas être désabonnée facilement : il faudrait l'avoir rangée dans une variable pour la retirer avec [-=], car deux lambdas écrites de la même façon sont deux délégués différents. On réserve donc les lambdas aux abonnements qui durent autant que l'émetteur ;
  • lignes 15-33 : boucle de saisie des températures au clavier. [Console.ReadLine()] rend [null] si l'entrée standard est fermée : [?.Trim() ?? ""] transforme ce cas en saisie vide ;
  • ligne 28 : si la température saisie est correcte, elle est transmise à l'émetteur [e1], qui déclenche l'événement [TemperatureTropHaute] si elle est supérieure à 19 °C.

Les résultats montrent :

  • ligne 2 : 17 °C ne dépasse pas le seuil : aucun événement ;
  • lignes 3-7 : 21 °C déclenche l'événement. Les trois abonnés sont avertis dans l'ordre de leur abonnement. [s1], qui n'accepte qu'une alerte, se désabonne aussitôt, ce qui n'empêche pas le journal, abonné après lui, d'être averti ;
  • lignes 10-12 : [s1] n'est plus averti ;
  • lignes 13-16 : [s0] reçoit sa troisième alerte et se désabonne à son tour ;
  • ligne 17 : il ne reste plus que le journal.

7.6. Les délégués et LINQ

Au chapitre 5, nous avons utilisé les méthodes de LINQ ([Where], [Select], [OrderBy]…) en leur passant des lambdas. Nous savons maintenant ce que sont ces paramètres : la méthode [Where] est déclarée ainsi dans la classe [System.Linq.Enumerable] :

public static IEnumerable<TSource> Where<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate)

Son paramètre [predicate] est un délégué [Func<TSource, bool>] : toute méthode, ou toute lambda, qui reçoit un élément et rend un booléen convient. Pour bien comprendre le fonctionnement de LINQ, écrivons nous-mêmes une méthode de filtrage. C'est le programme [delegues-09.cs] :

// les délégués et LINQ

int[] nombres = [3, 8, 1, 6, 5, 4, 9, 2];

// 1 - Where et Select attendent des délégués Func<int, bool> et Func<int, TResult>
Func<int, bool> estPair = n => n % 2 == 0;
var carresPairs = nombres.Where(estPair).Select(n => n * n);
Console.WriteLine($"LINQ : {string.Join(" ", carresPairs)}");

// 2 - notre propre méthode de filtrage, appelée avec une lambda puis un groupe de méthodes
Console.WriteLine($"Filtrer(n > 4) : {string.Join(" ", Outils.Filtrer(nombres, n => n > 4))}");
Console.WriteLine($"Filtrer(EstImpair) : {string.Join(" ", Outils.Filtrer(nombres, EstImpair))}");

// 3 - l'exécution est différée : le prédicat n'est appelé que pendant le parcours
IEnumerable<int> grands = Outils.Filtrer(nombres, n =>
{
    Console.Write($"[test {n}] ");
    return n > 7;
});
Console.WriteLine("la requête est définie, rien n'a encore été exécuté");
foreach (int n in grands)
{
    Console.Write($"-> {n} ");
}
Console.WriteLine();

// 4 - avec les membres d'extension, nos méthodes s'enchaînent comme celles de LINQ
string[] villes = ["Angers", "Paris", "Lyon", "Nantes", "Aix"];
var resultat = villes.Filtrer(v => v.Length <= 5).Transformer(v => v.ToUpper());
Console.WriteLine($"extension : {string.Join(" ", resultat)}");

static bool EstImpair(int n) => n % 2 != 0;

public static class Outils
{
    // filtre une séquence avec un prédicat : l'équivalent de Where
    public static IEnumerable<T> Filtrer<T>(IEnumerable<T> source, Func<T, bool> predicat)
    {
        foreach (T element in source)
        {
            if (predicat(element))
            {
                // l'élément est rendu à l'appelant ; le parcours reprendra ici
                yield return element;
            }
        }
    }
}

public static class Extensions
{
    // membres d'extension (C# 14) de IEnumerable<T>
    extension<T>(IEnumerable<T> source)
    {
        // Filtrer comme méthode d'extension : on réutilise Outils.Filtrer
        public IEnumerable<T> Filtrer(Func<T, bool> predicat) => Outils.Filtrer(source, predicat);

        // transforme chaque élément : l'équivalent de Select
        public IEnumerable<TResult> Transformer<TResult>(Func<T, TResult> transformation)
        {
            foreach (T element in source)
            {
                yield return transformation(element);
            }
        }
    }
}

Résultats :

1
2
3
4
5
6
7
> dotnet run delegues-09.cs
LINQ : 64 36 16 4
Filtrer(n > 4) : 8 6 5 9
Filtrer(EstImpair) : 3 1 5 9
la requête est définie, rien n'a encore été exécuté
[test 3] [test 8] -> 8 [test 1] [test 6] [test 5] [test 4] [test 9] -> 9 [test 2] 
extension : PARIS LYON AIX

Commentaires :

  • lignes 6-7 : la lambda de [Where] est d'abord rangée dans une variable de type [Func<int, bool>] pour faire apparaître son type. [Select] reçoit, lui, un délégué [Func<int, int>] qui transforme chaque élément : on obtient les carrés des nombres pairs ;
  • lignes 37-44 : notre méthode [Filtrer]. Elle est générique : elle filtre une séquence d'éléments de n'importe quel type [T], avec un prédicat de type [Func<T, bool>]. Elle parcourt la séquence et appelle le prédicat (ligne 41) sur chaque élément ;
  • ligne 44 : l'instruction [yield return] fait de [Filtrer] un itérateur : la méthode ne construit pas de liste, elle rend les éléments retenus un par un, à la demande de celui qui parcourt le résultat. Après chaque [yield return], l'exécution de [Filtrer] est suspendue ; elle reprend au même endroit quand l'élément suivant est demandé ;
  • ligne 11 : appel de [Filtrer] avec une lambda. Le type [T] est déduit du tableau : [int]. Ligne 12, on lui passe un groupe de méthodes ;
  • lignes 15-18 : un prédicat bavard, qui affiche chaque élément qu'il teste. Le résultat (lignes 5-6) montre que la création de la requête n'exécute rien : le message de la ligne 20 s'affiche avant le premier test. Ce n'est que pendant la boucle [foreach] que [Filtrer] s'exécute, et les tests alternent avec les affichages de la boucle. C'est l'exécution différée, la même que celle des requêtes LINQ ;
  • lignes 53-63 : un bloc de membres d'extension (C# 14, présentés au chapitre 4) ajoute au type [IEnumerable<T>] deux méthodes : [Filtrer], qui réutilise [Outils.Filtrer], et [Transformer], l'équivalent de [Select]. [Transformer] est doublement générique : [T] est le type des éléments de la séquence, [TResult] celui des éléments produits par la transformation ;
  • ligne 29 : grâce aux membres d'extension, nos méthodes s'appellent comme des méthodes de la séquence et s'enchaînent comme celles de LINQ : on garde les villes de 5 lettres au plus, puis on les met en majuscules.

Toute la bibliothèque LINQ est construite sur ce modèle : des méthodes d'extension de [IEnumerable<T>] qui reçoivent des délégués et rendent de nouvelles séquences, calculées à la demande par des itérateurs.

7.7. Les arbres d'expression

Une expression lambda peut être convertie en délégué, comme nous l'avons fait tout au long de ce chapitre. Elle peut aussi être convertie en arbre d'expression : au lieu de compiler la lambda en code exécutable, le compilateur génère des instructions qui construisent, à l'exécution, une structure de données décrivant la lambda. Il suffit pour cela d'utiliser le type [Expression<TDelegate>] de l'espace de noms [System.Linq.Expressions] au lieu du type délégué. Le programme [delegues-10.cs] compare les deux :

// les arbres d'expression
using System.Linq.Expressions;

// la même lambda, compilée en délégué ou conservée sous forme de données
Func<int, bool> fonction = n => n > 5 && n % 2 == 0;
Expression<Func<int, bool>> expression = n => n > 5 && n % 2 == 0;

// un délégué s'exécute, mais son code n'est pas lisible
Console.WriteLine($"délégué : {fonction}");
// un arbre d'expression se parcourt et s'affiche
Console.WriteLine($"expression : {expression}");
Console.WriteLine($"paramètre : {expression.Parameters[0].Name}, type rendu : {expression.ReturnType.Name}");
Console.WriteLine("arbre :");
Afficher(expression.Body, 1);

// un arbre d'expression peut être compilé en délégué puis exécuté
Func<int, bool> compilee = expression.Compile();
Console.WriteLine($"compilee(8)={compilee(8)}, compilee(7)={compilee(7)}");

// affichage récursif d'un nœud de l'arbre et de ses enfants
static void Afficher(Expression noeud, int niveau)
{
    string marge = new(' ', 2 * niveau);
    switch (noeud)
    {
        case BinaryExpression binaire:
            // opération à deux opérandes : &&, >, ==, %...
            Console.WriteLine($"{marge}{binaire.NodeType}");
            Afficher(binaire.Left, niveau + 1);
            Afficher(binaire.Right, niveau + 1);
            break;
        case ParameterExpression parametre:
            Console.WriteLine($"{marge}Parameter {parametre.Name}");
            break;
        case ConstantExpression constante:
            Console.WriteLine($"{marge}Constant {constante.Value}");
            break;
        default:
            Console.WriteLine($"{marge}{noeud.NodeType} {noeud}");
            break;
    }
}

Résultats :

> dotnet run delegues-10.cs
délégué : System.Func`2[System.Int32,System.Boolean]
expression : n => ((n > 5) AndAlso ((n % 2) == 0))
paramètre : n, type rendu : Boolean
arbre :
  AndAlso
    GreaterThan
      Parameter n
      Constant 5
    Equal
      Modulo
        Parameter n
        Constant 2
      Constant 0
compilee(8)=True, compilee(7)=False

Commentaires :

  • ligne 2 : l'espace de noms [System.Linq.Expressions] ne fait pas partie des [using] implicites ;
  • lignes 5-6 : la même lambda est affectée à un délégué [Func<int, bool>] puis à un arbre d'expression [Expression<Func<int, bool>>] ;
  • ligne 9 : un délégué ne sait afficher que son type : son code est du code compilé, illisible ;
  • ligne 11 : un arbre d'expression, lui, sait s'afficher. Le résultat (ligne 3) reproduit la lambda : [AndAlso] est le nom de l'opérateur [&&] ;
  • ligne 12 : on peut interroger l'arbre : ses paramètres, le type de son résultat, son corps ([Body]) ;
  • lignes 21-39 : une méthode récursive qui affiche l'arbre. Chaque nœud est un objet dérivé de la classe [Expression] : [BinaryExpression] pour une opération à deux opérandes ([&&], [>], [==], [%]), [ParameterExpression] pour un paramètre, [ConstantExpression] pour une constante. Le [switch] avec filtrage par motif sur le type traite chaque cas. Les résultats (lignes 6-14) montrent la structure de l'expression [n > 5 && n % 2 == 0] ;
  • ligne 17 : un arbre d'expression peut être compilé, à l'exécution, en délégué, puis exécuté.

À quoi cela sert-il ? Un délégué ne peut qu'être exécuté, dans le programme. Un arbre d'expression peut être analysé et traduit dans un autre langage. C'est ce que fait Entity Framework Core, que nous étudierons au chapitre 11. Quand on écrit une requête LINQ sur une table de base de données, par exemple [contexte.Tranches.Where(t => t.Limite > 20000)], la méthode [Where] utilisée n'est pas celle de [IEnumerable<T>] mais celle de [IQueryable<T>], dont le paramètre est un [Expression<Func<T, bool>>]. EF Core parcourt cet arbre, comme notre méthode [Afficher], et le traduit en une clause SQL [WHERE Limite > 20000] exécutée par le serveur de bases de données. La même lambda, écrite de la même façon, devient ainsi soit du code .NET, soit du SQL, selon le type du paramètre qui la reçoit.

7.8. Conclusion

Nous retiendrons de ce chapitre :

  • un délégué est un objet qui référence une ou plusieurs méthodes d'une signature donnée. Les types prédéfinis [Func<>], [Action<>] et [Predicate<T>] dispensent le plus souvent de définir ses propres types délégués ;
  • les expressions lambda permettent d'écrire la méthode à l'endroit même où l'on crée le délégué. Elles capturent les variables qui les entourent (attention aux boucles [for]) ;
  • un délégué peut remplacer une interface à une seule méthode, avec plus de souplesse ;
  • un événement est un délégué protégé par le mot clé [event] : on s'y abonne par [+=], on s'en désabonne par [-=], et seule la classe qui le déclare le déclenche, par [Evt?.Invoke(this, e)] ;
  • LINQ est construit sur des méthodes qui reçoivent des délégués, et EF Core sur des arbres d'expression.

Les délégués reviendront dans les chapitres suivants : une tâche ([Task.Run]) exécute un délégué dans un autre thread (chapitres 8 et 9), et les services web ASP.NET Core associent des lambdas aux adresses HTTP (chapitre 13).