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:

(defcondition example-condition [a b c])

(defn baz []
    (raise-condition (example-condition 1 2 3)))

(defn bar []
   (restart-case 
       (baz)
       (restart :my-restart identity)))

(defn foo []
   (handler-bind 
      [example-condition (fn [a b c] (invoke-restart :my-restart [a b c]))]
      (bar)))

(foo)

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:

(rec/define-record-type Condition
  (make-condition identifer params) condition?
  [identifer condition-identifier
   params condition-params])

(defmacro defcondition [name params]
  `(defn ~name ~params
     (make-condition ~name ~params)))
     
;; Erstellen einer Condition
(defcondition example-condition [a b c])

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:

(handler-bind 
   [example-condition (fn [a] (invoke-restart :my-restart a))]
   ;; Code für den die Bindung des Handler gelten soll:
   ...
)

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:

(def ^:dynamic *handlers* {})

(defmacro handler-bind [bindings & body]
  (assert (even? (count bindings)))
  `(binding [*handlers* (apply assoc *handlers* ~bindings)]
     ~@body))

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.

(def exception-identifier ::restart-invocation)

(defn condition-handler [name]
  (if-let [handler (get *handlers* name)]
    handler
    (throw (ex-info "No handler found" {:condition name}))))

(defn fire-condition [condition]
  (let [handler        (-> condition condition-identifier condition-handler)
        params         (condition-params condition)
        handler-result (apply handler params)]
    (if (restart-invocation? handler-result)
      (throw (ex-info "" {:type           exception-identifier
                          :handler-result handler-result}))
      (throw (ex-info "No restart invocation returned" {:handler handler})))))

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:

(rec/define-record-type Restart
  (restart n invocation-function) restart?
  [n restart-name
   invocation-function restart-invocation-function])

(rec/define-record-type RestartInvocation
  (make-restart-invocation restart-name params) restart-invocation?
  [restart-name restart-invocation-restart-name
   params restart-invocation-params])

(defn invoke-restart [sym & params]
  (make-restart-invocation sym params))

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 
    ;; do something here, e.g
    (fire-condition (example-condition 1 2 3))
    (restart :my-restart-1 
           (fn [baz] ...)) 
    (restart :my-restart-2
           (fn [buzz] ...)))

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:

(defn restart-invocation-exception? [e]
  (= (:type (ex-data e)) exception-identifier))

(defn find-restart [restarts name]
  (first (filter (fn [restart] (= (restart-name restart) name)) restarts)))

(defn restart-case-catch [e available-restarts]
  (if (restart-invocation-exception? e)
    (let [handler-result   (:handler-result (ex-data e))
          restart-name     (restart-invocation-restart-name handler-result)
          restart-params   (restart-invocation-params handler-result)]

      (if-let [restart (find-restart available-restarts restart-name)]
        (apply (restart-invocation-function restart) restart-params)
        (throw e)))
    (throw e)))

(defmacro restart-case [body & restarts]
  `(try
    ~body
    (catch Exception e#
      (restart-case-catch e# ~(vec restarts)))))
          

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.