Skip to content
← Todas las publicaciones
· Delta1 Labs Glass.NETDecompilación.NETAnálisis a fondo

Decompilar tuplas: dónde viven realmente los nombres (count, name)

Una ValueTuple no tiene un campo llamado count ni name: son Item1 e Item2, y el sistema de tipos no sabe nada de tus nombres. Los nombres que escribiste viven en un atributo [TupleElementNames] que el compilador adjunta a la firma del método, al campo o a la propiedad, como un array de cadenas con null en las posiciones sin nombre. Un decompilador que ignora ese atributo te entrega Item1/Item2; uno que lo lee restaura (int count, string name). Aquí tienes exactamente cómo se codifican los nombres de tupla, por qué sobreviven en las firmas pero desaparecen en las variables locales, y cómo Glass.NET los vuelve a poner.

Las tuplas son uno de los lugares donde la sintaxis de C# y el sistema de tipos del CLR discrepan en silencio, y la discrepancia es visible en cuanto decompilas. Escribiste (int count, string name); el runtime nunca ha oído hablar de count ni de name. La tupla es System.ValueTuple<int, string>, y sus dos campos son Item1 e Item2: fijos, genéricos, sin nombre. Entonces, ¿adónde fueron tus nombres? Son reales, sobreviven a la compilación y viven en un sitio sorprendente: en un atributo atornillado a la firma, no en el tipo en absoluto.

Esa separación es la razón por la que dos decompiladores pueden discrepar sobre el mismo método —uno muestra (int count, string name) y el otro ValueTuple<int, string> con Item1/Item2— y por la que los nombres de tupla vuelven limpios en un tipo de retorno pero se evaporan en una variable local. Este es un recorrido práctico por la codificación: qué contiene [TupleElementNames], cómo se aplanan las tuplas anidadas, dónde no se almacenan los nombres en absoluto, y cómo Glass.NET los lee de vuelta a la tupla que escribiste.

El código fuente

Un método cuya firma lleva nombres tanto en su parámetro como en su retorno, más una tupla anidada para mostrar el aplanamiento, más un elemento deliberadamente sin nombre:

public static class Stats
{
    public static (int count, string name) Summarize((int id, string label) input)
    {
        var scratch = (input.id, input.label.Trim());   // a LOCAL tuple
        return (scratch.Item1, scratch.Item2);
    }

    public static (int id, (string first, string last) name) Split(string full) =>
        (1, (full.Split(' ')[0], full.Split(' ')[1]));

    public static (int count, string) Partial() => (0, "unnamed");   // mixed
}

Dónde están realmente los nombres

Compila Summarize y mira los metadatos. El tipo de retorno es System.ValueTuple\2<int32, string>: tus countynameno están en él. En su lugar, el compilador adjunta unTupleElementNamesAttribute` al retorno, que lleva un array de cadenas paralelo a las posiciones de la tupla:

.method public hidebysig static valuetype [System.Runtime]System.ValueTuple`2<int32, string>
    Summarize(valuetype [System.Runtime]System.ValueTuple`2<int32, string> input) cil managed
{
    // on the RETURN:
    .param [0]
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.TupleElementNamesAttribute::.ctor(string[])
        = { string[2]('count', 'name') }

    // on the PARAMETER 'input':
    .param [1]
    .custom instance void ...TupleElementNamesAttribute::.ctor(string[])
        = { string[2]('id', 'label') }
    ...
}

Así que el mapeo de nombres es: el índice del array del atributo es la posición de la tupla. ["count", "name"] en el retorno significa Item1 → count, Item2 → name. El atributo se emite donde los nombres forman un contrato visible —retornos, parámetros, campos, propiedades— para que un llamador en otro ensamblado pueda enlazar con ellos. El tipo sigue siendo ValueTuple; solo el atributo lleva tu intención.

código fuente(int count, string name)el TIPO (sin nombres)ValueTuple<int, string>el ATRIBUTO (nombres)[TupleElementNames( {"count","name"})]Glass: índice = posiciónItem1 → countItem2 → name

Las tuplas anidadas se aplanan con nulls

Split devuelve (int id, (string first, string last) name): una tupla cuyo segundo elemento es a su vez una tupla. No hay atributo anidado; los nombres se aplanan en un array mediante un recorrido en preorden, con un null en cada posición que es un nodo compuesto (no-hoja):

.custom instance void ...TupleElementNamesAttribute::.ctor(string[])
    = { string[4]('id', nullref, 'first', 'last') }

Léelo como un recorrido de árbol: id es la primera hoja, null marca el contenedor de la tupla anidada en sí, luego first/last pertenecen a la tupla interior e id a la exterior. El decompilador reconstruye la forma recorriendo el árbol de tipos de ValueTuple y consumiendo los nombres posicionalmente, así que sabe que first/last pertenecen a la tupla interior e id a la exterior. El invariante clave: la longitud del array es igual al número de posiciones a lo largo de la forma aplanada, y el lector usa la estructura del tipo, no solo el array, para re-anidar.

Los elementos mezclados y sin nombre usan null

Partial devuelve (int count, string): uno con nombre, uno sin. Las posiciones sin nombre se almacenan como null:

.custom instance void ...TupleElementNamesAttribute::.ctor(string[])
    = { string[2]('count', nullref) }

Así que ["count", null] se reconstruye como (int count, string): Item1 recibe su nombre, Item2 recurre al posicional Item2, que es exactamente por lo que accedes a él en el código. Por eso un decompilador debe tratar null como “aquí no hay nombre”, no como una cadena vacía o un error.

Lo que muestra un decompilador ingenuo

Un decompilador que lee el tipo pero no el atributo es correcto sobre la forma y ciego a los nombres. Cada tupla se convierte en ValueTuple<…> y cada acceso es Item1/Item2:

// Naive output: names dropped, contract obscured.
public static ValueTuple<int, string> Summarize(ValueTuple<int, string> input)
{
    ValueTuple<int, string> scratch = new ValueTuple<int, string>(input.Item1, input.Item2.Trim());
    return new ValueTuple<int, string>(scratch.Item1, scratch.Item2);
}

public static ValueTuple<int, ValueTuple<string, string>> Split(string full) => …;

Compila a lo mismo, pero ha tirado la única pieza de información que hacía la firma autodescriptiva, y un llamador que lee esto ya no puede saber que Item1 es un recuento e Item2 un nombre.

Lo que Glass reconstruye

Glass está construido sobre ICSharpCode.Decompiler, el motor de ILSpy, que lee TupleElementNamesAttribute de cada firma y mapea los nombres de vuelta posicionalmente, restaurando la forma anidada desde el array aplanado y dejando las posiciones sin nombre como posicionales:

// Glass output: the signatures you wrote.
public static (int count, string name) Summarize((int id, string label) input)
{
    (int, string) scratch = (input.id, input.label.Trim());
    return (scratch.Item1, scratch.Item2);
}

public static (int id, (string first, string last) name) Split(string full) => …;

public static (int count, string) Partial() => (0, "unnamed");

Fíjate en qué volvió y qué no. Las firmas —retorno y parámetro de Summarize, el retorno anidado de Split, el retorno mezclado de Partial— están exactamente como se escribieron, porque el atributo las preservó. Pero scratch, la local, se reconstruye como una (int, string) simple accedida mediante Item1/Item2. Eso no es una limitación de Glass; es la verdad de los metadatos. El compilador no emite ningún TupleElementNamesAttribute para una variable local, porque los nombres de elementos de una local no son un contrato que nadie fuera del cuerpo del método pueda ver: no hay nada en el ensamblado que registrara jamás que los llamaste id y label dentro del método. Glass muestra exactamente lo que sobrevivió y no inventa nada.

Cuándo bajar a la vista de IL

La codificación del atributo es uno de los casos más claros donde ver los metadatos en crudo explica el C# decompilado:

  • ¿Por qué volvió un nombre aquí pero no allí? Cambia a IL y busca el .param que lleva TupleElementNamesAttribute. Presente en el retorno → nombres restaurados; ausente (una local) → Item1/Item2. La presencia o ausencia del atributo es la explicación.
  • Leer una forma anidada. El array aplanado con sus marcadores null solo es visible en IL. Cuando una tupla anidada reconstruida parece sorprendente, el array en el atributo es la verdad fundamental de qué nombre pertenece a qué posición.
  • Enlace entre ensamblados. Como los nombres viven en la firma, un consumidor en otro ensamblado enlaza con .count/.name puramente a partir de este atributo. Confirmar que se emite es cómo verificas que una biblioteca expone de verdad la API de tupla con nombres que pretendías.

Lee el C# para recuperar las firmas con nombres; baja al IL para ver que los nombres nunca estuvieron en el tipo: fueron un atributo todo el tiempo, e Item1/Item2 en una local son los metadatos diciéndote la verdad.

Prueba Nebula.NET

Endurece tu código .NET en minutos — empieza con la edición gratuita.