Juni 2017
Band 32, Nummer 6
Essential .NET: Benutzerdefinierte Iteratoren mit „yield“
Von Mark Michaelis
In meiner letzten Kolumne (msdn.com/magazine/mt797654) habe ich mich mit den Details befasst, wie die foreach-Anweisung von C# hinter den Kulissen funktioniert. Dabei habe ich beschrieben, wie der C#-Compiler die foreach-Funktionen in CIL (Common Intermediate Language) implementiert. Ich habe außerdem das Schlüsselwort „yield“ kurz in einem Beispiel vorgestellt (siehe Abbildung 1), ohne es jedoch hinreichend zu erläutern.
Abbildung 1: Sequenzielle Rückgabe einiger C#-Schlüsselwörter
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);
}
}
}
Dies ist eine Fortsetzung des damaligen Artikels, in der ich weitere Einzelheiten zum Schlüsselwort „yield“ und dessen Verwendung bereitstelle.
Iteratoren und Zustand
Durch Platzieren eines Haltepunkts zu Beginn der GetEnumerator-Methode in Abbildung 1 können Sie beobachten, dass „GetEnumerator“ zu Beginn der foreach-Anweisung aufgerufen wird. Zu diesem Zeitpunkt wird ein iterator-Objekt erstellt, und sein Zustand wird mit einem speziellen „start“-Zustand initialisiert, der die Tatsache darstellt, dass kein Code im Iterator ausgeführt wurde und daher auch noch keine Werte generiert wurden. Ab dann behält der Iterator seinen Zustand (Position) solange bei, wie die foreach-Anweisung in der Aufrufsite weiterhin ausgeführt wird. Jedes Mal, wenn die Schleife den nächsten Wert anfordert, geht die Steuerung auf den Iterator über und fährt an der Stelle fort, an der der Ausstieg im letzten Schleifendurchgang erfolgt ist. Die im iterator-Objekt gespeicherten Zustandsinformationen werden zum Bestimmen verwendet, an welcher Stelle die Steuerung fortgesetzt werden muss. Wenn die foreach-Anweisung in der Aufrufsite beendet wird, wird der Zustand des Iterators nicht mehr gespeichert. Abbildung 2 zeigt ein Sequenzdiagramm der ausgeführten Vorgänge auf Übersichtsebene. Denken Sie daran, dass die MoveNext-Methode in der IEnumerator<T>-Schnittstelle auftritt.
In Abbildung 2 leitet die foreach-Anweisung in der Aufrufsite einen Aufruf von „GetEnumerator“ für die von der CSharpBuiltInTypes-Instanz aufgerufenen Schlüsselwörter ein. Wie Sie sehen können, ist es immer sicher, „GetEnumerator“ erneut aufzurufen. „Neue“ enumerator-Objekte werden dann bei Bedarf erstellt. Anhand der Iteratorinstanz (auf die durch „iterator“ verwiesen wird) beginnt „foreach“ jede Iteration mit einem Aufruf von „MoveNext“. Innerhalb des Iterators wird ein Wert zurück an die foreach-Anweisung in der Aufrufsite gegeben. Nach der yield return-Anweisung pausiert die GetEnumerator-Methode offensichtlich bis zur nächsten MoveNext-Anforderung. Zurück im Schleifenkörper zeigt die foreach-Anweisung den zurückgegebenen Wert auf dem Bildschirm an. Im nächsten Schleifendurchgang wird „MoveNext“ erneut für den Iterator aufgerufen. Beachten Sie, dass die Steuerung im zweiten Durchgang bei der zweiten yield-return-Anweisung übernommen wird. Erneut zeigt „foreach“ die Ausgabe von „CSharpBuiltInTypes“ auf dem Bildschirm an und startet die Schleife dann erneut. Dieser Vorgang wird fortgesetzt, bis keine weiteren yield return-Anweisungen im Iterator vorhanden sind. An diesem Punkt wird die foreach-Schleife in der Aufrufsite beendet, weil „MoveNext“ den Wert FALSE zurückgibt.
Abbildung 2: Sequenzdiagramm mit „yield return“
Ein weiteres Beispiel für einen Iterator
Sehen wir uns ein ähnliches Beispiel mit „BinaryTree<T>“ an, das ich im vorherigen Artikel vorgestellt habe. Zum Implementieren von „BinaryTree<T>“ muss „Pair<T>“ zuerst die IEnumerable<T>-Schnittstelle mithilfe eines Iterators unterstützen. Abbildung 3 ist ein Beispiel, das jedes Element in „Pair<T>“ zurückgibt.
In Abbildung 3 durchläuft die Iteration über den Pair<T>-Datentyp die Schleife zwei Mal: zuerst durch „yield return First“ und dann durch „yield return Second“. Bei jedem Durchlaufen der yield return-Anweisung in „GetEnumerator“ wird der Zustand gespeichert, und die Ausführung scheint aus dem Kontext der GetEnumerator-Methode in den Schleifenkörper zu „springen“. Wenn die zweite Iteration beginnt, wird die Ausführung von „GetEnumerator“ mit der yield return Second-Anweisung fortgesetzt.
Abbildung 3: Verwenden von „yield“ zum Implementieren von „BinaryTree<T>“
public struct Pair<T>: IPair<T>,
IEnumerable<T>
{
public Pair(T first, T second) : this()
{
First = first;
Second = second;
}
public T First { get; } // C# 6.0 Getter-only Autoproperty
public T Second { get; } // C# 6.0 Getter-only Autoproperty
#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
}
Implementieren von „IEnumerable“ mit „IEnumerable<T>“
„System.Collections.Generic.IEnumerable<T>“ erbt von „System.Collections.IEnumerable“. Beim Implementieren von „IEnumerable<T>“ ist es daher auch erforderlich, „IEnumerable“ zu implementieren. In Abbildung 3 geschieht dies explizit, und die Implementierung umfasst einfach einen Aufruf der IEnumerable<T> GetEnumerator-Implementierung. Dieser Aufruf aus „IEnumerable.GetEnumerator“ von „IEnumerable<T>.GetEnumerator“ funktioniert aufgrund der Typkompatibilität (über Vererbung) zwischen „IEnumerable<T>“ und „IEnumerable“ immer. Da die Signaturen für beide „GetEnumerators“ identisch sind (der Rückgabetyp unterscheidet nicht zwischen Signaturen), muss mindestens eine der Implementierungen explizit sein. Aufgrund der von der IEnumerable<T>-Version bereitgestellten zusätzlichen Typsicherheit sollte die IEnumerable-Implementierung explizit sein.
Der folgende Code verwendet die Pair<T>.GetEnumerator-Methode und zeigt „Inigo“ und „Montoya“ in zwei aufeinanderfolgenden Zeilen an:
var fullname = new Pair<string>("Inigo", "Montoya");
foreach (string name in fullname)
{
Console.WriteLine(name);
}
Platzieren von „yield return“ in einer Schleife
Es ist nicht erforderlich, jede yield return-Anweisung so hart zu codieren, wie ich bei den „CSharpPrimitiveTypes“ und „Pair<T>“ vorgegangen bin. Mithilfe der yield return-Anweisung können Sie Werte aus dem Schleifenkonstrukt zurückgeben. Abbildung 4 verwendet eine foreach-Schleife. Bei jeder Ausführung von „foreach“ in „GetEnumerator“ wird der nächste Wert zurückgegeben.
Abbildung 4: Platzieren von yield return-Anweisungen innerhalb einer Schleife
public class BinaryTree<T>: IEnumerable<T>
{
// ...
#region IEnumerable<T>
public IEnumerator<T> GetEnumerator()
{
// Return the item at this node.
yield return Value;
// Iterate through each of the elements in the pair.
foreach (BinaryTree<T> tree in SubItems)
{
if (tree != null)
{
// Because each element in the pair is a tree,
// traverse the tree and yield each element.
foreach (T item in tree)
{
yield return item;
}
}
}
}
#endregion IEnumerable<T>
#region IEnumerable Members
System.Collections.IEnumerator
System.Collections.IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
#endregion
}
In Abbildung 4 gibt die erste Iteration das Stammelement innerhalb der binären Struktur zurück. Während der zweiten Iteration traversieren Sie das Unterelementpaar. Wenn das Unterelementpaar einen Wert ungleich null enthält, traversieren Sie in diesen untergeordneten Knoten und geben seine Elemente zurück. Beachten Sie, dass „foreach (T-Element in Struktur)“ ein rekursiver Aufruf eines untergeordneten Knotens ist.
Wie Sie bei „CSharpBuiltInTypes“ und „Pair<T>“ festgestellt haben, können Sie nun durch „BinaryTree<T>“ mit einer foreach-Schleife iterieren. Abbildung 5 zeigt diesen Vorgang.
Abbildung 5: Verwenden von „foreach“ mit „BinaryTree<string>“
// JFK
var jfkFamilyTree = new BinaryTree<string>(
"John Fitzgerald Kennedy");
jfkFamilyTree.SubItems = new Pair<BinaryTree<string>>(
new BinaryTree<string>("Joseph Patrick Kennedy"),
new BinaryTree<string>("Rose Elizabeth Fitzgerald"));
// Grandparents (Father's side)
jfkFamilyTree.SubItems.First.SubItems =
new Pair<BinaryTree<string>>(
new BinaryTree<string>("Patrick Joseph Kennedy"),
new BinaryTree<string>("Mary Augusta Hickey"));
// Grandparents (Mother's side)
jfkFamilyTree.SubItems.Second.SubItems =
new Pair<BinaryTree<string>>(
new BinaryTree<string>("John Francis Fitzgerald"),
new BinaryTree<string>("Mary Josephine Hannon"));
foreach (string name in jfkFamilyTree)
{
Console.WriteLine(name);
}
Und dies sind die Ergebnisse:
John Fitzgerald Kennedy
Joseph Patrick Kennedy
Patrick Joseph Kennedy
Mary Augusta Hickey
Rose Elizabeth Fitzgerald
John Francis Fitzgerald
Mary Josephine Hannon
Der Ursprung von Iteratoren
Im Jahr 1972 begannen Barbara Liskov und ein Team von Wissenschaftlern beim MIT mit der Erforschung von Programmiermethodiken. Der Schwerpunkt lag dabei auf benutzerdefinierten Datenabstraktionen. Zur Untermauerung eines Großteils ihrer Arbeit entwarfen sie eine Sprache namens „CLU“, die ein Konzept verwendete, das als „Cluster“ bezeichnet wurde („CLU“ sind die ersten drei Buchstaben dieses Begriffs). Cluster waren die Vorläufer der primären Datenabstraktion, die Programmierer heute verwenden: Objekte. Bei seiner Forschungsarbeit erkannte das Team, dass es zwar in der Lage war, die CLU-Sprache zum Abstrahieren einer Datendarstellung weg von den Endbenutzern seiner Typen zu verwenden, es jedoch konsequent die innere Struktur der Daten offenlegen musste, damit andere Benutzer diese auf intelligente Weise nutzen konnten. Das Ergebnis dieser bestürzenden Erkenntnis war der Entwurf eines Sprachkonstrukts namens „Iterator“. (Die CLU-Sprache bot zahlreiche Einblicke in das, was später als „objektorientierte Programmierung“ populär wurde.)
Abbrechen weiterer Iterationen: „yield break“
Unter bestimmten Umständen möchten Sie ggf. weitere Iterationen abbrechen. Sie können zu diesem Zweck eine if-Anweisung verwenden, damit keine weiteren Anweisungen im Code ausgeführt werden. Sie können jedoch auch „yield break“ verwenden, damit „MoveNext“ FALSE zurückgibt, die Steuerung sofort an den Aufrufer zurückgegeben und die Schleife beendet wird. Das folgende Beispiel zeigt eine solche Methode:
public System.Collections.Generic.IEnumerable<T>
GetNotNullEnumerator()
{
if((First == null) || (Second == null))
{
yield break;
}
yield return Second;
yield return First;
}
Diese Methode bricht die Iteration ab, wenn eines der Elemente in der Pair<T>-Klasse null ist.
Eine yield break-Anweisung ähnelt dem Platzieren einer return-Anweisung ganz oben in einer Funktion, wenn festgestellt wird, dass keine Aktion auszuführen ist. Durch dieses Verfahren können weitere Iterationen beendet werden, ohne den gesamten verbleibenden Code in einen if-Block einzuschließen. Es sind also mehrere Exits möglich. Verwenden Sie diese Möglichkeit mit Vorsicht, weil beim oberflächliches Lesen des Codes der frühe Exit leicht übersehen werden kann.
Funktionsweise von Iteratoren
Wenn der C#-Compiler auf einen Iterator stößt, erweitert er den Code in die geeignete CIL für das entsprechende Enumeratorentwurfsmuster. Im generierten Code erstellt der C#-Compiler zuerst eine geschachtelte private Klasse zum Implementieren der IEnumerator<T>-Schnittstelle sowie die Current-Eigenschaft und eine MoveNext-Methode. Die Current-Eigenschaft gibt einen Typ zurück, der dem Rückgabetyp des Iterators entspricht. Wie Sie in Abbildung 3 gesehen haben, enthält „Pair<T>“ einen Iterator, der einen T-Typ zurückgibt. Der C#-Compiler untersucht den im Iterator enthaltenen Code und erstellt den erforderlichen Code innerhalb der MoveNext-Methode sowie die Current-Eigenschaft, um sein Verhalten zu imitieren. Für den Pair<T>-Iterator generiert der C#-Compiler annähernd äquivalenten Code (siehe Abbildung 6).
Abbildung 6: C#-Äquivalent von vom Compiler generierten C#-Code für Iteratoren
using System;
using System.Collections.Generic;
public class Pair<T> : IPair<T>, IEnumerable<T>
{
// ...
// The iterator is expanded into the following
// code by the compiler.
public virtual IEnumerator<T> GetEnumerator()
{
__ListEnumerator result = new __ListEnumerator(0);
result._Pair = this;
return result;
}
public virtual System.Collections.IEnumerator
System.Collections.IEnumerable.GetEnumerator()
{
return new GetEnumerator();
}
private sealed class __ListEnumerator<T> : IEnumerator<T>
{
public __ListEnumerator(int itemCount)
{
_ItemCount = itemCount;
}
Pair<T> _Pair;
T _Current;
int _ItemCount;
public object Current
{
get
{
return _Current;
}
}
public bool MoveNext()
{
switch (_ItemCount)
{
case 0:
_Current = _Pair.First;
_ItemCount++;
return true;
case 1:
_Current = _Pair.Second;
_ItemCount++;
return true;
default:
return false;
}
}
}
}
Da der Compiler die yield return-Anweisung verwendet und Klassen generiert, die Code entsprechen, den Sie ggf. manuell geschrieben hätten, zeigen Iteratoren in C# die gleichen Leistungsmerkmale wie Klassen, die das Enumeratorentwurfsmuster manuell implementieren. Auch wenn keine Leistungsoptimierung vorhanden ist, sind die Steigerungen der Programmiererproduktivität erheblich.
Erstellen mehrerer Iteratoren in einer Klasse
Die weiter oben aufgeführten Beispiele haben „IEnumerable<T>.GetEnumerator“ implementiert, die Methode, nach der „foreach“ implizit sucht. Unter bestimmten Umständen möchten Sie ggf. andere Iterationssequenzen verwenden, z. B. Rückwärtsiteration, Filtern der Ergebnisse oder Iterieren durch eine andere Objektprojektion als die Standardprojektion. Sie können weitere Iteratoren in der Klasse deklarieren, indem Sie diese in Eigenschaften oder Methoden kapseln, die „IEnumerable<T>“ oder „IEnumerable“ zurückgeben. Wenn Sie z. B. rückwärts durch die Elemente von „Pair<T>“ iterieren möchten, könnten Sie eine GetReverseEnumerator-Methode bereitstellen. Abbildung 7 zeigt dies.
Abbildung 7: Verwenden von „yield return“ in einer Methode, die „IEnumerable<T>“ zurückgibt
public struct Pair<T>: IEnumerable<T>
{
...
public IEnumerable<T> GetReverseEnumerator()
{
yield return Second;
yield return First;
}
...
}
public void Main()
{
var game = new Pair<string>("Redskins", "Eagles");
foreach (string name in game.GetReverseEnumerator())
{
Console.WriteLine(name);
}
}
Beachten Sie, dass „IEnumerable<T>“ und nicht „IEnumerator<T>“ zurückgegeben wird. Dieses Verhalten unterscheidet sich von „IEnumerable<T>.GetEnumerator“ mit der Rückgabe von „IEnumerator<T>“. Der Code in „Main“ zeigt, wie „GetReverseEnumerator“ mithilfe einer foreach-Schleife aufgerufen wird.
Anforderungen der yield-Anweisung
Sie können die yield return-Anweisung nur in Membern verwenden, die einen IEnumerator<T>- oder IEnumerable<T>-Typ bzw. deren nicht generischen Äquivalente zurückgeben. Member, deren Codekörper eine yield return-Anweisung enthalten, verfügen möglicherweise nicht über eine einfache Rückgabe. Wenn der Member die yield return-Anweisung verwendet, generiert der C#-Compiler den erforderlichen Code zum Speichern des Zustands des Iterators. Wenn der Member die return-Anweisung anstelle von „yield return“ verwendet, ist der Programmierer im Gegensatz dazu dafür verantwortlich, seinen eigenen Zustandandautomaten zu verwalten und eine Instanz einer der Iteratorschnittstellen zurückzugeben. Außerdem gilt Folgendes: Ebenso wie alle Codepfade in einer Methode mit einem return-Typ eine return-Anweisung und einen Wert enthalten müssen (wenn sie keine Ausnahme auslösen), müssen alle Codepfade in einem Iterator eine yield return-Anweisung enthalten, wenn sie Daten zurückgeben sollen.
Die folgenden zusätzlichen Einschränkungen für die yield-Anweisung führen zu Compilerfehlern, wenn sie missachtet werden:
- Die yield-Anweisung darf nur in einer Methode, in einem benutzerdefinierten Operator oder im get-Accessor eines Indexers oder einer Eigenschaft verwendet werden. Der Member darf keinen ref- oder out-Parameter annehmen.
- Die yield-Anweisung darf nicht in einer anonymen Methode oder einem Lambdaausdruck verwendet werden.
- Die yield-Anweisung darf nicht in den catch- und finally-Klauseln der try-Anweisung verwendet werden. Außerdem darf eine yield-Anweisung nur in einem try-Block verwendet werden, wenn kein catch-Block vorhanden ist.
Zusammenfassung
Generika waren zweifellos das coole neue Feature in C# 2.0. Sie waren aber nicht das einzige sammlungsbezogene Feature, das damals eingeführt wurde. Eine weitere signifikante Neuerung war der Iterator. Wie ich in diesem Artikel erläutert habe, umfassen Iteratoren ein kontextanhängiges Schlüsselwort („yield“), das von C# zum Generieren des zugrunde liegenden CIL-Codes verwendet wird, der das von der foreach-Schleife verwendete Iteratormuster implementiert. Außerdem habe ich die yield-Syntax vorgestellt und beschrieben, wie sie die GetEnumerator-Implementierung von “IEnumerable<T>“ erfüllt, das Ausbrechen aus einer Schleife mit „yield break“ ermöglicht und sogar eine C#-Methode unterstützt, die ein IEnumeable<T>-Objekt zurückgibt.
Ein Großteil dieser Kolumne beruht auf meinem Buch „Essential C#“ (IntelliTect.com/EssentialCSharp), das ich zurzeit mit „Essential C# 7.0“ auf den neuesten Stand bringe. Weitere Informationen zu diesem Thema finden Sie in Kapitel 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