Menu

C# Generics: generische Klassen, Methoden und Constraints

Wie Generics in C# funktionieren: generische Klassen und Methoden mit Typparametern schreiben, Typinferenz, Constraints mit where (class, struct, new(), Basisklassen, Interfaces), default(T), statische Member pro Typ und Kovarianz mit IEnumerable<out T>.

Diese Seite enthält ausführbare Editoren - bearbeiten, ausführen und Ausgabe sofort sehen.

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:

ConstraintBedeutungErlaubt dir
where T : classReferenztypnull zuweisen, as verwenden
where T : structnicht nullable WerttypT? als Nullable<T> verwenden
where T : new()öffentlicher parameterloser Konstruktornew T() aufrufen (muss zuletzt stehen)
where T : Entityleitet von Entity abdie Member von Entity verwenden
where T : IComparable<T>implementiert das InterfaceCompareTo aufrufen
where T : Uleitet von einem anderen Typparameter abzwei Parameter in Beziehung setzen
where T : unmanaged (C# 7.3)Struct ohne Referenzen darinZeiger, stackalloc
where T : Enum (C# 7.3)ein Enum-Typan Methoden von Enum übergeben
where T : notnull (C# 8)nicht nullable TypAnnotationen 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

  • object verwenden, wo ein Typparameter hingehört. Das gibt die Prüfungen zur Kompilierzeit auf und boxt Werttypen.
  • Zwei T-Werte mit == vergleichen. Ohne class-Constraint kompiliert es nicht (CS0019), und mit einem vergleicht es Referenzen. Nimm EqualityComparer<T>.Default.Equals(a, b), das für jedes T funktioniert und das eigene Equals des Typs respektiert.
  • default(T) als „nicht gefunden“ zurückgeben. Bei Werttypen ist das mehrdeutig. Nimm bool 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> in List<Base> umwandeln lässt. Nimm stattdessen IEnumerable<Base> oder IReadOnlyList<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.

Coddy programming languages illustration

Lerne mit Coddy zu programmieren

LOS GEHT'S