Décompiler using : le try/finally que le compilateur écrit pour vous
Une instruction using n'a pas d'IL propre — le compilateur l'abaisse en un try/finally qui appelle Dispose, et await using en un try/finally qui attend DisposeAsync. Voici exactement à quoi ressemble ce code généré, les subtilités du null-check et de l'énumérateur struct, et comment un décompilateur le replie en using.
Certaines instructions C# correspondent à une seule instruction IL. using correspond à une forme de flux de contrôle — un try/finally — que le compilateur écrit en votre nom pour qu”une ressource soit toujours nettoyée, même quand le corps lève. Il n”y a pas d”opcode using ; ce qui est livré est le try/finally, et le travail d”un décompilateur est de reconnaître cette forme et de la replier dans le seul mot-clé que vous avez écrit. Parcourir l”abaissement explique à la fois ce que vous voyez dans un décompilé brut et deux comportements (await using, énumérateurs struct, le null-check) qui surprennent.
L’abaissement de base
Prenez le cas canonique :
using (var stream = File.OpenRead(path))
{
Process(stream);
}
Le compilateur abaisse ceci en un try/finally dont le finally libère la ressource. Décompilé sans reconnaissance de using, cela se lit à peu près :
FileStream stream = File.OpenRead(path);
try
{
Process(stream);
}
finally
{
if (stream != null)
((IDisposable)stream).Dispose();
}
Trois choses à remarquer. La ressource est acquise avant le try, donc un échec dans l”expression d”acquisition n”entre pas dans la région protégée. Le corps va dans le try. Et le finally libère — inconditionnellement par rapport à la façon dont le corps est sorti (normal, return ou exception), ce qui est tout l”intérêt de using : un nettoyage déterministe. La libération est castée en IDisposable car c”est le contrat que using vise, et elle est gardée par un null-check pour qu”une ressource nulle soit un no-op inoffensif plutôt qu”une NullReferenceException dans le chemin de nettoyage.
Une déclaration using — la forme sans accolades ajoutée en C# 8 — s”abaisse à l”identique ; le finally s”exécute simplement à la fin de la portée englobante plutôt qu”un bloc que vous avez écrit :
using var stream = File.OpenRead(path);
Process(stream);
// finally { if (stream != null) stream.Dispose(); } runs at end of scope
La même forme générée ; le décompilateur décide entre la forme de bloc et celle de déclaration selon où se termine la portée.
Le null-check, et l’exception du struct
Cette garde if (stream != null) est un indice fiable, mais elle n”est pas toujours présente — et son absence est informative. Quand la ressource est un type valeur qui implémente IDisposable, le compilateur fait deux choses différemment : il appelle Dispose() directement sur le struct (pas de null-check — un struct n”est jamais nul) et évite de le boxer en IDisposable, appelant la méthode d”interface sur la valeur directement pour préserver la sémantique du struct. Cela compte pour le using caché le plus courant de tous : foreach.
Un foreach sur une collection dont l”énumérateur est un struct (comme List<T>.Enumerator) s”abaisse en un try/finally en forme de using autour de l”énumération, et comme l”énumérateur est un struct, le finally appelle Dispose() dessus sans null-check :
// foreach (var item in list) { Use(item); } lowers to:
List<int>.Enumerator e = list.GetEnumerator();
try
{
while (e.MoveNext())
Use(e.Current);
}
finally
{
e.Dispose(); // struct enumerator — direct call, no null check, no boxing
}
Donc quand un décompilateur vous montre un try/finally avec une boucle MoveNext/Current et un Dispose non gardé sur une valeur, c”est un foreach, et le null-check absent vous dit que l”énumérateur était un struct. (Si l”énumérateur était une classe, vous verriez le familier if (e != null) e.Dispose().)
await using et IAsyncDisposable
Le frère async, await using, s”abaisse au même try/finally — mais le finally attend DisposeAsync() au lieu d”appeler Dispose() :
// await using (var conn = await OpenAsync()) { await Use(conn); } lowers to (conceptually):
var conn = await OpenAsync();
try
{
await Use(conn);
}
finally
{
if (conn != null)
await conn.DisposeAsync(); // a ValueTask, awaited in the finally
}
Parce qu”il y a un await dans le finally, la libération devient partie de la machine à états async de la méthode — l”appel à DisposeAsync() produit un ValueTask sur lequel le MoveNext généré se gare comme tout autre await. Dans un décompilé brut cela ressemble à un point de reprise de machine à états dans la région fault/finally, ce qui est exactement comment un décompilateur l”identifie : un DisposeAsync attendu dans un finally est la signature d”await using, et il est reconstruit comme tel. La même règle de null-check struct-vs-classe s”applique.
Comment le décompilateur le replie
Reconnaître using est de la reconnaissance de motif sur la forme générée, et un bon décompilateur y fait attention car tout try/finally n”est pas un using. Les marqueurs qu”il cherche :
- Un
finallydont le seul effet est un appel àDispose()/DisposeAsync()sur une ressource acquise immédiatement avant letry. Cette coïncidence « acquérir, try, finally-libérer » est l”empreinte. - La garde null (ou son absence en forme de struct) autour du Dispose, correspondant à la génération de code du compilateur lui-même — un
try/finally { x.Dispose(); }écrit à la main qui ne correspond pas à la forme gardée exacte est laissé comme un try/finally explicite plutôt que mal replié enusing. - Pour
foreach, la structure supplémentaireGetEnumerator/MoveNext/Currentdans letry, qui lui permet de reconstruireforeachplutôt qu”unusingnu sur un énumérateur.
Quand la forme correspond exactement, le décompilateur réduit cinq ou six lignes de try/finally au seul using (ou foreach, ou await using) que vous avez écrit. Quand ce n”est pas le cas — un finally qui fait plus que libérer, ou une libération qui n”est pas gardée comme le compilateur la garde — il montre honnêtement le try/finally, car transformer un bloc de nettoyage arbitraire en using déformerait le code.
Pourquoi cela vaut la peine de le savoir
Le using replié est ce que vous voulez lire, mais le try/finally en dessous explique un comportement réel. C”est pourquoi using garantit le nettoyage lors d”une exception — la libération est dans un finally, pas après le corps. C”est pourquoi libérer une ressource null ne lève pas — le compilateur la garde. C”est pourquoi un foreach sur un List<T> a zéro surcharge de libération que vous puissiez voir comme du boxing — l”énumérateur struct est libéré directement. Et quand vous lisez l”assemblage de quelqu”un d”autre sans symboles et voyez un try avec un seul Dispose gardé dans le finally, vous pouvez le relire directement comme un using même si le décompilateur a choisi de montrer la forme brute. Glass.NET replie ces motifs par défaut et vous laisse descendre au try/finally brut quand vous voulez confirmer exactement comment le nettoyage a été généré — c”est la vue que vous voulez dès qu”un Dispose ou DisposeAsync fait quelque chose de surprenant.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.