Generics, delegates et events
Types génériques, contraintes, delegates, events, lambdas.
Vérifié en septembre 2026 · .NET 10 (SDK 10.0.300), C# 14 · environ 16 min
Un générique écrit un algorithme une fois pour tous les types sans renoncer au contrôle du compilateur ; un délégué fait d'une méthode une valeur qu'on passe, qu'on range et qu'on appelle plus tard ; un event restreint ce délégué à ce qu'un abonné a le droit d'en faire. Les trois servent chaque jour — List<T>, les lambdas de LINQ, les notifications d'un composant — et chacun a son piège : une conversion refusée qui paraît évidente, une boucle dont tous les lambdas voient la même valeur, un objet que le ramasse-miettes ne libère jamais parce qu'il est resté abonné.
Pourquoi les génériques : sûreté de type et boxing
Avant C# 2, une collection réutilisable stockait des object : ArrayList, Hashtable. Deux défauts en découlent. Le compilateur ne sait plus ce que contient la collection : il accepte n'importe quoi à l'ajout, et la conversion écrite à la lecture n'est vérifiée qu'à l'exécution. Et un type valeur rangé dans un object est boxé : copié dans un objet alloué sur le tas, que le ramasse-miettes devra récupérer, puis déboxé à chaque lecture.
using System;
using System.Collections;
static class AvantLesGeneriques
{
public static void Executer()
{
var montants = new ArrayList(1_000);
var avant = GC.GetAllocatedBytesForCurrentThread();
for (var i = 0; i < 1_000; i++)
{
// Add prend un object : chaque int est boxe, c'est-a-dire copie
// dans un objet neuf alloue sur le tas.
montants.Add(i);
}
// 24 octets par boite en x64 : un en-tete, un pointeur de type, l'int
// et son remplissage.
Console.WriteLine(GC.GetAllocatedBytesForCurrentThread() - avant); // 24000
// Le compilateur accepte n'importe quel object...
montants.Add("mille");
var total = 0;
try
{
// ... et la conversion ecrite ici n'est verifiee qu'a l'execution.
foreach (int montant in montants)
{
total += montant;
}
}
catch (InvalidCastException ex)
{
// Les messages d'exception du runtime .NET 10 sont en anglais.
Console.WriteLine(ex.Message);
// Unable to cast object of type 'System.String' to type 'System.Int32'.
}
}
}List<int> range ses éléments dans un int[] : l'ajout écrit quatre octets dans le tableau, la lecture les rend tels quels, et une chaîne est refusée à la compilation.
using System;
using System.Collections.Generic;
static class AvecLesGeneriques
{
public static void Executer()
{
var montants = new List<int>(1_000);
var avant = GC.GetAllocatedBytesForCurrentThread();
for (var i = 0; i < 1_000; i++)
{
// List<int> range ses elements dans un int[] : aucune boite.
montants.Add(i);
}
Console.WriteLine(GC.GetAllocatedBytesForCurrentThread() - avant); // 0
// montants.Add("mille"); // CS1503 : conversion impossible de string en int
var total = 0;
foreach (var montant in montants)
{
total += montant; // un int, sans conversion ni verification
}
Console.WriteLine(total); // 499500
}
}Les génériques de .NET sont réifiés : l'argument de type existe à l'exécution, là où Java l'efface et où TypeScript efface tous ses types à la compilation. Le JIT produit un code natif spécialisé pour chaque type valeur, ce qui supprime le boxing, et un code unique, partagé, pour tous les types référence, qui ont tous la taille d'un pointeur. Le cours Mémoire et performance mesure les boxings qui restent cachés aujourd'hui.
Méthodes et types génériques
Un type générique fixe son paramètre à la construction : Page<int> est un type à part entière, dont chaque instance contient des int. Une méthode générique choisit le sien à chaque appel, et le compilateur l'infère des arguments quand il le peut, y compris du type que rend un lambda passé en argument. Il ne l'infère jamais du seul type de retour : un paramètre de type qui n'apparaît dans aucun paramètre s'écrit explicitement.
Chaque type construit est un type distinct à l'exécution, avec ses propres champs statiques : Compteur<int> et Compteur<string> ne partagent rien. C'est souvent voulu — un cache par type ne coûte qu'un champ statique — et c'est une surprise quand on croyait y ranger un état global.
using System;
using System.Collections.Generic;
// Un type generique : T est fixe quand on construit le type, pour toute l'instance.
sealed class Page<T>
{
public Page(IReadOnlyList<T> elements, int numero)
{
Elements = elements;
Numero = numero;
}
public IReadOnlyList<T> Elements { get; }
public int Numero { get; }
// Une methode generique dans un type generique : TSortie est choisi a chaque appel.
public Page<TSortie> Projeter<TSortie>(Func<T, TSortie> projection)
{
var sortie = new List<TSortie>(Elements.Count);
foreach (var element in Elements)
{
sortie.Add(projection(element));
}
return new Page<TSortie>(sortie, Numero);
}
}
static class Compteur<T>
{
// Un champ statique par type construit : Compteur<int> et Compteur<string>
// sont deux types distincts, chacun avec le sien.
public static int Appels;
}
static class Generiques
{
public static void Echanger<T>(ref T a, ref T b) => (a, b) = (b, a);
public static string Nommer<T>() => typeof(T).Name;
public static void Executer()
{
int x = 1, y = 2;
Echanger(ref x, ref y); // T infere : int
Console.WriteLine($"{x} {y}"); // 2 1
var page = new Page<int>([10, 20], numero: 1);
// TSortie est infere du type que rend le lambda : string.
var libelles = page.Projeter(n => $"#{n}");
Console.WriteLine(string.Join(", ", libelles.Elements)); // #10, #20
// T n'apparait dans aucun parametre : rien a inferer, il s'ecrit.
// Nommer(); // CS0411 : impossible de deduire les arguments de type
Console.WriteLine(Nommer<int>()); // Int32
// L'argument de type existe a l'execution.
Console.WriteLine(libelles.GetType()); // Page`1[System.String]
Compteur<int>.Appels++;
Compteur<int>.Appels++;
Compteur<string>.Appels++;
Console.WriteLine($"{Compteur<int>.Appels} {Compteur<string>.Appels}"); // 2 1
}
}Contraintes : ce que T doit savoir faire
Sans contrainte, le compilateur ne sait rien de T, sinon qu'il se convertit en object : le corps n'a guère droit qu'aux membres d'object, à l'affectation et à default. Une contrainte restreint les arguments de type admis et, en échange, donne accès à ce qu'elle garantit. Sa violation se voit à la compilation de l'appel : une erreur, sauf quand seule la nullabilité est en cause, qui avertit.
| Contrainte | Arguments admis | Ce que le corps y gagne |
|---|---|---|
class | types référence | null comme valeur de T? |
struct | types valeur non nullables | T? signifie Nullable<T> |
new() | types à constructeur public sans paramètre | new T() |
notnull | types non nullables, référence ou valeur | rien : un avertissement de nullabilité (CS8714) pour un argument nullable |
unmanaged | types valeur sans aucune référence, à toute profondeur | stackalloc ; pointeurs et sizeof en contexte unsafe |
| interface, classe de base | types qui l'implémentent ou en dérivent | ses membres ; pour une interface, ses membres statiques abstraits (C# 11) |
allows ref struct | en plus, les ref struct (C# 13) | rien : il impose au corps les règles des ref struct |
Le T? d'un paramètre sans contrainte mérite un arrêt. Pour un type référence, il annote la nullabilité ; pour un type valeur, il désigne T lui-même, et default vaut zéro. Le code générique qui rend default pour dire « rien trouvé » hérite de l'ambiguïté que le cours Collections et LINQ montre avec FirstOrDefault. La contrainte struct fait de T? un Nullable<T>, et l'absence retrouve sa propre valeur.
using System;
using System.Collections.Generic;
using System.Numerics;
interface IEntite
{
int Id { get; }
}
sealed class Client : IEntite
{
public int Id { get; init; }
public string Nom { get; init; } = "";
}
readonly struct Mesure
{
public double Valeur { get; init; }
}
static class Contraintes
{
// Interface : le corps peut lire Id, que la contrainte garantit.
public static Dictionary<int, T> IndexerParId<T>(IEnumerable<T> entites) where T : IEntite
{
var index = new Dictionary<int, T>();
foreach (var entite in entites)
{
index[entite.Id] = entite;
}
return index;
}
// Sans contrainte, T? ne change rien pour un type valeur : c'est T lui-meme,
// et default vaut 0 pour un int.
public static T? Premier<T>(IReadOnlyList<T> elements) => elements.Count > 0 ? elements[0] : default;
// struct : T? devient Nullable<T>, et l'absence a sa propre valeur. L'interface
// IParsable<T> a un membre statique abstrait (C# 11) : T.TryParse s'appelle sur le type.
public static T? Lire<T>(string texte) where T : struct, IParsable<T> =>
T.TryParse(texte, null, out var valeur) ? valeur : null;
// class : T est un type reference, null est une valeur de T?.
public static T? PremierOuNull<T>(IReadOnlyList<T> elements) where T : class =>
elements.Count > 0 ? elements[0] : null;
// new() : un constructeur public sans parametre, que new T() appelle.
public static List<T> Preparer<T>(int nombre) where T : new()
{
var elements = new List<T>(nombre);
for (var i = 0; i < nombre; i++)
{
elements.Add(new T());
}
return elements;
}
// notnull : Dictionary<TKey, TValue> l'exige de ses cles ; sans elle, le
// compilateur avertit (CS8714) des la declaration du dictionnaire.
public static Dictionary<TCle, int> Compter<TCle>(IEnumerable<TCle> cles) where TCle : notnull
{
var nombres = new Dictionary<TCle, int>();
foreach (var cle in cles)
{
nombres[cle] = nombres.GetValueOrDefault(cle) + 1;
}
return nombres;
}
// unmanaged : aucune reference, a aucune profondeur ; stackalloc devient possible.
public static int Octets<T>() where T : unmanaged
{
Span<T> tampon = stackalloc T[4];
return System.Runtime.InteropServices.MemoryMarshal.AsBytes(tampon).Length;
}
// Maths generiques : INumber<T> fournit T.Zero et l'operateur +.
public static T Somme<T>(IEnumerable<T> valeurs) where T : INumber<T>
{
var total = T.Zero;
foreach (var valeur in valeurs)
{
total += valeur;
}
return total;
}
public static void Executer()
{
var clients = IndexerParId([new Client { Id = 7, Nom = "Durand" }]);
Console.WriteLine(clients[7].Nom); // Durand
int[] vide = [];
Console.WriteLine(Premier(vide)); // 0 : absent ou zero ?
Console.WriteLine(Lire<int>("abc") is null); // True : l'absence se voit
Console.WriteLine(PremierOuNull(new[] { "a" })); // a
Console.WriteLine(Preparer<Client>(3).Count); // 3
Console.WriteLine(Compter(["CB", "CB", "Virement"])["CB"]); // 2
Console.WriteLine(Octets<Mesure>()); // 32 : quatre double
Console.WriteLine(Somme([1.5m, 2.25m]) == 3.75m); // True
// PremierOuNull(vide); // CS0452 : int n'est pas un type reference
// Lire<string>("abc"); // CS0453 : string n'est pas un type valeur
// Preparer<string>(3); // CS0310 : string n'a pas de constructeur sans parametre
// Compter(new string?[] { "CB", null }); // CS8714, avertissement : cle nullable
// Octets<Client>(); // CS8377 : Client est une classe
// Somme(["a", "b"]); // CS0311 : string n'implemente pas INumber<string>
}
} Une interface à membres statiques abstraits, depuis C# 11, contraint aussi les opérateurs et les méthodes statiques : c'est ce qui permet à Somme d'écrire T.Zero et += pour tout type numérique de .NET 7 et au-delà, et à Lire d'appeler T.TryParse.
allows ref struct, arrivé avec C# 13 et .NET 9, est une anti-contrainte : au lieu de restreindre les arguments admis, elle en ajoute — les ref struct comme Span<T>, que tout paramètre de type refusait jusque-là. En échange, le corps traite T comme une ref struct possible : ni boxing, donc ni conversion vers object ou vers une interface, ni champ hors d'une ref struct, ni capture par un lambda. C# 13 permet dans le même temps à une ref struct d'implémenter une interface, sans pouvoir s'y convertir, puisque ce serait un boxing : c'est par un paramètre de type ainsi autorisé que le code générique l'atteint. .NET 9 a posé l'anti-contrainte sur les paramètres de Func et d'Action.
using System;
interface IMesurable
{
int Longueur { get; }
}
// Depuis C# 13, une ref struct peut implementer une interface...
ref struct Fenetre : IMesurable
{
private readonly ReadOnlySpan<char> _texte;
public Fenetre(ReadOnlySpan<char> texte) => _texte = texte;
public int Longueur => _texte.Length;
}
static class AntiContrainte
{
// ... et servir d'argument de type, si le parametre l'autorise.
static int Mesurer<T>(T valeur) where T : IMesurable, allows ref struct => valeur.Longueur;
static int MesurerSansAnti<T>(T valeur) where T : IMesurable => valeur.Longueur;
public static void Executer()
{
var fenetre = new Fenetre("C-2025-001 livree".AsSpan(0, 10));
Console.WriteLine(Mesurer(fenetre)); // 10
// MesurerSansAnti(fenetre); // CS9244 : Fenetre est une ref struct
// IMesurable mesurable = fenetre; // CS0029 : ce serait un boxing
// .NET 9 a pose l'anti-contrainte sur les parametres de Func et Action :
// un delegue peut recevoir un ReadOnlySpan<char>.
Func<ReadOnlySpan<char>, int> chiffres = texte =>
{
var nombre = 0;
foreach (var caractere in texte)
{
if (char.IsAsciiDigit(caractere))
{
nombre++;
}
}
return nombre;
};
Console.WriteLine(chiffres("C-2025-001")); // 7
}
}Variance : in, out, et pourquoi List<T> n'en a pas
Une string est un object ; une List<string> n'est pas une List<object>. Si elle l'était, on ajouterait un int à une liste de chaînes à travers la variable List<object>. La relation entre arguments de type ne passe au type construit que lorsqu'il le permet sans risque et le déclare : c'est la variance, qui se déclare sur les paramètres de type des interfaces et des délégués génériques, jamais sur ceux d'une classe ou d'une struct.
out T déclare la covariance : T n'apparaît qu'en sortie. IEnumerable<out T> ne fait que rendre des T, une séquence de string est donc une séquence d'object. in T déclare la contravariance : T n'apparaît qu'en entrée. IComparer<in T> ne fait que recevoir des T, un comparateur d'object compare donc aussi des string. Le compilateur vérifie la déclaration : un T covariant placé en paramètre donne CS1961.
List<T> est invariant pour deux raisons, dont une suffirait : c'est une classe, et T y circule dans les deux sens, en entrée d'Add et en sortie de l'indexeur. Ses interfaces en lecture, IEnumerable<T> et IReadOnlyList<T>, sont covariantes : c'est par elles qu'une List<string> passe là où l'on attend des object. La variance ne vaut que pour les types référence : une conversion variante réinterprète la même référence sans rien changer à sa représentation, et un int devrait être boxé pour devenir un object.
Les tableaux montrent ce que l'invariance évite. Ils sont covariants sans restriction, en écriture comme en lecture : le compilateur accepte de traiter un string[] comme un object[], et c'est le runtime qui refuse l'écriture fautive : il doit pour cela vérifier le type de ce qu'on écrit dans un tableau de références.
using System;
static class Tableaux
{
public static void Executer()
{
string[] noms = ["Durand", "Martin"];
// Les tableaux sont covariants, y compris en ecriture : la conversion
// compile sans avertissement.
object[] objets = noms;
try
{
// Un int dans un tableau de string : le runtime verifie chaque
// ecriture dans un tableau de references, et refuse celle-ci.
objets[0] = 42;
}
catch (ArrayTypeMismatchException ex)
{
Console.WriteLine(ex.Message);
// Attempted to access an element as a type incompatible with the array.
}
}
}Une interface covariante en lecture seule exprime la même intention, traiter des chaînes comme des objets, sans laisser aucune écriture possible : l'erreur remonte à la compilation.
using System;
using System.Collections.Generic;
// out T : T n'apparait qu'en sortie. Le compilateur le verifie.
interface ISource<out T>
{
T Lire();
// void Ecrire(T valeur); // CS1961 : T covariant en position d'entree
}
static class Variance
{
public static void Executer()
{
List<string> noms = ["Lemaire", "Martin"];
// IReadOnlyList<out T> ne fait que rendre des T : des string sont des object.
IReadOnlyList<object> lecture = noms;
Console.WriteLine(lecture[0]); // Lemaire
// List<object> liste = noms; // CS0029 : classe, et T en entree d'Add
// lecture[0] = 42; // CS0200 : l'indexeur est en lecture seule
// Variance des types reference seulement : un int devrait etre boxe.
List<int> quantites = [1, 2];
// IEnumerable<object> nombres = quantites; // CS0266
// Le cast (IEnumerable<object>)quantites compilerait, et leverait
// InvalidCastException a l'execution.
// in T : IComparer<in T> ne fait que recevoir des T. Un comparateur
// d'object compare aussi des string.
IComparer<object> parLongueur = Comparer<object>.Create(
(a, b) => $"{a}".Length.CompareTo($"{b}".Length));
IComparer<string> pourDesNoms = parLongueur;
noms.Add("Li");
noms.Sort(pourDesNoms);
Console.WriteLine(string.Join(", ", noms)); // Li, Martin, Lemaire
// Les delegues aussi : Func<out TResult>, Action<in T>.
Func<string> fabrique = () => "C-001";
Func<object> generale = fabrique;
Action<object> journaliser = o => Console.WriteLine($"log {o}");
Action<string> journaliserTexte = journaliser;
journaliserTexte($"{generale()} {quantites.Count}"); // log C-001 2
}
}Délégués, Func, Action et lambdas
Un délégué est un objet qui désigne une méthode et, pour une méthode d'instance, l'objet sur lequel l'appeler. Son type fixe une signature. On le déclare avec delegate, on l'obtient à partir d'un nom de méthode ou d'un lambda, et on l'appelle comme une méthode. Les types délégués sont nominaux : deux types de même signature ne se convertissent pas l'un dans l'autre.
Func et Action couvrent presque tous les besoins sans rien déclarer : Func<T1, …, TResult> rend une valeur, Action n'en rend pas, de zéro à seize paramètres. Un type délégué nommé garde son intérêt quand le nom documente l'intention, ou pour un paramètre ref, out ou in, que Func ne sait pas exprimer. Un délégué est multicast : += compose une liste d'invocation, appelée dans l'ordre. Le cours Collections et LINQ montre le même lambda converti tantôt en délégué, tantôt en arbre d'expression.
using System;
// Un type delegue : une signature, a laquelle on donne un nom.
delegate int Remise(int montant);
static class Delegues
{
static int Soldes(int montant) => montant * 80 / 100;
public static void Executer()
{
// Un groupe de methodes se convertit en delegue.
Remise remise = Soldes;
Console.WriteLine(remise(100)); // 80
// Les types delegues sont nominaux : meme signature, types distincts.
// Func<int, int> copie = remise; // CS0029
Func<int, int> fonction = remise.Invoke; // un nouveau delegue, qui appelle remise
// Depuis C# 10, un lambda a un type naturel : ici, Func<int, int>.
var doubler = (int x) => x * 2;
Console.WriteLine(doubler.GetType().Name); // Func`2
// Action ne rend rien. += compose une liste d'invocation, appelee dans l'ordre.
Action<string> journal = texte => Console.WriteLine($"console : {texte}");
journal += texte => Console.WriteLine($"fichier : {texte}");
journal("demarrage");
// console : demarrage
// fichier : demarrage
// Un delegue multicast qui rend une valeur ne rend que celle du dernier.
fonction += doubler;
Console.WriteLine(fonction(100)); // 200 : Soldes a tourne, son 80 est perdu
}
}Ce qu'un lambda capture
Un lambda qui utilise une variable locale de la méthode qui l'entoure la capture : il ne copie pas sa valeur, il partage la variable. Le compilateur la déplace pour cela dans un champ d'une classe générée, la fermeture, allouée sur le tas. La variable vit alors aussi longtemps que le délégué, et chaque écriture se voit des deux côtés. Le modificateur static, depuis C# 9, interdit toute capture.
using System;
static class Capture
{
public static void Executer()
{
var appels = 0;
// Le lambda capture la variable, pas sa valeur : il partage la case.
Action compter = () => appels++;
compter();
compter();
Console.WriteLine(appels); // 2
// L'ecriture inverse se voit aussi.
Func<int> lire = () => appels;
appels = 10;
Console.WriteLine(lire()); // 10
// static interdit toute capture : l'intention devient verifiee.
// Func<int> figee = static () => appels; // CS8820
Func<int, int> carre = static x => x * x;
Console.WriteLine(carre(4)); // 16
}
} Le piège classique en découle. Une boucle for déclare sa variable une seule fois, avant le premier tour : la spécification la tient pour déclarée hors de la boucle. Tous les lambdas créés dans le corps capturent donc la même, et la lisent quand on les appelle, c'est-à-dire après la boucle :
using System;
using System.Collections.Generic;
static class Relances
{
public static void Executer()
{
var rappels = new List<Action>();
// for declare i une seule fois, avant le premier tour : les trois
// lambdas capturent la meme variable.
for (var i = 0; i < 3; i++)
{
rappels.Add(() => Console.Write($"relance {i} ; "));
}
// Elles la lisent quand on les appelle, apres la boucle : i vaut 3.
foreach (var rappel in rappels)
{
rappel();
}
// relance 3 ; relance 3 ; relance 3 ;
}
} Une variable déclarée dans le corps est neuve à chaque tour, et chaque lambda capture la sienne. C'est ce que fait foreach depuis C# 5 : jusque-là, sa variable d'itération était logiquement hors de la boucle, et le même code affichait le dernier élément à chaque appel. Eric Lippert, dans la mise à jour de son billet de 2009 consacré à ce piège, annonçait le changement et précisait que for ne changerait pas.
using System;
using System.Collections.Generic;
static class Relances
{
public static void Executer()
{
var rappels = new List<Action>();
for (var i = 0; i < 3; i++)
{
// Une variable declaree dans le corps est neuve a chaque tour :
// chaque lambda capture la sienne.
var rang = i;
rappels.Add(() => Console.Write($"relance {rang} ; "));
}
// Depuis C# 5, la variable d'un foreach est elle aussi neuve a chaque tour.
foreach (var client in new[] { "Durand", "Martin" })
{
rappels.Add(() => Console.Write($"{client} ; "));
}
foreach (var rappel in rappels)
{
rappel();
}
// relance 0 ; relance 1 ; relance 2 ; Durand ; Martin ;
}
}La même capture explique qu'une requête LINQ différée voie les valeurs du moment où on la parcourt, comme le montre le cours Collections et LINQ.
Events
Un event est un délégué dont la classe qui le déclare garde le contrôle. Un champ public de type délégué, lui, laisse n'importe quel code l'affecter avec =, ce qui efface les autres abonnés, ou l'invoquer, ce qui annonce un changement qui n'a pas eu lieu :
using System;
sealed class Commande
{
// Un champ public de type delegue : tout code peut l'affecter et l'appeler.
public Action<string>? StatutChange;
public void Expedier() => StatutChange?.Invoke("expediee");
}
static class Abonnes
{
public static void Executer()
{
var commande = new Commande();
commande.StatutChange += statut => Console.WriteLine($"mail : {statut}");
commande.StatutChange += statut => Console.WriteLine($"stock : {statut}");
// Un abonne ecrit = au lieu de += : les deux autres disparaissent sans bruit.
commande.StatutChange = statut => Console.WriteLine($"audit : {statut}");
// Et un code etranger annonce une livraison que la commande n'a pas faite.
commande.StatutChange("livree"); // audit : livree
commande.Expedier(); // audit : expediee
}
} Le mot-clé event change ce que le code extérieur a le droit d'écrire : seuls += et -= compilent hors de la classe, CS0070 refuse le reste. Le compilateur génère un champ privé et deux accesseurs, add et remove.
using System;
sealed class StatutChangeEventArgs(string statut) : EventArgs
{
public string Statut { get; } = statut;
}
sealed class Commande
{
// event : hors de la classe, seuls += et -= compilent.
public event EventHandler<StatutChangeEventArgs>? StatutChange;
public void Expedier() => OnStatutChange(new StatutChangeEventArgs("expediee"));
// Classe scellee : methode privee. Non scellee, elle serait protected virtual,
// pour que les derives puissent reagir ou declencher.
private void OnStatutChange(StatutChangeEventArgs e) => StatutChange?.Invoke(this, e);
}
static class Abonnes
{
public static void Executer()
{
var commande = new Commande();
commande.StatutChange += (sender, e) => Console.WriteLine($"mail : {e.Statut}");
commande.StatutChange += (sender, e) => Console.WriteLine($"stock : {e.Statut}");
// commande.StatutChange = null; // CS0070
// commande.StatutChange(commande, new StatutChangeEventArgs("livree")); // CS0070
commande.Expedier();
// mail : expediee
// stock : expediee
}
} La convention de .NET fixe la signature : EventHandler<TEventArgs>, un émetteur object? et un argument qui porte les données. La contrainte where TEventArgs : EventArgs, présente jusqu'à .NET Framework 4.0, a été retirée en 4.5, mais dériver d'EventArgs reste l'usage ; en .NET 10, TEventArgs est même contravariant. Une classe non scellée déclenche par une méthode protected virtual, point d'extension au sens du cours Programmation orientée objet, que ses dérivés redéfinissent pour réagir avant ou après les abonnés.
Se désabonner, ou fuir
S'abonner, c'est confier au publieur un délégué dont la cible est l'abonné. Tant que le publieur vit, l'abonné reste joignable depuis lui, et le ramasse-miettes ne le récupère pas, même quand plus rien d'autre ne le référence. Le cas typique réunit un publieur de longue durée et des abonnés éphémères qui ne se désabonnent jamais :
using System;
using System.Runtime.CompilerServices;
// Vit aussi longtemps que l'application : un singleton, une horloge, un bus.
sealed class Horloge
{
public event EventHandler? Tic;
public int Abonnes => Tic?.GetInvocationList().Length ?? 0;
}
// Ephemere : une vue, un composant, un objet par requete.
sealed class Tableau
{
public Tableau(Horloge horloge) => horloge.Tic += Rafraichir;
private void Rafraichir(object? sender, EventArgs e)
{
}
}
static class Fuite
{
[MethodImpl(MethodImplOptions.NoInlining)]
static WeakReference OuvrirPuisOublier(Horloge horloge) => new(new Tableau(horloge));
public static void Executer()
{
var horloge = new Horloge();
var tableau = OuvrirPuisOublier(horloge);
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
// Plus personne ne reference le tableau, sauf le delegue que tient l'horloge.
Console.WriteLine(tableau.IsAlive); // True
Console.WriteLine(horloge.Abonnes); // 1
}
} Le remède est de se désabonner quand l'abonné finit sa vie ; IDisposable donne un nom à ce moment, et using le garantit. Un abonnement par lambda se garde dans une variable : écrit une seconde fois, le même lambda est une autre méthode, et -= ne retire rien sans rien signaler. Le sens compte : l'abonnement fait retenir l'abonné par le publieur, pas l'inverse.
using System;
using System.Runtime.CompilerServices;
sealed class Horloge
{
public event EventHandler? Tic;
public int Abonnes => Tic?.GetInvocationList().Length ?? 0;
}
sealed class Tableau : IDisposable
{
private readonly Horloge _horloge;
public Tableau(Horloge horloge)
{
_horloge = horloge;
_horloge.Tic += Rafraichir;
}
// -= retire un delegue egal : meme methode, meme cible. Le groupe de
// methodes cree un delegue neuf, mais egal a celui du +=.
public void Dispose() => _horloge.Tic -= Rafraichir;
private void Rafraichir(object? sender, EventArgs e)
{
}
}
static class Fuite
{
[MethodImpl(MethodImplOptions.NoInlining)]
static WeakReference OuvrirPuisFermer(Horloge horloge)
{
using var tableau = new Tableau(horloge);
return new WeakReference(tableau);
}
public static void Executer()
{
var horloge = new Horloge();
var tableau = OuvrirPuisFermer(horloge);
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
Console.WriteLine(tableau.IsAlive); // False
Console.WriteLine(horloge.Abonnes); // 0
// Avec un lambda, garder le delegue : un second lambda identique est
// une autre methode, et -= ne retirerait rien, sans rien signaler.
EventHandler journal = (sender, e) => Console.WriteLine("tic");
horloge.Tic += journal;
horloge.Tic -= (sender, e) => Console.WriteLine("tic");
Console.WriteLine(horloge.Abonnes); // 1
horloge.Tic -= journal;
Console.WriteLine(horloge.Abonnes); // 0
}
}Déclencher depuis plusieurs threads
Les accesseurs add et remove générés sont sûrs entre threads : la spécification l'exige, et le compilateur du SDK .NET 10 les écrit avec une boucle sur Interlocked.CompareExchange. Le déclenchement, lui, est à la charge de la classe. Le test suivi de l'appel lit le champ deux fois, et un autre thread peut retirer le dernier abonné entre les deux :
using System;
using System.Threading;
sealed class Capteur
{
public event EventHandler<int>? Mesure;
public void Publier(int valeur)
{
// Deux lectures du champ : entre le test et l'appel, un autre thread
// peut retirer le dernier abonne, et l'appel porte alors sur null.
if (Mesure != null)
{
Mesure(this, valeur);
}
}
}
static class Course
{
public static void Executer()
{
var capteur = new Capteur();
EventHandler<int> abonne = (sender, valeur) => { };
var fini = false;
var autre = new Thread(() =>
{
while (!Volatile.Read(ref fini))
{
capteur.Mesure += abonne;
capteur.Mesure -= abonne;
}
});
autre.Start();
var echecs = 0;
for (var i = 0; i < 20_000_000; i++)
{
try
{
capteur.Publier(i);
}
catch (NullReferenceException)
{
echecs++;
}
}
Volatile.Write(ref fini, true);
autre.Join();
// Le nombre varie d'une execution a l'autre. Build Debug (dotnet run) :
// entre 24 000 et 106 000 en quatre executions (.NET 10, x64). Release : 0 sur la meme
// machine, le JIT optimisant pouvant fusionner les deux lectures, ce que
// le modele memoire lui permet sans l'y obliger.
Console.WriteLine(echecs);
}
}?.Invoke lit le champ une seule fois, et le modèle mémoire de .NET interdit au JIT d'introduire une lecture : la copie lue est celle qui sera appelée. Ce même modèle lui permet, en sens inverse, de fusionner deux lectures adjacentes, ce qui explique le zéro du code faux en Release : une chance, pas une garantie. La copie a une conséquence à accepter : un abonné qui vient de se désabonner sur un autre thread peut encore être appelé une fois. Et un abonné qui lève interrompt la liste ; quand les abonnés sont indépendants, GetInvocationList permet de les appeler un à un.
using System;
using System.Collections.Generic;
sealed class Capteur
{
public event EventHandler<int>? Mesure;
// Une seule lecture du champ, dans une variable temporaire. Un delegue est
// immuable (+= et -= en construisent un autre) : la copie reste valable.
public void Publier(int valeur) => Mesure?.Invoke(this, valeur);
// Des abonnes independants : chacun appele a part, les erreurs recueillies.
public List<Exception> PublierIsole(int valeur)
{
var erreurs = new List<Exception>();
var abonnes = Mesure;
if (abonnes is null)
{
return erreurs;
}
foreach (var abonne in abonnes.GetInvocationList())
{
try
{
((EventHandler<int>)abonne)(this, valeur);
}
catch (Exception ex)
{
erreurs.Add(ex);
}
}
return erreurs;
}
}
static class Isolement
{
public static void Executer()
{
var capteur = new Capteur();
capteur.Mesure += (sender, valeur) => throw new InvalidOperationException("seuil");
capteur.Mesure += (sender, valeur) => Console.WriteLine($"graphique : {valeur}");
try
{
capteur.Publier(42);
}
catch (InvalidOperationException)
{
// Le premier abonne a leve : le second n'a pas ete appele.
Console.WriteLine("liste interrompue");
}
var erreurs = capteur.PublierIsole(43); // graphique : 43
Console.WriteLine(erreurs.Count); // 1
}
} Protéger l'état que les abonnés partagent relève des outils du cours Multithreading et concurrence : lock, Interlocked, collections concurrentes.