Erstellen einer Ruby-DSL: Ein Leitfaden für fortgeschrittene Metaprogrammierung
Veröffentlicht: 2022-03-11Domänenspezifische Sprachen (DSL) sind ein unglaublich leistungsfähiges Werkzeug, um die Programmierung oder Konfiguration komplexer Systeme zu vereinfachen. Sie sind auch überall – als Softwareentwickler verwenden Sie höchstwahrscheinlich täglich mehrere verschiedene DSLs.
In diesem Artikel erfahren Sie, was domänenspezifische Sprachen sind, wann sie verwendet werden sollten und schließlich, wie Sie mithilfe fortschrittlicher Metaprogrammierungstechniken Ihre eigene DSL in Ruby erstellen können.
Dieser Artikel baut auf Nikola Todorovics Einführung in die Ruby-Metaprogrammierung auf, die ebenfalls im Toptal-Blog veröffentlicht wurde. Wenn Sie neu in der Metaprogrammierung sind, sollten Sie das zuerst lesen.
Was ist eine domänenspezifische Sprache?
Die allgemeine Definition von DSLs ist, dass sie Sprachen sind, die auf eine bestimmte Anwendungsdomäne oder einen bestimmten Anwendungsfall spezialisiert sind. Das bedeutet, dass Sie sie nur für bestimmte Dinge verwenden können – sie eignen sich nicht für die allgemeine Softwareentwicklung. Wenn das weit gefasst klingt, liegt es daran, dass es DSLs in vielen verschiedenen Formen und Größen gibt. Hier sind einige wichtige Kategorien:
- Auszeichnungssprachen wie HTML und CSS wurden entwickelt, um bestimmte Dinge wie die Struktur, den Inhalt und den Stil von Webseiten zu beschreiben. Mit ihnen lassen sich keine beliebigen Algorithmen schreiben, sie passen also zur Beschreibung einer DSL.
- Makro- und Abfragesprachen (z. B. SQL) setzen auf einem bestimmten System oder einer anderen Programmiersprache auf und sind normalerweise in ihren Möglichkeiten eingeschränkt. Daher gelten sie offensichtlich als domänenspezifische Sprachen.
- Viele DSLs haben keine eigene Syntax, sondern verwenden auf clevere Weise die Syntax einer etablierten Programmiersprache, die sich wie eine separate Minisprache anfühlt.
Diese letzte Kategorie wird als interne DSL bezeichnet, und eine davon werden wir sehr bald als Beispiel erstellen. Aber bevor wir darauf eingehen, werfen wir einen Blick auf ein paar bekannte Beispiele für interne DSLs. Die Routendefinitionssyntax in Rails ist eine davon:
Rails.application.routes.draw do root to: "pages#main" resources :posts do get :preview resources :comments, only: [:new, :create, :destroy] end end
Dies ist Ruby-Code, aber er fühlt sich eher wie eine benutzerdefinierte Routendefinitionssprache an, dank der verschiedenen Metaprogrammierungstechniken, die eine so saubere, einfach zu verwendende Schnittstelle ermöglichen. Beachten Sie, dass die Struktur der DSL mithilfe von Ruby-Blöcken implementiert wird und Methodenaufrufe wie get
und resources
zum Definieren der Schlüsselwörter dieser Minisprache verwendet werden.
Metaprogrammierung wird noch stärker in der RSpec-Testbibliothek verwendet:
describe UsersController, type: :controller do before do allow(controller).to receive(:current_user).and_return(nil) end describe "GET #new" do subject { get :new } it "returns success" do expect(subject).to be_success end end end
Dieses Stück Code enthält auch Beispiele für fließende Schnittstellen , die es ermöglichen, Deklarationen als einfache englische Sätze laut vorzulesen, was es viel einfacher macht, zu verstehen, was der Code tut:
# Stubs the `current_user` method on `controller` to always return `nil` allow(controller).to receive(:current_user).and_return(nil) # Asserts that `subject.success?` is truthy expect(subject).to be_success
Ein weiteres Beispiel für eine fließende Schnittstelle ist die Abfrageschnittstelle von ActiveRecord und Arel, die intern einen abstrakten Syntaxbaum zum Erstellen komplexer SQL-Abfragen verwendet:
Post. # => select([ # SELECT Post[Arel.star], # `posts`.*, Comment[:id].count. # COUNT(`comments`.`id`) as("num_comments"), # AS num_comments ]). # FROM `posts` joins(:comments). # INNER JOIN `comments` # ON `comments`.`post_id` = `posts`.`id` where.not(status: :draft). # WHERE `posts`.`status` <> 'draft' where( # AND Post[:created_at].lte(Time.now) # `posts`.`created_at` <= ). # '2017-07-01 14:52:30' group(Post[:id]) # GROUP BY `posts`.`id`
Obwohl die saubere und ausdrucksstarke Syntax von Ruby zusammen mit seinen Metaprogrammierfähigkeiten es einzigartig für die Erstellung domänenspezifischer Sprachen geeignet macht, existieren DSLs auch in anderen Sprachen. Hier ist ein Beispiel für einen JavaScript-Test mit dem Jasmine-Framework:
describe("Helper functions", function() { beforeEach(function() { this.helpers = window.helpers; }); describe("log error", function() { it("logs error message to console", function() { spyOn(console, "log").and.returnValue(true); this.helpers.log_error("oops!"); expect(console.log).toHaveBeenCalledWith("ERROR: oops!"); }); }); });
Diese Syntax ist vielleicht nicht so sauber wie die der Ruby-Beispiele, aber sie zeigt, dass mit geschickter Benennung und kreativem Einsatz der Syntax interne DSLs mit fast jeder Sprache erstellt werden können.
Der Vorteil interner DSLs besteht darin, dass sie keinen separaten Parser benötigen, dessen ordnungsgemäße Implementierung notorisch schwierig sein kann. Und da sie die Syntax der Sprache verwenden, in der sie implementiert sind, lassen sie sich auch nahtlos in den Rest der Codebasis integrieren.
Was wir dafür aufgeben müssen, ist syntaktische Freiheit – interne DSLs müssen in ihrer Implementierungssprache syntaktisch gültig sein. Wie viel Kompromisse Sie dabei eingehen müssen, hängt maßgeblich von der gewählten Sprache ab, wobei wortreiche, statisch typisierte Sprachen wie Java und VB.NET am einen Ende des Spektrums stehen und dynamische Sprachen mit umfangreichen Metaprogrammierungsfähigkeiten wie Ruby am anderen Ende des Spektrums stehen Ende.
Eigene bauen – Eine Ruby-DSL für die Klassenkonfiguration
Die Beispiel-DSL, die wir in Ruby bauen werden, ist eine wiederverwendbare Konfigurations-Engine zur Angabe der Konfigurationsattribute einer Ruby-Klasse mit einer sehr einfachen Syntax. Das Hinzufügen von Konfigurationsfunktionen zu einer Klasse ist eine sehr häufige Anforderung in der Ruby-Welt, insbesondere wenn es um die Konfiguration externer Gems und API-Clients geht. Die übliche Lösung ist eine Schnittstelle wie diese:
MyApp.configure do |config| config.app_ config.title = "My App" config.cookie_name = "my_app_session" end
Lassen Sie uns zuerst diese Schnittstelle implementieren – und sie dann als Ausgangspunkt verwenden, können wir sie Schritt für Schritt verbessern, indem wir weitere Funktionen hinzufügen, die Syntax bereinigen und unsere Arbeit wiederverwendbar machen.
Was brauchen wir, damit diese Schnittstelle funktioniert? Die MyApp
-Klasse sollte eine configure
-Klassenmethode haben, die einen Block übernimmt und diesen Block dann ausführt, indem sie ihm nachgibt und ein Konfigurationsobjekt mit Zugriffsmethoden zum Lesen und Schreiben der Konfigurationswerte übergibt:
class MyApp # ... class << self def config @config ||= Configuration.new end def configure yield config end end class Configuration attr_accessor :app_id, :title, :cookie_name end end
Sobald der Konfigurationsblock ausgeführt wurde, können wir einfach auf die Werte zugreifen und diese ändern:
MyApp.config => #<MyApp::Configuration:0x2c6c5e0 @app_, @title="My App", @cookie_name="my_app_session"> MyApp.config.title => "My App" MyApp.config.app_ => "not_my_app"
Bisher fühlt sich diese Implementierung nicht wie eine angepasste Sprache an, um als DSL betrachtet zu werden. Aber gehen wir einen Schritt nach dem anderen vor. Als Nächstes entkoppeln wir die Konfigurationsfunktionalität von der MyApp
-Klasse und machen sie generisch genug, um in vielen verschiedenen Anwendungsfällen verwendet werden zu können.
Wiederverwendbar machen
Wenn wir jetzt ähnliche Konfigurationsmöglichkeiten zu einer anderen Klasse hinzufügen wollten, müssten wir sowohl die Configuration
-Klasse als auch die zugehörigen Setup-Methoden in diese andere Klasse kopieren und die attr_accessor
Liste bearbeiten, um die akzeptierten Konfigurationsattribute zu ändern. Um dies zu vermeiden, verschieben wir die Konfigurationsfunktionen in ein separates Modul namens Configurable
. Damit sieht unsere MyApp
-Klasse folgendermaßen aus:
class MyApp #BOLD include Configurable #BOLDEND # ... end
Alles, was mit der Konfiguration zu tun hat, wurde in das Configurable
Modul verschoben:
#BOLD module Configurable def self.included(host_class) host_class.extend ClassMethods end module ClassMethods #BOLDEND def config @config ||= Configuration.new end def configure yield config end #BOLD end #BOLDEND class Configuration attr_accessor :app_id, :title, :cookie_name end #BOLD end #BOLDEND
Hier hat sich bis auf die neue Methode self.included
nicht viel geändert. Wir brauchen diese Methode, weil das Einschließen eines Moduls nur seine Instanzmethoden einmischt, sodass unsere Konfigurations- und configure
nicht standardmäßig zur config
hinzugefügt werden. Wenn wir jedoch eine spezielle Methode mit dem Namen „ included
“ für ein Modul definieren, ruft Ruby sie immer dann auf, wenn dieses Modul in einer Klasse enthalten ist. Dort können wir die Host-Klasse manuell mit den Methoden in ClassMethods
:
def self.included(host_class) # called when we include the module in `MyApp` host_class.extend ClassMethods # adds our class methods to `MyApp` end
Wir sind noch nicht fertig – unser nächster Schritt besteht darin, die Angabe der unterstützten Attribute in der Hostklasse zu ermöglichen, die das Configurable
Modul enthält. Eine Lösung wie diese würde gut aussehen:
class MyApp #BOLD include Configurable.with(:app_id, :title, :cookie_name) #BOLDEND # ... end
Vielleicht etwas überraschend ist, dass der obige Code syntaktisch korrekt ist – include
ist kein Schlüsselwort, sondern einfach eine reguläre Methode, die ein Module
-Objekt als Parameter erwartet. Solange wir ihm einen Ausdruck übergeben, der ein Module
zurückgibt, wird es ihn gerne enthalten. Anstatt Configurable
direkt einzubinden, benötigen wir also eine Methode mit dem Namen with
on, die ein neues Modul generiert, das mit den angegebenen Attributen angepasst wird:
module Configurable #BOLD def self.with(*attrs) #BOLDEND # Define anonymous class with the configuration attributes #BOLD config_class = Class.new do attr_accessor *attrs end #BOLDEND # Define anonymous module for the class methods to be "mixed in" #BOLD class_methods = Module.new do define_method :config do @config ||= config_class.new end #BOLDEND def configure yield config end #BOLD end #BOLDEND # Create and return new module #BOLD Module.new do singleton_class.send :define_method, :included do |host_class| host_class.extend class_methods end end end #BOLDEND end
Hier gibt es viel auszupacken. Das gesamte Configurable
-Modul besteht jetzt nur noch aus einer einzigen with
-Methode, wobei alles innerhalb dieser Methode geschieht. Zuerst erstellen wir mit Class.new
eine neue anonyme Klasse, die unsere Attributzugriffsmethoden enthält. Da Class.new
die Klassendefinition als Block nimmt und Blöcke Zugriff auf externe Variablen haben, können wir die attrs
Variable problemlos an attr_accessor
.
def self.with(*attrs) # `attrs` is created here # ... config_class = Class.new do # class definition passed in as a block attr_accessor *attrs # we have access to `attrs` here end
Die Tatsache, dass Blöcke in Ruby Zugriff auf externe Variablen haben, ist auch der Grund, warum sie manchmal als Closures bezeichnet werden, da sie die äußere Umgebung, in der sie definiert wurden, einschließen oder „abschließen“. Beachten Sie, dass ich den Ausdruck „definiert in“ verwendet habe. und nicht „hingerichtet in“. Das ist richtig – unabhängig davon, wann und wo unsere define_method
schließlich ausgeführt werden, sie werden immer auf die Variablen config_class
und class_methods
, selbst nachdem die with
-Methode ausgeführt wurde und zurückgekehrt ist. Das folgende Beispiel demonstriert dieses Verhalten:
def create_block foo = "hello" # define local variable return Proc.new { foo } # return a new block that returns `foo` end block = create_block # call `create_block` to retrieve the block block.call # even though `create_block` has already returned, => "hello" # the block can still return `foo` to us
Jetzt, da wir dieses nette Verhalten von Blöcken kennen, können wir fortfahren und ein anonymes Modul in class_methods
für die Klassenmethoden definieren, die der Hostklasse hinzugefügt werden, wenn unser generiertes Modul eingebunden wird. Hier müssen wir define_method
verwenden, um die config
-Methode zu definieren, da wir Zugriff auf die externe config_class
Variable innerhalb der Methode benötigen. Das Definieren der Methode mit dem Schlüsselwort def
würde uns diesen Zugriff nicht geben, da reguläre Methodendefinitionen mit def
keine Closures sind – aber define_method
nimmt einen Block, also funktioniert das:

config_class = # ... # `config_class` is defined here # ... class_methods = Module.new do # define new module using a block define_method :config do # method definition with a block @config ||= config_class.new # even two blocks deep, we can still end # access `config_class`
Schließlich rufen wir Module.new
auf, um das Modul zu erstellen, das wir zurückgeben werden. Hier müssen wir unsere Methode self.included
definieren, aber leider können wir das nicht mit dem Schlüsselwort def
tun, da die Methode Zugriff auf die externe Variable class_methods
benötigt. Daher müssen wir erneut define_method
mit einem Block verwenden, diesmal jedoch auf der Singleton-Klasse des Moduls, da wir eine Methode auf der Modulinstanz selbst definieren. Oh, und da define_method
eine private Methode der Singleton-Klasse ist, müssen wir send
verwenden, um sie aufzurufen, anstatt sie direkt aufzurufen:
class_methods = # ... # ... Module.new do singleton_class.send :define_method, :included do |host_class| host_class.extend class_methods # the block has access to `class_methods` end end
Puh, das war schon ziemlich Hardcore-Metaprogrammierung. Aber hat sich die zusätzliche Komplexität gelohnt? Sehen Sie sich die einfache Handhabung an und entscheiden Sie selbst:
class SomeClass include Configurable.with(:foo, :bar) # ... end SomeClass.configure do |config| config.foo = "wat" config.bar = "huh" end SomeClass.config.foo => "wat"
Aber wir können noch besser werden. Im nächsten Schritt werden wir die Syntax des configure
-Blocks ein wenig aufräumen, um unser Modul noch komfortabler zu machen.
Aufräumen der Syntax
Es gibt noch eine letzte Sache, die mich an unserer aktuellen Implementierung stört – wir müssen config
in jeder einzelnen Zeile im Konfigurationsblock wiederholen. Eine richtige DSL würde wissen, dass alles innerhalb des configure
-Blocks im Kontext unseres Konfigurationsobjekts ausgeführt werden sollte, und es uns ermöglichen, dasselbe damit zu erreichen:
MyApp.configure do app_id "my_app" title "My App" cookie_name "my_app_session" end
Lassen Sie es uns umsetzen, sollen wir? So wie es aussieht, brauchen wir zwei Dinge. Zuerst brauchen wir eine Möglichkeit, den an configure
übergebenen Block im Kontext des Konfigurationsobjekts auszuführen, damit Methodenaufrufe innerhalb des Blocks an dieses Objekt gehen. Zweitens müssen wir die Accessor-Methoden so ändern, dass sie den Wert schreiben, wenn ihnen ein Argument übergeben wird, und ihn zurücklesen, wenn sie ohne Argument aufgerufen werden. Eine mögliche Implementierung sieht so aus:
module Configurable def self.with(*attrs) #BOLD not_provided = Object.new #BOLDEND config_class = Class.new do #BOLD attrs.each do |attr| define_method attr do |value = not_provided| if value === not_provided instance_variable_get("@#{attr}") else instance_variable_set("@#{attr}", value) end end end attr_writer *attrs #BOLDEND end class_methods = Module.new do # ... def configure(&block) #BOLD config.instance_eval(&block) #BOLDEND end end # Create and return new module # ... end end
Die einfachere Änderung besteht darin, den configure
-Block im Kontext des Konfigurationsobjekts auszuführen. Wenn Sie die Methode instance_eval
von Ruby für ein Objekt aufrufen, können Sie einen beliebigen Codeblock so ausführen, als ob er innerhalb dieses Objekts ausgeführt würde, was bedeutet, dass, wenn der Konfigurationsblock die Methode app_id
in der ersten Zeile aufruft, dieser Aufruf an unsere Konfigurationsklasseninstanz geht.
Die Änderung der Attributzugriffsmethoden in config_class
ist etwas komplizierter. Um es zu verstehen, müssen wir zuerst verstehen, was genau attr_accessor
hinter den Kulissen getan hat. Nehmen Sie zum Beispiel den folgenden attr_accessor
-Aufruf:
class SomeClass attr_accessor :foo, :bar end
Dies entspricht der Definition einer Lese- und Schreibmethode für jedes angegebene Attribut:
class SomeClass def foo @foo end def foo=(value) @foo = value end # and the same with `bar` end
Als wir attr_accessor *attrs
in den Originalcode schrieben, definierte Ruby die Attribut-Reader- und -Writer-Methoden für uns für jedes Attribut in attrs
– das heißt, wir erhielten die folgenden Standard-Accessor-Methoden: app_id
, app_id=
, title
, title=
und so weiter an. In unserer neuen Version wollen wir die Standard-Writer-Methoden beibehalten, damit solche Zuweisungen weiterhin richtig funktionieren:
MyApp.config.app_ => "not_my_app"
Wir können die Writer-Methoden weiterhin automatisch generieren, indem attr_writer *attrs
. Allerdings können wir die Standard-Reader-Methoden nicht mehr verwenden, da diese auch in der Lage sein müssen, das Attribut zu schreiben, um diese neue Syntax zu unterstützen:
MyApp.configure do app_id "my_app" # assigns a new value app_id # reads the stored value end
Um die Reader-Methoden selbst zu generieren, überschleifen wir das Array attrs
und definieren für jedes Attribut eine Methode, die den aktuellen Wert der passenden Instanzvariable zurückgibt, wenn kein neuer Wert bereitgestellt wird, und den neuen Wert schreibt, wenn er angegeben wird:
not_provided = Object.new # ... attrs.each do |attr| define_method attr do |value = not_provided| if value === not_provided instance_variable_get("@#{attr}") else instance_variable_set("@#{attr}", value) end end end
Hier verwenden wir die Methode instance_variable_get
von Ruby, um eine Instanzvariable mit einem beliebigen Namen zu lesen, und instance_variable_set
, um ihr einen neuen Wert zuzuweisen. Leider muss dem Variablennamen in beiden Fällen ein „@“-Zeichen vorangestellt werden – daher die String-Interpolation.
Sie fragen sich vielleicht, warum wir ein leeres Objekt als Standardwert für „nicht bereitgestellt“ verwenden müssen und warum wir für diesen Zweck nicht einfach nil
verwenden können. Der Grund ist einfach – nil
ist ein gültiger Wert, den jemand vielleicht für ein Konfigurationsattribut setzen möchte. Wenn wir auf nil
testen würden, könnten wir diese beiden Szenarien nicht auseinanderhalten:
MyApp.configure do app_id nil # expectation: assigns nil app_id # expectation: returns current value end
Dieses leere Objekt, das in not_provided
gespeichert ist, wird immer nur sich selbst entsprechen, sodass wir auf diese Weise sicher sein können, dass niemand es an unsere Methode weitergibt und einen unbeabsichtigten Lesevorgang anstelle eines Schreibvorgangs verursacht.
Unterstützung für Referenzen hinzufügen
Es gibt noch eine weitere Funktion, die wir hinzufügen könnten, um unser Modul noch vielseitiger zu machen – die Möglichkeit, ein Konfigurationsattribut von einem anderen zu referenzieren:
MyApp.configure do app_id "my_app" title "My App" cookie_name { "#{app_id}_session" } End MyApp.config.cookie_name => "my_app_session"
Hier haben wir eine Referenz von cookie_name
zum Attribut app_id
. Beachten Sie, dass der Ausdruck, der die Referenz enthält, als Block übergeben wird – dies ist notwendig, um die verzögerte Auswertung des Attributwerts zu unterstützen. Die Idee ist, den Block erst später auszuwerten, wenn das Attribut gelesen wird und nicht, wenn es definiert ist – sonst würden komische Dinge passieren, wenn wir die Attribute in der „falschen“ Reihenfolge definieren:
SomeClass.configure do foo "#{bar}_baz" # expression evaluated here bar "hello" end SomeClass.config.foo => "_baz" # not actually funny
Wenn der Ausdruck in einen Block eingeschlossen ist, verhindert dies, dass er sofort ausgewertet wird. Stattdessen können wir den Block speichern, um ihn später auszuführen, wenn der Attributwert abgerufen wird:
SomeClass.configure do foo { "#{bar}_baz" } # stores block, does not evaluate it yet bar "hello" end SomeClass.config.foo # `foo` evaluated here => "hello_baz" # correct!
Wir müssen keine großen Änderungen am Configurable
Modul vornehmen, um Unterstützung für die verzögerte Auswertung mithilfe von Blöcken hinzuzufügen. Tatsächlich müssen wir nur die Definition der Attributmethode ändern:
define_method attr do |value = not_provided, &block| if value === not_provided && block.nil? result = instance_variable_get("@#{attr}") result.is_a?(Proc) ? instance_eval(&result) : result else instance_variable_set("@#{attr}", block || value) end end
Beim Setzen eines Attributs wird der block || value
block || value
speichert den Block, wenn einer übergeben wurde, oder speichert andernfalls den Wert. Wenn das Attribut später gelesen wird, prüfen wir, ob es sich um einen Block handelt, und werten es mit instance_eval
aus, wenn dies der Fall ist, oder wenn es kein Block ist, geben wir es wie zuvor zurück.
Unterstützende Referenzen haben natürlich ihre eigenen Vorbehalte und Grenzfälle. Zum Beispiel können Sie wahrscheinlich herausfinden, was passiert, wenn Sie eines der Attribute in dieser Konfiguration lesen:
SomeClass.configure do foo { bar } bar { foo } end
Das fertige Modul
Am Ende haben wir uns ein ziemlich nettes Modul besorgt, um eine beliebige Klasse konfigurierbar zu machen und diese Konfigurationswerte dann mit einer sauberen und einfachen DSL anzugeben, mit der wir auch auf ein Konfigurationsattribut von einem anderen verweisen können:
class MyApp include Configurable.with(:app_id, :title, :cookie_name) # ... end SomeClass.configure do app_id "my_app" title "My App" cookie_name { "#{app_id}_session" } end
Hier ist die endgültige Version des Moduls, das unsere DSL implementiert – insgesamt 36 Codezeilen:
module Configurable def self.with(*attrs) not_provided = Object.new config_class = Class.new do attrs.each do |attr| define_method attr do |value = not_provided, &block| if value === not_provided && block.nil? result = instance_variable_get("@#{attr}") result.is_a?(Proc) ? instance_eval(&result) : result else instance_variable_set("@#{attr}", block || value) end end end attr_writer *attrs end class_methods = Module.new do define_method :config do @config ||= config_class.new end def configure(&block) config.instance_eval(&block) end end Module.new do singleton_class.send :define_method, :included do |host_class| host_class.extend class_methods end end end end
Wenn Sie sich all diese Ruby-Magie in einem Codestück ansehen, das fast unlesbar und daher sehr schwer zu warten ist, fragen Sie sich vielleicht, ob sich all dieser Aufwand gelohnt hat, nur um unsere domänenspezifische Sprache ein bisschen netter zu machen. Die kurze Antwort lautet: Es kommt darauf an – was uns zum letzten Thema dieses Artikels bringt.
Ruby-DSLs – wann man sie verwendet und wann nicht
Sie haben wahrscheinlich beim Lesen der Implementierungsschritte unserer DSL bemerkt, dass wir, als wir die nach außen gerichtete Syntax der Sprache sauberer und benutzerfreundlicher machten, eine immer größere Anzahl von Metaprogrammierungstricks unter der Haube anwenden mussten, um dies zu erreichen. Dies führte zu einer Implementierung, die in Zukunft unglaublich schwer zu verstehen und zu ändern sein wird. Wie so viele andere Dinge in der Softwareentwicklung ist auch dies ein Kompromiss, der sorgfältig geprüft werden muss.
Damit eine domänenspezifische Sprache ihre Implementierungs- und Wartungskosten wert ist, muss sie eine noch größere Summe an Vorteilen auf den Tisch bringen. Dies wird normalerweise erreicht, indem die Sprache in so vielen verschiedenen Szenarien wie möglich wiederverwendbar gemacht wird, wodurch sich die Gesamtkosten zwischen vielen verschiedenen Anwendungsfällen amortisieren. Frameworks und Bibliotheken enthalten eher ihre eigenen DSLs, weil sie von vielen Entwicklern verwendet werden, von denen jeder die Produktivitätsvorteile dieser eingebetteten Sprachen genießen kann.
Erstellen Sie daher grundsätzlich nur dann DSLs, wenn Sie, andere Entwickler oder die Endbenutzer Ihrer Anwendung viel Nutzen daraus ziehen werden. Wenn Sie eine DSL erstellen, stellen Sie sicher, dass Sie ihr eine umfassende Testsuite beifügen und ihre Syntax ordnungsgemäß dokumentieren, da es sehr schwierig sein kann, allein aus der Implementierung herauszukommen. In Zukunft werden Sie und Ihre Mitentwickler Ihnen dafür danken.
Weiterführende Literatur im Toptal Engineering Blog:
- Wie man einen Dolmetscher von Grund auf neu schreibt