Kapitel 4. Generika
Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com
In Kapitel 3 habe ich gezeigt, wie man Typen schreibt und die verschiedenen Arten von Mitgliedern beschrieben, die sie enthalten können. Es gibt jedoch noch eine weitere Dimension von Klassen, Strukturen, Schnittstellen und Methoden, die ich nicht gezeigt habe. Sie können Typparameter definieren, also Platzhalter, mit denen du zur Kompilierzeit verschiedene Typen einfügen kannst. So kannst du nur einen Typ schreiben und dann mehrere Versionen davon erzeugen. Ein Typ, der dies kann, wird generischer Typ genannt. In den Laufzeitbibliotheken ist zum Beispiel eine generische Klasse namens List<T>
definiert, die als Array mit variabler Länge fungiert. T
ist hier ein Typparameter, und du kannst fast jeden Typ als Argument verwenden, also List<int>
ist eine Liste von Ganzzahlen, List<string>
eine Liste von Strings und so weiter.1 Du kannst auch eine generische Methode schreiben, d. h. eine Methode, die ihre eigenen Typargumente hat, unabhängig davon, ob ihr enthaltener Typ generisch ist.
Generische Typen und Methoden sind optisch dadurch gekennzeichnet, dass sie immer spitze Klammern (<
und >
) nach dem Namen haben. Diese enthalten eine kommagetrennte Liste von Parametern oder Argumenten. Hier gilt dieselbe Unterscheidung zwischen Parametern und Argumenten wie bei Methoden: In der Deklaration wird eine Liste von Parametern angegeben, und wenn du die Methode oder den Typ verwenden willst, gibst du Argumente für diese Parameter an. So definiert List<T>
einen einzelnen Typ-Parameter, T
, und List<int>
liefert ein Typ-Argument, int
, für diesen Parameter.
Du kannst jeden beliebigen Namen für Typ-Parameter verwenden, sofern die üblichen Einschränkungen für Bezeichner in C# eingehalten werden, aber es gibt einige gängige Konventionen. Es ist üblich (aber nicht universell), T
zu verwenden, wenn es nur einen Parameter gibt. Für Generics mit mehreren Parametern gibt es meist etwas aussagekräftigere Namen. Die Laufzeitbibliotheken definieren zum Beispiel die Dictionary<TKey, TValue>
Collection-Klasse. Manchmal siehst du einen solchen beschreibenden Namen auch dann, wenn es nur einen Parameter gibt, aber in jedem Fall siehst du ein T
Präfix, damit die Typparameter bei der Verwendung in deinem Code auffallen.
Generische Typen
Klassen, Structs, Records und Interfaces können generisch sein, ebenso wie Delegates, die wir uns in Kapitel 9 ansehen werden. Beispiel 4-1 zeigt, wie man eine generische Klasse definiert.
Beispiel 4-1. Definieren einer generischen Klasse
public
class
NamedContainer
<
T
>
{
public
NamedContainer
(
T
item
,
string
name
)
{
Item
=
item
;
Name
=
name
;
}
public
T
Item
{
get
;
}
public
string
Name
{
get
;
}
}
Die Syntax für Structs, Records und Interfaces ist weitgehend gleich: Auf den Typnamen folgt sofort eine Liste mit Typparametern. Beispiel 4-2 zeigt, wie man einen generischen Datensatz schreibt, der der Klasse in Beispiel 4-1 ähnelt.
Beispiel 4-2. Definieren eines generischen Datensatzes
public
record
NamedContainer
<
T
>(
T
Item
,
string
Name
);
In der Definition eines generischen Typs kann ich den Typparameter T
überall dort verwenden, wo du normalerweise einen Typnamen sehen würdest. Im ersten Beispiel habe ich ihn als Typ eines Konstruktorarguments verwendet, und in beiden Beispielen als Typ der Eigenschaft Item
. Ich könnte auch Felder vom Typ T
definieren. (Das habe ich auch getan, wenn auch nicht explizit. Automatische Eigenschaften erzeugen versteckte Felder, also hat meine Eigenschaft Item
ein zugehöriges verstecktes Feld vom Typ T
). Du kannst auch lokale Variablen vom Typ T
definieren. Und es steht dir frei, Typparameter als Argumente für andere generische Typen zu verwenden. Mein NamedContainer<T>
könnte zum Beispiel Member vom Typ List<T>
deklarieren.
Die Typen, die in den Beispielen 4-1 und 4-2 definiert werden, sind, wie alle generischen Typen, keine vollständigen Typen. Die Deklaration eines generischen Typs ist ungebunden, d.h. es gibt Typparameter, die ausgefüllt werden müssen, um einen vollständigen Typ zu erzeugen. Grundlegende Fragen, wie z. B. wie viel Speicher eine Instanz von NamedContainer<T>
benötigt, können nicht beantwortet werden, ohne zu wissen, was T
ist - das versteckte Feld für die Eigenschaft Item
würde 4 Byte benötigen, wenn T
ein int
wäre, aber 16 Byte, wenn es ein decimal
wäre. Die CLR kann keinen ausführbaren Code für einen Typ erzeugen, wenn sie nicht weiß, wie der Inhalt im Speicher angeordnet wird. Um diesen oder einen anderen generischen Typ zu verwenden, müssen wir also Typargumente angeben. Beispiel 4-3 zeigt, wie das geht. Wenn Typargumente angegeben werden, nennt man das Ergebnis manchmal einen konstruierten Typ. (Das hat nichts mit Konstruktoren zu tun, der besonderen Art von Membern, die wir in Kapitel 3 betrachtet haben. Tatsächlich verwendet Beispiel 4-3 auch diese - es ruft die Konstruktoren einiger konstruierter Typen auf.)
Beispiel 4-3. Verwendung einer generischen Klasse
var
a
=
new
NamedContainer
<
int
>(
42
,
"The answer"
);
var
b
=
new
NamedContainer
<
int
>(
99
,
"Number of red balloons"
);
var
c
=
new
NamedContainer
<
string
>(
"Programming C#"
,
"Book title"
);
Du kannst einen konstruierten generischen Typ überall dort verwenden, wo du auch einen normalen Typ verwenden würdest. Du kannst sie zum Beispiel als Typen für Methodenparameter und Rückgabewerte, Eigenschaften oder Felder verwenden. Du kannst sogar einen Typ als Argument für einen anderen generischen Typ verwenden, wie Beispiel 4-4 zeigt.
Beispiel 4-4. Konstruierte generische Typen als Typargumente
// ...where a, and b come from
Example 4-3.
var
namedInts
=
new
List
<
NamedContainer
<
int
>
>
(
)
{
a
,
b
}
;
var
namedNamedItem
=
new
NamedContainer
<
NamedContainer
<
int
>
>
(
a
,
"Wrapped"
)
;
Jeder unterschiedliche Typ, den ich als Argument an NamedContainer<T>
weitergebe, konstruiert einen eigenen Typ. (Und bei generischen Typen mit mehreren Typargumenten würde jede unterschiedliche Kombination von Typargumenten einen anderen Typ konstruieren.) Das bedeutet, dass NamedContainer<int>
ein anderer Typ ist als NamedContainer<string>
. Deshalb gibt es keinen Konflikt, wenn du NamedContainer<int>
als Typargument für ein anderes NamedContainer
zu verwenden, wie es in der letzten Zeile von Beispiel 4-4 geschieht - hier gibt es keine unendliche Rekursion.
Da jeder unterschiedliche Satz von Typargumenten einen eigenen Typ erzeugt, gibt es in den meisten Fällen keine implizite Kompatibilität zwischen verschiedenen Formen desselben generischen Typs. Du kannst eine NamedContainer<int>
nicht einer Variablen des Typs NamedContainer<string>
zuweisen oder umgekehrt. Es macht Sinn, dass diese beiden Typen nicht kompatibel sind, denn int
und string
sind ganz unterschiedliche Typen. Aber was wäre, wenn wir object
als Typargument verwenden würden? Wie in Kapitel 2 beschrieben, kannst du fast alles in eine object
Variable schreiben. Wenn du eine Methode mit einem Parameter des Typs object
schreibst, ist es in Ordnung, einen string
zu übergeben. Du könntest also erwarten, dass eine Methode, die einen NamedContainer<object>
nimmt, mit einem NamedContainer<string>
zufrieden ist. Das wird nicht funktionieren, aber einige generische Typen (insbesondere Schnittstellen und Delegierte) können erklären, dass sie diese Art von Kompatibilitätsbeziehung wünschen. Die Mechanismen, die dies unterstützen ( Kovarianz und Kontravarianz genannt), sind eng mit den Vererbungsmechanismen des Typsystems verbunden. In Kapitel 6 geht es um Vererbung und Typkompatibilität, daher werde ich diesen Aspekt der generischen Typen dort behandeln.
Die Anzahl der Typparameter ist Teil der Identität eines ungebundenen generischen Typs. Dadurch ist es möglich, mehrere Typen mit demselben Namen einzuführen, solange sie eine unterschiedliche Anzahl von Typparametern haben. (Der Fachbegriff für die Anzahl der Typparameter ist Arität).
Du könntest also eine generische Klasse mit dem Namen Operation<T>
und eine weitere Klasse mit dem Namen Operation<T1, T2>
und Operation<T1, T2, T3>
usw. definieren, alle im selben Namensraum, ohne dass es zu Unklarheiten kommt. Wenn du diese Typen verwendest, ist anhand der Anzahl der Argumente klar, welcher Typ gemeint ist -Operation<int>
verwendet eindeutig den ersten, während Operation<string, double>
zum Beispiel den zweiten verwendet. Und aus demselben Grund würde sich eine nicht-generische Klasse Operation
von generischen Typen gleichen Namens unterscheiden.
Mein Beispiel NamedContainer<T>
macht nichts mit den Instanzen seines Typarguments T
- es ruft keine Methoden auf und verwendet keine Eigenschaften oder andere Mitglieder von T
. Es akzeptiert lediglich ein T
als Konstruktorargument, das es zum späteren Abruf speichert. Das gilt auch für viele generische Typen in den Laufzeitbibliotheken - ich habe bereits einige Sammelklassen erwähnt, die alle Variationen desselben Themas sind, nämlich Daten für den späteren Abruf zu speichern.
Dafür gibt es einen Grund: Eine generische Klasse kann mit jedem Typ arbeiten, also kann sie wenig über ihre Typargumente annehmen. Das muss aber nicht so sein. Du kannst Einschränkungen für deine Typargumente festlegen.
Zwänge
In C# kannst du festlegen, dass ein Typargument bestimmte Anforderungen erfüllen muss. Nehmen wir zum Beispiel an, du möchtest bei Bedarf neue Instanzen des Typs erstellen können. Beispiel 4-5 zeigt eine einfache Klasse, die eine verzögerte Konstruktion ermöglicht: Sie stellt eine Instanz über eine statische Eigenschaft zur Verfügung, versucht aber erst dann, diese Instanz zu konstruieren, wenn du die Eigenschaft zum ersten Mal liest.
Beispiel 4-5. Eine neue Instanz eines parametrisierten Typs erstellen
// For illustration only. Consider using Lazy<T> in a real program.
public
static
class
Deferred
<
T
>
where
T
:
new
(
)
{
private
static
T
?
_instance
;
public
static
T
Instance
{
get
{
if
(
_instance
=
=
null
)
{
_instance
=
new
T
(
)
;
}
return
_instance
;
}
}
}
Warnung
In der Praxis würdest du eine solche Klasse nicht schreiben, denn die Laufzeitbibliotheken bieten Lazy<T>
an, das die gleiche Aufgabe erfüllt, aber flexibler ist. Lazy<T>
kann in Multithreading-Code korrekt funktionieren, was in Beispiel 4-5 nicht der Fall ist. Beispiel 4-5 soll nur veranschaulichen, wie Constraints funktionieren. Verwende es nicht!
Damit diese Klasse ihre Aufgabe erfüllen kann, muss sie in der Lage sein, eine Instanz des Typs zu erstellen, der als Argument für T
übergeben wird. Der Accessor get
verwendet das Schlüsselwort new
, und da er keine Argumente übergibt, ist es klar, dass T
einen parameterlosen Konstruktor haben muss. Das ist aber nicht bei allen Typen der Fall. Was passiert also, wenn wir versuchen, einen Typ ohne einen geeigneten Konstruktor als Argument für Deferred<T>
zu verwenden?
Der Compiler lehnt sie ab, weil sie gegen eine Einschränkung verstößt, die dieser generische Typ für T
deklariert hat. Constraints erscheinen direkt vor der öffnenden Klammer der Klasse und beginnen mit dem Schlüsselwort where
. Die Einschränkung new()
in Beispiel 4-5 besagt, dass T
einen Konstruktor mit Null-Argumenten haben muss .
Wäre diese Einschränkung nicht vorhanden, würde die Klasse in Beispiel 4-5 nicht kompiliert werden - du würdest einen Fehler in der Zeile bekommen, in der versucht wird, eine neue T
zu konstruieren. Ein generischer Typ (oder eine Methode) darf nur Eigenschaften seiner Typparameter verwenden, die er durch Constraints spezifiziert hat oder die durch den Basistyp object
definiert sind. (Der Typ object
definiert z. B. eine Methode ToString
, die du auf Instanzen eines beliebigen Typs aufrufen kannst, ohne eine Einschränkung angeben zu müssen).
C# bietet nur eine sehr begrenzte Anzahl von Beschränkungen. Du kannst z.B. keinen Konstruktor verlangen, der Argumente annimmt. Tatsächlich unterstützt C# nur sechs Arten von Beschränkungen für ein Typ-Argument: eine Typ-Beschränkung, eine Referenztyp-Beschränkung, eine Werttyp-Beschränkung, notnull
, unmanaged
und die new()
Beschränkung. Die letzte haben wir gerade gesehen, also schauen wir uns den Rest an.
Typ Beschränkungen
Du kannst das Argument für einen Typparameter darauf beschränken, mit einem bestimmten Typ kompatibel zu sein. Damit kannst du zum Beispiel verlangen, dass der Argumenttyp eine bestimmte Schnittstelle implementiert. Beispiel 4-6 zeigt die Syntax.
Beispiel 4-6. Eine Typbeschränkung verwenden
public
class
GenericComparer
<
T
>
:
IComparer
<
T
>
where
T
:
IComparable
<
T
>
{
public
int
Compare
(
T
?
x
,
T
?
y
)
{
if
(
x
=
=
null
)
{
return
y
=
=
null
?
0
:
-
1
;
}
return
x
.
CompareTo
(
y
)
;
}
}
Ich erkläre kurz den Zweck dieses Beispiels, bevor ich beschreibe, wie es die Vorteile einer Typbeschränkung nutzt. Diese Klasse bildet eine Brücke zwischen zwei Arten von Wertevergleichen, die du in .NET findest. Einige Datentypen verfügen über eine eigene Vergleichslogik, aber manchmal kann es sinnvoller sein, den Vergleich als separate Funktion in einer eigenen Klasse zu implementieren. Diese beiden Stile werden durch die Symbole IComparable<T>
und IComparer<T>
Schnittstellen dargestellt, die beide Teil der Laufzeitbibliotheken sind. (Sie befinden sich in den Namensräumen System
bzw. System.Collections.Generics
.) Ich habe IComparer<T>
in Kapitel 3gezeigt - eineImplementierung dieser Schnittstelle kann zwei Objekte oder Werte des Typs T
vergleichen. Die Schnittstelle definiert eine einzelne Compare
Methode, die zwei Argumente entgegennimmt und entweder eine negative Zahl, 0, oder eine positive Zahl zurückgibt, wenn das erste Argument kleiner, gleich oder größer als das zweite ist.
IComparable<T>
ist sehr ähnlich, aber die Methode CompareTo
nimmt nur ein einziges Argument an, weil du mit dieser Schnittstelle eine Instanz aufforderst, sich mit einer anderen Instanz zu vergleichen.
Einige der Auflistungsklassen der Laufzeitbibliotheken erfordern die Angabe eines IComparer<T>
um Ordnungsoperationen wie das Sortieren zu unterstützen. Sie verwenden das Modell, bei dem ein separates Objekt den Vergleich durchführt, weil dies zwei Vorteile gegenüber dem IComparable<T>
Modell bietet. Erstens kannst du so Datentypen verwenden, die IComparable<T>
nicht implementieren. Zweitens kannst du so verschiedene Sortierreihenfolgen einfügen. (Nehmen wir zum Beispiel an, du möchtest einige Strings nach der Groß- und Kleinschreibung sortieren. Der Typ string
implementiert IComparable<string>
, bietet aber eine Sortierreihenfolge, die Groß- und Kleinschreibung berücksichtigt). IComparer<T>
ist also das flexiblere Modell. Was aber, wenn du einen Datentyp verwendest, der IComparable<T>
implementiert, und du mit der Reihenfolge zufrieden bist, die er bietet? Was würdest du tun, wenn du mit einer API arbeitest, die eine IComparer<T>
verlangt?
Die Antwort ist, dass du wahrscheinlich einfach die .NET-Eigenschaft verwenden solltest, die für genau dieses Szenario entwickelt wurde: Comparer<T>.Default
. Wenn T
IComparable<T>
implementiert, wird diese Eigenschaft ein IComparer<T>
zurückgeben, das genau das tut, was du willst. In der Praxis brauchst du den Code in Beispiel 4-6 also nicht zu schreiben, weil Microsoft ihn bereits für dich geschrieben hat. Dennoch ist es lehrreich zu sehen, wie du deine eigene Version schreiben würdest, denn es zeigt, wie du eine Typbeschränkung verwenden kannst.
Die Zeile, die mit dem Schlüsselwort where
beginnt, besagt, dass diese generische Klasse das Argument für ihren Typparameter T
benötigt, um IComparable<T>
zu implementieren. Ohne diesen Zusatz würde die Methode Compare
nicht kompiliert werden - sie ruft die Methode CompareTo
mit einem Argument vom Typ T
auf. Diese Methode ist nicht bei allen Objekten vorhanden, und der C#-Compiler erlaubt dies nur, weil wir T
als Implementierung einer Schnittstelle definiert haben, die eine solche Methode anbietet.
Schnittstellenbeschränkungen sind etwas seltsam: Auf den ersten Blick sieht es so aus, als bräuchten wir sie eigentlich nicht. Wenn eine Methode ein bestimmtes Argument benötigt, um eine bestimmte Schnittstelle zu implementieren, würdest du normalerweise einfach diese Schnittstelle als Typ des Arguments verwenden. In Beispiel 4-6 ist das jedoch nicht möglich. Du kannst das in Beispiel 4-7 ausprobieren. Es lässt sich nicht kompilieren.
Beispiel 4-7. Wird nicht kompiliert: Schnittstelle nicht implementiert
public
class
GenericComparer
<
T
>
:
IComparer
<
T
>
{
public
int
Compare
(
IComparable
<
T
>?
x
,
T
?
y
)
{
if
(
x
==
null
)
{
return
y
==
null
?
0
:
-
1
;
}
return
x
.
CompareTo
(
y
);
}
}
Der Compiler wird sich beschweren, dass ich die Methode Compare
der Schnittstelle IComparer<T>
nicht implementiert habe. Beispiel 4-7 hat eine Compare
Methode, aber ihre Signatur ist falsch - das erste Argument sollte T
sein. Ich könnte auch die korrekte Signatur ausprobieren, ohne die Einschränkung anzugeben, wie Beispiel 4-8 zeigt.
Beispiel 4-8. Wird nicht kompiliert: fehlende Einschränkung
public
class
GenericComparer
<
T
>
:
IComparer
<
T
>
{
public
int
Compare
(
T
?
x
,
T
?
y
)
{
if
(
x
==
null
)
{
return
y
==
null
?
0
:
-
1
;
}
return
x
.
CompareTo
(
y
);
}
}
Auch das wird fehlschlagen, weil der Compiler die Methode CompareTo
, die ich verwenden will, nicht finden kann. Die Einschränkung für T
in Beispiel 4-6 sorgt dafür, dass der Compiler weiß, was diese Methode wirklich ist.
Type Constraints müssen übrigens nicht unbedingt Schnittstellen sein. Du kannst jeden Typ verwenden. Du kannst zum Beispiel verlangen, dass ein bestimmtes Typargument von einer bestimmten Basisklasse abgeleitet ist. Noch raffinierter ist es, wenn du die Einschränkung eines Parameters in Bezug auf einen anderen Typparameter definierst. Beispiel 4-9 verlangt zum Beispiel, dass das erste Typargument vom zweiten abgeleitet wird.
Beispiel 4-9. Ein Argument von einem anderen ableiten
public
class
Foo
<
T1
,
T2
>
where
T1
:
T2
...
Typbeschränkungen sind ziemlich spezifisch - sie erfordern entweder eine bestimmte Vererbungsbeziehung oder die Implementierung bestimmter Schnittstellen. Du kannst aber auch etwas weniger spezifische Einschränkungen definieren.
Referenztyp-Einschränkungen
Du kannst ein Typargument darauf beschränken, ein Referenztyp zu sein. Wie Beispiel 4-10 zeigt, sieht das ähnlich aus wie eine Typbeschränkung. Du setzt nur das Schlüsselwort class
anstelle eines Typnamens ein. Wenn du dich in einem Kontext befindest, in dem nullable annotation aktiviert ist, ändert sich die Bedeutung dieser Annotation: Sie verlangt, dass das Typargument ein nicht-nullbarer Referenztyp ist. Wenn du class?
angibst, kann das Typ-Argument entweder ein nullable oder ein non-nullable Referenztyp sein.
Beispiel 4-10. Constraint, das einen Referenztyp erfordert
public
class
Bar
<
T
>
where
T
:
class
...
Diese Einschränkung verhindert die Verwendung von Werttypen wie int
, double
oder struct
als Typargument. Ihr Vorhandensein ermöglicht es deinem Code, drei Dinge zu tun, die sonst nicht möglich wären. Erstens kannst du Code schreiben, der prüft, ob Variablen des betreffenden Typs null
sind.2 Wenn du nicht festgelegt hast, dass der Typ ein Referenztyp ist, besteht immer die Möglichkeit, dass es sich um einen Wertetyp handelt, und diese können keine null
Werte haben. Die zweite Möglichkeit ist, dass du ihn als Zieltyp für den as
Operator verwenden kannst, den wir uns in Kapitel 6 ansehen werden. Dies ist eigentlich nur eine Variante der ersten Funktion - das Schlüsselwort as
erfordert einen Referenztyp, weil es ein null
Ergebnis erzeugen kann.
Hinweis
Eine class
Einschränkung verhindert die Verwendung von nullbaren Typen wie int?
(oder Nullable<int>
, wie die CLR sie nennt). Du kannst zwar einen int?
auf null
testen und ihn mit dem as
Operator verwenden, aber der Compiler generiert für beide Operationen ganz anderen Code für löschbare Typen als für einen Referenztyp. Er kann keine einzige Methode kompilieren, die sowohl mit Referenztypen als auch mit löschbaren Typen zurechtkommt, wenn du diese Funktionen nutzt.
Die dritte Funktion, die ein Referenztyp-Constraint ermöglicht, ist die Fähigkeit, bestimmte andere generische Typen zu verwenden. Für generischen Code ist es oft praktisch, eines seiner Typargumente als Argument für einen anderen generischen Typ zu verwenden, und wenn dieser andere Typ eine Beschränkung festlegt, musst du die gleiche Beschränkung auf deinen eigenen Typparameter anwenden. Wenn also ein anderer Typ eine Klassenbeschränkung festlegt, musst du möglicherweise eines deiner eigenen Argumente auf die gleiche Weise einschränken.
Das wirft natürlich die Frage auf, warum der Typ, den du verwendest, die Einschränkung überhaupt braucht. Es könnte sein, dass er einfach nur auf null
testen oder den as
Operator verwenden will, aber es gibt noch einen anderen Grund für die Anwendung dieser Einschränkung. Es gibt Situationen, in denen eine generische Methode ohne die class
Einschränkung kompiliert werden kann, aber nicht korrekt funktioniert, wenn sie mit einem Wertetyp verwendet wird. Um das zu veranschaulichen, beschreibe ich ein Szenario, in dem ich manchmal diese Art von Einschränkung verwenden muss.
Ich schreibe regelmäßig Tests, bei denen eine Instanz der zu testenden Klasse erzeugt wird und die außerdem ein oder mehrere Fake-Objekte benötigen, die für reale Objekte einspringen, mit denen das zu testende Objekt interagieren soll. Die Verwendung dieser Stellvertreter reduziert die Menge an Code, die ein einzelner Test ausführen muss, und kann es einfacher machen, das Verhalten des getesteten Objekts zu überprüfen. Wenn ich zum Beispiel überprüfen will, ob mein Code zum richtigen Zeitpunkt Nachrichten an einen Server sendet, ich aber nicht möchte, dass während eines Unit-Tests ein echter Server läuft, stelle ich ein Objekt bereit, das dieselbe Schnittstelle implementiert wie die Klasse, die die Nachricht übermitteln soll, die Nachricht aber nicht wirklich sendet. Diese Kombination aus einem zu testenden Objekt und einem Fake ist ein so gängiges Muster, dass es sinnvoll sein kann, den Code in eine wiederverwendbare Basisklasse zu packen. Die Verwendung von Generika bedeutet, dass die Klasse für jede Kombination aus dem zu testenden und dem gefälschten Typ funktionieren kann. Beispiel 4-11 zeigt eine vereinfachte Version einer Hilfsklasse, die ich manchmal in solchen Situationen schreibe .
Beispiel 4-11. Eingeschränkt durch eine andere Bedingung
using
Microsoft.VisualStudio.TestTools.UnitTesting
;
using
Moq
;
public
class
TestBase
<
TSubject
,
TFake
>
where
TSubject
:
new
()
where
TFake
:
class
{
public
TSubject
?
Subject
{
get
;
private
set
;
}
public
Mock
<
TFake
>?
Fake
{
get
;
private
set
;
}
[TestInitialize]
public
void
Initialize
()
{
Subject
=
new
TSubject
();
Fake
=
new
Mock
<
TFake
>();
}
}
Es gibt verschiedene Möglichkeiten, gefälschte Objekte für Testzwecke zu erstellen. Du könntest einfach neue Klassen schreiben, die dieselbe Schnittstelle wie deine echten Objekte implementieren, aber es gibt auch Bibliotheken von Drittanbietern, die solche Objekte erzeugen können. Eine dieser Bibliotheken heißt Moq (ein kostenloses Open-Source-Projekt), aus der die Klasse Mock<T>
in Beispiel 4-11 stammt. Sie ist in der Lage, eine Fake-Implementierung einer beliebigen Schnittstelle oder einer nicht versiegelten Klasse zu erzeugen.(In Kapitel 6 wird das Schlüsselwort sealed
beschrieben.) Sie stellt standardmäßig leere Implementierungen aller Mitglieder bereit, und du kannst bei Bedarf weitere interessante Verhaltensweisen konfigurieren. Du kannst auch überprüfen, ob der zu testende Code das gefälschte Objekt so verwendet hat, wie du es erwartet hast.
Inwiefern ist das für Constraints relevant? Die Klasse Mock<T>
legt eine Referenztyp-Beschränkung für ihr eigenes Typ-Argument T
fest. Das liegt an der Art und Weise, wie Moq dynamische Implementierungen von Typen zur Laufzeit erstellt; eine Technik, die nur bei Referenztypen funktioniert. Moq erzeugt zur Laufzeit einen Typ. Wenn T
eine Schnittstelle ist, implementiert der erzeugte Typ diese Schnittstelle, wenn T
eine Klasse ist, wird der erzeugte Typ von ihr abgeleitet.3 Wenn T
eine Struktur ist, kann es nichts Nützliches tun, weil man nicht von einem Wertetyp ableiten kann. Das bedeutet, dass ich bei der Verwendung von Mock<T>
in Beispiel 4-11 sicherstellen muss, dass das Typargument, das ich übergebe, kein struct ist (d.h. es muss ein Referenztyp sein). Das Typargument, das ich verwende, ist jedoch einer der Typparameter meiner Klasse: TFake
. Ich weiß also nicht, welcher Typ das sein wird - das hängt von demjenigen ab, dermeine Klasse verwendet.
Damit meine Klasse ohne Fehler kompiliert werden kann, muss ich sicherstellen, dass ich die Beschränkungen der von mir verwendeten generischen Typen erfüllt habe. Ich muss garantieren, dass Mock<TFake>
gültig ist, und die einzige Möglichkeit, das zu tun, ist, eine Einschränkung zu meinem eigenen Typ hinzuzufügen, die verlangt, dass TFake
ein Referenztyp ist. Und genau das habe ich in der dritten Zeile der Klassendefinition in Beispiel 4-11 getan. Ohne diese Einschränkung würde der Compiler in den beiden Zeilen, die auf Mock<TFake>
verweisen, Fehler melden.
Allgemeiner ausgedrückt: Wenn du einen deiner eigenen Typparameter als Typargument für eine Generik verwenden willst, die eine Einschränkung angibt, musst du dieselbe Einschränkung für deinen eigenen Typparameter angeben.
Werttyp-Einschränkungen
Genauso wie du ein Typargument auf einen Referenztyp beschränken kannst, kannst du es auch auf einen Werttyp beschränken. Wie in Beispiel 4-12 gezeigt, ist die Syntax ähnlich wie die für eine Referenztyp-Beschränkung, aber mit dem Schlüsselwort struct
.
Beispiel 4-12. Constraint, das einen Wertetyp erfordert
public
class
Quux
<
T
>
where
T
:
struct
...
Bisher haben wir das Schlüsselwort struct
nur im Zusammenhang mit benutzerdefinierten Wertetypen gesehen, aber obwohl es so aussieht, erlaubt diese Einschränkung bool
, enum
und alle eingebauten numerischen Typen wie int
sowie benutzerdefinierte Strukturen.
Der Nullable<T>
Typ von .NET erzwingt diese Einschränkung. Aus Kapitel 3 wissen wir, dassNullable<T>
ein Wrapper für Wertetypen ist, der es einer Variablen erlaubt, entweder einen Wert oder keinen Wert zu enthalten. (Normalerweise verwenden wir die spezielle Syntax von C#, also schreiben wir zum Beispiel int?
statt Nullable<int>
.) Der einzige Grund, warum es diesen Typ gibt, ist, dass er Typen, die sonst keinen Wert enthalten können, die Möglichkeit gibt, einen Nullwert zu speichern. Es macht also nur Sinn, diesen Typ mit einem Werttyp zu verwenden - Referenzvariablen können bereits auf null
gesetzt werden, ohne dass dieser Wrapper benötigt wird. Die Werttyp-Beschränkung verhindert, dass du Nullable<T>
mit Typen verwendest, für die sie nicht notwendig ist.
Werttypen bis ganz nach unten mit nicht verwalteten Beschränkungen
Du kannst unmanaged
als Einschränkung angeben, die verlangt, dass das Typargument ein Wertetyp ist, aber auch, dass es keine Referenzen enthält. Alle Felder des Typs müssen Werttypen sein, und wenn eines dieser Felder kein eingebauter primitiver Typ ist, dann darf der Typ wiederum nur Felder enthalten, die Werttypen sind, und so weiter und so fort. In der Praxis bedeutet dies, dass alle Daten entweder einem der fest eingebauten Typen (im Wesentlichen alle numerischen Typen, bool
oder ein Zeiger) oder einem enum
Typ entsprechen müssen. Dies ist vor allem in Interop-Szenarien von Interesse, da Typen, die der unmanaged
Einschränkung entsprechen, sicher und effizient an nicht verwalteten Code weitergegeben werden können.
Nicht-Null-Beschränkungen
Wenn du die in Kapitel 3 beschriebene Funktion der löschbaren Referenzen verwendest (die standardmäßig aktiviert ist, wenn du neue Projekte erstellst), kannst du eine notnull
Einschränkung angeben. Diese erlaubt entweder Werttypen oder nicht löschbare Referenztypen, aber keine löschbaren Referenztypen.
Andere besondere Typenzwänge
In Kapitel 3 wurden verschiedene spezielle Arten von Typen beschrieben, darunter Aufzählungstypen (enum
) und Delegatentypen (ausführlich in Kapitel 9 behandelt). Manchmal ist es sinnvoll, Typargumente auf eine dieser Arten von Typen zu beschränken. Hierfür gibt es keinen besonderen Trick: Du kannst einfach Typbeschränkungen verwenden. Alle Delegatetypen leiten sich von System.Delegate
ab, und alle Aufzählungstypen leiten sich von System.Enum
ab. Wie Beispiel 4-13 zeigt, kannst du einfach eine Typbeschränkung schreiben, die verlangt, dass ein Typargument von einem dieser Typen abgeleitet sein muss.
Beispiel 4-13. Einschränkungen, die die Typen Delegate und enum
erfordern
public
class
RequireDelegate
<
T
>
where
T
:
Delegate
{
}
public
class
RequireEnum
<
T
>
where
T
:
Enum
{
}
Mehrere Beschränkungen
Wenn du mehrere Beschränkungen für ein einzelnes Typargument festlegen möchtest, kannst du sie einfach in eine Liste schreiben, wie Beispiel 4-14 zeigt. Es gibt einige Einschränkungen. Du kannst die Beschränkungen class
, struct
, notnull
oder unmanaged
nicht kombinieren - sie schließen sich gegenseitig aus. Wenn du eines dieser Schlüsselwörter verwendest, muss es an erster Stelle in der Liste stehen. Wenn die new()
-Beschränkung vorhanden ist, muss sie an letzter Stelle stehen.
Beispiel 4-14. Mehrere Beschränkungen
public
class
Spong
<
T
>
where
T
:
IEnumerable
<
T
>,
IDisposable
,
new
()
...
Wenn dein Typ mehrere Typparameter hat, schreibst du für jeden Typparameter, den du einschränken möchtest, eine where
Klausel. Das haben wir bereits gesehen - Beispiel4-11 definiert Beschränkungen für beide Parameter.
Null-ähnliche Werte
Es gibt bestimmte Funktionen, die alle Typen unterstützen und daher keine Einschränkung erfordern. Dazu gehören die von der Basisklasse object
definierten Methoden, die in den Kapiteln 3 und 6 behandelt werden. Aber es gibt noch eine grundlegendere Funktion, die in generischem Code manchmal nützlich sein kann.
Variablen eines beliebigen Typs können mit einem Standardwert initialisiert werden. Wie du in den vorangegangenen Kapiteln gesehen hast, gibt es einige Situationen, in denen die CLR dies für uns erledigt. Zum Beispiel haben alle Felder eines neu erstellten Objekts einen bekannten Wert, auch wenn wir keine Feldinitialisierungen schreiben und keine Werte im Konstruktor angeben. Auch bei einem neuen Array beliebigen Typs werden alle Elemente mit einem bekannten Wert initialisiert. Die CLR tut dies, indem sie den entsprechenden Speicher mit Nullen füllt. Was das genau bedeutet, hängt vom jeweiligen Datentyp ab. Bei den eingebauten numerischen Typen ist der Wert buchstäblich die Zahl 0
, aber bei nicht numerischen Typen ist es etwas anderes. Für bool
ist der Standardwert false
und für einen Referenztyp ist es null
.
Manchmal kann es für generischen Code nützlich sein, eine Variable auf diesen anfänglichen Standardwert von Null zu setzen. In den meisten Situationen kannst du dafür jedoch keinen literalen Ausdruck verwenden. Du kannst null
nicht einer Variablen zuweisen, deren Typ durch einen Typparameter festgelegt ist, es sei denn, dieser Parameter wurde auf einen Referenztyp beschränkt. Und du kannst das Literal 0
keiner solchen Variablen zuweisen, weil es derzeit keine Möglichkeit gibt, ein Typargument auf einen numerischen Typ zu beschränken.
Stattdessen kannst du mit dem Schlüsselwort default
den Nullwert für jeden Typ anfordern. (Dies ist dasselbe Schlüsselwort, das wir in Kapitel 2 innerhalb einer switch
Anweisung gesehen haben, das aber auf eine ganz andere Weise verwendet wird. C# setzt die Tradition der C-Familie fort, für jedes Schlüsselwort mehrere, voneinander unabhängige Bedeutungen zu definieren). Wenn du schreibst default(SomeType)
, wobei SomeType
entweder ein bestimmter Typ oder ein Typparameter ist, erhältst du den Standardausgangswert für diesen Typ: 0
, wenn es sich um einen numerischen Typ handelt, und den entsprechenden Wert für jeden anderen Typ. Zum Beispiel hat der Ausdruck default(int)
den Wert 0
, default(bool)
ist false
und default(string)
ist null
. Du kannst dies mit einem generischen Typparameter verwenden, um den Standardwert für das entsprechende Typargument zu erhalten, wie Beispiel 4-15 zeigt.
Beispiel 4-15. Den Standardwert (Null) eines Typarguments ermitteln
static
void
ShowDefault
<
T
>()
{
Console
.
WriteLine
(
default
(
T
));
}
Innerhalb eines generischen Typs oder einer Methode, die einen Typparameter T
definiert, erzeugt der Ausdruck default(T)
den Standardwert Null für T
- was auch immer T
sein mag -, ohne dass Einschränkungen erforderlich sind. Du könntest also die generische Methode in Beispiel 4-15 verwenden, um zu überprüfen, ob die Standardwerte für int
, bool
und string
die von mir angegebenen Werte sind.
Hinweis
Wenn die Funktion für löschbare Referenzen (beschrieben in Kapitel 3) aktiviert ist, betrachtet der Compiler default(T)
als potenziellen Nullwert, es sei denn, du hast die Verwendung von Referenztypen durch die struct
Einschränkung ausgeschlossen.
In Fällen, in denen der Compiler in der Lage ist, den benötigten Typ zu ermitteln, kannst du eine einfachere Form verwenden. Anstatt default(T)
zu schreiben, kannst du einfach default
schreiben. Das würde in Beispiel 4-15 nicht funktionieren, weil Console.WriteLine
so ziemlich alles akzeptieren kann, so dass der Compiler es nicht auf eine Option eingrenzen kann, aber in Beispiel 4-16 wird es funktionieren, weil der Compiler sehen kann, dass der Rückgabetyp der generischen Methode T
ist, so dass diese eine default(T)
benötigt. Da er das ableiten kann, reicht es, wenn wir nur default
schreiben.
Beispiel 4-16. Den Standardwert (nullähnlich) eines abgeleiteten Typs ermitteln
static
T
?
GetDefault
<
T
>()
=>
default
;
Und da ich dir gerade ein Beispiel für eine solche Methode gezeigt habe, ist dies ein guter Zeitpunkt, um über generische Methoden zu sprechen.
Generische Methoden
Neben den generischen Typen unterstützt C# auch generische Methoden. In diesem Fall folgt die Parameterliste des generischen Typs auf den Methodennamen und steht vor der normalen Parameterliste der Methode. Beispiel 4-17 zeigt eine Methode mit einem einzigen Typparameter. Sie verwendet diesen Parameter als Rückgabetyp und auch als Elementtyp für ein Array, das als Argument der Methode übergeben wird. Diese Methode gibt das letzte Element des Arrays zurück. Da es sich um eine generische Methode handelt, funktioniert sie mit jedem Elementtyp des Arrays.
Beispiel 4-17. Eine generische Methode
public
static
T
GetLast
<
T
>(
T
[]
items
)
=>
items
[^
1
];
Hinweis
Du kannst generische Methoden entweder innerhalb von generischen Typen oder von nicht-generischen Typen definieren. Wenn eine generische Methode Mitglied eines generischen Typs ist, sind alle Typparameter des enthaltenen Typs innerhalb der Methode im Geltungsbereich, ebenso wie die methodenspezifischen Typparameter.
Genau wie bei einem generischen Typ kannst du eine generische Methode verwenden, indem du ihren Namen zusammen mit ihren Typargumenten angibst, wie Beispiel 4-18 zeigt.
Beispiel 4-18. Aufrufen einer generischen Methode
int
[]
values
=
{
1
,
2
,
3
};
int
last
=
GetLast
<
int
>(
values
);
Generische Methoden funktionieren ähnlich wie generische Typen, allerdings mit Typparametern, die nur innerhalb der Methodendeklaration und des Methodenrumpfes gelten. Du kannst Einschränkungen auf die gleiche Weise angeben wie bei generischen Typen. Die Einschränkungen erscheinen nach der Parameterliste der Methode und vor dem Methodenrumpf, wie Beispiel 4-19 zeigt.
Beispiel 4-19. Eine generische Methode mit einer Einschränkung
public
static
T
MakeFake
<
T
>()
where
T
:
class
{
return
new
Mock
<
T
>().
Object
;
}
Es gibt jedoch einen wichtigen Unterschied zwischen generischen Methoden und generischen Typen: Du musst die Typargumente einer generischen Methode nicht immer explizit angeben.
Typeninferenz
Der C#-Compiler ist oft in der Lage, die Typargumente für eine generische Methode abzuleiten. Ich kann Beispiel 4-18 ändern, indem ich die Liste der Typargumente aus dem Methodenaufruf entferne, wie Beispiel 4-20 zeigt. Das ändert die Bedeutung des Codes in keiner Weise.
Beispiel 4-20. Generische Inferenz von Argumenten des Methodentyps
int
[]
values
=
{
1
,
2
,
3
};
int
last
=
GetLast
(
values
);
Wenn bei einem solchen normal aussehenden Methodenaufruf keine nicht-generische Methode mit diesem Namen verfügbar ist, sucht der Compiler nach geeigneten generischen Methoden. Wenn die Methode in Beispiel 4-17 im Gültigkeitsbereich ist, ist sie ein Kandidat und der Compiler versucht, die Typargumente zu ermitteln. Dies ist ein ziemlich einfacher Fall. Die Methode erwartet ein Array des Typs T
und wir haben ein Array mit Elementen des Typs int
übergeben. Es ist also nicht schwer herauszufinden, dass dieser Code als ein Aufruf von GetLast<int>
behandelt werden sollte.
Bei komplizierteren Fällen wird es noch komplexer. Die C#-Spezifikation widmet dem Algorithmus zur Typinferenz etwa sechs Seiten, aber das alles dient nur einem Ziel: Du kannst Typargumente weglassen, wenn sie überflüssig sind. Übrigens wird die Typinferenz immer zur Kompilierzeit durchgeführt, d.h. sie basiert auf dem statischen Typ der Methodenargumente.
Bei APIs, die ausgiebig von Generika Gebrauch machen (wie z.B. LINQ, das Thema von Kapitel 10), kann die explizite Auflistung aller Typargumente den Code sehr schwer nachvollziehbar machen, so dass man sich häufig auf die Typinferenz verlässt. Und wenn du anonyme Typen verwendest, ist die Inferenz von Typargumenten unerlässlich, weil es nicht möglich ist, die Typargumente explizit anzugeben.
Generika und Tupel
Die leichtgewichtigen Tupel von C# haben eine besondere Syntax, aber für die Laufzeit ist nichts Besonderes an ihnen. Sie sind einfach nur Instanzen einer Reihe von generischen Typen. Schau dir Beispiel 4-21 an. Hier wird (int, int)
als Typ einer lokalen Variablen verwendet, um anzuzeigen, dass es sich um ein Tupel handelt, das zwei int
Werte enthält.
Beispiel 4-21. Deklaration einer Tupelvariable auf die normale Weise
(
int
,
int
)
p
=
(
42
,
99
);
Schau dir jetzt Beispiel 4-22 an. Hier wird der Typ ValueTuple<int, int>
im Namensraum System
verwendet. Das entspricht aber genau der Deklaration in Beispiel 4-21. Wenn du in Visual Studio oder VS Code mit der Maus über die Variable p2
fährst, wird ihr Typ als (int, int)
angezeigt.
Beispiel 4-22. Deklaration einer Tupelvariable mit ihrem zugrunde liegenden Typ
ValueTuple
<
int
,
int
>
p2
=
(
42
,
99
);
Eine Sache, die die spezielle Syntax von C# für Tupel hinzufügt, ist die Möglichkeit, die Tupel-Elemente zu benennen. Die ValueTuple
Familie nennt ihre Elemente Item1
, Item2
, Item3
, usw., aber in C# können wir andere Namen wählen. Wenn du eine lokale Variable mit benannten Tupel-Elementen deklarierst, sind diese Namen eine Fiktion, die von C# verwaltet wird - sie haben keine Laufzeitrepräsentation. Wenn eine Methode jedoch ein Tupel zurückgibt, wie in Beispiel 4-23, ist das anders: Die Namen müssen sichtbar sein, damit der Code, der diese Methode benutzt, dieselben Namen verwenden kann. Auch wenn diese Methode in einer Bibliothekskomponente enthalten ist, die mein Code referenziert hat, möchte ich Pos().X
schreiben können, anstatt Pos().Item1
verwenden zu müssen.
Beispiel 4-23. Ein Tupel zurückgeben
public
static
(
int
X
,
int
Y
)
Pos
()
=>
(
10
,
20
);
Damit das funktioniert, fügt der Compiler dem Rückgabewert der Methode ein Attribut mit dem Namen TupleElementNames
zu, das ein Array mit den zu verwendenden Eigenschaftsnamen enthält.(In Kapitel 14 werden die Attribute beschrieben.) Du kannst selbst keinen Code schreiben, der dies tut: Wenn du eine Methode schreibst, die ein ValueTuple<int, int>
zurückgibt, und versuchst, das TupleElementNamesAttribute
als return
Attribut zu verwenden, wird der Compiler einen Fehler produzieren, der dich darauf hinweist, dieses Attribut nicht direkt zu verwenden und stattdessen die Tupel-Syntax zu benutzen. Aber dieses Attribut ist die Art und Weise, wie der Compiler die Tupel-Elementnamen meldet.
Beachte, dass es in den Laufzeitbibliotheken eine weitere Familie von Tupel-Typen gibt, Tuple<T>
, Tuple<T1, T2>
, und so weiter. Diese sehen fast genauso aus wie die ValueTuple
Familie. Der Unterschied ist, dass die Tuple
Familie der generischen Typen alle Klassen sind, während alle ValueTuple
Typen Structs sind. Die leichtgewichtige Tupel-Syntax von C# verwendet nur die ValueTuple
Familie. Die Tuple
-Familie gibt es in den Laufzeitbibliotheken allerdings schon viel länger. Deshalb sieht man sie oft in älterem Code, der eine Reihe von Werten bündeln muss, ohne dafür einen neuen Typ zu definieren.
Innerhalb von Generika
Wenn du dich mit C++ Templates auskennst, wirst du inzwischen bemerkt haben, dass sich C# Generics deutlich von Templates unterscheiden. Oberflächlich betrachtet haben sie einige Ähnlichkeiten und können auf ähnliche Weise verwendet werden - beide eignen sich zum Beispiel für die Implementierung von Sammelklassen. Es gibt jedoch einige auf Templates basierende Techniken, die in C# einfach nicht funktionieren, wie zum Beispiel der Code in Beispiel 4-24.
Beispiel 4-24. Eine Templating-Technik, die in C# Generics nicht funktioniert
public
static
T
Add
<
T
>(
T
x
,
T
y
)
{
return
x
+
y
;
// Will not compile
}
In einem C++-Template ist so etwas möglich, aber nicht in C#, und du kannst es nicht vollständig mit einer Einschränkung beheben. Du könntest eine Typbeschränkung hinzufügen, die verlangt, dass T
von einem Typ abgeleitet ist oder eine Schnittstelle implementiert, die einen benutzerdefinierten +
Operator definiert, wodurch sich das Ganze kompilieren ließe, aber das würde nur für Typen funktionieren, die von diesem Basistyp abgeleitet sind. In C++ kannst du eine Vorlage schreiben, die zwei Elemente eines beliebigen Typs addiert, unabhängig davon, ob es sich um einen eingebauten oder einen eigenen Typ handelt. Außerdem brauchen C++-Vorlagen keine Einschränkungen; der Compiler kann selbst herausfinden, ob ein bestimmter Typ als Argument für eine Vorlage geeignet ist.
Dieses Problem ist nicht spezifisch für die Arithmetik. Das grundsätzliche Problem besteht darin, dass generischer Code, der auf Constraints angewiesen ist, um zu wissen, welche Operationen für seine Typparameter verfügbar sind, nur Funktionen verwenden kann, die als Mitglieder von Schnittstellen oder gemeinsamen Basisklassen dargestellt werden. Wäre die Arithmetik in .NET schnittstellenbasiert, wäre es möglich, eine Einschränkung zu definieren, die sie erfordert. Aber Operatoren sind allesamt statische Methoden, und obwohl Schnittstellen statische Mitglieder enthalten können,4 Der dynamische Dispatch-Mechanismus, der es jedem Typ ermöglicht, seine eigene Schnittstellenimplementierung bereitzustellen, funktioniert nur für Instanzmitglieder.5
Die Einschränkungen der C#-Generik ergeben sich aus der Art und Weise, wie sie funktionieren soll, daher ist es sinnvoll, den Mechanismus zu verstehen. (Diese Einschränkungen sind übrigens nicht spezifisch für eine bestimmte CLR-Implementierung. Sie ergeben sich zwangsläufig aus der Art und Weise, wie Generics in das Design der .NET-Laufzeit integriert sind).
Generische Methoden und Typen werden kompiliert, ohne zu wissen, welche Typen als Argumente verwendet werden. Das ist der grundlegende Unterschied zwischen C# Generics und C++ Templates - in C++ bekommt der Compiler jede Instanziierung einer Vorlage zu sehen. In C# hingegen kannst du generische Typen instanziieren, ohne auf den entsprechenden Quellcode zugreifen zu können, lange nachdem der Code kompiliert wurde. Immerhin hat Microsoft die generische Klasse List<T>
schon vor Jahren geschrieben, aber du könntest heute eine brandneue Klasse schreiben und diese als Typargument einfügen, ganz einfach. (Du könntest darauf hinweisen, dass es die C++ Standardbibliothek std::vector
noch länger gibt. Allerdings hat der C++-Compiler Zugriff auf die Quelldatei, in der die Klasse definiert ist, was bei C# und List<T>
nicht der Fall ist. C# sieht nur die kompilierte Bibliothek.)
Das Ergebnis ist, dass der C#-Compiler genügend Informationen haben muss, um typsicheren Code zu erzeugen, wenn er generischen Code kompiliert. Nehmen wir Beispiel 4-24. Er kann nicht wissen, was der +-Operator hier bedeutet, weil er für verschiedene Typen unterschiedlich wäre. Bei den eingebauten numerischen Typen müsste dieser Code mit den speziellen Intermediate Language (IL)-Befehlen zur Durchführung der Addition kompiliert werden. Wäre dieser Code in einem geprüften Kontext (d.h. unter Verwendung des in Kapitel 2 vorgestellten Schlüsselworts checked
), hätten wir bereits ein Problem, denn der Code für die Addition von ganzen Zahlen mit Überlaufprüfung verwendet unterschiedliche IL-Opcodes für ganze Zahlen mit und ohne Vorzeichen. Da es sich um eine generische Methode handelt, haben wir es vielleicht gar nicht mit den eingebauten numerischen Typen zu tun - vielleicht haben wir es mit einem Typ zu tun, der einen benutzerdefinierten +
Operator definiert; in diesem Fall müsste der Compiler einen Methodenaufruf erzeugen. In diesem Fall müsste der Compiler einen Methodenaufruf erzeugen. (Benutzerdefinierte Operatoren sind nur Methoden unter der Haube.) Oder wenn der betreffende Typ die Addition nicht unterstützt, sollte der Compiler einen Fehler erzeugen.
Es gibt verschiedene Möglichkeiten, einen einfachen Additionsausdruck zu kompilieren, je nachdem, welche Typen tatsächlich beteiligt sind. Das ist in Ordnung, wenn die Typen dem Compiler bekannt sind, aber er muss den Code für generische Typen und Methoden kompilieren, ohne zu wissen, welche Typen als Argumente verwendet werden.
Man könnte argumentieren, dass Microsoft vielleicht eine Art vorläufiges semikompiliertes Format für generischen Code hätte unterstützen können, und in gewisser Weise hat es das auch getan. Bei der Einführung der Generik hat Microsoft das Typsystem, das Dateiformat und die AWL-Anweisungen so geändert, dass generischer Code Platzhalter für Typparameter verwenden kann, die bei der vollständigen Konstruktion des Typs ausgefüllt werden. Warum sollte das nicht auch für Operatoren gelten? Warum sollte der Compiler nicht schon bei der Kompilierung von Code, der einen generischen Typ verwenden will, Fehler erzeugen, anstatt darauf zu bestehen, dass er Fehler erzeugt, wenn der generische Code selbst kompiliert wird? Nun, es stellt sich heraus, dass du zur Laufzeit neue Sätze von Typargumenten einfügen kannst - mit der Reflection-API, die wir uns in Kapitel 13 ansehen werden, kannst du generische Typen konstruieren. An dem Punkt, an dem ein Fehler auftritt, ist nicht unbedingt ein Compiler verfügbar, da nicht alle Versionen von .NET mit einer Kopie des C#-Compilers ausgeliefert werden. Was sollte passieren, wenn eine generische Klasse in C# geschrieben wurde, aber von einer ganz anderen Sprache verwendet wird, die vielleicht keine Operatorüberladung unterstützt? Welche Sprache sollte dann die Regeln anwenden, wenn es darum geht, herauszufinden, was mit dem Operator +
zu tun ist? Sollte es die Sprache sein, in der der generische Code geschrieben wurde, oder die Sprache, in der das Typargument geschrieben wurde? (Was ist, wenn es mehrere Typparameter gibt und du für jedes Argument einen Typ verwendest, der in einer anderen Sprache geschrieben wurde?) Oder vielleicht sollten die Regeln von der Sprache kommen, die entschieden hat, die Typargumente in den generischen Typ oder die Methode einzubauen, aber was ist mit Fällen, in denen ein Stück generischer Code seine Argumente an eine andere generische Entität weitergibt? Selbst wenn du entscheiden könntest, welcher dieser Ansätze der beste wäre, setzt dies voraus, dass die Regeln, mit denen bestimmt wird, was eine Codezeile tatsächlich bedeutet, zur Laufzeit verfügbar sind. Eine Annahme, die wiederum auf der Tatsache beruht, dass die entsprechenden Compiler nicht unbedingt auf dem Rechner installiert sind, auf dem der Code ausgeführt wird.
Die .NET-Generik löst dieses Problem, indem sie verlangt, dass die Bedeutung des generischen Codes beim Kompilieren des generischen Codes vollständig definiert wird, und zwar nach den Regeln der Sprache, in der der generische Code geschrieben wurde. Wenn der generische Code Methoden oder andere Mitglieder verwendet, müssen diese statisch aufgelöst werden (d. h. die Identität dieser Mitglieder muss zur Kompilierzeit genau bestimmt werden). Das bedeutet, dass die Kompilierzeit für den generischen Code selbst gilt, nicht für den Code, der den generischen Code verwendet. Diese Anforderungen erklären, warum die C#-Generik nicht so flexibel ist wie das Consumer-Compile-Time-Substitutionsmodell, das C++ verwendet. Der Vorteil ist, dass du Generika in Bibliotheken in Binärform kompilieren kannst und sie von jeder .NET-Sprache, die Generika unterstützt, mit völlig vorhersehbarem Verhalten verwendet werden können.
Zusammenfassung
Generics ermöglichen es uns, Typen und Methoden mit Typargumenten zu schreiben, die zur Kompilierzeit ausgefüllt werden können, um verschiedene Versionen der Typen oder Methoden zu erzeugen, die mit bestimmten Typen arbeiten. Einer der wichtigsten Anwendungsfälle für Generics war damals, als sie eingeführt wurden, die Möglichkeit, typsichere Sammelklassen wie List<T>
zu schreiben. Einige dieser Sammlungstypen werden wir uns imnächsten Kapitel ansehen.
1 Bei der Benennung von generischen Typen ist es üblich, das Wort of zu verwenden, wie in "List of T" oder "List of int".
2 Dies ist auch dann zulässig, wenn du die einfache class
Einschränkung in einem aktivierten nullable annotation context verwendet hast. Die Funktion "nullable references" bietet keine hieb- und stichfesten Garantien für die Nicht-Null-Stellung, so dass ein Vergleich mit null
möglich ist.
3 Moq nutzt die dynamische Proxy-Funktion aus dem Castle Project, um diesen Typ zu erzeugen. Wenn du etwas Ähnliches in deinem Code verwenden möchtest, findest du es im Castle Project.
4 Statische Schnittstellenmitglieder sind im .NET Framework nicht verfügbar.
5 Es gibt einen Vorschlag, um statische Interfacemember dynamisch zu versenden. Obwohl er nicht offiziell Teil von C# 10.0 ist, enthält das .NET 6.0 SDK eine Vorschauimplementierung. Du kannst sie ausprobieren, indem du die Projekteigenschaft EnablePreviewFeatures
auf true setzt. Wenn dies in einer zukünftigen Version unterstützt wird, werden wir vielleicht eine IAddable<T>
sehen.
Get Programmierung C# 10 now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.