Anzeige
Anzeige
Anzeige
Anzeige
Anzeige
Anzeige
Lesedauer 9 Min.

Generics in C#

Richtig benutzte Generics gewährleisten Typsicherheit bei hoher Performance.
Motivation für diesen Beitrag zu Generics in C# war die Beobachtung, dass Entwickler häufig gegen Best Practices verstoßen und nicht selten offene Fragen existieren. Fakt ist, dass sich die Generics in C#.NET fundamental von anderen Konzepten – etwa von Java und C++ – unterscheiden. Dieser Artikel betrachtet die Generics gemäß dem C#-­ECMA-Standard, während sich die Aussagen über Implementationen auf das .NET Framework beziehen.Das Ziel von generischen Programmen ist es, Algorithmen unabhängig von den später verwendeten Datentypen zu formulieren. Das folgende Beispiel soll zur Verwendung von Generics motivieren: Zahlen sollen in einer Liste gespeichert werden. Dabei sollen Zahlen, die kleiner sind als die größte bereits in der Liste vorhandene Zahl, nicht gespeichert werden. Geben Sie also die Zahlen [1, 4, 2, 7, 5] ein, so soll das Ergebnis [1, 4, 7] lauten: Als erste Zahl wird die 1 hinzugefügt. Anschließend kommt die 4 dazu und ist damit das größte Objekt. Da die danach eingegebene 2 kleiner ist als die 4, wird sie nicht berücksichtigt. Die 7 ist wieder größer als die 4 und wird folglich gespeichert. Eine Implementierung könnte für ganze Zahlen so aussehen wie in Listing 1 gezeigt.
Listing 1: Incrementing Collection für int
<span class="hljs-keyword">internal</span> <span class="hljs-keyword">class</span> <span class="hljs-title">IncrementingCollection</span> { <br/>  <span class="hljs-keyword">private</span> <span class="hljs-keyword">readonly</span> List<<span class="hljs-keyword">int</span>> _storageList = <br/>    <span class="hljs-keyword">new</span> List<<span class="hljs-keyword">int</span>>(); <br/>  <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">int</span>[] <span class="hljs-title">GetData</span>(<span class="hljs-params"></span>) </span>{ <br/>    <span class="hljs-comment">// kopiert die Daten, schützt vor Änderungen</span><br/><span class="hljs-comment">    return _storageList.ToArray();  </span><br/><span class="hljs-comment">  } </span><br/><span class="hljs-comment">  public void AddItem(int itemToAdd) { </span><br/><span class="hljs-comment">    if (_storageList.Count == 0 || </span><br/><span class="hljs-comment">      itemToAdd > _storageList.Last()) </span><br/><span class="hljs-comment">    { </span><br/><span class="hljs-comment">      _storageList.Add(itemToAdd); </span><br/><span class="hljs-comment">    } </span><br/><span class="hljs-comment">  } </span><br/><span class="hljs-comment">} </span> 
Der Code erzeugt das gewünschte Ergebnis. Die Wiederverwendbarkeit ist allerdings nicht optimal. Wenn beispielsweise eine neue Anforderung gestellt wird und der Algorithmus auch mit reellen Zahlen umgehen soll, dann taugt die Collection dafür nicht. Eine Möglichkeit, sie anzupassen, besteht darin, die Klasse zu kopieren und int durch float zu ersetzen. Dies ist aber nicht wirklich praktikabel, da man mit jedem neuen Datentyp die IncrementingCollection kopieren muss. Anstatt die Klasse zu kopieren, kann man auf die nachfolgende Technik zurückgreifen. Es fällt auf, dass die einzige Bedingung, die der Datentyp erfüllen muss, die Vergleich­barkeit ist. Diese muss mit IComparable gegeben sein, und die IncrementingCollection2 kann wiederverwendet werden, siehe Listing 2. Benutzt werden kann die Klasse nun wie folgt:
Listing 2: IncrementingCollection2 mit IComparable
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-type">IncrementingCollection2</span> { </span><br/><span class="hljs-class">  private readonly <span class="hljs-type">List</span><<span class="hljs-type">IComparable</span>> _storageList = </span><br/><span class="hljs-class">    new <span class="hljs-type">List</span><<span class="hljs-type">IComparable</span>>(); </span><br/><span class="hljs-class">  public <span class="hljs-type">IComparable</span>[] <span class="hljs-type">GetData</span>() { </span><br/><span class="hljs-class">    // kopiert die <span class="hljs-type">Daten</span>, schützt vor Änderungen</span><br/><span class="hljs-class">    return _storageList.<span class="hljs-type">ToArray</span>();  </span><br/><span class="hljs-class">  } </span><br/><span class="hljs-class">  public void <span class="hljs-type">AddItem</span>(<span class="hljs-type">IComparable</span> <span class="hljs-title">itemToAdd</span>) { </span><br/><span class="hljs-class">    if (<span class="hljs-title">itemToAdd</span> == <span class="hljs-title">null</span>) </span><br/><span class="hljs-class">      throw new <span class="hljs-type">ArgumentNullException</span>(</span><br/><span class="hljs-class">      <span class="hljs-title">nameof</span>(<span class="hljs-title">itemToAdd</span>)); </span><br/><span class="hljs-class">    if (<span class="hljs-title">_storageList</span>.<span class="hljs-type">Count</span> == 0 || </span><br/><span class="hljs-class">      <span class="hljs-title">itemToAdd</span>.<span class="hljs-type">CompareTo</span>(<span class="hljs-title">_storageList</span>.<span class="hljs-type">Last</span>()) > 0) </span><br/><span class="hljs-class">    { </span><br/><span class="hljs-class">      _storageList.<span class="hljs-type">Add</span>(<span class="hljs-title">itemToAdd</span>); </span><br/><span class="hljs-class">    } </span><br/><span class="hljs-class">  } </span><br/><span class="hljs-class">} </span> 

var collectionWithFloats = 
  new IncrementingCollection2(); 
collectionWithFloats.AddItem(2.5f); 
collectionWithFloats.AddItem(4.5f); 
collectionWithFloats.AddItem(3.5f); 
// Cast in den genutzten Datentyp 
float[] dataFloats = 
 collectionWithFloats.GetData().Cast<float>().ToArray(); 
 
Hier ist unschön, dass durch die Nutzung der IncrementingCollection2 der Aufrufer der Methode GetData die Informa­tion verliert, welcher Datentyp in der Collection gespeichert worden ist. Da es in diesem einfachen Fall ersichtlich ist, um welchen Datentyp es sich bei dem Rück­gabewert handelt, kann dieser mit LINQ konvertiert werden. Der Nachteil des Overheads ist offensichtlich. Beim Casten muss man zudem extrem aufpassen, dass keine Laufzeit­fehler entstehen, denn der Compiler lässt den statischen Type­cast nicht nur mit float, sondern mit jedem beliebigen Daten­typ zu. Was macht der Compiler, wenn wir Äpfel mit Birnen vergleichen?
Listing 3: Kritischer Vergleich
var collectionWithFloats = <br/>  new IncrementingCollection2(); <br/>collectionWithFloats.AddItem(2); <br/>collectionWithFloats.AddItem(4f);  
Der Compiler bemängelt nichts, denn sowohl 2 als auch 4f sind IComparable. Während man noch argumentieren kann, dass 2 und 4f vergleichbar sein könnten, ergibt es spätestens dann keinen Sinn mehr, wenn man eine Klasse class Apfel : IComparable und eine zweite Klasse class Birne : IComparable hat und deren Instanzen nacheinander in dieselbe Col­lec­tion gibt. Tatsächlich wird zur Laufzeit auch bei Vergleichen von int mit float eine System.ArgumentException geworfen. Der Code in Listing 3 wird das Programm also abstürzen lassen, obwohl es ohne Probleme kompiliert. Ein weiteres Problem ist, dass dabei int-Werte in IComparable geboxt werden. Häufiges und unnötiges Boxing kann Performance-Probleme zur Folge haben.

Generics in C 2.0

Der C#-ECMA-Standard (ECMA-334:2006) [1] beziehungsweise (ISO/IEC 23270:2006) hat 2006 in Version 2.0 Generics als Antwort auf das obige Problem eingeführt. Eine ähnliche Motivation kann in der neuesten Version dem Kapitel 8.16.1 „Why Generics“ entnommen werden. Ziel ist es, dem Programmierer mehr Sicherheit zu geben und gleichzeitig unnötiges Boxing zu verhindern. Wissenschaftliche Ausarbeitungen über Generics gibt es unter anderem von Microsoft Research [2]. Macht man von Generics etwas mehr Gebrauch, kann man die Collection wie in Listing 4 implementieren:
Listing 4: Collection mit Generic
internal class IncrementingCollection<T> <br/>  where T : IComparable<T> <br/>{ <br/>  private readonly List<T> _storageList = <br/>    new List<T>(); <br/>  public void AddItem(T itemToAdd) { <br/>    if (itemToAdd == null) <br/>      throw new ArgumentNullException(<br/>      nameof(itemToAdd)); <br/>    if (_storageList.Count == 0) <br/>    { <br/>      _storageList.Add(itemToAdd); <br/>      return; <br/>    } <br/>    if (itemToAdd.CompareTo(<br/>      _storageList.Last()) > 0) <br/>      _storageList.Add(itemToAdd); <br/>  } <br/>}  
Durch den Klassenparameter T kann man die Collection so implementieren, als sei T eine beliebige, aber feste Klasse. Durch where T:IComparable<T> wird signalisiert, dass die Klasse nur mit solchen Typen instanzierbar ist, die IComparable<T> erfüllen. Ein Vergleich von Äpfeln und Birnen wird zudem unterbunden, wie Bild 1 zeigt. Zudem verliert man bei GetData nicht die Information, um welchen Datentyp es sich handelt. In diesem Fall ist es ein Array<T>, also Array<int>.

IComparable versus IComparable

Eine Frage, die man sich stellen kann, ist, ob man die Anforderung des Datentyps im Algorithmus mit IComparable oder IComparable<T> formulieren sollte. Angenommen, die verwendeten Klassen erfüllen beide Interfaces. Dies ist unter anderem dann nicht der Fall, wenn man ein nicht ordentlich implementiertes oder sehr altes API benutzt. Best Practice ist es, als Datentyp beide Interfaces zu implementieren, um mit alten Algorithmen kompatibel zu sein (Listing 5). Wenn man aber annehmen kann, dass alle Datentypen, die den Algorithmus aufrufen, sowohl IComparable als auch IComparable<T> sind, welches Interface sollte dann mit der where-Klausel im Algorithmus verlangt werden? Fehler aufgrund von nicht zuei­nander passenden Typen können mit beiden Interfaces verhindert werden. Fehler wie in Listing 3, bei dem man Äpfel mit Birnen vergleicht, geben keinen Hinweis auf die Lösung. Prüfungen wie in Bild 1 haben mit der Comparable-Spezifizierung nichts zu tun.
Listing 5: Comparable<T> versus Comparable
public int CompareGeneric&amp;lt;T&amp;gt;(T a, T b) &lt;br/&gt;  where T : IComparable&amp;lt;T&amp;gt; &lt;br/&gt;{ &lt;br/&gt;  return a.CompareTo(b); &lt;br/&gt;} &lt;br/&gt;public int Compare&amp;lt;T&amp;gt;(T a, T b) &lt;br/&gt;  where T : IComparable &lt;br/&gt;{ &lt;br/&gt;  return a.CompareTo(b); &lt;br/&gt;}  
Die Antwort lautet IComparable<T>! Die Begründung findet sich direkt im Interface: int CompareTo(object obj); Wie man sieht, ist dies ein vergleichbarer Ansatz wie in Listing 2. Da IComparable während des Kompilierens keine Informa­tion darüber besitzt, welcher Datentyp verglichen wird, ist der Parameter der CompareTo-Methode vom Typ object. Implementiert eine Klasse nun die CompareTo-Methode, muss diese erst einmal das obj in seinen Typ zurückwandeln (casten), um anschließend einen Vergleich durchführen zu können. Bei Wertetypen (value types) müssen die Daten dabei noch völlig umsonst geboxt werden!
Beide Methoden kann man nur mit Parametern mit identischen Typen aufrufen, und beide Methoden verlangen vergleichbare Datentypen. Bild 2 zeigt den generierten IL-Code beider Varianten. In der Abbildung sieht man links den generierten IL-Code der nicht generischen CompareTo-Methode und auf der rechten Seite den IL-Code der generischen CompareTo-Methode. Auffällig ist, dass die generische Methode keinen Boxbefehl hat und eine kleinere Code size aufweist, was zu einem Benchmark motiviert. Der Benchmark
in Listing 6 (siehe auch [3]) wurde durchgeführt, um die Be­obachtung zu untermauern. Bild 3 zeigt die Ergebnisse.Es ist nicht verwunderlich, dass die Ergebnisse für die Object-Methode schlechter sind, als die der Generic-Methode. Verwunderlich ist jedoch die krasse Relation. Die Object-­Methode hat in diesem Data-Crunching-Beispiel deutliche Performance-Probleme.

Wie funktionieren Generics?

Generics bringen Performance-Vorteile und dem Programmierer mehr Sicherheit beim Entwickeln. Genauer gesagt ist es nicht möglich, einen Laufzeitfehler zu erzeugen, wenn man auf statische Typenkonvertierungen verzichtet. Wird ­eine Collection wie in Listing 4 verwendet oder wie die obere Methode in Listing 6, dann kann bereits zur Kompilierzeit ermittelt werden, ob die Typen zueinander passen. Nun wird gezeigt, wie der Compiler beziehungsweise die Laufzeitumgebung mit generischen Typen umgeht.
Listing 6: Benchmark
[ClrJob] &lt;br/&gt;public class GenericVsObject { &lt;br/&gt;  private const int N = 10000; &lt;br/&gt;  private readonly int[] _data; &lt;br/&gt;  public GenericVsObject() { var random = &lt;br/&gt;    new Random(42); &lt;br/&gt;    _data = new int[N].Select(&lt;br/&gt;    p =&amp;gt; random.Next()).ToArray(); } &lt;br/&gt; &lt;br/&gt;  [Benchmark] public int Object() { &lt;br/&gt;    return _data.Aggregate(_data[0], Compare); } &lt;br/&gt;  [Benchmark] public int Generic() { &lt;br/&gt;    return _data.Aggregate(_data[0], &lt;br/&gt;      CompareGeneric); } &lt;br/&gt;  private int Compare&amp;lt;T&amp;gt;(T a, T b) &lt;br/&gt;    where T : IComparable { &lt;br/&gt;    return a.CompareTo(b); } &lt;br/&gt;  private int CompareGeneric&amp;lt;T&amp;gt;(T a, T b) &lt;br/&gt;    where T : IComparable&amp;lt;T&amp;gt; { &lt;br/&gt;    return a.CompareTo(b); }      &lt;br/&gt;} &lt;br/&gt;public class Program { &lt;br/&gt;  public static void Main(string[] args) { &lt;br/&gt;    BenchmarkRunner.Run&amp;lt;GenericVsObject&amp;gt;(); &lt;br/&gt;  } &lt;br/&gt;}  
Am Anfang des Artikels wurde der Ansatz vorgeschlagen, für jeden benutzten Typ die Klasse naiv zu kopieren und die Typparameter mit den konkreten Typen jeweils zu ersetzen. Dies ist vergleichbar mit dem Ansatz, der in C++ mittels Templates umgesetzt ist [4]. Beim Kompilieren wird ermittelt, mit welchen Typen die generische Klasse benutzt wird. Dann wird die Klasse vom C++-Compiler intern kopiert und jeweils separat kompiliert. Dieser Ansatz sorgt für große Binaries und gegebenenfalls lange Kompilierungszeiten. Das Problem mit den großen Binaries findet man auch unter der Bezeichnung Code-Explosion [5]. Da C#-Klassen in der CLR mittels Just-in-Time-Verfahren erst dann kompiliert werden, wenn sie gebraucht werden, bleibt dem Entwickler die lange Kompilierzeit erspart und die Binaries bleiben schlank. Zudem bietet C# Code-Sharing innerhalb einer App-Domain, was dafür sorgt, dass beispielsweise eine List<int> nicht für jede Binary eigenen x86-Code erzeugt. Nachfolgend wird das Generieren von Generics in der CLR betrachtet.Für Referenztypen (zum Beispiel Klassen und Strings) bietet es sich an, die generische Klasse intern einfach als Klasse mit Typparameter object zu kompilieren und diese Klasse als Basis für alle anderen Referenztypen zu teilen. Der Compiler hat bereits geprüft, dass der Code nur von zusammenpassenden Typen genutzt wird. Der Binary ist es aber egal, ob ein Typparameter von der Klasse ClassA oder ClassB ist. Intern haben alle Klassen Referenzen derselben Größe – 32 Bit bei 32-Bit-Plattformen und 64 Bit bei 64-Bit-Plattformen [6]. Dies ist vergleichbar mit Listing 2 mit object anstelle IComparable. Man kann sich also vorstellen, dass eine List<ClassA> intern nichts anderes ist als eine List<object>. Dementsprechend ist der generierte x86-Code von List<ClassA> exakt der gleiche wie von List<ClassB>. Wie bereits angedeutet, wird die Typsicherheit schon beim Kompilieren überprüft. Entscheidend ist hier, dass es zu keiner Code-Explosion mehr kommt. Der generierte Code von List<ClassA> ist im Grunde derselbe wie List<List<ClassA>>, da sowohl List<ClassA> als auch ClassA Referenztypen sind. Listing 7 zeigt, wie man eine Code-Explosion provozieren kann.
Listing 7: Code-Explosion
static void GenerateType&amp;lt;T&amp;gt;(int counter) { &lt;br/&gt;  if (counter == 1000) { &lt;br/&gt;    Console.WriteLine("1000 classes generated"); &lt;br/&gt;    return; &lt;br/&gt;  } &lt;br/&gt;  GenerateType&amp;lt;List&amp;lt;T&amp;gt;&amp;gt;(++counter); &lt;br/&gt;}  
Anders verhält sich der JIT-Compiler bei Wertetypen wie int, float oder struct. Hier liegen keine Zeiger im Speicher, bei denen man so tun kann, als seien sie object, sondern die Werte an sich. Tatsächlich könnte man, wie in Listing 5 angedeutet, die Wertetypen boxen. Dies führt aber unter anderem zu Performance-Nachteilen, wie in Bild 3 zu sehen ist. Hier hat man sich dafür entschieden, für jeden Wertetyp eigenen x86-Code zu generieren. Es liegt also für List<int> und List<float> jeweils eigener x86-Code vor. Vorteilhaft ist die Performance, Nachteile kann die Verwendung von Generics mit structs haben. Eine Code-Explosion ist an dieser Stelle nicht ausgeschlossen. Es kann sein, dass man für structs desselben Layouts und insbesondere derselben Größe den Code teilen kann, dies konnte aber in Recherchen nicht belegt werden.
Interessant ist noch die Frage, wie sich der JIT-Compiler verhält, wenn man in einer Klasse oder in einer Methode sowohl Werte- als auch Referenztypen verwendet. Auch in diesem Fall wird so viel Code wie möglich geteilt. Ein Dictionary<string, int> ist also gleichzusetzen mit einem Dictionary<object, int> und somit auch mit einem Dictionary<ClassA, int>. Ein Dictionary<object, float> hingegen erzeugt separaten Code.

Fazit

In diesem Artikel wurden verschiedene Ansätze zum generischen Programmieren besprochen. Zunächst wurde der Ansatz aufgezeigt, die Klassen zu kopieren. Dies ist vergleichbar mit Templates in C++. Im darauffolgenden Ansatz wurde in Listing 2 durch Abstrahieren nur noch eine einzige Implementierung benötigt. Dies führt aber zu Performance-Problemen und die Typsicherheit ist nicht mehr gewährleistet, da man auf statische Typkonvertierung angewiesen ist. Einige Interfaces existieren sowohl in generischer als auch in nicht generischer Form. Hier ist es aufgrund unnötigen Boxings empfehlenswert, die generische Version zu verwenden – sofern erlaubt. Die Generics ab C# 2.0 ermöglichen für den Entwickler Typsicherheit und sorgen für hohe Performance. ­Eine Anwendung sieht man in Listing 4. Abschließend wurde das Code-Sharing-Konzept betrachtet, laut dem der JIT-Compiler so viel x86-Code wie möglich teilt und für jeden Wertetyp separaten Code erzeugt.

Fussnoten

  1. C# Language Specification, ECMA International, http://www.dotnetpro.de/SL1711Generics1
  2. Dachuan Yu, Andrew Kennedy, Don Syme, Formalization of ­Generics for the .NET Common Language Runtime, in POPL ’04, http://www.dotnetpro.de/SL1711Generics2
  3. BenchmarkDotNet.Diagnostics.Windows, http://www.dotnetpro.de/SL1711Generics3
  4. Standard C++ Foundation, isocpp, 2017, http://www.dotnetpro.de/SL1711Generics4
  5. Cédric Bastoul, Code Generation in the Polyhedral ­Model Is Easier Than You Think, http://www.dotnetpro.de/SL1711Generics5
  6. Jeffrey Richter, CLR Via C#, Fourth Edition, Microsoft Press, 2012, ISBN 978-0-73566745-7,

Neueste Beiträge

DWX hakt nach: Wie stellt man Daten besonders lesbar dar?
Dass das Design von Websites maßgeblich für die Lesbarkeit der Inhalte verantwortlich ist, ist klar. Das gleiche gilt aber auch für die Aufbereitung von Daten für Berichte. Worauf besonders zu achten ist, erklären Dr. Ina Humpert und Dr. Julia Norget.
3 Minuten
27. Jun 2025
DWX hakt nach: Wie gestaltet man intuitive User Experiences?
DWX hakt nach: Wie gestaltet man intuitive User Experiences? Intuitive Bedienbarkeit klingt gut – doch wie gelingt sie in der Praxis? UX-Expertin Vicky Pirker verrät auf der Developer Week, worauf es wirklich ankommt. Hier gibt sie vorab einen Einblick in ihre Session.
4 Minuten
27. Jun 2025
„Sieh die KI als Juniorentwickler“
CTO Christian Weyer fühlt sich jung wie schon lange nicht mehr. Woran das liegt und warum er keine Angst um seinen Job hat, erzählt er im dotnetpro-Interview.
15 Minuten
27. Jun 2025
Miscellaneous

Das könnte Dich auch interessieren

UIs für Linux - Bedienoberflächen entwickeln mithilfe von C#, .NET und Avalonia
Es gibt viele UI-Frameworks für .NET, doch nur sehr wenige davon unterstützen Linux. Avalonia schafft als etabliertes Open-Source-Projekt Abhilfe.
16 Minuten
16. Jun 2025
Mythos Motivation - Teamentwicklung
Entwickler bringen Arbeitsfreude und Engagement meist schon von Haus aus mit. Diesen inneren Antrieb zu erhalten sollte für Führungskräfte im Fokus stehen.
13 Minuten
19. Jan 2017
Bausteine guter Architektur - Entwurf und Entwicklung wartbarer Softwaresysteme, Teil 2
Code sauberer gestalten anhand von wenigen Patterns und Grundhaltungen.
6 Minuten
Anzeige
Anzeige
Anzeige
Anzeige
Anzeige