Tupel dekompilieren: wo die Namen (count, name) wirklich leben
Ein ValueTuple hat kein Feld namens count oder name — das sind Item1 und Item2, und das Typsystem weiß nichts von deinen Namen. Die Namen, die du geschrieben hast, leben in einem [TupleElementNames]-Attribut, das der Compiler an die Methodensignatur, das Feld oder die Eigenschaft anhängt, als Array von Zeichenketten mit null an den namenlosen Stellen. Ein Dekompiler, der dieses Attribut ignoriert, gibt dir Item1/Item2; einer, der es liest, stellt (int count, string name) wieder her. Hier erfährst du genau, wie Tupelnamen kodiert werden, warum sie auf Signaturen überleben, aber auf lokalen Variablen verschwinden, und wie Glass.NET sie wieder einsetzt.
Tupel sind einer der Orte, an denen die Syntax von C# und das Typsystem des CLR stillschweigend uneins sind, und die Uneinigkeit ist sichtbar, sobald du dekompilierst. Du hast (int count, string name) geschrieben; die Laufzeit hat nie von count oder name gehört. Das Tupel ist System.ValueTuple<int, string>, und seine zwei Felder sind Item1 und Item2 — fest, generisch, namenlos. Wo sind also deine Namen geblieben? Sie sind echt, sie überleben die Kompilierung, und sie leben an einem überraschenden Ort: in einem Attribut, das an die Signatur geschraubt ist, gar nicht im Typ.
Diese Trennung ist der Grund, warum zwei Dekompiler über dieselbe Methode uneins sein können — einer zeigt (int count, string name), der andere ValueTuple<int, string> mit Item1/Item2 — und warum Tupelnamen auf einem Rückgabetyp sauber zurückkommen, aber auf einer lokalen Variablen verdunsten. Dies ist eine praktische Tour durch die Kodierung: was [TupleElementNames] hält, wie verschachtelte Tupel abflachen, wo die Namen gar nicht gespeichert werden, und wie Glass.NET sie zurück zu dem Tupel liest, das du getippt hast.
Der Quellcode
Eine Methode, deren Signatur Namen sowohl auf ihrem Parameter als auch auf ihrem Rückgabewert trägt, plus ein verschachteltes Tupel, um das Abflachen zu zeigen, plus ein absichtlich unbenanntes Element:
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
}
Wo die Namen wirklich sind
Kompiliere Summarize und schau in die Metadaten. Der Rückgabetyp ist System.ValueTuple\2<int32, string>— deinecountundnamesind nirgends darin. Stattdessen hängt der Compiler einTupleElementNamesAttribute` an den Rückgabewert, das ein String-Array parallel zu den Positionen des Tupels trägt:
.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') }
...
}
Die Namensabbildung ist also: der Array-Index des Attributs ist die Tupelposition. ["count", "name"] auf dem Rückgabewert bedeutet Item1 → count, Item2 → name. Das Attribut wird dort emittiert, wo die Namen einen sichtbaren Vertrag bilden — Rückgaben, Parameter, Felder, Eigenschaften — damit ein Aufrufer in einer anderen Assembly sich an sie binden kann. Der Typ bleibt ValueTuple; nur das Attribut trägt deine Absicht.
Verschachtelte Tupel flachen mit nulls ab
Split gibt (int id, (string first, string last) name) zurück — ein Tupel, dessen zweites Element selbst ein Tupel ist. Es gibt kein verschachteltes Attribut; die Namen werden in ein Array abgeflacht durch eine Preorder-Traversierung, mit einem null an jeder Position, die ein zusammengesetzter (Nicht-Blatt-)Knoten ist:
.custom instance void ...TupleElementNamesAttribute::.ctor(string[])
= { string[4]('id', nullref, 'first', 'last') }
Lies es als Baumtraversierung: id ist das erste Blatt, null markiert den Container des verschachtelten Tupels selbst, dann gehören first/last zum inneren Tupel und id zum äußeren. Der Dekompiler baut die Form wieder auf, indem er den ValueTuple-Typbaum durchläuft und die Namen positionsweise verbraucht, sodass er weiß, dass first/last zum inneren Tupel und id zum äußeren gehören. Die Schlüssel-Invariante: die Array-Länge entspricht der Anzahl der Positionen über die abgeflachte Form, und der Leser nutzt die Typstruktur, nicht das Array allein, um neu zu verschachteln.
Gemischte und unbenannte Elemente nutzen null
Partial gibt (int count, string) zurück — eines benannt, eines nicht. Unbenannte Positionen werden als null gespeichert:
.custom instance void ...TupleElementNamesAttribute::.ctor(string[])
= { string[2]('count', nullref) }
Also rekonstruiert sich ["count", null] zu (int count, string) — Item1 bekommt seinen Namen, Item2 fällt auf das positionsbasierte Item2 zurück, was genau das ist, wodurch du im Code darauf zugreifst. Deshalb muss ein Dekompiler null als „hier kein Name” behandeln, nicht als leere Zeichenkette oder Fehler.
Was ein naiver Dekompiler zeigt
Ein Dekompiler, der den Typ liest, aber nicht das Attribut, ist richtig über die Form und blind für die Namen. Jedes Tupel wird zu ValueTuple<…> und jeder Zugriff ist 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) => …;
Es kompiliert zum Gleichen, aber es hat die eine Information weggeworfen, die die Signatur selbsterklärend machte — und ein Aufrufer, der dies liest, kann nicht mehr sagen, dass Item1 eine Zahl und Item2 ein Name ist.
Was Glass rekonstruiert
Glass basiert auf ICSharpCode.Decompiler, der ILSpy-Engine, die TupleElementNamesAttribute an jeder Signatur liest und die Namen positionsweise zurückbildet — die verschachtelte Form aus dem abgeflachten Array wiederherstellend und unbenannte Positionen positionsbasiert lassend:
// 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");
Beachte, was zurückkam und was nicht. Die Signaturen — Rückgabe und Parameter von Summarize, der verschachtelte Rückgabewert von Split, der gemischte Rückgabewert von Partial — sind genau wie geschrieben, weil das Attribut sie bewahrt hat. Aber scratch, die Lokale, wird als schlichtes (int, string) rekonstruiert, auf das über Item1/Item2 zugegriffen wird. Das ist keine Glass-Einschränkung; es ist die Wahrheit der Metadaten. Der Compiler emittiert kein TupleElementNamesAttribute für eine lokale Variable, weil die Elementnamen einer Lokalen kein Vertrag sind, den irgendjemand außerhalb des Methodenkörpers sehen kann — es gibt nichts in der Assembly, das je aufgezeichnet hätte, dass du sie innerhalb der Methode id und label genannt hast. Glass zeigt genau, was überlebt hat, und erfindet nichts.
Wann in die IL-Ansicht wechseln
Die Attributkodierung ist einer der klarsten Fälle, in denen das Betrachten der rohen Metadaten das dekompilierte C# erklärt:
- Warum kam ein Name hier zurück, aber dort nicht? Wechsle zu IL und suche das
.param, dasTupleElementNamesAttributeträgt. Vorhanden auf dem Rückgabewert → Namen wiederhergestellt; fehlend (eine Lokale) →Item1/Item2. Das Vorhandensein oder Fehlen des Attributs ist die Erklärung. - Eine verschachtelte Form lesen. Das abgeflachte Array mit seinen
null-Platzhaltern ist nur in IL sichtbar. Wenn ein rekonstruiertes verschachteltes Tupel überraschend aussieht, ist das Array im Attribut die Grundwahrheit, welcher Name zu welcher Position gehört. - Assembly-übergreifende Bindung. Da die Namen auf der Signatur leben, bindet sich ein Konsument in einer anderen Assembly rein aus diesem Attribut an
.count/.name. Zu bestätigen, dass es emittiert wird, ist die Art, zu prüfen, ob eine Bibliothek wirklich die benannte Tupel-API bereitstellt, die du beabsichtigt hast.
Lies das C#, um die benannten Signaturen zurückzubekommen; wechsle in die IL, um zu sehen, dass die Namen nie im Typ waren — sie waren die ganze Zeit ein Attribut, und Item1/Item2 auf einer Lokalen sind die Metadaten, die dir die Wahrheit sagen.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.