Jump to content
Unity Insider Forum
Sign in to follow this  
Sid Burn

Coroutinen und LINQ

Recommended Posts

Da es an dieser Stelle wohl immer wieder auf kommt wollte ich ein kleines Tutorial zum Thema Coroutinen bzw. dessen implementierung geben. Immer wieder kommt die Frage auf wie Coroutinen implementiert sind. z.B. bei der Frage ob eine Coroutine ein Thread darstellt. An dieser Stelle zeige ich was hinter Coroutinen stekt. Und wie man diese Technik allgemein besser nutzen kann. Anders jedoch als vielleicht einige vermuten würden führt das ganze in richtung LINQ, foreach und "Extension Methods".

 

An dieser Stelle möchte ich einen sogenannten Top-Down erklärung nehmen. Wir fangen mit dem mächtigsten Konstrukt an, und bröseln es dann weiter in seine bestandteile auf. Das mächtigste Konstrukt ist jedoch ersteinmal das sogenannte IEnumerable interface. Man sollte es nicht mit IEnumerator verwechseln. Ein IEnumerable nutzt ein IEnumerator. Wenn man Coroutinen in Unity nutzt dann nutzt man jedoch letzteres (IEnumerator). Ansonsten gibt es von beiden Interfaces eine Generic und eine non-Generic Variante. Die vollen Pfade der Interface sind.

 

System.Collections.IEnumerator <- (Das nutzt Unity als "Coroutine")

System.Collections.IEnumerable

System.Collections.Generic.IEnumerator<T>

System.Collections.Generic.IEnumerable<T> <- (Hiermit fangen wir an)

 

1) IEnumerable<T>

 

IEnumerable<T> ist lediglich ein Interface das man einfach sehr simpel für jede Klasse implementieren kann. Jedoch schauen wir uns dies nicht direkt an. Man selber muss nämlich innerhalb von C# nie direkt eine Klasse mit diesem Interface bauen. Stattdessen unterstützt C# eine spezielle Syntax das dies für uns übernimmt. Es gibt das sogenannte "yield" Schlüsselwort das dies für uns übernimmt. Zum Beispiel können wir folgendes schreiben um einen Fibonacci Zahlen generator zu schreiben.

 

Wer Fibonacci Zahlen nicht kennt. Jede neue Zahl wird berechnet indem man die zwei vorherigen zahlen addiert. Man fängt mit "1,1" and. Man bekommt also folgende reihenfolge -> 1,1,2,3,5,8,13,21,... Fibonacci ist eine unendliche Sequenz.

 

Man könnte diese Funktion folgendermaßen in C# implementieren

 

static IEnumerable<long> Fib() {
yield return 1;
var x = 1L;
var y = 1L;
while ( true ) {
	var nx = x + y;
	y = x;
	x = nx;
	yield return x;
}
}

 

Um die ersten 30 Fibonacci zahlen auszugeben könnten wir folgendes schreiben.

 

var fibs = Fib();
foreach (var fib in fibs.Take(30)) {
Console.WriteLine("{0}", fib);
}

 

Schauen wir uns als erstes die Definition der Fib Funktion etwas genauer an. Als erstes sehen wir die benutzung von "yield return" anstatt einem normalen "return". Dazu wurde der Rückgabewert geändert. Anstatt das wir eine Funktion haben die einfach nur ein "long" zurück gibt. Haben wir stattdessen "IEnumerable<long>". In einer normalen Funktion können wir sonst nur einen einzigen wert zurück geben. Was haben also mehrere "yield return" zu bedeuten? Anders als bei normalen Funktion kann man sich das ganze grob folgendermaßen vorstellen.

 

Wenn "yield return" aufgerufen wird, dann liefert die funktion einen wert zurück. Jedoch wenn man die Funktion nochmals aufruft, dann fängt man exakt da an, wo man aufgehört. Man kann dies praktisch auch als eine art "pausieren" ansehen. Nach diesem Prinzip gibt unsere Funktion also zuerst mit "yield return 1" die Zahl "1" als ersten wert zurück. Und würde dann pausieren. Ruft man die funktion wieder auf, dann würde man da anfangen wo man aufgehört hat. Es würden dann die variable "x" und "y" definiert werden. Und dann in die endlos schleife in while herein gehen. Die berechnung die dann ausgeführt wird, berechnet den neuen wert und speichert diese werte in x sowie y. Am ende der Schleife wird mit "yield return x" dann der letzte wert zurück geliefert.

 

In jeder anderen Funktion wäre das eine endlos schleife und sollte man vermeiden. Jedoch da "yield return" praktisch nur einen wert zurück gibt und dann die funktion pausiert, stellt dies kein Problem dar. Tatsächlich ist das ganze sogar praktisch. Den wir können so die unendliche Folge von Fibonacci zahlen als eine Funktion darstellen.

 

Doch wie genau bekommen wir werte aus der Funktion? Zuerst muss man dafür ersteinmal die Funktion aufrufen. Wenn wir "Fib()" aufrufen dann bekommen wir zuersteinmal ein "IEnumerable<long>" objekt zurück. jedoch sei an dieser stelle erwähnt das wir noch keinerlei Fibonacci Zahl berechnet haben. Wir haben wie bei einem "new Objekt" aufruf lediglich ein neues objekt erstellt das nun praktisch unsere Funktion darstellt. Um nun jeden wert eines IEnumerable durchzugehen hat C# eine spezielle Konstrukt. Nämlich das "foreach" konstrukt. "foreach" arbeitet nur mit objekten die das "IEnumerable<T>" interface implementieren. Das was foreach im grunde macht ist folgendes.

 

1) Es ruft die Funktion auf

2) Wird ein ein wert zurück geliefert dann wird der schleifen body ausgeführt

3) Wenn kein wert zurück geliefert wird, dann ist foreach zuende

 

Doch was passiert wie bei unseren fall wo wir kein ende haben? Fibonacci ist doch unendlich. Nun würden wir nicht ".Take(30)" aufrufen (eine LINQ Methode) dann würden wir in eine endlos schleife landen. Jedoch auch hier gibt es shcon interessantes zu sehen. Wir hätten zwar eine endlosschleife, jedoch würden wir sofort eine Zahl nach der anderen sehen. Stellen wir uns vor wir hätten stattdessen eine methode geschrieben das uns ein "List<long>" zurück zu geben versucht.

 

Der unterschied würde sein das wir zwar ebenfalls eine Endlos-Methode haben, jedoch würden wir versuchen eine Liste aufzubauen die immer weiter und weiter wächst. Bis irgendwann das programm abstürzt da wir nicht genügend speicher haben um eine unnedliche Liste zu enthalten. Und solange unsere Funktion läuft würden wir auch keine Zahlen auf dem Bildschirm sehen. Den es kann ja nie eine List<long> zurück gegeben werden.

 

In diesem Sinne können wir uns IEnumerable<long> sogar wie ein List<long> zur vereinfachung vorstellen. Es stellt also grundsätzlich eine Collection von werten dar. Der unterschied bei IEnumerable<long> ist jedoch das neue werte erst bei bedarf generiert werden können. Und man bekommt jeden einzelnen wert direkt zurück. Wir können also auch von unserer unendlichen Folge von Fibonacci zahlen nur die ersten 30 Zahlen generieren lassen und diese ausgeben.

 

Die Methode ".Take(30)" die uns für "IEnumerable<T>" zur verfügung stellt macht genau dies.

 

 

2) Wie funktioniert Take(30)?

 

Dies lässt nun die Frage aufkommen, wie funktioniert die Take Methode eigentlich genau? Bisher wissen wir nur das wir mit "foreach" alle werte durchgehen können. Jedoch wie können wir nur die ersten 30 Werte durchgehen? Die Antwort finden wir genauer wenn wir uns das IEnumerable<T> interface genauer anschauen. Den Tatsächlich implementiert dieses Interface nur eine einzige Methode. Nämlich eine Methode Namens "GetEnumerator()" das uns einen "IEnumerator<T>" zurück liefert. Wir könnten also auch folgendes Schreiben.

 

IEnumerable<long> fibs  = Fib();
IEnumerator<long> fibs2 = fibs.GetEnumerator();

 

Zur veranschaulichung habe ich die Typen explizit geschrieben. Unser Fib() aufruf liefert uns also ein IEnumerable zurück. Und dieses hat eine Methode namen "GetEnumerator" das uns nun einen IEnumerator zurück liefert. Welche Methoden hat also nun ein IEnumerator?

 

Es hat zuerst einen Property namens "Current" und Methoden wie "MoveNext" und "Reset". Tatsächlich können wir so nun selber Take(30) implementieren. Das ganze sieht nun so aus.

 

IEnumerable<long> fibs  = Fib();
IEnumerator<long> fibs2 = fibs.GetEnumerator();

for (int i=0; i<30; i++) {
fibs2.MoveNext();
Console.WriteLine("{0}", fibs2.Current);
}

 

Das was wir also tun ist folgendes.

 

1) Wir generieren eine schleife die bis 30 zählt.

2) Wir rufen MoveNext() auf unseren "IEnumerator" auf. Dieses "unpausiert" unsere Funktion und generiert einen neuen wert.

3) Der aktuell/zuletzt berechnete Wert können wir über das Property "Current" erfahren.

 

 

 

3) Es gibt kein "pausieren"

 

Tatsächlich existiert ein "pausieren" und "wiederherstellen" eigentlich überhaupt nicht. Rufen wir MoveNext() auf, dann ist dies wie jede andere Methode auch. Sie wird ausgeführt bis sie fertig ist, und das war es. Ein IEnumerator ist letztendlich nichts anderes oder sogar vergleichbar zu der Random Klasse! Beispielsweise so benutzen wir die C# Random Klasse.

 

var rng    = new System.Random();
var random = rng.NextDouble();

 

Das heißt unser MoveNext() berechnet nur einen wert, und setzt einfach einen Property. Letztendlich können wir unser Fibonacci auch selber direkt als eine Klasse schreiben das das IEnumerator Interface selber implementiert. Wie würde das nun aussehen?

 

class EnumeratorExample : IEnumerator<long> {
public long Current { get; private set; }
private long x = 1L;
private long y = 1L;

// non-generic IEnumerator implementation
object IEnumerator.Current {
	get {
		return this.Current as object;
	}
}

public void Dispose() {
	// Nothing to Dispose
}

public bool MoveNext() {
	this.Current = x + y;
	y = x;
	x = this.Current;
	return true;
}

public void Reset() {
	x = 1L;
	y = 1L;
}
}

 

benutzen können wir diese Klasse exakt wie das was uns Fib().GetEnumerator() zurück geliefert hat. Tatsächlich generiert der C# Compiler nur solche Klassen wann immer wir "yield" in einer Funktion verwenen.

 

IEnumerator<long> fibs = new EnumeratorExample();

for (int i=0; i<30; i++) {
fibs.MoveNext();
Console.WriteLine("{0}", fibs.Current);
}

 

Ansonsten müssen wir beim direkten implementieren noch "object IEnumerator.Current" sowie "Dispose" und "Reset" implementieren. Der Kern unserer Logik ist aber die MoveNext() Funktion. Das was wir hier tun ist bei jedem Aufruf einfach einen neuen wert zu berechnen. Dieses in "Current" zu speichern und das war es.

 

Wir sehen ansonsten noch das unser "MoveNext" ein bool zurück liefert. Dieses ist dafür da um das ende zu mackieren. Geben wir "true" zurück dann sagen wir "es kommen noch werte". Geben wir "false" zurück dann sagen wir das dies der letzte wert war und wir am ende angelangt sind. In unserem Fall da wir eine unendliche Reihenfolge haben, und es immer einen neuen wert gibt (wie bei der Random Klasse) geben wir jedoch immer "true" zurück.

 

Wegen dieser Funktionalität sollte man normalerweise eher soetwas nutzen um jeden wert eines IEnumerator durchzuegehen.

 

IEnumerator<long> fibs = new EnumeratorExample();
while ( fibs.MoveNext() ) {
var current = fibs.Current;
// Do something with current
}

 

im Grunde ist das auch die korrekte übersetzung was ein "foreach" tut.

 

foreach ( var current in Fibs() ) {
    // Do something with current
}

 

Da wir jedoch unendlich Werte haben, und MoveNext immer "true" zurück liefert, müssen wir also explizit "MoveNext" sooft aufrufen wie wir es wollen. Dies ist in grunde auch im groben wie ein "Take" implementiert ist. Es ruft einfach so oft MoveNext auf, wie benötigt.

 

 

 

4) State Machines

 

Jedoch ist das noch nicht alles, wer tatsächlich aufgepasst hat, wird einen kleinen unterschied merken in unserer expliziten implementierung. Rufen wir Fib auf das direkt "yield return" nutzt dann bekommen wir "1, 2, 3, 5" am anfang zurück. Unsere EnumeratorExample Klasse fängt jedoch mit "2, 3, 5, 8" an. Daher die beginnende "1" fehlt. Warum?

 

Nun der grund hierfür ist das unsere eigene Implementierung zwar die "Loop" Logik umgewandelt hat. Jedoch nicht das erste beginnende "yield return". Wir erinnern uns. Unsere Fib Funktion sah so aus.

 

static IEnumerable<long> Fib() {
yield return 1;
....
while ( true ) {
	....
	yield return x;
}
}

 

Daher wir hatten zu beginn der funktion ein yield. Und dann eine Loop mit yield. Unsere eigene Implementation enthält aber nur die Loop Logik. Wie können wir MoveNext() also nun so implementieren das wir "1" ausgeben. Und danach erst unsere Berechnung ausgeführt wird? Nun wir könnten natürlich "x=0" und "y=1" setzen. dann würde unsere erste berechnung "var nx = 0 + 1" sein und es würde ebenfalls 1 heraus kommen. Dass ist jedoch nicht dasselbe. Tatsächlich können wir ja auch soviele explizit "yield return" hinzufügen wie wir möchten. Beispielsweise könnten wir ja auch so anfangen.

 

static IEnumerable<long> Fib() {
yield return 1;
yield return 1;
....
while ( true ) {
	....
	yield return x;
}
}

 

Dies wäre dann nicht mehr so einfach behebar. Wie bekommen wir also solch eine Logik in einer "flachen" Funktion? Die Antwort lautet, durch eine State Machine. Bei einer State Machine geht es darum das wir einen State mit uns führen. Anhand diesen State können wir dann entscheiden was wir genau ausführen müssen. Durch das ändern des States können wir ändern was als nächstes ausgeführt. Nun Code sagt mehr als Worte, dahher hier ein Beispiel wie wir die führende 1 korrekt implementiert bekommen.

 

class EnumeratorStateExample : IEnumerator<long> {
public long Current { get; private set; }
private long x = 1L;
private long y = 1L;
private int state = 1;

// non-generic IEnumerator implementation
object IEnumerator.Current {
	get {
		return this.Current as object;
	}
}

public void Dispose() {
	// Nothing to Dispose
}

public bool MoveNext() {
	var retn = false;
	switch (this.state) {
		case 1:
			retn = this.State1();
			break;
		case 2:
			retn = this.State2();
			break;
	}   
	return retn;
}

bool State1() {
	this.Current = 1;
	this.state = 2;
	return true;
}

bool State2() {
	this.Current = x + y;
	y = x;
	x = this.Current;
	return true;
}

public void Reset() {
	x = 1L;
	y = 1L;
}
}

 

Was wir nun sehen ist einfach eine zusätzliche variable "state". Diese befindet sich zu anfang in "state 1". Wenn wir in "State 1" sind dann wird die Funktion "State 1" ausgeführt. Dieses setzt "Current" auf 1", setzt den state auf "state 2". MoveNext entscheidet dann beim aufruf einfach ob es "State1" oder "State2" ausführen soll um den nächsten wert zu berechnen. Im grunde bedeutet es das jeder aufruf eines "yield" befehls innerhalb einer Methode einen neuen State erzeugt. Und um es genauer zu sagen. Jedesmal wenn wir "yield return" nutzen dann werden solche "IEnumerator" State klassen erzeugt.

 

Die erzeugung der States, das managen der States etc. wird alles bereits für uns abgenommen. Das "yield" schlüsselwort ist ein Features des C# Compilers und dieser erzeugt den entsprechenden Code und Klassen für uns, ohne das wir zu unnötig komplexen low-level implementierungen wie oberen greifen müssen und ganze Klassen selber ausprogrammieren müssen. Dies beantwortet wohl nun auch die Frage nach der performance oder wie genau das "Pausieren" eigentlich funktioniert.

 

Um es kurz zu sagen. Es gibt kein pausieren. Ein "yield return" wird zu einem normalen Objekt mit einer "MoveNext" funktion. Die Klasse enthält eine State Machine um zu entscheiden was ausgeführt wird. Variablen wie "x" und "y" sind normale Attribute einer Klasse.

 

Dies führt und auch zu Unity. Der einzige Unterschied zu Unity besteht darin das wir direkt "IEnumerator" zurück liefern. Weiterhin nutzt Unity die non-generic variante. Wenn wir mittels "yield return new WaitForSeconds(5f)" z.B. beenden dann passiert nichts anderes als das wir hier ein "WaitForSeconds" objekt zurück liefern. Unity hat einfach eine Liste von "IEnumerator" objekten parrat. Unity ruft einfach jede "MoveNext" methode eines jeden IEnumerator bei jedem frame auf.

 

Ansonsten anhand des "Current" objektes entscheidet Unity dann was es tun soll. Ist es "null" dann wird MoveNext für diesen IEnumerator im nächsten Frame aufgerufen. Ist dort ein "WaitForSeconds" Objekt (Unity muss hier explizit casten da es ja non-generic ist) dann packt es evtl. dass IEnumerator Objekt einfach in einer Liste für eine spätere ausführung.

 

Und das war es auch im großen und ganzen. Keine Threads, keine Asynchronität, sondern nur Objekte wo immer mal wieder eine MoveNext Methode bei bedarf aufgerufen wird.

 

 

5) Weiterführend

 

An dieser Stelle möchte ich nun noch ein bisschen weiterführend auf IEnumerable eingehen. Wer nur wissen wollte wie Coroutinen in Unity implementiert sind muss nicht mehr weiter lesen. Wer weiter liest bekommt nochmal im groben erklärt wie LINQ eigentlich genau funktioniert. Das ganze deswegen weil alles was bisher hier erklärt wurde ist nicht die Implementierung von Coroutinen in Unity. Sondern es ist die erklärung wie LINQ in C# implementiert ist.

 

Die Frage die sich hauptsächlich stellt ist. Wofür braucht man eigentlich das IEnumerable Interface? Bisher schien ja alles vom IEnumerator abzuhängen. Warum wrapt ein IEnumerable aber einen IEnumerator nochmals? Die antwort darauf ist, das System war eigentlich nie gedacht gewesen das man IEnumerator direkt nutzt. Sie sind eine relevantes Detail zur implementierung. Jedoch auch nur das. Gedacht war es lediglich IEnumerable<T> zu nutzen. Und so ist es auch in C# umgesetzt. Es gibt etliche "LINQ" Methoden die alle auf IEnumerable<T> implementiert sind, jedoch existieren keine für IEnumerator. Was gemeint ist, sind Methoden wie Select, Where, Aggregate, Sort, Take u.s.w.

 

Das erklärt allerdings noch nicht die Frage, den die Frage ist weiterhin, gut aber warum wrapt man ein IEnumerator nochmals? Der Sinn genauer gesagt ist das man durch das wrapen die eigentliche Logik durch "Composition" zusammen bauen kann, ohne das es direkt von den Werten abhängt. Und wahrscheinlich ist diese Antwort mehr verwirrend als hilfreich. Schauen wir uns also mal ein paar Beispiele an.

 

var fibs = Fib().Take(10).Select(x => x * 2);
foreach ( var fib in fibs ) {
Console.WriteLine("{0}", fib);
}

 

Wer soetwas z.B. ausführt wird die Ausgabe "2, 4, 6, 10, ..." sehen. Aber was passiert hier eigentlich genau? Als erstest liefert "Fib()" ein IEnumerable<long> zurück. Wir erinnern uns das noch keinerlei werte berechnet werden. Wir können mit GetEnumerator() einen Enumerator bekommen der uns dann werte zurück liefert. Aber bisher haben wir nichts anderes als ein objekt mit der idee "etwas das uns in zukunft werte zurück geben kann, oder auch nicht."

 

Auf diesen objekt wird dann "Take" aufgerufen. Hier ist es eher wichtig zu verstehen was Objekt Orientirung überhaupt bedeutet. Den Objekt-Orientierung ist im grunde nichts anderes als ein Funktionsaufruf das automatisch einen ersten "unsichtbaren" parameter hat. Nämlich das objekt selber, dass auch über das "this" schlüsselwort zugänglich ist. Diese unterscheidung ist hier relevant denn das ganze LINQ interface stammt aus der Funktionalen Programmierung, und es hilft auch sich dieses bewusst zu machen. Den das was wir hier haben ist in wirklichkeit einfach nur eine Funktion namens "Take" das zwei werte übergeben bekommt.

 

1) Ein IEnumerable<T> das werte zurück liefern kann

2) Eine Zahl "10" das sagt wieviele Elemente wir vom IEnumerable<T> auslesen sollen.

 

Das was Take nun macht ist aber folgendes. Take selber ist ebenfalls so implementiert das es "yield return" nutzt. Das heißt es liefert nach dem aufrufen wieder ein neues IEnumerable<T> zurück. Und das heißt noch genauer. Nach einem aufruf von Take(10) passiert ebenfalls erstmal gar nichts. Genausowenig wie nach einem aufruf von "Fib()" etwas passiert. Das was man genauer hat ist ein Funktion Take, das zwei werte übergeben bekommt und wieder ein neues objekt zurück liefert. Und dieses objekt "kann" dann einen wert zurück liefern wenn man es fragt es soll einen wert zurück liefern.

 

Und das gleiche passiert beim Select aufruf. Select bekommt wieder zwei Argumente übergeben. Ein "IEnumerable<T>" und eine Funktion. Die Idee hinter Select ist es die Funktion für jeden wert des IEnumerable<T> auszuführen. Aber natürlich auch hier wieder nutzt Select natürlich ein "yield". Daher, es macht erstmal nichts und auch die Rückgabe von Select ist wieder nur ein neues "IEnumerable<T>".

 

Und dies ist die Grundessenz. Man kann also "Logik" miteinander verknüpfen nach dem Motto "Tue zuerst X", "dann auf das Resultat Y", "auf dieses resultat dann Z" ohne das man jedoch direkt werte hat. Die eigentliche berechnung kann dann erst später erfolgen. Um das genauer zu verstehen. Gehen wir die Ausführung einmal Rückwärts durch, exakt so wie der obere code es tun würde.

 

Um es genauer zu sagen. "fibs" im oberen Beispiel ist das Resultat der "Select" methoden aufruf. Das heißt ein IEnumerable<T>. Und in der foreach Schleife versuchen wir nun alle werte des Select zu durchlaufen. Was nun passiert ist folgendes.

 

1) Select selber verfügt ja, über keine werte, es hatte aber ein erstes Argument ein IEnumerable<T> das werte liefern kann. Es fragt dieses also nach dem ersten wert

2) Dieses IEnumerable<T> ist das was Take(10) zurück geliefert hat.

3) Take selber soll nun einen wert zurück liefern. Als erstes prüft es ob es den überhaupt noch ein wert zurück geliefert werden soll. Den mit "Take(10)" haben wir ja angegeben das nur 10 werte zurück geliefert werden sollen.

4) Take hat bisher noch keinen wert zurück geliefert, also fragt es zuerst einmal seinen IEnumerable<T> nach einem wert das Take übergeben wurde.

5) Nun sind wir beim IEnumerable<T> das von Fib() generiert wurde. Dies liefert nun den ersten wert "1" zurück.

6) Take setzt nun intern einen wert und speichert das es "1" wert zurück geliefert hat, und liefert dann "1" zurück.

7) Select bekommt nun endlich den wert "1". Select nimmt nun die Funktion "x => x * 2" und wendet es auf 1 an. Das ergebnis ist 2 was es zurück liefert.

8) Nun kommt der erste schleifendurchlauf der foreach schleife, und 2 wird ausgegeben.

9) Nun versucht foreach den nächsten wert von Select zu lesen.

10) Die Schritte 1-9 werden solange wiederholt wie Select einen wert zurück liefert.

 

Wie kommt es zu einer endbedingung? Nun relevant ist das Select die Werte zuerst von "Take" "empfängt". Und Take speichert intern wieviele Werte es zurück liefern soll. Wenn es noch werte zurück liefern soll dann tut es das. Wenn es sein limit erreicht hat, dann beendet es sich. Wir erinnern uns. Intern können IEnumerator "false" zurück liefern um ein ende zu signalisieren. Wichtig zu verstehen ist das das zusammenbauen zwar von "links" nach "rechts" ist. Aber die eigentliche ausführung von "rechts" nach "links".

 

Sprich wir haben.

 

var fibs = Fib().Take(10).Select(x => x * 2);

 

wir haben zuerst "Fib" dann "Take" und dann "Select". im grunde stellt das auch die Logik da. Zuerst alle Fibonacci zahlen. Dann nimmt 10 davon. Dann multipliziere jeden wert mit 2. Aber so würde keine ausführung funktionieren. Den "Fib" liefert ja unendlich werte zurück. Man kann nicht unendlich werte "Take" übergeben dass dann die ersten 10 werte daraus heraus nimmt.

 

Die eigentliche ausführung ist von rechts nach Links. Daher Select multipliziert alle werte mit 2 für sein IEnumerable das es hat. Dieses ist Take das dann nur 10 werte von Fib versucht auszulesen. Und ebenfalls relevant ist. Es wird immer jeder wert einzelnt ausgelesen. nicht alle auf einmal.

 

Sprich Select fragt nicht. "Take" gibt mir alle deine werte. Sondern es sagt. "Take" gibt mir deinen nächsten wert (MoveNext). Den würden man nach alle werte fragen dann würde dies ebenfalls wieder nicht funktionieren. Den dann würde Take Fib nach all seinen werten fragen. Und diese sind ja unendlich.

 

 

 

6) Wir bauen uns LINQ selber

 

Nachdem die Logik hoffentlich verstanden wurde, bauen wir nun einmal unsere eigene Methoden. Wenn die Logik bisher noch nicht verstanden wurde, dann kann das selber implementieren evtl. helfen. Wir implementieren also zuerst einmal "Take" selber. An dieser stelle wähle ich die namen wie sie in Funktioanlen Sprachen genannt werden. Das erzeugt weniger Konflikte. Bei Take wäre das auch ersteinmal "take". Allerdings schreiben wir alle funktionen klein. Dazu wollen wir ersteinmal nur normale static methoden schreiben.

 

Wir erinnern uns. "take" ist eine methode mit zwei argument. Ein IEnumerable<T> und eine zahl wieviele werte ausgelesen werden sollen.

 

static IEnumerable<T> take<T>(IEnumerable<T> source, int amount) {
int count = 0;
foreach (var x in source) {
	if (count++ < amount) {
		yield return x;
	}
	else {
		yield break;
	}
}
}

 

Neu hinzugekommen ist nun lediglich "yield break". Mit "yield break" können wir explizit sagen das keine weiteren werte mehr kommen werden. Was dann dazu führt das der IEnumerable<T> in einen State übergeht wo nach einem MoveNext() dann keine weiteren werte mehr zurück geliefert werden oder berechnet werden. Aus diesem grund können wir auch einfach "foreach" nutzen, und das auch wenn "source" unendlich werte hat. Nutze können wir das ganze dann so.

 

var fibs = Fib();
var nums = take(fibs, 10);
foreach (var x in nums) {
Console.WriteLine("{0}", x);
}

 

Schauen wir nun wie wir "Select" einbauen. Der name hierfür lautet in der Funktionale Programmierung "map". Den man kann sich das ganze so vorstellen das man werte von einem zum anderen mapt. "map" bekommt ebenfalls wieder zwei argumente. Wieder ein "IEnumerable" das werte liefert, und eine Funktion das für jeden Wert ausgeführt werden soll. Dies ist dann die "mapper" funktion.

 

static IEnumerable<U> map<T,U>(IEnumerable<T> source, Func<T,U> mapper) {
foreach (var x in source) {
	var y = mapper(x);
	yield return y;
}
}

 

Anders als "Take" sehen wir hier das sich aber noch zusätzlich der Typ ändern kann. Wir haben ein "IEnumerable<T>" als input. Wir bekommen jedoch ein "IEnumerable<U>" als Output zurück. Wir haben eine Function "Func<T,U>" das ein "T" übergeben bekommt, und ein "U" zurück liefert. Wenn man geübt ist solche Signaturen zu lesen dann kann man die Logik der Funktion bereits anhand seiner signatur erkennen. Den um ein "IEnumerable<U>" zurück zu liefern

müssen wir logischerweise "Func<T,U>" auf jeden wert von IEnumerable<T> ausführen.

 

In der Benutzung sieht das ganze nun so aus.

 

var fibs    = Fib();
var first10 = take(fibs, 10);
var nums    = map(first10, x => x * 2);

foreach (var x in nums) {
Console.WriteLine("{0}", x);
}

 

Wenn wir alles in einer Zeile schreiben würden, dann würde es folgendermaßen ausschauen.

 

var nums = map(take(Fib(), 10), x => x * 2);

 

Das ist natürlich nicht besonders lesbar. Da man so die logik von innen nach ausen lesen muss. Funktionale Sprachen haben meist ihre eigene Wege um das ganze lesbarer zu machen (z.B. Pipes die werte in eine Funktion geben. Das ganze ist Linux (Bash) Pipes ähnlich (cat file | grep "xyz")). Für C# wurden hierfür speziell sogenannte Extension Methods eingebaut. Mit Extension Methods können wir Funktionen dann praktisch so schreiben als würden sie direkt auf dem Objekt liegen. Um unsere Funktionen in Extension Methods umzuwandeln müssen wir diese lediglich in einer static klasse packen. Dazu müssen wir das keyword "this" in der argumentenliste aufnehmen und darauf achten das sie "public static" sind. Das ganze sieht dann so aus.

 

public static class IEnumerableExtension {
public static IEnumerable<T> take<T>(this IEnumerable<T> source, int amount) {
	int count = 0;
	foreach (var x in source) {
		if (count++ < amount) {
			yield return x;
		}
		else {
			yield break;
		}
	}
}

public static IEnumerable<U> map<T, U>(this IEnumerable<T> source, Func<T, U> mapper) {
	foreach (var x in source) {
		var y = mapper(x);
		yield return y;
	}
}
}

 

Danach können wir das ganze auch folgendermaßen schreiben.

 

var nums = Fib().take(10).map(x => x * 2);

 

Um noch etwas übung damit zu bekommen. Hier ist beispielsweise eine implementierung von "Where" -> filter. Und spätere ".Net" versionen haben noch "Zip". Möchte man "zip" auch in Unity haben. So können diese implementierungen dann aussehen.

 

public static IEnumerable<T> filter<T>(this IEnumerable<T> source, Func<T,bool> predicate) {
foreach (var x in source) {
	if (predicate(x)) {
		yield return x;
	}
}
}

public static IEnumerable<Tuple<T1,T2>> zip<T1,T2>(this IEnumerable<T1> source1, IEnumerable<T2> source2) {
using (var e1 = source1.GetEnumerator())
using (var e2 = source2.GetEnumerator()) {
	while (true) {
		var b1 = e1.MoveNext();
		var b2 = e2.MoveNext();
		if (b1 && b2) {
			yield return Tuple.Create(e1.Current, e2.Current);
		}
		else {
			yield break;
		}
	}
}
}

 

So wird dann filter genutzt.

 

var nums = Fib().take(10).map(x => x * 2).filter(x => x % 3 == 0);
foreach (var x in nums) {
Console.WriteLine("{0}", x);
}

 

Ausgabe

6
42

 

Und hier wäre ein Beispiel für Zip mit einen weiteren Counter IEnumerable.

 

static IEnumerable<long> Counter() {
long counter = 1;
while ( true ) {
	yield return counter++;
}
}

static IEnumerable<long> Fib() {
yield return 1;
var x = 1L;
var y = 1L;
while (true) {
	var nx = x + y;
	y = x;
	x = nx;
	yield return x;
}
}

 

var fibcounter = Counter().zip(Fib()).take(10);
foreach (var x in fibcounter) {
Console.WriteLine("{0}: {1}", x.Item1, x.Item2);
}

 

Ausgabe:

1: 1
2: 2
3: 3
4: 5
5: 8
6: 13
7: 21
8: 34
9: 55
10: 89

 

Einen wichtigen Punkt um nochmals auf die Frage zurück zu kommen warum wir IEnumerator<T> nochmals in einem IEnumerable<T> wrappen. Der Relevante Punkt ist simpel "vereinfachung". Die Idee dahinter ist das man foreach nutzen kann um IEnumerable<T> durchzugehen. foreach kümmert sich dann darum den IEnumerator zu holen und solange durchzulaufen wie MoveNext() ein wahr zurück gibt. Würden wir direkt mit IEnumerator arbeiten dann müssten wir dies machen.

 

Ebenfalls und das ist der eher relevante teil, so dient IEnumerable als eine art "Konstruktur". Man hat also eine Funktion die noch werte setzten/initialisieren kann bevor wir den IEnumerator zurück bekommen. Jedoch können wir den IEnumerable<T> mit dieser Logik herum reichen. Dies ermöglicht es uns sehr viel einfacher belibige IEnumerable zu kombinieren ohne das wir wissen müssen woher dieser IEnumerable eigentlich kommt.

  • Like 6

Share this post


Link to post
Share on other sites

Join the conversation

You can post now and register later. If you have an account, sign in now to post with your account.

Guest
Reply to this topic...

×   Pasted as rich text.   Paste as plain text instead

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   Your previous content has been restored.   Clear editor

×   You cannot paste images directly. Upload or insert images from URL.

Loading...
Sign in to follow this  

×
×
  • Create New...