using dekompilieren: das try/finally, das der Compiler für Sie schreibt
Eine using-Anweisung hat kein eigenes IL — der Compiler senkt sie zu einem try/finally ab, das Dispose aufruft, und await using zu einem try/finally, das DisposeAsync awaited. Hier steht genau, wie dieser generierte Code aussieht, die Feinheiten von Null-Prüfung und Struct-Enumerator, und wie ein Decompiler ihn zurück in using klappt.
Manche C#-Anweisungen bilden sich auf eine einzige IL-Instruktion ab. using bildet sich auf eine Kontrollfluss-Form ab — ein try/finally — das der Compiler in Ihrem Namen schreibt, damit eine Ressource immer bereinigt wird, selbst wenn der Körper wirft. Es gibt keinen using-Opcode; was ausgeliefert wird, ist das try/finally, und die Aufgabe eines Decompilers ist es, diese Form zu erkennen und sie in das eine Schlüsselwort zurückzuklappen, das Sie geschrieben haben. Die Absenkung durchzugehen erklärt sowohl, was Sie in einem rohen Dekompilat sehen, als auch ein paar Verhaltensweisen (await using, Struct-Enumeratoren, die Null-Prüfung), die überraschen.
Die grundlegende Absenkung
Nehmen Sie den kanonischen Fall:
using (var stream = File.OpenRead(path))
{
Process(stream);
}
Der Compiler senkt das zu einem try/finally ab, dessen finally die Ressource freigibt. Ohne using-Erkennung dekompiliert, liest es sich ungefähr:
FileStream stream = File.OpenRead(path);
try
{
Process(stream);
}
finally
{
if (stream != null)
((IDisposable)stream).Dispose();
}
Drei Dinge zu beachten. Die Ressource wird vor dem try erworben, also tritt ein Fehler im Erwerbsausdruck nicht in die geschützte Region ein. Der Körper geht in das try. Und das finally gibt frei — unabhängig davon, wie der Körper verlassen wurde (normal, return oder Ausnahme), was genau der Sinn von using ist: deterministische Bereinigung. Die Freigabe wird zu IDisposable gecastet, weil das der Vertrag ist, den using anvisiert, und sie ist durch eine Null-Prüfung abgesichert, damit eine null-Ressource ein harmloses No-op statt einer NullReferenceException im Bereinigungspfad ist.
Eine using-Deklaration — die klammerlose Form, die in C# 8 hinzukam — senkt identisch ab; das finally läuft einfach am Ende des umschließenden Geltungsbereichs statt eines Blocks, den Sie geschrieben haben:
using var stream = File.OpenRead(path);
Process(stream);
// finally { if (stream != null) stream.Dispose(); } runs at end of scope
Dieselbe generierte Form; der Decompiler entscheidet zwischen der Block- und der Deklarationsform danach, wo der Geltungsbereich endet.
Die Null-Prüfung und die Struct-Ausnahme davon
Diese if (stream != null)-Absicherung ist ein zuverlässiger Hinweis, aber sie ist nicht immer vorhanden — und ihr Fehlen ist aufschlussreich. Wenn die Ressource ein Werttyp ist, der IDisposable implementiert, tut der Compiler zwei Dinge anders: Er ruft Dispose() direkt auf dem Struct auf (keine Null-Prüfung — ein Struct ist nie null) und vermeidet es, es zu IDisposable zu boxen, indem er die Schnittstellenmethode direkt auf dem Wert aufruft, um die Semantik des Structs zu bewahren. Das ist wichtig für das häufigste versteckte using überhaupt: foreach.
Ein foreach über eine Sammlung, deren Enumerator ein Struct ist (wie List<T>.Enumerator), senkt zu einem using-förmigen try/finally um die Aufzählung ab, und weil der Enumerator ein Struct ist, ruft das finally Dispose() darauf ohne Null-Prüfung auf:
// 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
}
Wenn ein Decompiler Ihnen also ein try/finally mit einer MoveNext/Current-Schleife und einem ungeschützten Dispose auf einem Wert zeigt, ist das ein foreach, und die fehlende Null-Prüfung sagt Ihnen, dass der Enumerator ein Struct war. (Wäre der Enumerator eine Klasse, sähen Sie das vertraute if (e != null) e.Dispose().)
await using und IAsyncDisposable
Das Async-Geschwister, await using, senkt zum selben try/finally ab — aber das finally awaited DisposeAsync(), statt Dispose() aufzurufen:
// 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
}
Weil ein await im finally steht, wird die Freigabe Teil der Async-Zustandsmaschine der Methode — der DisposeAsync()-Aufruf erzeugt einen ValueTask, auf dem das generierte MoveNext wie bei jedem anderen await parkt. In einem rohen Dekompilat sieht das aus wie ein Zustandsmaschinen-Fortsetzungspunkt in der Fault-/Finally-Region, was genau ist, wie ein Decompiler es identifiziert: Ein im finally awaitetes DisposeAsync ist die Signatur von await using und wird als solches rekonstruiert. Dieselbe Struct-vs-Klasse-Null-Prüfungsregel gilt.
Wie der Decompiler es zurückklappt
using zu erkennen ist Mustererkennung auf der generierten Form, und ein guter Decompiler ist dabei sorgfältig, weil nicht jedes try/finally ein using ist. Die Marker, nach denen er sucht:
- Ein
finally, dessen einzige Wirkung einDispose()/DisposeAsync()-Aufruf ist, auf einer unmittelbar vor demtryerworbenen Ressource. Diese Koinzidenz von „erwerben, try, finally-freigeben” ist der Fingerabdruck. - Die Null-Absicherung (oder ihr struct-förmiges Fehlen) um das Dispose, passend zur eigenen Codegenerierung des Compilers — ein handgeschriebenes
try/finally { x.Dispose(); }, das nicht der exakten abgesicherten Form entspricht, wird als explizites try/finally belassen statt fälschlich inusinggeklappt. - Für
foreachdie zusätzlicheGetEnumerator/MoveNext/Current-Struktur imtry, die ihm erlaubt,foreachzu rekonstruieren statt eines nacktenusingüber einen Enumerator.
Wenn die Form exakt passt, reduziert der Decompiler fünf oder sechs Zeilen try/finally auf das eine using (oder foreach oder await using), das Sie geschrieben haben. Wenn nicht — ein finally, das mehr tut als freigeben, oder eine Freigabe, die nicht so abgesichert ist, wie der Compiler sie absichert — zeigt er ehrlich das try/finally, weil einen beliebigen Bereinigungsblock in using zu verwandeln den Code falsch darstellen würde.
Warum es sich zu wissen lohnt
Das zurückgeklappte using ist das, was Sie lesen wollen, aber das try/finally darunter erklärt echtes Verhalten. Es ist der Grund, warum using die Bereinigung bei einer Ausnahme garantiert — die Freigabe ist in einem finally, nicht nach dem Körper. Es ist der Grund, warum das Freigeben einer null-Ressource nicht wirft — der Compiler sichert sie ab. Es ist der Grund, warum ein foreach über ein List<T> null sichtbaren Freigabe-Overhead als Boxing hat — der Struct-Enumerator wird direkt freigegeben. Und wenn Sie die Assembly eines anderen ohne Symbole lesen und ein try mit einem einzelnen abgesicherten Dispose im finally sehen, können Sie es direkt als using zurücklesen, selbst wenn der Decompiler die rohe Form zeigt. Glass.NET klappt diese Muster standardmäßig zurück und lässt Sie zum rohen try/finally hinabsteigen, wenn Sie bestätigen wollen, wie genau die Bereinigung generiert wurde — die Ansicht, die Sie wollen, sobald ein Dispose oder DisposeAsync etwas Überraschendes tut.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.