April 2017

Band 32, Nummer 4

Essential .NET: Informationen zum Verständnis der foreach-Interna von C# und der benutzerdefinierten Iteratoren mit „yield“

Von Mark Michaelis

Mark MichaelisIn diesem Monat beschäftige ich mich mit den Interna eines der Kernkonstrukte von C#, mit dem wir alle häufig programmieren: der foreach-Anweisung. Wenn Sie verstehen, wie sich „foreach“ intern verhält, können Sie anschließend die Implementierung der foreach-Sammlungsschnittstellen mithilfe der yield-Anweisung untersuchen, wie ich zeigen werde.

Die foreach-Anweisung ist leicht zu programmieren. Es überrascht mich aber immer wieder, wie wenige Entwickler ihre interne Funktionsweise verstehen. Wissen Sie z. B., dass „foreach“ für Arrays anders als für IEnumberable<T>-Auflistungen funktioniert? Wie gut kennen Sie die Beziehung zwischen „IEnumerable<T>“ und „IEnumerator<T>“? Wenn Sie die Enumerable-Schnittstellen verstehen, können Sie diese auch souverän mit „yield“ implementieren? 

Wann ist eine Klasse eine Sammlung?

Gemäß der Definition ist eine Sammlung in Microsoft  .NET Framework eine Klasse, die mindestens „IEnumerable<T>“ (oder den nicht generischen Typ „IEnumerable“) implementiert. Diese Schnittstelle ist entscheidend, weil das Implementieren der Methoden von „IEnumerable<T>“ die Mindestanforderung für die Unterstützung des Iterierens durch eine Sammlung darstellt.

Die Syntax der foreach-Anweisung ist einfach und vermeidet Komplexität, weil Sie nicht wissen müssen, wie viele Elemente vorhanden sind. Die Laufzeit unterstützt die foreach-Anweisung jedoch nicht direkt. Der C#-Compiler transformiert den Code stattdessen wie in den nächsten Abschnitten beschrieben.

„foreach“ mit Arrays: Das folgende Beispiel zeigt eine einfache foreach-Schleife, die durch ein Array von Integerwerten iteriert und dann jeden Integerwert an die Konsole ausgibt:

int[] array = new int[]{1, 2, 3, 4, 5, 6};
foreach (int item in array)
{
  Console.WriteLine(item);
}

Aus diesem Code erstellt der C#-Compiler eine CIL-Entsprechung der for-Schleife:

int[] tempArray;
int[] array = new int[]{1, 2, 3, 4, 5, 6};
tempArray = array;
for (int counter = 0; (counter < tempArray.Length); counter++)
{
  int item = tempArray[counter];
  Console.WriteLine(item);
}

Beachten Sie in diesem Beispiel, dass „foreach“ Unterstützung für die Eigenschaft „Length“ und den Indexoperator ([]) benötigt. Mit der Eigenschaft „Length“ kann der C#-Compiler die for-Anweisung verwenden, um durch jedes Element im Array zu iterieren.

„foreach“ mit „IEnumerable<T>“: Der oben aufgeführte Code funktioniert zwar gut für Arrays, in denen die Länge fest ist und der Indexoperator immer unterstützt wird, aber nicht alle Typen von Sammlungen verfügen über eine bekannte Anzahl von Elementen. Außerdem unterstützen viele der Sammlungsklassen (z. B. „Stack<T>“, „Queue<T>“ und „Dictionary<TKey und TValue>“) das Abrufen von Elementen nach Index nicht. Daher ist ein allgemeinerer Ansatz für das Iterieren durch Sammlungen von Elementen erforderlich. Das Iteratormuster stellt diese Funktionalität zur Verfügung. Wenn Sie das erste, das nächste und das letzte Element bestimmen können, muss die Anzahl nicht bekannt sein, und die Unterstützung des Abrufs von Elementen nach Index ist nicht erforderlich.

Die Schnittstelle „System.Collections.Generic.IEnumerator<T>“ und die nicht generische Schnittstelle „System.Collections.IEnumerator“ sind für die Aktivierung des Iteratormusters zum Iterieren durch Sammlungen von Elementen konzipiert. Für das oben gezeigte Länge-Index-Muster gilt dies nicht. Abbildung 1 zeigt ein Klassendiagramm der Beziehung dieser Schnittstellen.

Ein Klassendiagramm der IEnumerator- und IEnumerator-Schnittstellen
Abbildung 1: Ein Klassendiagramm der IEnumerator- und IEnumerator-Schnittstellen

Die Schnittstelle „IEnumerator“, von der „IEnumerator<T>“ abgeleitet wird, umfasst drei Member. Der erste ist der boolesche Wert „MoveNext“. Mithilfe dieser Methode können Sie von einem Element in der Sammlung zum nächsten Element gelangen und gleichzeitig feststellen, dass Sie jedes Element aufgezählt haben. Der zweite Member, eine schreibgeschützte Eigenschaft namens „Current“, gibt das Element zurück, das aktuell verarbeitet wird. „Current“ wird in „IEnumerator<T>“ überladen und stellt eine typspezifische Implementierung dieser Schnittstelle bereit. Mit diesen beiden Membern für die Sammlungsklasse ist es möglich, einfach mithilfe einer while-Schleife durch die Sammlung zu iterieren:

System.Collections.Generic.Stack<int> stack =
  new System.Collections.Generic.Stack<int>();
int number;
// ...
// This code is conceptual, not the actual code.
while (stack.MoveNext())
{
  number = stack.Current;
  Console.WriteLine(number);
}

In diesem Code gibt die MoveNext-Methode FALSE zurück, wenn das Ende der Sammlung erreicht wird. Auf diese Weise entfällt die Notwendigkeit, Elemente beim Ausführen der Schleife zu zählen.

(Die ResetMethode löst normalerweise eine „NotImplementedException“ aus und sollte daher nie aufgerufen werden. Wenn Sie eine Enumeration erneut starten müssen, erstellen Sie einfach einen neuen Enumerator.)

Das Beispiel oben zeigt die Quintessenz der Ausgabe des C#-Compilers, kompiliert aber tatsächlich nicht auf diese Weise, weil zwei wichtige Details hinsichtlich der Implementierung ausgelassen werden: Interleaving und Fehlerbehandlung.

Der Zustand ist freigegeben: Das Problem bei einer Implementierung wie im Beispiel oben besteht darin, dass die Sammlung einen Statusindikator des aktuellen Elements verwalten muss, wenn sich zwei Schleifen überlappen (eine foreach-Anweisung in einer anderen foreach-Anweisung , die beide die gleiche Sammlung verwenden), damit beim Aufruf von „MoveNext“ das nächste Element ermittelt werden kann. In diesem Fall kann sich eine Interleavingschleife auf die andere auswirken. (Gleiches gilt auch für Schleifen, die von mehreren Threads ausgeführt werden.)

Damit dieses Problem nicht auftritt, unterstützen die Sammlungsklassen die IEnumerator<T>- und IEnumerator-Schnittstellen nicht direkt. Stattdessen ist eine zweite Schnittstelle namens „IEnumerable<T>“ verfügbar, deren einzige Methode „GetEnumerator“ ist. Der Zweck dieser Methode besteht in der Rückgabe eines Objekts, das „IEnumerator<T>“ unterstützt. Die Sammlungsklasse verwaltet keinen Zustand, sondern eine andere Klasse (normalerweise eine geschachtelte Klasse, damit sie Zugriff auf die Interna der Sammlung unterstützt) unterstützt die IEnumerator<T>-Schnittstelle und verwaltet den Zustand der Iterationsschleife. Der Enumerator ähnelt einem „Cursor“ oder einem „Lesezeichen“ in der Sequenz. Sie können mehrere Lesezeichen verwenden. Wenn Sie eines von ihnen verschieben, wird die Sammlung unabhängig von den anderen Sammlungen aufgezählt. Mithilfe dieses Musters sieht die C#-Entsprechung einer foreach-Schleife wie der in Abbildung 2 gezeigte Code aus.

Abbildung 2: Ein separater Enumerator, der den Zustand während einer Iteration verwaltet

System.Collections.Generic.Stack<int> stack =
  new System.Collections.Generic.Stack<int>();
int number;
System.Collections.Generic.Stack<int>.Enumerator
  enumerator;
// ...
// If IEnumerable<T> is implemented explicitly,
// then a cast is required.
// ((IEnumerable<int>)stack).GetEnumerator();
enumerator = stack.GetEnumerator();
while (enumerator.MoveNext())
{
  number = enumerator.Current;
  Console.WriteLine(number);
}

Bereinigung nach der Iteration: Da die Klassen, die die IEnumerator<T>-Schnittstelle implementieren, den Zustand verwalten, muss manchmal der Zustand nach Verlassen der Schleife bereinigt werden (weil alle Iterationen abgeschlossen wurden oder eine Ausnahme ausgelöst wurde). Zu diesem Zweck wird die IEnumerator<T>-Schnittstelle von „IDisposable“ abgeleitet. Enumeratoren, die „IEnumerator“ implementieren, implementieren nicht notwendigerweise „IDisposable“. Falls doch, wird auch „Dispose“ aufgerufen. Dies ermöglicht den Aufruf von „Dispose“ nach dem Beenden der foreach-Schleife. Die C#-Entsprechung des endgültigen CIL-Codes ähnelt daher Abbildung 3.

Abbildung 3: Kompiliertes Ergebnis von „foreach“ für Sammlungen

System.Collections.Generic.Stack<int> stack =
  new System.Collections.Generic.Stack<int>();
System.Collections.Generic.Stack<int>.Enumerator
  enumerator;
IDisposable disposable;
enumerator = stack.GetEnumerator();
try
{
  int number;
  while (enumerator.MoveNext())
  {
    number = enumerator.Current;
    Console.WriteLine(number);
  }
}
finally
{
  // Explicit cast used for IEnumerator<T>.
  disposable = (IDisposable) enumerator;
  disposable.Dispose();
  // IEnumerator will use the as operator unless IDisposable
  // support is known at compile time.
  // disposable = (enumerator as IDisposable);
  // if (disposable != null)
  // {
  //   disposable.Dispose();
  // }
}

Beachten Sie Folgendes: Da die IDisposable-Schnittstelle durch „IEnumerator<T>“ unterstützt wird, kann die using-Anweisung den Code in Abbildung 3 vereinfachen, damit er Abbildung 4 ähnelt.

Abbildung 4: Fehlerbehandlung und Ressourcenbereinigung mit „using“

System.Collections.Generic.Stack<int> stack =
  new System.Collections.Generic.Stack<int>();
int number;
using(
  System.Collections.Generic.Stack<int>.Enumerator
    enumerator = stack.GetEnumerator())
{
  while (enumerator.MoveNext())
  {
    number = enumerator.Current;
    Console.WriteLine(number);
  }
}

Denken Sie jedoch daran, dass die CIL das using-Schlüsselwort nicht direkt unterstützt. Der Code in Abbildung 3 ist daher tatsächlich eine genauere C#-Darstellung des foreach-CIL-Codes.

„foreach“ ohne „IEnumerable“: C# erfordert nicht, dass „IEnumerable“/„IEnumerable<T>“ so implementiert wird, dass eine Iteration durch einen Datentyp unter Verwendung von „foreach“ erfolgt. Der Compiler verwendet stattdessen ein Konzept, das unter dem Namen „Duck-Typing“ bekannt ist. Dabei wird nach einer GetEnumerator-Methode gesucht, die einen Typ mit einer Current-Eigenschaft und einer MoveNext-Methode zurückgibt. Für Duck-Typing wird anhand des Namens gesucht, anstatt eine Schnittstelle oder einen expliziten Methodenaufruf der Methode zu verwenden. (Der Name „Duck-Typing“ leitet sich von der seltsamen Vorstellung ab, dass das Objekt nur eine Quak-Methode implementieren muss, um wie als „Duck“ (Ente) behandelt zu werden. Es muss keine IDuck-Schnittstelle implementieren.) Wenn beim Duck-Typing keine geeignete Implementierung des aufzählbaren Musters gefunden wird, überprüft der Compiler, ob die Sammlung die Schnittstellen implementiert.

Informationen zu Iteratoren

Da Sie nun die Interna der foreach-Implementierung kennen, beschäftigen wir uns damit, wie Iteratoren zum Erstellen benutzerdefinierter Implementierungen der IEnumerator<T>-, IEnumerable<T>- und entsprechender nicht generischer Schnittstellen für benutzerdefinierte Sammlungen verwendet werden. Iteratoren stellen eine saubere Syntax zum Angeben zur Verfügung, wie durch Daten in Sammlungsklassen iteriert werden soll, und zwar insbesondere unter Verwendung der foreach-Schleife. Sie ermöglichen Endbenutzern einer Sammlung die Navigation in der internen Struktur, ohne diese zu kennen.

Das Problem beim Enumerationsmuster besteht darin, dass es mühsam sein kann, dieses manuell zu implementieren, weil es alle Zustände verwalten muss, die zum Beschreiben der aktuellen Position in der Sammlung erforderlich sind. Dieser interne Zustand kann für eine Klasse vom Sammlungstyp „Liste“ einfach sein. Hier reicht der Index der aktuellen Position aus. Für Datenstrukturen, die einen rekursiven Durchlauf erfordern (z. B. binäre Strukturen) kann der Zustand eine recht komplizierte Angelegenheit sein. Damit die Herausforderungen beim Implementieren dieses Musters nicht zu groß werden, wurde in C# 2.0 das kontextabhängige Schlüsselwort „yield“ hinzugefügt, damit es einfacher für eine Klasse wird, die Art und Weise vorzuschreiben, wie die foreach-Schleife durch ihre Inhalte iteriert.

Durch das Definieren eines „Iterator:Iterators“ können Methoden einer Klasse implementiert werden. Es handelt sich um syntaktische „Kurzbefehle“ für das komplexere Enumerationsmuster. Wenn der C#-Compiler auf einen Iterator trifft, erweitert er dessen Inhalte in CIL-Code, der das Enumeratormuster implementiert. Es gibt also keine Laufzeitabhängigkeiten für das Implementieren von Iteratoren. Da der C#-Compiler die Implementierung über CIL-Codegenerierung ausführt, gibt es keinen echten Vorteil der Laufzeitleistung im Vergleich zur Verwendung von Iteratoren. Jedoch wird die Produktivität des Programmierers erheblich gesteigert, wenn Iteratoren anstelle einer manuellen Implementierung des Enumeratormusters verwendet werden. Damit Sie diese Optimierung besser verstehen können, sehen wird uns zuerst an, wie ein Iterator im Code definiert wird.

Iteratorsyntax: Ein Iterator bietet eine Kurzimplementierung von Iteratorschnittstellen: der Kombination aus den IEnumerable<T>- und IEnumerator<T>-Schnittstellen. Abbildung 5 deklariert einen Iterator für den generischen Typ „BinaryTree<T>“, indem eine GetEnumerator-Methode erstellt wird (wenn auch noch ohne Implementierung).

Abbildung 5: Iteratorschnittstellenmuster

using System;
using System.Collections.Generic;
public class BinaryTree<T>:
  IEnumerable<T>
{
  public BinaryTree ( T value)
  {
    Value = value;
  }
  #region IEnumerable<T>
  public IEnumerator<T> GetEnumerator()
  {
    // ...
  }
  #endregion IEnumerable<T>
  public T Value { get; }  // C# 6.0 Getter-only Autoproperty
  public Pair<BinaryTree<T>> SubItems { get; set; }
}
public struct Pair<T>: IEnumerable<T>
{
  public Pair(T first, T second) : this()
  {
    First = first;
    Second = second;
  }
  public T First { get; }
  public T Second { get; }
  #region IEnumerable<T>
  public IEnumerator<T> GetEnumerator()
  {
    yield return First;
    yield return Second;
  }
  #endregion IEnumerable<T>
  #region IEnumerable Members
  System.Collections.IEnumerator
    System.Collections.IEnumerable.GetEnumerator()
  {
    return GetEnumerator();
  }
  #endregion
  // ...
}

Zurückgeben von Werten aus einem Iterator: Die Iteratorschnittstellen sind wie Funktionen. Anstatt einen einzelnen Wert zurückzugeben, geben sie jedoch nacheinander eine Sequenz von Werten zurück. Im Fall von „BinaryTree<T>“ gibt der Iterator eine Sequenz von Werten des Typarguments zurück, das für „T“ angegeben wird. Wenn die nicht generische Version von „IEnumerator“ verwendet wird, sind die zurückgegebenen Werte stattdessen vom Typ „object“.

Zum ordnungsgemäßen Implementieren des Iteratormusters müssen Sie interne Zustände verwalten, um die Position nachzuverfolgen, an der Sie sich beim Aufzählen der Sammlung befinden. Im Fall von „BinaryTree<T>“ verfolgen Sie nach, welche Elemente im Baum bereits aufgezählt wurden und welche noch anstehen. Iteratoren werden vom Compiler in einen „Zustandscomputer“ transformiert, der die aktuelle Position nachverfolgt und weiß, wie er sich selbst an die nächste Position „bewegt“.

Die Anweisung „yield return“ gibt jedes Mal einen Wert zurück, wenn ein Iterator darauf trifft. Die Steuerung geht sofort an den Aufrufer zurück, der das Element angefordert hat. Wenn der Aufrufer das nächste Element anfordert, beginnt die Ausführung des Codes sofort im Anschluss an die zuvor ausgeführte Anweisung „yield return“. In Abbildung 6 werden die integrierten C#-Datentyp-Schlüsselwörter sequenziell zurückgegeben.

Abbildung 6: Sequenzielle Rückgabe einiger C#-Schlüsselwörter

using System;
using System.Collections.Generic;
public class CSharpBuiltInTypes: IEnumerable<string>
{
  public IEnumerator<string> GetEnumerator()
  {
    yield return "object";
    yield return "byte";
    yield return "uint";
    yield return "ulong";
    yield return "float";
    yield return "char";
    yield return "bool";
    yield return "ushort";
    yield return "decimal";
    yield return "int";
    yield return "sbyte";
    yield return "short";
    yield return "long";
    yield return "void";
    yield return "double";
    yield return "string";
  }
    // The IEnumerable.GetEnumerator method is also required
    // because IEnumerable<T> derives from IEnumerable.
  System.Collections.IEnumerator
    System.Collections.IEnumerable.GetEnumerator()
  {
    // Invoke IEnumerator<string> GetEnumerator() above.
    return GetEnumerator();
  }
}
public class Program
{
  static void Main()
  {
    var keywords = new CSharpBuiltInTypes();
    foreach (string keyword in keywords)
    {
      Console.WriteLine(keyword);
    }
  }
}

Die Ergebnisse von Abbildung 6 werden in Abbildung 7 gezeigt, einer Liste der integrierten C#-Typen.

Abbildung 7: Eine Liste der C#-Schlüsselwortausgabe aus dem Code in Abbildung 6

object
byte
uint
ulong
float
char
bool
ushort
decimal
int
sbyte
short
long
void
double
string

Dies bedarf unbedingt weiterer Erläuterungen. In diesem Monat steht mir aber kein weiterer Platz zur Verfügung. Sie müssen also gespannt auf eine weitere Kolumne warten. Nur so viel sei gesagt: Mit Iteratoren können Sie Sammlungen auf magische Weise als Eigenschaften erstellen. Abbildung 8 zeigt dies. In diesem Fall werden spaßeshalber C# 7.0-Tupel verwendet. Diejenigen unter Ihnen, die zu ungeduldig sind, können sich mit dem Quellcode beschäftigen oder Kapitel 16 meines Buchs „Essential C#“ lesen.

Abbildung 8: Verwenden von „yield return“ zum Implementieren einer IEnumerable<T>-Eigenschaft

IEnumerable<(string City, string Country)> CountryCapitals
{
  get
  {
    yield return ("Abu Dhabi","United Arab Emirates");
    yield return ("Abuja", "Nigeria");
    yield return ("Accra", "Ghana");
    yield return ("Adamstown", "Pitcairn");
    yield return ("Addis Ababa", "Ethiopia");
    yield return ("Algiers", "Algeria");
    yield return ("Amman", "Jordan");
    yield return ("Amsterdam", "Netherlands");
    // ...
  }
}

Zusammenfassung

In dieser Kolumne habe ich mich mit Funktionalität beschäftigt, die seit Version 1.0 Bestandteil von C# ist und sich seit der Einführung von Generika in C# 2.0 nicht viel geändert hat. Auch wenn diese Funktionalität häufig eingesetzt wird, verstehen viele Entwickler die Details der internen Funktionsweise nicht. Daher habe ich ein wenig an der Oberfläche des Iteratormusters gekratzt (unter Einsatz des yield return-Konstrukts) und ein Beispiel vorgestellt.

Ein Großteil dieser Kolumne stammt aus meinem Buch „Essential C#“ (IntelliTect.com/EssentialCSharp), das ich zurzeit mit „Essential C# 7.0“ auf den neuesten Stand bringe. Weitere Informationen finden Sie in den Kapiteln 14 und 16.


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 den folgenden technischen Experten von IntelliTect für die Durchsicht dieses Artikels: Kevin Bost, Grant Erickson, Chris Finlayson, Phil Spokas und Michael Stokesbary