Programmiersprachen und Übersetzer
02 - Syntaktische Analyse

Inhaltsverzeichnis

1 Was ist die Syntax einer Sprache?

Ein Übersetzer für (Maschinen-)ProgrammeSiehe Ebenenmodell. wird meistens mit einer, oft sehr langen, Zeichenkette konfrontiert, von der der Programmierer behauptet, es wäre ein valides Programm. Da der Programmier beim Kodieren wahrscheinlich unter Zeitdruck stand, faul war, oder einfach ein anderes Verständnis von Ästhetik hat, darf der Übersetzer kreative Formatierungen und allerhöchstens die Erfüllung der minimalen Sprachregeln erwarten. Er muss also herausfinden, ob diese Zeichenkette ein valides Programm der erwarteten Sprache darstellt oder ob es fehlerhaft ist.

Der erste Schritt dieser akribischen Untersuchung des Quellprogramms ist die Syntaktische Analyse oder Syntaxanalyse. Diese ist Vergleichbar mit der Rechtschreib- und Grammatikprüfung bei normalsprachlichen Texten, bei der es zunächst auch nur darum geht, ob alle Worte richtig geschrieben und die Regeln der Satzstruktur eingehalten wurde. Dabei überprüft die Syntaxanalyse ebenso wenig den Sinn eines Satzes, welchen man auch als seine Semantik bezeichnen kann, wie dies ein Rechtschreibprogramm tut.

Ein Beispiel, im Kontext von Programmiersprachen, für den Unterschied zwischen Syntax und Semantik ist das folgende Pythonprogramm. Es stellt zwar ein syntaktisch korrektes Programm dar, ist also nach den Regeln der Sprache aufgeschrieben worden, aber es ergibt keinen Sinn, weil die Variable undef_variable nicht definiert wurde. Versuchen Sie im interaktiven Interpreter, der wie ein Übersetzer auch eine Syntaxanalyse durchführt, einen Syntaxfehler zu provozieren.

foo = undef_variable + 3

Um zu prüfen, ob ein gegebenes Programm wirklich zum Maschinenmodell passt, wird es demnach nicht ausreichen, seine syntaktische Korrektheit zu prüfen. Aber es ist ein erster wichtiger Schritt.

Als Seiteneffekt der Syntaktischen Analyse bekommen wir auch noch den abstrakten Syntaxbaum oder AST (engl. abstract syntax tree). Dieser Baum, der das Rechenergebnis von Scanner und Parser ist, fängt die Struktur des Programms ein und wird in der folgenden Übersetzung als Datenbasis benötigt. Mit dieser Struktur des Programms meinen wir so etwas wie die korrekte Klammerung von arithmetischen Ausdrücken (1+2*3-4 wird verstanden als 1+(2*3)-4) oder welche Codezeile zum then-Teil einer Bedingung gehört.

Für den Informatiker ist die Beschäftigung mit dem Thema Scannen und Parsen aus mehreren Gründen sehr lohnend: Durch den direkten Vergleich von Programmtext und AST sieht man, dass die konkrete Syntax einer Programmiersprache relativ egal ist, hauptsächlich mit Ästhetik, Klarheit und leichter Verständlichkeit zusammenhängt, und vom Übersetzer bereits im ersten Schritt weggewischt wird. Dennoch ist die Extraktion von Strukturen aus einer Zeichenkette ein immer wiederkehrendes Thema der Informatik, das nicht nur bei Programmiersprachen auftritt, sondern, zum Beispiel, auch bei Dateiformaten oder Netzwerkprotokollen.

Daher hat die Informatik auch eine Reihe von präzisen Formalismen entwickelt (reguläre und kontextfreie Sprachen), mit Hilfe derer man solche Probleme erfassen und angehen kann. Mit dieser Formalisierung kam auch die Möglichkeit Werkzeuge zu bauen, die einem dabei helfen, Werkzeuge zur Syntaxanalyse zu erstellen (Parsergeneratoren)Parsergeneratoren werden auch Compiler-Compiler genannt, da sie Übersetzer sind, die Code erzeugen, der in Übersetzern verwendet wird..

2 Scanner und Tokenstrom

Die Eingabe unseres Übersetzers ist eine Zeichenkette, die der Programmierer mühsam auf der Tastatur eingetippt und dann in einer Datei gespeichert hat. In dem Prozess des Kodierens hat der Programmierer die Programmstruktur, die in seinem Geist entstanden ist, in diese Zeichenkette serialisiert.

Als ersten Schritt wollen wir die Zeichenkette in einen linearen Tokenstrom aus sprachspezifischen Elementen, wie Schlüsselwörter und Bezeichner, verwandeln. Dazu läuft der Scanner (= Lexer) einmal(!) über die Zeichenkette und emittiert, immer wenn ein vollständiges Sprachelement, was häufig aus mehr als einem Zeichen besteht, gelesen wurde, ein einzelnes Token. Diese Token haben immer eine Tokenklasse (z.B. Bezeichner oder "öffnende runde Klammer") und manchmal eine Nutzlast. In dieser Nutzlast transportiert der Scanner weitere Informationen zum Token zu den nachfolgenden Übersetzerstufen. So haben beispielsweise alle Bezeichner die gleiche Tokenklasse (identifier) und tragen die überdeckten Zeichen aus der Eingabe (das Lexem) als Nutzlast (z.B., Bezeichnername) mit sich herum.

Durch den Prozess des Scannens eliminieren wir Leerzeichen und jegliche Codeformatierung, wir vereinheitlichen unterschiedliche Schreibweisen des gleichen Sprachelements und wir reduzieren die Anzahl der zu verarbeitenden Objekte erheblich. Dies macht Übersetzerbauern das Leben im Folgenden erheblich einfacher, weil der Rest der Syntaxanalyse deutlich schneller ablaufen kann und wir viele Sonderfälle der Sprache bereits abgedeckt haben. So würden wir beispielsweise einen deutlich aufwendigeren Parser benötigen, wenn wir erst dort Kommentare, die überall im Quellcode auftauchen können, behandeln würden.

Um einen Scanner für eine Sprache zu erstellen, haben wir prinzipiell zwei Möglichkeiten: Entweder wir kodieren eine entsprechende Funktion manuell oder wir leiten einen Scanner von einer Menge regulärer Ausdrücke mit Hilfe eines Generators ab. Der manuelle Ansatz wird auch ad-hoc Ansatz genannt, da die Kodierung als ein normales Programm keinem Formalismus folgt. Dies hat den Nachteil, dass es schwierig ist, einen korrekten Scanner von Hand zu schreiben, aber es bringt auch den Vorteil, dass man an jeder Stelle Sonderbehandlungen einbauen kann, mit sich.

In beiden Fällen benutzt der Scanner zwei Operationen auf dem Eingabestrom: read() konsumiert ein Zeichen vom Anfang und gibt es zurück, peek() gibt dasselbe Zeichen zurück, ohne es zu konsumieren. Insbesondere letzteres, das Vorausschauen, oder look-ahead, im Zeichenstrom ist ein Konzept, welches uns beim Parser wieder begegnen wird. Eine wichtige Eigenschaft von Scannern und Parsern ist es, wie viele Zeichen der Vorausschau benötigt werden. Die meisten Sprachen kommen mit einem Scanner aus, der nur ein einzelnes Zeichen Vorschau benötigtFortran ist ein trauriges Gegenbeispiel: http://shape-of-code.coding-guidelines.com/2009/12/20/parsing-fortran-95/.

Im BeispielVollständiger Scanner: ./lst/02-scanner.py liefert die Funktion scanner_next() bei jedem Aufruf eine 2-elementige Liste, bestehend aus Tokentyp und Nutzlast, zurück. Die Funktion überspringt zunächst alle Leer- und Tabulatorzeichen, bevor die eigentliche Tokenerkennung startet. Dieser Beispielscanner erkennt nur Ganzzahlen, allerdings ist es möglich diese Zahlen in binärer (0b111), oktaler (0777), oder hexadezimaler Schreibweise zu erkennen. Hierfür erkennt der Scanner, ob ein entsprechendes Präfix vorliegt (z.B. 0x für hexadezimal) und liest danach entsprechend der erkannten Notation solange Zeichen aus der entsprechenden Klasse zulässiger Zeichen, bis ein unzulässiges Zeichen erreicht wird (stream.read_many()).

class Stream:
    """Eine (ineffiziente) Abstraktion fuer einen Eingabestrom aus Zeichen,
       die es erlaubt ein einzelnes Zeichen in die Zukunft zu blicken
       (look-ahead).
    """
    def __init__(self, data):
        self.data = list(data)

    def peek(self):
        if len(stream.data) == 0:
            return '\0'
        return stream.data[0]

    def read(self):
        if len(self.data) == 0:
            return '\0'
        return self.data.pop(0)

    def read_many(self, allowed_characters):
        ret = ""
        while self.peek() in allowed_characters:
            ret += self.read()
        return ret


#scanner0
def scanner_next(stream):
    # Leerzeichen vom Anfang wegwerfen
    while stream.peek() in " \t":
        stream.read()

    ch = stream.read()
    if ch == '0': # literal: hex, oktal, oder binär
        ch = stream.read()
        if ch == 'x':
            hexdigits = "0123456789abcdefABCDEF"
            chars = stream.read_many(hexdigits)
            value = int(chars, base=16)
            return ["literal", value]
        # ....
#scanner1
        elif ch == 'b':
            chars = stream.read_many('01')
            value = int(chars, base=2)
            return ['literal', value]
        elif ch in '01234567':
            chars  = ch + stream.read_many('01234567')
            value = int(chars, 8)
            return ['literal', value]
#scanner2
        else:
            raise ScannerError("invalid character")
#scanner3

stream = Stream("0x17 027 0b10111 0777")

while True:
    token = scanner_next(stream)
    if token is None:
        break
    print(repr(token))

An diesem Beispiel ist es wichtig zu erkennen, dass das Zeichen 0 ein gemeinsames Präfix ist und erst mit dem zweiten Zeichen (b, x, oder eine Oktalzahl) wirklich feststeht, welche Notation an dieser Stelle vorliegt. Mit jedem weiteren gelesenen Zeichen, wird hier die Menge der möglichen Notationen weiter eingeschränkt. So ist ab der ersten gelesenen 0 klar, dass es sich nicht mehr um eine Zahl in Dezimalnotation handeln kann.

Für den Schritt der Tokenerkennung aus einem Zeichenstrom hat sich herausgestellt, dass der Rückgriff auf formale Konzepte aus der theoretischen Informatik höchst lohnend ist und, dass man mit regulären Ausdrücken die Token der meisten Programmiersprachen hervorragend beschreiben kann. Reguläre Ausdrücke beschreiben eine reguläre Sprache und haben die gleiche Ausdrucksstärke wie eine reguläre Grammatik. Man kann sagen, dass reguläre Ausdrücke ein Formalismus ist, mit dem man reguläre Grammatiken konstruieren kann, ohne ausversehen in die nächst komplexere Sprachklasse (kontextfrei) zu rutschen. Daher wollen wir nun der ad-hoc Implementierung scanner_next() aus dem Beispiel einen formaleren Hintergrund geben.

Zu Beginn müssen wir uns daran erinnern, was Grammatiken überhaupt waren. Die theoretische Informatikvorlesung ist lange her, und die Erinnerung bereits verschwommen. Zunächst ist eine (formale) Grammatik ein Regelsatz, der beschreibt, wie man, beginnend von einem Startsymbol, über einen iterativen Textersetzungsprozess zu einer Zeichenkette kommt. Zu jedem Zeitpunkt besteht die Zeichenkette aus einer Reihe von terminalen und nichtterminalen Symbolen und die Ersetzung wird solange wiederholt, bis nur noch terminale Symbole übrig sind. Die Ersetzung an sich erfolgt durch eine Menge von Regel, die ein Muster erkennt und durch ein anderes Muster ersetzt. Alle Zeichenketten, die abgeleitet werden können, nennt man die zugehörige (formale) SpracheVerwechslungsgefahr: Formale Sprachen sind etwas anderes als Programmiersprachen und sie haben nichts mit einem Maschinenmodell zu tun. Das Skript unterscheidet daher strikt zwischen formalen Sprachen und (einfach nur) Sprachen.. Im Folgenden leiten wir, Zeile für Zeile, die Zeichenkette 0xa2c aus dem Startsymbol hexliteral her:

hexliteral
hexprefix hexdigit hexdigit*
0x hexdigit hexdigit*
0xa hexdigit*
0xa2 hexdigit*
0xa2c hexdigit*
0xa2c

Je nachdem wie die Regeln einer Grammatik strukturiert sind, werden diese in unterschiedliche Klassen eingeteiltWikipedia: Chomsky-Hierarchie. Die einfachste Klasse, die besonders wünschenswerte Eigenschaften hat, sind die regulären Grammatiken. Bei dieser Klasse gilt, dass alle Regeln kontextfrei sind und das man mit einem endlichen Automaten erkennen kann ob ein Wort in der Sprache ist (Wortproblem). Dabei bedeutet kontextfrei, dass auf den linken Seiten der Ersetztungsregeln immer nur ein einzelnes Nichtterminal steht, sodass jedes Nichtterminal, egal in welchem Kontext es steht, immer gleich ersetzt wird. Die Abbildbarkeit von regulären Grammatiken auf endliche Automaten führt dazu, dass man aus der Grammatik sehr leicht ein Programm ableiten kann, welches das Wortproblem lößt. Dabei drehen wir die Richtung der Grammatik um, und erzeugen keine Zeichenketten mehr, sondern konsumieren diese. Genau das, was wir für unseren Scanner brauchen.

Allerdings ist der Nachweis ob eine gegebene Grammatik wirklich regulär ist, nicht ganz leicht. Daher gehen wir das Problem von einer anderen Seite heran und greifen auf reguläre Ausdrücke zurück um die Token unserer Sprache zu beschreiben. Bei diesem Formalismus ist konstruktiv sicher gestellt, dass jeder reguläre Ausdruck eine reguläre Sprache beschreibt. Daher kann man aus jedem regulären Ausdruck eine entsprechende reguläre Grammatik ableiten und es kann niemals geschehen, dass ein regulärer Ausdruck ausversehen nicht mehr regulär ist. Erreicht wird diese Garantie durch das Verbot rekursiver ErsetzungenIn keiner der möglichen Ersetzungen eines Nichtterminals kann das Nichtterminal noch einmal auftreten.. Um dennoch das wiederholte Auftreten eines Nichtterminals zu erlauben, welches in regulären Grammatiken möglich istReguläre Grammatik: A -> xA, Regulärer Ausdruck: x* wird der Kleene-Stern eingeführt, bei dem das direkt vorangegangene Nichtterminal 0 bis N mal ersetzt werden kann. Beim Parsen werden wir kontextfreie Grammatiken verwenden, die rekursive Ersetzungen enthalten und daher nicht mittels regulärerer Ausdrücke beschrieben werden können.

Für das Erkennen einer regulären Sprache konstruiert man aus den Grammatikregeln einen endlichen Automaten, der die Zeichenkette elementweise konsumiert. An jeder Kante des Automaten gibt es eine Bedingung (engl. Guard), die angibt, bei welchen gelesenen Zeichen die Transition möglich ist. Ist die Entscheidung, welche Transition genommen wird, zu irgendeinem Zeitpunkt nicht eindeutig, so ist der Automat nicht-deterministisch. Erreichen wir beim konsumieren einen akzeptierenden Zustand, so haben wir ein Wort der formalen Sprache erkannt. Allerdings können akzeptierende Zustände auch wieder verlassen werden, um noch längere Worte der Sprache zu erkennen. Im Fall der Hexadezimalzahlen dürfen am Ende noch beliebig viele Zeichen aus der Klasse [0-9a-fA-F] auftauchen.

Im Kontext eines Scanners bedeutet das Erreichen eines akzeptierenden Zustandes, dass wir ein valides Token gefunden haben. Allerdings könnte dieses Token auch das Präfix eines längeren Tokens sein. So ist zwar 0xa2 eine valide hexadezimale Zahl, aber es ist auch das Präfix für das Wort 0xa2c. Für Scanner wollen wir immer jenes Token finden, das die maximale Länge hat (longest possible match).

Da es in einer Programmiersprache unterschiedliche Arten von Token gibt, die es zu erkennen gilt, müssen wir mehrere Automaten kombinieren; ein Automat/Grammatik/regulären Ausdruck/Sprache für jeden Tokentyp. Dazu lassen wir "einfach" alle Automaten parallel laufen und füttern jedem Automaten jedes gelesene Zeichen, sodass jeder Automat eine Transition macht. Hat ein Automat zu einem Zeitpunkt keine valide Transition, so fällt er aus (Folien: er wird grau) und wird nicht weiter mit Zeichen gefüttert. Erreicht einer der Automaten einen akzeptierenden Zustand, so merken wir uns das erkannte Wort (auch Lexem genannt). Wir füttern die Automaten so lange mit Zeichen, bis auch der letzte von ihnen ausgefallen ist und geben das zuletzt gefundene Wort als Token zurück.

Dieses parallele Ausführen von endlichen Automaten können wir entweder tatsächlich durchführen oder wir können, was effizienter ist, einen kombinierten Automaten konstruieren: Dazu fügt man einen weiteren Startzustand ein, der über Epsilon-Transitionen die Startzustände der einzelnen Token erreichen kann (was den kombinierten Automaten immer nicht-deterministisch macht). Mittels der Potenzmengenkonstruktion leitet man den äquivalenten deterministischen Automaten her.

Zurück zu den weniger theoretischen Aspekten des Scannens. Im Beispiel auf den Folien ist eine Situation abgebildet, bei der das Schlüsselwort fn das Präfix für den Bezeichner fnord ist und zum gleichen Zeitpunkt (fn) zwei Automaten das Lexem als valides Wort erkennen würden. In solchen Fällen müssen wir durch zusätzliche Regeln angeben, welcher der Tokentypen den Vorrang hat. Für Programmiersprachen müssen Schlüsselwörter immer höhere Prioritäten als Bezeichner haben, solange der Bezeichner nicht länger ist. Dies ist der Grund, wieso Sie Schlüsselwörter nicht als Variablennamen verwenden können und diese daher auch oft als reservierte Wörter genannt werden.

Da die manuelle Konstruktion von endlichen Automaten und deren Kombination müßig ist, gibt es Programme, die aus einer Menge von regulären Ausdrücken einen Scanner erzeugen. Ein solches Programm ist flex(1). Sollten Sie später einen Lexer brauchen, der schnell und korrekt eine Zeichenkette in Tokens zerlegt, so zögern Sie nicht und nehmen Sie einen Scannergenerator zur Hand! Mehr zu den Sicherheitsaspekten von Scannern, und auch Parsern, lernen wir in einem kleinen Exkurs am Ende dieser Vorlesung kennen.

3 Parser und der Syntaxbaum

Die syntaktische Analyse hat zwei primäre Ziele: (1) Die Überprüfung der syntaktischen Regeln der Sprache, also ob das Programm richtig notiert wurde. (2) Die Befüllung einer geeigneten Datenstruktur mit der extrahierten Struktur des Programms.

Für beide Ziele stellt sich die Frage, welcher Natur die syntaktischen Konstrukte in echten Programmiersprachen sind. Hierbei fällt schnell auf, dass viele Konstrukte geschachtelt vorkommen können: Dies bedeutet, dass ein Konstrukt von einem Konstrukt anderer Art umschlossen auftreten kann. So umschließt ein Funktionsrumpf eine oder mehrere Programmanweisungen. Es ist sogar recht häufig, dass ein Konstrukt von einem Konstrukt gleicher Art umschlossen auftreten darf, und damit rekursiv ist. Dabei ist der arithmetische Ausdruck das Paradebeispiel, bei dem die Schachtelung entweder explizit durch Klammern oder implizit durch die Operatorpräzedenz notiert wird.

Um der geschachtelten Natur von Programmiersprachen Rechnung zu tragen, benötigen wir also eine Datenstruktur, die ebenfalls rekursiv ist. Die bedeutet, dass eine Instanz dieser Datenstruktur weitere, kleinere, Instanzen der gleichen Datenstruktur enthalten kann. Die einfachste rekursive Datenstruktur, die in Frage kommt, ist die einfach verkettete Liste:

struct list {
    int value;
    struct list * next;
};

Bei dieser einfach verketteten Liste ist das nachfolgende Element next wiederum eine einfach verkettete Liste (oder NULL). Allerdings enthält jedes Element einer Liste maximal eine weitere Liste. Da wir bereits, bei den Anweisungen und dem Funktionsrumpf, erkannt haben, dass ein Sprachkonstrukt mehrere "Kinder" haben kann, ist sofort klar, dass die verkettete Liste ungeeignet für unsere Zwecke ist.

Daher müssen wir auf die nächst mächtigere Klasse von Datenstruktur, nämlich die der Bäume, wechseln. Bäume sind hervorragend geeignet, die syntaktische Struktur von Programmen zu erfassen: Sie sind rekursiv, da jedes Kind eines Knotens wiederum ein Unterbaum ist; dabei sind Blätter triviale Bäume.

Für unser Beispiel der arithmetischen Ausdrücke zeigt ein innerer Knoten an, welche Operation ausgeführt werden soll. Die Kinder dieses Knotens sind die Operanden, die verrechnet werden. Da Operationen in der Regel nicht kommutativ sind, ist die Reihenfolge der Kinder wichtig.

Für die Speicherung von Bäumen können wir dann doch wieder Listen verwenden, solange wir Listen als Listenelemente zulassen. Dabei speichern wir immer im ersten Element einer Liste den aktuellen Knoten; alle weiteren Listenelemente sind die Kinder. Der Baum für den arithmetischen Ausdruck 1*2+3/4 wäre dannIn Lisp wäre es (+ (* 1 2) (/ 3 4)). In dieser Programmiersprache, die von Studierenden aufgrund ihrer immensen Menge an Klammerung gehassten wird, werden Programme direkt in einer solchen Listennotation notiert. Es gibt nur wenige weitere syntaktische Regeln. Dies erklärt auch ihre Beliebtheit bei Übersetzerbauern: Man kommt mit einem sehr einfachen Parser aus. Wenn Sie also über die folgenden Abschnitte stöhnen, denken Sie daran: Mit Lisp wäre das alles nicht nötig.:

def node(tree):
    return tree[0]

def children(tree):
    return tree[1:]

tree = ['+', ['*', [1], [2]], ['/', [3], [4]]]
print(children(tree))

Im Kapitel über Scanner haben wir bereits formale Grammatiken und formale Sprachen als eine Methode kennen gelernt, Regeln von Programmiersprachen zu notieren. Mit dieser Erfolgsgeschichte im Rücken, können wir uns nun daran machen auch die weiteren Sprachregeln formal aufzuschreiben.

Hierbei stellt allerdings die erwähnte rekursive Natur von Programmiersprachen ein Hindernis dar: Bei regulären Sprachen haben wir explizit verboten, dass Regeln sich selbst referenzieren, also rekursiv sind. Daher sind Typ-3-Grammatiken nichtDies ist auch der Grund, wieso man mit Regexen kein HTML parsen kann. Versuchen Sie es nicht, es wird immer scheitern bzw. ein Hack bleiben. geeignet, um Programme zu parsen und wir müssen auf die Typ-2 Grammatiken, also die kontextfreien Sprachen wechseln. Diese erlauben es, dass auf der linken Seite einer Regel ein Nicht-Terminal stehen darf, welches (direkt oder indirekt) wiederholt auf der rechten Seite auftauchen kann. Dies erlaubt es, dass eine kontextfreie Grammatik "Klammern zählen" kann und daher in der Lage ist korrekt balancierte Klammerungen zu erzeugen.

Im Allgemeinen ist die Komplexität des "Wortproblems" (Prüfung, ob ein Wort in der Sprache ist) für kontextfreie Sprachen kubischWikipedia: Cocke-Younger-Kasami-Algorithmus. Allerdings gibt es mit den deterministischen kontextfreien Sprachen eine Klasse von Sprachen, die in linearer Zeit, also viel viel schneller, erkennbar sind. Daher sind eigentlich alle Programmiersprachen in dieser Klasse zu finden, da das Lösen des Wortproblems gleichbedeutend mit dem Parsen ist.

Um zu verstehen, was deterministisch im Fall von kontextfreien Sprachen bedeutet, müssen wir uns Ableitungsbäume für kontextfreie Grammatiken anschauen. Bei Ableitungsbäumen entspricht jeder innere Knoten einem Nicht-Terminal, jedes Blatt entspricht einem Terminal. Die durchgeführten Ableitungen kann man von oben nach unten ablesen: Die Kinder eines Nicht-Terminals entsprechen den Elementen der rechten Seite der angewendeten Regel. Die Blätter (Terminale), wenn wir sie von links nach rechts lesen, ergeben das erzeugte Wort. Der Ableitungsbaum ist also eine Vorschrift, wie die Regel einer Grammatik anzuwenden ist, damit ein bestimmtes Wort entsteht. Umgekehrt bedeutet dies auch, dass wir beim Lösen des Wortproblems einen Ableitungsbaum angeben müssen, um zu zeigen, dass das Wort in der Sprache ist. Das rekonstruieren des Ableitungsbaums nennt man auch parsen.

Gibt es mehr als eine Möglichkeit einen Ableitungsbaum für ein gegebenes Wort zu rekonstruieren, ist die Grammatik mehrdeutig. In Falle einer mehrdeutigen Grammatik, versehen wir zwei der entstehenden Ableitungsbäume mit einem speziellen Namen: Die Linksableitung entsteht, wenn immer das linkeste Nicht-Terminal zuerst abgeleitet wird; die Rechtsableitung vice versa mit dem rechtesten Nicht-Terminal.

Gibt es genau einen Ableitungsbaum für jedes Wort, so ist sie eindeutig. Diese Eindeutigkeit ist etwas, was wir bei Programmiersprachen auf jeden Fall haben wollen. Denn der Übersetzer soll die Struktur unseres Programms ja nicht einmal so und einmal anders erkennen.

Noch strenger als "eindeutig", ist die Eigenschaft des deterministisch: Bei einer deterministischen Grammatik gibt es eine allgemeine Vorschrift, wie und in welcher Reihenfolge die Regeln angewendet werden müssen, um einen Ableitungsbaum aus einem gegebenen Wort zu rekonstruierenFormaler: Deterministische kontextfreie Sprachen können mit deterministischen Kellerautomaten erkannt werden.. Zum Beispiel kann solch eine Vorschrift sein, dass man das Wort von links nach rechts durchgeht und am jeweils nächsten Zeichen (look-ahead) entscheidet, welche Regel im Folgenden angewendet werden muss. Dies ist der Sprachklasse LL(1), mit der wir uns nun intensiver auseinander setzen werden.

3.1 Parser mit rekursivem Abstieg

Wie bereits erwähnt, kann man bei einer LL(1)-Grammatik anhand des nächsten Zeichens deterministisch entscheiden, welche Regel der Grammatik angewendet werden soll. Um dies zu verstehen, müssen wir mehr darauf eingehen, wie ein Parser für eine LL(1)-Sprache aussieht. Die einfachste Form eines LL(1)-Parsers verwendet die Methode des rekursiven Abstiegs, um aus einem Wort den Ableitungsbaum zu rekonstruieren.

Bei einem solchen Parser wird für jedes Nicht-Terminal eine Funktion erzeugt, die genauso heißt wie das Nicht-Terminal. Diese Funktionen haben den Tokenstrom bzw. ein Scanner-Objekt als Argument und können vorne aus diesem Strom Zeichen entnehmen (read()). Weiterhin können sie das erste Zeichen mittels peek() anschauen, ohne es zu entnehmen (look-ahead). Als Rückgabewert liefern diese Funktionen (partielle) Ableitungsbäume.

Wenn wir eine solche Nicht-Terminal-Funktion aufrufen, entscheidet sie anhand des aktuellen Look-aheads, welche Regel nun angewendet werden muss und konsumiert im Folgenden solange Token aus dem Strom, bis das Teilwort, das zu dieser Regelanwendung gehört, aus dem Tokenstrom entfernt ist. Ein solcher Aufruf ist also ein Art "Rückwärtsableitung" auf der linken Seite des Tokenstroms. Die Funktion ordnet die Token so an, dass sie die Blätter des zurückgegebenen Ableitungsbaums sind.

Wird an einer Stelle auf der rechten Seite ein Nicht-Terminal erwartet, so ruft die Funktion rekursiv die entsprechende Parsefunktion auf und gibt den Rückgabewert als Unterbaum weiter. Es sind diese rekursiven Aufrufe, die der Klasse der Parser mit rekursivem Abstieg ihren Namen geben!

Um ein ganzes Programm zu parsen, rufen wir einfach nur die Funktion, die dem Startsymbol entspricht, auf. Im Folgenden können Sie mit dem intAdd-Parser aus den Vorlesungsfolien herumspielen.

from collections import namedtuple

class ParserError(RuntimeError):
    pass

class Stream:
    """Eine (ineffiziente) Abstraktion fuer einen Eingabestrom aus Tokens,
       die es erlaubt, ein einzelnes Zeichen in die Zukunft zu blicken.
       (look-ahead).
    """
    def __init__(self, data):
        self.data = list(data)

    def peek(self):
        """Returns the type of the current token."""
        if len(self.data) > 0:
            return self.data[0][0]

    def read(self, expected=None):
        """Consumes the first token and returns it. If `expected' is given,
           the token type is checked and a ParserError is thrown in case."""
        token = self.data.pop(0)
        if expected is not None and token[0] != expected:
            raise ParserError("Expected type: " + str(expected))
        return token[1]

TOK_INT = 0
TOK_PLUS = 1
TOK_EOF = 2

#parser0
def intAdd(token_stream):
    # Nur eine Regel: intAdd -> Int intAddTail
    ## 'Int': Das erste Token muss ein INT sein
    int_load  = token_stream.read(expected=TOK_INT)
    ## 'intAddTail': Rekursiver Abstieg
    tail_tree = intAddTail(token_stream)
    # Baumstruktur als geschachtelte Listen
    return ["intAdd", int_load, tail_tree] #parser1

#parser2
def intAddTail(token_stream):
    # Verwende Look-Ahead zur Regelauswahl
    if token_stream.peek() == TOK_PLUS:
        # intAddTail -> '+' Int intAddTail
        ## '+': consume the PLUS
        _         = token_stream.read(expected=TOK_PLUS)
        ## 'Int': capture the integer
        int_load  = token_stream.read(expected=TOK_INT)
        ## 'intAddTail': Rekursiver Abstieg!
        tail_tree = intAddTail(token_stream)
        # Baumstruktur zurückgeben
        return ["intAddTail", int_load, tail_tree]
    elif token_stream.peek() == TOK_EOF: #... #parser3
        return ["intAddTail",]
    else:
        raise ParserError("Expected + or $.")

token_stream = Stream([
    [TOK_INT, 1],
    [TOK_PLUS, None],
    [TOK_INT, 2],
    [TOK_PLUS, None],
    [TOK_INT, 3],
    [TOK_EOF, None]
])

print(intAdd(token_stream))

Parser mit rekursivem Abstieg können entweder manuell erstellt werden, oder man verwendet einen LL(1)-Parsergenerator. Ein solcher Generator bekommt eine LL(1)-Grammatik als Eingabe, analysiert die Struktur und die Eigenschaften der Grammatik und generiert ein entsprechendes Programm. Mit dieser Analyse von LL(1)-Grammatiken wollen wir uns nun genauer beschäftigen, um zu verstehen, wie ein solcher Generator genau funktioniert. Dies wird Sie nicht nur in die Lage versetzen LL(1)-Grammatiken zu formulieren, sondern auch selbst einen Parsergenerator zu schreiben. Es ist einfacher, als Sie befürchten!

Die wichtigste Information, die wir aus einer LL(1)-Grammatik extrahieren wollen, sind die PREDICT-Mengen: Für jede Regel der Grammatik gibt die zugehörige PREDICT-Menge, die terminale Symbole enthält, an, ob diese Regel genommen wird. Diese Mengen enthalten also genau jenen Token, anhand dessen der Parser mit rekursivem Abstieg, anhand des Look-Aheads, seine Entscheidungen trifft. Vice Versa: Haben wir die PREDICT-Mengen, so können wir daraus also einfach einen Parser konstruieren.

Zusätzlich können wir anhand der PREDICT-Mengen auch prüfen, ob eine gegebene Grammatik eine LL(1)-Grammatik ist. Überschneiden sich die PREDICT-Mengen von zwei Regeln, die das gleiche Nicht-Terminal auf der linken Seite haben, auch nur in einem Zeichen, so liegt keine LL(1)-Grammatik vor. Im Parser mit rekursivem Abstieg könnten wir keine deterministische Entscheidung über die nächste anzuwendende Regel treffenYouTube: Feuerzangebowle: Ostgoten, die wissen auch nicht ganz genau, wo sie hin müssen..

Zunächst werden wir so tun, als hätten wir die PREDICT-Mengen schon und wollen uns anschauen, wie der Parsergenerator den Code für den Parser erzeugt. Dafür hat der gezeigte Parsergenerator mehrere interne Datenstrukturen: Die Liste nonterminals enthält ein Objekt für jedes Nicht-Terminal der Grammatik. Jedes Nicht-Terminal NT hat einen Namen (NT.name) und eine Liste mit Regeln, auf deren linken Seite das Nicht-Terminal vorkommt (NT.rules)Diese Regeln für das Nicht-Terminal A nennt man auch A-Produktionen.. Jede Regel hat eine linke und eine rechte Seite (rule.righthandside). Die rechte Seite ist dabei eine Liste von Symbolen (Terminale und Nichtterminale), über die der Generator iterieren kann.

Um den gezeigten Generator zu verstehen, müssen Sie auf die Einrückungen achten. Diese sind für den Parsergenerator (grau) und den generierten Code (gelb) unterschiedlich. Die gelben Zeilen sind vollständige Zeilen, die ausgegeben werden, die Einrückungen beziehen sich also auf den gelben linken Rand.

Der Generator erzeugt für jedes Nicht-Terminal eine Funktion und benennt sie anhand des Namens des Nicht-Terminals. Innerhalb der Funktion wird für jede Regel ein bedingter Block emittiert, der mit einem return Statement endet, das den Ableitungsbaum zurückgeben wird. Die erzeugte Bedingung prüft, ob das nächste Zeichen in der PREDICT-Menge, die zur aktuellen Regel gehört, enthalten ist (in). Innerhalb des bedingten Blockes werden, nacheinander, alle Symbole der rechten Seite aus dem Tokenstrom gelesen. Ist ein Symbol ein Terminal, so lässt der Generator den Parser das Zeichen direkt in der Funktion aus dem Strom konsumieren. Hierbei bekommt die read()-Funktion das optionale Argument expected=, sodass sie prüft, ob das vorderste Token wirklich von der entsprechenden Klasse ist. Ist dies nicht der Fall, so wird ein Parserfehler geworfen. Ist das Symbol ein Nicht-Terminal, so delegieren wir das Parsen für diese Teil der rechten Seite an die entsprechende Funktion (rekursiver Abstieg). Weiterhin legt der Generator für jedes Symbol eine Variable an, um das Token (Terminale) bzw. den Unterbaum (Nichtterminale) aufzufangen und zur Konstruktion des zurückgegebenen Baumes zu verwenden.

Da der Parsergenerator eine Grammatik als Eingabe und einen Parser als Ausgabe hat, können wir ihn als Übersetzer bezeichnen. Dabei akzeptiert der Generator alle Grammatiken der Klasse LL(1) und erzeugt ein Programm in der Programmiersprache Python.

3.2 Analyse von LL(1)-Grammatiken

Nun wollen wir uns der Berechnung der PREDICT-Mengen für die Regeln einer gegebene Grammatik zuwenden. Informell ausgedrückt stellen wir uns dabei die Frage: "Welche Regelanwendung einer Regel führt dazu, dass wir im Tokenstrom das Zeichen sehen, was wir sehen?"

Falls eine rechte Seite nur ein einziges Terminal enthält, oder mit einem Terminal startet, ist die Sache ganz klar: Die PREDICT-Menge dieser Regel hat nur ein Element, nämlich dieses erste Terminal der rechten Seite. Dies war zum Beispiel bei unserem intAdd~/~intAddTail-Beispiel ganz absichtlich der FallTatsächlich hat die Grammatikklasse, bei der alle Produktionen mit einem Terminal starten, einen eigenen Namen: Simple LL(1) oder SLL(1)..

Steht an erster Stelle der rechten Seite ein Nicht-Terminal, wird die Sache komplizierter und wir müssen zwei Hilfsmengen (FIRST und FOLLOW) und ein Hilfsprädikat (EPS) einführen, um die PREDICT-Menge zu berechnen. Das Prädikat EPS wird genau dann wahr, wenn das übergebene (teilweise abgeleitete) Wort durch beliebig viele Ableitungen zum leeren Wort werden kann. Wir werden mittels EPS prüfen, ob eine rechte Seite bzw. ein Präfix einer rechten Seite durch Ableitung "verschwinden" kann.

Die FIRST-Menge gibt an, mit welchen Zeichen die Ableitung eines (partiell abgeleiteten) Wortes anfangen kann. Dies sieht nur auf den ersten Blick genau so aus wie die Definition der PREDICT-Menge. Allerdings bezieht sich die FIRST-Menge nur auf ein Wort a (wie es auf der rechten Seite auftritt), die PREDICT-Menge auf eine Regel A->a. Der Unterschied wird sichtbar, wenn das Wort a, als Ganzes, zum leeren Wort abgeleitet werden kann und quasi verschwindet: Die PREDICT-Menge muss dann noch weitere Zeichen enthalten, die nicht in der FIRST-Menge enthalten sind, da die Regel dennoch zur Anwendung selektiert werden muss, auch wenn ihre Produktion verschwindet. Nur so erhalten wir einen vollständigen und korrekten Ableitungsbaum. Im Beispiel, müssen wir für trans die Epsilon-Produktion auswählen, wenn wir ( im Tokenstrom sehen.

Um diesen Schritt von FIRST(a) auf PREDICT(A->a) zu machen, müssen wir die Grammatik als Ganzes analysieren und nicht nur die Ableitungen, die aus der rechten Seite einer Regel folgen. Hierzu führen wir die FOLLOW-Menge ein, die für jedes Nicht-Terminal A berechnet werden kann. Neben der formalen Definition (siehe Folie), kann man FOLLOW auch informell beschreiben: Welche Terminale können direkt nach einer vollständigen Ableitung von A in einem Wort folgen? So kann, im Beispiel, dem Nicht-Terminal trans in jeder beliebigen Ableitung nur das Token ( folgen, da trans auf der rechten Seite nur in factor und vor einem ( vorkommt.

Etwas schwieriger ist factor_tail. Man könnte zwar meinen, dass + Teil der FOLLOW-Menge ist, da mittels dieser Regel die ganzen + erzeugt werden. Allerdings gibt es keine Satzform (partielle Ableitung), bei der bereits ein + rechts von factor_tail steht. Wenn man sich ansieht, was in rechten Seiten rechts von factor_tail steht, kommt man dazu, dass factor_tail am Ende von seiner eigenen Regel und am Ende der term Regel steht. In beiden Fällen können wir, weil das Wort dort endet, nichts darüber lernen, was rechts von factor_tail stehen könnte. Daher müssen wir, bei term schauen was rechts davon stehen kann. Dies bringt uns dann zu der Erkenntnis, dass die FOLLOW-Menge von factor_tail und term gleich ist.

Die Berechnung der FOLLOW-Mengen funktioniert also so, dass anhand der Nichtterminale die Regeln rückwärts gehen und dort immer schauen, was rechts vom aktuellen Nicht-Terminal vorkommen könnte. Erreichen wir das Ende einer rechten Seite, springen wir eine Stufe weiter zurück. Wir führen also eine Tiefensuche in den Regeln der Grammatik durch. Eine detailliertere Diskussion des Algorithmus zur Berechnung der FOLLOW-Menge (sowie FIRST und EPS) werden wir in den Übungen durchführen.

Mittels EPS, FIRST und FOLLOW können wir nun die PREDICT-Menge einer Regel A->a berechnen. Wenn die rechte Seite nicht verschwinden kann (EPS(a) ist False), so müssen wir nur damit rechnen, die Zeichen aus FIRST(a) zu sehen, wenn wir die Regel anwenden sollen. Verschwindet die rechte Seite (EPS(a) == True), so müssen wir zusätzlich die Zeichen aus FOLLOW(A) erwarten. Wir verschmelzen also die FOLLOW-Menge des Nicht-Terminals mit der FIRST-Menge der Regel.

Aus dem letzten Satz können wir auch direkt lernen, dass bei LL(1)-Grammatiken immer nur eine Regel von allen A-Produktionen verschwinden darf. Sonst würden wir FOLLOW(A) zu zwei PREDICT-Mengen hinzuschlagen und die Mengen wären daher nicht mehr schnittfrei. Dies ergibt auch intuitiv Sinn: Wenn wir bei A stehen und zwei Regeln zur Auswahl haben, die dieses A verschwinden lassen, woher sollen wir wissen, welche wir auswählen sollen? Die Grammatik wäre nicht mehr deterministisch.

3.3 Schwierigkeiten mit LL(1)

Mit der Berechnung von PREDICT haben wir alles, was wir für unseren, vorher vorgestellten, Parsergenerator brauchen. Ist die Welt nun also eine bessere und wir können alles, was wir von einer Programmiersprachensyntax verlangen, in LL(1) ausdrücken?Rhetorische Frage. Leider nicht, denn LL(1)-Grammatiken haben einige Probleme, die wir im Weiteren diskutieren wollen. Dennoch war die Beschäftigung mit LL(1) äußerst lohnend, weil wir gesehen haben, wie man an die Analyse einer Grammatik herangehen kann, wie ein Parsergenerator im Prinzip arbeitet und wie der fertige Parser schlussendlich arbeitet.

Das erste Problem sind gemeinsame Präfixe. Wenn zwei A-Produktionen mit dem gleichen Terminal starten, so haben sie ein solches gemeinsames Präfix. Wir könnten daher nicht mehr deterministisch entscheiden, welche der beiden Regeln wir auswählen sollen. Im Beispiel wüssten wir mit einem Token Look-Ahead (Ident) nicht, ob wir die obere oder die untere Regel wählen sollten. Wir könnten das Problem zwar lösen, indem wir uns ein weiteres Token Look-Ahead gönnen würden (Ident := vs. Ident (), aber dann würden wir LL(1)-Land verlassen und in LL(2) landen.

Die andere Variante ist das Umformen der Regeln mittels Linksfaktorisierung. Dabei spalten wir die zweideutigen A-Produktionen auf, indem wir ein weiteres _tail Nicht-Terminal einführen. Das originale Nicht-Terminal (stmt) hat nur noch eine Regel, die das gemeinsame Präfix konsumiert und mit dem _tail Symbol endet. In den _tail-Regeln wird dann anhand der nun unterschiedlichen Präfixe entschieden, welche Regel zutrifft.

Allerdings gibt es mit der Linksfaktorisierung auch ein Problem: Die umgeformte Grammatik ist nicht äquivalent zur originalen Grammatik, die die Regel mit dem gemeinsamen Präfix enthältMan kann an diesem Beispiel auch sehen, dass Sprachklassen und Grammatikklassen etwas unterschiedliches sind. Die gezeigte Sprache ist LL(1), aber die Grammatik, die wir verwendet hatten, war in LL(2). Durch die Umformung haben wir es geschafft, eine Grammatik zu erzeugen, die näher an der eigentlichen Klasse der Sprache ist.. Sie erzeugt zwar die gleiche formale Sprache, liefert aber andere Ableitungsbäume; etwas womit wir im Übersetzerbau umgehen müssen.

Das zweite Problem von LL(1)-Grammatiken ist, dass sie keine LinksrekursionLinksfaktorisierung != Linksrekursion erlauben. Linksrekursion bedeutet, dass man durch Ableitung des Nicht-Terminals A zu einer Satzform kommt, die wieder mit A beginnt. Da LL(1)-Parser linksreduzierend sind, würde das bedeuten, dass wir immer wieder versuchen A zu reduzieren, ohne ein einziges Terminal zu konsumieren; rekursiv; für immer. Der Parser würde sich also in einer Endlosrekursion fangen.

Dass eine solche linksrekursive Grammatik nicht LL(1) sein kann, sehen wir auch an den PREDICT-Mengen. Im Beispiel der linksrekursiven Variante von intAdd sind die PREDICT-Mengen für die beiden Regeln von int nicht schnittfrei, womit der LL(1)-Test fehlschlägt. Allerdings sehen wir auch, dass die linksrekursive Variante deutlich intuitiver aufzuschreiben ist als die bisher diskutierte nicht-linksrekursive Variante, die zwei Nichtterminale braucht, um die gleiche Sprache abzubilden.

Wiederum können wir (manchmal) die Grammatik heilen, indem wir ein zusätzliches Nicht-Terminal einführen. Dazu machen wir zuerst aus der Linksrekursion eine Rechtsrekursion. In der rechtsrekursiven Grammatik wächst das Wort nicht mehr an der linken Seite sondern an der rechten Seite. Daher können wir beim Parsen Zeichen an der linken Seite konsumieren, bevor wir wieder zum rekursiven Nicht-Terminal kommen. Wir machen also Fortschritt (progress) und laufen nicht mehr unendlich lange. In der rechtsrekursiven Grammatik haben wir ein gemeinsames Präfix erzeugt, das wir, wie gehabt, mit Linksfaktorisierung entfernen können.

Das dritte Problem von LL(1)-Grammatiken ist, dass sie viele Nichtterminale enthalten und daher auch große Ableitungsbäume erzeugen. Noch schlimmer wird es dadurch, dass wir mit der Linksfaktorisierung Konstrukte zerreißen: Semantisch zusammengehörenden Token werden nun in unterschiedlichen Unterbäumen abgespeichert. Bei der gezeigten Addition sind die Operanden keine direkten Geschwister mehr.

Heutzutage haben wir mit den modernen Parsergeneratoren sehr gute Werkzeuge, um Parser aus formalen Grammatiken zu erzeugen, die sowohl effizient sind, als auch "schöne" Ableitungsbäume erzeugen und sich auf 50 Jahre Forschung im Bereich der Syntaxanalyse stützen. Diese Generatoren führen, neben der reinen Analyse und Generierung, noch diverse Grammatikumformungen durch. Ebenfalls werden entsprechende Baumtransformationen auf den Ableitungsbäumen durchgeführt, sodass es gar nicht zu diesen "hässlichen" Bäumen kommt. So ist der antlr Parsergenerator in der Version 4 in der Lage, folgende Grammatik, inklusive der Operatorpräzedenz, richtig zu einem Parser mit rekursivem Abstieg umzuformen:

E -> E + E
E -> E * E

In der Übung werden wir uns genauer mit LL(1)-Parsergeneratoren und der Extraktion der EPS-, FIRST- und FOLLOW-Mengen beschäftigen. Diese intensive Beschäftigung hat den Zweck, Sie mehr in die Denke formal notierter Grammatiken einzuführen. Später, in der Praxis, sollten Sie allerdings vermeiden ihre Parser, oder gar Parsergeneratoren, selbst zu schreiben. Mehr dazu im Exkurs über Parsing und Sicherheit.

Das andere Thema, welches wir in der Vorlesung ganz ausgelassen haben, obwohl es für viele zum Kanon der Syntaxanalyse gehört, sind die LR(1)-Grammatiken. Diese Klasse von Grammatiken hat weder das Problem der gemeinsamen Präfixe noch Schwierigkeiten mit Linksrekursion. Allerdings ist die Analyse und die Konstruktion dieser Parser deutlich aufwendiger zu verstehen als bei LL(1)-Parsern.

In a nutshell ist der Unterschied zwischen LL(k) und LR(k) in etwa folgender: Bei einem LL-Parser muss immer anhand des Look-Aheads direkt entschieden werden, welche Regel nun im Folgenden angewendet werden muss. Dies bringt eben genau die Probleme, die wir bei einem gemeinsamen Präfix haben, mit sich: Dort können wir diese eindeutige Entscheidung nicht sofort treffen. LR(k)-Parser haben die Möglichkeit, beliebige Zeit in einem Zustand des "sich noch nicht entschieden habens" (tristate) zu bleiben. Sie führen dazu (virtuell) eine Menge von möglichen Regeln mit sich, die mit jedem weiteren Token weiter eingeschränkt wird. Erst wenn diese Menge nur noch eine Regel enthält, wird sie angewendet. Wenn Sie weiteres Interesse an LR(k)-Parsern haben, sei Ihnen, neben der Lektüre des Drachenbuches, der Artikel LR(k)-Analyse für Pragmatiker von Andreas Kunert ans Herz gelegt.

3.4 Abstrakter Syntaxbaum

Bisher haben wir nur davon gesprochen, dass unsere Parser Ableitungsbäume erzeugen bzw. aus dem gegebenen Programmtext rekonstruieren. Diese Ableitungsbäume haben alle Token, die der Scanner erkennt, als Blätter. Über diesen Blättern wird, für jede angewendete Grammatikregel, ein innerer Knoten erzeugt: Alle Elemente der rechten Regelseite werden als Kindsknoten unter einen, mit dem Nicht-Terminal markiertem, inneren Knoten gehängt. Der Wurzelknoten des Ableitungsbaums ist immer das nichtterminale Startsymbol der Grammatik.

Während des Parsens unterscheidet man noch, ob man den Baum von den Blättern zur Wurzel (bottom-up) oder von der Wurzel zu den Blättern (top-down) hin wachsen lässt. LL(1)-Parser sind Top-Down-Parser. LR(1)-Parser sind Bottom-Up-Parser.

Allerdings sind diese Ableitungsbäume eher unpraktisch zu handhaben: Zum einen enthalten sie wirklich alle Token des Tokenstroms. Also auch jene Zeichen, die wir nur benutzt haben, um dem Parser zu vermitteln, was wir eigentlich wollen. Das Paradebeispiel hierfür ist explizite Klammerung. In einer perfekten Welt wüsste der Parser, wie wir einen arithmetischen Ausdruck interpretiert haben wollen; in der Realität müssen wir Klammern verwenden, wenn wir mit der impliziten Klammerung von Punkt-vor-Strich nicht zufrieden sind.

Aber es gibt in Ableitungsbäumen auch zu viele innere Knoten: Durch Regelumformungen und durch Einführung von zusätzlichen Nichtterminalen zur korrekten Erkennung der Operatorpräzedenz, kommen viele viele innere Knoten hinzu, die keinen Wert mehr im weiteren Verlauf des Übersetzungsvorgangs haben.

Daher gibt es das Konzept des Abstrakten Syntaxbaums (AST). Der AST ist ein vom Ableitungsbaum abgeleiteter Baum, der die essentielle Struktur des Programms erfasst und, wie es der Scanner auch schon gemacht hat, nur noch die wesentliche Information an die späteren Übersetzerstufen liefert. Was genau wesentlich bedeutet, hängt stark von der Sprache ab.

Um den AST zu generieren, haben wir prinzipiell zwei Möglichkeiten: Entweder falten wird den vollständig aufgebauten Ableitungsbaum mittels Baumtransformationen im Nachhinein wieder zusammen und kompaktifizieren ihn dabei, oder wir lassen den Parser den AST direkt erzeugen. Für letzteres müssen wir dem Parsergenerator mitteilen, wie diese AST-Konstruktion von statten gehen soll. Wir brauchen also zusätzliche Informationen in der Grammatik, die nicht die erkannte formale Sprache verändert, aber dem Generator zusätzliche Informationen liefert. Den Weg, den die meisten Generatoren hier gehen ist eine Reduktionsaktion anzugeben:

varDef -> var Ident = expr  { return ("varDef", $2, $3) }

Die Aktion (in geschweiften Klammern), wird als Rückgabewert der Parsefunktionen eingesetzt, nachdem die Pattern ($1,$2,…) durch die Elemente der rechten Seite ersetzt wurden. Im Folienbeispiel würde die Funktion für varDef dann so ausehen:

def varDef(token_stream):
    if token_stream.peek() == TOK_VAR:
        # ...
        return ("varDef", t_id, expr_tree)

Noch mehr Details zu diesen Reduktionsaktionen gibt es in den Übungen.

3.5 Probleme mit realen Sprachen

Wikipedia: Thelexerhack

Nachdem wir sehr detailiert über Parser geredet haben, wollen wir nun wieder herauszoomen und uns auf unser eigentliches Ziel konzentrieren: Die Extraktion der Programmstruktur für eine real existierende Programmiersprache. Mit welchem Parsingalgorithmus das passiert, ist uns im Grunde egal, hauptsache es ist schnell, korrekt und eindeutig. Dabei gibt es allerdings einige Probleme, die entweder bereits in der Sprache angelegt sind oder die wir machen können, wenn wir die formale Grammatik aufschreiben.

Einen klassischen Fehler, den man beim Erstellen einer Grammatik machen kann, ist das dangling-else Problem. Dabei wird bei einem doppelt geschachtelten if-else nicht klar, ob der else-Zweig zum inneren oder zum äußeren if gehört. Dieses Problem kann nur auftreten, wenn die Sprache es erlaubt, die Klammerung von Codeblöcken wegzulassen, falls man nur ein einzelnes Statement hat. Bei der angegeben Grammatik entsteht so das Problem, dass sie nicht eindeutig ist (also auch nicht LL(1)). Konstruiert man dennoch einen Parser, so würde ein linksreduzierender Parser die eine und ein rechtsreduzierender Parser die andere Variante finden. Daher muss eine Sprache, die die Auslassung der Codeblockklammerung erlaubt, genau definieren, wie dieser Fall behandelt wird. Historisch gesehen wurde ein dangling-else Problem zum ersten Mal bei ALGOL-60 erkannt.

Ein anderes grundsätzliches Problem der Syntaxanalyse ist, dass wir manchmal nicht alle Notationsregeln der Sprache mit nur einem Zeichen Look-Ahead prüfen können. Beim Beispiel auf den Folien kommt das Problem zutage, dass bei Java manche Modifikatoren innerhalb einer Klassendefinition sowohl für Member als auch für Methoden (public) erlaubt sind, während dies bei anderen nicht der Fall ist (abstract macht keinen Sinn für eine Membervariable). Da allerdings erst nach dem Bezeichner klar wird, ob ein Member oder eine Methode vorliegt, kann ein LL(1)-Parser nicht entscheiden, ob der Modifikator an dieser Stelle erlaubt ist oder nicht. Die Lösung hierfür ist das teilweise Aufschieben der Entscheidung über die syntaktische Korrektheit in die semantische Analyse. Der Parser erlaubt also beide gezeigten Programme, erzeugt einen AST, und eine spätere Prüfung schlägt der Memberdefinition Alarm.

Das dritte und schwerwiegendste Problem von Parsing ist, dass wir bisher die Unwahrheit über die kontextfreie Syntax von Programmiersprachen gesagt habenStackoverflow: Which contemporary computer languages are LL(1)?. Viele Sprachen sind nämlich nicht wirklich kontextfrei. Dies bedeutet, dass ein Unterbaum des Ableitungsbaums nicht nur von den überdeckten Token abhängt, sondern auch von der semantischen Interpretation anderer Programmteile. Oder anders gesagt: Das Parsen der meisten Sprachen ist nicht lokal machbar.

Das klassische Beispiel hierfür ist typedef aus der Programmiersprache C. Hiermit zeigt der Programmierer an, dass ab der Stelle des typedefs ein Bezeichner nicht mehr als Variablenname, sondern als Alias für einen anderen Typen interpretiert werden soll. So führt der Ausdruck typedef char foo dazu, dass foo der Name eines Datentyps ist, der genau ein Byte groß ist und an jeder Stelle verwendet werden kann, an der auch char verwendet werden darf.

Da aber einige Grammatikregeln anhand des Wissens, ob wir gerade auf den Namen einer Variable oder den Namen eines Typs schauen, eine Unterscheidung treffen, müssen wir an diesen Stellen wissen, welche Typnamen es gibt. Man kann dieses Problem entweder mit einer Menge FormalismusWikipedia: Attributegrammar erschlagen, oder man gibt die Kontextfreiheit zu einem gewissen Teil auf.

Beim Lexer Hack, lässt man Parser und Scanner miteinander reden. Der Scanner führt eine Liste mit gerade aktiven Typnamen mit und gibt unterschiedliche Token für Typnamen und für Bezeichner an den Parser. Der Parser seinerseits teilt dem Scanner beim Erkennen eines typedef mit, dass es einen neuen Typnamen gibthttps://blogs.gnome.org/otte/2019/10/17/riddle-me-this/. Dies kann in der Reduktionsregel der Grammatik erfolgen:

typedef_decl -> typedef type Ident  { scanner.typenames.add($3); return ('typedef', $3, $2); }

4 Exkurs: Parsing und Sicherheit

Ein weiteres Problem, dass ganz eng mit Parsen verbunden ist, ist die Validierung von Benutzereingaben und die damit verbundenen Sicherheitsimplikationen. Jede Form von Informationsextraktion aus einer Zeichenkette ist Parsing. Daher sind Parser die ersten, die mit möglicherweise schadhaften und gefährlichen Eingaben konfrontiert sind. Hat der Parser eine Sicherheitslücke, so ist diese besonders einfach auszunutzen, da der Angreifer an keiner anderen Instanz vorbei muss.

Daher ist es nicht nur vom Programmieraufwand her korrekt gedacht einen Parsergenerator zu verwenden, sondern häufig auch vom Sicherheitsstandpunkt: Dabei hilft die genaue Spezifikation der erwartete Eingaben als formale Grammatik nicht nur einen Parser für das Austauschformat zu generieren, sondern auch das Format genau zu beschreiben. Mittels Grammatikanalysen kann man dann sogar nachweisen, dass die Grammatik deterministisch parsebar ist.

Das andere sicherheitsrelevante Thema beim Validieren von Zeichenketten ist, dass man sehr schnell an den Punkt kommt, dass die Validierung der Eingabe an sich schon eine virtuelle Maschine darstellt und vielleicht Turing-vollständig ist. Es geht also um Situationen, bei denen die Struktur eines Programms selbst ein Programm ist, dass vom Übersetzer während der Übersetzungszeit ausgeführt wird, während er versucht nachzuweisen, dass das Programm valide ist. Der Übersetzer wird also selbst zum Sprachprozessor für dieses Meta-Programm.

Das bekannteste Beispiel hierfür sind C++-Templates: Mit C++-Templates kann der Programmierer Datentypen definieren, die parametrisch von anderen Datentypen abhängen. Ein Beispiel für einen solchen Datentyp ist die doppelt verkettete Liste von Integern (std::deque<int>). Jetzt muss der Übersetzer allerdings diese parametrischen Datentypen vollständig expandieren, um zu prüfen, ob alle Typen korrekt verwendet wurden. Da aber auch Templatetypen Argumente für Templatetypen sein können, hat man alle Elemente zusammen, um ein Programm zu schreiben: Rekursion und Abbruchbedingungen. Es wurde sogar nachgewiesen, dass C++-Templates wirklich Turing-vollständig sind, indem eine Turingmaschine mittels C++-Templates erstellt wurde.

Jetzt hat es sich im Falle von C++-Templates herausgestellt, dass eine solche Flexibilität in der Sprache viele Möglichkeiten bietet, elegante Programme zu schreiben. Allerdings möchten Sie nicht aus Versehen in die Falle tappen, dass jemand auf Ihrem Parser für Netzwerkpakete Bitcoins minen kann. Mit LANGSEC gibt es sogar eine Forschungsrichtung, die sich auf genau diese sprachlich formale Sicht der Sicherheit konzentriert. Ihre Forderung ist Context-Free or Regular!

5 Zusammenfassung

Fußnoten:

2

Parsergeneratoren werden auch Compiler-Compiler genannt, da sie Übersetzer sind, die Code erzeugen, der in Übersetzern verwendet wird.

4

Vollständiger Scanner: ./lst/02-scanner.py

5

Verwechslungsgefahr: Formale Sprachen sind etwas anderes als Programmiersprachen und sie haben nichts mit einem Maschinenmodell zu tun. Das Skript unterscheidet daher strikt zwischen formalen Sprachen und (einfach nur) Sprachen.

7

In keiner der möglichen Ersetzungen eines Nichtterminals kann das Nichtterminal noch einmal auftreten.

8

Reguläre Grammatik: A -> xA, Regulärer Ausdruck: x*

9

In Lisp wäre es (+ (* 1 2) (/ 3 4)). In dieser Programmiersprache, die von Studierenden aufgrund ihrer immensen Menge an Klammerung gehassten wird, werden Programme direkt in einer solchen Listennotation notiert. Es gibt nur wenige weitere syntaktische Regeln. Dies erklärt auch ihre Beliebtheit bei Übersetzerbauern: Man kommt mit einem sehr einfachen Parser aus. Wenn Sie also über die folgenden Abschnitte stöhnen, denken Sie daran: Mit Lisp wäre das alles nicht nötig.

10

Dies ist auch der Grund, wieso man mit Regexen kein HTML parsen kann. Versuchen Sie es nicht, es wird immer scheitern bzw. ein Hack bleiben.

12

Formaler: Deterministische kontextfreie Sprachen können mit deterministischen Kellerautomaten erkannt werden.

13

YouTube: Feuerzangebowle: Ostgoten, die wissen auch nicht ganz genau, wo sie hin müssen.

14

Diese Regeln für das Nicht-Terminal A nennt man auch A-Produktionen.

15

Tatsächlich hat die Grammatikklasse, bei der alle Produktionen mit einem Terminal starten, einen eigenen Namen: Simple LL(1) oder SLL(1).

16

Rhetorische Frage.

17

Man kann an diesem Beispiel auch sehen, dass Sprachklassen und Grammatikklassen etwas unterschiedliches sind. Die gezeigte Sprache ist LL(1), aber die Grammatik, die wir verwendet hatten, war in LL(2). Durch die Umformung haben wir es geschafft, eine Grammatik zu erzeugen, die näher an der eigentlichen Klasse der Sprache ist.

18

Linksfaktorisierung != Linksrekursion

19

Wikipedia: Thelexerhack

21

Wikipedia: Attributegrammar

Creative Commond BY Logo
Skript zur Vorlesung Programmiersprachen und Übersetzer, Christian Dietrich, 2019,2020.
Die Materialien zu dieser Vorlesung sind, soweit nicht anders deklariert, unter der Creative Commons Attribution International License (CC-BY 4.0) veröffentlicht.
Github Logo
Github Repository
Der Quellcode aus dem Folien und Skript erzeugt werden sind auf Github zu finden: https://github.com/luhsra/vorlesung-psu. Pull request are welcome!