Menu

Événements en C# : mot-clé event, EventHandler, abonnement et déclenchement

Comment fonctionnent les événements en C# : déclarer un événement avec EventHandler et EventHandler<T>, les classes EventArgs personnalisées, s'abonner et se désabonner avec += et -=, déclencher un événement sans risque avec ?.Invoke, pourquoi un événement vaut mieux qu'un champ délégué public, et la fuite mémoire causée par un désabonnement oublié.

Cette page contient des éditeurs exécutables - modifiez, exécutez et voyez la sortie instantanément.

Un événement permet à une classe d'annoncer que quelque chose s'est produit sans savoir qui écoute. D'autres objets s'abonnent avec +=, et quand la classe déclenche l'événement, la méthode de chaque abonné s'exécute.

Sortie :

Order A-17 saved
  receipt emailed for A-17
Order A-18 saved
  receipt emailed for A-18
  free gift added to A-18

Shop ne sait pas que les reçus ou les cadeaux existent. Il déclenche OrderPlaced, et tout ce qui s'est abonné réagit. Ajouter une nouvelle réaction (points de fidélité, statistiques) revient à ajouter un abonné, pas à modifier PlaceOrder.

Déclarer un événement

Une déclaration d'événement est un membre de type délégué doté du mot-clé event :

public event EventHandler<OrderPlacedEventArgs> OrderPlaced;

Le type délégué fixe la signature des gestionnaires. Le code .NET utilise presque toujours l'un de deux types intégrés, et suivre cette convention rend vos événements familiers à tous les autres développeurs .NET :

  • EventHandler : void (object sender, EventArgs e), pour les événements qui ne transportent pas de données. Déclenchez-le avec EventArgs.Empty.
  • EventHandler<TEventArgs> : void (object sender, TEventArgs e), où TEventArgs est votre classe qui transporte les données.

Les conventions qui les accompagnent :

  • sender est l'objet qui a déclenché l'événement, en général this.
  • La classe de données s'appelle {Something}EventArgs, dérive de EventArgs (facultatif depuis .NET 4.5, mais toujours d'usage) et expose des propriétés en lecture seule.
  • L'événement porte le nom de ce qui s'est produit : OrderPlaced, Closed, PriceChanged. Un nom en ...ing (Closing) sert aux événements déclenchés avant l'action, souvent avec un moyen de l'annuler.
  • Dans les classes destinées à être héritées, le déclenchement passe par une méthode protected virtual void OnOrderPlaced(OrderPlacedEventArgs e), pour que les classes dérivées puissent s'y greffer.

S'abonner et se désabonner

+= ajoute un gestionnaire et -= le retire. Un gestionnaire peut être une méthode ou une lambda. Pour retirer une lambda plus tard, gardez-la dans une variable, car réécrire la même lambda crée un délégué différent que -= ne trouvera pas :

Sortie :

set to 32
  display shows 32 C
  alarm: too hot
set to 35
  display shows 35 C
set to 20
  display shows 20 C

Retirer un gestionnaire qui n'a jamais été ajouté n'est pas une erreur ; cela ne fait rien. EventHandler<int> montre que l'argument de type n'a pas besoin de dériver de EventArgs sur le .NET moderne, même si une classe dédiée laisse la place d'ajouter des champs plus tard sans casser les abonnés.

Les gestionnaires s'exécutent de façon synchrone, dans l'ordre d'abonnement, sur le thread qui a déclenché l'événement. Si un gestionnaire lève une exception, les gestionnaires restants ne s'exécutent pas et l'exception remonte au code qui a déclenché l'événement.

Déclencher un événement sans risque

Un événement sans abonnés contient null. L'appeler directement lèverait NullReferenceException, donc déclenchez-le avec l'opérateur conditionnel null :

OrderPlaced?.Invoke(this, args);

Avant C# 6, le schéma consistait à copier le champ dans une variable locale, à la tester, puis à invoquer la copie. La copie compte dans le code multithread : tester OrderPlaced != null puis appeler OrderPlaced(...) lit le champ deux fois, et un autre thread pourrait retirer le dernier gestionnaire entre les deux. ?. le lit une seule fois, c'est donc à la fois plus court et correct.

Pourquoi event et pas un champ délégué public

Sans le mot-clé event, un champ délégué public fonctionne pour s'abonner, mais il donne à chaque abonné un contrôle total :

Sortie :

analytics ping
analytics ping

Un seul = tapé à la place de += a silencieusement retiré les deux gestionnaires précédents, et du code extérieur a pu déclencher le « clic » sans aucun clic. Marquer le champ event transforme ces deux lignes en erreurs de compilation :

error CS0070: The event 'Button.Clicked' can only appear on the left hand side of += or -= (except when used from within the type 'Button')

À l'intérieur de Button, l'événement se comporte toujours comme un champ délégué normal, donc la classe peut l'invoquer et le tester contre null.

La fuite du désabonnement oublié

Quand vous vous abonnez, l'événement stocke un délégué, et le délégué garde une référence vers l'objet abonné. Tant que l'émetteur est en vie et que le gestionnaire est attaché, l'abonné ne peut pas être récupéré par le ramasse-miettes, et il continue de recevoir les événements après que vous en avez fini avec lui :

Sortie :

  closed widget shows 101.5
  open widget shows 101.5
subscribers left: 1
  closed widget shows 99.0

Mettre leaky à null n'a rien changé pour le flux : sa liste de gestionnaires référence toujours ce widget, donc le widget « fermé » continue de se mettre à jour et reste en mémoire. Le widget du bloc using s'est désabonné dans Dispose et a cessé de recevoir les événements. C'est l'une des fuites mémoire les plus courantes dans le code .NET de bureau et serveur, surtout avec les événements statiques et les services à l'échelle de l'application, qui vivent jusqu'à la fin du processus.

La règle : quiconque s'abonne à un événement d'un objet qui lui survit doit se désabonner, en général dans Dispose (voir l'instruction using). Quand l'émetteur et l'abonné ont la même durée de vie, comme un formulaire et ses propres boutons, aucun désabonnement n'est nécessaire.

Accesseurs add et remove personnalisés

Un événement peut définir ce que font += et -=, comme le get et le set d'une propriété. C'est rare, mais c'est ainsi que les frameworks stockent les gestionnaires dans un dictionnaire partagé ou les transmettent à un autre objet :

private EventHandler closed;

public event EventHandler Closed
{
    add    { Console.WriteLine("subscriber added");   closed += value; }
    remove { Console.WriteLine("subscriber removed"); closed -= value; }
}

Avec des accesseurs personnalisés, la classe déclenche l'événement via le champ de stockage (closed?.Invoke(this, EventArgs.Empty)), puisque l'événement lui-même n'a plus de stockage.

Questions fréquentes

Qu'est-ce qu'un événement en C# ?

Un événement est un membre de classe qui permet à d'autres objets de demander à être notifiés quand quelque chose se produit. Il repose sur un délégué multicast, mais le code extérieur ne peut qu'ajouter des gestionnaires (+=) et les retirer (-=). Seule la classe qui déclare l'événement peut le déclencher.

Quelle est la différence entre un événement et un délégué en C# ?

Un délégué est un type qui référence des méthodes. Un événement est un membre déclaré avec un type délégué plus le mot-clé event, qui restreint l'accès : depuis l'extérieur de la classe, vous ne pouvez ni l'invoquer, ni lire sa liste de gestionnaires, ni l'affecter avec =, seulement vous abonner et vous désabonner. Un champ délégué public permet tout cela, donc n'importe quel abonné pourrait effacer les autres ou déclencher l'événement.

Comment transmettre des données avec un événement C# ?

Dérivez une classe de EventArgs avec les données en propriétés en lecture seule (public class OrderPlacedEventArgs : EventArgs { public decimal Total { get; } }) et déclarez l'événement comme EventHandler<OrderPlacedEventArgs>. Déclenchez-le avec OrderPlaced?.Invoke(this, new OrderPlacedEventArgs(total));.

Comment déclencher un événement sans risque en C# ?

Utilisez MyEvent?.Invoke(this, args);. Un événement sans abonnés vaut null, donc l'appeler directement lève NullReferenceException. L'opérateur ?. lit le champ une seule fois, ce qui évite aussi une situation de concurrence où un autre thread retire le dernier gestionnaire entre un test de null et l'appel.

Les événements C# peuvent-ils causer des fuites mémoire ?

Oui. S'abonner stocke un délégué qui référence l'abonné, donc l'émetteur garde l'abonné en vie aussi longtemps que l'émetteur vit. Un émetteur à longue durée de vie (un événement statique, un service à l'échelle de l'application) avec des abonnés éphémères qui ne se désabonnent jamais les garde tous en mémoire. Désabonnez-vous avec -= quand l'abonné a terminé, en général dans Dispose.

Coddy programming languages illustration

Apprendre à coder avec Coddy

COMMENCER