netzstaub

beatz & funkz

Thursday, February 24, 2005

Javascriptcompiler

Vor kurzem habe ich kurz erklärt, was es mit meinem Javascriptcompiler auf sich hat. Auf Grund erhöhtem Idlens bin ich jetzt nicht so schnell vorangekommen wie ich es wollte, aber langsam wird das ganz schon recht brauchbar. Der Compiler funktioniert wie folgt: als Eingabe bekommt er eine Liste von Lisp-artigem Code, der nach Javascript transformiert werden soll. Hier ist es ganz wichtig: das ist kein Lisp was da reinkommt, sondern nur eine Liste, die nach Lisp-Code aussieht, aber eigentlich so eine Art lose definiertes Javascript-Lisp-Dialekt ist. Dieses Dialekt kann man dann relativ einfach in “richtigem” Lisp-Code einbetten. Als Ausgabe kommt eine Liste von entweder Strings oder weitere Listen, die menschenlesbaren Javascript sind. Die Listen sind dazu da, um die “Verschachtelung” des Codes zu veranschaulichen, so dass der Code auch lesbar indentiert werden kann. Dieser Eintrag wird recht lang, mehr also im Body…

Ein kleines Beispiel:

Unser Javascript-Lisp-Dialekt (ALERT ist z.B. eine Funktion die es in Lisp nicht gibt):

(if (> 3 4) (alert "3 ist groesser als 4") (alert "4 ist groesser als 3"))

Was da rauskommt:

CL-USER> (js-compile '(if (> 3 4) (alert "3 ist groesser als 4") (alert "4 ist groesser als 3")))

ergibt

(("if (3 > 4) {"
  ("alert(\"3 ist groesser als 4\");")
  "} else {"
  ("alert(\"4 ist groesser als 3\");") "}"))

Und wenn man es indentiert:

CL-USER> (js-indent *)
"if (3 > 4) {
   alert(\"3 ist groesser als 4\");
} else {
   alert(\"4 ist groesser als 3\");
}
"

Die Kernfunktion JS-COMPILE

Wie ihr schon sehen konntet ist die Kernfunktion von dem Compiler die Funktion JS-COMPILE. Diese Funktion nimmt eine Lisp-Datenstruktur (also eine Nummer, ein String, ein Symbol oder eben eine Liste), und transformiert diese nach Javascript. Bei Nummern und Strings ist das recht einfach, bei Symbolen wenden wir die kleine Voodoofunktion von oben an, bei Listen wirds allerdings ein bisschen komplizierter. Aber erstmal die Funktion:

(defun js-compile (expr)
  (setf expr (js-expand-form expr))
  (cond
    ((null expr) nil)
    ((symbolp expr) `(,(symbol-to-js expr)))
    ((stringp expr) `(,(prin1-to-string expr)))
    ((numberp expr) `(,(prin1-to-string expr)))

    ((not (listp expr))
     (error "Unknown atomar expression ~A" expr))

    (t (js-form expr))))

Wie man erkennen kann (ich denke das geht auch recht gut für Leute die jetzt kein Lisp können) besteht die Funktion aus einem grossen COND Statement. COND ist sowas wie case in C, eine Art Fallunterscheidung. Hier: Wenn das ein Symbol ist, gib bitte (SYMBOL-TO-JS EXPR) zurück, wandle das Symbol also zu eine Javascriptnamen um. Wenn es ein String ist, dann gib einfach das gequotete String zurück. Wenn es eine Nummer ist, genauso, und sonst rufe JS-FORM auf. Wer ganz schlau ist hat bestimmt gemerkt, dass wir hier nicht direkt Strings oder Nummern zurückgeben, sondern Listen, das erkläre ich aber später.

Makros in unserem Lisp-Javascript-Dialekt

Eins der Hauptgründe für den Compiler überhaupt ist, dass ich in Javascript liebend gerne Lisp-Makros einsetzen will, um mir Tipparbeit zu sparen. Diese Makros müssen wir aber beim Compilen aufrufen, das geschieht nicht automatisch. Dazu rufen wir ganz zu Anfang von JS-COMPILE die Funktion JS-EXPAND-FORM auf, die die zu kompilierende Datenstruktur auf Makros untersucht, und diese Makros dann auch auswertet. Um Makros auszuwerten gibt es in Lisp die Funktion MACROEXPAND. Allerdings kann ich diese nicht direkt anwenden, weil viele “native” Konstrukte von meinem Lisp-Dialekt (z.B. IF, oder AND) selber als LISP-Makros realisiert sind, und die will ich bloss nicht auswerten, weil da kommen dann unverständliche Compilerinnerein zum Vorschein. Z.B.:

CL-USER> (macroexpand '(and a b))
(IF A (AND B))

Um sowas zu verhindern überprüft die Funktion JS-EXPAND-FORM, ob es sich um ein “natives” Konstrukt handelt, oder ob da vielleicht doch ein Makro zum Einsatz kommen könnte. Ganz sauber ist das hier nicht, und ich bin mir noch nicht so sicher, ob ich das im Endeffekt auch beibehalten werden. Das Problem ist nämlich, dass hier zwei semantische Ebenen vermischt werden. Die Datenstruktur, die kompiliert wird, sollte eigentlich reiner Lisp-Javascript-Dialekt sein. Jedoch wird diese Datenstruktur mit Funktionen verarbeitet, die eigentlich auf “richtigem” Lisp arbeiten. Da kann es zu dummen Nebeneffekten kommen. Aber nun gut, hier erstmal JS-EXPAND-FORM:

(defun js-expand-form (expr)
  (cond ((atom expr) expr)
        ((op-form-p expr) expr)
        (t (let* ((name (car expr))
                  (js-form (gethash name *js-forms*)))
             (if js-form
                 expr
                 (macroexpand expr))))))

Wieder haben wir hier eine grosse Fallunterscheidung. Ist EXPR atomar (also keine Liste), dann gibt es hier kein Makro, das es anzuwenden gilt, und wir geben einfach EXPR zurück. Ist EXPR eine Liste, ist aber das erste Element ein Operator (z.B. + oder AND), dann gibt es auch kein anzuwendendes Makro, und EXPR wird zurückgegeben. Hier wird also z.B. vermieden, das aus (AND A B) was komisches gemacht wird. Als allerletztes wird noch überprüft, ob es sich hier um eine “Spezialform” unserer Lisp-Javascript-Dialektsprache handelt, die auch nicht expandiert werden darf. Alle Spezialformen werden in einer Hashtabelle namens *JS-FORMS* festgehalten. Wenn EXPR auch keine Spezialform ist, wird endlich das böse MACROEXPAND angewendet.

Der “eigentliche” Compiler



Jetzt habt ihr die Funktion JS-COMPILE gesehen, und denkt bestimmt: “die macht ja aber gar nichts”. Die eigentliche Arbeit wird nämlich bei dem Übersetzen von Listen geleistet. Diese Rolle übernimmt die Funktion JS-FORM. Eine Liste kann mehrere Sachen sein. Entweder, sie ist ein arithmetischer (bzw. logicher Ausdruck), eine sogenannte Expression in dem Javascript-Standard. Oder sie ist ein Funktionsaufruf (eigentlich auch eine Expression). Oder sie ist eben eine Spezialform, wie z.B. IF, oder eine Variablendeklaration (LET), usw… Diese Fallunterscheidung macht JS-FORM, und den Code will ich euch nicht weiter vorenthalten:

(defun js-form (expr)
  (let* ((name (car expr))
         (args (cdr expr))
         (js-form (gethash name *js-forms*)))
    (cond (js-form
           (let ((*js-form-name* name))
             (apply js-form args)))
          ((op-form-p expr)
           (list (js-expression expr)))
          (t
           ;; sonst funktionsaufruf
           (list (format nil "~A(~{~A~^, ~})"
                         (js-compile-single name)
                         (mapcar #'js-compile-single args)))))))

Eine Listen-EXPR sieht immer so aus (NAME ARG1 ARG2 ARG3…). Den Namen holen wir uns als erstes raus, und gucken in unserer Hashtabelle *JS-FORMS* nach, ob EXPR vielleicht eine Spezialform ist. In dem Fall rufen wir den Code auf, der diese Spezialform verarbeiten kann (wird im nächsten Abschnitt dokumentiert). Wenn der Ausdruck eine Expression ist, also ein Operator zum Einsatz kommt, dann wird die Funktion JS-EXPRESSION aufgerufen. Im Defaultfall wir daraus einfach ein Funktionsaufruf gemacht. FORMAT ist übrigens das Lisp-Äquivalent für printf, und ist so eine kleine Programmiersprache für sich. Hier ein kleines Beispiel:

CL-USER> (JS-FORM '(print "Hallo Welt"))

ergibt:

("print(\"Hallo Welt\")")

Spezialformen in unserem Compiler

Uns ist jetzt schon ein paarmal die Hashtabelle *JS-FORMS* begegnet, in der die Spezialformen abgelegt sind. Der Trick ist, dass die Funktion, die eine Spezialform verarbeiten kann, unter dem Namen der Spezialform in der Tabelle abgelegt ist. Begegnet uns eine Spezialform, brauchen wir nur in der Tabelle nachzugucken und die Funktion aufzurufen (genau das macht JS-FORM). In der ersten Version des Compilers war JS-FORM eine riesige Fallunterscheidung, die alle Spezialformen behandeln konnte. Das wurde nach 3 Spezialformen ein bisschen lästig. Deswegen kam jetzt ein Makro zum Einsatz (wer hätte es gedacht). Mit dem Makro kann man eine Funktion definieren, die ein Spezialform behandelt, und die Funktion wird automagisch in der Hashtabelle abgelegt. Das Makro heisst DEFJSFORM, und ein Anwendungsbeispiel sieht wie folgt aus:

(defjsform return (arg)
  (concatenate 'string "return " (js-compile-single arg)))

Hier wird die Spezialform RETURN definiert, die eigentlich nicht viel mehr macht als ihr Argument auszuwerten, und “return” noch vorne dranhängen. Interessanter ist die Makroexpansion von DEFJSFORM:

CL-USER> (macroexpand '(defjsform return (arg)
  (concatenate 'string "return " (js-compile-single arg))))

ergibt:

(PROGN (DEFUN JS-RETURN (ARG)
         (LET ((#:G1618
                (PROGN (CONCATENATE 'STRING
                                    "return "
                                    (JS-COMPILE-SINGLE ARG)))))
           (LIST #:G1618)))
       (DOLIST (MATCH '(RETURN)) (SETF (GETHASH MATCH *JS-FORMS*) #'JS-RETURN)))

Hier wird zuerst eine Funktion namens JS-RETURN definiert, die ein Argument nimmt. Dieses Argument wird wie angegeben verarbeitet. Schliesslich wird die Funktion unter ‘RETURN in der Tabelle *JS-FORMS* abgelegt. Das DOLIST sieht ein bisschen kompliziert, ist aber dazu da, um eine Verarbeitungsfunktion unter mehreren Namen abzulegen. Zum Beispiel so (weil der Code für INCF bzw. DECF ziemlich derselbe ist):

(defjsform (incf-decf incf decf) (form)
  (concatenate 'string (cdr (assoc *js-form-name* '((incf . "++")
                                                    (decf . "--"))))
               (js-compile-single form)))

Zusammenfassung



Der Compiler ist noch nicht wirklich fertig, aber es fehlt nicht mehr viel. Ich hoffe das dieser kleine Einblick verständlich war (ich beantworte auch gerne Fragen), und gezeigt hat, wie schnell man eigentlich einen Compiler bauen kann. Die ganze lästige Kleinkramerei mit Lexer und Parser bleibt uns ja zum Glück erspart :)

Zum Abschluss ein komplizierteres Beispiel:

(jsfunction vis-by-class (class-name state)
    (if (and (blorg)
             (bla.foobar (bla.foobar) 1 2))
        (let ((a-blorg 2)) (setf a-blorg (+ 2 a-blorg)))
        (progn (setf a (* 1 2 3 4)) 2))
    (do ((i 0 (incf i))
         (j n (decf j)))
        ((null j))
      (print i)))

ergibt:

"function visByClass(className, state) {
      if (blorg() && bla.foobar(bla.foobar(), 1, 2)) {
         {
            var aBlorg = 2;
            aBlorg = 2 + aBlorg;
         }
      } else {
         a = 1 * 2 * 3 * 4;
         2;
      }
      for (var i = 0, var j = n; !null(j); i = ++i, j = --j) {
         print(i);
      }
}
"
posted by manuel at 11:39 pm  

No Comments »

No comments yet.

RSS feed for comments on this post.

Leave a comment

Powered by WordPress