Reguläre Ausdrücke, kurz Regex genannt, sind aus der heutigen Softwareentwicklung kaum wegzudenken. Ob Textsuche, Datenvalidierung oder komplexe Mustererkennung – sie bieten eine flexible und mächtige Methode, um mit Text zu arbeiten. Doch obwohl viele Entwickler regelmäßig Regex einsetzen, bleibt das Verständnis, wie Regex-Engines im Inneren arbeiten, oft verborgen. Der Aufbau einer eigenen Regex-Engine bietet nicht nur Einblicke in fundamentale Konzepte der Informatik, sondern fördert auch ein tieferes Verständnis von Compiler-Techniken und Graphentheorie. In diesem Beitrag wird der Prozess erklärt, wie man von einem regulären Ausdruck zu einem lauffähigen Erkennungsautomat gelangt, und wie man diesen in C++ implementiert.
Dabei wird besonders der Schwerpunkt auf die theoretischen Grundlagen gelegt, die zur praktischen Realisierung nötig sind. Ein regulärer Ausdruck beschreibt eine Sprache – das heißt, er definiert eine Menge von Zeichenketten, die zu diesem Muster passen. Diese Sprache kann durch verschiedene Grundoperationen erzeugt werden: Vereinigung, Konkatenation und Kleene-Stern. Vereinigung entspricht dem logischen ODER, das bedeutet, entweder der Ausdruck links oder rechts vom Operator trifft zu. Konkatenation bedeutet, dass zwei Muster aneinandergehängt werden, sodass die Reihenfolge der Zeichen exakt erfüllt wird.
Der Kleene-Stern ermöglicht die Wiederholung eines Musters beliebig oft, einschließlich keiner Wiederholung. Um beispielsweise das Muster (a|b)*ab zu verstehen, stellt man sich vor, beliebig viele Zeichen a oder b, gefolgt von einem a und einem b, zu akzeptieren. Eine Reihe von Worten wie ab, bab, oder aaab würde zu einer solchen Sprache gehören. Um diese Muster nicht nur zu beschreiben, sondern auch auf Eingabedaten anwendbar zu machen, müssen reguläre Ausdrücke in erkennbare Automaten übersetzt werden. Hierzu werden sogenannte endliche Automaten verwendet.
Der nichtdeterministische endliche Automat (NFA) ist dabei ein grundlegendes Konzept. NFAs können sich aufgrund ihrer Nichtdeterminismus-Eigenschaft quasi an mehreren Orten gleichzeitig befinden. Dies bedeutet, dass das System für jeden eingehenden Buchstaben mehrere mögliche Übergänge hat, inklusive der Möglichkeit, Übergänge ohne Konsum von Eingaben (ε-Übergänge) zu nutzen. Ein solcher Automat wird formal durch Zustandsmengen, ein Eingabealphabet, eine Übergangsfunktion, einen Startzustand und eine Menge akzeptierender Zustände definiert. Der Bau eines NFAs aus einem regulären Ausdruck lässt sich elegant durch das McNaughton-Yamada-Thompson-Verfahren realisieren.
Dieses Verfahren baut rekursiv komplexe Ausdrücke aus einfacheren zusammen, wobei stets ein einzelner Start- und ein einzelner Endzustand für jede Teilausdrucks-NFA sichergestellt wird. Für den leeren String ε erzeugt man beispielsweise eine einfache Verbindung von Start- zu Endzustand über einen ε-Übergang. Für einzelne Zeichen wird eine unmittelbare Verbindung mit dem entsprechenden Buchstaben als Übergang erzeugt. Unionen werden durch einen neuen Start- und Endzustand mit ε-Übergängen zu den NFAs der Teilmengen realisiert. Konkatenation wird durch das Verbinden des akzeptierenden Zustands des ersten NFAs mit dem Startzustand des zweiten mittels eines ε-Übergangs modelliert.
Der Kleene-Stern schließlich erzeugt Zyklen, die Wiederholungen ermöglichen, indem er zusätzliche ε-Übergänge vom Start- zum akzeptierenden Zustand sowie vom akzeptierenden zurück zum Startzustand hinzufügt. Zur Implementation werden Zustände als Klassen mit Sammlungen von Übergängen modelliert. Jeder Zustand hält eine Liste von ε-Übergängen und eine Map von Eingabesymbolen auf Folgezustände. Die Verwaltung der Lebenszeit der Objekte erfolgt mittels modernem Speicher-Management wie smarten Zeigern, um Speicherlecks auszuschließen. Die Konstruktion des NFA beginnt mit einfachen Basiselementen, die dann schrittweise zu größeren Einheiten verschmolzen werden.
Ein wichtiger Teil der Engine ist die Funktion zur Berechnung der ε-Hülle, welche alle erreichbaren Zustände bei exklusiver Nutzung von ε-Übergängen bestimmt. Dieses Verfahren wird typischerweise mit Hilfe einer Tiefensuche realisiert und umfasst alle Zustände, die von einer gegebenen Menge aus ohne Eingabe anderer Zeichen erreichbar sind. Ebenso wird die Übergangsfunktion für ein gegebenes Eingabezeichen realisiert, die anhand der aktuell möglichen Zustände die Folgezustände ermittelt. Das Parsen des regulären Ausdrucks erfolgt mithilfe eines rekursiven Abstiegsparsers, der die Operatoren in der richtigen Priorität abarbeitet. Die Reihenfolge von Operatoren-Priorität ist Kleene-Stern, danach Konkatenation und zuletzt die Vereinigung.
Die Parser-Routinen verarbeiten jeweils den Teil des Ausdrucks, der einer Priorität entspricht, und rufen innerlich höher priorisierte Routinen auf, um Korrektheit und Struktur zu gewährleisten. Klammern werden dabei speziell behandelt, um verschachtelte Ausdrücke korrekt zu interpretieren. Das Ergebnis des Parsers ist ein kompletter NFA, der den gesamten regulären Ausdruck abbildet. Sobald der NFA generiert ist, können Eingabeworte gegen ihn geprüft werden. Die Simulation der Automata erfolgt, indem für jeden Buchstaben zunächst alle erreichbaren Folgezustände mittels der Übergangsfunktion gefunden und anschließend deren ε-Hüllen berechnet werden.
Wenn zu irgendeinem Zeitpunkt keine erreichbaren Zustände mehr vorliegen, bedeutet das, dass die Eingabe nicht akzeptiert wird. Nach Verarbeitung aller Eingaben erfolgt die Prüfung, ob einer der derzeit möglichen Zustände ein akzeptierender Zustand ist. Falls ja, gehört die Zeichenkette zur Sprache des regulären Ausdrucks. Die so realisierte Regex-Engine bietet eine solide Basis zum Verstehen und Experimentieren mit regulären Ausdrücken. Sie ist zwar einfach gehalten und noch nicht auf maximale Effizienz optimiert, liefert aber wesentliche Einblicke in den Aufbau und Betrieb solcher Pattern-Matching-Werkzeuge.