Ausgewählte Kapitel aus dem Übersetzerbau - Exam.pdf

Ausgewählte Kapitel aus dem Übersetzerbau - Exam
Ausgewählte Kapitel aus dem Übersetzerbau - Exam Aufgabe 1) Tokenisierung und Lexikalische Analyse Betrachte die Konzepte der Tokenisierung und der Rolle eines Lexers bei der lexikalischen Analyse. Diese Begriffe sind zentral für das Verständnis der Übersetzung von Programmiersprachen in kompilierten Programmen. Tokens sind die kleinsten syntaktisch unterscheidbaren Einheiten in einer Programmiers...

© StudySmarter 2024, all rights reserved.

Ausgewählte Kapitel aus dem Übersetzerbau - Exam

Aufgabe 1)

Tokenisierung und Lexikalische AnalyseBetrachte die Konzepte der Tokenisierung und der Rolle eines Lexers bei der lexikalischen Analyse. Diese Begriffe sind zentral für das Verständnis der Übersetzung von Programmiersprachen in kompilierten Programmen.Tokens sind die kleinsten syntaktisch unterscheidbaren Einheiten in einer Programmiersprache und können verschiedene Arten haben, darunter Bezeichner, Schlüsselwörter, Literale, Operatoren und Trennzeichen. Der Lexer, ein Werkzeug zur lexikalischen Analyse, nimmt Quellcode-Text als Eingabe und gibt eine Folge von Tokens aus. Jedes Token hat das Format <type, value>. Diese Übung fordert dich dazu auf, die Konzepte der Tokenisierung und des Einsatzes eines Lexers zu untersuchen und anzuwenden.

a)

  • Teilaufgabe 1: Beschreibe in deinen eigenen Worten den Zweck und die Funktionsweise eines Lexers. Verwende dabei die Begriffe Tokenisierung, Token, <type, value>, Quellcode, und Ausgabe.

Lösung:

  • Teilaufgabe 1: Der Zweck eines Lexers besteht darin, den Quellcode einer Programmiersprache in kleinere, leichter analysierbare Einheiten, sogenannte Tokens, zu zerlegen. Dieser Prozess wird als Tokenisierung bezeichnet. Der Lexer liest den Quellcode Zeichen für Zeichen und gruppiert bestimmte Zeichenfolgen zu bedeutungsvollen Einheiten, den Tokens. Ein Token ist die kleinste syntaktische Einheit und kann verschiedene Typen haben, wie Bezeichner, Schlüsselwörter, Literale, Operatoren und Trennzeichen. Jedes Token wird als Paar im Format <type, value> ausgegeben. Dabei gibt type den Typ des Tokens (z. B. Schlüsselwort oder Operator) und value den genauen Wert bzw. Inhalt des Tokens an. Die Aufgabe des Lexers ist es also, einen Input-Stream des Quellcodes in eine Folge von Tokens umzuwandeln, die als Output für die weitere Analyse und Verarbeitung durch den Compiler oder Interpreter dient.

b)

  • Teilaufgabe 2: Gegeben ist der folgende Quellcode-Ausschnitt in einer hypothetischen Programmiersprache:
  • int main() {  int a = 5;  a = a + 10;  print(a);}
  • Identifiziere die verschiedenen Token-Arten (Bezeichner, Schlüsselwörter, Literale, Operatoren, Trennzeichen) und liste die entsprechenden Tokens im <type, value>-Format auf.

Lösung:

  • Teilaufgabe 2: Gegeben ist der folgende Quellcode-Ausschnitt in einer hypothetischen Programmiersprache:
int main() {  int a = 5;  a = a + 10;  print(a);}
  • Hier sind die verschiedenen Token-Arten und die entsprechenden Tokens im <type, value>-Format:
  • Schlüsselwörter:
    • <keyword, 'int'>
    • <keyword, 'main'>
    • <keyword, 'int'>
    • <keyword, 'print'>
  • Bezeichner:
    • <identifier, 'a'>
    • <identifier, 'a'>
    • <identifier, 'a'>
  • Operatoren:
    • <operator, '='>
    • <operator, '='>
    • <operator, '+'>
  • Literale:
    • <literal, '5'>
    • <literal, '10'>
  • Trennzeichen:
    • <delimiter, '('>
    • <delimiter, ')'>
    • <delimiter, '{'>
    • <delimiter, '}'>
    • <delimiter, '('>
    • <delimiter, ')'>
    • <delimiter, ';'>
    • <delimiter, ';'>
    • <delimiter, ';'>
    • <delimiter, ')'>
    • <delimiter, ';'>

c)

  • Teilaufgabe 3: Entwickle ein einfaches Python-Programm, das als Lexer dient und den oben gegebenen Quellcode-Ausschnitt in eine Folge von Tokens umwandelt. Gib die Token-Liste im Format <type, value> aus. Beachte, dass der Quellcode nicht vollständig analysiert sein muss – die Fokussierung auf einen kleinen Ausschnitt ist ausreichend.
  • Hier ist ein Beispiel, wie der Quellcode, den du schreiben müsstest, aussehen könnte:
  • 'your code actual here'

Lösung:

  • Teilaufgabe 3: Entwickle ein einfaches Python-Programm, das als Lexer dient und den oben gegebenen Quellcode-Ausschnitt in eine Folge von Tokens umwandelt. Hier ist der Python-Quellcode, der die Aufgabe erfüllt:
class Lexer:    def __init__(self, source_code):        self.source_code = source_code        self.tokens = []        self.current_char = ''        self.index = 0        self.token_types = {            'keywords': ['int', 'print'],            'operators': ['=', '+'],            'delimiters': ['(', ')', '{', '}', ';']        }    def next_char(self):        if self.index < len(self.source_code):            self.current_char = self.source_code[self.index]            self.index += 1        else:            self.current_char = None    def add_token(self, token_type, value):        self.tokens.append((token_type, value))    def get_tokens(self):        self.next_char()        while self.current_char is not None:            if self.current_char.isspace():                self.next_char()            elif self.current_char.isalpha():                self.collect_identifier_or_keyword()            elif self.current_char.isdigit():                self.collect_number()            elif self.current_char in self.token_types['operators']:                self.add_token('operator', self.current_char)                self.next_char()            elif self.current_char in self.token_types['delimiters']:                self.add_token('delimiter', self.current_char)                self.next_char()            else:                self.next_char()        return self.tokens    def collect_identifier_or_keyword(self):        identifier = ''        while self.current_char is not None and self.current_char.isalnum():            identifier += self.current_char            self.next_char()        if identifier in self.token_types['keywords']:            self.add_token('keyword', identifier)        else:            self.add_token('identifier', identifier)    def collect_number(self):        number = ''        while self.current_char is not None and self.current_char.isdigit():            number += self.current_char            self.next_char()        self.add_token('literal', number)# Gegebener Quellcodesource_code = 'int main() {  int a = 5;  a = a + 10;  print(a);}'# Lexer initialisieren und Tokens erhaltenlexer = Lexer(source_code)result = lexer.get_tokens()# Tokens ausgebenfor token in result:    print(f'<{token[0]}, {token[1]}>')
  • Dieses Python-Programm implementiert einen einfachen Lexer, der den gegebenen Quellcode in Tokens umwandelt und sie im <type, value>-Format ausgibt.

Aufgabe 2)

Es soll eine Textverarbeitungssoftware entwickelt werden, die eine Vielzahl von Textmustern erkennen und verarbeiten kann. Verwenden Sie dazu reguläre Ausdrücke, um folgende Aufgaben zu bewältigen:

  • Erstellung eines regulären Ausdrucks zur Erkennung von E-Mail-Adressen.
  • Filterung von Wörtern, die mit einem bestimmten Zeichen beginnen und eine bestimmte Länge haben.
  • Extrahierung von Daten, die einem spezifischen Muster entsprechen.

a)

Teilaufgabe 1: Schreiben Sie einen regulären Ausdruck, der alle gültigen E-Mail-Adressen erkennt. Eine gültige E-Mail-Adresse besteht aus:

  • ein bis mehreren alphanumerischen Zeichen (einschließlich Unterstrich und Punkt) vor dem @-Symbol
  • einem @-Symbol
  • ein bis mehreren alphanumerischen Zeichen (einschließlich Punkt) nach dem @-Symbol.

Geben Sie auch ein Beispieltext an und markieren Sie die erkannten E-Mail-Adressen.

Lösung:

Um einen regulären Ausdruck zu schreiben, der gültige E-Mail-Adressen erkennt, müssen wir die angegebenen Regeln beachten:

  • Ein bis mehrere alphanumerische Zeichen (einschließlich Unterstrich und Punkt) vor dem @-Symbol.
  • Ein @-Symbol.
  • Ein bis mehrere alphanumerische Zeichen (einschließlich Punkt) nach dem @-Symbol.

Hier ist der entsprechende reguläre Ausdruck:

/[\w\.]+@[\w\.]+/

Dieser reguläre Ausdruck erkennt gültige E-Mail-Adressen, die wie folgt aufgebaut sind:

  • [\w\.]: Alphanumerische Zeichen, Unterstriche und Punkte.
  • +: Ein oder mehrere der vorherigen Zeichen.
  • @: Das @-Symbol.
  • [\w\.]+: Ein oder mehrere alphanumerische Zeichen, Unterstriche und Punkte nach dem @-Symbol.

Hier ist ein Beispieltext mit E-Mail-Adressen:

Bitte kontaktieren Sie uns unter info@example.com oder support.team@service.co.uk für weitere Informationen.

Erkannte E-Mail-Adressen:

  • info@example.com
  • support.team@service.co.uk

b)

Teilaufgabe 2: Schreiben Sie einen regulären Ausdruck, der alle Wörter in einem Text erkennt, die:

  • mit dem Buchstaben 'A' beginnen
  • genau 5 Zeichen lang sind

Geben Sie ein entsprechendes Beispiel an und markieren Sie die erkannten Wörter im Text.

Lösung:

Um einen regulären Ausdruck zu schreiben, der alle Wörter erkennt, die mit dem Buchstaben 'A' beginnen und genau 5 Zeichen lang sind, können wir wie folgt vorgehen:

Der reguläre Ausdruck sieht folgendermaßen aus:

\bA\w{4}\b

Hier ist eine Aufschlüsselung des regulären Ausdrucks:

  • \b: Wortgrenze, um sicherzustellen, dass wir am Anfang oder Ende eines Wortes sind.
  • A: Der Buchstabe 'A', mit dem das Wort beginnen muss.
  • \w{4}: Genau 4 alphanumerische Zeichen, da das ganze Wort 5 Zeichen lang sein muss (einschließlich des Anfangsbuchstabens 'A').
  • \b: Wortgrenze, um das Ende des Wortes zu markieren.

Hier ist ein Beispieltext mit Wörtern:

Der Arzt stellte sicher, dass alle Akten aktualisiert wurden. Auch der Akira wurde informiert.

Erkannte Wörter:

  • Arzt (nicht erkannt, da es nur 4 Zeichen hat)
  • Akira (erkannt, da es mit 'A' beginnt und genau 5 Zeichen hat)

c)

Teilaufgabe 3: Schreiben Sie einen regulären Ausdruck, um Datumsangaben im Format DD/MM/YYYY zu extrahieren. Stellen Sie sicher, dass der reguläre Ausdruck:

  • Den Tag als zwei Ziffern (01-31) erkennt
  • Den Monat als zwei Ziffern (01-12) erkennt
  • Das Jahr als vier Ziffern erkennt

Geben Sie ein Beispieltext an und markieren Sie die extrahierten Datumsangaben.

Lösung:

Um Datumsangaben im Format DD/MM/YYYY zu extrahieren, müssen wir sicherstellen, dass der reguläre Ausdruck die spezifischen Bereiche für Tag (01-31), Monat (01-12) und Jahr (vier Ziffern) korrekt erkennt.

Hier ist der entsprechende reguläre Ausdruck:

\b(0[1-9]|[12][0-9]|3[01])\/(0[1-9]|1[0-2])\/\d{4}\b

Hier ist eine Erklärung des regulären Ausdrucks:

  • \b: Wortgrenze, um sicherzustellen, dass wir am Anfang des Datums beginnen.
  • (0[1-9]|[12][0-9]|3[01]): Erkannt werden Tage von 01 bis 31.Anfangs eine '0' für die Tage 01-09.'[12][0-9]' für die Tage 10-29.'3[01]' für die Tage 30 und 31.
  • \/(0[1-9]|1[0-2])\/: Erkannt werden Monate von 01 bis 12.Anfangs eine '0' für die Monate 01-09.'1[0-2]' für die Monate 10-12.
  • \d{4}: Genau vier Ziffern für das Jahr.
  • \b: Wortgrenze, um das Ende des Datums zu markieren.

Hier ist ein Beispieltext mit Datumsangaben:

Die Veranstaltung findet am 12/05/2023 statt. Ein weiteres Meeting wurde für den 01/01/2022 geplant.

Erkannte Datumsangaben:

  • 12/05/2023
  • 01/01/2022

Aufgabe 3)

In diesem Bereich wirst Du eine kontextfreie Grammatik (CFG) analysieren und eine Parsing-Strategie anwenden. Betrachte die folgende CFG, die eine einfache arithmetische Sprache definiert:

  • Nicht-Terminale: S, E, T, F
  • Terminalsymbole: '+', '*', '(', ')', 'id'
  • Startsymbol: S
  • Produktionsregeln:
    • S → E
    • E → E + T | T
    • T → T * F | F
    • F → ( E ) | id

a)

Teilaufgabe 1: Bestimme für die gegebenen Produktionsregeln die First- und Follow-Mengen für jedes der Nicht-Terminalen (S, E, T, F).

Lösung:

Um die First- und Follow-Mengen für die gegebenen Produktionsregeln zu bestimmen, gehen wir Schritt für Schritt vor.

  • Schritt 1: Bestimmen der First-Mengen
  • Die First-Menge eines Nicht-Terminals enthält die Terminalsymbole, die als erstes Symbol in irgendeiner Ableitung der Produktionsregeln erscheinen können.

    • First(S): Da S → E, haben S und E die gleiche First-Menge.
    • First(E):
      • Betrachte E → E + T: Hier sehen wir, dass E rekursiv ist und als erstes Terminalsymbol zur First-Menge von E addiert.
      • Betrachte E → T: E reduziert sich auf T. Daher ist die First-Menge von E die gleiche wie die von T.
    • First(T):
      • Betrachte T → T * F: Auch hier haben wir die rekursive Situation mit T.
      • Betrachte T → F: T reduziert sich auf F.
    • First(F):
      • Betrachte F → ( E ): Hier ist das erste Terminalsymbol '('.
      • Betrachte F → id: Das erste Terminalsymbol ist 'id'.
    • Final: First-Mengen:
      • First(S) = First(E) = {'id', '('}
      • First(E) = {'id', '('}
      • First(T) = {'id', '('}
      • First(F) = {'id', '('}
  • Schritt 2: Bestimmen der Follow-Mengen
  • Die Follow-Menge eines Nicht-Terminals enthält die Terminalsymbole, die unmittelbar nach diesem Nicht-Terminal in irgendeiner Ableitung der Produktionsregeln erscheinen können.

    • Follow(S): Da S das Startsymbol ist, enthält Follow(S) das Endmarker-Symbol $ (EOF).
    • Follow(E):
      • In S → E: Hier geht Follow(S) in Follow(E) über.
      • In E → E + T: Das '+'-Symbol kann unmittelbar nach E erscheinen.
      • In E → T: Follow(E) wird durch Follow(T) ergänzt.
    • Follow(T):
      • In E → E + T: Follow(T) enthält das '+'-Symbol.
      • In T → T * F: Das '*' kann unmittelbar nach T erscheinen.
      • In T → F: Follow(T) wird durch Follow(F) ergänzt.
    • Follow(F):
      • In T → T * F: Follow(F) kann '*' enthalten.
      • In F → ( E ): Die Follow-Menge von F wird durch Follow(')') ergänzt, aber ')' selbst wird nicht hinzugefügt.
      • In F → id: Follow(F) enthält die gleichen Symbole wie Follow(T) und Follow(E).
    • Final: Follow-Mengen:
      • Follow(S) = {'$'}
      • Follow(E) = {'+', '$'}
      • Follow(T) = {'+', '*', '$'}
      • Follow(F) = {'+', '*', '$'}

    b)

    Teilaufgabe 2: Erstelle einen rekursiven Abstieg-Parser (Top-Down Parsing) für diese Grammatik. Zeige den Parsing-Prozess für den Eingabestring 'id + id * id'.

def parse_S():     # Deine Implementierung hierdef parse_E():    # Deine Implementierung hierdef parse_T():    # Deine Implementierung hierdef parse_F():    # Deine Implementierung hierdef main():    input_string = 'id + id * id'    # Call starting function    parse_S()if __name__ == '__main__':    main()

Lösung:

Ein rekursiver Abstieg-Parser ist eine einfache und intuitive Methode zur Analyse einer kontextfreien Grammatik. Wir implementieren einen solchen Parser für die gegebene Grammatik und zeigen den Parsing-Prozess für den Eingabestring 'id + id * id'.

Der Parser wird vier Funktionen enthalten, eine für jedes Nicht-Terminal in der Grammatik (S, E, T, und F). Wir beginnen mit der Definition einiger globaler Variablen, um den Eingabestring und den aktuellen Index zu verfolgen.

  input_string = '' index = 0 def parse_S(): print("Parsing S") parse_E() if index == len(input_string): print("Parsing erfolgreich!") else: print("Parsing fehlgeschlagen. Unverarbeiteter Eingabe-String: " + input_string[index:]) def parse_E(): print("Parsing E") parse_T() while match('+'): advance() parse_T() def parse_T(): print("Parsing T") parse_F() while match('*'): advance() parse_F() def parse_F(): print("Parsing F") if match('id'): advance() elif match('('): advance() parse_E() if match(')'): advance() else: error() else: error() def match(token): global index return input_string[index:index+len(token)] == token def advance(): global index index += 1 def error(): print("Parsing error at index " + str(index) + ": " + input_string[index:]) exit(1) def main(): global input_string input_string = 'id + id * id'.replace(' ', '') # Whitespace entfernen parse_S() if __name__ == '__main__': main()  

Im Detail betrachtet:

  • Funktion parse_S(): Sie beginnt den Parsing-Prozess, indem sie parse_E() aufruft. Nach der Rückkehr überprüft sie, ob der gesamte Eingabestring verarbeitet wurde.
  • Funktion parse_E(): Sie analysiert ein E. Dies beginnt mit der Analyse von T. Falls danach ein '+' folgt, wird erneut T analysiert.
  • Funktion parse_T(): Sie analysiert ein T. Dies beginnt mit der Analyse von F. Falls danach ein '*' folgt, wird erneut F analysiert.
  • Funktion parse_F(): Sie analysiert ein F. Es kann entweder 'id' oder ein Ausdruck in Klammern sein. Falls ein '(' folgt, wird parse_E() aufgerufen und anschließend muss ein ')' folgen, sonst gibt es einen Fehler.
  • Hilfsfunktionen: match(token) überprüft, ob das aktuelle Token passt, und advance() bewegt den Zeiger im Eingabestring vorwärts.

Durch das Ersetzen von Leerzeichen in main() und mit dem Parsing-Prozess für 'id + id * id' wird der Parser den string erfolgreich analysieren und die Struktur der Grammatik überprüfen.

c)

Teilaufgabe 3: Baue die LR(0)-Parsing-Tabelle für die gegebene Grammatik auf. (Hinweis: Zustände und Goto/Action-Tabellen berücksichtigen).

Lösung:

LR(0)-Parsing-Tabellen bestehen aus zwei Hauptkomponenten: der Zustandsmenge (item sets) und den Parsing-Tabellen (Action- und Goto-Tabellen). Lass uns Schritt für Schritt durchgehen, um diese zu erstellen.

  • Schritt 1: Erstellen der LR(0)-Zustände
  • Zuerst müssen wir die Items für jedes Nicht-Terminal erstellen und dann die Zustände finden.

    Die gegebene Grammatik ist:

    • S → E
    • E → E + T | T
    • T → T * F | F
    • F → ( E ) | id

    Wir fügen ein erweitertes Startsymbol hinzu:

    • S' → S

    Erstellen der Items:

    • S' → .S
    • S → .E
    • E → .E + T
    • E → .T
    • T → .T * F
    • T → .F
    • F → .( E )
    • F → .id

    Der Anfangszustand umfassen die Closure von S' → .S:

    • Zustand 0: {S' → .S, S → .E, E → .E + T, E → .T, T → .T * F, T → .F, F → .( E ), F → .id}

    Von hier aus leiten wir die restlichen Zustände ab:

    • Zustand 1: {S' → S.} durch Verschieben von S in Zustand 0
    • Zustand 2: {S → E., E → E. + T} durch Verschieben von E in Zustand 0
    • Zustand 3: {E → T., T → T. * F} durch Verschieben von T in Zustand 0
    • Zustand 4: {T → F., F → ( E )., F → id.}
    • Zustand 5: {E → E + .T, T → .T * F, T → .F, F → .( E ), F → .id} durch Verschieben von + in Zustand 2
    • Zustand 6: {T → T * .F, F → .( E ), F → .id} durch Verschieben von * in Zustand 3
    • Zustand 7: {F → ( .E ), E → E . + T, E → .E + T, E → .T, T → .T * F, T → .F, F → .( E ), F → .id} durch Verschieben von ( in Zustand 4
    • Weitere Zustände folgen entsprechend den Verschiebungen und Reduktionen.
  • Schritt 2: Erstellen der Parsing-Tabellen
  • Die Action- und Goto-Tabellen werden aus den abgeleiteten Zuständen erstellt.

    Eine grobe Skizze der Parsing-Tabellen könnte wie folgt aussehen:

    Action-Tabelle:

    Zustand id ( ) + * $
    0 s4 s7
    1 acc
    2 s5 r1
    3 r3 s6 r3
    4 r6 r6 r6 r6
    5 s4 s7
    6 s4 s7
    7 s4 s7

    Goto-Tabelle:

    Zustand S E T F
    0 1 2 3 4
    1
    2
    3
    4
    5 6 4
    6 4
    7 2 3 4

    Zusätzliche Zustände und Einträge in den Tabellen werden entsprechend den Übergängen und Reduktionen hinzugefügt.

    Beachte, dass dies eine vereinfachte und teilweise Darstellung der Parsing-Tabellen ist. Der vollständige Aufbau der Zustände und Tabellen erfordert das Durchlaufen der Items und die Wiederholung der Verschiebungen und Reduktionen, bis alle Zustände und Einträge ausgearbeitet sind.

    d)

    Teilaufgabe 4: Zeige den Parsing-Prozess des Bottom-Up Parsing (Shift-Reduce) für den Eingabestring 'id + id * id' unter Verwendung der zuvor erstellten LR(0)-Parsing-Tabelle.

    Lösung:

    Um den Parsing-Prozess des Bottom-Up Parsing (Shift-Reduce) für den Eingabestring 'id + id * id' zu zeigen, verwenden wir die zuvor erstellte LR(0)-Parsing-Tabelle. Der spektakuläre Parsing-Prozess wird durch eine Verarbeitungstabelle dargestellt, die die Schritte des Shifting und Reduzierens beschreibt.

    Meine LR(0)-Parsing-Tabelle sieht wie folgt aus:

    • Action-Tabelle:
    Zustand id ( ) + * $
    0 s4 s7
    1 acc
    2 s5 r1
    3 r3 s6 r3
    4 r6 r6 r6 r6
    5 s4 s7
    6 s4 s7
    7 s4 s7
    • Goto-Tabelle:
    Zustand S E T F
    0 1 2 3 4
    1
    2
    3
    4
    5 6 4
    6 4
    7 2 3 4
    • Schritt-für-Schritt Parsing-Prozess:

    Der Eingabestring ist 'id + id * id'. Wir werden ihn Zeichen für Zeichen durchgehen und den Stack sowie die Aktionen aufzeichnen.

    Schritt Stack Eingabe Aktion
    1 0 id + id * id$ Shift 4
    2 0 id 4 + id * id$ Reduce F → id (r6)
    3 0 F 3 + id * id$ Reduce T → F (r3)
    4 0 T 2 + id * id$ Reduce E → T (r1)
    5 0 E 1 + id * id$ Shift 5
    6 0 E 1 + 5 id * id$ Shift 4
    7 0 E 1 + 5 id 4 * id$ Reduce F → id (r6)
    8 0 E 1 + 5 F 3 * id$ Reduce T → F (r3)
    9 0 E 1 + 5 T 2 * id$ Shift 6
    10 0 E 1 + 5 T 2 * 6 id$ Shift 4
    11 0 E 1 + 5 T 2 * 6 id 4 $ Reduce F → id (r6)
    12 0 E 1 + 5 T 2 * 6 F 3 $ Reduce T → T * F (r2)
    13 0 E 1 + 5 T 2 $ Reduce E → E + T (r1)
    14 0 E 1 $ Accept

    Der Parsing-Prozess für den Eingabestring 'id + id * id' ist erfolgreich. Jede Zeile des Parsing-Prozesses zeigt die Aktionen (Shift oder Reduce) und den Zustand des Stacks sowie der verbleibenden Eingabe im Detail an.

    Aufgabe 4)

    Recursive-Descent-Parser und LR-Parser sind Parsing-Techniken zur Analyse und Verarbeitung von Programmiersprachen; sie helfen, Quellcode in eine abstrakte Syntaxbaumstruktur umzuwandeln.

    • Recursive-Descent-Parser: Top-Down Parsing, einfache Implementierung, aber begrenzte Sprachabdeckung (meist für LL(k)-Grammatiken)
    • LR-Parser: Bottom-Up Parsing, komplexer, dafür mächtiger und weit verbreitet, weil sie größere Klasse von Grammatiken (LR(k)-Grammatiken) verarbeiten können
    • Recursive-Descent-Parser: Jeder Knoten im Parsebaum entspricht einer Prozedur/Funktion im Parser
    • LR-Parser: Verwendet Parsing-Tabelle und Automat zur Steuerung des Parsing-Prozesses

    a)

    Erkläre detailliert die Funktionsweise eines Recursive-Descent-Parsers und bespreche, warum er hauptsächlich für LL(k)-Grammatiken effektiv ist. Gehe dabei konkret auf die Ausführungspunkte ein, an denen rekursive Aufrufe stattfinden.

    Lösung:

    Funktionsweise eines Recursive-Descent-Parsers

    Ein Recursive-Descent-Parser ist eine Art Top-Down-Parser, der zur syntaktischen Analyse von Quellcode verwendet wird. Er ist relativ einfach zu implementieren und basiert auf einer rekursiven Funktion oder Prozedur für jede Nichtterminalregel in der Grammatik. Hier sind die wichtigsten Schritte, die die Funktionsweise eines Recursive-Descent-Parsers erklären:

    • Der Parser besteht aus mehreren rekursiven Funktionen, von denen jede eine Grammatikregel repräsentiert. Jedes Nichtterminal in der Grammatik wird durch eine eigene Funktion dargestellt.
    • Die Eingabe wird von links nach rechts gelesen und die Parser-Funktionen werden rekursiv aufgerufen, um die Grammatikregeln zu überprüfen und den Parsebaum zu erstellen.
    • Eine Funktion liest das nächste Token von der Eingabe und prüft, ob es den erwarteten Regeln entspricht. Wenn dies der Fall ist, wird der Analyseprozess fortgesetzt; andernfalls wird ein Fehler zurückgegeben.
    • Wenn die Funktion auf ein Terminalsymbol trifft, vergleicht sie das aktuelle Token mit dem erwarteten Token. Bei Übereinstimmung wird das nächste Token gelesen, sonst wird ein Fehler signalisiert.
    • Die rekursiven Aufrufe erfolgen entsprechend der Regeln der Grammatik, was bedeutet, dass die Funktionen sich gegenseitig aufrufen, bis alle Regeln erfüllt sind oder ein Fehler entdeckt wird.

    Warum Recursive-Descent-Parser hauptsächlich für LL(k)-Grammatiken effektiv sind

    Ein Recursive-Descent-Parser ist hauptsächlich für die Analyse von LL(k)-Grammatiken geeignet, aus folgenden Gründen:

    • LL(k)-Grammatiken: LL steht für 'Left-to-right parsing, Leftmost derivation' und der Parameter k gibt die Anzahl der Vorschautoken an, die benötigt werden, um die richtige Regel auszuwählen. Ein Recursive-Descent-Parser liest die Eingabe von links nach rechts und erzeugt den Parsebaum durch die linkeste Ableitung.
    • Eindeutige Entscheidungen: Da LL(k)-Grammatiken so definiert sind, dass sie aufgrund der Vorschautoken deterministische Entscheidungen treffen können, passt dies gut zu der rekursiven Natur des Parsers, der bei jedem Schritt die nächste Regel anhand der aktuellen und der nächsten k Token beschließt.
    • Einfachheit: LL(k)-Grammatiken sind oft einfacher strukturiert und benötigen keine Rückverfolgung oder komplizierte Parsing-Tabellen, was bedeutet, dass die Implementierung eines Recursive-Descent-Parsers straightforward und unkompliziert sein kann.
    • Fehlerbehandlung: Recursive-Descent-Parser haben den Vorteil, dass sie in den rekursiven Aufrufen gut definiert sind, sodass Fehler leicht zu erkennen und zu handhaben sind, was bei bottom-up Parsern komplizierter sein könnte.

    Konkrete Ausführungspunkte für rekursive Aufrufe

    Die rekursiven Aufrufe in einem Recursive-Descent-Parser finden konkret an den Punkten statt, an denen die Grammatikregeln andere Nichtterminale referenzieren. Hier ein Beispiel:

    • Angenommen, wir haben die Grammatikregel A → BC. Wenn die Funktion parseA() aufgerufen wird, ruft diese Funktion parseB() und danach parseC() auf. Diese rekursiven Aufrufe folgen direkt der Struktur der Grammatik.
    • Ebenfalls, bei einer Regel wie B → b | d, prüft die Funktion parseB(), ob das nächste Token b oder d ist und ruft entsprechend die passende Funktion auf, die dann die jeweilige Ableitung vervollständigt.

    b)

    Stell dir vor, du hast eine Grammatik, die nicht LL(1) ist. Beschreibe die Herausforderungen und Einschränkungen, die einem Recursive-Descent-Parser bei der Analyse einer solchen Grammatik begegnen könnten.

    Lösung:

    Herausforderungen und Einschränkungen eines Recursive-Descent-Parsers bei der Analyse einer nicht LL(1)-Grammatik

    Ein Recursive-Descent-Parser ist besonders gut geeignet für die Analyse von LL(1)-Grammatiken, da diese deterministisch sind und einfache Entscheidungen auf Basis eines einzigen Lookahead-Tokens treffen können. Wenn es jedoch darum geht, eine Grammatik zu analysieren, die nicht LL(1) ist, stößt ein Recursive-Descent-Parser auf viele Herausforderungen und Einschränkungen. Hier sind einige der wichtigsten Punkte:

    • Mehrdeutigkeit: Nicht LL(1)-Grammatiken sind oft mehrdeutig, was bedeutet, dass der Parser bei einem bestimmten Punkt in der Eingabe mehrere Möglichkeiten hat, wie er fortfahren könnte. Ein Recursive-Descent-Parser kann diese Mehrdeutigkeit nicht automatisch auflösen, da er keine Lookahead-Mechanismen für Grammatikregeln hat, die mehr als ein Token erfordern.
    • Rückverfolgung: Einer der größten Nachteile ist die Notwendigkeit von Rückverfolgung. Wenn der Parser eine falsche Entscheidung trifft, muss er zurückgehen und eine alternative Ableitung ausprobieren. Dies kann sehr ineffizient und kompliziert sein.
    • Linksrekursion: Linkrekursive Grammatikregeln führen zu einer Endlosschleife in einem Recursive-Descent-Parser. Zum Beispiel: Eine Regel wie A → A α | β kann von einem rekursiven Descent-Parser nicht verarbeitet werden, da der Parser ständig die Regel für A erneut aufrufen würde, ohne je zu einer Basisregel (wie β) zu gelangen.
    • Mehrere Lookaheads: Nicht LL(1)-Grammatiken erfordern häufig mehrere Lookahead-Tokens (k) zur Unterscheidung zwischen Alternativen in einer Regel. Ein Recursive-Descent-Parser kann jedoch nicht einfach mehrere Tokens im Voraus lesen und benötigt spezielle Anpassungen oder komplexere Logik, um solche Grammatiken zu handhaben.
    • Komplexe Grammatiken: Nicht LL(1)-Grammatiken haben oft komplexere Strukturen, die nicht leicht durch einfache rekursive Funktionen modelliert werden können. Hier ist eine detailliertere Parsingtabelle oder ein Automat notwendig, wie sie bei LR-Parsern verwendet werden.

    Beispiele für Herausforderungen in einer nicht LL(1)-Grammatik:

    • Grammatik mit Linksrekursion: Betrachten wir die folgende Grammatik:
       A → A + b | c 
      Diese Regel ist linksrekursiv und führt dazu, dass der Recursive-Descent-Parser endlos rekursiv A aufruft. Ein typischer Ansatz zur Beseitigung der Linksrekursion ist erforderlich, was die Grammatik ändern oder spezielle Techniken einführen würde.
    • Grammatik mit Mehrdeutigkeit: Angenommen, wir haben die Grammatik:
       B → b | b + c 
      Ein Recursive-Descent-Parser wird Schwierigkeiten haben zu entscheiden, ob er B → b oder B → b + c verwenden soll, wenn das aktuelle Token b ist. Dies erfordert mehr Lookahead-Tokens, um eine eindeutige Entscheidung zu treffen.

    Schlussfolgerung:

    Zusammenfassend lässt sich sagen, dass ein Recursive-Descent-Parser auch wenn er einfach zu implementieren ist, für die Analyse von Grammatiken, die nicht LL(1) sind, erheblich eingeschränkt ist. Die Hauptprobleme liegen in der Unfähigkeit, Mehrdeutigkeiten zu behandeln, der Gefahr von Endlosschleifen bei Linksrekursion, dem Bedarf an Rückverfolgung und der Notwendigkeit von mehreren Lookahead-Tokens. Diese Einschränkungen machen ihn für komplexere Grammatiken weniger geeignet im Vergleich zu mächtigeren Parsern wie LR-Parsern.

    c)

    Entwerfe eine Parsing-Tabelle für einen einfachen LR(0)-Parser für die folgende Grammatik:

'S -> A a | b''A -> A b | a'
Erstelle das dazugehörige Zustandsdiagramm (Automat) und erläutere den Parsing-Prozess für die Eingabezeichenfolge 'b'.

Lösung:

Parsing-Tabelle und Zustandsdiagramm für einen LR(0)-Parser

Wir werden eine Parsing-Tabelle und ein Zustandsdiagramm für die folgende Grammatik entwerfen:

 S → A a | b A → A b | a 

Schritt 1: Erstellen der LR(0)-Items

Zuerst erstellen wir die LR(0)-Items für die Grammatik:

  • Augmentierte Grammatik: Füge ein neues Startsymbol hinzu:
     S' → S 
  • Initial-Items-Gruppe (closure):
     I0: S' → . S S → . A a S → . b A → . A b A → . a 

Wir erweitern diese Gruppe durch die Verschiebung des Punktes für alle Produktionen:

  •  I1: S' → S . 
  •  I2: S → b . 
  •  I3: S → A . a A → . A b A → . a 
  •  I4: A → a . 
  •  I5: A → A . b A → . A b A → . a 
  •  I6: A → A b . 

Schritt 2: Übergänge zwischen den Zuständen

Erstellen der Übergänge zwischen den Zuständen durch Verschieben des Punktes:

  •  Von I0: Mit S zu I1 Mit b zu I2 Mit A zu I3 
  •  Von I3: Mit a zu I4 Mit A zu I5 
  •  Von I5: Mit b zu I6 Mit A zu I5 

Schritt 3: Erstellung der Parsing-Tabelle

Basierend auf den LR(0)-Items und den Zuständen können wir die Parsing-Tabelle erstellen:

Zustandab$SA
0S213
1Accept
2Reduce S → b
3S45
4Reduce A → a
5S4S65
6Reduce A → A b

Zustandsdiagramm (Automat)

 I0 --b--> I2 I0 --S--> I1 I0 --A--> I3 I3 --a--> I4 I3 --A--> I5 I5 --b--> I6 

Parsing-Prozess für Eingabezeichenfolge 'b'

Die Eingabezeichenfolge ist 'b'. Wir folgen der Parsing-Tabelle, um den LR(0)-Parsing-Prozess zu erklären:

  1. Startzustand: Zustand 0, Eingabe 'b', Stapel: [0]
  2. Aktion: 'b' in Zustand 0 führt zu Shift-Operation nach Zustand 2. Stapel: [0, 2]
  3. Aktion: Zustand 2 hat eine Reduce-Aktion (S → b). Stapel: [0, S]
  4. Aktion: Zustand S führt zu Zustand 1. Stapel: [0, 1]
  5. Aktion: Zustand 1 akzeptiert die Eingabezeichenfolge '$' Ergebnis: Eingabe wird akzeptiert.

Der Parsing-Prozess für die Eingabezeichenfolge 'b' endet erfolgreich, indem die Eingabe als gültig akzeptiert wird.

d)

Vergleiche die Vor- und Nachteile von Recursive-Descent-Parsern und LR-Parsern hinsichtlich ihrer Anwendbarkeit, Flexibilität und Effizienz. In deiner Diskussion, gehe konkret auf deren Einsatzmöglichkeiten in modernen Compilern ein und unterfüttere deine Argumente mit Beispielen.

Lösung:

Vergleich von Recursive-Descent-Parsern und LR-Parsern

In diesem Abschnitt vergleichen wir Recursive-Descent-Parser (RD-Parser) und LR-Parser hinsichtlich Anwendbarkeit, Flexibilität und Effizienz. Wir werden uns ihre Vor- und Nachteile im Detail ansehen und deren Einsatzmöglichkeiten in modernen Compilern diskutieren.

Recursive-Descent-Parser

  • Vorteile:
    • Einfache Implementierung: RD-Parser sind relativ unkompliziert zu implementieren, da sie auf rekursiven Funktionen oder Prozeduren basieren, die direkt den Produktionsregeln entsprechen. Die direkte Umsetzung von Grammatikregeln in Code ist intuitiv und leicht nachvollziehbar.
    • Lesbarkeit: Der Code von RD-Parsers ist oft gut lesbar und nachvollziehbar, was die Wartung und das Debuggen erleichtert.
    • Fehlerhandling: RD-Parser sind gut für die Fehlerbehandlung geeignet, da sie explizite Rückgaben und Fehlernachrichten an den Stellen erlauben, wo die Fehler auftreten.
  • Nachteile:
    • Begrenzte Sprachabdeckung: RD-Parser sind hauptsächlich für LL(k)-Grammatiken geeignet. Sie haben Schwierigkeiten beim Umgang mit nichtdeterministischen oder linksrekursiven Grammatiken.
    • Effizienzprobleme: Bei komplexen Grammatiken kann der Parser ineffizient werden, besonders wenn Rückverfolgung notwendig ist.

LR-Parser

  • Vorteile:
    • Mächtigkeit: LR-Parser können eine größere Klasse von Grammatiken (LR(k)-Grammatiken) parsen, einschließlich solcher, die ambiguiert oder linksrekursiv sind. Dies macht sie mächtiger und flexibler im Umgang mit verschiedenen Sprachstrukturen.
    • Determinismus: LR-Parser benötigen keine Rückverfolgung und treffen immer deterministische Entscheidungen basierend auf ihrer Zustandsmaschine und Parsing-Tabelle.
    • Effizienz: Der Parsing-Prozess in einem LR-Parser ist effizienter, da jede Entscheidung auf Basis eines festen Zustands und Eingabetokens erfolgt.
  • Nachteile:
    • Komplexe Implementierung: Die Implementierung eines LR-Parsers ist weitaus komplexer. Es erfordert das Erstellen umfangreicher Parsing-Tabellen und Verstehen des zugrundeliegenden Automaten.
    • Schwierige Fehlerbehandlung: Die Fehlerdiagnose in einem LR-Parser kann schwierig sein, da die Fehler oft nur indirekt über Zustände und Tabellen erkennbar sind.

Einsatzmöglichkeiten in modernen Compilern

In modernen Compilern werden sowohl RD-Parser als auch LR-Parser verwendet, abhängig von den spezifischen Anforderungen der zu parsenden Sprache und der gewünschten Effizienz:

  • Recursive-Descent-Parser in modernen Compilern:RD-Parser sind gut geeignet für einfache, nicht komplexe Sprachen und werden häufig in Interpretern und kleinen Compilern eingesetzt. Ein Beispiel ist der Compiler für die Programmiersprache TINY, die durch ein RD-Parser implementiert wird. Sie sind auch nützlich für schnelle Prototypen und für die Entwicklung domänenspezifischer Sprachen.
  • LR-Parser in modernen Compilern:LR-Parser sind die bevorzugte Wahl für die Analyse komplexer Programmiersprachen wie C, C++, und Java. Diese Sprachen haben oft komplizierte Syntaxregeln, die Linksrekursion oder Mehrdeutigkeiten beinhalten. Ein gutes Beispiel ist der GCC-Compiler (GNU Compiler Collection), der Yacc/Bison, eine Implementierung eines LR-Parsers, zur Syntaxanalyse verwendet.

Schlussfolgerung:

Zusammenfassend haben sowohl Recursive-Descent-Parser als auch LR-Parser ihre eigenen Vor- und Nachteile. RD-Parser sind einfacher zu implementieren und gut für kleinere, weniger komplexe Sprachkonstrukte, während LR-Parser mächtiger und effizienter bei der Verarbeitung komplexer und realitätsnaher Programmiersprachen sind. Bei der Auswahl der Parsing-Technik muss man sich dem spezifischen Kontext und den Anforderungen des Projektes bewusst sein.

Sign Up

Melde dich kostenlos an, um Zugriff auf das vollständige Dokument zu erhalten

Mit unserer kostenlosen Lernplattform erhältst du Zugang zu Millionen von Dokumenten, Karteikarten und Unterlagen.

Kostenloses Konto erstellen

Du hast bereits ein Konto? Anmelden