Mit Generics schreibst du einen Typ oder eine Methode einmal und verwendest sie mit vielen Typen, ohne die Typsicherheit zu verlieren. Statt einer List aus object, die Casts braucht und alles annimmt, bekommst du List<int> und List<Order>, jede vom Compiler geprüft. Der Platzhalter in spitzen Klammern, per Konvention T, ist ein Typparameter; der Typ, den ein Aufrufer liefert, ist das Typargument.
Warum Generics: das Problem, das sie lösen
Vor den Generics (C# 1) speicherten Collections object. Das kompilierte, aber Fehler zeigten sich erst zur Laufzeit, und jedes int wurde beim Hineinlegen geboxt:
var prices = new System.Collections.ArrayList();
prices.Add(9.99m);
prices.Add("12.50"); // compiles: anything is an object
decimal total = 0;
foreach (object p in prices)
total += (decimal)p; // InvalidCastException on the string
Mit List<decimal> ist das zweite Add ein Kompilierfehler, die Schleife braucht keinen Cast, und die Decimals werden als Decimals gespeichert.
Generische Klassen
Deklariere Typparameter nach dem Klassennamen und verwende sie in der Klasse wie jeden anderen Typ:
Ausgabe:
Page 2/3: Chen, Dara, more: True
Page 1/1: 90, 72, 85, more: False
Page<string> und Page<int> sind zwei verschiedene Typen aus einer Definition. Eine Klasse kann mehrere Typparameter haben (Dictionary<TKey, TValue>); nenne sie T, wenn es einen gibt, und TSomething, wenn es mehrere sind.
Generische Methoden und Typinferenz
Eine Methode kann eigene Typparameter haben, egal ob ihre Klasse generisch ist. Aufrufer lassen das Typargument meist weg, weil der Compiler es aus den Argumenten ableitet:
Ausgabe:
2 1
back front
ho ho ho
4.50 EUR, 12.00 EUR, 0.99 EUR
Die Inferenz arbeitet nur mit Argumenten, nie mit dem Rückgabetyp. Repeat(0, 3) braucht keine Angabe, aber eine Methode wie T Create<T>() hat nichts, woraus sie ableiten könnte, Aufrufer müssen also Create<Customer>() schreiben. Der Großteil von LINQ besteht aus generischen Methoden, die sich auf diese Inferenz stützen: prices.Where(p => p > 1) ist Where<decimal>, ohne dass du es schreibst.
Constraints: was T sein darf
Innerhalb einer generischen Methode lässt der Compiler nur zu, was für jedes mögliche T funktioniert. Ohne Constraint ist das kaum mehr als das, was object unterstützt. Um CompareTo aufzurufen, eine Instanz zu erzeugen oder eine Property zu lesen, sag dem Compiler mit where, was T sein muss:
Ausgabe:
17
plum
19.99
42 Zoe
Die Constraints, die du verwenden wirst:
| Constraint | Bedeutung | Erlaubt dir |
|---|---|---|
where T : class | Referenztyp | null zuweisen, as verwenden |
where T : struct | nicht nullable Werttyp | T? als Nullable<T> verwenden |
where T : new() | öffentlicher parameterloser Konstruktor | new T() aufrufen (muss zuletzt stehen) |
where T : Entity | leitet von Entity ab | die Member von Entity verwenden |
where T : IComparable<T> | implementiert das Interface | CompareTo aufrufen |
where T : U | leitet von einem anderen Typparameter ab | zwei Parameter in Beziehung setzen |
where T : unmanaged (C# 7.3) | Struct ohne Referenzen darin | Zeiger, stackalloc |
where T : Enum (C# 7.3) | ein Enum-Typ | an Methoden von Enum übergeben |
where T : notnull (C# 8) | nicht nullable Typ | Annotationen für Nullable-Referenztypen |
Mehrere Constraints für einen Parameter werden durch Kommas getrennt; mehrere Parameter bekommen jeweils eine eigene where-Klausel: class Cache<TKey, TValue> where TKey : notnull where TValue : class.
Ein fehlender Constraint zeigt sich als Kompilierfehler an der Stelle, an der du den Member verwendest, zum Beispiel CS1061 ('T' does not contain a definition for 'CompareTo') oder CS0304 beim Aufruf von new T() ohne new().
default(T)
Generischer Code braucht manchmal „den leeren Wert“ eines Typs, den er nicht kennt. default(T) liefert 0 für Zahlen, false für bool, null für Referenztypen und Nullable-Typen und sonst einen auf null gesetzten Struct:
Ausgabe:
31
0
Porto
True
0001-01-01
Die zweite Zeile zeigt die Schwäche dieses Musters: Bei Werttypen sehen „nicht gefunden“ und „eine 0 gefunden“ gleich aus. Deshalb verwenden Framework-Methoden stattdessen die Form TryGet (bool TryGetValue(TKey key, out TValue value)). Seit C# 7.1 kannst du das kürzere return default; schreiben, wenn der Typ aus dem Kontext klar ist.
Statische Member gelten pro konstruiertem Typ
Jeder konstruierte Typ (Counter<int>, Counter<string>) bekommt seine eigene Kopie der statischen Felder der Klasse:
Ausgabe:
2
1
False
Das ist gelegentlich nützlich (ein Cache pro Typ wie TypeInfo<T>.Name, einmal pro T berechnet) und gelegentlich eine Überraschung, wenn du einen gemeinsamen Zähler erwartet hast. Lege gemeinsamen Zustand in eine nicht generische Klasse.
Generische Collections
Die generischen Collections decken die meisten Bedürfnisse ab: List<T>, Dictionary<TKey, TValue>, HashSet<T>, Queue<T>, Stack<T> und die Interfaces, die sie teilen, IEnumerable<T>, ICollection<T>, IList<T>, IReadOnlyList<T>. Nimm das engste Interface an, das eine Methode braucht (IEnumerable<T>, wenn sie nur durchläuft), und gib einen konkreten oder schreibgeschützten Typ zurück.
Kovarianz und Kontravarianz
Ein Dog ist ein Animal, aber eine List<Dog> ist keine List<Animal>:
List<Dog> dogs = new List<Dog>();
List<Animal> animals = dogs;
// error CS0029: Cannot implicitly convert type 'List<Dog>' to 'List<Animal>'
Wäre das erlaubt, würde animals.Add(new Cat()) eine Katze in die Hundeliste setzen. Interfaces, die Werte nur erzeugen, sind sicher, und sie werden mit out als kovariant deklariert:
IEnumerable<Animal> readOnly = dogs; // fine: IEnumerable<out T>
IReadOnlyList<Animal> alsoFine = dogs; // fine: IReadOnlyList<out T>
Das Spiegelbild, Kontravarianz, markiert mit in, gilt für Interfaces und Delegates, die Werte nur verbrauchen: Eine Action<Animal> lässt sich dort verwenden, wo eine Action<Dog> erwartet wird, denn alles, was mit jedem Tier umgehen kann, kann mit einem Hund umgehen. IComparer<in T> ist kontravariant, und Func<in T, out TResult> verwendet beide Markierungen. Varianz gilt nur für Interfaces und Delegates und nur für Typargumente, die Referenztypen sind: IEnumerable<int> lässt sich nicht in IEnumerable<object> umwandeln.
Häufige Fehler
objectverwenden, wo ein Typparameter hingehört. Das gibt die Prüfungen zur Kompilierzeit auf und boxt Werttypen.- Zwei
T-Werte mit==vergleichen. Ohneclass-Constraint kompiliert es nicht (CS0019), und mit einem vergleicht es Referenzen. NimmEqualityComparer<T>.Default.Equals(a, b), das für jedesTfunktioniert und das eigeneEqualsdes Typs respektiert. default(T)als „nicht gefunden“ zurückgeben. Bei Werttypen ist das mehrdeutig. Nimmbool TryX(..., out T value).- Zu viele Constraints. Jeder engt ein, wer die Methode verwenden kann. Schränke nur ein, was der Rumpf wirklich braucht.
- Erwarten, dass sich
List<Derived>inList<Base>umwandeln lässt. Nimm stattdessenIEnumerable<Base>oderIReadOnlyList<Base>an.
Häufig gestellte Fragen
Was sind Generics in C#?
Mit Generics schreibst du eine Klasse, ein Interface oder eine Methode einmal mit einem Platzhaltertyp, T, den Aufrufer ausfüllen: List<int>, List<Customer>. Der Compiler prüft jede Verwendung gegen den echten Typ, du bekommst also Typsicherheit ohne Casts, und Werttypen werden ohne Boxing gespeichert. Die Collections in System.Collections.Generic sind das bekannteste Beispiel.
Wie schreibe ich in C# eine generische Methode?
Setze die Typparameter hinter den Methodennamen: static T Largest<T>(List<T> items) where T : IComparable<T> { ... }. Aufrufer lassen das Typargument meist weg, weil der Compiler es aus den Argumenten ableitet: Largest(prices) funktioniert, wenn prices eine List<decimal> ist. Schreibe es explizit, Largest<decimal>(...), wenn es nichts gibt, woraus es abgeleitet werden kann.
Was bedeutet where T : class in C#?
Es ist ein Constraint: T muss ein Referenztyp sein, der generische Code darf einem T also null zuweisen und as T verwenden. Weitere Constraints sind where T : struct (ein nicht nullable Werttyp), where T : new() (hat einen öffentlichen parameterlosen Konstruktor, der Code kann also new T() aufrufen), where T : SomeBaseClass und where T : ISomeInterface, mit dem der Code die Member dieses Interfaces auf T aufrufen kann.
Was ist default(T) in C#?
Der Standardwert des Typs, der T am Ende ist: 0 für Zahlen, false für bool, null für Referenztypen und Nullable-Typen und ein auf null gesetzter Struct für andere Werttypen. Generischer Code verwendet ihn, wenn er „kein Wert“ braucht, ohne den Typ zu kennen, etwa als Rückgabewert eines gescheiterten Nachschlagens. C# 7.1 hat das kürzere Literal default hinzugefügt.
Warum kann ich in C# eine List<Dog> nicht einer List<Animal> zuweisen?
Weil eine List<Animal> jedes Tier annimmt: Wäre die Zuweisung erlaubt, könnte Code eine Cat zu etwas hinzufügen, das in Wahrheit eine List<Dog> ist. Der Compiler lehnt das ab (CS0029). Schreibgeschützte Interfaces sind kovariant, IEnumerable<Animal> animals = dogs; und IReadOnlyList<Animal> animals = dogs; kompilieren also beide, weil sich über sie nichts hinzufügen lässt.