Decompilar la interpolación de cadenas: leer el handler de vuelta a $"..."
El C# moderno ya no compila $"Hello {name}" a string.Format: lo reduce a un DefaultInterpolatedStringHandler con llamadas a AppendLiteral y AppendFormatted. Aquí tienes exactamente cómo se ve eso en IL, cómo Glass.NET reconoce la secuencia por patrones para reconstruir una cadena interpolada con su formato y alineación intactos, y el único caso en el que honestamente no puede.
Aquí va un pequeño acertijo. Decompilas un ensamblado .NET moderno, aterrizas en un método que claramente construía un mensaje, y en lugar del $"..." que escribiste ves una variable local de tipo DefaultInterpolatedStringHandler, un constructor que recibe dos enteros y una secuencia de llamadas a AppendLiteral / AppendFormatted. No hay nada mal: esto es tu cadena interpolada. Desde C# 10 y .NET 6 el compilador dejó de reducir $"..." a string.Format y empezó a construirla a través de un handler, por buenas razones de rendimiento. Un decompilador que no se hubiera puesto al día te dejaría leyendo esa secuencia mecánica de llamadas a mano.
Este artículo es un recorrido práctico por esa reducción y por cómo Glass.NET la lee de vuelta. Veremos el IL real que emite el compilador, cómo las piezas se corresponden una a una con una cadena interpolada, cómo los especificadores de formato y la alineación sobreviven al viaje de ida y vuelta, y el único caso honesto —los handlers personalizados— en el que la forma a nivel de código fuente difiere genuinamente de $"..." y un decompilador fiel te muestra eso en lugar de inventárselo.
Qué emite el compilador
Toma una interpolación corriente con dos huecos:
string Describe(string name, int count) =>
$"Hello {name}, you have {count} messages";
En .NET 6+ eso no se convierte en string.Format. El compilador reserva un DefaultInterpolatedStringHandler en la pila, le indica por adelantado cuánto texto literal y cuántos huecos esperar, le va pasando los literales y los valores en orden, y le pide la cadena terminada. En IL:
ldloca.s handler
ldc.i4.s 21 // total length of the literal text
ldc.i4.2 // number of interpolation holes
call instance void [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::.ctor(int32, int32)
ldloca.s handler
ldstr "Hello "
call instance void DefaultInterpolatedStringHandler::AppendLiteral(string)
ldloca.s handler
ldarg.1 // name
call instance void DefaultInterpolatedStringHandler::AppendFormatted<string>(!!0)
ldloca.s handler
ldstr ", you have "
call instance void DefaultInterpolatedStringHandler::AppendLiteral(string)
ldloca.s handler
ldarg.2 // count
call instance void DefaultInterpolatedStringHandler::AppendFormatted<int32>(!!0)
ldloca.s handler
ldstr " messages"
call instance void DefaultInterpolatedStringHandler::AppendLiteral(string)
ldloca.s handler
call instance string DefaultInterpolatedStringHandler::ToStringAndClear()
ret
La estructura es rígida, y ese es justo el punto: es exactamente lo que la hace recuperable. Hay un constructor que lleva dos enteros de pista, luego una secuencia estrictamente alternada de AppendLiteral (una cadena constante) y AppendFormatted<T> (un valor), y finalmente un único ToStringAndClear que produce el resultado. Los dos enteros del constructor son una pista de tamaño —21 es la longitud combinada de "Hello ", ", you have " y " messages", y 2 es el número de huecos— para que el handler pueda predimensionar su búfer. No forman parte del texto; un decompilador los lee como corroboración, no como contenido.
La reconstrucción, pieza a pieza
Glass.NET reconoce esa forma y la recorre en orden de emisión, convirtiendo cada llamada de vuelta en un fragmento del literal original:
Cada AppendLiteral("Hello ") aporta su constante directamente al texto literal. Cada AppendFormatted(expr) se convierte en un hueco {expr}, con expr decompilado a partir de lo que se empujó como argumento: una variable local, un campo, una subexpresión completa. El ToStringAndClear() de cierre le indica al reconocedor que la cadena está completa. Reensamblado en orden, obtienes exactamente $"Hello {name}, you have {count} messages", no una transcripción de llamadas al handler.
El formato y la alineación sobreviven al viaje
Los huecos de interpolación llevan dos modificadores opcionales: una alineación ({value,-10}) y un especificador de formato ({value:C2}). Estos no desaparecen en la reducción: seleccionan una sobrecarga distinta de AppendFormatted. Dado:
string Row(string label, decimal price) =>
$"{label,-12}{price,8:C2}";
el compilador llama a las sobrecargas que reciben los argumentos extra: AppendFormatted<string>(value, int alignment) para la etiqueta alineada a la izquierda, y AppendFormatted<decimal>(value, int alignment, string format) para el precio alineado a la derecha y con formato de moneda:
ldstr "" // no leading literal; holes are adjacent
...
ldarg.1
ldc.i4.s -12
call instance void DefaultInterpolatedStringHandler::AppendFormatted<string>(!!0, int32)
...
ldarg.2
ldc.i4.8
ldstr "C2"
call instance void DefaultInterpolatedStringHandler::AppendFormatted<decimal>(!!0, int32, string)
Glass mapea el int final de vuelta a la alineación tras la coma y el string final de vuelta al formato tras los dos puntos, así que reconstruye $"{label,-12}{price,8:C2}" con ambos modificadores en su sitio. La sobrecarga es el metadato: el decompilador no tiene que adivinar qué significaba cada argumento, porque la firma lo dice.
Cuando no es un handler en absoluto
Dos casos producen legítimamente algo distinto de la forma con handler, y reconocerlos es tan parte de una decompilación correcta como reconocer el handler:
La interpolación completamente constante se pliega a un literal. Si todos los huecos son constantes en tiempo de compilación, no hay nada que formatear en tiempo de ejecución: el compilador calcula la cadena final y emite un único ldstr. Así que $"v{1}.{0}" se convierte en la constante "v1.0" en IL, y el decompilador muestra "v1.0", correctamente, porque la interpolación ya no existe en el código compilado. El $ era una comodidad del código fuente que el compilador ya gastó.
Los targets antiguos se reducen a string.Format. El tipo handler llegó con .NET 6. Apunta a netstandard2.0 o a .NET Framework y el mismo $"Hello {name}" se reduce a string.Format("Hello {0}", name) en su lugar. Glass reconstruye $"..." también a partir de ese patrón clásico —casando los marcadores {0}, {1} de la cadena de formato con el array de argumentos—, así que recuperas la interpolación sea cual sea la reducción que usó tu target. Ver un handler frente a una llamada a Format es en realidad una señal del target framework del ensamblado.
El límite honesto: handlers personalizados
El único caso en el que un decompilador fiel no te mostrará $"..." es un handler de cadenas interpoladas personalizado: un tipo marcado con [InterpolatedStringHandler], usado por cosas como el logging de alto rendimiento y Debug.Assert. Ahí, la gracia es que la interpolación tiene una semántica distinta: el handler puede decidir no evaluar los huecos en absoluto. Cuando escribes
logger.LogDebug($"expensive {ComputeDiagnostics()} detail");
contra una sobrecarga que recibe un handler de interpolación de logging, el compilador emite una construcción de ese handler con un flag de habilitación out bool shouldAppend, y protege cada AppendFormatted para que ComputeDiagnostics() se ejecute solo si el nivel de depuración está realmente habilitado. Esa evaluación condicional no se puede expresar como un $"..." plano: representarla así sería mentir sobre cuándo se ejecuta tu código. Un buen decompilador muestra la construcción del handler y los appends protegidos, que es la verdad de lo que se ejecuta. Si quieres recuperar la intención legible, ese es un buen sitio para añadir un comentario durante la exportación a proyecto; la forma mecánica es correcta, solo que de más bajo nivel que el azúcar que tecleaste.
Por qué importa cuando lees un binario
La interpolación de cadenas está en todas partes —mensajes de log, excepciones, cadenas de interfaz, SQL (por favor, no)— y esas son exactamente las cadenas que buscas con grep cuando intentas entender un ensamblado desconocido, rastrear dónde se formatea un valor o auditar una dependencia. Un decompilador que vuelca secuencias crudas de AppendLiteral convierte cada una de ellas en una pequeña tarea de descodificación; uno que reconstruye $"..." te deja leer el código tal como lo escribió el autor. Glass.NET hace lo segundo porque se apoya en ICSharpCode.Decompiler, el mismo motor que ILSpy, que sigue de cerca las reducciones modernas. Apúntalo a cualquier DLL de .NET, navega hasta un método, y las cadenas interpoladas se leen como código fuente; consulta Primeros pasos y la guía de navegación para probarlo con tus propios binarios.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.