Entwickeln domänenspezifischer Sprachen mit Ruby: Auf dem Weg zur nächsten Programmiersprache
Im Laufe der Jahre hat die Popularität von Ruby stetig zugenommen, die Anzahl verfügbarer Anwendungen steigt mit jedem Tag. Doch hat dies nicht nur Vorteile für den Entwickler – so muss er immer komplexere Programmierschnittstellen (API) verstehen und beherrschen, um ein gewünschtes Problem lösen zu können. Trotz der Flexibilität von Ruby, die hoch geschätzt wird, gibt es immer wieder Sachverhalte, die man anders ausdrücken möchte beziehungsweise der eben noch geschriebene Programmcode fühlt sich einfach nicht gut an. In diesem Fall hat man wahrscheinlich Code geschrieben, der einen bestimmten Zustand mit einer bestimmten Semantik beschreibt und versucht, ein bestimmtes Problem innerhalb dieses eingeschränkten Kreises – der Domäne – zu lösen. Im nun folgenden Refactoring-Schritt würde man die API solange anpassen, bis diese sich gut in das Programmiermodell einbettet. Mit der Optimierung des geschriebenen Codes wurde schon der erste Schritt zu einer domänenspezifischen Sprache (DSL) getan, auch wenn man es auf den ersten Blick nicht glauben mag.
Doch bevor nun näher auf die eigentliche Sprache eingegangen wird, soll zuerst kurz definiert werden, was eine domänenspezifische Sprache überhaupt ist: Sie beschreibt ein Programmiermodell, das auf eine bestimmte Situation beschränkt ist und Verallgemeinerungen bewusst außen vor lässt, um sich voll und ganz dem eigentlichen Problem zu widmen. Dabei wird bei der DSL zwischen zwei verschiedenen Arten – interne und externe DSL – unterschieden. Die interne DSL probiert, den gewünschten Sachverhalt ganz mit den Mitteln der Wirtssprache auszudrücken. Ein Beispiel für ein solches Paradigma ist das bekannte Framework ActiveRecord aus Ruby on Rails. Hier werden durch geschickten Umgang mit den sprachlichen Mitteln von Ruby die Modelle mit deklarativen Mitteln definiert, die im Prinzip nicht zu der typischen imperativen Syntax passen. Eine externe DSL erkennt man daran, dass diese von einem Parser analysiert und dann von einem Interpreter verarbeitet wird – ein typisches Beispiel ist ein Bash-Skript unter Linux/Unix. Die Bash ist auf eine ganz bestimmte Domäne eingeschränkt, die möglichen Operationen der Sprache sind speziell auf den Umgang mit Dateien und Verzeichnissen optimiert.
Was ist nun der Unterschied zwischen externen domänenspezifischen Sprachen und vollwertigen Programmiersprachen (GPL)? Er besteht darin, dass eine GPL mit dem Ziel entworfen wurde, möglichst alle Probleme in den unterschiedlichsten Domänen zu lösen, während der Einsatzbereich von einer externen DSL auf eine Domäne beschränkt ist.
Grammatik, wie in der Schule?
Das wichtigste Werkzeug beim kreativen Prozess der Sprachentwicklung ist die Grammatik der jeweiligen Sprache. Grammatiken bilden dabei wie in der natürlichen Sprache den Regelsatz, nach dem Aussagen in der Sprache allgemein gültig formuliert werden.
In der Linguistik formulieren Grammatiken Ersetzungsregeln, die bestimmen, wie man von einem Ausgangspunkt zu einem Endpunkt kommt. Für den Sprachdesigner ist dabei das wichtigste Ziel, eine deterministische und finite Grammatik zu definieren.
Die Analyse der Eingabedaten beginnt mit der Zerlegung in die Grundbegriffe der Sprache, die Token, durch den Lexer. Übertragen auf die deutsche Sprache wäre dies die Zerlegung der ursprünglichen Zeichenkette (Beispiel: „Ich helfe dir.“) in die drei Grundelemente der Sprache, Subjekt, Prädikat und Objekt. Im nächsten Schritt werden mittels vordefinierter Regeln ( HS -> S P O ) die einzelnen Token (S, P, O) durch den Parser in ein Symbol (HS) ersetzt. Dies wird so lange fortgeführt, bis alle Token und Symbole durch Terminalsymbole – Symbole, für die keine weiteren Ersetzungsregeln definiert sind – ersetzt wurden.
Ein Rechenbeispiel
Das folgende Beispiel zeigt, trotz seines geringen Umfangs, die Möglichkeiten einer externen Sprache auf und stellt außerdem dar, wie einfach es ist, eine solche Sprache in Ruby mit Hilfe des Dhaka-Projekts [1] zu implementieren. Dhaka ist ein in Ruby geschriebener Parsergenerator zur einfachen Implementierung einer externen DSL. Dabei greift der Generator dem Entwickler hilfreich unter die Arme und bietet dennoch die von Ruby gewöhnte Flexibilität. Zielsetzung ist es, eine DSL zu erstellen, die einen in Präfixnotation eingegebenen String in das für Ruby verständliche Infixformat umwandelt, dieses evaluiert und das Ergebnis der Operation zurückgibt.
Es soll die Eingabe „(+ 2 1)“ analysiert und das Ergebnis 3 ausgegeben werden. Außerdem soll zusätzlich Klammerung erlaubt sein, sodass folgender Ausdruck „(+ (- 10 2) 1)“ den Wert 9 zur Folge hat. Wie oben schon erwähnt, wird im ersten Schritt ein Lexer benötigt, der die Eingabemenge in für den Parser verständliche Stückchen zerlegt. Benötigt wird eine Erkennung für die Klammern, die Operatoren und natürlich für die Zahlen. In der Dhaka-Syntax formuliert wird dies zu dem Code in Listing 1.
class SimpleLexer < Dhaka::LexerSpecification ops = ["+", "-"]; keys = ["(", ")"] keys.each { |op| for_symbol(op) { create_token(op) } } ops.each { |op| for_symbol(op) { create_token("OPERATOR") }} for_pattern('\d+') { create_token('n') } for_pattern('\s+') { } end
Listing 1
In Zeilen 3 bis 9 werden für die Operatoren (‚+‘,‘-‚) und die Schlüsselwörter (‚(‚, ‚)‘) eigene Token angelegt. Reguläre Ausdrücke definieren dabei, was ein Token ist, bevor dieses angelegt wird. Whitespaces wie Zeilenumbrüche und Leerzeichen werden ignoriert. Damit ist der Lexer spezifiziert, welcher die Eingaben für den Parser vorbereitet.
_Start_ : Expr Expr : ( OPERATOR Expr Expr ) | n
Listing 2
Nun muss die Grammatik definiert werden. Sie muss Ausdrücke erkennen, also zuordnen, dass eine Folge von Klammer, Operator, Zahl, Zahl und Klammer einen Ausdruck bildet. Zusätzlich dazu ist noch das Erkennen von Schachtelung zu ermöglichen. In der BNF-Schreibweise (eine spezielle Art, Ableitungsregeln zu schreiben) in Listing 2 kann man gut erkennen, dass die Ersetzungsregel rekursiv und so lange angewendet wird, bis ein Ausdruck nur noch ein Zahlsymbol ist. Für das Zahlsymbol sind keine Ersetzungsregeln definiert, es ist damit ein Terminalsymbol. Die Klammern in der BNF-Schreibweise definieren keine Präferenzregeln, eine Klammer stellt hier ein spezielles Token dar. Ähnlich einfach wie die Definition des Lexers ist die Definition des Parsers und seiner Grammatik.
class SimpleGrammar < Dhaka::Grammar for_symbol(Dhaka::START_SYMBOL_NAME) { start %w| Expr | } for_symbol("Expr") do operation %w| ( OPERATOR Expr Expr ) | integer %w| n | end end
Listing 3
Nachdem Lexer und Parser erstellt wurden, soll mit dem nächsten Schritt die Entwicklung der eigenen DSL vorangetrieben werden. Um das Ergebnis der Eingabe berechnen zu können, muss das Resultat der Analyse, der Parsebaum, ausgewertet werden. Die Dhaka-Evaluatorklasse wird nun zur Hilfe genommen, um den Parsebaum zu durchlaufen. Der Entwickler hat hierbei die Möglichkeit, an bestimmten Stellen Hooks in den Baum zu integrieren und so eigenen Code ausführen zu lassen.
class SimpleEval < Dhaka::Evaluator self.grammar = SimpleGrammar define_evaluation_rules do for_start { eval(evaluate(child_nodes[0])) } for_operation { evaluate(child_nodes[2]) + child_nodes[1].token.value + evaluate(child_nodes[3]) } for_integer { child_nodes[0].token.value.to_s } end
Listing 4
Listing 4 lässt gut erkennen, wie dies funktioniert. Zunächst wird im Evaluator mittels einer Klassenvariable definiert, dass die zuvor erstellte Grammatik als Grundlage für die Auswertung verwendet werden soll. Im Anschluss daran werden die notwendigen Hooks installiert, wobei es nur zwei Dinge zu beachten gibt: Hooks können nicht für die eigentliche Regel, sondern nur für Produktionen definiert werden und für jedes Terminalsymbol muss immer ein Hook definiert werden. Damit die Mathematik wieder stimmt, können nun Lexer und Parser zusammen mit dem gerade entstandenen Evaluator aufgerufen werden, sodass „(+ (- 10 2) 1)“ den Wert 9 ergibt.
lexer = Dhaka::Lexer.new(SimpleLexer) parser = Dhaka::Parser.new(SimpleGrammar) puts SimpleEval.new.evaluate( parser.parse(lexer.lex("(+ (- 10 2) 1)")) )
Listing 5
Ist das schon alles?
Natürlich ist das hier gezeigte Beispiel recht einfach und übersichtlich, doch zeigt es, wie schnell man eine eigene Programmiersprache entwickeln kann. Die Möglichkeiten, die eine externe DSL dem Entwickler bietet, sind nahezu unbegrenzt. Man sollte sich jedoch nicht verleiten lassen, für jeden Spezialfall eine DSL zu schreiben, denn wenn zusätzlich zum eigentlichen Applikationscode noch ein weiteres Projekt gewartet werden muss, kann dies die Kosten für die Softwareentwicklung steigern. Auf der anderen Seite kann eine DSL die nötige Flexibilität bieten, um schneller zum Ziel zu kommen beziehungsweise spezielle Anwendungsgebiete überhaupt abdecken zu können.
Als Ausblick zur Verwendung einer externen Sprache sei dabei das RMQL-Projekt genannt. RMQL steht für Ruby on Rails Model Query Language und erlaubt dem Benutzer, über eine Anfragesprache direkt auf ein Rails-Modell zuzugreifen. Die Sprache ist dabei an XQuery angelehnt und ermöglicht dem Benutzer, mit der Anfrage gleichzeitig das Ergebnisdokument zu definieren. Das RMQL-Projekt ist mit Hilfe des Dhaka-Frameworks implementiert und als Open Source verfügbar [2]. RMQL zeigt neben der eigentlichen Sprachimplementierung auch, wie bestimmte Konzepte aus dem Bereich der Virtuellen Maschinen wie Stack Frames und überprüfte Methodenaufrufe realisiert werden können.
Wer sich weitergehend mit dem Thema domänenspezifischer Sprachen beschäftigen will, dem sei das Buch „Antl3 – The ultimative Reference“ [3] empfohlen, das alle notwendigen Konzepte noch einmal tiefergehend erläutert.