August 2017
Band 32, Nummer 8
.NET-Grundlagen – C# 7.0: Informationen zu Tupeln
Von Mark Michaelis
Im letzten November habe ich in der Sonderausgabe von Connect(); in einer Übersicht über C# 7.0 (msdn.microsoft.com/magazine/mt790178) Tupel vorgestellt. In diesem Artikel beschäftige ich mich erneut mit Tupeln und behandle die gesamte Bandbreite der Syntaxoptionen.
Beschäftigen wir uns zuerst einmal mit der folgenden Frage: Warum Tupel? Gelegentlich kann es hilfreich sein, Datenelemente zu kombinieren. Angenommen, Sie arbeiten z. B. mit Informationen zu Ländern, etwa zum ärmsten Land weltweit im Jahr 2017: Malawi (mit der Hauptstadt Lilongwe) mit einem Pro-Kopf-Bruttoinlandsprodukt von 226,50 US-Dollar. Sie könnten natürlich eine Klasse für diese Daten deklarieren. Sie stellen aber nicht Ihre typische noun/object-Beziehung dar. Es handelt sich offensichtlich mehr um eine Sammlung von miteinander in Beziehung stehenden Daten als um ein Objekt. Natürlich würde ein Country-Objekt wesentlich mehr Daten als nur Eigenschaften für den Namen, die Hauptstadt und das Pro-Kopf-Bruttoinlandsprodukt enthalten. Alternativ könnten Sie jedes Datenelement in einzelnen Variablen speichern, das Ergebnis wäre aber keine Zuordnung zwischen den Datenelementen. 226,50 US-Dollar würde Malawi nicht zugeordnet, außer vielleicht durch ein gemeinsames Suffix oder Präfix in den Variablennamen. Eine andere Option wäre das Kombinieren aller Daten in einer Zeichenfolge. Dabei entstünde der Nachteil, dass für das Arbeiten mit den einzelnen Elementen das jeweilige Element analysiert werden müsste. Ein letzter Ansatz könnte das Erstellen eines anonymen Typs sein. Aber auch für diese Vorgehensweise gelten Einschränkungen. Diese reichen in der Tat dafür aus, dass Tupel potenziell anonyme Typen komplett ersetzen könnten. Am Ende des Artikels werde ich auf dieses Thema zurückkommen.
Die beste Option ist möglicherweise das C# 7.0-Tupel, das im einfachsten Fall eine Syntax bereitstellt, die das Kombinieren der Zuordnung mehrerer Variablen verschiedener Typen in einer einzelnen Anweisung ermöglicht:
(string country, string capital, double gdpPerCapita) =
("Malawi", "Lilongwe", 226.50);
In diesem Fall weise ich nicht nur mehrere Variablen zu, sondern deklariere sie auch.
Für Tupel gelten jedoch mehrere zusätzliche Syntaxmöglichkeiten, die in Abbildung 1 gezeigt werden.
Abbildung 1: Beispielcode für die Tupeldeklaration und -zuordnung
Beispiel | Beschreibung | Beispielcode |
1. | Zuweisen eines Tupels zu einzeln deklarierten Variablen. | (string country, string capital, double gdpPerCapita) = |
2. | Zuweisen eines Tupels zu einzeln deklarierten Variablen, die vordeklariert sind. | string country; |
3. | Zuweisen eines Tupels zu einzeln deklarierten und implizit typisierten Variablen. | (var country, var capital, var gdpPerCapita) = |
4. | Zuweisen eines Tupels zu einzeln deklarierten Variablen, die implizit mit einer distributiven Syntax typisiert sind. | var (country, capital, gdpPerCapita) = |
5. | Deklarieren eines benannten Elementtupels, Zuweisen von Tupelwerten zu diesem und anschließendes Zugreifen auf die Tupelelemente anhand des Namens. | (string Name, string Capital, double GdpPerCapita) countryInfo = |
6. | Zuweisen eines benannten Elementtupels zu einer einzelnen implizit typisierten Variablen, die implizit typisiert ist, und anschließendes Zugreifen auf die Tupelelemente anhand des Namens. | var countryInfo = |
7. | Zuweisen eines unbenannten Tupels zu einer einzelnen implizit typisierten Variablen und anschließendes Zugreifen auf die Tupelelemente anhand ihrer Item-number-Eigenschaft. | var countryInfo = |
8. | Zuweisen eines benannten Elementtupels zu einer einzelnen implizit typisierten Variablen und anschließendes Zugreifen auf die Tupelelemente anhand ihrer Item-number-Eigenschaft. | var countryInfo = |
9. | Verwerfen von Tupelteilen mit Unterstrichen. | (string name, _, double gdpPerCapita) countryInfo = |
Obwohl die rechte Seite ein Tupel darstellt, stellt die linke Seite in den ersten vier Beispielen noch einzelne Variablen dar, die zusammen mithilfe von Tupelsyntaxzugewiesen werden. Dafür sind mindestens zwei durch Kommas getrennte Elemente erforderlich, die durch Klammern zugeordnet werden. (Ich verwende die Termtupelsyntax, weil der vom Compiler generierte zugrunde liegende Datentyp auf der linken Seite unter technischen Aspekten kein Tupel ist.) Obwohl ich mit als Tupel kombinierten Werten auf der rechten Seite beginne, besteht das Ergebnis darin, dass die Zuweisung auf der linken Seite das Tupel in seine Bestandteile dekonstruiert. In Beispiel 2 erfolgt die Zuweisung auf der linken Seite zu vordeklarierten Variablen. In den Beispielen 1, 3 und 4 werden die Variablen in der Tuplelsyntax deklariert. Da ich nur Variablen deklariere, folgt die Benennung und Verwendung von Groß-/Kleinschreibung den allgemein anerkannte Richtlinien für den Frameworkentwurf. Beispiel: „Verwenden Sie camelCase für die Namen lokaler Variablen“.
Beachten Sie Folgendes: Auch wenn die implizite Typisierung („var“) über jede Variablendeklaration in der Tupelsyntax verteilt werden kann (Abbildung 4 zeigt dies), können Sie für einen expliziten Typ (z. B. „string“) nicht so vorgehen. In diesem Fall deklarieren Sie tatsächlich einen Tupeltyp und verwenden nicht nur Tupelsyntax. Daher müssen Sie einen Verweis auf das NuGet-Paket „System.ValueType“ hinzufügen (zumindest bis .NET Standard 2.0). Da bei Tupeln jedes Element einen anderen Datentyp aufweisen kann, muss das Verteilen des expliziten Typnamens auf alle Elemente nicht notwendigerweise funktionieren. Dies wäre nur der Fall, wenn alle Elementdatentypen identisch sind (und selbst dann ist es für den Compiler unzulässig).
In Beispiel 5 deklariere ich auf der linken Seite ein Tupel und weise es dann auf der rechten Seite zu. Beachten Sie, dass das Tupel benannte Elemente aufweist. Dabei handelt es sich um Namen, auf die Sie anschließend verweisen können, um die Elementwerte aus dem Tupel abzurufen. Auf diese Weise wird die Syntax mit „countryInfo.Name“, „countryInfo.Capital“ und „countryInfo.GdpPerCapita“ in der Anweisung „System.Console.WriteLine“ ermöglicht. Das Ergebnis der Tupeldeklaration auf der linken Seite ist eine Gruppierung der Variablen in eine einzelne Variable („countryInfo“), aus der Sie anschließend auf die Bestandteile zugreifen können. Dies ist sinnvoll, weil Sie dann diese einzelne Variable an andere Methoden übergeben können. Diese Methoden sind ebenfalls in der Lage, auf die einzelnen Elemente im Tupel zuzugreifen.
Wie bereits erwähnt, verwenden mit Tupelsyntax definierte Variablen camelCase. Die Konvention für Tupelelementnamen wurde jedoch nicht ausreichend definiert. Es wurde vorgeschlagen, Konventionen für Parameternamen zu verwenden, wenn sich das Tupel wie ein Parameter verhält (z. B. bei der Rückgabe mehrerer Werte, bei der vor der Tupelsyntax out-Parameter verwendet worden wären). Die Alternative besteht in der Verwendung von PascalCase und der Namenskonvention für öffentliche Felder und Eigenschaften. Ich favorisiere gemäß den Konventionen für die Groß-/Kleinschreibung von Bezeichnern (itl.tc/caprfi) stark den letztgenannten Ansatz. Tupelelementnamen werden als Member des Tupels gerendert, und die Konvention für alle (öffentlichen) Member (auf die potenziell mithilfe eines Punktoperators zugegriffen wird) ist PascalCase.
Beispiel 6 stellt die gleiche Funktionalität wie Beispiel 5 bereit, obwohl es benannte Tupelelemente für den Tupelwert auf der rechten Seite und eine implizite Typdeklaration auf der linken Seite verwendet. Die Namen werden jedoch in der implizit typisierten Variablen persistent gespeichert, damit sie für die WriteLine-Anweisung weiterhin verfügbar sind. Auf diese Weise eröffnet sich natürlich die Möglichkeit, dass Sie die Elemente auf der linken Seite mit Namen benennen können, die sich von den auf der rechten Seite verwendeten Namen unterscheiden. Zwar lässt der C#-Compiler dies zu, er gibt aber eine Warnung aus, dass die Elementnamen auf der rechten Seite ignoriert werden, weil die Namen auf der linken Seite Vorrang besitzen.
Wenn keine Elementnamen angegeben werden, sind die einzelnen Elemente weiterhin aus der zugewiesenen Tupelvariablen verfügbar. Die Namen lauten jedoch „Item1“, „Item2“ usw. Beispiel 7 zeigt dies. Tatsächlich ist der ItemX-Name immer für das Tupel verfügbar. Dies gilt selbst dann, wenn benutzerdefinierte Namen bereitgestellt werden (siehe Beispiel 8). Wenn jedoch IDE-Tools (etwa eine der aktuellen Versionen von Visual Studio, die C# 7.0 unterstützen) verwendet werden, wird die ItemX-Eigenschaft nicht in der IntelliSense-Dropdownliste angezeigt. Das ist auch gut so, weil wahrscheinlich der angegebene Name vorzuziehen ist.
Wie Beispiel 9 zeigt, können Teile einer Tupelzuordnung mithilfe eines Unterstrichs ausgeschlossen werden. Dies wird als „Verwerfen“ bezeichnet.
Tupel sind eine schlanke Lösung zum Kapseln von Daten in einem Objekt (ähnlich einem Einkaufswagen, dem Sie verschiedene Einkäufe aus dem Store hinzufügen). Im Gegensatz zu Arrays enthalten Tupel Elementdatentypen, die sich praktisch ohne Einschränkungen voneinander unterscheiden können (obwohl Zeiger unzulässig sind). Sie werden jedoch durch den Code identifiziert und können nicht zur Laufzeit geändert werden. Ein weiterer Unterschied zu Arrays besteht darin, dass die Anzahl der Elemente im Tupel zur Kompilierzeit ebenfalls hartcodiert ist. Außerdem kann einem Tupel kein benutzerdefiniertes Verhalten hinzugefügt werden (trotz Erweiterungsmethoden). Wenn Sie den gekapselten Daten Verhalten zuweisen müssen, besteht die bevorzugte Vorgehensweise darin, objektorientierte Programmierung zu nutzen und eine Klasse zu definieren.
Der System.ValueTuple<…>-Typ
Der C#-Compiler generiert Code, der eine Sammlung generischer Werttypen (Strukturen, z. B. „System.ValueTuple<T1, T2, T3>“) als zugrunde liegende Implementierung für die Tupelsyntax für alle Tupelinstanzen auf der rechten Seite in den Beispielen in Abbildung 1 verwendet. Auf ähnliche Weise wird die gleiche Sammlung generischer System.ValueTuple<...>-Werttypen für den Datentyp auf der linken Seite verwendet (ab Abbildung 5). Wie es von einem Tupeltyp zu erwarten ist, beziehen sich die einzigen enthaltenen Methoden auf Vergleich und Gleichheit. Jedoch (vielleicht überraschend) sind keine Eigenschaften für „ItemX“ vorhanden, sondern nur Schreib-Lesefelder (dies scheint den grundlegenden Richtlinien für die .NET-Programmierung wie unter itl.tc/CS7TuplesBreaksGuidelines dargelegt zu widersprechen).
Neben der Abweichung von den Programmierungsrichtlinien stellt sich eine weitere Frage zum Verhalten. Wenn die benutzerdefinierten Elementnamen und ihre Typen nicht in der System.ValueTuple<...>-Definition enthalten sind, wie ist es dann möglich, dass jeder benutzerdefinierte Elementname anscheinend ein Member des System.ValueTuple<...>-Typs ist, auf den als Member dieses Typs zugegriffen werden kann?
Die Tatsache ist überraschend (insbesondere für die Benutzer, die mit der anonymen Typimplementierung vertraut sind), dass der Compiler keinen zugrunde liegenden CIL-Code (Common Intermediate Language) für die Member generiert, der den benutzerdefinierten Namen entspricht. Selbst ohne einen zugrunde liegenden Member mit dem benutzerdefinierten Name ist (anscheinend) aus der C#-Perspektive ein solcher Member vorhanden.
Für alle Beispiele benannter lokaler Tupelvariablen (etwa für das folgende Beispiel):
var countryInfo = (Name: "Malawi", Capital: "Lilongwe", GdpPerCapita: 226.50)
ist es eindeutig möglich, dass die Namen dem Compiler für den restlichen Bereich des Tupels bekannt sind, weil dieser Bereich in dem Member gebunden ist, in dem er deklariert wird. Und tatsächlich verwendet der Compiler (und die IDE) diesen Bereich einfach, um das Zugreifen auf jedes Element anhand des Namens zu ermöglichen. Anders ausgedrückt: Der Compiler untersucht die Elementnamen in der Tupeldeklaration und nutzt diese dann, um Code zuzulassen, der diese Namen im Bereich verwendet. Auch aus diesem Grund werden die ItemX-Methoden nicht über IntelliSense der IDE als verfügbare Member des Tupels angezeigt (die IDE ignoriert diese einfach und ersetzt sie durch die benannten Elemente).
Das Ermitteln der Elementnamen aus dem Bereich in einem Member ist für den Compiler sinnvoll. Was geschieht aber, wenn ein Tupel außerhalb eines Members bereitgestellt wird, z. B. ein Parameter oder ein Rückgabewert aus einer Methode, die sich in einer anderen Assembly befindet (für die möglicherweise kein Quellcode verfügbar ist)? Für alle Tupel, die Teil der API (öffentliche oder private API) sind, fügt der Compiler den Metadaten des Members Elementnamen in Form von Attributen hinzu. Dies:
[return: System.Runtime.CompilerServices.TupleElementNames(
new string[] {"First", "Second"})]
public System.ValueTuple<string, string> ParseNames(string fullName)
{
// ...
}
ist beispielsweise die C#-Entsprechung dessen, was der Compiler für Folgendes generiert:
public (string First, string Second) ParseNames(string fullName)
In diesem Zusammenhang ermöglicht C# 7.0 nicht die Verwendung benutzerdefinierter Elementnamen, wenn der explizite System.ValueTuple<…>-Datentyp verwendet wird. Wenn Sie also „var“ in Beispiel 8 in Abbildung 1 ersetzen, erhalten Sie daher Warnungen, dass jeder Elementname ignoriert wird.
Folgendes sollten Sie sich außerdem zu „System.ValueTuple<…>“ bewusst machen:
- Insgesamt sind acht generische System.ValueTuple-Strukturen verfügbar, die die Unterstützung eines Tupels mit bis zu sieben Elementen ermöglichen. Für das achte Tupel („System.ValueTuple<T1, T2, T3, T4, T5, T6, T7, TRest>“) ermöglicht der letzte Typparameter das Angeben eines zusätzlichen Werttupels. Auf diese Weise wird Unterstützung für n Elemente ermöglicht. Wenn Sie z. B. ein Tupel mit acht Parametern angeben, generiert der Compiler automatisch „System.ValueTuple<T1, T2, T3, T4, T5, T6, T7, System.ValueTuple<TSub1>>“ als den zugrunde liegenden Implementierungstyp. (Aus Gründen der Vollständigkeit ist „System.Value<T1>“ vorhanden, wird aber nur direkt und nur als Typ verwendet. „System.Value<T1>“ wird nie direkt vom Compiler verwendet, weil die C#-Tupelsyntax mindestens zwei Elemente erfordert.)
- Es ist ein nicht generisches „System.ValueTuple“ verfügbar, das als Tupelfactory mit Create-Methoden fungiert, die jeder Werttupelstelligkeit entsprechen. Die Einfachheit der Verwendung eines Tupelliterals wie etwa „var t1 = ("Inigo Montoya", 42)“ ersetzt die Create-Methode zumindest für Programmierer, die mit C# 7.0 (oder höher) arbeiten.
- Im praktischen Einsatz können C#-Entwickler „System.ValueTuple“ und „System.ValueTuple<T>“ im Wesentlichen ignorieren.
Ein weiterer Tupeltyp war in .NET Framework 4.5 enthalten: „System.Tuple<…>“. Damals wurde davon ausgegangen, dass dieser Typ als Kerntupelimplementierung weiterentwickelt werden würde. Sobald C# jedoch Tupelsyntax unterstützte, wurde erkannt, dass ein Werttyp allgemein leistungsfähiger ist. Daher wurde „System.ValueTuple<…>“ eingeführt und ersetzt „System.Tuple<…>“ in praktisch allen Fällen. Eine Ausnahme stellt die Abwärtskompatibilität mit vorhandenen APIs dar, die „System.Tuple<…>“ benötigen.
Zusammenfassung
Bei der Einführung des neuen C# 7.0-Tupels war vielen Entwicklern nicht bewusst, dass es anonyme Typen praktisch ersetzt und außerdem zusätzliche Funktionalität bereitstellt. Tupel können z. B. von Methoden zurückgegeben werden, und die Elementnamen werden in der API so persistent gespeichert, dass aussagekräftige Namen anstelle der ItemX-Typnamen verwendet werden können. Ebenso wie anonyme Typen können Tupel sogar komplexe hierarchische Strukturen darstellen, die möglicherweise in komplexeren LINQ-Abfragen vorhanden sind (wenngleich Entwickler hier ebenso wie bei anonymen Typen Vorsicht walten lassen sollten). Vor diesem Hintergrund können möglicherweise Fälle eintreten, in denen der Tupelwerttyp 128 Byte überschreitet und daher ggf. einen Grenzfall für die Verwendung anonymer Typen darstellt, weil es sich um einen Verweistyp handelt. Mit Ausnahme dieser Grenzfälle (der Zugriff über eine typische Reflektion wäre ein weiteres Beispiel) gibt es praktisch keinen Grund, einen anonymen Typ bei der Programmierung mit C# 7.0 oder höher zu verwenden.
Die Möglichkeit der Programmierung mit einem Tupeltypobjekt besteht schon lange (wie ich bereits erwähnte, wurde eine Tupelklasse „System.Tuple<…>“ mit .NET Framework 4 eingeführt, war aber auch davor schon in Silverlight verfügbar). Diese Lösungen wiesen jedoch nie eine begleitende C#-Syntax auf und selten mehr als eine .NET-API. C# 7.0 bringt eine erstklassige Tupelsyntax mit sich, die Literale (etwa var tuple = (42, "Inigo Montoya“), implizite Typisierung, starke Typisierung, öffentliche API-Nutzung, integrierte IDE-Unterstützung für benannte ItemX-Daten und vieles mehr ermöglicht. Zugegebenermaßen werden Sie Tupel wohl nicht in jeder C#-Datei verwenden. Sie können jedoch sehr nützlich sein. Sie werden die Tupelsyntax sicher der Alternative eines out-Parameters oder einem anonymen Typ vorziehen.
Ein Großteil dieses Artikels beruht auf meinem Buch „Essential C#“ (IntelliTect.com/EssentialCSharp), das ich zurzeit mit „Essential C# 7.0“ auf den neuesten Stand bringe. Weitere Informationen zu diesem Thema finden Sie in Kapitel 3.
RICHTLINIEN FÜR DIE TUPELELEMENTBENENNUNG
Verwenden Sie camelCase für alle Variablen, die mithilfe von Tupelsyntax deklariert werden.
Erwägen Sie die Verwendung von PascalCase für alle Tupelelementnamen.
Mark Michaelis ist der Gründer von IntelliTect und arbeitet als leitender technischer Architekt und Trainer. Seit fast zwei Jahrzehnten ist er ein Microsoft MVP und seit 2007 Microsoft-Regionalleiter. Michaelis arbeitet in verschiedenen Microsoft-Softwareentwicklungs-Reviewteams mit, einschließlich C#, Microsoft Azure, SharePoint und Visual Studio ALM. Er hält häufig Vorträge bei Entwicklerkonferenzen und hat viele Bücher geschrieben, einschließlich seines letzten „Essential C# 6.0 (5th Edition)“ (itl.tc/EssentialCSharp). Sie können ihn auf Facebook unter facebook.com/Mark.Michaelis, über seinen Blog unter IntelliTect.com/Mark, auf Twitter: @markmichaelis oder per E-Mail unter mark@IntelliTect.com erreichen.
Unser Dank gilt dem folgenden technischen Experten bei Microsoft für die Durchsicht dieses Artikels: Mads Torgersen