Décompiler les événements C# : la boucle CompareExchange derrière += et -=
Une ligne de C# — public event EventHandler Changed; — devient quatre choses dans l'assembly : un champ délégué privé, un accesseur add_Changed et un accesseur remove_Changed, et une ligne de la table Event qui les relie via MethodSemantics. Les accesseurs ne sont pas non plus de simples affectations : depuis C# 4, le compilateur émet une boucle Interlocked.CompareExchange sans verrou autour de Delegate.Combine et Delegate.Remove, et les binaires plus anciens portent à la place une variante [MethodImpl(Synchronized)]. Un décompilateur doit reconnaître les deux formes pour replier les quatre pièces en une seule déclaration, conserver un add/remove explicite lorsque l'auteur en a écrit un, et restituer le site de levée ?.Invoke plutôt que la danse dup/brtrue en laquelle il se compile. Voici les métadonnées, l'IL, et ce qu'en fait Glass.NET.
public event EventHandler Changed; est l’une des déclarations les plus courtes de C#, et l’une des plus fortement désucrées. Le CLR n’a aucune notion d’event en tant qu’emplacement de stockage. Ce qu’il a, c’est une ligne de métadonnées Event qui nomme un type délégué et pointe vers deux méthodes, et une convention que le compilateur C# complète autour d’elle : un champ caché pour contenir le délégué, un accesseur qui ajoute un gestionnaire, et un accesseur qui en retire un. Les corps des accesseurs sont là où cela devient intéressant, parce que le compilateur a changé d’avis sur la façon de les écrire, et les deux versions circulent dans la nature.
Un décompilateur doit replier quatre entités de métadonnées en la seule ligne que vous avez écrite, et il ne peut le faire que s’il reconnaît les corps des accesseurs comme étant ceux du compilateur plutôt que les vôtres. Trompez-vous et la sortie vous montre soit un champ plus deux méthodes avec une étrange boucle dedans, soit — pire — réduit une paire add/remove écrite à la main qui fait quelque chose d’important en une ligne qui le cache.
Quatre éléments de métadonnées
Compilez ceci :
public sealed class Counter
{
public event EventHandler Changed;
}
et le module contient ce qui suit, que ildasm affiche sous une forme qui laisse déjà deviner la structure :
.field private class [System.Runtime]System.EventHandler Changed
.custom instance void [System.Runtime]System.Runtime.CompilerServices
.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 )
.method public hidebysig specialname instance void
add_Changed(class [System.Runtime]System.EventHandler 'value') cil managed { ... }
.method public hidebysig specialname instance void
remove_Changed(class [System.Runtime]System.EventHandler 'value') cil managed { ... }
.event [System.Runtime]System.EventHandler Changed
{
.addon instance void Counter::add_Changed(class [System.Runtime]System.EventHandler)
.removeon instance void Counter::remove_Changed(class [System.Runtime]System.EventHandler)
}
Dans les tables de métadonnées, c’est une ligne dans Event (0x14) contenant le nom et le TypeDefOrRef du délégué, une ligne dans EventMap (0x12) reliant le type propriétaire à sa plage d’événements, et deux lignes dans MethodSemantics (0x18) dont les indicateurs Semantics sont AddOn (0x0008) et RemoveOn (0x0010), chacune pointant vers une méthode accesseur. Le champ est une ligne Field ordinaire — rien dans les métadonnées ne le rattache à l’événement, hormis son nom et le fait que les accesseurs l’utilisent.
Le corps de l’accesseur depuis C# 4 : une boucle sans verrou
Voici le add_Changed complet tel que le compilateur actuel l’émet :
.method public hidebysig specialname instance void
add_Changed(class [System.Runtime]System.EventHandler 'value') cil managed
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices
.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 )
.maxstack 3
.locals init (class [System.Runtime]System.EventHandler V_0, // current
class [System.Runtime]System.EventHandler V_1, // snapshot
class [System.Runtime]System.EventHandler V_2) // combined
IL_0000: ldarg.0
IL_0001: ldfld class [System.Runtime]System.EventHandler Counter::Changed
IL_0006: stloc.0 // current = this.Changed
IL_0007: ldloc.0
IL_0008: stloc.1 // snapshot = current
IL_0009: ldloc.1
IL_000a: ldarg.1
IL_000b: call class [System.Runtime]System.Delegate
[System.Runtime]System.Delegate::Combine(class Delegate, class Delegate)
IL_0010: castclass [System.Runtime]System.EventHandler
IL_0015: stloc.2 // combined = snapshot + value
IL_0016: ldarg.0
IL_0017: ldflda class [System.Runtime]System.EventHandler Counter::Changed
IL_001c: ldloc.2
IL_001d: ldloc.1
IL_001e: call !!0 [System.Threading]System.Threading.Interlocked::CompareExchange
<class [System.Runtime]System.EventHandler>(!!0&, !!0, !!0)
IL_0023: stloc.0 // current = CAS(ref field, combined, snapshot)
IL_0024: ldloc.0
IL_0025: ldloc.1
IL_0026: bne.un.s IL_0007 // if field had changed, retry
IL_0028: ret
}
Lisez-le comme le C# qu’il représente :
void add_Changed(EventHandler value)
{
EventHandler current = Changed, snapshot;
do
{
snapshot = current;
var combined = (EventHandler)Delegate.Combine(snapshot, value);
current = Interlocked.CompareExchange(ref Changed, combined, snapshot);
}
while (current != snapshot);
}
La boucle existe parce que les délégués sont immuables : Combine renvoie un nouveau délégué multicast, et si deux threads s’abonnent au même instant, une séquence naïve lire-combiner-écrire en perd un. CompareExchange publie le nouveau délégué uniquement si le champ contient encore l’instantané à partir duquel il a été calculé ; sinon, il renvoie la valeur réelle du champ et la boucle recalcule à partir de celle-ci. remove_Changed est identique octet pour octet, avec Delegate::Remove à la place de Combine.
Notez l’instanciation générique CompareExchange<EventHandler>(!!0&, !!0, !!0) — les jetons !!0 sont le paramètre générique propre à la méthode. Un décompilateur qui reconnaît ce corps par motif doit se baser sur la forme (ldfld, boucle, Combine/Remove, castclass, ldflda, CompareExchange générique, bne.un retournant au stockage de l’instantané) plutôt que sur des jetons précis, puisque le type délégué, le champ, et le maxstack/l’ordre des locales de la méthode varient tous.
Les événements statiques utilisent la même boucle avec ldsfld/ldsflda et sans ldarg.0.
L’ancienne forme : [MethodImpl(Synchronized)]
Les assemblies compilés avec les compilateurs C# 1 à 3 — encore nombreux dans les bases de code anciennes et les références tierces — portent un accesseur différent :
.method public hidebysig specialname instance void
add_Changed(class [mscorlib]System.EventHandler 'value') cil managed synchronized
{
.maxstack 8
IL_0000: ldarg.0
IL_0001: ldarg.0
IL_0002: ldfld class [mscorlib]System.EventHandler Counter::Changed
IL_0007: ldarg.1
IL_0008: call class [mscorlib]System.Delegate
[mscorlib]System.Delegate::Combine(class Delegate, class Delegate)
IL_000d: castclass [mscorlib]System.EventHandler
IL_0012: stfld class [mscorlib]System.EventHandler Counter::Changed
IL_0017: ret
}
L’indicateur synchronized dans l’en-tête de la méthode (MethodImplOptions.Synchronized) fait prendre au runtime un verrou sur this — ou sur l’objet type, pour les événements statiques — autour de tout le corps, de sorte que le simple ldfld/Combine/stfld est sûr. Il a été remplacé parce que verrouiller sur this est un verrou public que n’importe qui peut disputer, et parce que le verrou est bien plus coûteux qu’un CAS qui ne réessaie presque jamais.
Les deux corps signifient exactement la même chose : ceci est l’événement de type champ du compilateur. Un décompilateur qui ne reconnaît que la boucle moderne montrera l’événement de l’assembly plus ancien comme un champ, deux méthodes et un mystérieux indicateur synchronized.
Lever l’événement : les formes de ?.Invoke
Le côté abonnement n’est que la moitié de l’histoire. L’auteur lève aussi l’événement, et C# a connu trois idiomes, chacun avec son propre IL. Le moderne :
Changed?.Invoke(this, EventArgs.Empty);
ldarg.0
ldfld class [System.Runtime]System.EventHandler Counter::Changed
dup
brtrue.s INVOKE
pop // null: discard the duplicate and skip
br.s END
INVOKE:
ldarg.0
ldsfld class [System.Runtime]System.EventArgs [System.Runtime]System.EventArgs::Empty
callvirt instance void [System.Runtime]System.EventHandler::Invoke(object, class EventArgs)
END:
Le triplet dup/brtrue/pop est l’empreinte de l’opérateur conditionnel null : charger une fois, tester la copie, utiliser l’original. L’idiome antérieur à C# 6 lit le champ dans une variable locale et teste la locale — un stloc/ldloc/brfalse au lieu de dup/brtrue/pop — et est sémantiquement identique. La troisième forme est celle sans aucun test :
Changed(this, EventArgs.Empty); // throws if nobody subscribed
qui est un simple ldfld + callvirt Invoke. Un décompilateur doit restituer chacune de ces formes telle qu’elle a été écrite, parce que la différence est une information : la forme nue dans une dépendance décompilée est une NullReferenceException qui attend un appel sans abonné, et la forme avec copie locale dans une base de code moderne vous dit que le fichier est antérieur à C# 6. Replier les trois en ?.Invoke serait plus propre, et faux.
Lorsque l’auteur a écrit les accesseurs
La règle de repliement a une précondition : les accesseurs doivent être ceux du compilateur. Les classes qui exposent de nombreux événements — les contrôles WinForms en sont le cas classique — évitent d’allouer un champ par événement en conservant les abonnés dans un EventHandlerList indexé par un objet statique :
private static readonly object ChangedKey = new();
public event EventHandler Changed
{
add => Events.AddHandler(ChangedKey, value);
remove => Events.RemoveHandler(ChangedKey, value);
}
Il n’y a pas de champ Changed, pas de [CompilerGenerated] sur les accesseurs, et les corps sont un ldsfld ChangedKey / ldarg.1 / callvirt EventHandlerList::AddHandler. La ligne Event et les lignes MethodSemantics sont identiques au cas de type champ — les métadonnées ne peuvent réellement pas vous dire qui a écrit les accesseurs. Seul le corps le peut. Un décompilateur qui voit autre chose que les deux formes connues doit émettre add { … } / remove { … } tels quels, parce que ce qui s’y trouve — une recherche par clé, un appel de transfert, un enregistrement par référence faible — est un comportement que l’auteur a choisi.
Deux variantes supplémentaires complètent la famille. Une implémentation d’interface explicite d’un événement possède des accesseurs nommés IObservable.add_Changed, avec le .addon de la ligne .event pointant vers eux et une directive .override, et doit être restituée sous la forme event EventHandler IObservable.Changed { add … remove … } — les implémentations explicites ne peuvent pas être de type champ. Un événement abstrait ou d’interface possède une ligne Event et des déclarations d’accesseurs sans aucun corps, et se restitue sous la forme event EventHandler Changed; avec le modificateur abstract — le même texte qu’un événement de type champ, distingué uniquement par l’absence de champ et l’indicateur abstract sur les accesseurs.
Ce que Glass.NET émet
Assemblez les règles de reconnaissance et l’obligation pour un événement de type champ est claire. S’arrêter au niveau du métal donne ceci :
// Wrong: four entities rendered literally
[CompilerGenerated] private EventHandler Changed;
[CompilerGenerated]
public void add_Changed(EventHandler value)
{
EventHandler current = Changed, snapshot;
do { snapshot = current;
current = Interlocked.CompareExchange(ref Changed,
(EventHandler)Delegate.Combine(snapshot, value), snapshot); }
while (current != snapshot);
}
// ... remove_Changed, and an event declaration that references both
tandis que retrouver l’intention de l’auteur donne :
// Right: the line the author wrote
public event EventHandler Changed;
Glass.NET reconnaît à la fois la boucle sans verrou et la forme synchronized comme de type champ, replie le champ, les accesseurs et la ligne Event en une seule déclaration, et conserve les corps add/remove écrits à la main exactement tels qu’écrits — y compris le motif EventHandlerList, les événements d’interface explicites et les événements abstraits. Au site de levée, il préserve lequel des trois idiomes le code utilisait, de sorte qu’un ?.Invoke, une vérification par copie locale ou une invocation nue se relisent chacun comme eux-mêmes. Le mot-clé event a toujours été une fiction de niveau source au-dessus d’une ligne Event et de deux méthodes ; le travail consiste à remettre la fiction en place sans perdre le seul cas où les méthodes n’étaient pas une fiction.
Voir les tables de métadonnées expliquées pour les tables Event, EventMap et MethodSemantics que ceci lit, et lambdas et fermetures pour les instances de délégués qui finissent généralement de l’autre côté de +=.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.