Domänenspezifische Sprachen, domain specific languages (DSLs) sind in der IT- und Ruby-Welt sehr verbreitet und werden (teilweise unbewusst) täglich benutzt. Man denke an Make, Rake, acts_as_state_machine und rSpec. An einem kleinen Beispiel soll gezeigt werden wie man selbst einfach DSLs bauen kann.
Mit DSLs will man folgende Ziele erreichen:
- weniger Redundanz
- deklarative Beschreibung eines Sachverhaltes
- bessere Lesbarkeit
- weniger technischer Code
- leichte Erlernbarkeit aufgrund des beschränkten Umfangs
Die DSLs lassen sich grob in zwei Klassen unterteilen:
Externe DSLs werden durch einen, meist speziell dafür geschriebenen, Parser eingelesen und dann interpretiert.
Vorteile:
- Die DSL kann vollkommen frei aufgebaut werden.
- Ein Dokument der DSL wird Interpretiert (gelangt also nicht direkt zur Ausführung) wodurch mögliche Sicherheitslücken vermieden werden.
Nachteile:
- Für jede DSL muss ein Parser geschrieben werden.
- Enthält das Dokument dynamische Elemente müssen diese ebenfalls geparst und interpretiert werden.
Beispiele für DSLs sind eine Reihe von Konfigurationsdateien:
crontab, Makefile ,…
Neuere Systeme gehen dazu über die DSL in From von XML Dateien zu verarbeiten. Das hat den Vorteil, dass ein gewöhnlicher XML-Parser verwendet werden kann. Der Nachteil ist, dass die DSL nicht mehr frei ist und evtl. mehr Tipparbeit erforderlich ist (hohe Redundanz). Dynamische Inhalte müssen ebenfalls nachträglich interpretiert werden.
Interne DSL benutzen Komponenten der Wirtssprache um die DSL zu parsen und auszuwerten. Die Vor/Nachteile sind genau umgekehrt zu den externen DSL.
In der Ruby Gemeinschaft werden, da Ruby selbst eine sehr freie Syntax hat, meist interne DSLs verwendet. An einem Beispiel soll die Implementierung einer DSL erläutert werden.
Der folgende Code stellt die Klassen Lexicon, Word und Wordform zur Verfügung. Damit soll der Aufbau eines einfachen Lexikons möglich sein.
class Lexicon def initialize @words={} end def add_word(word) @words[word.uname]=word end def show @words.each_value do |word| puts word end end end class Word attr_reader :uname, :forms, :categories def initialize(uname) @uname=uname @forms=[] end def add_form(form) @forms << form end def to_s forms.inject("#{uname}:\n") do |out, form| out << form.to_s << "\n" end end end class WordForm attr_reader :form, :categories def initialize(form, categories) @form=form @categories=categories end def is?(value) @categroies.values.include?(value.to_s) end def to_s "#{form} - #{categories.inspect}" end end
Ein Lexikon lässt sich wie folgt aufbauen:
l=Lexicon.new w=Word.new('gehen') wf=WordForm.new('ging', :tempus => :imperfect, :numerus => :sg3, :genus => :m) wf2=WordForm.new('ging', :tempus => :imperfect, :numerus => :sg1) w.add_form wf w.add_form wf2 l.add_word(w)
Zum Testen:
l.show gehen: ging - { :tempus => :imperfect, :numerus => :sg3, :genus => :m } ging - { :tempus => :imperfect, :numerus => :sg1 }
Der erste Ansatz
Der erste Schritt zur Vereinfachung des Aufbaus soll wie folgt aussehen:
l=Lexicon.new do |lex| lex.word('gehen') do |word| word.form('ging', :tempus => :imperfect, :numerus => :sg3, :genus => :m) word.form('ging', :tempus => :imperfect, :numerus => :sg1) end end
Dafür müssen die Klassen erweitert/ergänzt werden:
class Lexicon def initialize @words={} yield self end def word(*attr) wrd=Word.new(*attr) yield wrd add_word(wrd) end end class Word def form(*attr) frm=WordForm.new(*attr) add_form(frm) end end
Drei Dinge sind an diesem Ansatz “unvorteilhaft”:
- Die Verknüpfung der Klassen untereinander durch Aufruf der Konstruktoren (Lexikon <-> Word und Word <-> WordForm). Der Nachteil wird bei der Vererbung der Klassen sehr deutlich.
- Die klare Struktur der Klassen wurde durch die Methoden, die für die DSL benötigt werden verunreinigt. Die DSL sollte auf dem Model aufsetzen und diese so wenig wie möglich beeinflussen.
- Die DSL ist noch immer “unschön”. Am besten wäre es, wenn das Lexikon sich wie folgt aufbauen liesse:
l=lexicon do word 'gehen' do form 'ging', :tempus => :imperfect, :numerus => :sg3, :genus => :m form 'ging', :tempus => :imperfect, :numerus => :sg1 end end
Der richtige Weg
Wir wenden uns also wieder der ursprünglichen Form zu und führen eine neue Klasse LexiconBuilder ein:
class LexiconBuilder attr_reader :lexicon def initialize(lexicon) @lexicon=lexicon end def word(*attr) @word=Word.new(*attr) yield @lexicon.add_word(@word) nil end def form(*attr) frm=WordForm.new(*attr) @word.add_form(frm) nil end end
Dann benötigen wir noch die Definition von lexicon:
def lexicon(&block) lexikon=Lexicon.new builder=LexiconBuilder.new(lexikon) builder.instance_eval &block lexikon end
Hier liegt ein wenig Ruby Magie in der Luft. Nachdem das Lexicon und der LexiconBuilder erzeugt werden, wird der übergebene Block im Kontext des Builders ausgeführt. Jegliche Methodenaufrufe erfolgen also auf der Instanz von LexiconBuilder.
LexiconBuilder übernimmt hier die Rolle des Parsers. Es stellt alle (word, form) syntaktischen Elemente der DSL bereit und baut die Struktur wie gewünscht auf.
Die Vorteile liegen auf der Hand:
- Keine Einführung neuer Abhängigkeiten in bestehenden Klassen
- Alle Ahängigkeiten sind in LexiconBuilder gekappselt, LexiconBuilder lässt sich austauschen um eine andere DSL zu unterstützen.
Dieser relativ einfache Builder hat den Nachteil, dass es die “Syntax”, in unserem Fall die Abfolge der Methodenaufrufe, nicht überprüft. So wäre das folgende Programm durchaus korrekt, würde aber falsche Ergebnisse liefern:
l=lexicon do word 'gehen' do word 'foobar' do form 'ging',:tempus => :imperfect, :numerus => :sg3, :genus => :m form 'ging',:tempus => :imperfect, :numerus => :sg1 end end end
Wie man sieht, ist der Aufbau von DSLs keine Raketenwissenschaft und sollte, wenn der Code “häßlich” wird, auf jeden Fall in Betracht gezogen werden.
Schlagworte: DSL


3. März 2009 um 11:34
Sehr spannender Artikel – vielen Dank!
5. März 2009 um 08:42
Danke sehr – das ist doch elegant gemacht.