 Ja, willkommen zurück zu Programmierparadigmen und zu diesem dritten Teil des Themenblocks Funktionale Programmiersprachen. Was wir jetzt hier in diesem dritten Teil machen wollen, ist uns einen bestimmten Aspekt der Evaluierung von funktionalen Sprachen ein bisschen genauer anzuschauen, nämlich die Frage, welche Reihenfolge die einzelnen Expressions, die ich in so einem Programm habe, denn überhaupt evaluiert werden. Dieses Problem ist aus verschiedenen Gründen interessant. Zum einen ist es natürlich wichtig, für die Sematik des Programmes zu wissen, welche Reihenfolge die einzelnen Expressions denn überhaupt evaluiert werden. Zum anderen spielt es natürlich auch eine Rolle für die Effizienz oder die Performance des Programmes. Es gibt da zwei Antworten, die logisch viel Sinn machen und die auch beide tatsächlich in der Praxis verwendet werden, nämlich zum einen die sogenannte Applicative Order und zum anderen die sogenannte Normal Order. Was bedeutet das? Also, Applicative Order bedeutet das Argumente, die ich in eine Funktion reingebe, evaluiert werden, bevor ich diese Funktion aufrufe. Das ist so ein bisschen das, was man als programmierende imperativen Sprache erwarten würde, wenn ich Argumente übergebe, dann evaluiere ich die natürlich erst und gebe die dann in die Funktion rein. Die andere Variante, die sogenannte Normal Order, macht das aber eben nicht, sondern die übergibt die Argumente unevaluiert und die werden erst evaluiert, wenn sie tatsächlich innerhalb dieser Funktion benutzt werden. Also es kann sein, dass ich in eine Funktion vielleicht drei Argumente reingebe, aber schlussendlich in der Funktion nur eins davon benutze, dann werden die anderen nie evaluiert und müssen dann natürlich entsprechend auch nicht berechnet werden, was natürlich gut für die Effizienz sein kann. Schauen wir uns das Ganze mal an einem Beispiel an, und zwar am Beispiel einer einfachen Funktion, die ich jetzt hier mal in Schieben hinschreibe, die den Namen Double hat und wie folgt auszieht. Also wir definieren diese Funktion, da ist eine Funktion, das ist natürlich als Lambda Expression, sagen die bekommt ein Argument und was wir dann hier machen ist, wir rufen plus auf, indem wir dieses Argument zweimal übergeben, also wir duplizieren sozusagen diesen Wert und geben diesen duplizierten Wert dann zurück. So, jetzt schauen wir uns mal für diese beiden Evolutionsvarianten an, Applicative Order und Normal Order, was da jeweils passiert, wenn wir diese Funktion dann auf bestimmte Werte anwenden und fangen wir mal an mit Applicative Order. Also zur Erinnerung, Applicative Order bedeutet, die Argumente werden zuerst evaluiert und anschließend dann anschließend dann an die Funktion übergeben, wenn wir das machen, also wir rufen hier oben mal Double auf und als Argument wollen wir übergeben das Ergebnis von mal angewandt auf 3 und 4, dann wird das Ganze evaluiert zu folgendem, wir schauen zunächst, was der Wert des Argumentes hier ist und evaluieren also dieses 3 mal 4 zu 12, rufen dann Double auf und schauen also in die Lambda Expression und sehen, was wir mit dem gegebenen Wert X machen sollen, nämlich diesen Wert 2 mal zu nehmen und das Ganze mit Plus zu verbinden und das evaluiert dann schlussendlich zu 24. Schauen wir uns diese zwei Varianten mal anhand eines einfachen Beispiels an und zwar einem Beispiel, wo wir eine Funktion zunächst erstmal definieren, namens Double und um diese Funktion dann zu definieren, benutzen wir natürlich eine Lambda Expression, in der wir sagen, dass diese Funktion ein Argument gibt, das bekommt, das nennt wie X und was wir dann damit machen ist, dass wir Plus auf 2 mal dieses Argument anwenden, also schlussendlich das gegebenen Wert Plus nochmal den gegebenen Wert berechnen und das dann zurückgeben. Schauen wir mal an zu wie und zu welchem Wert ein Aufruf von Double jetzt evaluiert wird, einmal mit Applicative Order und dann nachher auch noch mit Normal Order. Als Applicative Order zur Änderung ist die Variante, wo wir zunächst die Argumente evaluieren und die dann evaluiert in die Funktion reingeben. Sagen wir mal, wir wollen hier unsere Double Funktion aufrufen und zwar mit dem Argument, dass das Ergebnis von mal angewandt auf 3 und 4 sein soll, dann evaluieren wir also zunächst den Wert des Argumentes und haben im nächsten Schritt dann den Aufruf von Double angewandt auf 12 und anschließend gehen wir erst in die Double Funktion rein und ersetzen den Aufruf mit dem Body dieser Funktion, also mit Plus von 12 und 12 und kommen dann schlussendlich hier auf den Wert 24. In Normal Order läuft das Ganze jetzt ein bisschen anders ab, also was hier passiert ist Folgendes, wir fangen auch wieder damit an unseren Funktionsaufruf hinzuschreiben, also Double aufgerufen mit dem Ergebnis von mal 3, 4 als Argument und weil wir jetzt aber in Normal Order evaluieren würden, wir zunächst die Funktion aufrufen, also zunächst den Aufruf durch den Body der Funktion ersetzen, bevor wir das Argument evaluieren, das heißt wir schreiben jetzt hin Plus und dann zweimal das Argument und jetzt wird dieser Ausdruck Schritt für Schritt evaluiert von innen nach außen und von links nach rechts, das heißt zunächst evaluieren wir den ersten Teil Ausdruck und haben dann dieses Zwischenergebnis und dann evaluieren wir den zweiten Teil Ausdruck und haben dann Plus von 12 und 12 dastehen, was dann schlussendlich zu 24 evaluiert. Was man in dem Beispiel also sieht ist, dass hier dasselbe rauskommt, allerdings ein auf der rechten Seite mehrere mehr Evolutionsschritte nötig sind und insbesondere wird eben dieses Argument und das mal von 3 und 4 zweimal berechnet und wenn das Argument jetzt vielleicht komplexere Berechnung wäre, dann wäre das natürlich unnötige Arbeit, die man an der Stelle machen würde. Um zu sehen, ob das jetzt immer so ist, schauen wir uns mal noch ein zweites Beispiel an und in diesem zweiten Beispiel definieren wir zunächst auch erstmal wieder eine Funktion, die hier jetzt Switch heißen soll und diese Funktion besteht wie immer aus einer Lambda Expression und hier haben wir zur Abwechslung gleich mal 4 Argumente, nämlich x, a, b und c und schreiben dann hin, was mit diesen Argumenten passieren soll. Nämlich wie verwenden hier unser Cont keyword für diese Multiway Conditional Expression, bei der wir verschiedene Fälle abdecken und der erste Fall ist, dass kleiner von x und 0 wahr ist und in dem Fall evaluieren wir unsere Funktion zu a und geben dann a zurück. Für den Fall, dass x gleich 0 sein sollte, geben wir b zurück und für den Fall, dass x größer als 0 sein sollte, geben wir c zurück. Jetzt muss ich meine ganzen Klammer noch zu machen und verzählen mich dabei hoffentlich nicht und dann können wir anschauen, wie ein Aufruf dieser Funktion jetzt evaluiert wird. So, dann evaluieren wir das ganze Mal bzw. evaluieren einen Aufruf dieser Switch Funktion und zwar zunächst mit der Applicative Order, also der Evolutionsreinfolge, wo wir zunächst die Argumente evaluieren und dann in die Funktion reingehen. Und zwar machen wir das für einen Aufruf von Switch, wo wir minus 1 als erstes Argument übergeben, also für das x und dann verschiedene andere Ausdrücke für die Argumente a, b und c, nämlich hier plus von 1 und 2, dann plus von 2 und 3 und dann plus von 3 und 4. Und jetzt schauen wir uns an, wie das ganze evaluiert wird. Also zunächst müssen die Argumente evaluiert werden, sprich wir fangen die von links nach rechts an zu evaluieren. Machen das mit dem ersten, kommen also dann auf die drei hier und haben dann noch die zwei anderen. Wenn wir das jetzt evaluieren, müssen wir also zunächst die Argumente anschauen und evaluieren die von links nach rechts. Das heißt, wir fangen an, das zweite Argument, das plus von 1, 2 zu evaluieren, was zu 3 evaluiert und die restlichen Argumente, das plus von 2 und 3 und das plus von 3 und 4 bleiben dann erst mal noch stehen. Dann geht es weiter und wir arbeiten uns weiter von links nach rechts durch diese Argumenteliste hindurch. Also minus 1 und 3 und, oops, kommen dann hier auf 5 und das vierte Argument bleibt noch stehen und das wird dann im nächsten Evolutionsschritt angegangen und wir haben dann diesen Zustand, wo wir tatsächlich alle Argumente erst mal evaluiert haben. Wenn wir da angelangt sind, gehen wir dann tatsächlich in die Funktion rein. Das heißt, wir ersetzen den Funktionsaufruf durch das, was im Funktionsbody drin steht, also in dem Fall durch diese Conditional Expression. Das heißt, wir haben dann das Kond dastehen mit jetzt den konkreten Werten, nämlich kleiner von x, also minus 1 und null und wenn das zu true evaluieren sollte, kämen wir auf 3 und dann noch die anderen beiden Bedingungen, nämlich dass minus 1 gleich null ist, in dem Fall kämen wir auf 5 und das minus 1 größer als null ist, in dem Fall kämen wir auf 7. Das Kond schaut jetzt die Argumente an und stellt dann dabei fest, dass das erste Argument wahr ist und die anderen alle falsch sind. Also das true wird durch Hashtag t dargestellt und die anderen schreibe ich jetzt nicht hin, aber die werden dann alle Hashtag f, also false und als Ergebnis kommt dann also schlussendlich hier der Wert 3 raus. Wir haben aber die ganzen Argumente alle einzeln evaluiert, auch wenn die schlussendlich gar nicht verwendet werden. Zum Vergleich dazu schauen wir uns jetzt mal an, was mit normal order passiert und zwar wieder derselbe Aufruf, also wir rufen wieder switch auf mit minus 1 und dann plus von 1 und 2 und plus von 2 und 3 und plus von 3 und 4 und weil wir jetzt aber in normal order evaluieren, also zunächst in die Funktion reingehen und die Argumente dann nur evaluieren, wenn wir sie wirklich brauchen, kommen wir hier direkt zu unserem Multiway conditional, wo jetzt statt den konkreten Werten, denn die haben wir ja noch nicht. Erstmal die Argumente so drin stehen, wie sie übergeben werden, also falls minus 1 kleiner als null ist, dann würden wir evaluieren, was bei 1 plus 2 rauskommt. Falls aber minus 1 gleich null sein sollte, dann würden wir evaluieren, was bei plus angewandt auf 2 und 3 rauskommt und für den Fall das minus 1 größer als null ist, würden wir den letzten Ausdruck, also das dritte Argument evaluieren, nämlich dieses plus von 3 und 4. An der Stelle würde der Interpreter dann aber schon feststellen, dass eben nur die erste Bedingung hier war, also wir hier wieder true rausbekommen und deswegen eigentlich nur diese eine Fallesrelevant ist. Die anderen sind nämlich alle false, das heißt an der Stelle würde dann einfach nur noch plus von 1 und 2 ausgerechnet werden und wir kämen schlussendlich auch wieder auf den Wert 3 genauso wie auf der linken Seite. Das Interessante an diesem Beispiel ist also das im Gegensatz zu dem Beispiel, was wir vorher gesehen haben, jetzt mehr Arbeit gemacht wird, wenn wir applicative oder anwenden, denn dann würden die Argumente alle evaluiert, obwohl wir die gar nicht alle brauchen. Was wir also sehen hier ist, dass das eine oder das andere nicht unbedingt immer besser ist, sondern es tatsächlich davon abhängt, wie genau das Programm geschrieben ist und es manchmal effizienter wäre, applicative oder zu verwenden, aber manchmal auch effizienter wäre, normal oder zu verwenden. Jetzt haben wir gesehen, wie sich applicative oder normal oder auf die Effizienz auswirkt, nämlich dass manchem Programm das eine oder das andere mehr oder weniger Berechnungsschritte benötigt, aber neben diesem Einfluss auf die Effizienz hat die Evaluationsreinfolge tatsächlich auch einen Einfluss auf die Korrektheit oder auf das konkrete Verhalten des Programms. Und zwar ist das genau dann der Fall, wenn in manchen vielleicht gar nicht benötigten Sub-Expressions ein Laufzeitfehler auftritt. Also wenn ich zum Beispiel einen Laufzeitfehler in einem der Argumente habe, weil ich da zum Beispiel Plus von einer Zahl und einem String berechnen will, was aber nun mal nicht geht, dann würde in applicative oder das auffallen und das Programm würde terminieren und ich würde diesen Laufzeitfehler tatsächlich sehen, weil dieser Ausdruck der Plus von der Zahl und dem String macht natürlich dann schon evaluiert wird, aber das würde in normal oder nicht unbedingt bemerkt und insbesondere eben dann nicht bemerkt, wenn dieses Argument schlussendlich gar nicht verwendet wird. Das heißt, es geht nicht nur um Effizienz hier, sondern die Korrektheit und das Verhalten des Programms werden auch davon beeinflusst, ob wir applicative oder normal oder haben. Diese Idee, dass sich bestimmte Sub-Expressions gar nicht unbedingt evaluieren muss oder vielleicht später evaluieren kann, kann man auch ein bisschen weiter verallgemeinern und zwar über Funktionen und deren Argumente hinaus und das ist genau das, was Lazy Evaluation macht. Also die Idee von Lazy Evaluation ist, ist, dass sich jedes Sub-Expressions on-demand erst evaluieren kann, wenn ich sie tatsächlich brauche und außerdem, dass sich bestimmte Expressions, wenn sie wieder auftauchen, auch gar nicht nochmal zu evaluieren braucht, sondern mir deren Ergebnis eigentlich auch merken kann. Also wenn ich zum Beispiel einen Funktionsaufruf habe und diese Funktion braucht einfach ein ganzes Weilchen, weil sie was Komplexes berechnet und ich denselben Aufruf mit denselben Argumenten an mehreren Stellen in meinem Programm habe oder vielleicht an mehreren Stellen dieser Aufruf dann erreicht wird und ausgeführt wird, dann muss ich das eigentlich nicht zweimal ausführen, sondern es reicht, wenn ich das einmal mache. Also Lazy Evaluation hat zwei Komponenten, einmal, dass ich faulerweise Dinge erst später evaluiere und dass ich vielleicht auch wieder faulerweise bestimmte Dinge nicht nochmal mache, sondern mir einfach das Ergebnis vom letzten Mal gemerkt habe. In Programmiersprachen, die keine Seiteneffekte haben, also zum Beispiel Haskell, ist diese Lazy Evaluation transparent für den Programmierer. Das heißt, der Programmierer bekommt das eigentlich nicht mit, sondern der Compiler oder die Laufzeit-Umgebung kann das machen, um das Programm zum Beispiel effizienter auszuführen und der Programmierer merkt das gar nicht, denn dadurch, dass ich keine Seiteneffekte habe, ist es eigentlich egal, wann eine Expression evaluiert wird und es ist auch egal, ob die nochmal neu evaluiert wird oder ob ich einfach das alte Ergebnis wieder nehme, denn ohne Seiteneffekte und ohne Zustand außer den Parametern kann sich einem Ergebnis ja nichts ändern. In Sprachen mit Seiteneffekten wie zum Beispiel die Schiebensprache, die wir hier ja gesehen haben, ist das natürlich durchaus wichtig und deswegen ist Lazy Evaluation auch nicht standardmäßig eingestellt, sondern der Programmierer kann in Schiebensprachen explizit Lazy Evaluation verlangen, indem man dieses Delay Keyboard vor eine Expression schreibt und damit explizit angibt, dass das bitte auch später und nur on-demand evaluiert werden kann. Das heißt, ich kann die Effekte von Lazy Evaluation oder auch von Normal oder auch in der Sprache wie Schiebensprachen bekommen, muss das aber dann explizit angehen. Zum Abschluss dieser Thematik Evaluationsreinfolge habe ich noch ein kleines Quiz und zwar in der Form einer Expression in Scheme, die zwei Funktionen benutzt und die Frage ist, welche Schritte werden denn da gemacht, wenn man das jetzt in Applikative Order oder Normal Order evaluiert und als etwas, was man einfach jetzt hier überprüfen kann, ist die Frage, wie viele Schritte sind denn da überhaupt nötig. Hier oben sehen Sie zwei Funktionen Definitionen, Funktion Double und Funktion Average und die Expression, um die es dann geht, ist hier zu sehen und Sie sollen jetzt einfach mal das Video anhalten und mal selber aufschreiben, wie wird diese Expression denn evaluiert und wie viele Schritte sind denn da nötig, wenn ich Applikative Order oder Normal Order verwende. Schauen wir die Lösung mal kurz an, also was dann rauskommt, ist 5 und 8. In Applikative Order sind es 5 Schritte und in Normal Order 8 Schritte und warum zeige ich jetzt auch noch. Schauen wir uns die Evolution also mal an und zwar zunächst wieder unter Applikative Order. Unsere Expression war dieser Aufruf von Double und als Argument übergeben wir einen Aufruf von Average mit den Werten 2 und 4. In Applikative Order werden also erst die Argumente evaluiert, sprich ich schau mir an, was rauskommt, wenn ich Average auf 2 und 4 anwende. Das passiert, indem ich erst die Argumente dieses Aufrufs, also 2 und 4 evaluiere, aber da das schon Zahlen sind, muss ich da nichts machen und anschließend dann den Body der Funktion hier einsetze. Also ich habe dann dieses Teilen von plus von 2 und 4 und 2 und das wird jetzt evaluiert, indem ich plus von 2 und 4 evaluiere und anschließend dann diese Division komme dann also hier auf 3 und weiß jetzt, dass mein Argument 3 ist. Ruf damit dann Double auf und rechnet dann das aus, was im Body von Double steht, nämlich plus von 3 und 3 und kommt damit schlussendlich auf 6. Das heißt an der Stelle sind es 5 Berechnungsschritte, die in Applikative Order nötig sind. Zum Vergleich dazu ist noch das Ganze in Normal Order, also der Reihenfolge, wo wir die Argumente nicht sofort evaluieren, sondern das faulerweise erst machen, wenn sie tatsächlich benutzt wird. Also hier wieder unser Aufruf von Double, der Average als Argument bekommt und hier würden wir jetzt gleich in die Double Funktion reingehen, also die Argumente erst mal so stehen lassen, wie sie sind, haben also hier unser plus angewandt auf Average von 2 und 4 und dann nochmal Average von 2 und 4 und jetzt würden wir in der Funktion drinnen dann von links nach rechts diesen Ausdruck evaluieren, also zunächst den ersten Average Ausdruck ersetzen durch den Body der Funktion, den zweiten, dass man erst mal so stehen, wie er ist und evaluieren jetzt diesen Ausdruck weiter von innen nach außen plus von 2 und 4 ergibt dann 6. Der Rest bleibt erst mal wie er ist, dann haben wir hier die 3 stehen und dem nächsten Schritt können wir uns dann noch den zweiten Aufruf anschauen und hier passiert jetzt genau das gleiche nochmal, wir ersetzen wieder den Aufruf durch den Body der Funktion, das ist mal zu viel und evaluieren den Ausdruck dann weiter von innen nach außen, also hier mal wieder 6 und die 2 und dann schlussendlich sehen sehen wir, dass wir 3 plus 3 berechnen und kommen da auch auf das Ergebnis 6 und im Gegensatz zu der Applicative Order auf der linken Seite sind das hier also 8 Schritte, also das ist wieder ein Beispiel, wo wir mit Normal Order mehr Arbeit machen würden, weil wir ganz einfach zweimal das selber ausrechnen. Wenn wir jetzt natürlich das Zwischenergebnis dieses Aufruf von Average von 4 und 2 auch noch zwischenspeichern würden, was man in der Sprache mit Lazy Evaluation natürlich machen kann, dann müssten wir das auch nicht zweimal ausrechnen, sondern könnten den schon berechneten Wert, nämlich die 3, die daraus kommt, wiederverwenden und das Ganze dann genauso effizient machen wie in Applicative Order. Ja, und damit sind wir auch am Ende dieses dritten Teils vom Themenblog Funktionale Programmiersprachen. Ich hoffe, Sie wissen jetzt ein bisschen besser, wie diese Sprachen überhaupt evaluiert werden und was es mit Lazy Evaluation und der Frage, in welche Reihenfolge bestimmte Sub-Expressions überhaupt evaluiert werden, denn so auf sich hat. Das ist auch das Ende von Programmierparadigmen in diesem Semester. Ich hoffe, Ihnen hat die Veranstaltung gefallen und Sie haben so ein bisschen Überblick darüber bekommen, welche Programmiersprachen und Programmiersprachenparadigmen denn es alles gibt. Das Wichtigste, was Sie, glaube ich, mitnehmen sollten, ist, dass das alles Paradigmen sind. Also es geht nicht um die Sündtags einer bestimmten Sprache, sondern eben darum zu erkennen, dass viele von den Ideen, die wir hier in der Veranstaltung besprochen haben, eben auf viele Sprachen angewandt werden kann und sich eigentlich überall wiederfindet. Und wenn man die Paradigmen einmal verstanden hat, dann kann man auch relativ einfach eine neue Sprache lernen, weil man im Prinzip nur dieselben Ideen wieder und wieder trifft und die ein bisschen anderer Oberfläche haben, ein bisschen anderer Sündtags, aber unter dieser Oberfläche doch eigentlich dieselben Konzepte warten. Vielen Dank fürs Zuhören und ich hoffe, wir sehen uns irgendwo.