Délégués et évènements

Thomas Levesque (traduction)

Traducteur

tomlev

Beaucoup de gens ont du mal à voir la différence entre les évènements et les délégués. Il faut dire que C# n'aide pas à comprendre, en nous permettant de déclarer des évènements de type champ qui sont automatiquement supportés par un champ délégué du même nom. Le but de cet article est de clarifier les choses pour vous. Une autre source de confusion est l'ambiguïté du terme « délégué ». Parfois il est utilisé pour parler d'un type délégué, et parfois pour parler d'une instance d'un type délégué. J'utiliserai « type délégué » et « instance de délégué » pour les distinguer, et « délégué » pour parler de ce sujet dans un sens général.

Commentez cet article : Commentez ♪

- Titre : Délégués et évènements

- Auteur : Thomas Levesque (traduction)

- Traducteur : tomlev

- Parution : 18 avril 2012

- Licence : Copyright ® 2012 Jon Skeet. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- Traduction

- Types délégués

- Instances de délégué : les bases

- Créer des instances de délégué

- Appeler des instances de délégué

- Combiner des délégués

- Évènements

- Un raccourci : les évènements de type champ

- À quoi ça sert ?

- Évènements thread-safe

- Instances de délégué : autres méthodes

- Conclusion

- Remerciements

Traduction

Ceci est la traduction la plus fidèle possible de l'article de Jon Skeet, Delegates and Events.

Types délégués

D'une certaine manière, vous pouvez voir un type délégué un peu comme une interface avec une seule méthode. Il spécifie la signature de la méthode, et quand vous avez une instance de délégué, vous pouvez l'appeler comme si c'était une méthode avec la même signature. Les délégués proposent d'autres fonctionnalités, mais la possibilité de faire des appels avec une signature particulière est la raison de l'existence du concept de délégué. Les délégués contiennent une référence à une méthode, et (pour les méthodes d'instance) une référence vers l'objet cible sur lequel la méthode doit être appelée.

Les types délégués sont déclarés à l'aide du mot-clé delegate. Ils peuvent apparaître comme des types autonomes, ou imbriqués dans une classe, comme illustré ci-dessous :

namespace DelegateArticle
{
public delegate string FirstDelegate (int x);

public class Sample
{
public delegate void SecondDelegate (char a, char b);
}
}

Ce code déclare deux types délégués. Le premier est DelegateArticle.FirstDelegate, qui a un seul paramètre de type int et renvoie une string. Le second est DelegateArticle.Sample.SecondDelegate, qui prend deux paramètres de type char, et ne renvoie rien (parce que son type de retour est spécifié comme void).

Notez que l'utilisation du mot-clé delegate ne correspond pas toujours à la déclaration d'un type délégué. Le même mot-clé est utilisé pour créer une instance d'un type délégué en utilisant une méthode anonyme.

Les types déclarés ici héritent de System.MulticastDelegate, qui hérite à son tour de System.Delegate. En pratique, vous verrez seulement des types délégués qui héritent de MulticastDelegate. La différence entre Delegate et MulticastDelegate est en grande partie historique ; dans les bêta de .NET 1.0, la différence était significative (et agaçante) - Microsoft a envisagé de fusionner les deux types en un seul, mais a finalement décidé qu'il était trop tard dans le cycle de publication pour faire un changement aussi important. Vous pouvez grosso modo faire comme s'il s'agissait d'un seul type.

N'importe quel type délégué que vous créez a les membres hérités de ses types de base, un constructeur avec deux paramètres de type object et IntPtr, et trois méthodes supplémentaires : Invoke, BeginInvoke et EndInvoke. Nous reviendrons au constructeur dans une minute. Les méthodes ne peuvent pas être héritées de quoi que ce soit, car leurs signatures varient en fonction de la signature du délégué lui-même. Si on reprend l'exemple ci-dessus, le premier délégué a les méthodes suivantes :

public string Invoke (int x);
public System.IAsyncResult BeginInvoke(int x, System.AsyncCallback callback, object state);
public string EndInvoke(IAsyncResult result);

Comme vous pouvez le voir, les types de retour de Invoke et EndInvoke correspondent à celui de la signature du délégué, tout comme les paramètres de Invoke et les premiers paramètres de BeginInvoke. Nous verrons la raison d'être de Invoke dans la prochaine section, et nous parlerons de BeginInvoke et EndInvoke dans la . Il est un peu prématuré de parler d'appeler des méthodes alors qu'on ne sait pas encore créer une instance, de toute façon. Nous aborderons cela (et d'autres choses) dans la section suivante.

Instances de délégué : les bases

Maintenant que nous savons comment déclarer un type délégué et ce qu'il contient, regardons comment créer une instance d'un tel type, et ce qu'on peut en faire.

Créer des instances de délégué

Cet article ne couvre pas les fonctionnalités de C# 2.0 et 3.0 pour la création d'instances de délégué, ni la variance des délégués génériques introduite en C# 4.0. Mon article sur les closures parle des fonctionnalités de C# 2.0 et 3.0 - vous pouvez aussi lire les chapitres 5, 9 et 13 de C# in Depth pour beaucoup plus de détails. En me concentrant sur la façon explicite de créer des instances de délégué en C# 1.0/1.1, je pense qu'il sera plus facile de comprendre ce qu'il se passe sous le capot. Quand vous aurez compris les bases, ça vaudra vraiment la peine de connaître les fonctionnalités proposées par les nouvelles versions – mais si vous essayez de les utiliser avant d'avoir une bonne compréhension des bases, cela risque de vous embrouiller.

Comme mentionné plus tôt, les données les plus importantes de n'importe quelle instance de délégué sont la méthode à laquelle le délégué est lié, et la référence sur laquelle appeler cette méthode (la cible). Pour les méthodes statiques, la cible n'est pas requise. Le CLR lui-même supporte d'autres formes légèrement différentes de délégués, où soit le premier argument passé à une méthode statique est contenu dans le délégué, soit la cible de la méthode d'instance est fournie en argument quand la méthode est appelée. Référez-vous à la documentation de System.Delegate pour plus d'informations à ce sujet si ça vous intéresse, mais ne vous en préoccupez pas trop pour l'instant.

Alors, maintenant que nous connaissons les deux éléments requis pour créer une instance de délégué (ainsi que le type lui-même, bien sûr), comment les indiquer au compilateur ? On utilise ce que la spécification C# appelle une expression de création de délégué, qui est de la forme new type-de-délégué (expression). L'expression doit être soit un autre délégué du même type (ou un type délégué compatible en C# 2.0), soit un groupe de méthodes - le nom d'une méthode et optionnellement une cible, spécifiés comme si vous appeliez la méthode, mais sans les arguments ni les parenthèses. Créer des copies d'un délégué est assez rare, on se concentrera donc sur la forme la plus courante. Le code ci-dessous contient quelques exemples :

// Les deux expressions de création suivantes sont équivalentes,
// avec InstanceMethod qui est une méthode d'instance de la classe
// qui contient l'expression de création (ou d'une classe de base).
// La cible est "this".
FirstDelegate d1 = new FirstDelegate(InstanceMethod);
FirstDelegate d2 = new FirstDelegate(this.InstanceMethod);

// Ici on crée une instance de délégué qui fait référence à la même
// méthode que dans les deux premiers exemples, mais avec une cible différente.
FirstDelegate d3 = new FirstDelegate(anotherInstance.InstanceMethod);

// Cette instance de délégué utilise une méthode d'instance d'une autre
// classe, en spécifiant l'instance sur laquelle appeler la méthode
FirstDelegate d4 = new FirstDelegate(instanceOfOtherClass.OtherInstanceMethod);

// Cette instance de délégué utilise une méthode statique dans la classe
// qui contient l'expression de création (ou une classe de base).
FirstDelegate d5 = new FirstDelegate(StaticMethod);

// Cette instance de délégué utilise une méthode statique dans une
// classe différente.
FirstDelegate d6 = new FirstDelegate(OtherClass.OtherStaticMethod);

Le constructeur qu'on a mentionné précédemment a deux paramètres - un object et un IntPtr. L'object est une référence à la cible (ou null pour une méthode statique) et le IntPtr est un pointeur vers la méthode elle-même.

Un point à souligner est que les instances de délégué peuvent faire référence à des méthodes et des cibles qui ne seraient normalement pas visibles au point où l'appel est réellement effectué. Par exemple, une méthode privée peut être utilisée pour créer une instance de délégué, et cette instance de délégué peut ensuite être renvoyée à partir d'un membre public. Sinon, la cible d'une instance peut être un objet dont l'appelant final ne connaît absolument rien. Cependant, aussi bien la cible que la méthode doivent être accessibles à partir du code qui crée le délégué. Autrement dit, si (et seulement si) vous pouvez appeler une méthode donnée sur un objet donné, vous pouvez utiliser cette méthode et cette cible pour créer un délégué. Les droits d'accès sont effectivement ignorés au moment de l'appel. À ce propos…

Appeler des instances de délégué

Les instances de délégué sont appelées comme si elles étaient les méthodes elles-mêmes. Par exemple, pour appeler le délégué auquel la variable d1 fait référence, on pourrait écrire :

string result = d1(10);

La méthode à laquelle l'instance de délégué fait référence est appelée sur l'objet cible (s'il y en a un), et le résultat est renvoyé. Écrire un programme complet pour démontrer cela sans inclure beaucoup de code apparemment inutile est délicat. Cependant, voici un programme qui donne un exemple avec une méthode statique et un autre avec une méthode d'instance. DelegateTest.StaticMethod pourrait être écrit plus simplement StaticMethod, de la même façon que (dans une méthode d'instance) on pourrait écrire InstanceMethod au lieu de this.InstanceMethod - j'ai inclus le nom de la classe juste pour montrer clairement comment faire y référence à partir d'une autre classe.

using System;

public delegate string FirstDelegate (int x);

class DelegateTest
{
string name;

static void Main()
{
FirstDelegate d1 = new FirstDelegate(DelegateTest.StaticMethod);

DelegateTest instance = new DelegateTest();
instance.name = "My instance";
FirstDelegate d2 = new FirstDelegate(instance.InstanceMethod);

Console.WriteLine (d1(10)); // Affiche "Static method: 10"
Console.WriteLine (d2(5));  // Affiche "My instance: 5"
}

static string StaticMethod (int i)
{
return string.Format ("Static method: {0}", i);
}

string InstanceMethod (int i)
{
return string.Format ("{0}: {1}", name, i);
}
}

La syntaxe C# est juste un raccourci pour appeler la méthode Invoke fournie par chaque type délégué. Les délégués peuvent aussi être exécutés de façon asynchrone s'ils fournissent les méthodes BeginInvoke/EndInvoke. Cela sera expliqué .

Combiner des délégués

Les délégués peuvent être combinés de telle façon que quand vous appelez le délégué, toute une liste de méthodes est appelée - avec éventuellement des cibles différentes. Quand j'ai dit précédemment qu'un délégué contenait une cible et une méthode, c'était une petite simplification. C'est ce que contient une instance de délégué qui représente une méthode. Pour plus de clarté, j'appellerai de telles instances de délégué des délégués simples. L'alternative est une instance de délégué qui est effectivement une liste de délégués simples, tous du même type (c'est-à-dire ayant tous la même signature). J'appellerai ceux-ci des délégués combinés. Les délégués combinés peuvent eux-mêmes être combinés ensemble, ce qui crée une grosse liste de délégués simples, comme on pourrait s'y attendre.

Il est important de comprendre que les instances de délégué sont toujours immuables. Le fait de les combiner (ou de les séparer) crée une nouvelle instance de délégué qui représente la nouvelle liste de cibles et méthodes à appeler. C'est comme pour les chaînes de caractères : si vous appelez String.PadLeft par exemple, cela ne modifie pas réellement la chaîne sur laquelle vous l'appelez - cela renvoie juste une nouvelle chaîne avec le padding approprié.

Combiner deux instances de délégué se fait habituellement avec l'opérateur d'addition, comme si les instances de délégué étaient de simples chaînes ou nombres. Soustraire une instance de délégué d'une autre se fait habituellement avec l'opérateur de soustraction. Notez que quand vous soustrayez un délégué combiné d'un autre délégué combiné, la soustraction se fait en termes de listes. Si la liste à soustraire n'est pas trouvée dans la liste d'origine, le résultat est juste la liste d'origine. Sinon, la dernière instance de la liste est enlevée. Cela est plus facile à montrer avec des exemples. Plutôt que du vrai code, le tableau qui suit utilise des listes de délégués simples d1, d2, etc. Par exemple, [d1, d2, d3] est un délégué combiné qui, quand il est exécuté, appelle d1, puis d2, puis d3. Une liste vide est représentée par null plutôt que par une vraie instance de délégué.

Expression

Résultat

null + d1

d1

d1 + null

d1

d1 + d2

[d1, d2]

d1 + [d2, d3]

[d1, d2, d3]

[d1, d2] + [d2, d3]

[d1, d2, d2, d3]

[d1, d2] - d1

d2

[d1, d2] - d2

d1

[d1, d2, d1] - d1

[d1, d2]

[d1, d2, d3] - [d1, d2]

d3

[d1, d2, d3] - [d2, d1]

[d1, d2, d3]

[d1, d2, d3, d1, d2] - [d1, d2]

[d1, d2, d3]

[d1, d2] - [d1, d2]

null

Les instances de délégué peuvent aussi être combinées avec la méthode statique Delegate.Combine, ou soustraites avec la méthode static Delegate.Remove. Le compilateur C# convertit les opérateurs d'addition et de soustraction en des appels à ces méthodes. Puisque ce sont des méthodes statiques, elles fonctionnent sans problème avec des références nulles.

Les opérateurs d'addition et de soustraction fonctionnent aussi dans les affectations : d1 += d2; est exactement équivalent à d1 = d1 + d2;, idem pour la soustraction. Encore une fois, l'instance de délégué originale reste inchangée ; la valeur de d1 change simplement pour devenir une référence vers le nouveau délégué combiné.

Notez que puisque les délégués supplémentaires sont toujours ajoutés et supprimés à la fin de la liste, x += y; x -= y; est toujours une non-opération.

Si un type délégué déclare qu'il renvoie une valeur (c'est-à-dire que son type de retour n'est pas void) et qu'un délégué combiné de ce type est appelé, la valeur renvoyée par cet appel est celle qui est renvoyée par le dernier délégué simple de la liste.

Évènements

Pour commencer : les évènements ne sont pas des instances de délégué. Essayons encore :

Les évènements ne sont pas des instances de délégué.

Il est fâcheux que C# vous laisse les utiliser de la même façon dans certaines situations, mais il est très important que vous compreniez bien la différence.

Je trouve que le moyen le plus simple pour comprendre les évènements est de les voir un peu comme des propriétés. Bien que les propriétés ressemblent à des champs, elles ne le sont certainement pas - et vous pouvez créer des propriétés qui n'utilisent pas de champs du tout. De la même manière, bien que les évènements ressemblent à des instances de délégué en ce qui concerne la manière d'exprimer les opérations d'ajout et de retrait, ce ne sont pas des instances de délégué.

Les évènements sont des paires de méthodes, décorées de façon appropriée en IL pour les lier l'une à l'autre et indiquer aux langages que ces méthodes représentent des évènements. Les méthodes correspondent aux opérations add et remove, et chacune d'elles prend en paramètre une instance de délégué du même type que l'évènement lui-même. Ce que vous faites avec ces méthodes dépend entièrement de vous, mais l'usage typique est d'ajouter ou de retirer le délégué d'une liste de gestionnaires pour l'évènement. Quand l'évènement est déclenché (quel que soit le déclencheur - un clic sur un bouton, l'expiration d'un délai, une exception non gérée), les gestionnaires sont appelés un par un. Notez qu'en C#, l'appel des gestionnaires d'un évènement ne fait pas partie de l'évènement lui-même (le CIL définit une association avec une méthode raise_nomDeLEvenement, ainsi que d'autres méthodes, mais elles ne sont pas utilisées en C#).

Les méthodes add et remove de l'évènement sont appelées en C# en utilisant nomDeLEvenement += instanceDeDelegue; et nomDeLEvenement -= instanceDeDelegue;, respectivement, où nomDeLEvenement peut être qualifié avec une référence (par exemple myForm.Click) ou un nom de type (par exemple MaClasse.UnEvenement). Les évènements statiques sont relativement rares.

Les évènements eux-mêmes peuvent être déclarés de deux façons. La première est d'écrire explicitement les méthodes add et remove, déclarées d'une façon similaire aux propriétés, mais avec le mot-clé event. Voici un exemple d'un évènement pour le type de délégué System.EventHandler. Remarquez qu'on ne fait rien avec les instances de délégué passées aux méthodes add et remove - on affiche simplement quelle opération a été appelée. Notez aussi que l'opération remove est appelée bien qu'on lui ait demandé de retirer null.

using System;

class Test
{
public event EventHandler MyEvent
{
add
{
Console.WriteLine ("add operation");
}

remove
{
Console.WriteLine ("remove operation");
}
}

static void Main()
{
Test t = new Test();

t.MyEvent += new EventHandler (t.DoNothing);
t.MyEvent -= null;
}

void DoNothing (object sender, EventArgs e)
{
}
}

Bien qu'il soit rare en pratique d'ignorer la valeur du paramètre value de cette façon, il y a des cas où on ne souhaite pas stocker les gestionnaires dans un simple champ de type délégué. Par exemple, dans des situations où il y a beaucoup d'évènements, mais où peu sont susceptibles d'être gérés, vous pouvez avoir une correspondance entre une clé qui décrit l'évènement et le délégué qui le gère. C'est ce que fait Windows Forms - cela permet d'avoir un grand nombre d'évènements sans gâcher de la mémoire avec des variables qui contiennent presque toutes la valeur null.

Un raccourci : les évènements de type champ

C# fournit un moyen simple de déclarer à la fois une variable de type délégué et un évènement. Cela s'appelle les évènements de type champ (field-like events), et c'est très simple à déclarer - c'est la même chose que la version « longue », mais sans le « corps » de l'évènement :

public event EventHandler MyEvent;

Cela crée une variable de type délégué et un évènement, tous deux du même type. Le niveau d'accès à l'évènement est déterminé par sa déclaration (par exemple le code ci-dessus déclare un évènement public), mais la variable délégué est toujours privée. Le corps implicite de l'évènement ajoute ou enlève des instances de délégués de la variable de façon assez évidente, mais les changements sont faits dans un verrou. Pour C# 1.1, l'évènement est équivalent à ceci :

private EventHandler _myEvent;

public event EventHandler MyEvent
{
add
{
lock (this)
{
_myEvent += value;
}
}
remove
{
lock (this)
{
_myEvent -= value;
}
}
}

Le code ci-dessus est valable pour un membre d'instance. Pour un évènement déclaré comme statique, la variable est également statique et le verrou est pris sur typeof(XXX), où XXX est le nom de la classe qui déclare l'évènement. En C# 2 il n'y a pas vraiment de garantie sur l'objet utilisé pour le verrou - seulement sur le fait qu'un seul objet associé à l'instance est utilisé pour le verrou dans le cas des évènements d'instance, ou un seul objet associé à la classe pour les évènements statiques (notez que cela n'est valable que pour les évènements de classe, et non pour les évènements de structure - le verrouillage des évènements est problématique pour les structures ; en pratique je ne me souviens pas avoir jamais vu une structure avec des évènements). En fait, rien de tout cela n'est aussi utile que vous pourriez le croire - voir la section sur les pour plus de détails.

Alors, que se passe-t-il quand vous faites référence à MyEvent dans le code ? En fait, dans le code du type lui-même (types imbriqués compris), le compilateur génère du code qui fait référence à la variable délégué (_myEvent dans le code ci-dessus). Dans tous les autres contextes, le compilateur génère du code qui fait référence à l'évènement.

À quoi ça sert ?

Maintenant que nous savons ce qu'ils sont, quel est l'intérêt d'avoir des délégués et des évènements ? La réponse, c'est l'encapsulation. Supposons que le concept d'évènement n'existe pas en C#/.NET. Comment une autre classe pourrait-elle s'abonner à un évènement ? Trois options :

1. Une variable délégué publique ;

2. Une variable délégué accessible via une propriété ;

3. Une variable délégué avec des méthodes AddXXXHandler et RemoveXXXHandler.

L'option 1 est clairement horrible, pour toutes les raisons habituelles qui font qu'on abhorre les variables publiques. L'option 2 est légèrement meilleure, mais permet aux abonnés de s'écraser mutuellement - il serait trop facile d'écrire someInstance.MyEvent = eventHandler;, ce qui remplacerait tous les gestionnaires existants de l'évènement au lieu d'en ajouter un nouveau. En plus, il faudrait encore écrire les propriétés.

L'option 3 correspond en gros à ce que les évènements vous offrent, mais ces derniers fournissent aussi une convention garantie (générée par le compilateur et supportée par des flags supplémentaires au niveau de l'IL) et une implémentation « gratuite » si la sémantique des évènements de type champ vous convient. L'abonnement et le désabonnement à un évènement sont encapsulés sans permettre d'accès arbitraire à la liste des gestionnaires de l'évènement, et les langages peuvent faciliter les choses en fournissant une syntaxe pour la déclaration et l'abonnement.

Évènements thread-safe

Cette section nécessite quelques révisions en ce qui concerne C# 4

Nous avons vu un peu plus tôt le verrouillage des évènements de type champ durant les opérations add/remove. Cela procure un certain niveau de sécurité vis-à-vis des threads. Malheureusement, ce n'est pas terriblement utile. Premièrement, même avec C# 2, la spécification autorise que le verrou soit pris sur l'objet courant this, ou sur le type lui-même pour les évènements statiques. Cela enfreint le principe selon lequel on ne doit prendre de verrou que sur des références privées pour éviter les interblocages (deadlocks) accidentels.

Ironiquement, le second problème est exactement l'inverse du premier - puisqu'en C# 2 vous n'avez aucune garantie sur le verrou qui va être utilisé, vous ne pouvez pas l'utiliser vous-même quand vous déclenchez l'évènement pour vous assurer de voir la valeur la plus récente dans le thread qui fait le déclenchement. Vous pouvez prendre le verrou sur autre chose ou utiliser une des méthodes de barrière de mémoire, mais ça laisse une sorte de mauvais goût dans la bouche.

Si vous voulez être vraiment thread-safe, de façon à ce que quand vous déclenchez l'évènement, vous utilisiez toujours la valeur la plus récente de la variable délégué, tout en vous assurant que les opérations add/remove n'interfèrent pas l'une avec l'autre, vous devez écrire le corps des opérations add/remove vous-même. Voilà un exemple :

/// <summary>
/// Variable délégué qui supporte l'évènement SomeEvent.
/// </summary>
SomeEventHandler someEvent;

/// <summary>
/// Verrou pour l'accès au délégué SomeEvent.
/// </summary>
readonly object someEventLock = new object();

/// <summary>
/// L'évènement SomeEvent.
/// </summary>
public event SomeEventHandler SomeEvent
{
add
{
lock (someEventLock)
{
someEvent += value;
}
}
remove
{
lock (someEventLock)
{
someEvent -= value;
}
}
}

/// <summary>
/// Déclenche l'évènement SomeEvent.
/// </summary>
protected virtual void OnSomeEvent(EventArgs e)
{
SomeEventHandler handler;
lock (someEventLock)
{
handler = someEvent;
}
if (handler != null)
{
handler (this, e);
}
}

Vous pouvez utiliser un seul verrou pour tous vos évènements, et même aussi pour d'autres choses - cela dépend de votre situation. Notez qu'il faut affecter la valeur courante à une variable locale à l'intérieur du verrou (pour avoir la valeur la plus récente) et ensuite tester si la valeur et null et l'exécuter en dehors du verrou : conserver le verrou pendant que vous déclenchez l'évènement est une très mauvaise idée, car cela pourrait facilement mener à une situation d'interblocage (un gestionnaire d'évènement pourrait tout à fait attendre qu'un autre thread fasse quelque chose, et si cet autre thread essaie d'appeler les opérations add ou remove de l'évènement, vous obtenez un deadlock).

Tout ceci fonctionne parce qu'une fois que la valeur de someEvent a été affectée à handler, la valeur de handler ne changera plus même si celle de someEvent change. Donc si tous les gestionnaires se désabonnent de l'évènement, someEvent deviendra null mais handler aura toujours la même valeur que quand il a été assigné. En fait, puisque les instances de délégué sont immuables, tous les gestionnaires qui étaient abonnés quand la ligne handler = someEvent; a été exécutée seront appelés, même si d'autres se sont abonnés entre ce moment et la ligne handler (this, e);.

Maintenant, il est important de se demander si vous avez ou non besoin qu'un évènement soit thread-safe. Est-ce que des gestionnaires vont être ajoutés ou retirés depuis d'autres threads ? Avez-vous besoin de déclencher l'évènement depuis un autre thread ? Si vous contrôlez entièrement votre application, la réponse peut tout à fait être « non ». Si vous écrivez une bibliothèque de classes, il est plus probable qu'il soit important d'être thread-safe. Même si vous n'avez pas besoin d'être thead-safe, il est possible que vous vouliez implémenter les opérations add/remove pour contourner le problème du verrou externe que C# utilise (ou peut utiliser dans le cas de C# 2). À partir de là, les opérations deviennent assez triviales. Voici l'équivalent non thread-safe du code précédent.

/// <summary>
/// Variable délégué qui supporte l'évènement SomeEvent.
/// </summary>
SomeEventHandler someEvent;

/// <summary>
/// L'évènement SomeEvent.
/// </summary>
public event SomeEventHandler SomeEvent
{
add
{
someEvent += value;
}
remove
{
someEvent -= value;
}
}

/// <summary>
/// Déclenche l'évènement SomeEvent.
/// </summary>
protected virtual void OnSomeEvent(EventArgs e)
{
if (someEvent != null)
{
someEvent (this, e);
}
}

Le test de nullité est là parce que la variable délégué est nulle quand il n'y a aucune instance de délégué à appeler. Une façon de simplifier les choses est d'utiliser par défaut une instance de délégué « no-op » qui ne fait rien, et qui n'est jamais retirée. À partir de là, vous pouvez simplement obtenir la valeur de la variable délégué (dans un lock si vous voulez être thread-safe) et exécuter l'instance de délégué. S'il n'y a pas de « vrai » délégué cible à appeler, la cible no-op sera exécutée et c'est tout ce qui se produira.

Instances de délégué : autres méthodes

Nous avons vu précédemment qu'appeler unDelegue(10) était en fait un raccourci pour unDelegue.Invoke(10). Les types délégués peuvent aussi permettre un comportement asynchrone en utilisant la paire de méthodes BeginInvoke/EndInvoke. Celles-ci sont optionnelles du point de vue de la spécification CLI, mais les types délégués de C# les fournissent toujours. Elles suivent le même modèle d'exécution asynchrone que le reste de .NET, en permettant de spécifier une méthode de rappel (callback), ainsi qu'un objet pour stocker des informations d'état. Les délégués sont exécutés sur des threads créés par le pool de threads du système.

Le premier exemple ci-dessous fonctionne sans callback, en utilisant simplement BeginInvoke et EndInvoke à partir du même thread. C'est parfois utile quand un seul thread est utilisé pour une opération qui est synchrone en général, mais qui contient des éléments qui peuvent être exécutés en parallèle. Les méthodes impliquées sont toutes statiques pour plus de simplicité, mais des instances de délégué avec des objets cibles spécifiques peuvent aussi être utilisées, et le sont souvent. EndInvoke renvoie la valeur qui a été retournée par l'appel au délégué. Si l'appel lance une exception, la même exception est lancée par EndInvoke.

using System;
using System.Threading;

delegate int SampleDelegate(string data);

class AsyncDelegateExample1
{
static void Main()
{
SampleDelegate counter = new SampleDelegate(CountCharacters);
SampleDelegate parser = new SampleDelegate(Parse);

IAsyncResult counterResult = counter.BeginInvoke ("bonjour", null, null);
IAsyncResult parserResult = parser.BeginInvoke ("10", null, null);
Console.WriteLine ("Le thread principal continue");

Console.WriteLine ("Le compteur a renvoyé {0}", counter.EndInvoke(counterResult));
Console.WriteLine ("L'analyseur a renvoyé {0}", parser.EndInvoke(parserResult));

Console.WriteLine ("Terminé");
}

static int CountCharacters (string text)
{
Thread.Sleep (2000);
Console.WriteLine ("Comptage des caractères dans {0}", text);
return text.Length;
}

static int Parse (string text)
{
Thread.Sleep (100);
Console.WriteLine ("Analyse du texte {0}", text);
return int.Parse(text);
}
}

Les appels à Thread.Sleep ne sont là que pour démontrer que l'exécution se fait vraiment en parallèle. Si le Sleep dans CountCharacters est aussi long, c'est pour forcer le pool de thread du système à exécuter les tâches dans deux threads différents - le pool exécute successivement les requêtes qui ne prennent pas longtemps pour éviter de créer plus de threads que nécessaire. En dormant longtemps, on simule une opération longue. Voici la sortie générée par une exécution de ce code :

Le thread principal continue
Analyse du texte 10
Comptage des caractères dans hello
Le compteur a renvoyé 5
L'analyseur a renvoyé 10
Terminé

Les appels à EndInvoke sont bloquants jusqu'à ce que le délégué ait fini de s'exécuter, à peu près de la même façon que Thread.Join bloque jusqu'à ce que les threads concernés soient terminés. Les valeurs IAsyncResult renvoyées par les appels à BeginInvoke permettent d'accéder à l'état passé en dernier paramètre de BeginInvoke, mais cela n'est typiquement pas utilisé dans le style d'invocation asynchrone illustré ci-dessus.

Le code ci-dessus est assez simple, mais souvent pas aussi puissant qu'un modèle qui utilise des callbacks après la fin de l'exécution du délégué. Typiquement, le callback appellera EndInvoke pour obtenir le résultat du délégué. Bien que ce soit théoriquement un appel bloquant, il ne bloquera en fait jamais parce que le callback n'est appelé que quand le délégué a fini de s'exécuter. Le callback peut utiliser l'état fourni à BeginInvoke comme information de contexte supplémentaire. L'exemple de code ci-dessous utilise les mêmes délégués de comptage et d'analyse que dans l'exemple précédent, mais avec un callback pour afficher les résultats. L'état est utilisé pour déterminer comment afficher chaque résultat, donc un seul callback peut être utilisé pour les deux appels asynchrones. Notez le cast de IAsyncResult en AsyncResult : la valeur fournie au callback est toujours une instance de AsyncResult, et ce fait peut être utilisé pour obtenir l'instance de délégué d'origine, de façon à ce que le callback puisse appeler EndInvoke. C'est assez étrange que AsyncResult se trouve dans l'espace de nom System.Runtime.Remoting.Messaging, alors que toutes les autres classes impliquées se trouvent dans System ou System.Threading, mais c'est la vie.

using System;
using System.Threading;
using System.Runtime.Remoting.Messaging;

delegate int SampleDelegate(string data);

class AsyncDelegateExample2
{
static void Main()
{
SampleDelegate counter = new SampleDelegate(CountCharacters);
SampleDelegate parser = new SampleDelegate(Parse);

AsyncCallback callback = new AsyncCallback (DisplayResult);

counter.BeginInvoke ("bonjour", callback, "Le compteur a renvoyé {0}");
parser.BeginInvoke ("10", callback, "L'analyseur a renvoyé {0}");

Console.WriteLine ("Le thread principal continue");

Thread.Sleep (3000);
Console.WriteLine ("Terminé");
}

static void DisplayResult(IAsyncResult result)
{
string format = (string) result.AsyncState;
AsyncResult delegateResult = (AsyncResult) result;
SampleDelegate delegateInstance = (SampleDelegate)delegateResult.AsyncDelegate;

Console.WriteLine (format, delegateInstance.EndInvoke(result));
}

static int CountCharacters (string text)
{
Thread.Sleep (2000);
Console.WriteLine ("Comptage des caractères dans {0}", text);
return text.Length;
}

static int Parse (string text)
{
Thread.Sleep (100);
Console.WriteLine ("Analyse du texte {0}", text);
return int.Parse(text);
}
}

Cette fois presque tout le travail est fait dans des threads du pool. Le thread principal démarre simplement les tâches asynchrones, puis dort assez longtemps pour laisser le travail se terminer. (Les threads du pool sont des threads d'arrière-plan - sans l'appel à Sleep dans le Main, l'application se terminerait avant que les appels aux délégués aient fini de s'exécuter.) Un exemple de sortie est montré ci-dessous - remarquez comment cette fois, parce qu'il n'y a pas d'ordre garanti pour les appels à EndInvoke, le résultat de l'analyseur est affiché avant celui du compteur. Dans l'exemple précédent, il était presque certain que l'analyseur s'était terminé avant le compteur, mais le thread principal attendait d'abord d'obtenir le résultat du compteur.

Le thread principal continue
Analyse du texte 10
Comptage des caractères dans hello
L'analyseur a renvoyé 10
Le compteur a renvoyé 5
Terminé

Notez que vous devez appeler EndInvoke quand vous utilisez l'exécution asynchrone, pour garantir qu'il n'y aura pas de fuite de mémoire ou de handle. Certaines implémentations peuvent ne pas fuir, mais vous ne devriez pas vous fier à cela. Lisez mon article sur le pool de thread pour un exemple de code qui permet un comportement asynchrone de type « tire et oublie » si cela est un problème.

Conclusion

Les délégués offrent un moyen simple de représenter l'appel à une méthode, potentiellement avec un objet cible, comme un élément de donnée qui peut être transmis. Ils sont le fondement des évènements, qui sont en réalité des conventions pour ajouter ou retirer du code de gestion qui sera appelé au moment approprié.

Remerciements

Je tiens à remercier Jon Skeet pour son aimable autorisation de traduire cet article, ainsi que ClaudeLELOUP pour sa relecture attentive et ses corrections.