Decompilar la interpolación de cadenas de C# y los manejadores de cadenas interpoladas
Una cadena interpolada no tiene IL propio: el compilador elige una de tres reducciones completamente distintas según tu framework de destino, los tipos de los huecos y si un método optó por un manejador. Aquí tienes exactamente qué emite `$"..."` como string.Concat, como string.Format y como la danza de DefaultInterpolatedStringHandler, además del caso de manejador personalizado en el que la cadena nunca se construye, y dónde un decompilador puede replegar cada forma a `$"..."`.
Una cadena interpolada es uno de los fragmentos de C# de aspecto más inocente que puedes escribir — $"Hola {name}, tienes {count} mensajes" — y uno de los menos predecibles una vez que llega al IL. No hay instrucción de interpolación. El compilador reescribe cada $"..." en llamadas a métodos corrientes, y tiene tres formas completamente distintas de hacerlo: un string.Concat, un string.Format con una plantilla posicional o — en destinos modernos — una secuencia de construir-y-añadir contra DefaultInterpolatedStringHandler. Cuál elige depende de tu framework de destino, de los tipos en los huecos y de si el método receptor optó por un manejador propio. Recorrer cada reducción explica qué puede replegar en $"..." un decompilador como Glass.NET, y dónde solo puede mostrarte la maquinaria que el compilador emitió realmente.
Tres reducciones, una sintaxis
El dato más útil sobre $"..." es que la sintaxis no te dice casi nada del IL. El compilador mira adónde va la cadena y qué hay en los huecos y elige:
El resto de este artículo son esas cuatro cajas, en orden, con el IL que produce cada una y la huella que un decompilador usa para reconocerla.
El atajo del concat
Si cada hueco de la cadena ya es una string, y ningún hueco tiene alineación ni especificador de formato, el compilador hace lo más barato posible: llama a String.Concat. Dado
string Greet(string first, string last) => $"{first} {last}";
el cuerpo es simplemente un concat de tres argumentos — el espacio literal es solo otro operando:
ldarg.1 // first
ldstr " "
ldarg.2 // last
call string System.String::Concat(string, string, string)
ret
Esto es byte a byte idéntico a lo que compila first + " " + last, e idéntico a un string.Concat(first, " ", last) escrito a mano. No hay marcador que diga “esto fue interpolación”. Un decompilador es libre de renderizarlo como $"{first} {last}", como first + " " + last o como el concat explícito: las tres son lecturas fieles del mismo IL. La interpolación, como idea diferenciada, no sobrevivió a la compilación.
La plantilla de formato
En cuanto un hueco no es una cadena — un número, un DateTime, cualquier cosa que necesite formato — la reducción clásica es String.Format con una plantilla posicional. Esto es lo que obtienes en netstandard2.0, en .NET Framework y en cualquier destino anterior al tipo manejador. Para
string Status(int count, double rate) => $"{count} items at {rate:P1}";
el compilador construye la cadena de plantilla, encajona los argumentos de tipo por valor en un object[] y llama a Format:
ldstr "{0} items at {1:P1}"
ldc.i4.2
newarr [System.Runtime]System.Object
dup
ldc.i4.0
ldarg.1 // count
box [System.Runtime]System.Int32 // int -> object
stelem.ref
dup
ldc.i4.1
ldarg.2 // rate
box [System.Runtime]System.Double // double -> object
stelem.ref
call string System.String::Format(string, object[])
ret
Aquí hay dos cosas que merece la pena notar, porque son exactamente lo que ves en la salida decompilada. Primero, tu especificador de formato :P1 se conserva literalmente dentro de la plantilla como {1:P1} — el decompilador puede leerlo directo del ldstr. Segundo, los tipos por valor se encajonan; el object[] es el coste de asignación que acarrea esta reducción, y es la razón de que exista la forma más reciente del manejador.
Como esto es una llamada literal a String.Format, es indistinguible de una que escribieras a mano. Un decompilador mostrará una de las dos. Muchos imprimen la llamada string.Format("{0} items at {1:P1}", count, rate) tal cual, porque es la lectura de más bajo nivel y más portable; un decompilador consciente de C# puede volver a endulzarla a $"{count} items at {rate:P1}" analizando la plantilla y mapeando {0},{1} de vuelta sobre el orden de los argumentos. Ambas son correctas.
La danza del manejador
En .NET 6 y posteriores, una asignación simple de una cadena interpolada a una string ya no pasa por String.Format. El compilador la reduce contra DefaultInterpolatedStringHandler, un ref struct de System.Runtime.CompilerServices que acumula el resultado en un búfer interno — sin análisis de plantilla, sin object[], sin encajonar los argumentos en el array. El mismo método Status en un destino moderno se decompila a esta forma:
string Status(int count, double rate)
{
DefaultInterpolatedStringHandler handler =
new DefaultInterpolatedStringHandler(9, 2); // literalLength=9, formattedCount=2
handler.AppendFormatted(count);
handler.AppendLiteral(" items at ");
handler.AppendFormatted(rate, "P1");
return handler.ToStringAndClear();
}
Lee los argumentos del constructor, porque son la primera mitad de la huella. El manejador se crea con (int literalLength, int formattedCount) — el número total de caracteres literales (" items at " son nueve, más cualquier otra tanda de literales) y el número de huecos. El manejador los usa para dimensionar su búfer de entrada. Luego el cuerpo es un intercalado estricto: un AppendLiteral por cada tanda de texto literal, un AppendFormatted por cada hueco, en orden de origen, rematado por exactamente un ToStringAndClear() que produce la string y devuelve el búfer. En IL es una secuencia de call/callvirt contra una variable local del tipo manejador:
ldloca.s handler
ldc.i4.s 9
ldc.i4.2
call instance void DefaultInterpolatedStringHandler::.ctor(int32, int32)
ldloca.s handler
ldarg.1
call instance void DefaultInterpolatedStringHandler::AppendFormatted<int32>(!!0)
ldloca.s handler
ldstr " items at "
call instance void DefaultInterpolatedStringHandler::AppendLiteral(string)
...
ldloca.s handler
call instance string DefaultInterpolatedStringHandler::ToStringAndClear()
ret
Ese constructor de longitud-de-literales-y-recuento, las llamadas alternas AppendLiteral/AppendFormatted<T> sobre una única variable local del manejador y el ToStringAndClear que lo remata son una silueta que nada más produce. Un decompilador moderno la reconoce y repliega todo el bloque a $"{count} items at {rate:P1}". Un decompilador que no lo hace — o que está leyendo una salida donde el optimizador izó o reordenó algunas de las adiciones — muestra las llamadas del manejador literalmente. Eso no es un fallo: es la lectura honesta, y a menudo es más informativa que el azúcar, porque te muestra exactamente dónde ocurre la asignación del búfer y cada instanciación genérica de AppendFormatted<T>.
Alineación, formato y la trampa de la cultura
La familia AppendFormatted tiene varias sobrecargas, y cuál selecciona el compilador te dice qué había entre las llaves. Un hueco pelado {x} se vuelve AppendFormatted<T>(T value). Un especificador de formato {x:N2} se vuelve AppendFormatted<T>(T value, string? format) con el formato como argumento string literal. Una alineación {x,10} se vuelve AppendFormatted<T>(T value, int alignment), y ambas juntas {x,10:N2} se vuelven AppendFormatted<T>(T value, int alignment, string? format). Así que puedes reconstruir el contenido exacto de las llaves a partir de la sobrecarga y sus argumentos constantes — el texto de formato está ahí mismo, en la llamada.
Hay una sutileza de comportamiento que la vista decompilada hace visible y que muerde a quien refactoriza entre las formas. DefaultInterpolatedStringHandler formatea usando la cultura actual por defecto, exactamente como string.Format. Si necesitas invariante o una cultura específica, la cadena interpolada debe pasarse a una API que enhebre un IFormatProvider en el constructor del manejador — el compilador entonces emite una sobrecarga de constructor que toma el proveedor. Si ves un DefaultInterpolatedStringHandler construido sin un argumento proveedor, el resultado es sensible a la cultura; eso es un detalle semántico real escondido en el IL reducido, no ruido.
Manejadores personalizados: cuando la cadena nunca se construye
El patrón del manejador no se limita a construir una string. Cualquier método puede aceptar una cadena interpolada directamente tomando un parámetro cuyo tipo esté marcado con [InterpolatedStringHandler]. El compilador construye entonces ese manejador y añade dentro de él, de modo que la cadena interpolada se formatea directamente en el destino sin ninguna string intermedia. El ejemplo más claro es StringBuilder.Append:
sb.Append($"id={id} name={name}");
no construye una cadena y la añade. Se reduce a la construcción de un StringBuilder.AppendInterpolatedStringHandler que escribe directamente en sb:
var handler = new StringBuilder.AppendInterpolatedStringHandler(8, 2, sb); // note: sb passed in
handler.AppendLiteral("id=");
handler.AppendFormatted(id);
handler.AppendLiteral(" name=");
handler.AppendFormatted(name);
sb.Append(ref handler);
La pista en el código decompilado es que el constructor del manejador toma un tercer argumento — el propio StringBuilder — y la llamada final es Append(ref handler) en lugar de Append(string). Ese argumento extra del constructor proviene de un atributo [InterpolatedStringHandlerArgument("sb")] sobre el parámetro, que le dice al compilador que reenvíe el receptor al manejador.
El caso más llamativo es un manejador que puede decidir no construir la cadena en absoluto. Debug.Assert(condition, $"expensive {Describe()} message") no debe pagar por el mensaje cuando la aserción pasa. El constructor de su manejador tiene un parámetro out bool final:
var handler = new AssertInterpolatedStringHandler(17, 1, condition, out bool shouldAppend);
if (shouldAppend)
{
handler.AppendLiteral("expensive ");
handler.AppendFormatted(Describe());
handler.AppendLiteral(" message");
}
Debug.Assert(condition, ref handler);
Cuando condition es true el constructor pone shouldAppend = false, todo el bloque if se omite y Describe() nunca se llama. Este es el comportamiento de evaluación condicional del que la gente depende sin darse cuenta, y solo es visible en la forma reducida — no hay manera de expresar “omite la interpolación” en la propia sintaxis $"...". Un decompilador que repliega esto a Debug.Assert(condition, $"...") acierta sobre el resultado, pero oculta la guarda shouldAppend; cambiar a la vista de IL o de C# reducido en Glass.NET es como confirmas que el mensaje realmente se omite en la ruta caliente.
La interpolación constante desaparece por completo
El último caso es el que no tiene nada que recuperar. Si cada parte de una cadena interpolada es una constante en tiempo de compilación, el conjunto entero se pliega en tiempo de compilación:
const string Version = "2";
const string Banner = $"Acme v{Version}"; // becomes the constant "Acme v2"
Banner se emite como el literal de cadena "Acme v2". No hay manejador, ni Format, ni Concat — los metadatos contienen una constante, y cualquier referencia a Banner es solo ldstr "Acme v2". Un decompilador muestra una constante de cadena pelada, porque es todo lo que existe. Lo mismo vale para una cadena interpolada sin huecos como $"ready", que es solo el literal "ready". La interpolación que el compilador pudo resolver estáticamente no deja rastro de haber sido interpolación.
Qué sacar de la vista decompilada
La interpolación de cadenas es la ilustración más limpia de un tema que recorre toda esta serie: la sintaxis de superficie es una petición, y el compilador la responde de forma distinta según el contexto. La misma $"..." puede ser un String.Concat, un String.Format con un object[] encajonado, una secuencia de DefaultInterpolatedStringHandler, una adición directa a un StringBuilder o — cuando las entradas son constantes — nada más que un literal horneado. Solo algunas de ellas llevan una huella lo bastante fuerte para reconstruir el $"...": la danza del manejador y las formas de manejador personalizado son reconocibles; las formas de concat y format son indistinguibles de llamadas escritas a mano; la forma constante ya desapareció. Un buen decompilador repliega lo que puede probar y muestra las llamadas reducidas donde no puede — y las llamadas reducidas suelen ser lo importante, porque ahí es donde ves el encajonado, la asignación del búfer, la dependencia de la cultura y la guarda shouldAppend que el azúcar oculta discretamente. Abre el ensamblado en Glass.NET, cambia un método a IL y observa cómo una $"..." de una línea se despliega en las llamadas que el runtime realmente hace. Para el flujo de trabajo completo, consulta cómo decompilar una DLL de .NET a C#.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.