1. Introduction
Quand on écrit
worker.WorkCompleted += listener.OnWorkCompleted;
on ajoute en réalité un pointeur vers une méthode dans la "invocation list" (multicast delegate) de l'événement. Cette "liste" à l'intérieur de l'événement est simplement une séquence de méthodes qui seront appelées quand l'événement est levé. En C# l'événement est implémenté au-dessus d'un delegate qui supporte plusieurs abonnés.
Imaginez une newsletter : vous avez une liste d'abonnés (adresses email). Quand vous envoyez la newsletter (vous déclenchez l'événement), tous les abonnés reçoivent le message. Si quelqu'un se désabonne, il est retiré de la liste et ne reçoit plus rien.
Comment ajouter ou retirer un abonné
S'abonner (+=) et se désabonner (-=) opèrent sur le delegate interne de l'événement. Voici un exemple avec une lambda qu'on peut à la fois ajouter et retirer :
EventHandler<WorkCompletedEventArgs> handler = (sender, e) =>
{
Console.WriteLine($"[Lambda] Travail terminé : {e.Message}");
};
worker.WorkCompleted += handler; // On s'abonne
worker.WorkCompleted -= handler; // On se désabonne
Pour des méthodes normales, la désinscription ressemble à la même chose :
worker.WorkCompleted += listener.OnWorkCompleted;
worker.WorkCompleted -= listener.OnWorkCompleted;
Notez : si vous avez abonné la même méthode plusieurs fois, elle sera appelée autant de fois, et il faudra appeler -= autant de fois pour la retirer complètement.
2. Pourquoi gérer ça manuellement ?
Pourquoi c'est important de gérer les abonnés ?
Dans des applications réelles, surtout long-lived (par exemple desktop ou serveurs), une mauvaise gestion des abonnements peut mener à des fuites mémoire. Si un objet abonné n'est plus nécessaire mais reste "accroché" dans la liste d'abonnés de l'événement, il ne sera pas collecté par le garbage collector — parce qu'il y a encore une référence depuis le delegate de l'événement.
Illustration
| Action | Résultat pour l'abonné |
|---|---|
| += (abonné) | Ajouté à la liste |
| -= (désabonné) | Retiré de la liste |
| Objet abonné supprimé | Si NON désabonné ! — PAS supprimé, car il y a encore une référence depuis l'événement |
| Objet abonné supprimé | Si DÉSA Bonné — sera libéré normalement |
Comment savoir qui est abonné à un événement ?
Les événements encapsulent la liste d'abonnés, donc depuis l'extérieur de la classe publishante vous ne pouvez pas obtenir directement cette liste — vous pouvez seulement ajouter (+=) ou retirer (-=) des handlers.
Cependant, à l'intérieur de la classe où l'événement est déclaré sur la base d'un delegate (par exemple EventHandler), on peut obtenir la liste actuelle des abonnés via la méthode GetInvocationList() :
// À l'intérieur de la classe publishante
if (WorkCompleted != null)
{
foreach (Delegate subscriber in WorkCompleted.GetInvocationList())
{
Console.WriteLine($"Handler : {subscriber.Method.Name}, Objet : {subscriber.Target}");
}
}
Ce genre d'astuce est rarement nécessaire pour le développement quotidien, mais peut être utile pour le debugging ou pour implémenter un mécanisme de désabonnement global.
3. Appeler un événement en toute sécurité : "mines" et comment les contourner
Qu'est-ce qui peut mal tourner quand on appelle un événement ?
Tout semble simple : vous appelez
WorkCompleted?.Invoke(this, args);
et tout marche... la plupart du temps ! Mais il y a des subtilités. Les voici :
1. Danger multithread
Dans une application multithread il est possible qu'entre le check de l'événement sur null et l'appel aux handlers un autre thread modifie les abonnements. Par exemple :
1) Thread A vérifie : WorkCompleted != null.
2) En même temps, le thread B se désabonne de l'événement (-=), et la liste devient vide.
3) Thread A tente d'appeler WorkCompleted.Invoke(...) — on obtient un NullReferenceException, parce qu'il n'y a plus de handlers.
C'est une race condition classique lors du travail avec des événements.
2. Exceptions inattendues dans les handlers
Si un des abonnés lance une exception pendant le traitement de l'événement, l'appel aux autres handlers est interrompu. Autrement dit l'événement "tombe" sur la première exception, et les autres abonnés ne sont pas notifiés. Pour éviter ça, on recommande d'envelopper l'appel de chaque handler dans un try-catch si c'est important que tous reçoivent le signal.
3. Fuite de contexte indésirable
Un handler d'événement est souvent une méthode d'instance qui capture la référence à l'objet abonné (this). Si l'abonné oublie de se désabonner du publisher, la référence à lui reste dans la liste de delegates du publisher. En conséquence le garbage collector ne pourra pas libérer cet objet — fuite mémoire.
Comment appeler l'événement en toute sécurité ?
1) Copier le delegate dans une variable locale
Appeler via une variable locale garantit que pendant l'appel la liste des abonnés ne changera pas :
// La vieille méthode fiable
var handler = WorkCompleted;
if (handler != null)
{
handler(this, args);
}
Ou plus moderne, avec l'opérateur null-conditional :
WorkCompleted?.Invoke(this, args);
Dans la plupart des cas c'est suffisant, car le compilateur C# "comprend" cette construction et effectue une copie interne de la référence (voir la documentation officielle).
2) Protection contre les exceptions des handlers
Si c'est crucial que tous les handlers soient appelés (même si l'un d'eux plante), utilisez une itération manuelle :
var handler = WorkCompleted;
if (handler != null)
{
foreach (EventHandler<WorkCompletedEventArgs> subscriber in handler.GetInvocationList())
{
try
{
subscriber(this, args);
}
catch (Exception ex)
{
// On logge, mais on n'autorise pas l'événement à "s'effondrer"
Console.WriteLine($"Erreur dans le handler : {ex.Message}");
}
}
}
Ce pattern est rarement nécessaire pour des scénarios UI simples, mais pertinent pour des librairies, des loggers et des systèmes complexes.
3) Prévenir les fuites mémoire
Si l'abonné vit moins longtemps que le publisher (par exemple une fenêtre qui s'abonne à un événement d'application), il doit se désabonner :
worker.WorkCompleted -= listener.OnWorkCompleted;
Sinon le garbage collector ne pourra pas libérer listener, même s'il n'y a plus de références "explicites" vers lui.
4. Exemple pratique : manager d'abonnements/désabonnements en masse
Étendons notre application pédagogique. Imaginons qu'on a plusieurs listeners — et qu'on veut les abonner et les désabonner dynamiquement pendant l'exécution.
public class WorkListener
{
private readonly string _name;
public WorkListener(string name)
{
_name = name;
}
public void OnWorkCompleted(object sender, WorkCompletedEventArgs e)
{
Console.WriteLine($"Listener {_name} : {e.Message}");
}
}
Dans le programme principal :
var worker = new Worker();
var listeners = new List<WorkListener>
{
new WorkListener("Ivan"),
new WorkListener("Maria"),
new WorkListener("Denis")
};
// On abonne tous les listeners
foreach (var listener in listeners)
worker.WorkCompleted += listener.OnWorkCompleted;
// On déclenche l'événement
worker.DoWork();
// Désabonnement en masse
foreach (var listener in listeners)
worker.WorkCompleted -= listener.OnWorkCompleted;
// On vérifie qu'il n'y a plus de réactions
worker.DoWork();
Dans la console après la première exécution il y aura 3 messages, après la seconde — aucun.
5. Conseils pour travailler en sécurité avec les événements
- Désabonnez-vous à temps si le cycle de vie de l'abonné est plus court que celui du publisher.
- Si vous implémentez le pattern "publisher long-lived — subscriber temporaire", assurez-vous toujours de vous désabonner, par exemple dans Dispose(), à la fermeture de la fenêtre ou à la fin explicite de la vie de l'objet.
- Pour des événements one-shot vous pouvez utiliser un handler anonyme (lambda) qui se désabonne lui-même à l'intérieur :
EventHandler<WorkCompletedEventArgs> handler = null;
handler = (s, e) =>
{
Console.WriteLine("Événement traité une seule fois !");
worker.WorkCompleted -= handler;
};
worker.WorkCompleted += handler;
- N'enregistrez pas de références aux abonnés ou handlers juste pour vérifier "qui est abonné" — ce n'est pas nécessaire dans la logique métier normale. Faites-le uniquement pour le debugging.
6. Erreurs fréquentes et comment les éviter
Erreur n°1 : oubli de se désabonner — fuite mémoire.
Si l'abonné ne se désabonne pas, surtout dans de grosses applis avec beaucoup d'événements et d'abonnés, des objets peuvent rester en mémoire plus longtemps que nécessaire. Cette erreur se manifeste rarement immédiatement, mais conduit à une augmentation de la consommation mémoire et à une dégradation des performances.
Erreur n°2 : appel d'un événement sans vérifier le null.
Si l'événement n'a pas d'abonnés et qu'on tente de l'appeler directement, on obtiendra un NullReferenceException. Dans les versions récentes de C# l'opérateur null-safe ?. aide, mais si vous travaillez avec du code ancien ou que vous itérez manuellement les handlers, n'oubliez pas de vérifier le null.
Erreur n°3 : une exception dans un handler interrompt l'appel des autres.
Si un handler lance une exception, les handlers suivants ne seront pas appelés. Si c'est important que tous les abonnés reçoivent la notification, parcourez les handlers en boucle et enveloppez chaque appel dans un bloc try/catch.
GO TO FULL VERSION