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 :
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 :
Pour créer un objet de type [EventHandler] qui référence cette méthode, on peut écrire :
ou, plus simplement :
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 :
Résultats :
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 :
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] :
Résultats :
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 :
Résultats :
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 :
Résultats :
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 :
Résultats :
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] :
Résultats :
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] :
Résultats :
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] :
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] :
- la classe [C1] définit un champ de ce type délégué :
- 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 :
- [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 :
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 :
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] :
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] :
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 :
Résultats :
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] :
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] :
Résultats :
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 :
Résultats :
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).