Yielding - eine einfache Möglichkeit, Enumeratoren umzusetzen

    • VB.NET

      Yielding - eine einfache Möglichkeit, Enumeratoren umzusetzen

      Hallo Community.

      Heute möchte ich euch das Prinzip von Yielding näherbringen und euch zeigen, was die Vorteile gegenüber konventioneller Enumeratoren sind. Codes gibts wie immer in VB.Net und C#.
      Zuerst einmal müsst ihr wissen, Yield ist ein Compilerfeature und hängt damit nicht von der verwendeten Framework-Version, sondern von der VisualStudio-Version ab, die ihr verwendet. Um das Yield-Schlüsselwort verwenden zu können, braucht ihr in VB.Net mindestens Visual Studio 2012 und in C# mindestens Visual Studio 2005.


      Was sind überhaupt Enumeratoren?
      Da Yield stark auf der Funktionsweise von Enumeratoren aufbaut, werde ich erst einmal genau beschreiben, was Enumeratoren sind und wie sie funktionieren.

      Die Basis von Enumeratoren bildet das IEnumerable<T>-Interface. Klassen oder Strukturen, die IEnumerable implementieren, sind immer eine Art von Auflistung bzw. etwas, von dem man mehrere Elemente des selben Typs bekommt. Die bekanntesten Beispiele für Auflistungsklassen, die IEnumerable implementieren, sind vermutlich Array, List<T> und Dictionary<TKey, TValue>, es gibt aber noch einige mehr davon, zu finden im System.Collections.Generic-Namespace.
      Wichtig zu beachten ist, dass alle diese genannten ein spezieller Fall von IEnumerables sind, nämlich Collections (repräsentiert durch das ICollection-Interface, welches eine Erweiterung für IEnumerable ist). Collections sind immer von endlicher Länge, jedoch wird dies nicht zwingend von IEnumerable vorausgesetzt, soll heißen, eine Klasse, die IEnumerable implementiert, kann theoretisch unendlich Elemente besitzen. Warum das möglich ist, obwohl doch nichts am Computer unendlich sein kann, wird sich später zeigen, wenn wir die Funktionsweise von IEnumerable durchsprechen.

      Das IEnumerable<T>-Interface deklariert als einzigen Member die GetEnumerator()-Funktion. Diese gibt ein weiteres Interface zurück, nämlich IEnumerator<T>, auf diesem IEnumerator geschieht dann die "Magie".
      Bevor wir weitermachen: IEnumerator implementiert IDisposable, ihr solltet also immer Dispose aufrufen, wenn ihr mit einem IEnumerator gearbeitet habt. Ihr werdet aber grundsätzlich sogut wie nie mit diesem Interface in Berührung kommen, warum, erkläre ich weiter unten.
      Das IEnumerator-Interface hat neben Dispose noch zwei weitere Methoden, MoveNext() und Reset(), und die Eigenschaft Current. Um zu verstehen, wie diese miteinander zusammenarbeiten, könnt ihr euch den IEnumerator als Zeiger auf das IEnumerable vorstellen, von dem ihr ihn euch geholt habt. Er zeigt immer auf genau ein Element in diesem IEnumerable, welches durch die Current-Eigenschaft dargestellt wird. Dadurch ergibt sich oben angesprochene Möglichkeit von unendlichen Auflistungen, denn der IEnumerator speichert effektiv immer nur ein Element (das, worauf er gerade zeigt). Alle anderen Elemente im IEnumerable müssen nicht zwangsläufig gespeichert sein, sondern könnten dynamisch generiert werden, auch endlos. Eine solche Implementierung von IEnumerable wird euch vermutlich nur sehr selten oder gar nicht begegnen, aber behaltet einfach im Hinterkopf, dass es theoretisch möglich wäre.
      Um nun an das nächste Element aus der Auflistung zu kommen, müsst ihr MoveNext aufrufen, dadurch wird Current dann zum nächsten Element in der Auflistung. Zu beachten ist hier auch, dass der Anfangszustand von eine IEnumerators so festgelegt ist, dass er auf kein Element zeigt, ihr müsst also auch einmal MoveNext aufrufen, um an das erste Element zu gelangen. MoveNext gibt euch außerdem einen Boolean zurück, der euch sagt, ob überhaupt noch ein Element in der Auflistung vorhanden war. Kommt True zurück, so befindet sich das nächste Element in Current, kommt False zurück, so gab es kein weiteres Element und Current ist Nothing. So könnt ihr bestimmen, wann ihr das Ende der Auflistung erreicht habt.
      Zu guter letzt setzt Reset den Enumerator wieder in den Anfangszustand zurück.

      For Each und IEnumerator<T>
      Höchst wahrscheinlich habt ihr schon des öfteren mit dem IEnumerator-Interface gearbeitet, ihr habt es bloß nicht gemerkt, da der Compiler euch eine weit bequemere Möglichkeit gibt, als andauernd MoveNext aufzurufen und Current abzufragen. Diese Möglichkeit ist For Each.

      For Each lässt sich ungefähr so übersetzten:

      VB.NET-Quellcode

      1. For Each item In list
      2. Next

      C-Quellcode

      1. foreach (var item in list)
      2. { }
      ist äquivalent zu:

      VB.NET-Quellcode

      1. Dim enumerator = list.GetEnumerator()
      2. Do While enumerator.MoveNext()
      3. Dim item = enumerator.Current
      4. Loop
      5. enumerator.Dispose()

      C-Quellcode

      1. var enumerator = list.GetEnumerator();
      2. while (enumerator.MoveNext())
      3. {
      4. var item = enumerator.Current;
      5. }
      6. enumerator.Dispose();

      Sieht mit For Each doch eindeutig besser aus, funktioniert im Hintergrund aber mit IEnumerator.

      Wie kann ich selbst Enumeratoren implementieren?
      Okay, genug der Theorie, jetzt kommt mal ein praktischer Anwendungsfall, wobei man Enumeratoren gebrauchen könnte. Ich werde den selben Fall später auch mit Yield realisieren, damit ihr einen Vergleich habt.
      Unser Zeil ist es, eine Extension-Methode für den TextReader (Basisklasse von StreamReader) zu erstellen, die die einzelnen Zeilen enumeriert, anstatt sie in ein Array zu schreiben. Vorteil ist, dass wir immer nur die aktuelle Zeile speichern und auch nur die Zeilen lesen, die wirklich angefragt werden, was z.B. bei zeitaufwändigen Streamzugriffen wie Festplattenzugriff nützlich ist.
      Wenn ihr nicht wisst, was eine Extension-Methode ist, hier wirds für VB erklärt und hier für C#.

      Zuerst legen wir fest, wie unsere Funktion aussehen wird. Klar ist, sie muss ein IEnumerable<T> zurückgeben, damit wir enumerieren können. Der einzige Parameter wird der TextReader sein (siehe dazu bei den Extension-Methoden) und T ist String, denn ein Zeile ist ja ein String.
      Das ist also unsere Funktion:

      VB.NET-Quellcode

      1. <Extension>
      2. Public Function Lines(reader As TextReader) As IEnumerable(Of String)
      3. End Function

      C-Quellcode

      1. public static IEnumerable<string> Lines(this TextReader reader)
      2. {
      3. }

      Jetzt müssen wir von irgend wo her ein IEnumerable bekommen. Eine List<T> oder ein Array kommt hierfür aber nicht in Frage, denn damit hätten wir unser Ziel ja verfehlt, und würden doch wieder alles auf einmal laden. Wir müssen uns also selbst was basteln.
      Also gut, dann erstellen wir halt eine neue Klasse und lassen diese IEnumerable<string> implementieren.
      Spoiler anzeigen

      VB.NET-Quellcode

      1. Friend Class TextReaderLinesEnumerable : Implements IEnumerable(Of String)
      2. Public Function GetEnumerator() As IEnumerator(Of String) Implements IEnumerable(Of String).GetEnumerator
      3. End Function
      4. Public Function GetEnumerator1() As IEnumerator Implements IEnumerable.GetEnumerator
      5. End Function
      6. End Class

      C-Quellcode

      1. internal class TextReaderLinesEnumerable : IEnumerable<string>
      2. {
      3. public IEnumerator<string> GetEnumerator()
      4. {
      5. }
      6. System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
      7. {
      8. }
      9. }

      Und schon merken wir, uups, wir brauchen ja auch noch den Enumerator selbst. Gesagt getan, hier ist er:
      Spoiler anzeigen

      VB.NET-Quellcode

      1. Friend Class TextReaderLinesEnumerator : Implements IEnumerator(Of String)
      2. Public ReadOnly Property Current As String Implements IEnumerator(Of String).Current
      3. Get
      4. End Get
      5. End Property
      6. Public Sub Dispose() Implements IDisposable.Dispose
      7. End Sub
      8. Public ReadOnly Property Current1 As Object Implements IEnumerator.Current
      9. Get
      10. End Get
      11. End Property
      12. Public Function MoveNext() As Boolean Implements IEnumerator.MoveNext
      13. End Function
      14. Public Sub Reset() Implements IEnumerator.Reset
      15. End Sub
      16. End Class

      C-Quellcode

      1. internal class TextReaderLinesEnumerator : IEnumerator<string>
      2. {
      3. public string Current
      4. {
      5. get { }
      6. }
      7. public void Dispose() { }
      8. object System.Collections.IEnumerator.Current
      9. {
      10. get { }
      11. }
      12. public bool MoveNext()
      13. {
      14. }
      15. public void Reset()
      16. {
      17. }
      18. }

      Jetzt müssen wir es schaffen, dass dieser Enumerator die Zeilen des TextReader enumeriert. Folglicherweise muss der Enumerator den TextReader kennen, was wiederum bedeutet, dass wir diesen durchreichen müssen.
      Für diesen Zweck habe ich folgenden Code im Enumerator hinzugefügt:

      VB.NET-Quellcode

      1. Private reader As TextReader
      2. Public Sub New(reader As TextReader)
      3. Me.reader = reader
      4. End Sub

      C-Quellcode

      1. TextReader reader;
      2. public TextReaderLinesEnumerator(TextReader reader)
      3. {
      4. this.reader = reader;
      5. }
      Und das Enumerable, dass damit auch schon fertig ist, sieht jetzt so aus:
      Spoiler anzeigen

      VB.NET-Quellcode

      1. Friend Class TextReaderLinesEnumerable : Implements IEnumerable(Of String)
      2. Private reader As TextReader
      3. Public Sub New(reader As TextReader)
      4. Me.reader = reader
      5. End Sub
      6. Public Function GetEnumerator() As IEnumerator(Of String) Implements IEnumerable(Of String).GetEnumerator
      7. Return New TextReaderLinesEnumerator(reader)
      8. End Function
      9. Public Function GetEnumerator1() As IEnumerator Implements IEnumerable.GetEnumerator
      10. Return New TextReaderLinesEnumerator(reader)
      11. End Function
      12. End Class

      C-Quellcode

      1. internal class TextReaderLinesEnumerable : IEnumerable<string>
      2. {
      3. TextReader reader;
      4. public TextReaderLinesEnumerable(TextReader reader)
      5. {
      6. this.reader = reader;
      7. }
      8. public IEnumerator<string> GetEnumerator()
      9. {
      10. return new TextReaderLinesEnumerator(reader);
      11. }
      12. System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
      13. {
      14. return new TextReaderLinesEnumerator(reader);
      15. }
      16. }
      Dies ist übrigens ein Paradebeispiel dafür, dass ein IEnumerable nicht unbedingt eine Liste mit bekannter Größe sein muss, in der alle Elemente gespeichert sind. Dieses Enumerable weiß gar nichts davon, wie groß es ist, und speichert auch nicht seine Elemente.

      Alles, was nun noch zu tun bleibt, ist den eigentlich Enumerationsvorgang zu implementieren. Dazu lese ich einfach bei MoveNext() die nächste Zeile aus dem Reader, welche durch Current zurückgegeben wird.
      Ich habe leider keine Möglichkeit gefunden, die Reset-Funktion zu implementieren. Vielleicht hat dazu ja jemand ne Idee, wenn ja, dann sagt sie mir bitte. :)
      Spoiler anzeigen

      VB.NET-Quellcode

      1. Friend Class TextReaderLinesEnumerator : Implements IEnumerator(Of String)
      2. Private _current As String
      3. Public ReadOnly Property Current As String Implements IEnumerator(Of String).Current
      4. Get
      5. Return _current
      6. End Get
      7. End Property
      8. Public Sub Dispose() Implements IDisposable.Dispose
      9. End Sub
      10. Public ReadOnly Property Current1 As Object Implements IEnumerator.Current
      11. Get
      12. Return _current
      13. End Get
      14. End Property
      15. Public Function MoveNext() As Boolean Implements IEnumerator.MoveNext
      16. If reader.Peek() >= 0 Then
      17. Current = reader.ReadLine()
      18. Return True
      19. End If
      20. Return False
      21. End Function
      22. Public Sub Reset() Implements IEnumerator.Reset
      23. End Sub
      24. End Class

      C-Quellcode

      1. internal class TextReaderLinesEnumerator : IEnumerator<string>
      2. {
      3. TextReader reader;
      4. public TextReaderLinesEnumerator(TextReader reader)
      5. {
      6. this.reader = reader;
      7. }
      8. public string Current { get; private set; }
      9. public void Dispose() { }
      10. object System.Collections.IEnumerator.Current
      11. {
      12. get { return Current; }
      13. }
      14. public bool MoveNext()
      15. {
      16. if (reader.Peek() >= 0)
      17. {
      18. Current = reader.ReadLine();
      19. return true;
      20. }
      21. return false;
      22. }
      23. public void Reset() { }
      24. }

      Alles, was nun noch zu tun bleibt, ist, ein solches Enumerable in unserer Funktion zurückzugeben:

      VB.NET-Quellcode

      1. <Extension>
      2. Public Function Lines(reader As TextReader) As IEnumerable(Of String)
      3. Return New TextReaderLinesEnumerable(reader)
      4. End Function

      C-Quellcode

      1. public static IEnumerable<string> Lines(this TextReader reader)
      2. {
      3. return new TextReaderLinesEnumerable(reader);
      4. }



      Zwischenfazit
      So, endlich geschafft, jetzt können wir mittels For Each die Zeilen eines TextReaders durchenumerieren.
      Findet irgend jemand, dass das viel zu aufwändig für so eine simple Aufgabe war? Ich auch. :D
      Hier kommt Yield ins Spiel, mit dessen Hilfe wir diesen unverschämt langen Code auf ein Minimum zusammenschrumpfen lassen können.


      Yield
      Yield ist ein kleines aber feines Schlüsselwort, das uns gleich einiges an Arbeit ersparen wird, denn Yield erzeugt Enumeratoren, und das komplett automatisch.
      Zur Demonstration ist hier mal der Code von oben, nur mit Yield umgesetzt:

      VB.NET-Quellcode

      1. <Extension>
      2. Public Iterator Function Lines(reader As TextReader) As IEnumerable(Of String)
      3. While reader.Peek() >= 0
      4. Yield reader.ReadLine()
      5. End While
      6. End Function

      C-Quellcode

      1. public static IEnumerable<string> Lines(this TextReader reader)
      2. {
      3. while (reader.Peek() >= 0)
      4. {
      5. yield return reader.ReadLine();
      6. }
      7. }

      Jetzt wird sich vermutlich manch einer fragen "Ja ist der Artentus denn ein Zauberer, wenn der 50 Zeilen Code in so ne kleine Funktion reinquetschen kann?".
      Und nein, natürlich ist das keine Zauberei, das ist Yield. ;) Wir haben den Code lediglich auf das verkürtzt, was er eigentlich macht, nämlich einfach alle Zeilen des TextReaders nacheinander zurückzugeben.
      Aber wie haben wir das gemacht?
      Nun, so kompliziert ist es eigentlich gar nicht. Es sollte euch aufgefallen sein, dass der Rückgabewert der Funktion ein IEnumerable<string> ist, jedoch returne ich nur Strings (Zeile 4 VB, 5 C#). Und noch verrückter, ich habe ein Return in einer Schleife stehen, obwohl ich mit Return ja normalerweise direkt die Funktion verlassen würde.
      Die Erklärung ist: schreibt man vor das Return ein Yield, dann steht es nicht mehr für eine direkte Rückgabe des angegebenen Wertes, sondern es fügt den Wert vielmehr einem IEnumerable hinzu. Das besondere ist hierbei aber das, was bei einem Aufruf der Funktion passiert. Enumeriert man über diese Funktion mittels For Each oder mit MoveNext, so wird der Code darin nur bis zum ersten Yield ausgeführt, der Wert, der geyieldet wurde, wird in Enumerator.Current geschrieben bzw. in die Laufvariable der Schleife und dann wir die Funktion verlassen. Allerdings merkt sich das Yield, wo es zuletzt im Code stand und beim nächsten Aufruf an MoveNext bzw. im nächsten Schleifendurchlauf wird einfach an dieser Stelle die Ausführung fortgesetzt. Das geht so lange, bis das tatsächliche Ende der Funktion erreicht ist und der vom Yield generierte Enumerator beim MoveNext False zurückgibt bzw. die Schleife zu ende ist.
      oder um es nochmal kurz und klar auszudrücken: wenn man auf dem Enumerator MoveNext aufruft, dann wird die Funktion so lange ausgeführt, bis die Ausführung auf ein Yield trifft. Der Wert, der dort zurückgegeben wird, wird in die Current-Eigenschaft des Enumerators gespeichert und die Ausführung springt zurück an die Stelle, an der MoveNext aufgerufen wurde. Beim nächsten MoveNext-Aufruf springt die Ausführung wieder an die Stelle des Yields, an dem als letztes abgebrochen wurde und geht so lange weiter, bis auf das nächste Yield getroffen wird oder die Funktion zu ende ist.

      Daraus folgt automatisch, dass eine Funktion, die mindestens ein Yield enthällt, 1. einen Rückgabewert vom Typ IEnumerable<T> oder IEnumerator<T> haben muss und 2. alles, was man mit Yield Return diesem Enumerable/Enumerator hinzufügt natürlich vom Typ T oder von einem abgeleiteten (bzw. implizit darin konvertierbaren) Typen sein muss. In diesem Fall kann ich also nur String yielden, weil T bei mir String ist.
      In VB.Net muss eine Funktion, die Yield verwendet, mit dem "Iterator"-Schlüsselwort gekennzeichnet werden.
      Weiterhin sollte man auch keine normalen Return-Statements in einer Funktion verwenden, die schon Yield Return enthält.

      Yield Break
      Da man ja kein normales Return mehr in einer solchen Funktion verwenden kann, braucht man einen anderen Weg, um die Funktion vorzeitig zu beenden. Dies wird durch Yield Break realisiert.
      Stößt die Ausführung auf ein Yield Break-Statement, so wird die Funktion sofort verlassen und MoveNext gibt False zurück. Genau genommen wird also nicht die Funktion verlassen, sondern der Enumerator wird beendet, was aber im Prinzip auf das selbe herausläuft.


      Schlusswort
      Ich habe irgendwas zu ungenau erklärt oder ihr möchtet, dass ich auf etwas noch einmal genauer eingehe? Ihr könnt mich jederzeit gerne fragen.
      Ihr habt irgendwo einen Fehler entdeckt? Bitte sagt es mir, dann werde ich es korrigieren.

      Den kompletten Code könnt ihr euch auch als Archiv runterladen, ihr findet ihn im Anhang.
      Es liegt außerdem auch ein Beispielcode von @ErfinderDesRades: bei, der nochmal einen etwas komplizierteren Fall zeigt, vielen Dank hierfür.

      Ich hoffe, ich konnte euch auch dieses mal helfen und euch Yield verständlich darlegen.
      Dateien
      • YieldSample01.rar

        (146,11 kB, 222 mal heruntergeladen, zuletzt: )

      Dieser Beitrag wurde bereits 3 mal editiert, zuletzt von „Artentus“ ()