1. Introduction
D'une certaine façon, s'abonner à un événement en C# — c'est comme s'abonner à la newsletter de mèmes d'un ami : les nouvelles arrivent tant que vous n'avez pas dit "assez" et que vous ne vous êtes pas désabonné. En programmation c'est particulièrement important, parce qu'un abonnement oublié ce n'est pas juste "encore un mème", c'est une fuite de mémoire !
Imaginez que vous avez un formulaire dans l'application (par exemple, une fenêtre de paramètres). Il s'abonne à un événement de la fenêtre principale pour réagir aux changements. L'utilisateur ferme le formulaire en pensant qu'il est détruit, mais le handler est toujours abonné ! Le formulaire reste en mémoire parce que l'objet principal garde une référence vers lui via l'événement.
Conclusion : Si l'objet-abonné s'est abonné à l'événement de l'éditeur et "a oublié" de se désabonner, le garbage collector ne le supprimera pas tant que l'éditeur est vivant.
Rappel des opérateurs += et présentation de -=
- += — abonnement : ajoute le handler à la liste d'invocation de l'événement.
- -= — désabonnement : supprime le handler de la liste d'invocation.
Ça ressemble à ça :
worker.WorkCompleted += handler; // abonnement
worker.WorkCompleted -= handler; // désabonnement
Si un handler a été ajouté deux fois, il faut le supprimer autant de fois pour qu'il disparaisse vraiment de la liste d'invocation.
Un peu d'intimité interne
En coulisses, un événement en C# est un champ-delegate (ou une liste de delegates), et l'opérateur += appelle en fait Delegate.Combine, tandis que -= appelle Delegate.Remove. L'objet qui s'est abonné à l'événement devient une partie du graphe de références. Voilà pourquoi un abonnement oublié = fuite de mémoire.
2. Fuites de mémoire via les événements : comment ça fonctionne
Situation classique
class Window
{
public event EventHandler Updated;
public void SimulateUpdate()
{
// Simulation : on notifie tous les abonnés
Updated?.Invoke(this, EventArgs.Empty);
}
}
class SettingsForm
{
public void OnWindowUpdated(object sender, EventArgs e)
{
Console.WriteLine("SettingsForm réagit à la mise à jour de la fenêtre");
}
}
Allons-y pas à pas :
var window = new Window();
var settingsForm = new SettingsForm();
window.Updated += settingsForm.OnWindowUpdated;
window.SimulateUpdate(); // SettingsForm réagit
// L'utilisateur a fermé le formulaire. On perd toutes les références vers lui :
settingsForm = null;
// Mais l'objet SettingsForm NE sera PAS supprimé par le garbage collector tant que window est vivant,
// parce que window.Updated garde encore une référence à la méthode OnWindowUpdated,
// et donc aussi à l'objet SettingsForm.
Que faire ?
Se désabonner :
// Pour cela il faut que l'on ait encore une référence au handler ou à l'objet :
window.Updated -= settingsForm.OnWindowUpdated;
settingsForm = null; // Maintenant l'objet pourra être collecté
Tableau : qui garde une référence vers qui
| Action | Qui garde la référence | Peut-on libérer la mémoire ? |
|---|---|---|
| Abonnement à un événement (+=) | Éditeur vers abonné | Non, tant que l'éditeur est vivant |
| Désabonnement (-=) | Non | Oui, après suppression de toutes les références externes |
| Pas d'abonnement | Non | Oui |
3. Comment organiser correctement le désabonnement
Suppression explicite du handler
On peut le faire, par exemple, au moment de la fermeture de la fenêtre ou du formulaire :
class SettingsForm
{
private readonly Window _window;
public SettingsForm(Window window)
{
_window = window;
_window.Updated += OnWindowUpdated;
}
public void Close()
{
_window.Updated -= OnWindowUpdated; // on se désabonne !
// ici le code de fermeture (par exemple, Dispose, GC.SuppressFinalize, etc.)
}
public void OnWindowUpdated(object sender, EventArgs e)
{
// Traitement des événements
}
}
Si SettingsForm est détruit via le bouton "fermer", il est important de ne pas oublier d'appeler la méthode où se fait le désabonnement (par exemple Close()).
Utilisation de l'interface IDisposable
Pour des objets complexes qui s'abonnent à des événements et contrôlent leur cycle de vie, il est pratique d'implémenter l'interface IDisposable. Dans la méthode Dispose() on effectue tous les désabonnements nécessaires.
class SettingsForm : IDisposable
{
private readonly Window _window;
public SettingsForm(Window window)
{
_window = window;
_window.Updated += OnWindowUpdated;
}
public void OnWindowUpdated(object sender, EventArgs e)
{
// ...
}
public void Dispose()
{
_window.Updated -= OnWindowUpdated;
// Ici on libère aussi d'autres ressources
}
}
Maintenant SettingsForm peut être utilisé dans un bloc using, on peut appeler explicitement Dispose() ou automatiser la libération des ressources (par exemple via GC.SuppressFinalize pour les types finalisables).
4. Interaction avec les expressions lambda : dangers et astuces
Si vous vous abonnez à un événement avec une expression lambda mais que vous ne conservez pas la lambda dans une variable, vous ne pourrez pas vous désabonner !
// Abonnement — lambda anonyme
window.Updated += (s, e) => Console.WriteLine("La lambda est appelée !");
// Comment se désabonner maintenant ? — Impossible !
window.Updated -= (s, e) => Console.WriteLine("La lambda est appelée !"); // C'est un autre delegate !
Que faire ?
Conservez la lambda dans une variable-delegate :
EventHandler handler = (s, e) => Console.WriteLine("La lambda est appelée !");
window.Updated += handler;
// ... maintenant on peut se désabonner !
window.Updated -= handler;
5. Nuances utiles
Particularités du cycle de vie des objets et des événements
Un autre problème fréquent — des références croisées via des événements entre deux objets "longue durée". Par exemple, une fenêtre est abonnée à l'événement d'une autre, les deux sont activement utilisées, ne sont pas supprimées — et la mémoire augmente.
Recommandation : Essayez toujours de vous rappeler qui s'abonne à qui et quand il faut se désabonner. Si l'abonnement a le même cycle de vie que l'éditeur — ok. Si l'abonné peut vivre moins longtemps que l'éditeur, implémentez un désabonnement explicite.
Règle universelle : "Si tu t'es abonné — désabonne-toi !"
- Pour les éditeurs longue durée (par exemple globaux, singletons, fenêtres principales) — implémentez toujours le désabonnement côté abonnés.
- Pour les objets temporaires (par exemple notifications one-shot ou événements où l'abonné vit plus longtemps que l'éditeur) — on peut être plus relax, mais surveillez toujours le contexte.
- Utilisez des approches comme WeakEvent (événements faibles) ou des frameworks spéciaux si vous ne voulez pas gérer le désabonnement manuellement.
6. Erreurs typiques en travaillant avec le désabonnement
Désabonnement raté : le method-handler doit être le même
Il est très important que lors du désabonnement vous fournissiez exactement le même handler que lors de l'abonnement. Sinon le désabonnement ne fonctionnera pas.
Erreur :
window.Updated += settingsForm.OnWindowUpdated;
// ...
window.Updated -= new SettingsForm().OnWindowUpdated; // Ne fonctionnera pas ! C'est une autre instance et un autre delegate !
Correct :
window.Updated -= settingsForm.OnWindowUpdated;
Si, lors de l'abonnement, on utilise une lambda anonyme sans conserver la référence au delegate, se désabonner est impossible parce que ce sera une autre instance de delegate :
// Abonnement
window.Updated += (s, e) => Console.WriteLine("Lambda !");
// Tentative de désabonnement — ne fonctionnera pas !
window.Updated -= (s, e) => Console.WriteLine("Lambda !");
Désabonnement "oublié"
Très souvent le désabonnement est simplement oublié, surtout quand l'abonné vit plus longtemps que l'éditeur ou quand le développeur ne comprend pas complètement le mécanisme des événements. En conséquence, les objets-abonnés restent en mémoire plus longtemps que nécessaire, ce qui cause des fuites de mémoire et des problèmes de performance.
GO TO FULL VERSION