 Ja, herzlich willkommen zurück zur Veranstaltung Programmierparadigmen. Wir sind immer noch im Modul-Kontrollfluss und das ist der fünfte und letzte Teil innerhalb dieses Moduls und zwar geht es hier um Rekosionen. Also eine andere Art, wie wir Dinge mehrmals wiederholen können innerhalb eines Programms, nämlich indem eine Funktion sich selbst wieder aufruft. Grundsätzlich gilt es, dass diese Idee von Rekosionen genauso mächtig ist wie die Iteration, die wir im letzten Teil der Veranstaltung ja gerade gesehen haben. Also man kann sowohl mit Iteration als auch mit Rekosionen genau dasselbe ausdrücken und es gibt keine Algorithmen, was jemand jetzt nur mit dem ein oder mit dem anderen aufschreiben könnte. In den meisten praktischen Programmiersprachen wird auch beides angeboten, also die Sprache bieten sowohl Rekosion als auch Iteration an. Das heißt als Programmierer hat man im Prinzip freie Auswahl und kann Dinge entweder so oder so ausdrücken. In der Praxis gibt es natürlich bestimmte Arten von Algorithmen oder bestimmte Arten zu programmieren, wo es sich eher anbietet Iteration zu verwenden oder eher anbietet Rekosion zu verwenden. Zum Beispiel findet man Iteration sehr viel häufiger in imperativen Programmiersprachen. Ganz einfach aus dem Grund, dass in imperativen Programmiersprachen man häufig den Code so schreibt, dass innerhalb des schleifen Bodies bestimmte Variablen aktualisiert werden, also dass sozusagen die Iteration selbst gewisse Seiteneffekte hat. Wohin gegen Rekosion sehr häufig in funktionalen Programmiersprachen verwendet wird, was da besonders viel Sinn macht, weil man häufig innerhalb einer rekosiven Funktion, die keine nicht lokalen Variablen aktualisiert, sondern lediglich lokale Variablen aktualisiert und vielleicht die Argumente, die dann an den rekosiven Aufruf übergeben werden berechnet. Wie gesagt, da ist keine klare Drennlinie, also das ist nicht so, dass Rekosion jetzt nur in funktionalen Sprachen verwendet wird und auch nicht mit Iteration und imperativen Sprachen, aber es gibt eben bestimmte Probleme und bestimmte Stile zu programmieren, wo es sich eher anbietet, entweder Iteration oder Rekosion zu benutzen. Ein wichtiges Argument dabei, was man eigentlich benutzt, ist natürlich die Effizienz, also wie schnell der Code Schluss endlich ausgeführt werden kann. Und hier ist es so, dass wenn man naiv eine rekosive Funktion aufschreibt bzw. wenn die anschließend naiv kompiliert wird vom Compiler, dass dann in der Regel weniger effizient ist, als wenn man den selben Code in einer iterativen, also schleifenbasierten Form aufschreiben würde. Warum das so ist, schauen wir uns jetzt einfach mal an. Abstrakt gesprochen ist der Grund, dass für jeden rekosiven Aufruf ein weiterer Allocation Frame erstellt wird und der auf den Stack draufkommt, sodass wir, wenn das naiv geschieht, im Prinzip immer weitere Allocation Frames auf unserem Stack haben und ich sozusagen für N-Iterationen dann auch N-Solche Allocation Frames hätte. Wie gesagt, hätte, weil das alles nur in naiven Implementierungen und in einer naiven Art und Weise diese Rekosion aufzuschreiben, der Fall ist. Warum das so ist, schauen wir uns am besten mal mit diesem Beispiel hier an. Also das ist ein Stück Code in Scheme und was der macht, ist einfach durch Zahlen zu iterieren im Bereich von Low und High und für jede dieser Zahlen dann f von i aufzurufen und anschließend die Summe all dieser Ergebnisse von f und i zusammen zu addieren. Das kann man rekosiv in Scheme, wie man hier sieht, so aufschreiben. Also ich habe da einmal diese Funktion, die das Ganze macht und die Summe heißt, die ist eine Funktion mit drei Argumenten. Also ich bekomme hier einmal diese Funktion f, die ich auf jeden Element i aufrufen möchte und außerdem den Wert, wo es losgehen soll und den Wert, wo wir schlussendlich wieder aufführen wollen. Und was wir dann hier innerhalb der Funktion machen, ist, dass wir diese Bedingung überprüfen, ob Low gleich High ist und die zwei Sachen, die danach folgen, also in dieser Zeile und in dieser Zeile sind die beiden Branches, die ausgeführt werden sollen, wenn diese Bedingung wahr ist oder eben wenn sie falsch ist. Wenn sie wahr ist, rufen wir einfach f auf Low auf, das ist sozusagen das Ende dieser Iteration oder das Ende dieser Rekosion. Und falls die Bedingung noch nicht wahr ist, rufen machen wir das, was hier unten steht und das sind mehrere Sachen. Zum einen rufen wir diese Plus Funktionen auf, also wir addieren Dinge, nämlich einmal f auf Low angewendet und dann das Ergebnis von diesem langen Konstrukt hier. Und was das hier unten macht, ist schlussendlich diese Funktion f wieder Rekosiv, sorry, diese Funktion Sam wieder Rekosiv aufzurufen, also dieses Sam ist das gleiche, wie hier unten. Und zwar indem wir wieder diese Funktion f reingeben, dass selbe Ende der Rekosion behalten, weil daran ändert sich ja nichts, allerdings den aktuellen Wert verändern, indem wir jetzt nicht bei Low anfangen, sondern eben bei Low plus 1 und somit einen Schritt weitergehen in unserer Summe. So, das ist jetzt ein kleines Beispiel und jetzt schauen wir uns mal an, warum das ganze, wenn es ineffizient kompiliert wird, dazu führt, dass wir für jede Rekosion einen weiteren Allocation Frame brauchen, was natürlich dann dazu führt, dass das Ganze nicht besonders schnell ist. Ja, schauen wir uns mal anhand von dem Beispiel an, wie sich dieser Algorithmus jetzt auf dem auf den Function Stack auswirkt und warum das, wenn man das naiv betreibt, warum das da ineffizient ist. Also, das ist jetzt ein Beispiel für diese Summation Funktion. Ich mache mal noch ein M mehr rein. So, in dem Beispiel rufen wir also unsere Funktion Sam auf und zwar indem wir irgendeine Funktion f übergeben und den Anfangswert 2 und dann den höchsten Wert, wo es hingehen soll von 4. Und wir schauen uns erst mal an, wie das Ganze in dieser naiven Implementierung, die wir gerade gesehen haben, abläuft. Was hier passiert, ist Folgendes. Also, wir haben unseren Function Stack und für jeden Aufruf einer Funktion gibt es da einen weiteren Allocation Frame. Wir rufen zunächst ja erst mal Summ auf, wobei das Argument F dann den auf diese Funktion F, die wir haben, zeigt. Das Argument Low wird 2 sein, weil wir das als 2 das Argument übergeben und das Argument High wird 4 sein. Jetzt gehen wir in den reklusiven Aufruf rein, sprich wir rufen nochmals Summ auf, erstellen also einen weiteren Allocation Frame, in dem dann wieder F gleich F ist, daran ändert sich nichts. Das Low verändert sich, denn wir übergeben das alte Low plus 1, also sprich 3 und High bleibt auch, was es ist, also das ist weiterhin 4. Und dann haben wir noch einen reklusiven Aufruf, das heißt Summ wird nochmal aufgerufen, ein weiterer Allocation Frame wird erstellt und draufgesetzt. F ist immer noch F, High ist immer noch 4, aber Low ist wieder das alte Low plus 1, sprich 4. Und an der Stelle ist dann die Bedingung dieses Ifs, was wir in der even Implementierung haben, war. Das heißt wir werden keine weiteren Funktionsaufrufe haben und werden auch keine weiteren Allocation Frames erstellen. Das heißt aber für diese einfache reklusive Implementierung haben wir hier drei Allocation Frames gebraucht. 3 Allocation Frames sind natürlich jetzt im Allgemeinen kein Problem. Problematischer wird es, wenn wir jetzt nicht 2 und 4 übergeben hätten, sondern sagen wir mal 2 und 4 Millionen, weil dann hätten wir plötzlich 4 Millionen Allocation Frames und das wären dann doch einige zu viel, weil jeder von diesen Frames natürlich Speicher braucht und diesen Speicher zu beschreiben und dann auch wieder frei zu geben, natürlich auch einiges an Zeit braucht. So, es ist die Frage, wie kann man das Ganze ein bisschen effizienter ausführen, sodass man eben nicht einen Allocation Frame für jeden reklusiven Aufruf erstellen muss, weil das wie man gerade gesehen hat nicht besonders gut skaliert. Der Trick nennt sich Tail Recursion und die Grundidee ist eigentlich ganz einfach. Die Grundidee ist, dass wir den reklusiven Aufruf so gestalten, dass es das letzte Statement in der Funktion ist, bevor die Funktion zurückkehrt. Und wenn wir das so machen, dann kann der Compiler einen Trick anwenden, nämlich dass er ganz einfach den selben Allocation Frame wiederverwendet, anstatt einen neuen oben drauf zu setzen. Das funktioniert wie gesagt nur, wenn dieser reklusive Aufruf das letzte Statement ist, bevor die Funktion zurückkehrt, weil ich nämlich dann danach ja nichts anderes mehr habe, sprich, ich brauche den aktuellen Allocation Frame eigentlich nicht mehr, sondern kann in diesem aktuellen Allocation Frame im Prinzip auch die nächste Iteration, also den nächsten Aufruf der selben Funktion gleich wieder abbegen. Wie das dann auf dem Stack aussieht, schauen wir uns gleich an. Vorher ist man ein Beispiel von Code, der eben Tail Recursion erlaubt. Das ist dieselbe Algorithmus, den wir vorhin schon gesehen haben. Also wir rufen wieder diese Funktion F auf jedem Element zwischen low und high auf und summieren dann die Ergebnisse dieser Aufrufe von F zusammen. Plus eben diesmal so, dass wir diesen reklusiven Aufruf als letztes Statement haben. Also die Funktion, die hier reklusiv aufgerufen wird, ist summ und die wird eben hier unten aufgerufen. Und so wie das jetzt geschrieben ist, ist das der letzte Aufruf innerhalb dieser Funktion, weil natürlich erst die ganzen Argumente, die ich diese Funktion übergebe, evaluiert werden und dann nachdem, was alles passiert ist, rufe ich dann dieses summ auf und es ist sozusagen der letzte Aufruf. Wer nicht überzeugt ist, dass das dasselbe macht, kann sich das dann mal ein bisschen genauer anschauen. Ich erkläre es jetzt nicht im Detail, sondern wir schauen uns mal an, wie das Ganze dann auf dem Function Stack aussieht und wie sich das Ganze auf die Effizienz auswirkt. So dann schauen wir uns mal an, wie unsere hoffentlich effizientere Implementierung dieser Summationsfunktion jetzt ausgeführt wird und welche Auswirkungen das Ganze auf den Function Stack hat. Also wir rufen wieder diese Summfunktion auf, übergeben auch wieder f und 2 als den Wert, wo wir starten und 4 als den Wert, wo wir enden wollen und außerdem jetzt noch die bis dahin aufgesammelte Summe, dieses Subtotal mit was, was initial erstmal 0 ist. Das Ganze ist jetzt also für unsere Tail Recursive Implementation. So und da gibt es natürlich wieder ein Function Stack und es wird natürlich wieder für diesen ersten Aufruf ein Allocation Frame kreiert. Das heißt, wir rufen hier ja Summ auf, übergeben an f diese Funktion f, übergeben als Wert für low, den Wert 2, high wird 4 und das Subtotal ist 0. Und das Interessante passiert jetzt, wenn der recursive Aufruf stattfindet. Denn dadurch, dass wir den recursiven Aufruf jetzt als letztes Statement in der Funktion Summ haben, kann der Compiler eine Optimierung vornehmen, denn wir wissen, dass wir nach diesem Aufruf diesen aktuellen Allocation Frame von Summ eigentlich nicht mehr brauchen. Das heißt, also anstatt jetzt hier einen neuen drauf zu bauen, den wir dann wieder wegwerfen würden, um schlussendlich auch noch den hier wegzuwerfen, benutzen wir einfach den aktuellen Allocation Frame wieder, indem wir einfach den aktuellen Frame benutzen und nur die Werte überschreiben, die im nächsten Funktionsaufruf anders sind. Im nächsten Funktionsaufruf ist eigentlich alles gleich, f und high und Subtotal ändern sich erst mal nicht. Was sich aber ändert, ist dieser Wert von low, der nämlich nicht 2 ist, sondern dann 3 sein wird. Und wir können das im aktuellen Allocation Frame einfach überschreiben, denn wir brauchen den Allocation Frame der aufrufenden Funktion hinterher ja nicht mehr, weil der recursive Aufruf eh das letzte Statement ist, was in dieser Funktion geschieht. Wenn wir dann diese Funktion mit 3 aufrufen, kommen wir irgendwann an den Punkt, dass wir wieder den recursiven Aufruf haben und dann können wir wieder denselben Trick machen und dann nehme ich diese 3 durch die 4 überschreiben und nach 4 gibt es dann auch keine weiteren recursiven Aufrufe mehr. Das heißt, wir haben es geschafft, all die recursiven Aufrufe auszuführen, ohne dafür jeweils einen neuen Allocation Frame zu kreieren, sondern wir haben stattdessen immer diesen einen Allocation Frame wieder benutzt. Ja, und dann sind wir auch schon am Ende dieses fünften und letzten Teils des Moduls zum Thema Kontrollfluss. Ich hoff, Sie haben ein bisschen was dazu gelernt, in welcher Reihenfolge die Dinge also eigentlich in so einem Programm funktionieren und wie man als Programmierer festlegen kann, in welcher Reihenfolge die Dinge tatsächlich passieren. Wie Sie gesehen haben, gibt es eine ganze Bandbreite von verschiedenen Sprachkonstrukten und nicht alle Sprachen bieten alle von denen an, aber ich denke, es war hoffentlich für jeden ein bisschen was Neues dabei und Sie haben jetzt vielleicht auch den Mut mal die ein oder andere Sprachfiete auszuprobieren, weil Sie vorher vielleicht noch nicht so viel verwendet haben. Damit danke fürs Zuhören und bis zum nächsten Mal.