Reguläre Ausdrücke

Richard Feynman, einer der bekanntesten Physiker des 20. Jahrhunderts, war überzeugt, dass niemand die Quantentheorie versteht. Über reguläre Ausdrücke, auch reguläre Expressionen genannt, könnte ähnliches behauptet werden. Vielleicht liegt es daran, dass diese Ausdrücke auf den ersten Blick einschüchternd aussehen können:

#?([\da-fA-F]{2})([\da-fA-F]{2})([\da-fA-F]{2})

Allerdings sind reguläre Ausdrücke nicht ganz so kontraintuitiv wie besagte Theorie und sind in der Entwicklung sowie im IT-Alltag ein Werkzeug, welches in vielen Fällen hilfreich zur Seite stehen kann, aber auch Grenzen hat.

Definition

Aus Sicht der theoretischen Informatik ist ein regulärer Ausdruck eine Methode, um Mengen von Zeichenketten mit gemeinsamen Mustern zu beschreiben. Reguläre Ausdrücke werden durch die Verwendung von verschiedenen Operatoren und Konventionen definiert, die es ermöglichen, komplexe Suchmuster zu erstellen.

Die Theorie der regulären Ausdrücke basiert auf den Konzepten der endlichen Automaten und der regulären Sprachen. Vereinfacht gesehen, kann die Funktionalität eines regulären Ausdrucks als Musterabgleich gesehen werden. Oft werden diese Ausdrücke als Regex oder Rexexp abgekürzt.

Ein einfacher regulärer Ausdruck könnte wie folgt aussehen:

[abc]

Dieser Ausdruck würde in einem Text die Zeichen a, b, und c finden. Über einen solchen regulären Ausdruck können Zeichenfolgen in Texten identifiziert, extrahiert und verarbeitet werden.

Geschichte

Die Geschichte regulärer Ausdrücke geht zurück auf das Jahr 1951, in welchem der Mathematiker Stephen Cole Kleene den Begriff prägte.

Praktische Anwendung fanden solche Ausdrücke in den 1960er-Jahren, als Ken Thompson, diese im Editor QED implementierte, als er diesen für das Compatible Time-Sharing System (CTSS) neuschrieb.

Über die Jahre wurde die Funktionalität regulärer Ausdrücke in weitere Werkzeuge wie grep und sed integriert. Im Rahmen der Unix-Philosophie, in welcher ein Werkzeug eine Aufgabe gut beherrschen soll, boten sich hiermit noch mächtigere Werkzeuge für die Textverarbeitung. Die Ausdrücke fanden nicht nur in der Textverarbeitung, sondern auch in der lexikalischen Analyse im Compiler-Design Anwendung.

Neben der Integration in solche Werkzeuge wurde vor allem beginnend mit den 1980er-Jahren die Integration regulärer Ausdrücke in Programmiersprachen wie Perl vorangetrieben. Einhergehend mit dieser Entwicklung wurde die Ausdrücke mächtiger und mehr Anwendungsfälle konnten mit diesen bearbeitet werden. Larry Wall, der Schöpfer von Perl, erweiterte die Fähigkeiten regulärer Ausdrücke erheblich und machte sie zu einem zentralen Bestandteil seiner Sprache.

In den 1990er-Jahren kam es zu einer Standardisierung von Syntax und Verhalten regulärer Ausdrücke, was die Entwicklung der Bibliothek der Perl-kompatiblen regulären Ausdrücke (PCRE) vorantrieb. Diese Bibliothek wurde in vielen Anwendungen verwendet und ist bis heute eine der am weitesten verbreiteten Implementierungen von regulären Ausdrücken.

Grundlagen

Doch wie genau werden reguläre Ausdrücke erstellt? Solche Ausdrücke können aus vielen unterschiedlichen Elementen bestehen, wie Literalen, Zeichenklassen, Quantifizierer und Gruppen.

So wäre ein regulärer Ausdruck bestehend aus einem einzigen Literal gültig:

a

Dieser Ausdruck würde hierbei auf die Vorkommen von a in einem Text matchen.
In regulären Ausdrücken können Oder-Verknüpfungen gebildet werden. So würde der Ausdruck:

a|b

entweder auf das Zeichen a oder auf das Zeichen b in einem Text matchen.

In den meisten Fällen wird allerdings nicht nach einem einzelnen Literal gematcht, sondern mit Zeichenklassen, Quantifizierern und Gruppen gearbeitet. Im Grundsatz würde allerdings nichts dagegen sprechen, einen regulären Ausdruck komplett, als Literal zu definieren:

Supercalifragilisticexpialigetisch

Diese Definition würde dazu führen, dass jedes Auftreten des Wortes Supercalifragilisticexpialigetisch gematcht werden würde. Allerdings wäre der reguläre Ausdruck in diesem Fall nicht mehr als eine einfache Suche.

Da es in regulären Ausdrücken eine Reihe von Zeichen mit spezieller Bedeutung gibt, müssen diese in bestimmten Fällen maskiert werden. Dies geschieht mit einem Backslash und dem sich anschließenden Zeichen:

\.

In diesem Beispiel würde der Punkt als normaler Punkt behandelt werden und nicht als Zeichen mit spezieller Bedeutung betrachtet werden.

Zeichenklassen

Zeichenklassen in regulären Ausdrücken erlauben es, eine Menge von Zeichen zu definieren, von denen jedes ein potenzielles Match für ein Zeichen aus dem Eingabetext darstellen kann. So würde der reguläre Ausdruck:

[abcdefghijklmnopqrstuvwxyz]

auf alle Zeichen zwischen a und z matchen. Die Zeichen werden hierbei in eckige Klammern eingefasst. Obiger Ausdruck kann allerdings sinnvoller gestaltet werden:

[a-z]

Der Bindestrich führt dazu, dass der Ausdruck als a bis z gelesen werden kann und damit einen Bereich definiert. Auch können mehrere Zeichenklassen in einem Block definiert werden:

[a-zA-Z]

Diese Zeichenklassen würde bei Buchstaben in Klein- und Großschreibung anschlagen. Bei dieser Notation ist darauf zu achten, dass die Definitionen ohne Leerzeichen aneinander gehangen werden.

Um auch deutsche Umlaute und das Eszett zu berücksichtigen, müssen diese Zeichen explizit zur Zeichenklasse hinzugefügt werden:

[a-zA-ZäöüÄÖÜß]

Standard-Zeichenklassen

Neben den selbstdefinierten Zeichenklassen existieren eine Reihe von vordefinierten Zeichenklassen, welche ebenfalls genutzt werden können.

So existiert die Zeichenklasse \d, welche allen Dezimalziffern entspricht, also 0 bis 9; dies entspricht der selbstdefinierten Zeichenklasse:

[0-9]

Die Zeichenklasse \D definiert das Gegenteil der vorherigen Klasse und matcht auf alle Nichtziffern und entspricht damit folgender Zeichenklasse:

[^0-9]

Während der Zirkumflex innerhalb einer Zeichenklasse normal genutzt werden kann, negiert er, am Anfang der Zeichenklasse stehend, diese. Eine Erweiterung dieser Standardklassen sind die Klassen \w und \W, welche für alle Wortzeichen stehen und folgenden selbstdefinierten Zeichenklassen entsprechen würden:

[a-zA-Z0-9_]
[^a-zA-Z0-9_]

Daneben existieren, abhängig von der Implementierung weitere vordefinierte Zeichenklassen, wie \s für Whitespace-Zeichen oder die Klasse \S für alle Nicht-Whitespace-Zeichen.

Metazeichen

Im Rahmen regulärer Ausdrücke sind einige Metazeichen definiert, welche unterschiedlichste Bedeutungen haben und nicht als die Zeichen selbst interpretiert werden. Sie werden verwendet, um Muster für die Textsuche und -manipulation zu definieren. Einige dieser Zeichen sollen nachfolgend vorgestellt werden.

Der Punkt ist als Metazeichen so definiert, dass er für jedes beliebige Zeichen, bis auf einen Zeilenumbruch steht. Damit würde der Ausdruck:

a.c

unter anderem auf folgende Zeichenketten matchen:

aac
abc
acc

Solange ein a gefolgt von einem beliebigen Zeichen, wiederum gefolgt von einem c im Text vorkommt, wird dieses gematcht.

Der Zirkumflex markiert den Anfang einer Zeile bzw. eines Textes. Damit würde der reguläre Ausdruck:

^Lorem Ipsum

in einem gegebenen Beispieltext:

Lorem Ipsum
	
abcdefghijklmnopqrstuvwxyz ABCDEFGHIKLMNOPQRSTUWXYZ
	
Lorem Ipsum

das Auftreten von Lorem Ipsum am Anfang des Textes matchen. Die gegenteilige Operation kann mit dem Metazeichen $ erreicht werden:

Lorem Ipsum$

In diesem Fall würde das Lorem Ipsum am Ende des Beispieltextes gefunden werden.

Es ist wichtig zu beachten, dass innerhalb einer benutzerdefinierten Zeichenklasse die meisten Metazeichen, wie der Punkt oder der Stern, ihre spezielle Bedeutung verlieren und als normale Zeichen behandelt werden. Ausnahmen sind der Zirkumflex, wenn er als erstes Zeichen in der Klasse verwendet wird, um die Klasse zu negieren, der Bindestrich, um einen Bereich anzugeben, und der Backslash, um Escape-Sequenzen zu ermöglichen.

Quantifizierer

Quantifizierer (engl. Quantifiers) in regulären Ausdrücken sind spezielle Metazeichen, die angeben, wie oft das vorangehende Element in einem Textmuster vorkommen muss, um eine Übereinstimmung zu erzielen. Sie sind entscheidend, um die Flexibilität der Mustererkennung zu erhöhen.

Gegeben sei folgende Telefonnummer:

0176/04069015

Für diese Nummer könnte ein regulärer Ausdruck erstellt werden:

[0-9][0-9][0-9][0-9]/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]

In dem Beispiel wurde auf Standardzeichenklassen verzichtet. Stattdessen wurden eigene Zeichenklassen definiert. Durch diesen Ausdruck werden die ersten vier Ziffern, dann ein Slash und anschließend die folgenden Ziffern gematcht.

Allerdings ist dieser Ausdruck weder sonderlich elegant, noch deckt er das Problem vollständig ab. Immerhin können Telefonnummern unterschiedlich lang sein und auch der Slash könnte optional sein. Hier kommen Quantifizierer zum Einsatz, von denen es eine Vielzahl mit diversen Anwendungsoptionen gibt.

Der Stern-Quantifizierer definiert, dass das vorangehende Element null- oder mehrmals vorkommt. So würde der Ausdruck:

a*b

unter anderem auf folgende Zeichenketten matchen:

ab
aab
aaab
b

Die Anzahl der a’s vor dem Zeichen b sind hierbei unerheblich.

Das Plus-Quantifizierer definiert, dass das vorangehende Element mindestens einmal vorkommen muss. Damit würde der Ausdruck:

a+b

unter anderem auf folgende Zeichenketten matchen:

ab
aab
aaab

Ein Match auf eine Zeichenkette nur bestehend aus einem b wäre bei diesem Quantifizierer ausgeschlossen.

Zu dieser Gruppe von Quantifizierern gehört auch das Fragezeichen. Bei diesem kann das vorangehende Element null- oder einmal vorkommen. Damit würde der Ausdruck:

a?b

unter anderem auf folgende Zeichenketten matchen:

ab
b

Ein Match auf eine Zeichenkette wie aab wäre bei dieser Variante ausgeschlossen.

Daneben existieren eine Reihe von komplexeren Quantifizierer wie den geschweiften Klammern, mit denen eine bestimmte Anzahl von Wiederholungen definiert werden kann. Der Ausdruck:

a{3}b

wurde spezifizieren, dass drei a’s hintereinander folgend von einem b gesucht werden. Damit würde dieser Ausdruck auf folgende Zeichenkette matchen:

aaab

Eine Erweiterung dieser Variante ist die Nutzung von Bereichen:

a{2,4}b

Hiermit würden alle Zeichenketten matchen bei denen zwischen zwei und vier a’s enthalten sind:

aab
aaab
aaaab

Eine weitere Abwandlung dieser Notationen ist es festzulegen, wie oft ein Element mindestens vorkommen muss:

a{2,}b

In diesem Fall müsste das Zeichen a mindestens zweimal vorkommen.

Mithilfe dieser Quantifizierer könnte der Ausdruck zum Match obiger Telefonnummer nun wesentlich vereinfacht werden:

[0-9]+/?[0-9]+

Damit ist der Ausdruck so definiert, dass eine beliebige Anzahl an Ziffern vorkommen können, gefolgt von einem optionalen Slash, wiederum gefolgt von einer beliebigen Anzahl an Ziffern.

Allerdings zeigt sich hier auch eine der Grenzen regulärer Ausdrücke: die Vielfalt möglicher Telefonnummernformate weltweit. Je nach Land und Region gelten unterschiedliche Regeln für den Aufbau einer Telefonnummer. Ein umfassender regulärer Ausdruck, der all diese Varianten abdeckt, könnte schnell sehr komplex werden und schwer zu warten sein.

In solchen Fällen kann es sinnvoll sein, zusätzliche Validierungslogik zu implementieren und nicht zu versuchen, die komplette Logik über einen regulären Ausdruck zu implementieren.

Gierige und faule Quantifizierer

Bei den beschriebenen Quantifizierern existieren gierige (engl. greedy) und faule (engl. lazy) Varianten. Gierige Quantifizierer versuchen, so viel wie möglich von der Zeichenkette zu erfassen, während sie den Regeln des regulären Ausdrucks folgen. Damit greifen sie den längstmöglichen Teil der Zeichenkette, der mit dem Muster übereinstimmt.

Faulen Quantifizierern hingegen geht es darum, so wenig wie möglich zu erfassen, während sie immer noch eine Übereinstimmung finden. Damit greifen sie den kürzestmöglichen Teil der Zeichenkette, der mit dem Muster übereinstimmt.

Quantifizierer sind im Normalfall gierig. Der Ausdruck:

a.*b

würde hiermit die Zeichenfolge:

ababab

als Ganzes matchen. Um diesen Ausdruck in der Lazy-Konfiguration zu betreiben, muss ein Fragezeichen nachgestellt werden:

a.*?b

In diesem Fall würden die ab-Blöcke jeweils einzeln gematcht werden.

Neben diesen beiden Varianten existieren noch possessive Quantifizierer. Solche Quantifizierer verhalten sich wie gierige Quantifizierer, aber sie geben einmal erfasste Zeichen nicht mehr frei.

Das kann das Matching verhindern, wenn der Rest des Musters nicht mehr passt. Possessive Quantifizierer können in bestimmten Situationen die Effizienz der Auswertung verbessern, da sie das aufwendige Backtracking unterbinden, können aber auch zu nicht intuitiven Ergebnissen führen, wenn sie nicht mit Bedacht eingesetzt werden.

Gruppierungen

Eine weitere Möglichkeit bei der Entwicklung regulärer Ausdrücke sind Gruppierungen. So würde der Ausdruck:

abc

nur auf die Zeichenkette abc matchen. Sollen hier jetzt auch Zeichenketten wie abcabc gematcht werden, können Klammern zur Gruppierung im Zusammenhang mit einem Quantifizierer genutzt werden:

(abc)+

Damit würden unter anderem folgende Zeichenketten auf den Ausdruck passen:

abc
abcabc
abcabcabc

Eine weitere Art von Gruppierung ist die Bildung von sogenannten Erfassungsgruppen. Mit diesen Gruppen, welche einen Teil des Ausdrucks ausmachen, kann später weitergearbeitet werden, z. B. in Form von Rückreferenzen:

(abc).*\1

In diesem Beispiel wird die Zeichenkette abc gesucht, welcher beliebige Zeichen folgen, bis schlussendlich wieder abc folgt. Dies wird über den Rückverweis auf die erste Erfassungsgruppe (\1) gelöst. Damit würde dieser Ausdruck unter anderem auf folgende Zeichenketten matchen:

abcabc
abcloremabc
abcipsumabc

Eine weitere Möglichkeit zur Nutzung von Gruppierung sind Oder-Verknüpfungen in einer Gruppe:

(a|b)c

Mit einer solchen Verknüpfung würden Zeichenketten wie ac und bc gematcht, allerdings nicht die Zeichenkette abc.

Daneben existieren Varianten wie verschachtelte Gruppen, benannte Gruppen oder nicht erfassende Gruppen, welche hier nicht weiter im Detail behandelt werden sollen.

Lookaround-Assertions

Eine weitere Möglichkeit zur Verfeinerung von regulären Ausdrücken sind Lookahead– und Lookbehind-Assertions. Im Deutschen könnte man diese Begrifflichkeiten mit vorwärts bzw. rückwärtsgerichtete Bedingungen grob übersetzen. Mit diesen kann die Umgebung eines Matches definiert werden.

So würde der Ausdruck:

Redaktion(?=skonferenz)

in einem Text auf das Wort Redaktion matchen, wenn es in der Zeichenkette Redaktionskonferenz enthalten wäre. Würde das Wort Redaktion alleine im Text stehen, so würde hier kein Match stattfinden. Bei dieser Variante handelt es sich um einen positiven Lookahead, da überprüft wird, ob das Muster nach dem Match vorkommt.

Das Gegenteil ist ein negativer Lookahead:

Redaktion(?!skonferenz)

Bei diesem würde auf das einzelne Wort Redaktion gematcht werden, auf das Wort Redaktion in der Zeichenkette Redaktionskonferenz allerdings nicht.

Neben den Lookahead-Assertions existieren analog dazu Lookbehind-Assertions welche prüfen, ob das Muster vor dem Match vorhanden ist:

(?<=Schluss)redaktion
(?<!Schluss)redaktion

Ein komplexeres Beispiel für die Anwendung von Lookarounds könnte die Suche nach verschiedenen Schreibweisen des Wortes Hauptstraße sein. Ein regulärer Ausdruck, der Hauptstr., Hauptstraße und Hauptstrasse matcht, könnte wie folgt aussehen:

Hauptstr(aße|asse|\.)(?=\s|\b)

Hierbei sorgt die Lookahead-Assertion (?=\s|\b) dafür, dass das Match nur dann stattfindet, wenn das gesuchte Wort gefolgt von einem Whitespace (\s) oder einem Wortgrenzenzeichen (\b) steht. Diese Vorgehensweise verhindert, dass unerwünschte Matches wie Hauptstrasseneinmündung entstehen.

Flags

Neben den eigentlichen regulären Ausdrücken existieren eine Reihe von Optionen, welche das Verhalten der Engine zur Auswertung der regulären Ausdrücke anpassen.

So existiert mit der Global-Option (g) die Möglichkeit die Suche im gesamten Text durchzuführen und nicht nur bis zur ersten Übereinstimmung. Mit der Option i ignoriert die Engine die Groß- und Kleinschreibung im Rahmen der Mustererkennung.

Die Option Multiline (m) verändert das Verhalten für die Zeichen ^ und $, sodass sie nicht mehr nur den Start und das Ende des gesamten Textes markieren, sondern den Anfang und das Ende jeder einzelnen Zeile analysieren. Über die Singleline– bzw. Dotall-Direktive (s) kann der Punkt als Wildcard-Zeichen auch über die Grenzen der Zeilenumbrüche hinweg arbeiten.

Mit der Unicode-Option (u) kann bei einigen Engines die Auswertung von Unicode-Zeichen aktiviert werden.

Gesetzt werden können diese Flags als Teil des regulären Ausdrucks, so z. B. für das Global-Flag in Verbindung mit der Ignorierung der Groß- und Kleinschreibung:

(abc)/gi

Je nach verwendeter Engine werden diese Flags auch anders gesetzt, z. B. in Java beim Anlegen eines Pattern:

Pattern pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE);
Matcher matcher = pattern.matcher(text);

Daneben existieren in einigen Engines bestimmte Flags nicht, z. B. da Java bereits so konzipiert ist, dass die Engine alle Übereinstimmungen in einem gegebenen Text finden kann. Damit entspricht dieses Verhalten dem Global-Flag.

Unterschiede

Obwohl die grundlegenden Konzepte von regulären Ausdrücken über verschiedene Sprachen hinweg ähnlich sind, gibt es Unterschiede in der Syntax und Funktionalität, die Entwickler beachten müssen.

Einige Engines bieten erweiterte Funktionen wie Lookahead– und Lookbehind-Assertions, andere unterstützen benannte Gruppen. Die Unterstützung für Unicode ist ebenfalls ein Unterscheidungsmerkmal, da manche Engines in der Lage sind, mit einer Vielzahl von Zeichensätzen und Sprachen umzugehen, während andere auf ASCII beschränkt sind.

Trotz der Vielfalt unter den Engines haben sich viele Gemeinsamkeiten herausgebildet. Dazu gehören Metazeichen wie den Punkt für jedes Zeichen, den Stern für null oder mehr Wiederholungen, das Plus für eine oder mehr Wiederholungen und das Fragezeichen für null oder eine Wiederholung. Ebenso sind Zeichenklassen, Negationen, Anker für Anfang und Ende einer Zeichenkette, und einfache Quantifizierer weitgehend identisch.

Reguläre Ausdrücke in der Entwicklung

Bei der Anwendung regulärer Ausdrücke in der Entwicklung sind die jeweiligen Gegebenheiten der Programmiersprachen zu berücksichtigen. Ein Beispiel hierfür ist die Java-Methode zum Matchen in der Stringklasse:

String text = "3";
boolean matches = text.matches("[123]");

Hier wird die matches-Methode genutzt, um zu überprüfen, ob der gesamte String text dem regulären Ausdruck entspricht. Diese Herangehensweise ist für einmalige Überprüfungen einfach und direkt. Allerdings ist es ineffizient, wenn der gleiche Ausdruck in einer Schleife oder mehrfach im Code verwendet wird, da bei jedem Aufruf der Ausdruck neu kompiliert wird.

Alternativ kann diese Operation anders implementiert werden:

Pattern pattern = Pattern.compile("[123]");
boolean matches = pattern.matcher(text).matches();

Diese Variante ist effizienter, wenn der gleiche Ausdruck mehrfach genutzt werden soll. Hier wird das Pattern einmal kompiliert und kann anschließend mehrfach verwendet werden, ohne dass es jedes Mal neu kompiliert werden muss.

Reguläre Ausdrücke in Anwendungen

Neben der direkten Nutzung regulärer Ausdrücke in der Entwicklung, existieren unzählige Tools, wie Texteditoren oder Kommandozeilenwerkzeuge wie grep, welche ebenfalls Unterstützung für reguläre Ausdrücke liefern.

Für grep könnte dies wie folgt aussehen:

grep -E "G{1}N" gpl3.txt

Auch Texteditioren und IDEs enthalten Suchmethodiken, um über reguläre Ausdrücke zu finden.

Die Suche mittels regulärer Ausdrücke in IntelliJ IDEA

Damit ist es möglich, Textstellen zu finden, die komplexeren Mustern entsprechen und mit einer einfachen Suche nicht ohne weiteres gefunden werden können.

Nutzung

Reguläre Ausdrücke sind ein mächtiges Werkzeug, wenn es darum geht, Benutzereingaben zu validieren. Sie werden häufig verwendet, um sicherzustellen, dass E-Mail-Adressen, Telefonnummern und andere Formen von Daten bestimmten Mustern entsprechen.

So könnte mit einem solchen Ausdruck z. B. die Validität einer E-Mail-Adresse überprüft werden:

[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?

Daneben können sie beim Parsen von Zeichenketten genutzt werden, z. B. um spezifische Informationen aus einem Text wie einer Logdatei herauszufiltern.

In der Compiler-Konstruktion werden reguläre Ausdrücke verwendet, um Tokens zu identifizieren, indem sie Muster definieren, die Schlüsselwörter, Operatoren oder andere syntaktische Elemente erkennen. Auch bei der Textbearbeitung, wie dem massenhaften Ersetzen, können reguläre Ausdrücke ihre Stärke ausspielen.

Trotzdem sollte nicht vergessen werden, dass sich reguläre Ausdrücke nicht für alle Zwecke eignen. So sollte z. B. davon abgesehen werden, nicht formale Sprachen mit regulären Ausdrücken zu parsen. Für das Parsen von HTML mittels regulärer Ausdrücke existiert dazu ein geradezu legendärer Post bei Stack Overflow, der sich dieses Problems annimmt.

Stack Overflow beantwortet die Frage nach dem Sinn von regulären Ausdrücken in Verbindung mit HTML

Best Practices

Werden reguläre Ausdrücke genutzt, so sollten einige Best Practices berücksichtigt werden. Bei unsachgemäßer Verwendung kann ein regulärer Ausdruck schnell unübersichtlich und schwer wartbar werden kann.

So sollten genutzte reguläre Ausdrücke kommentiert werden und ihre Fachlichkeit darlegen, um das Verständnis zu erhöhen.

Anstelle von nummerierten Rückreferenzen lassen sich, wenn unterstützt, benannte Gruppen nutzen. Einige Engines erlauben auch die Verwendung von Leerzeichen und Zeilenumbrüchen in den Ausdrücken, was hilft, sie besser zu strukturieren. Zudem kann es vorteilhaft sein, lange und komplexe Muster in kleinere, wiederverwendbare Teile zu zerlegen, was die Wartung und das Verständnis des Ausdrucks erleichtert.

Es ist ratsam, in regulären Ausdrücken so spezifisch wie möglich zu sein. Anstatt generische Zeichen wie den Punkt, der jedes Zeichen repräsentieren kann, zu verwenden, sollten präzisere Zeichen oder Zeichenklassen gewählt werden. Dies hilft, unnötiges Backtracking zu vermeiden, das die Performance beeinträchtigen kann.

Obwohl Lookaround-Assertions in bestimmten Situationen sehr nützlich sein können, sollten sie mit Bedacht eingesetzt werden, da sie bei komplexen Mustern und großen Textmengen die Performance negativ beeinflussen können. Auch bei der Verwendung von Gruppierungen gilt gleiches, denn jede zusätzliche Gruppierung bedeutet mehr Aufwand bei der Verarbeitung des Ausdrucks.

Schließlich ist es unerlässlich, dass reguläre Ausdrücke gründlich getestet werden. Idealerweise sollte dies mit einer breiten Palette von Testfällen geschehen, um sicherzustellen, dass sie in jeder erwarteten Situation korrekt funktionieren.

Unter der Haube

In der alltäglichen Anwendung werden regulären Ausdrücke von einer Engine ausgeführt. Ein kurzer Blick in eine solche Engine, kann sich lohnen, da es hilft zu verstehen, wie reguläre Ausdrücke sinnvoll gestaltet werden können. Eine Engine, die diese Ausdrücke verarbeitet, kann entweder textorientiert oder regex-orientiert sein.

Eine textorientierte Engine, implementiert als deterministischer endlicher Automat (DFA), analysiert den Eingabetext sequenziell. Diese Methode ist schnell und effizient, da sie keine alternativen Pfade verfolgt und somit kein Backtracking benötigt. DFAs liefern stets die längste Übereinstimmung und sind aufgrund ihrer deterministischen Natur in der Performance vorhersehbar.

Im Gegensatz dazu steht die regex-orientierte Engine, die auf einem nichtdeterministischen endlichen Automaten (NFA) basiert. Eine solche Engine ist in der Lage, mehrere Pfade gleichzeitig zu verfolgen und bei Bedarf mittels Backtracking alternative Wege zu untersuchen. Dies ermöglicht eine flexible Mustererkennung, kann jedoch bei komplexen Ausdrücken zu einer erhöhten Rechenlast führen. NFAs priorisieren die am weitesten links stehende Übereinstimmung und können bei mehreren möglichen Matches zu einer kürzeren Übereinstimmung führen, selbst wenn weiter rechts im Text eine längere vorhanden wäre.

Moderne Regex-Engines sind meist regex-orientiert und nutzen einen Preprozessor, um den regulären Ausdruck vorzuverarbeiten, etwa um Makros in Zeichenklassen umzuwandeln. Anschließend wird der Ausdruck kompiliert, wobei er in eine effiziente Form überführt wird, die entweder als Reihe von Instruktionen oder als Zustandsautomat von der Engine verarbeitet werden kann.

Die Wahl der Engine hängt von den spezifischen Anforderungen der Aufgabe ab. Während DFAs für einfache, vorhersehbare Suchvorgänge geeignet sind, bieten NFAs die notwendige Flexibilität für komplexere Mustererkennungen.

Risiken und Nebenwirkungen

Neben der Möglichkeit reguläre Ausdrücke für Aufgaben zu nutzen, für die sie nicht geeignet sind, existieren auch andere Probleme, die mit diesen Ausdrücken zusammenhängen.

So gibt es den Regular expression Denial of Service
-Angriff (ReDoS), welcher ausnutzt, dass viele Engines für reguläre Ausdrücke bei bestimmten Ausdrücken extrem langsam werden und viele Systemressourcen beanspruchen können.

Ein schönes Beispiel für eine solche Anfälligkeit, war der Ausfall von Stack Overflow im Jahre 2016. Dieser wurde durch einen Post mit zu vielen Leerzeichen verursacht, welcher dazu führte, dass die auf Backtracking basierte Engine über 199 Millionen Überprüfungen durchführen musste.

Allerdings gibt es Alternativen zu Backtracking nutzenden Engines, wie RE2 von Google. Diese Engine garantiert, basierend auf einem endlichen Automaten, eine lineare Ausführungszeit, bezogen auf die Eingabedaten und ist trotzdem mit den Features moderner Engines ausgestattet.

Werkzeuge

Für die Nutzung und Erstellung von regulären Ausdrücken existieren hilfreiche Werkzeugen. Zu diesen Werkzeugen gehören eine Reihe von Online-Testern. Dies sind interaktive Werkzeuge, die es dem Nutzer ermöglicht, regulären Ausdrücke in Echtzeit zu testen und zu debuggen. Diese Werkzeuge bieten oft farblich hervorgehobene Übereinstimmungen und Erklärungen für jedes Element des Ausdrucks.

Einer dieser Tester ist RegExr, welcher unter regexr.com zu finden ist.

Mit RegExr können reguläre Ausdrücke schnell ausprobiert werden

Neben der interaktiven Oberfläche bietet RegExr eine Referenz und eine große Anzahl von Community Patterns, die viele Probleme bereits abdecken und so die Entwicklung eines eigenen Ausdrucks beschleunigen können.

Ein weiterer Tester für reguläre Ausdrücke ist regular expressions 101, welcher unter regex101.com zu finden ist.

regular expressions 101 verfügt über einen Debugger

Eine Besonderheit dieses Dienstes ist der integrierte Debugger, mit welchem regulären Ausdrücke analysiert werden können. Einen umgekehrten Weg geht der Regex Generator von Olaf Neumann.

Der Regex Generator von Olaf Neumann

Mithilfe dieses Werkzeuges können reguläre Ausdrücke anhand eines Datenbeispieles erzeugt werden. Kommandozeilenwerkzeuge wie rgxg arbeiten nach ähnlichen Prinzipien und können auch offline genutzt werden.

Fazit

Insgesamt sind reguläre Ausdrücke ein mächtiges Werkzeug, um Textmuster zu durchsuchen und zu manipulieren. Sie ermöglichen eine effiziente Verarbeitung von Texten und sind daher ein wichtiges Werkzeug für Entwickler und Anwender. In der Praxis werden sie in verschiedenen Bereichen eingesetzt, um Texte zu durchsuchen, zu filtern und zu manipulieren.

Durch die Einhaltung von Best Practices und dem damit verbundenen Vermeiden häufiger Fehler können Entwickler sicherstellen, dass ihre regulären Ausdrücke sowohl leistungsfähig als auch wartbar sind.

Dieser Artikel erschien ursprünglich auf Golem.de und ist hier in einer alternativen Variante zu finden.

Fluent Interfaces unter Java

Der Begriff des Fluent Interface hat mittlerweile knapp zwei Jahrzehnte auf dem Buckel und solche Schnittstellen sind mittlerweile auch in größerem Umfang in der Praxis angekommen und werden dort vielfältig genutzt. Trotzdem scheint es manchmal schwer zu fallen genau zu definieren, was ein solches Fluent Interface ist, was es auszeichnet und wie es in der Praxis sinnvoll genutzt werden kann.

Herkunft

Fluent Interfaces, welche wohl holprig mit einer fließenden Schnittstelle übersetzt werden könnten, sind eine Begrifflichkeit, welche in dieser Form erstmals 2005 von Martin Fowler und Eric Evans definiert wurden.

Martin Fowler ist bekannt für seine Arbeit, am Manifest für Agile Softwareentwicklung und seine Bücher, welche unter anderem das Code-Refactoring in die breite Öffentlichkeit trugen und populär machten.

Eric Evans ist vorwiegend für seine Beiträge rund um das Domain-Driven Design bekannt, dessen Gedanken er erstmals im gleichnamigen Buch Anfang der 2000er-Jahre zusammenfasste und somit den Begriff prägte.

Sinnvoller wäre für Fluent Interfaces allerdings die Bezeichnung als sprechende Schnittstelle, welcher eher der Idee im Kern entspricht. Bezugnehmend auf die Wortherkunft aus dem englischen to be fluent in a language, kann allerdings auch im Deutschen von Fluent Interfaces gesprochen werden.

Ideen des Fluent Interfaces

Dem Namen entsprechend ist eines der Ziele eines Fluent Interfaces, APIs besser lesbar und verstehbarer zu gestalten. Daneben soll die API Funktionalitäten passend zum Objekt bereitstellen können.

Bei Betrachtung einer solchen sprechenden Schnittstelle, soll der Quellcode sich wie eine natürliche Sprache bzw. ein Text in einer solchen anfühlen, anstatt eine Reihe von abstrakten Anweisungen darzustellen. Martin Fowler definierte auch ein Maß für diese Fluidität:

The key test of fluency, for us, is the Domain Specific Language quality. The more the use of the API has that language like flow, the more fluent it is.

Ein schönes Beispiel für die Nutzung einer solchen sprechenden Schnittstelle ist die Anwendung des Mock-Frameworks Mockito:

when(uuidGenerator.getUUID()).thenReturn(15);

Auch ohne genauere Kenntnisse über Mockito kann der Programmierer aus diesem Beispiel herauslesen, dass wenn die Methode getUUID angefordert wird, im Rahmen des Mocks immer der Wert 15 zurückgegeben wird.

Die Zeile kann mit sprachlichen Mitteln einfach gelesen werden und verrät dem Nutzer etwas über die dahinterliegende Funktionalität. Das Ziel, die entsprechenden Schnittstellen einfach lesbar und benutzbar zu machen, wird sich dadurch erkauft, dass es meist aufwendiger ist, solche Schnittstellen zu designen und zu entwickeln.

Dies bedingt sich durch das vorher zu durchdenkende Design und einem Mehraufwand beim Schreiben des eigentlichen Quellcodes, unter anderem in Form zusätzlicher Methoden und anderen Objekten zur Abbildung des Fluent Interface.

Zur Implementierung von Fluent Interfaces werden unterschiedliche Techniken wie das Method Chaining genutzt. Daneben werden häufig Elemente wie statische Factory-Methoden und entsprechende Imports, sowie benannte Parameter genutzt.

Sprechende Schnittstellen ermöglichen es auch Personen einen Einblick in den Quellcode zu geben, welche nicht mit den technischen Details desselben vertraut sind. Auch bei der Abbildung von fachlicher Logik helfen Fluent Interfaces, z. B. über entsprechende Methodennamen, welche sich auf die Fachlichkeit der Anwendung beziehen.

Method Chaining

Eine der Techniken zur Erstellung von sprechenden Schnittstellen ist das Method Chaining, welches im Deutschen treffend mit Methodenverkettung übersetzt werden kann.

Methodenketten sind eine Technik, bei der mehrere Methoden aufeinanderfolgend aufgerufen werden, um ein Ergebnis zu erhalten. Beispielsweise könnte ein Entwickler mehrere Methoden aufrufen, um einen bestimmten Wert aus einem Array zu extrahieren oder einen Alternativwert bereitzustellen. Die Methodenketten ermöglichen es dem Entwickler, mehrere Aufgaben in einer Kette zu erledigen, anstatt sie auf getrennte Statements zu verteilen.

Das bedeutetet allerdings im Umkehrschluss nicht, dass die Verkettung von Methoden gleich einem Fluent Interface entspricht, sodass an dieser Stelle immer differenziert werden sollte.

Am Ende einer Methodenkette wird das benötigte Objekt zurückgegeben oder die gewünschte Aufgabe ausgeführt. Aussehen könnte eine solche Methodenkette, in diesem Fall am Beispiel der Stream-API, wie folgt:

User nathalie = Arrays.stream(users)
        .filter(user -> "Nathalie".equals(user.getForename()))
        .findFirst()
        .orElse(null);

Bei solchen Methodenketten gilt es zu berücksichtigen, dass bestimmte Methoden innerhalb der Kette im schlimmsten Fall null zurückgeben oder Exceptions auslösen. Je nach genutztem Interface müssen diese Fälle entsprechend beachtet werden.

Interessanterweise brechen Methodenketten mit einigen älteren Konventionen. So würden z. B. Setter in einem Builder-Pattern:

User.Builder.newBuilder()
               .name("Mustermann")
               .forename("Max")
               .username("maxmustermann")
               .build();

einen Wert zurückgeben, in diesem Beispiel das Builder-Objekt. Im Normalfall würde der Setter stattdessen keinen Rückgabeparameter aufweisen. Er wäre in einem gewöhnlichen Objekt wie folgt definiert:

public void setName(String name) {
    this.name = name;
}

Diese Konvention rührt vom Command-Query-Separation-Pattern, welches besagt, dass entweder Kommandos oder Queries definiert werden sollten. Abfragen als solche, dürfen keinerlei Auswirkungen am Zustand der Klasse haben, während bei Kommandos entsprechende Auswirkungen explizit erwünscht sind. Dies ist z. B. dann der Fall, wenn Daten geändert werden.

Method Nesting

Eine weitere Technik, welche für die Nutzung und das Design von Fluent Interfaces benutzt wird, ist das Method Nesting bzw. das Verschachteln von Methoden. Ein Beispiel hierfür liefert die Java-Bibliothek REST Assured, welche eine domänenspezifische Sprache zur Abfrage und Validierung von REST-Services darstellt:

when()
  .get("https://api.pi.delivery/v1/pi?start=0&numberOfDigits=100").
then()
  .statusCode(200)
  .body("content", startsWith("31415926535897932384"));

Neben der Verkettung von Methoden werden hier auch Methoden ineinander verschachtelt. In diesem Fall wird die startWith-Methode innerhalb der body-Methode geschachtelt, was dafür sorgt, dass beim Lesen ein besseres Verständnis entsteht, was im Quellcode passiert.

Die startWith-Methode stammt hierbei aus dem Hamcrest-Framework, welches viele solcher Methoden, unter anderem zur Nutzung in Unit-Tests, bereitstellt.

Statische Imports

Damit die Sprachelemente der Bibliothek im obigen Beispiel nicht immer voll referenziert werden müssen, werden statische Imports genutzt:

import static io.restassured.RestAssured.*;
import static org.hamcrest.Matchers.*;

Damit kann eine statische Methode ohne ihren Klassennamen referenziert und genutzt werden. Ohne diesen statischen Import würde obiges Beispiel etwas umfangreicher aussehen:

RestAssured.when()
  .get("https://api.pi.delivery/v1/pi?start=0&numberOfDigits=100").
then()
  .statusCode(200)
  .body("content", Matchers.startsWith("31415926535897932384"));

Damit wird über den Mechanismus der statischen Importe sichergestellt, dass der Quelltext, im Rahmen der Philosophie der sprechenden Schnittstellen, möglichst lesbar bleibt.

Object Scoping

Vor allem in Programmiersprachen mit einem globalen Namensraum und echten Funktionen können Funktionen, welche einzelne Bestandteile einer domänenspezifischen Sprache darstellen, in diesem globalen Namensraum angelegt werden.

Allerdings ist dies in den meisten Fällen unerwünscht, sodass die Bestandteile der domänenspezifischen Sprache, wie im Beispiel REST Assured in entsprechende Objekte verpackt werden und damit den globalen Namensraum entlasten. Dieses Vorgehen trägt den Namen Object Scoping.

Anwendung

Doch wozu können solche Fluent Interfaces in der Praxis genutzt werden? Unterschiedlichste Szenarien werden immer wieder angesprochen, wenn es um die Nutzung dieser Technik geht.

Eine häufigere Anwendung ist das Builder-Pattern und die Nutzung in Form domänenspezifischer Sprachen kurz DSLs bzw. im englischen Original Domain Specific Languages.

Eine solche domänenspezifische Sprache ist eine Programmiersprache, welche für ein bestimmtes Feld, besagte Domäne, entwickelt wird. Häufig werden sie verwendet, um komplexe Aufgaben zu vereinfachen, indem eine auf die Domäne und die zu lösenden Probleme angepasste Syntax genutzt wird, welche meist große Schnittmengen mit der Fachlichkeit der Domäne beinhaltet.

Fluent Interfaces in der Java-Welt

Auch in der Java Class Library existieren APIs, welche als Fluent Interface angelegt sind. Ein prominentes Beispiel hierfür ist die Stream-API:

User aMustermann = Arrays.stream(users)
        .parallel()
        .filter(user -> "Mustermann".equals(user.getName()))
        .findAny()
        .orElse(null);

Über die Stream-API können unterschiedlichste Anforderungen abgebildet werden. In diesem Fall wird ein Array mit Objekten vom Typ User durchsucht und parallel ein Nutzer mit dem Nachnamen Mustermann gesucht. Dieses Objekt wird dann zurückgegeben. Wird kein solches Objekt gefunden, so wird stattdessen null zurückgegeben.

Auch in anderen Frameworks wie Mocking-Frameworks oder solchen Bibliotheken, welche domänenspezifische Sprachen abbilden, wird intensiv Gebrauch von Fluent Interfaces gemacht.

Frameworks, wie Spring, nutzen ebenfalls interne DSLs, um bestimmte Anforderungen und Möglichkeiten zur Konfiguration abzubilden.

Fluent Interfaces im Builder-Pattern

Eine weitere Anwendung für Fluent Interfaces im weiteren Sinne ist das sogenannte Builder-Pattern. Bei diesem existiert eine Klasse und ein dazugehöriger Builder, welcher dazu dient das Objekt zu konstruieren.

Hier wird ersichtlich, dass dies auf den ersten Blick mehr Aufwand bei der Implementation bedeutet. Allerdings müssen „Standardaufgaben“, wie das Erstellen eines Builders nicht unbedingt von Hand erledigt werden, da es für unterschiedlichste IDEs entsprechende Unterstützung in Form von Erweiterungen gibt. Für IntelliJ IDEA wäre ein Plugin für diese Aufgabe der InnerBuilder von Mathias Bogaert.

Ein Plugin für die einfache Erzeugung von Buildern in IntelliJ IDEA

Mithilfe dieser Plugins können Builder-Klassen schnell erstellt und bei Änderungen der eigentlichen Basisklasse angepasst werden, indem die Builder-Klasse über das Plugin neu generiert wird. Dies ermöglicht eine unkomplizierte Änderung von Klassen samt ihrer dazugehörigen Builder.

Manchmal wird beim Builder-Pattern infrage gestellt, ob es sich bei diesem wirklich um ein Fluent Interface handelt. Dabei wird die Unterscheidung getroffen, dass Fluent Interfaces sich mehr auf die Manipulation und Konfiguration von Objekten konzentrieren, während Builder den Fokus auf die Erzeugung von Objekten legen.

Es stellt sich allerdings die Frage, inwiefern diese Gedanken akademischer Natur, in der Praxis relevant sind, da auch Builder sich aus Sicht des Lesers des Quellcodes gut als Fluent Interfaces erschließen lassen.

Builder-Pattern im Beispiel

Wie könnte ein solches Builder-Pattern nun an einem einfachen Beispiel aussehen? Gegeben sei folgende Klasse:

public class User {

    private String name;

    private String forename;

    private String username;

    public User() {
    }
}

Die Klasse mit dem Namen User enthält drei Felder in welchen der Name, Vorname und der Nutzername des Nutzers gespeichert werden. Um die Felder der Klasse zu befüllen, existieren unterschiedliche Möglichkeiten.

So könnte ein entsprechender Konstruktor definiert werden, welcher diese Felder setzt:

public User(String name, String forename, String username) {
    this.name = name;
    this.forename = forename;
    this.username = username;
}

Dieser könnte nun aufgerufen werden, um eine Instanz der Klasse zu erstellen und die Felder zu füllen:

User max = new User("Mustermann", "Max", "maxmustermann");

Soll jedoch ein Wert nicht gesetzt werden, kann dieser auf null gesetzt werden. Allerdings kann es hier auch den Fall geben, dass mit NotNull-Annotationen und einer entsprechenden Validation gearbeitet wird:

public User(@NotNull String name, @NotNull String forename, @NotNull String username)

Durch die Annotation soll verhindert werden, dass die entsprechenden Felder mit dem Wert null initialisiert werden können. In einem solchen Fall ist für die entsprechenden Felder auch die Anwendung des Builder-Patterns nur schwer vorstellbar, z. B. über das Setzen der Werte in der newBuilder-Methode.

Sind solche Einschränkungen nicht vorhanden, könnte alternativ mit unterschiedlichen Konstruktoren gearbeitet werden, in welchen jeweils nur einige der Felder gesetzt werden können.

Spätestens bei komplexeren Objekten wird auffallen, dass diese Methode eher ungeeignet ist. Der klassische Weg ist es hier entsprechende Getter und Setter zum Abrufen und Setzen der Eigenschaften bereitzustellen:

public String getName() {
    return name;
}

public void setName(String name) {
    this.name = name;
}

public String getForename() {
    return forename;
}

public void setForename(String forename) {
    this.forename = forename;
}

public String getUsername() {
    return username;
}

public void setUsername(String username) {
    this.username = username;
}

Zwar können Getter und Setter auch über Annotationen, z. B. mithilfe der Bibliothek Lombok, vor dem Compile-Vorgang, generiert werden, allerdings hält der Autor nicht sonderlich viel von dieser Art von „Präprozessor-Magie“.

Hierbei werden unnötige Abhängigkeiten für doch recht simple Aufgaben, an das Projekt angehangen, welche wiederum bestimmte Funktionsweisen vor dem Entwickler verstecken. Vor allem, wenn der Entwickler nicht mit dem Projekt vertraut ist, kann dies das Verständnis erschweren.

Unter Nutzung der Setter kann ein Objekt nun feingranular mit den gewünschten Werten befüllt werden:

User user = new User();

user.setName("Mustermann");
user.setForename("Max");
user.setUsername("maxmustermann");

Soll z. B. der Vorname nicht gesetzt werden, so kann die entsprechende Zeile einfach weggelassen werden. Diese Nutzung bzw. Schreibweise wird manchmal unter dem Namen Method Sequencing zusammengefasst.

An dieser Stelle könnte stattdessen ein Builder genutzt werden. Bei diesem würde das Ganze wie folgt aussehen:

User user = User.Builder.newBuilder()
        .name("Mustermann")
        .forename("Max")
        .username("maxmustermann")
        .build();

Bei dieser Schreibweise wird der Builder genutzt, um das Objekt zu erstellen und mit seinen Werten zu befüllen. Der Builder liefert dazu die benötigen Methoden:

public static final class Builder {
    private String name;
    private String forename;
    private String username;

    private Builder() {
    }

    public static Builder newBuilder() {
        return new Builder();
    }

    public Builder name(String name) {
        this.name = name;
        return this;
    }

    public Builder forename(String forename) {
        this.forename = forename;
        return this;
    }

    public Builder username(String username) {
        this.username = username;
        return this;
    }

    public User build() {
        return new User(this);
    }
}

In vielen modernen Implementationen des Pattern wird meist auf das Präfix set für die Setter verzichtet und stattdessen nur der Name des Feldes als Methode definiert. Alternativ wird häufig auch das Präfix with genutzt.

Beim Quelltext wird ersichtlich, dass jede Methode, bis auf die build-Methode, eine Instanz des Builders zurückgibt und damit die Verkettung der unterschiedlichen Methoden erlaubt.

Fluent Interfaces zur Definition einer DSL nutzen

Neben dem Entwicklungsmuster des Builders können mithilfe von Fluent Interfaces domänenspezifische Sprachen (DSLs) erstellt werden.

Bei solchen Sprachen handelt es sich um speziell für ein bestimmtes Anwendungsgebiet entwickelte Programmiersprachen. Sie sind oft einfacher und leichter zu verstehen als allgemeine Programmiersprachen und ermöglichen es Entwicklern bzw. den Anwendern der Sprache, effizienter und präziser zu programmieren.

DSLs werden häufig in bestimmten Bereichen wie der Finanzbranche, der Medizin, der Luftfahrtindustrie, der Spieleentwicklung und der Datenanalyse eingesetzt. Sie können dazu beitragen, Prozesse zu automatisieren und zu vereinfachen und ermöglichen Entwicklern, komplexe Aufgaben in kürzerer Zeit zu erledigen.

Zu den bekanntesten domänenspezifischen Sprachen gehört SQL und auch reguläre Ausdrücke können als DSL gesehen werden. Grundsätzlich werden domänenspezifischen Sprachen in externe und interne Sprachen unterteilt.

Bei den internen DSLs wird eine Wirtssprache, in diesem Beispiel Java, genutzt, um die DSL abzubilden. Externe DSLs hingegen stehen für sich allein und nutzen keinerlei Wirtssprache. Deshalb sind sie auf eigens dafür entwickelte Compiler oder Interpreter angewiesen.

Im folgenden Beispiel soll eine DSL für HTML entwickelt und anhand dieser sollen einige Konzepte der sprechenden Schnittstellen in Zusammenhang gebracht werden. Die HTML-DSL soll dazu genutzt werden, HTML-Dokumente zu bauen. Ein Aufruf dieser DSL könnte wie folgt aussehen:

String html = new Html()
        .head()
          .meta("UTF-8")
          .meta("keywords", "fluent, interface")
          .title("Fluent interfaces")
          .finalise()
        .body()
          .text("Fluent interfaces")
          .br()
          .finalise()
        .generate();

Während bei einem einfachen Builder immer der Builder als solcher zurückgegeben wird, wird in diesem Fall nicht immer das Hauptobjekt, die Klasse Html, zurückgegeben. Stattdessen werden teilweise sogenannte Intermediate-Objekte zurückgegeben. Diese haben außerhalb der DSL keinerlei sinnvolle Funktionalität, sondern dienen dazu nur die Methoden anzubieten, welche im Kontext von HTML erlaubt sind.

Über Intermediate-Objekte lassen sich somit auch bestimmten Reihenfolgen erzwingen. Damit kann der Compiler zumindest zur partiellen Überprüfung der Anweisungen benutzt werden und dem Anwender bzw. Entwickler werden nur die Funktionalitäten zur Verfügung gestellt, welche im entsprechenden Kontext sinnvoll sind.

Über die Intermediate-Objekte werden die entsprechenden Methoden zur Verfügung gestellt

Unterstützt wird der Entwickler daneben durch die Autovervollständigung der IDE, welche die Methoden des jeweiligen Intermediate-Objektes darstellen kann. Anschließend dient die Methode finalise dazu, wieder eine Ebene höher in der Hierarchie der Intermediate-Objekte zu wandern.

In der Theorie könnten die entsprechende Verschachtelung beliebig komplex gestaltet werden. So können div-Blöcke als Elemente genutzt werden und innerhalb des Intermediate-Objektes für den div-Block stehen dann ausschließlich solche Elemente, welche im Rahmen eines div-Blockes genutzt werden können.

Bei der Nutzung einer solchen domänenspezifischen Sprache ist es wichtig, dass der jeweilige Aufruf auch sinnvoll beendet werden muss. So kann es bei falscher Nutzung durchaus passieren, dass der Entwickler, wenn er entsprechende Aufrufe vergisst, ein Intermediate-Objekt als Rückgabe am Ende erhält, mit welchem er nicht viel anfangen kann. Dieses Problem wird auch als Finishing-Problem bezeichnet.

Beim Design einer DSL muss immer sichergestellt werden, dass es einen Aufruf in den jeweiligen Intermediate-Objekten gibt, welcher dafür sorgt am Ende ein sinnvolles Ergebnis zu erhalten bzw. wieder zum eigentlichen Objekt zurückführt.

Automatische Generation von sprechenden Schnittstellen

Viele domänenspezifische Sprachen werden von Hand geschrieben, allerdings existieren durchaus Ansätze solche Sprachen auch über eine entsprechende definierte Grammatik zu generieren.

Auch Codegeneratoren wie z. B. für GraphQL-Schnittstellen erzeugen unter Umständen Fluent Interfaces, welche dann genutzt werden können:

return new ProductResponseProjection()
        .categoryId()
        .categoryName()
        .issuingCountry();

In diesem Fall wird aus dem GraphQL-Schema eine entsprechende API plus das dazugehörige Modell generiert, welches sich dann nach Außen hin als Builder– bzw. Fluent Interface darstellt.

Ob und wieweit die Generierung von sprechenden Schnittstellen automatisiert werden kann, hängt immer sehr stark vom jeweiligen Anwendungsfall ab.

Fazit

Fluent Interfaces sind ein Begriff, der nun schon eine Weile durch die Welt geistert und wahrscheinlich finden sich in den Köpfen unterschiedliche Vorstellung davon.

Teilweise ist es schwierig, eine harte Definition von sprechenden Schnittstellen zu finden, wie der StringBuilder unter Java zeigt:

String abc = new StringBuilder()
        .append("ABC")
        .append("DEF")
        .append("GHJ")
        .toString();

Die append-Methode stellt hier eine Art Builder-Pattern in Form einer sprechenden Schnittstelle zur Verfügung und kann damit im Kleinen als solche gesehen werden. Die Bandbreite von Fluent Interfaces im Kleinen, wie dem Builder-Pattern, bis zu ausgewachsenen domänenspezifischen Sprachen mit eigenen Grammatiken ist groß.

Alles in allem spielen sprechende Schnittstellen vorwiegend bei komplexen Objekten und entsprechenden Use Cases ihre Stärken aus. Sie sorgen für mehr Verständnis und nutzen die Möglichkeiten des Compilers und der IDE zur Unterstützung aus.

Trotz der Vorteile, welche solche sprechenden Schnittstellen bieten, sollte vor der Implementation immer genau überlegt werden, ob für den jeweiligen Anwendungszweck wirklich ein solche benötigt wird. Hier sollte die erhöhte Komplexität in der Implementation und Pflege mit der Notwendigkeit abgewogen werden.

Der Quellcode der einzelnen vorgestellten sprechenden Schnittstellen kann über GitHub eingesehen und ausprobiert werden.

Dieser Artikel erschien ursprünglich auf Golem.de und ist hier in einer alternativen Variante zu finden.

Erste Schritte mit Rust

Vor ein paar Tagen wollte ich die Programmiersprache Rust ausprobieren. Bei Rust handelt es sich um eine Sprache, welche syntaktisch stark an C angelehnt ist und besonderes Augenmerk auf Sicherheit legt. Die erste Frage, die sich mir stellte, ist, ob es für Rust, eine vernünftige IDE-Unterstützung existiert. Fündig geworden bin ich bei IntelliJ IDEA, welches nach Installation des Rust-Plugins zur Programmierung in der Sprache genutzt werden kann. Debugging von Rust-Programmen wird aus technischen Gründen nur in der JetBrains IDE CLion unterstützt, sodass hier einige Abstriche gemacht werden müssen. Neben der Rust-Integration für IntelliJ IDEA gibt es ebenfalls ein Plugin für Visual Studio Code, welches hier allerdings nicht weiter behandelt werden soll.

Mit dem passenden Plugin beherrscht IntelliJ IDEA Rust.

Neben der eigentlichen IDE wird Rust benötigt. Dieses kann über die offizielle Seite der Sprache bezogen werden. Nach der Installation kann in der IDE ein erstes Projekt angelegt werden. Anschließend findet sich in einem Ordner mit dem Namen src eine Datei mit dem Namen main.rs:

fn main() {
    println!("Hello, world!");
}

In dieser Datei findet sich ein minimales Hello world-Programm. Das Ausrufezeichen hinter dem println zeigt unter Rust an, das es sich um ein Makro handelt. Damit können Methoden und Makros einfach auseinander gehalten werden. Da ich Hello world-Programme immer etwas sinnfrei finde was das Lernen einer neuen Programmiersprache angeht, wollte ich für den ersten Versuch das Spiel Zahlenraten programmieren. Ziel des Spieles ist es eine Zahl zwischen 0 und 1000 zu erraten, welche der Rechner sich ausgedacht hat. Dazu muss im ersten Schritt eine Variable definiert werden, in welcher die zufällige Zahl gespeichert wird. Grundsätzlich sieht eine Variablendefinition und Deklaration unter Rust wie folgt aus:

let name: Typ = Wert;

Variablen in Rust sind immer Konstanten, wenn sie nicht explizit als veränderlich angegeben werden. Möglich ist dies mit dem Schlüsselwort mut:

let mut name: Typ = Wert;

Die Benennung von Variablen und anderen Elementen folgt in Rust einem bestimmten Schema. So werden Variablen snake_case benannt. Neben der Definition der Variable muss eine zufällige Zahl zwischen 0 und 1000 generiert werden. Dazu dient ein Zufallsgenerator, welcher über ein use eingebunden werden muss:

use rand::Rng;

Anschließend kann die Variable definiert und deklariert werden:

let number: u32 = rand::thread_rng().gen_range(0, 1000);

In diesem Fall wird ein vorzeichenloser Integer mit 32 Bit Breite als Datentyp definiert. Wird nun versucht das Rust-Programm zu kompilieren, so wird die Fehlermeldung:

error[E0432]: unresolved import `rand`
 --> src\main.rs:1:5
  |
1 | use rand::Rng;
  |     ^^^^ use of undeclared type or module `rand`

error[E0433]: failed to resolve: use of undeclared type or module `rand`
 --> src\main.rs:4:23
  |
4 |     let number: u32 = rand::thread_rng().gen_range(0, 1000);
  |                       ^^^^ use of undeclared type or module `rand`

error: aborting due to 2 previous errors

auftauchen. Grund hierfür ist, dass das genutzte Paket über den Paketmanager von Rust bezogen werden muss. Pakete werden über den Paketmanager Cargo bezogen. Die entsprechenden Pakete des offiziellen Repositorys sind unter crates.io zu finden. In der Datei Cargo.toml müssen die entsprechenden Abhängigkeiten eingebunden werden:

[dependencies]
rand = "0.7.3"
text_io = "0.1.8"

Neben dem Pseudozufallszahlengenerator, wurde gleich noch das Paket text_io eingebunden, welches später für die Eingabe von Text benötigt wird. Dieses Paket stellt hierbei das Makro read zur Verfügung, mit dessen Hilfe eine Eingabe realisiert werden kann:

let user_number: u32 = read!();

Damit sind die Grundlagen für das Spiel Zahlenraten gelegt und der Rest des Quellcodes ergibt praktisch sich von selbst:

use rand::Rng;
use text_io::read;

fn main() {

    let number: u32 = rand::thread_rng().gen_range(0, 1000);

    let mut running = true;

    println!("Ich habe mir eine Zahl zwischen 0 und 1000 ausgedacht.");

    while running {

        println!("Dein Vorschlag: ");
        let user_number: u32 = read!();

        if number > user_number {
            println!("Meine Zahl ist größer.");
        } else if number < user_number {
            println!("Meine Zahl ist kleiner.");
        } else {
            running = false;
        }
    }

    println!("Du hast die Zahl erraten. Es war die {}.", number);
}

Hervorzuheben ist, das unter Rust in if-Statements und Schleifen keine Klammern benötigt werden. Werden diese trotzdem genutzt, so warnt der Compiler entsprechend und bittet die überflüssigen Klammern zu entfernen.

Neben der entsprechenden Dokumentation auf der offiziellen Seite empfiehlt sich das Rust Cookbook als Quelle für die ersten Schritte unter Rust. Wer Rust einfach mal im Browser ausprobieren möchte, kann hierfür den Rust Playground nutzen.

Mono und der Compiler

Bei dem kompilieren eines Mono Projektes unter MonoDevelop auf einem Kubuntu System kam es zu folgender Fehlermeldung:

Could not obtain a C# compiler. C#-Compiler für Mono / .NET 2.0 nicht gefunden.

Das Problem entsteht dadurch das Mono verschiedene Compiler kennt, jeweils für die 1.1er, die 2.0er, die 2.1er und die 4.0er Laufzeitumgebung. Die Lösung ist eine einfache Nachinstallation der betreffenden Compiler mittels:

sudo apt-get install mono-mcs mono-gmcs mono-dmcs

Danach sollten die Projekte wieder ohne Probleme kompilieren.

Weitere Informationen gibt es unter:
http://www.mono-project.com/CSharp_Compiler