Racket und #lang
Racket steht – wie allen Lisp-Dialekten – das mächtige Werkzeug Makros zur Verfügung. Mit ihnen können wir die Sprache erweitern und an unsere Bedürfnisse anpassen. Dabei sind nicht nur einfache syntaktische Konstrukte möglich, nein, ganze Typsysteme (wie zum Beispiel „Typed Racket“) oder Concurrency-Systeme („core.async“ aus Clojure) können damit implementiert werden.
Ein weiterer Aspekt sind eingebettete domänenspezifische Sprachen (eDSL): mithilfe von Makros kann eine präzise, den Fachbegriffen der Domäne entsprechende Sprache mit angepasster Syntax, entwickelt werden.
Racket geht den Weg der DSLs noch ein Stück weiter: Hier können wir die DSLs ganz ohne Racket-Code drum herum verwenden. Wie aus einer eDSL eine DSL wird schauen wir uns heute an!
Racket
Zu Racket wurde auf diesem Blog schon einiges geschrieben, u. a.:
- Eine kleine Einführung in die rein funktionale Programmierung
- Eine Monade für Wahrscheinlichkeitsverteilungen
- Randomisierte Tests mit QuickCheck
Deshalb verzichten wir an dieser Stelle auf eine (syntaktische) Einführung.
Makros und define-syntax
Zu Makros wurde in diesem Blog auch schon einiges geschrieben (z. B. Makros in Clojure), weshalb wir uns hier auch kurz fassen.
Mit Makros lässt sich der Quellcode vor der eigentlichen Evaluation anpassen – mit allen Sprachmitteln der eigentlichen Sprache. In Lisps ist dies besonders einfach, da der Programmcode wie Lisp-Listen aufgebaut ist und dementsprechend wie Listen bearbeitet werden kann.
In Racket erstellen wir Makros mit dem Spezialkonstrukt
define-syntax
. Folgend schreiben wir das kleine Makro infix
, das
uns die in Racket eigentlich ungültige Infixschreibweise (2 + 3)
ermöglicht, via (infix (2 + 3))
:
(define-syntax (infix form)
(syntax-parse form
[(infix (zahl1 op zahl2))
...]
...))
define-syntax
bekommt als erstes Argument den Namen des Makros und
eine sogenannte Form. Diese Form ist der komplette Aufruf (infix
(2 + 3))
, also eine Liste mit zwei Elementen. Im Rumpf benutzen wir
syntax-parse
, das uns ermöglicht, die Form
auseinanderzunehmen und mit Templating den Code zu generieren, der
seinen Platz annimmt. Das Auseinandernehmen erfolgt komfortabel mit
Pattern-Matching, so dass wir die
einzelnen Elemente nicht umständlich mit first
und rest
herausholen müssen. syntax-parse
erlaubt mehrere Patterns auf
form
, wir benötigen aber nur das eine, und schreiben nun noch, wie
der Aufruf (infix (2 + 3))
transformiert werden soll:
(define-syntax (infix form)
(syntax-parse form
[(infix (zahl1 op zahl2))
#`(op zahl1 zahl2))))
Wir tauschen einfach die zwei ersten Elemente! Unbekannt ist noch
#`
. Mit #`
(sprich: hash quasiquote) können wir ein Syntax-Objekt
erstellen. In Racket sind die Forms bei der Makroexpansionszeit nicht
bare Listen, sie sind in Syntax-Objekte gewrappt. Ein Syntax-Objekt
hält Informationen zum Kontext des Makro-Aufrufes bereit, unter
anderem in welchem Modul und in welcher Zeile dieser Aufruf geschehen
ist. Beispielsweise erhalten wir hier
infix.rkt> #`"Hallo"
#<syntax:infix.rkt:3:2 "Hallo">
ein Syntax-Objekt mit der Information, dass der Aufruf von #"Hallo"
(aus der REPL) im Namespace infix.rkt
in Zeile 3 geschehen ist.
Eine Datenbank-DSL
Wir wollen nun eine kleine Datenbank-DSL in Racket realisieren. Über (eingebettete) DSLs wurde im Blog auch schon geschrieben:
An dieser Stelle gehen wir einen Schritt weiter und verlassen Racket und dessen Syntax augenscheinlich komplett, und schreiben eine „Stand-Alone DSL“, die folgende Syntax erlaubt:
SHOW-DB
PUT "milk" 1.50
PUT "water" 1.00
x = GET "water"
PRINT x
SHOW-DB
SHOW-DB
soll hier den aktuellen Datenbankstatus ausdrucken; PUT
ein Key-Value-Paar abspeichern; GET
ein Value zu gegebenen Key aus
der Datenbank holen und an eine Variable binden; PRINT
einen Wert
ausdrucken.
Vorbereitend einige Hilfsfunktionen für unsere Datenbank, die der Einfachheit halber eine Hashmap ist:
(define the-db (make-hash))
(define (get-it key)
(hash-ref the-db key))
(define (put-it key val)
(hash-set! the-db key val))
(define (print-it x)
(display (format "~v" x))
(newline))
Zunächst schreiben wir eine eingebettete DSL, die nur einzelne Befehle akzeptiert:
(db (PRINT "Hallo"))
(db (PUT "kaan" 35))
(db (x = GET "kaan))
Wie oben eingeführt, benutzen wir dafür syntax-parse
und haben hier
vier Patterns:
(define-syntax (db form)
(syntax-parse form
[(db (SHOW-DB))
...]
[(db (PRINT x))
...]
[(db (PUT name val))
...]
[(db (var = GET name))
...]))
Nun müssen nur noch die Ellipsen ausgefüllt werden, mit dem Code, der das jeweilige Pattern in der Makroexpansionszeit ersetzen soll. Das ist für die ersten drei Fälle denkbar einfach, wir benutzen unsere obig definierten Hilfsfunktionen (und achten darauf, Syntax-Objekte zurückzugeben):
(define-syntax (db form)
(syntax-parse form
[(db (SHOW-DB))
#`(print-it the-db)]
[(db (PRINT x))
#`(print-it x)]
[(db (PUT name val))
#`(put-it name val)]
[(db (var = GET name))
#`(define var (get-it name))]))
Im vierten Fall erzeugen wir ein Syntax-Objekt, das das übergebene
Symbol var
an das Value bindet.
Jetzt können wir bereits Folgendes machen:
db.rkt> (db (SHOW-DB))
'#hash()
db.rkt> (db (PUT "kaan" 35))
db.rkt> (db (SHOW-DB))
'#hash(("kaan" . 35))
db.rkt> age
; age: undefined;
; cannot reference an identifier before its definition
db.rkt> (db (age = GET "kaan"))
db.rkt> age
35
Nun wollen wir noch realisieren, dass das Makro mehrere dieser „Statements“ auf einmal verarbeiten kann.
Das fertige Makro sieht so aus:
(define-syntax (db form)
(syntax-parse form
[(db statement ...)
#`(begin
#,@(map (lambda (statement)
(syntax-parse (datum->syntax #`db statement)
[(SHOW-DB)
#`(begin
(display "THE DB: ")
(print-it the-db))]
[(PRINT x)
#`(print-it x)]
[(PUT name val)
#`(put-it name val)]
[(var = GET name)
#`(define var (get-it name))]))
(syntax->list #'(statement ...))))]))
Mit ...
im syntax-parse
-Makro beim Pattern-Matching können wir
angeben, dass sich die letzte Variable beliebig oft wiederholen kann.
Im Weiteren nehmen wir uns diese beliebig lange statement
-Liste her
und iterieren über sie mit map
; der Body der anonymen Funktion ist
unser von oben schon bekannter Code.
Damit können wir nun folgenden Ausdruck in unserer eDSL schreiben:
(db (SHOW-DB)
(PUT "milk" 1.50)
(PUT "water" 1.00)
(x = GET "water")
(PRINT x)
(SHOW-DB))
Dies ausgewertet druckt aus:
THE DB: '#hash()
1.0
THE DB: '#hash(("milk" . 1.5) ("water" . 1.0))
Wie können wir den obigen Code nun völlig ohne Klammern schreiben? Ein Makro ist dazu nicht in der Lage, schließlich muss auch hier mit einer öffnenden Klammer gestartet werden. Das muss also vorher passieren.
Racket gibt einem die Möglichkeit, den Parser (der sogenannte Reader) zu beeinflussen. Genau das machen wir uns nun zu Nutze.
Ein eigener Reader
Um den Text
SHOW-DB
PUT "milk" 1.50
PUT "water" 1.00
x = GET "water"
PRINT x
SHOW-DB
für Racket verständlich zu machen, müssen folgende drei Schritte passieren:
- der Text landet in einer Datei, die mit
#lang reader "db-reader.rkt"
beginnt - jede Zeile wird geklammert
- alle Zeilen werden in einen
(db ...)
-Aufruf eingeschlossen
Schritt 1 weist Racket an, einen eigens definierten Reader, der in der
Datei db-reader.rkt
definiert ist, zu benutzen.
Wir legen also die Datei db-reader.rkt
an, die den Quellcode nach
dem Einlesen, vor Makroexpansion und Evaluation, verändern kann. Sie
beginnt so:
#lang racket
(provide (rename-out (db-read-syntax read-syntax)))
(define (db-read-syntax src in)
(datum->syntax
#f
`(module db racket
(require "db.rkt")
(db
,@(parse-program src in)))))
Wir nennen unseren neuen Reader db-read-syntax
und fangen mit
Schritt 3 an, setzen also um das gesamte geparste Programm (den Text
oben) erst einmal (db ...)
. Darüber packen wir alles in ein Modul,
so sehen unter der Haube alle Racket-Quelltexte aus. Und zu guter
Letzt laden wir noch die Datei db.rkt
, in der unser obiges Makro
definiert ist.
parse-program
macht nun nicht viel anderes, als jede Zeile in
Klammern zu setzen:
(define (parse-program src in)
(define line (parse-line src in))
(if (eof-object? line)
'()
(cons line (parse-program src in))))
(define (parse-line src in)
(regexp-try-match #px"^\\s+" in)
(if (eof-object? (peek-char in))
eof
(read (open-input-string (string-append "(" (read-line in) ")")))))
In parse-line
lesen wir eine Zeile via (read-line in)
, setzen
Klammern und lesen dann mit dem „normalen“ Reader (read
) diese Zeile
ein.
Damit haben wir es geschafft! Unsere obigen Statements können als „Stand-Alone DSL“ ausgeführt werden!
Fazit
Mit Racket steht uns ein mächtiges Werkzeug zur Erstellung von eDSLs
und DSLs zur Verfügung. Rackets Makrosystem, und die vielen
ausdruckstarken Hilfsfunktionen und -Makros, machen das Makroschreiben
zu einer leichten Fingerübung. Die Möglichkeit, den Reader anzupassen,
und damit via #lang
eine eigenständige Sprache zu schreiben, sucht
seinesgleichen.