Simple Restarts: Hinter den Kulissen
Im vorherigen Blogpost haben wir Conditional Restarts in Clojure kennen gelernt und dabei die Bibliothek „Simple Restarts“ verwendet. Diese Bibliothek wurde als Anschauungsbeispiel erstellt, um Conditional Restarts in Clojure zu erklären. In diesem Blogpost werden wir einen Blick hinter die Kulissen werfen und verstehen, wie die Magie hinter „Simple Restarts“ implementiert ist: Eine lehrreiche Reise voller Illusion, Überraschungen und „Aha!“s.
Voraussetzungen
Neben guten Kenntnissen in Clojure, sollte der Leser die Einführung zu Conditional Restarts, die auf „Simple Restarts“ aufbaut, gelesen haben. Außerdem ist unsere Blogreihe zu Makros in Clojure sehr zu empfehlen, da ein großer Teil der Bibliothek mithilfe von Makros implementiert wurde. Insbesondere der dritte Teil der Serie ist wichtig, denn neben Makros kommen auch Records aus Active Clojure zum Einsatz, um zusammengesetzte Daten zu beschreiben.
Den Call-Stack hinauf und wieder hinunter
Conditional Restarts ermöglichen das Zusammenspiel von Restarts und Handlern und das Auslösen von Conditions über mehrere Funktionsaufrufe hinweg. Daher werden wir zunächst den Call-Stack genauer betrachten und verstehen, wie dieser funktioniert, um dann Conditional Restarts zu begreifen.
Wird eine Funktion aufgerufen, legt ein Prozessor oder eine virtuelle Maschine ein Frame auf den Call-Stack. Dieser Frame beinhaltet unter anderem die Argumente der Funktion, aber auch andere wichtige Informationen. Ruft die aufgerufene Funktion wiederum eine weitere Funktion auf, wird der Vorgang wiederholt, der Stack wächst. Ist eine Funktion abgearbeitet, wird sie vom Stack entfernt und die Berechnungen werden nach der Stelle fortgesetzt, an der der Frame auf den Stack gelegt wurde.
Historisch bedingt wächst der Call-Stack nach unten:
Wird foo
aufgerufen und somit bar
, liegen beide Funktionen auf dem
Stack (b). Durch die Auswertung von bar
kommt baz
hinzu (c), das nach vollständiger
Auswertung wieder entfernt wird (d).
Doch was hat dies mit Conditional Restarts zu tun? Um im Falle einer Condition
den passenden Restart zu finden, müssen wir im Stack zurück und sukzessive in den
vorherigen Frames nach dem ersten passenden Restart suchen. Nehmen wir an, dass
foo
, bar
und baz
mit folgendem Inhalt, unter Zuhilfenahme von „Simple
Restarts“, gefüllt sind:
Es muss nun möglich sein, die Ausführung von baz
zu unterbrechen und im Stack
rückwärts in bar
nach passenden Restarts suchen. Ebenso müssen wir aber dafür
sorgen, dass der Handler, der in foo
angibt, welcher Restart ausgelöst wird, in den
tieferen Stack-Frames (Aufruf von baz
) bekannt ist.
Wir benötigen also die Möglichkeit, Informationen in tiefere Stack-Frames zu reichen, aber auch die, wieder zurückzuspringen. Clojure bietet zwei Mechanismen, die das ermöglichen: Bindings und Exceptions. Zudem verwenden wir Makros, um die Illusion perfekt zu machen.
Binden der Handler
Bevor es so richtig losgeht, implementieren wir ein Makro, um Conditions zu
definieren. Um eine Condition zu beschreiben, führen wir einen Record mit den
Feldern identifier
und parameters
ein. In dem Makro zur Definition einer
Condition defcondition
definieren wir einfach eine Funktion, die den Namen des
Identifiers trägt und die Parameter entgegen nimmt:
Wird also (example-condition 1 2 3)
aufgerufen, wird ein Record erzeugt, welcher
das definierte Symbol example-condition
der Funktion selbst als Identifier beinhaltet,
sowie den Vektor [1 2 3]
im Feld condition-params
. Durch das Macro ist es
möglich, eine Definition zu erzeugen, die Implementierungsdetails verbirgt.
Diese Condition kann in der Bibliothek dazu verwendet werden, Handler zu binden:
Um dieses Makro zu implementieren, verwenden wir Clojure-Bindings in Verbindung mit dynamischen Variablen. Diese erlauben es uns, Variablen neu zu binden (pro Thread). Die Idee ist, eine Map von aktuell gültigen Handlern mitzuführen:
Mit der eingebauten Funktion binding
fügen wir die an handler-bind
übergebenen Condition-Handler-Paare in die dynamischen Map *handlers*
ein. Die
Map verwendet die durch die Condition-Definitionen definierten Funktionen als
Schlüssel, die Werte sind die Handler-Funktionen, die im Vektor jeweils an
gerader Stelle stehen. Wir verwenden hier ein Makro, da in body
beliebiger
Code stehen kann, der vorerst nicht ausgeführt wird. Wie die Funktion
invoke-restart
implementiert ist, erfahren wir später.
Conditions abfeuern
Sind die Handler erstmal in tieferen Stackframes sichtbar, kann das Feuerwerk
beginnen: Conditions können ausgelöst, vom Handler bearbeitet und
Exceptions geworfen werden, um in höheren Stackframes zu einem passenden Restart
zu gelangen. Wir implementieren die Funktion fire-condition
, die eine Instanz
einer Condition entgegennimmt und eine Funktion condition-handler
, die
verwendet wird, um aus der dynamischen Map den passenden Handler auszuwählen.
In fire-condition
wird also mithilfe des Condition-Identifiers der passende
Handler ausgwählt und dieser anhand der Condition-Parameter ausgewertet. Handler
müssen stets eine Restart-Invocation (siehe folgenden Abschnitt) zurückgeben.
Die Funktion lässt im nächsten Schritt eine Exception fallen, die diese
Restart-Invocation beinhaltet und vom Typ ::restart-invocation
ist.
Restart und Restart-Invocation
Sowohl Restarts also auch Restart-Invocations sind lediglich Daten:
Ein Restart besteht aus einem Namen für den Restart und einer Funktion für den Wiedereinstieg. Eine Restart-Invocation dagegen beinhaltet den Namen des auszulösenden Restarts, sowie einer Liste von Parametern für die Funktion für den Wiedereinstieg.
Restarts können anhand der Bibliothek folgendermaßen in den Code eingehängt werden:
restart-case
nimmt einen Ausdruck entgegen, der ausgewertet werden soll und
eine Liste von Restarts, die im Falle einer Condition für diesen Ausdruck gelten
sollen. Da das Auslösen einer Condition über Exceptions den Stack aufwärts
kommuniziert, fangen wir diese im Makro auf:
Die Magie passiert in der Funktion restart-case-catch
: Diese überprüft zuerst,
ob es sich um die gewünschte Exception handelt—ansonsten wird die Exception
einfach erneut geworfen. Im Falle einer Restart-Invocation-Exception wird der
Name und die Parameter des auszulösenden Restarts aus der Restart-Invocation
extrahiert und der passende Restart aus der Liste der übergebenen Restarts gesucht.
Existiert kein passender Restart, wird die Exception erneut geworfen. Es kann ja sein, dass ein passender Restart in höheren Stack-Frames definiert wurde. Findet sich ein solcher Restart, wird die Wiedereinstiegsfunktion ausgewertet—und fertig ist das Kunststück.
Fazit
In diesem Blogpost haben wir die Tricks, die hinter der „Simple Restarts“-Bibliothek stehen, nachvollzogen. Es wurde gezeigt, wie die zwei Mechanismen, Bindings und Exceptions, verwendet werden, um bidirektional über den Stack hinweg zu kommunizieren. Makros helfen dabei, wie Illusionen, über die eigentlichen Vorgänge hinwegzutäuschen und ermöglichen es dem Anwender der Bibliothek ohne Verständnis der Interna, Sachverhalte abzubilden—wir Informatiker nennen dies Abstraktion. Lisps und ihr mächtiges Makrosystem erleichtern die Implementierung solcher Bibliotheken enorm.
Die Bibliothek hat auch Schwächen: Neben Fehlerbehandlung lässt sich die Verwendung von Exceptions nicht vollständig vor dem Anwender verbergen: Fängt dieser an einer Stelle alle Exceptions ab, funktioniert auch der Restart-Mechanismus nicht mehr. Dies könnte vom Anwender nicht gewollt sein und aus Unwissenheit über die Interna der Bibliothek passieren. Für Anschauungszwecke reicht der Exception-Mechanimus jedoch vollkommen.
Der vollständige Quelltext der Bibliothek ist auf GitHub verfügbar.