web-dev-qa-db-fra.com

Comment désinscrire correctement un gestionnaire d'événements

Dans une revue de code, je suis tombé sur ce fragment de code (simplifié) pour désenregistrer un gestionnaire d'événements:

 Fire -= new MyDelegate(OnFire);

J'ai pensé que cela ne désenregistre pas le gestionnaire d'événements car cela crée un nouveau délégué qui n'avait jamais été enregistré auparavant. Mais en recherchant MSDN, j'ai trouvé plusieurs exemples de code qui utilisent cet idiome.

J'ai donc commencé une expérience:

internal class Program
{
    public delegate void MyDelegate(string msg);
    public static event MyDelegate Fire;

    private static void Main(string[] args)
    {
        Fire += new MyDelegate(OnFire);
        Fire += new MyDelegate(OnFire);
        Fire("Hello 1");
        Fire -= new MyDelegate(OnFire);
        Fire("Hello 2");
        Fire -= new MyDelegate(OnFire);
        Fire("Hello 3");
    }

    private static void OnFire(string msg)
    {
        Console.WriteLine("OnFire: {0}", msg);
    }

}

À ma grande surprise, les événements suivants se sont produits:

  1. Fire("Hello 1"); a produit deux messages, comme prévu.
  2. Fire("Hello 2"); a produit un message!
    Cela m'a convaincu que l'annulation de l'inscription des délégués new fonctionne!
  3. Fire("Hello 3"); a lancé un NullReferenceException.
    Le débogage du code a montré que Fire est null après avoir désenregistré l'événement.

Je sais que pour les gestionnaires d'événements et les délégués, le compilateur génère beaucoup de code derrière la scène. Mais je ne comprends toujours pas pourquoi mon raisonnement est faux.

Qu'est-ce que je rate?

Question supplémentaire: du fait que Fire est null quand aucun événement n'est enregistré, je conclus que partout où un événement est déclenché, une vérification par rapport à null est requise.

64
gyrolf

L'implémentation par défaut du compilateur C # consistant à ajouter un gestionnaire d'événements appelle Delegate.Combine, lors de la suppression d'un gestionnaire d'événements appelle Delegate.Remove:

Fire = (MyDelegate) Delegate.Remove(Fire, new MyDelegate(Program.OnFire));

La mise en œuvre du cadre de Delegate.Remove ne regarde pas l'objet MyDelegate lui-même, mais la méthode à laquelle le délégué fait référence (Program.OnFire). Ainsi, il est parfaitement sûr de créer un nouvel objet MyDelegate lors de la désinscription d'un gestionnaire d'événements existant. Pour cette raison, le compilateur C # vous permet d'utiliser une syntaxe abrégée (qui génère exactement le même code en arrière-plan) lors de l'ajout/suppression de gestionnaires d'événements: vous pouvez omettre le new MyDelegate partie:

Fire += OnFire;
Fire -= OnFire;

Lorsque le dernier délégué est supprimé du gestionnaire d'événements, Delegate.Remove renvoie null. Comme vous l'avez découvert, il est essentiel de vérifier l'événement contre null avant de le déclencher:

MyDelegate handler = Fire;
if (handler != null)
    handler("Hello 3");

Il est affecté à une variable locale temporaire pour se défendre contre une éventuelle condition de concurrence critique avec la désinscription des gestionnaires d'événements sur d'autres threads. (Voir mon article de blog pour plus de détails sur la sécurité des threads d'affecter le gestionnaire d'événements à une variable locale.) Une autre façon de se défendre contre ce problème est de créer un délégué vide qui est toujours abonné; bien que cela utilise un peu plus de mémoire, le gestionnaire d'événements ne peut jamais être nul (et le code peut être plus simple):

public static event MyDelegate Fire = delegate { };
82
Bradley Grainger

Vous devez toujours vérifier si un délégué n'a pas de cible (sa valeur est nulle) avant de le tirer. Comme indiqué précédemment, une façon de procéder consiste à s'abonner avec une méthode anonyme de ne rien faire qui ne sera pas supprimée.

public event MyDelegate Fire = delegate {};

Cependant, ce n'est qu'un hack pour éviter les NullReferenceExceptions.

Le simple fait de vérifier si un délégué est nul avant d'invoquer n'est pas sûr pour les threads, car un autre thread peut se désinscrire après la vérification de null et le rendre nul lors de l'appel. Il existe une autre solution consiste à copier le délégué dans une variable temporaire:

public event MyDelegate Fire;
public void FireEvent(string msg)
{
    MyDelegate temp = Fire;
    if (temp != null)
        temp(msg);
}

Malheureusement, le compilateur JIT peut optimiser le code, éliminer la variable temporaire et utiliser le délégué d'origine. (selon Juval Lowy - Programmation des composants .NET)

Donc, pour éviter ce problème, vous pouvez utiliser une méthode qui accepte un délégué comme paramètre:

[MethodImpl(MethodImplOptions.NoInlining)]
public void FireEvent(MyDelegate fire, string msg)
{
    if (fire != null)
        fire(msg);
}

Notez que sans l'attribut MethodImpl (NoInlining), le compilateur JIT pourrait aligner la méthode, la rendant sans valeur. Étant donné que les délégués sont immuables, cette implémentation est threadsafe. Vous pouvez utiliser cette méthode comme:

FireEvent(Fire,"Hello 3");
15
user37325