Decompilar using: el try/finally que el compilador escribe por ti
Una sentencia using no tiene IL propio — el compilador la reduce a un try/finally que llama a Dispose, y await using a un try/finally que espera DisposeAsync. Aquí tienes exactamente qué aspecto tiene ese código generado, las sutilezas del null-check y del enumerador struct, y cómo un decompilador lo pliega de vuelta en using.
Algunas sentencias de C# se mapean a una única instrucción IL. using se mapea a una forma de flujo de control — un try/finally — que el compilador escribe en tu nombre para que un recurso siempre se limpie, incluso cuando el cuerpo lanza. No hay opcode using; lo que se distribuye es el try/finally, y el trabajo de un decompilador es reconocer esa forma y plegarla de vuelta en la única palabra clave que escribiste. Recorrer la reducción explica tanto lo que ves en un decompilado crudo como un par de comportamientos (await using, enumeradores struct, el null-check) que sorprenden.
La reducción básica
Toma el caso canónico:
using (var stream = File.OpenRead(path))
{
Process(stream);
}
El compilador reduce esto a un try/finally cuyo finally libera el recurso. Decompilado sin reconocimiento de using, se lee aproximadamente:
FileStream stream = File.OpenRead(path);
try
{
Process(stream);
}
finally
{
if (stream != null)
((IDisposable)stream).Dispose();
}
Tres cosas a notar. El recurso se adquiere antes del try, así que un fallo en la expresión de adquisición no entra en la región protegida. El cuerpo va en el try. Y el finally libera — incondicionalmente respecto a cómo salió el cuerpo (normal, return o excepción), que es justo el objetivo de using: limpieza determinista. La liberación se castea a IDisposable porque ese es el contrato al que using apunta, y está protegida por un null-check para que un recurso nulo sea un no-op inofensivo en vez de una NullReferenceException en la ruta de limpieza.
Una declaración using — la forma sin llaves añadida en C# 8 — se reduce idénticamente; el finally simplemente se ejecuta al final del ámbito contenedor en vez de un bloque que escribiste:
using var stream = File.OpenRead(path);
Process(stream);
// finally { if (stream != null) stream.Dispose(); } runs at end of scope
La misma forma generada; el decompilador decide entre la forma de bloque y la de declaración por dónde termina el ámbito.
El null-check, y la excepción del struct
Esa guarda if (stream != null) es una pista fiable, pero no siempre está presente — y su ausencia es informativa. Cuando el recurso es un tipo valor que implementa IDisposable, el compilador hace dos cosas de forma distinta: llama a Dispose() directamente sobre el struct (sin null-check — un struct nunca es nulo) y evita hacerle boxing a IDisposable, llamando al método de interfaz sobre el valor directamente para preservar la semántica del struct. Esto importa para el using oculto más común de todos: foreach.
Un foreach sobre una colección cuyo enumerador es un struct (como List<T>.Enumerator) se reduce a un try/finally con forma de using alrededor de la enumeración, y como el enumerador es un struct, el finally llama a Dispose() sobre él sin 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
}
Así que cuando un decompilador te muestra un try/finally con un bucle MoveNext/Current y un Dispose sin guardar sobre un valor, eso es un foreach, y el null-check ausente te dice que el enumerador era un struct. (Si el enumerador fuera una clase, verías el familiar if (e != null) e.Dispose().)
await using e IAsyncDisposable
El hermano async, await using, se reduce al mismo try/finally — pero el finally espera DisposeAsync() en vez de llamar a 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
}
Como hay un await en el finally, la liberación se vuelve parte de la máquina de estado async del método — la llamada a DisposeAsync() produce un ValueTask sobre el que el MoveNext generado se aparca como cualquier otro await. En un decompilado crudo esto parece un punto de reanudación de máquina de estado dentro de la región fault/finally, que es justo cómo un decompilador lo identifica: un DisposeAsync esperado en un finally es la firma de await using, y se reconstruye como tal. La misma regla de null-check struct-vs-clase aplica.
Cómo lo pliega de vuelta el decompilador
Reconocer using es coincidencia de patrones sobre la forma generada, y un buen decompilador es cuidadoso con ello porque no todo try/finally es un using. Los marcadores que busca:
- Un
finallycuyo único efecto es una llamada aDispose()/DisposeAsync()sobre un recurso adquirido inmediatamente antes deltry. Esa coincidencia de «adquirir, try, finally-liberar» es la huella. - La guarda null (o su ausencia con forma de struct) alrededor del Dispose, coincidiendo con la propia generación de código del compilador — un
try/finally { x.Dispose(); }escrito a mano que no coincide con la forma protegida exacta se deja como un try/finally explícito en vez de mal plegarlo enusing. - Para
foreach, la estructura adicionalGetEnumerator/MoveNext/Currentdentro deltry, que le permite reconstruirforeachen vez de unusingpelado sobre un enumerador.
Cuando la forma coincide exactamente, el decompilador colapsa cinco o seis líneas de try/finally en el único using (o foreach, o await using) que escribiste. Cuando no — un finally que hace más que liberar, o una liberación que no está protegida como el compilador la protege — muestra honestamente el try/finally, porque convertir un bloque de limpieza arbitrario en using tergiversaría el código.
Por qué vale la pena saberlo
El using plegado es lo que quieres leer, pero el try/finally de debajo explica comportamiento real. Es por qué using garantiza la limpieza ante una excepción — la liberación está en un finally, no después del cuerpo. Es por qué liberar un recurso null no lanza — el compilador lo protege. Es por qué un foreach sobre un List<T> tiene cero sobrecarga de liberación que puedas ver como boxing — el enumerador struct se libera directamente. Y cuando lees el ensamblado de otra persona sin símbolos y ves un try con un solo Dispose protegido en el finally, puedes leerlo directamente como un using aunque el decompilador eligiera mostrar la forma cruda. Glass.NET pliega estos patrones de vuelta por defecto y te deja bajar al try/finally crudo cuando quieres confirmar exactamente cómo se generó la limpieza — que es la vista que quieres en cuanto un Dispose o DisposeAsync hace algo sorprendente.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.