Aus Linux-Magazin 01/2006

Domain Specific Languages in Ruby

© photocase.com

Domain Specific Languages vereinfachen die textbasierte Arbeit am Computer durch eine zusätzliche Abstraktionsstufe. Statt jedes Detail in einer allgemeinen Sprache auszuprogrammieren, genügen wenige Schlüsselwörter. Ruby enthält die nötigen Ausdrucksmittel für solche Vereinfachungen.

Die Aufgabe problemspezifischer Sprachen (Domain Specific Languages, DSL) ist es, möglichst intuitiv eine recht konkrete Aufgabe zu lösen. Dazu verwenden sie ein möglichst einfaches Vokabular, das zum Problemfeld passt und den Benutzer nicht überfordert. Solche kleinen Spezialsprachen haben in Unix eine lange Tradition, wie der Kasten “DSLs in der Praxis” beweist. Eine moderne Skriptsprache wie Ruby macht durch ihre flexible Syntax das Schreiben von Domain Specific Languages einfach.

Extern und intern

Eine DSL heißt extern, wenn ihre Verarbeitung die Umwandlung in eine andere Sprache oder einen speziellen Interpreter voraussetzt. Für externe DSLs kann der Entwickler eine eigene Syntax definieren, ohne sich den Regeln einer existierenden Sprache zu unterwerfen. Eine interne DSL bedient sich im Gegensatz dazu direkt der Ausdrucksmittel einer Programmiersprache.

Interne DSLs brauchen keinen eigenen Parser, die syntaktische Richtigkeit des Code überprüft der Compiler oder Interpreter. Die Entwicklungsumgebungen kennen die Sprachsyntax, rücken also beispielsweise korrekt ein und färben Sprachkonstrukte farbig.

Ruby eignet sich wegen ihrer vielfältigen Metaprogramming-Fähigkeiten, ihrer offenen Klassen und des flexiblen Objektmodells sehr gut zum Schreiben interner DSLs. Blocks erleichtern es beispielsweise, diese um eigene Sprachelemente zu erweitern und Anweisungen in einem festgelegten Kontext auszuführen. Auch die Möglichkeit, Syntaxelemente abzukürzen oder wegzulassen, erleichtert die Formulierung sauberer DSLs. Die folgenden Abschnitte stellen anhand einiger Beispiele die wichtigsten Sprachelemente für DSLs in Ruby vor.

Die Programmiersprache bietet diverse Möglichkeiten, um die syntaktische Komplexität zu minimieren. Dazu ein Beispiel im Stil der DSL von Switchtower, einem auf die Verteilung von Webanwendungen mit Ruby on Rails [1] spezialisierten Deployment-Tool. Die Switchtower-DSL lässt den Anwender Aufgaben definieren, die auf verschiedenen Servern laufen sollen:

task("backup", { "roles" => "db" })

Die Aufgabe bekommt mit dem ersten Parameter der Methode »task« den Namen »backup« zugeteilt. Manche Aufgaben sollen auf jedem Server ausgeführt werden, andere nur auf einzelnen. Die Parameter in Form eines Hash legen fest, dass die Aufgabe »backup« nur auf Servern mit der Rolle »db« laufen soll.

Besser: Weniger Klammern

In Ruby sind runde Klammern um die Variablen eines Methodenaufrufs nur zu setzen, wenn der Code sonst für den Interpreter nicht eindeutig wäre. So lässt sich die obige Aufgabe umformulieren: »task “backup”, { “roles” => “db” }«. Ruby erkennt Hashes am Ende der Parameterliste auch ohne die sonst nötigen geschweiften Klammern. Das vereinfacht den Funktionsaufruf nochmals zu »task “backup”, “roles” => “db”«.

Kommt in einem Ruby-Programm eine fixe Zeichenkette vor, empfiehlt es sich meist, sie durch ein Symbol zu ersetzen. Kommt das gleiche Symbol im Programm mehrfach vor, belegt es nur einmal Speicher und ist einfacher zu schreiben als eine normale Zeichenkette.

Statt der Anführungszeichen am Anfang und Ende des Strings ist nur ein führender Doppelpunkt notwendig. Das obige Beispiel reduziert sich damit auf »task :backup, :roles => :db«. Natürlich sind diese Änderungen kosmetischer Natur. Bei DSLs ist aber einfache Handhabung besonders wichtig, wozu auch ein klares Erscheinungsbild gehört.

Zu den wichtigsten Elementen von Ruby gehören Blocks, abgeschlossene Codebereiche, die eine Methode bei ihrem Aufruf übergeben bekommt. Im Normalfall folgt der Block nach der schließenden runden Klammer. Anfang und Ende des Blocks markieren entweder »do« und »end« oder geschweifte Klammern. Der Block läuft immer in dem Kontext ab, in dem er definiert wurde. In der Methode ruft die Anweisung »yield« den Block auf. Die Variablenzuweisung im Block findet zwischen Pipezeichen statt.

DSLs in der Praxis

Populäre Beispiele für Domain Specific Languages sind Regular Expressions, die SVG-Definition für Vektorgrafiken, der Pattern-Scanner Awk oder Konfigurationssprachen wie bei Sendmail. Allen ist gemeinsam, dass sie für einen speziellen Einsatzzweck optimiert sind. Im Unix-Bereich sind seit langem die beiden Tools Lex und Yacc auf das Erstellen und die Verarbeitung von Domain Specific Languages spezialisiert.

In der Ruby-Welt gibt es viele Beispiele für interne DSLs: Das Make-ähnliche Build-Tool Rake [2] verwendet eine DSL für die Konfiguration abhängiger Aufgaben. Das ORM-Paket Active Record [3] benutzt eine sehr einfache DSL zur Beschreibung der Beziehungen zwischen Datenbanktabellen.

Das Deployment-Werkzeug Switchtower [4] wiederum setzt eine DSL dazu ein, um notwendige Aktionen für die Verteilung einer Webanwendung zu definieren. Builder [5] schließlich ist ein Werkzeug für die Erzeugung von XML-Files und ein Stellvertreter für besonders dynamische DSLs: Das Tool fängt alle Aufruf einer Methode ab und wandelt sie in entsprechende XML-Tags um.

Blocks der Art »do |var| puts var end« lassen sich in einer DSL sehr gut verwenden, um in beschreibender Form Einstellungen vorzunehmen:

task :backup, :roles => :db do
  run "backup_command"
end

Eines der mächtigsten Features von Ruby sind die offenen Klassen. Damit ist es jederzeit möglich, Klassen und deren Methoden auch nach ihrer ursprünglichen Definition zu verändern. Das gilt sogar für die Ruby-Basisklassen. Ein gutes Beispiel für eine entsprechende Veränderung der Basisklassen findet sich im Active-Support-Modul [6]. Es überlädt unter anderem die Numeric-Klasse, um den Umgang mit Zeitangaben intuitiver zu gestalten:

class Numeric
  def minutes
    self * 60
  end
  def hours
    minutes * 60
  end
end

Als Beispiel wäre eine Anweisung der Form »5.minutes + 1.hours« denkbar. Eine kleine Änderung erhöht die Lesbarkeit der DSL noch einmal. Während »5.minutes« bereits sehr gut lesbar ist, trifft das auf »1.hours« nicht zu. Stattdessen wäre es passender, »1.hour« schreiben zu können. Da die bestehende Version programmtechnisch richtig funktioniert, fehlt nur eine Kopie der Methode. Das ist leicht mit der Ruby-Methode »alias« zu erreichen:

class Numeric
  def hours
    minutes * 60
  end
  alias :hour :hours
end

Funktionen im Griff

Häufig sollen Schlüsselwörter der DSL auf übergebene Werte reagieren. Der einfachste Weg dazu ist es, mit einer Funktion zu arbeiten, die ein übergebener Wert parametrisiert, zum Beispiel »execute :foo«. Klarer wäre es jedoch, direkt »foo« zu schreiben. Dafür kommen dem Ruby-Programmierer die Methoden »method_missing« und »constant_missing« zu Hilfe: Wann immer eine nicht existierende Methode aufgerufen wird, gibt der Ruby-Interpreter einen »NameError« aus (oder wenn ihm klar ist, dass es sich um eine Methode handelt, einen »NoMethodError«). Davor ruft er jedoch die Methode »method_missing« auf, die dann die Ausnahme auslöst.

Dies Verhalten kann ein Programmierer ausnutzen und »method_missing« einfach neu definieren. Die selbst geschriebene Methode wertet den Namen der nicht existierenden Methode aus und reagiert entsprechend darauf. Natürlich sollte die Methode die Fälle, in denen die DSL nicht zuständig ist, weiter mit einer Ausnahme abfangen. Am einfachsten ruft sie dazu das ursprüngliche »method_missing« auf (Listing 1).

Dieses Beispiel definiert die »method_missing«-Methode der Klasse »Example« neu. Zunächst kopiert es per »alias« das ursprüngliche »method_missing«. In der Methodendefinition stehen drei Argumente: »sym« für das Symbol der fehlenden Methode, also beispielsweise »:foo« für »foo«. Da die Anzahl der Argumente, die an die fehlende Methode übergeben wurden, nicht vorhersehbar ist, besteht das zweite Argument aus einer Parameterliste. Das erreicht in Ruby ein Stern vor dem Variablennamen am Ende der Parameterliste: »*args«.

Nach diesem Element dürfen keine weiteren Parameter folgen – Ausnahme ist (drittens) die Übergabe eines Blocks, angezeigt durch ein »&« vor dem Variablennamen: »&block«. Das stellt sicher, dass die fehlende Methode alle übergebenen Informationen auswerten kann.

Ein Problem entsteht bei dieser Vorgehensweise, wenn die Methoden der DSL so heißen, dass ein Namenskonflikt mit einer existierenden Methode auftritt – jedes Objekt in Ruby besitzt ja eine Vielzahl an Methoden, die es vom Basisobjekt vererbt bekommt. In diesem Fall wird »method_missing« nicht aufgerufen und das Programm funktioniert nicht wie erwartet. Ein Aufruf von »undef_method« entfernt die namensgleichen Methoden aus der Basisklasse. Ein gutes Beispiel dafür ist »BlankSlate« aus dem Builder von Jim Weirich [5] (siehe Kasten “In der Praxis”). »undef_method« befreit diese Klasse von fast allen Methoden, »method_added« schirmt Kernelmodul und Basisobjekt vor dynamisch hinzugefügten Methoden ab.

Ruby interpretiert normalerweise alles, was mit einem Großbuchstaben beginnt, als Konstante. Wenn dies für einen der dynamischen Methodennamen zutrifft, funktioniert »method_missing« ebenfalls nicht mehr. Stattdessen gibt Ruby einen »NameError« aus. Die Lösung verbirgt sich in diesem Fall hinter »constant_missing«, einer Klassenmethode. Wenn Ruby einen Aufruf als Methode identifiziert, interpretiert sie ihn auch korrekt. Das ist immer dann der Fall, wenn Parameter übergeben werden oder dem Aufruf die typischen runden Klammern folgen.

Arbeiten lassen

Der Artikel hat bereits angedeutet, wie praktisch die Übergabe von Blocks an eine Methode für eine intuitive DSL sein kann. Die Frage ist nun, wie die Methode den Block ausführt. Mit »yield« führt Ruby den Block in seinem ursprünglichen Kontext aus. In einer DSL möchte man den Block allerdings stattdessen oft dazu verwenden, um mit den Methoden in der abstrahierenden Klasse zu arbeiten. Die einfachste Möglichkeiten dazu ist es, im Block direkt Bezug auf die Instanz zu nehmen:

instanz.methode do
  instanz.foo
end

Diese Variante lässt sich noch abkürzen, indem Yield als Parameter »self« erhält. Im Block genügt dann bereits eine Kurzvariante:

instanz.methode do |i|
  i.foo
end

Das ist zwar kürzer, aber immer noch nicht so intuitiv, wie es bei einer DSL sein soll. Jetzt kommt eine weitere wichtige Methode ins Spiel: »instance_eval« ist dazu da, Ruby-Code im Kontext einer Instanz auszuführen, entweder mit einem String oder mit einem Block aufgerufen. Eine DSL kann den Aufruf von »instance_eval« etwa dazu verwenden, einen übergebenen Block im Kontext der abstrahierenden Klasse auszuführen. Dadurch darf der Anwender im Block die Kurzform für Anweisungen benutzen, weil die Instanz wegfällt:

instanz.methode do
  foo
end

Die Methode selbst verwendet dazu statt des üblichen »yield« die Anweisung »instance_eval &block«, zum Beispiel folgendermaßen:

def methode(&block)
  instance_eval &block
end

Eine weitere interessante Anwendung besteht darin, mit »instance_eval« eine ganze Datei auszuführen. Das hält die DSL besonders klar und kurz, da alle administrativen Tätigkeiten wie das Laden benötigter Bibliotheken oder das Instanziieren von Klassen an eine andere Stelle ausgelagert sind – so arbeitet auch die DSL von Switchtower.

Die Methode »task« gehört zu einer eigenen Klasse, die eine Konfigurationsdatei lädt und per »instance_eval« ausführt. In der Datei selbst sind die Befehle dadurch ausschließlich anwendungsspezifisch. Der einzige entstehende Nachteil besteht darin, dass es sich hierbei um keine selbstständig ausführbare Ruby-Datei mehr handelt. Abbildung 1 zeigt in einer vereinfachten schematischen Darstellung die Umsetzung dieses Beispiels.

Listing 1:
»method_missing«

01 class Example
02   alias :altes_method_missing :method_missing
03   def method_missing(sym, *args, &block)
04     if sym.to_s =~ regex
05       # Sonderbehandlung für fehlende
06       # Methodennamen nach dem Muster regex
07     else
08       altes_method_missing(sym, *args, &block)
09     end
10   end
11 end

Listing 2:
Basisklasse

01 class Basisklasse
02   def self.belongs_to(beziehungsname)
03     define_method("#{beziehungsname}=") do
04       # neue Datenbank-Beziehung herstellen
05     end
06     define_method("#{beziehungsname}") do
07       # aktuelle Beziehung anzeigen
08     end
09   end
10 end
Abbildung 1: Switchtower lädt über die Methode »load« des »Configuration«-Interface eine DSL-Datei und führt die Task aus.

Abbildung 1: Switchtower lädt über die Methode »load« des »Configuration«-Interface eine DSL-Datei und führt die Task aus.

Zur Laufzeit

Häufig versucht man mit einer DSL Problemstellungen zu lösen, deren Werte sich bei jeder Anwendung ändern. Es reicht dann nicht mehr aus, nur die Parameter einer Klasse über die DSL einzustellen. Der Ansatz über »method_missing« ist dazu nur beschränkt nutzbar, da in diesem Fall alle Aufrufe über eine einzelne Methode laufen, die mit der Zeit immer komplexer wird.

Die Lösung dafür liefern zur Laufzeit mit »define_method« definierte Methoden. Interessant ist diese Technik, wenn sie Unterklassen mit eigenen Methoden spezialisiert. Die in Active Record verwendete DSL zur Beschreibung der Beziehungen zwischen Datenbanktabellen zeigt besonders gut die Möglichkeiten von »define_method«.

Ein vereinfachtes Beispiel für die Beziehung zwischen Autoren und Büchern: Ein Autor gehört zu einer beliebigen Anzahl von Büchern, ein Buch gehört zu einem Autor. In der problemspezifischen Sprache von Active Record ist diese Beziehung sehr einfach auszudrücken (auf Englisch, um den Pluralisierungsregeln von Active Record gerecht zu werden):

class Author < ActiveRecord::Base
  has_many :books
end
class Book < ActiveRecord::Base
  belongs_to :author
end

Hinter den Kulissen erzeugt Ruby nun für beide Klassen neue Methoden, beispielsweise für die Klasse »Book« die Methoden »author« und »author=«, um den Autor anzuzeigen beziehungsweise um einen Autor zuzuweisen.

Dieses Beispiel zeigt, wie viel Komplexität eine gute DSL verbergen kann. Die Umsetzung profitiert davon, dass in Ruby auch Klassendefinitionen bereits ausführbaren Code enthalten können. Eine vereinfachte Umsetzung einer solchen Klassenmethode kann aussehen wie in Listing 2.

Bei einem Aufruf der Klassenmethode »belongs_to« definiert »define_method« eine neue Methode. Dem übergebenen Namen folgt ein »=«, aus »author« wird also die Methode »author=«. Im Block muss sich dann der entsprechende Code für die Zuweisung der Datenbankbeziehung befinden. Die Vorgehensweise wiederholt sich innerhalb der Klassenmethode für alle weiteren benötigten Methoden mit zusätzlichen Aufrufen von »define_method«.

Fazit

Für kaum eine andere Anwendung ist es so nützlich, die beschriebenen Sprachelemente im Detail zu kennen, wie für die Erstellung einer DSL. Sie helfen dem Programmierer die Vorteile einer internen DSL zu nutzen und trotzdem die Syntax – ähnlich einer externen DSL – möglichst frei und intuitiv zu gestalten.

Zugunsten einer möglichst sauberen und einfach bedienbaren DSL ist die erhöhte Komplexität auf Seiten der Abstraktion erträglich. Die DSL soll dabei eine möglichst intuitive Beschreibung des Problemfelds gestatten. Ruby eignet sich sehr gut für die Erstellung interner DSLs, vor allem wegen des flexiblen Objektmodells und der Möglichkeit, Syntaxelemente einzusparen. (ofr)

Infos

[1] Armin Röhrl, “Wie auf Schienen”: Linux-Magazin 12/04, S. 100

[2] Rake: [http://rake.rubyforge.org]

[3] Active Record: [http://ar.rubyonrails.com/]

[4] Switchtower: [http://manuals.rubyonrails.com/read/book/17]

[5] Builder: [http://builder.rubyforge.org]

[6] Active Support: [http://as.rubyonrails.com/]

Der Autor

Michael Raidel arbeitet in Salzburg als Programmierer mit dem Schwerpunkt Webapplikationen. [http://www.induktiv.at/]

LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben