Sie sind auf Seite 1von 152

Grundlagen des Compilerbaus

Prof. Dr. Rita Loogen


Fachbereich Mathematik und Informatik
Wintersemester 2015/16
Vorwort
Ein Compiler ist ein Programm, das Programme einer höheren Programmiersprache
in ausführbaren Maschinencode übersetzt. Unter Compilerbau oder auch Übersetzer-
bau versteht man die Entwicklung von Compilern. Die Vorlesung befasst sich mit den
Grundlagen des Compilerbaus, d.h. es geht weniger darum, einen konkreten Compiler
zu entwickeln, als allgemeine Techniken des Compilerbaus und alternative Entwurfs-
methoden kennenzulernen. Obwohl die Vorlesung also vor allem theoretische Konzepte
und grundsätzliche Techniken sprachunabhängig vorstellt, werden in den begleitenden
Übungen zumindest einzelne Compilerkomponenten programmiert. Hierbei wird die
funktionale Sprache Haskell als Entwicklungssprache verwendet, da diese eine beson-
ders einfache Umsetzung der formalen Methoden in lauffähige Programme erlaubt. Die
praktischen Übungen dienen der Verdeutlichung der allgemeinen Techniken.

Auch wenn Informatiker(-innen) heutzutage selten damit betraut werden, selber einen
Compiler zu entwickeln, ist die Kenntnis der grundlegenden Methoden des Compiler-
baus von immenser Bedeutung. Bei jeder Eingabe, Analyse und/oder Transformation
von Daten können die Techniken des Compilerbaus eingesetzt werden. Sie sollten daher
zum Rüstzeug eines jeden Informatikers gehören.

Die Vorlesung greift in vielen Teilen auf die Vorlesung Compilerbau zurück, die Prof.
Dr. Klaus Indermark an der RWTH Aachen gehalten hat. Kapitel 7 basiert auf dem
Kapitel 5 Übersetzung objektorientierter Sprachen“ des Buches Übersetzerbau von R.

Wilhelm und D. Maurer, das im Springer Verlag erschienen ist. Dieses Skript wurde
unter Mitwirkung meines Mitarbeiters Jost Berthold erstellt, dem ich an dieser Stelle
für seine Unterstützung herzlich danke. Ich danke meinem Mitarbeiter Mischa Die-
terle sowie Philipp Legrum, Sascha Feld, Jan Gehlhaar und weiteren Hörerinnen und
Hörern der Vorlesung, die mich auf Druckfehler in der Vorgängerversion dieses Skriptes
aufmerksam gemacht haben.
Auf dem Deckblatt ist Mr Happy abgebildet, nach dem der Haskell-Parsergenerator
Happy benannt wurde.

Marburg, im Oktober 2015


Rita Loogen

i
Inhaltsverzeichnis
1 Einleitung 1
1.1 Grundlegende Begriffe . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
1.2 Struktur eines Compilers bzw. Übersetzungsvorgangs . . . . . . . . . . 3
1.3 Bootstrapping . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
1.3.1 Hochziehen eines Compilers . . . . . . . . . . . . . . . . . . . . 7
1.3.2 Verbesserung der Effizienz . . . . . . . . . . . . . . . . . . . . . 8
1.4 Vom Interpreter zum Compiler-Compiler mit partiellen Auswertern . . 10
1.4.1 Übersetzung durch partielle Auswertung . . . . . . . . . . . . . 10
1.4.2 Erzeugung eines Compilers . . . . . . . . . . . . . . . . . . . . . 11
1.4.3 Erzeugung eines Compiler-Compilers . . . . . . . . . . . . . . . 11
1.5 Aufbau der Vorlesung . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12

2 Lexikalische Analyse 13
2.1 Endliche Automaten und reguläre Ausdrücke . . . . . . . . . . . . . . . 14
2.1.1 Reguläre Ausdrücke . . . . . . . . . . . . . . . . . . . . . . . . . 14
2.1.2 Nichtdeterministische endliche Automaten . . . . . . . . . . . . 15
2.1.3 Deterministische Automaten, Potenzmengenkonstruktion . . . . 16
2.1.4 Satz von Kleene . . . . . . . . . . . . . . . . . . . . . . . . . . . 17
2.2 Lexikalische Analyse mit endlichen Automaten . . . . . . . . . . . . . . 18
2.2.1 Principle of longest match . . . . . . . . . . . . . . . . . . . . . 19
2.2.2 Principle of first match . . . . . . . . . . . . . . . . . . . . . . . 20
2.2.3 Berechnung der flm-Analyse . . . . . . . . . . . . . . . . . . . . 20
2.3 Praktische Aspekte der Scannerkonstruktion . . . . . . . . . . . . . . . 22
2.4 Scannergeneratoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23

3 Syntaktische Analyse 27
3.1 Kontextfreie Grammatiken und Kellerautomaten . . . . . . . . . . . . . 27
3.1.1 Links-/Rechtsanalyse . . . . . . . . . . . . . . . . . . . . . . . . 29
3.1.2 Der Top-Down-Analyseautomat (TDA) . . . . . . . . . . . . . . 30
3.2 Top-Down-Analyse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 33
3.2.1 LL(k)-Grammatiken . . . . . . . . . . . . . . . . . . . . . . . . 34
3.2.2 Ein deterministischer LL(1)-TDA . . . . . . . . . . . . . . . . . 38
3.2.3 Eliminierung von Linksrekursion und Linksfaktorisierung . . . . 40
3.2.4 Komplexität der LL(1)-Analyse . . . . . . . . . . . . . . . . . . 41
3.3 Top-Down Analyse mit rekursiven Prozeduren . . . . . . . . . . . . . . 41
3.3.1 Festlegung des Typs der Parserfunktionen . . . . . . . . . . . . 42
3.3.2 Elementare Parserfunktionen . . . . . . . . . . . . . . . . . . . . 42
3.3.3 Parserkombinatoren . . . . . . . . . . . . . . . . . . . . . . . . . 43
3.3.4 Von der Grammatik zum RD-Parser . . . . . . . . . . . . . . . 45
3.3.5 Einbindung von lookahead-Informationen . . . . . . . . . . . . . 46
3.3.6 Die Parser-Kombinator-Bibiothek Parsec . . . . . . . . . . . . . 46
3.4 Bottom-Up-Analyse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 50
3.4.1 Der Bottom-Up-Analyseautomat . . . . . . . . . . . . . . . . . 50
3.4.2 LR(k)-Grammatiken . . . . . . . . . . . . . . . . . . . . . . . . 53

ii
INHALTSVERZEICHNIS

3.4.3 LR(0)-Analyse . . . . . . . . . . . . . . . . . . . . . . . . . . . 53
3.4.4 Der LR(0)-Analyseautomat . . . . . . . . . . . . . . . . . . . . 57
3.4.5 SLR(1)-Analyse . . . . . . . . . . . . . . . . . . . . . . . . . . . 58
3.4.6 LR(1)- und LALR(1)-Analyse . . . . . . . . . . . . . . . . . . . 61
3.4.7 Mehrdeutige Grammatiken . . . . . . . . . . . . . . . . . . . . . 65
3.4.8 Der Parsergenerator Happy . . . . . . . . . . . . . . . . . . . . 68
3.5 Konkrete und abstrakte Syntax . . . . . . . . . . . . . . . . . . . . . . 69

4 Semantische Analyse 71
4.1 Attributgrammatiken . . . . . . . . . . . . . . . . . . . . . . . . . . . . 73
4.2 Zirkularitäten . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 74
4.3 Verfahren zur Lösung von Attributgleichungssystemen . . . . . . . . . . 77
4.3.1 Termersetzung . . . . . . . . . . . . . . . . . . . . . . . . . . . 77
4.3.2 Wertübergabe . . . . . . . . . . . . . . . . . . . . . . . . . . . . 77
4.3.3 Uniforme Berechnung . . . . . . . . . . . . . . . . . . . . . . . . 78
4.3.4 Kopplung an die Syntaxanalyse . . . . . . . . . . . . . . . . . . 78
4.4 S-Attributgrammatiken . . . . . . . . . . . . . . . . . . . . . . . . . . . 78
4.5 L-Attributgrammatiken . . . . . . . . . . . . . . . . . . . . . . . . . . . 79

5 Übersetzung in Zwischencode (Synthesephase) 83


5.1 Übersetzung von Ausdrücken und Anweisungen . . . . . . . . . . . . . 83
5.1.1 Syntax der Sprache PSA . . . . . . . . . . . . . . . . . . . . . . 83
5.1.2 Semantik von PSA-Programmen . . . . . . . . . . . . . . . . . . 84
5.1.3 Zwischencode für PSA . . . . . . . . . . . . . . . . . . . . . . . 87
5.1.4 Übersetzung von PSA-Programmen in MA-Code . . . . . . . . . 89
5.2 Übersetzung von Blöcken und Prozeduren . . . . . . . . . . . . . . . . 93
5.2.1 PSP — eine Programmiersprache mit Prozeduren . . . . . . . . 93
5.2.2 Zwischencode für PSP . . . . . . . . . . . . . . . . . . . . . . . 96
5.2.3 Übersetzung von PSP-Programmen in MP-Code . . . . . . . . . 99
5.3 PSPP: Übersetzung von parametrisierten Prozeduren . . . . . . . . . . 105
5.3.1 Zwischencode für PSPP . . . . . . . . . . . . . . . . . . . . . . 106
5.3.2 Übersetzung von PSPP-Programmen . . . . . . . . . . . . . . . 108

6 Codeoptimierung 114
6.1 Gleitfenster-Optimierungen . . . . . . . . . . . . . . . . . . . . . . . . 114
6.2 Allgemeine Analysetechniken . . . . . . . . . . . . . . . . . . . . . . . . 115
6.3 Drei-Adress-Code . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 116
6.4 Basisblockdarstellung und Datenflussgraphen . . . . . . . . . . . . . . . 118
6.5 Lokale Analyse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 121
6.5.1 Lokale Konstantenpropagation — Vorwärtsanalyse . . . . . . . 123
6.5.2 Nutzlose Anweisungen — Rückwärtsanalyse . . . . . . . . . . . 123
6.5.3 Elimination gemeinsamer Teilausdrücke — Vorwärtsanalyse . . 124
6.6 Fallstudie: Code-Optimierung am Beispiel von Quicksort . . . . . . . . 126
6.6.1 Elimination lokaler gemeinsamer Teilausdrücke . . . . . . . . . . 127
6.6.2 Global gemeinsame Teilausdrücke . . . . . . . . . . . . . . . . . 128
6.6.3 Kopienpropagation und Elimination nutzloser Anweisungen . . . 130

iii
6.6.4 Codeverschiebung . . . . . . . . . . . . . . . . . . . . . . . . . . 130
6.6.5 Induktionsvariablen und Operatorreduktion . . . . . . . . . . . 131
6.7 Globale Analyse: Datenflussanalyse . . . . . . . . . . . . . . . . . . . . 132
6.8 Taxonomie von Datenflussproblemen . . . . . . . . . . . . . . . . . . . 134

7 Übersetzung objektorientierter Sprachen 135


7.1 Kernkonzepte objektorientierter Sprachen . . . . . . . . . . . . . . . . 135
7.1.1 Vererbung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 135
7.1.2 Generizität . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 137
7.2 Übersetzung von Methoden . . . . . . . . . . . . . . . . . . . . . . . . 137
7.3 Übersetzung einfacher Vererbung . . . . . . . . . . . . . . . . . . . . . 138
7.4 Behandlung von Mehrfachvererbung . . . . . . . . . . . . . . . . . . . . 140
7.5 Unabhängige Mehrfachvererbung . . . . . . . . . . . . . . . . . . . . . 142
7.6 Abhängige Mehrfachvererbung . . . . . . . . . . . . . . . . . . . . . . . 144

Literatur 147

iv
1. Einleitung

1 Einleitung
Unter Compilerbau oder auch Übersetzerbau versteht man die Entwicklung von Compi-
lern, d.h. von Übersetzern höherer Programmiersprachen in Maschinencode. Die Vor-
lesung behandelt allgemeine sprachunabhängige Techniken und Methoden des Compi-
lerbaus und ihre theoretischen Grundlagen.

1.1 Grundlegende Begriffe


Zu Beginn legen wir zunächst fest, was wir unter einem Compiler verstehen wollen:

Ein Compiler ist ein Programm zur Übersetzung von Programmen einer
höheren Programmiersprache (Quellprogramme) in äquivalente Programme
einer Zielsprache (Zielprogramme).

Unter einer höheren Programmiersprache versteht man dabei imperative und objekt-
orientierte Programmiersprachen oder deklarative, d.h. funktionale oder Logik- Pro-
grammiersprachen. In den imperativen Programmiersprachen bestehen Programme aus
Wertzuweisungen, Kontrollstrukturen, Datenstrukturen und Programmstrukturen wie
Modulen oder Klassen. In deklarativen Sprachen bilden Ausdrücke, Funktionen und
Prädikate, Rekursion und algebraische Strukturen zentrale Elemente. Der Sprachtyp
der Quellsprache spielt vor allem in den “späten Phasen” der Übersetzung, d.h. bei der
Codegenerierung eine zentrale Rolle, während die Techniken und Methoden der frühen
Übersetzungsphasen, bei denen die Quellsprache erkannt und analysiert wird, weitge-
hend sprachunabhängig sind. Hier geht wesentlich die Theorie der formalen Sprachen
(nach Chomsky) und die pragmatische Umsetzung von diesbezüglichen Aufgaben in
Algorithmen ein. Dies erklärt auch, warum Compilertechniken in vielen Anwendungen
von Bedeutung sind, in denen Beschreibungssprachen für spezielle Zwecke gebraucht
werden, etwa Dokumentbeschreibungssprachen wie LATEX oder HTML oder Datenbank-
anfragesprachen wie SQL, um nur zwei Beispiele zu nennen. Die Analysetechniken des
Compilerbaus und die zugehörigen Werkzeuge können hier eine wichtige Unterstützung
bieten.
Die Zielsprache eines Compilers kann prinzipiell eine beliebige Programmiersprache
sein. Meist handelt es sich allerdings um eine Maschinensprache (native code), eine
Assemblersprache oder eine systemnahe Sprache wie etwa C. Bei sogenannten Cross-
Compilern ist die Zielsprache eine andere höhere Programmiersprache, wie etwa Java.
Verschiedene Aspekte von Programmiersprachen spielen bei der Übersetzung eine Rol-
le:

Syntax: formaler hierarchischer Aufbau eines Programms aus strukturellen Kompo-


nenten (wie z.B. Deklarationen, Kontrollstrukturen, Ausdrücke...). Meistens wird
die Syntax in erweiterter Backus-Naur-Form (EBNF) spezifiziert.

Semantik: Bedeutung eines Programms.


Diese kann in verschiedener Weise angegeben werden, z.B. in Form der sogenann-
ten denotationellen Semantik, bei der einem Programm die Ein-/Ausgabefunktion
als Semantik zugeordnet wird:

1
1. Einleitung

M : Program → Input −→ Ausgabe

Pragmatik: alles, was den Umgang mit einer Programmiersprache erleichtert


Hierzu zählt etwa der sogenannte syntaktische Zucker, unter dem man benutzer-
freundliche Formulierungen für Sprachkonstrukte versteht. Zum Beispiel vermit-
telt die Anweisung

if <bedingung> then <anweisung1> else <anweisung2>

eine intuitive Vorstellung über den Ablauf der Auswertung, während etwa die
Formulierung

f 125 (<bedingung>, <anweisung1>, <anweisung2>)

keinerlei Rückschlüsse zulässt.


Zur Pragmatik gehören auch Hilfswerkzeuge wie etwa syntaxgesteuerte Editoren
oder Debugger.
Unter der Äquivalenz von Programmen versteht man ihre semantische Gleichheit.
Ein korrekter Compiler sollte natürlich Quellprogramme in äquivalente Zielprogramme
übersetzen, d.h. es muss für einen Compiler comp : Q −→ Z, wobei Q und Z die Menge
der Programme der Quell- bzw. Zielsprache seien, und beliebige Eingaben inp gelten:

Compilerkorrektheit: MQ [[prog]]inp = MZ [[compiler(prog)]]inp


Unter der Übersetzungszeit versteht man die Zeit, während der der Übersetzungsvor-
gang abläuft. Alle Informationen und Größen, die zu dieser Zeit bestimmt werden
können, heißen statisch. Entsprechend ist die Laufzeit die Zeit, in der das Zielpro-
gramm auf einer realen oder abstrakten Maschine ausgeführt wird. Größen, die dem
Compiler noch nicht bekannt sind, nennt man dynamisch.
An Compiler werden häufig die folgenden Anforderungen gestellt:
Korrektheit Das Programm in Zielsprache (Ausgabe des Compilers) soll zu dem Pro-
gramm der Quellsprache semantisch äquivalent sein (siehe oben).
Effizienz Die Programme in Zielsprache (Ausgabe) sollen effizient in Zeit- und Platz-
bedarf sein. Dies ist keine Forderung an die Effizienz des Compilers!
Fehlerbehandlung Der Compiler soll Fehler im Quellprogramm erkennen und in
verständlicher Form mitteilen. Daneben kann er auch Debugging unterstützen,
etwa durch interaktive Ausführung und Breakpoints.
verschiedene Optimierungsstufen Je mehr Zeit für Optimierung investiert wird,
desto länger kann die Übersetzung dauern. Daher soll die Anzahl“ der Optimie-

rungen einstellbar sein.
inkrementelle Übersetzung Bei einem erneuten Aufruf des Compilers werden nur
die Programmteile übersetzt, die von Programmänderungen betroffen sind.

2
1.2 Struktur eines Compilers bzw. Übersetzungsvorgangs

1.2 Struktur eines Compilers bzw. Übersetzungsvorgangs


Grundsätzlich geschieht die Übersetzung in verschiedenen Stufen, die im allgemeinen
stark verzahnt ablaufen. Dabei werden zwei Phasen der Übersetzung unterschieden:
die Analyse- und die Synthesephase, deren Bedeutung begrifflich klar ist. In der Analy-
sephase wird die Struktur eines Programms bestimmt. Hier steht die Fehlererkennung
im Vordergrund. In der Synthesephase (Synthese = Erzeugung) wird Maschinencode
aus attributierten Syntaxbäumen generiert.
Daneben kann man auch das sog. Frontend und das Backend des Compilers unter-
scheiden, wobei das Frontend im Gegensatz zum Backend maschinenunabhängig ist.
Es umfasst also die Analysephase zusammen mit der Zwischencodegenerierung und
maschinenunabhängigen Optimierungen, während das Backend aus der eigentlichen
Maschinencodegenerierung und -optimierung besteht.
Die einzelnen Compilerphasen sind in Abbildung 1 gezeigt. Die Analysephase besteht
aus drei Stufen. Die erste Stufe ist die lexikalische Analyse, die von einem sogenannten
Scanner durchgeführt wird. Der Scanner versucht in der Zeichenfolge, die er als Eingabe
erhält, elementare lexikalische Komponenten wie Bezeichner, Zahlen und Schlüsselwor-
te, die sogenannte Mikrosyntax, zu erkennen. Er produziert als Ausgabe eine Folge
von sogenannten Token oder Symbolen. In der folgenden Phase wird aus dieser Folge
die (i.allg. kontextfreie) Syntax des Programms, d.h. der hierarchische Programmauf-
bau durch den sogenannten Parser analysiert und als Ableitungs- oder Syntaxbaum
dargestellt. Die anschließende semantische Analyse durch den Analyser behandelt die
kontextsensitiven Anteile der Syntax. Sie versieht die Syntax mit sog. Attributen, et-
wa den Typen der Bezeichner. Außerdem werden Kontextabhängigkeiten wie etwa die
Deklariertheit von Bezeichnern überprüft. Auf dem entstehenden attributieren Syntax-
baum können bereits erste Optimierungen durchgeführt werden. Während der gesam-
ten Analysephase wird die Symboltabelle aufgebaut und nach und nach mit Inhalten
gefüllt, bevor sie in der Codeerzeugung benutzt werden kann. Hier werden die Bezeich-
ner (Speicheradressen), Zahlen und symbolischen Konstanten verwaltet.
In der Synthesephase wird (evtl. über bestimmten Zwischencode) der Code in der
Zielsprache erstellt und evtl. noch optimiert (Entfernung von Redundanzen, Umord-
nung). Die Erzeugung von Zwischencode hat verschiedene Vorteile. Es können maschi-
nenunabhängig eine Reihe von Optimierungen zur Verbesserung der Laufzeit und des
Speicherbedarfs des erzeugten Maschinenprogramms durchgeführt werden. Die Porta-
bilität wird erhöht. Statt bei n Quell- und m-Zielsprachen n × m separate Compiler zu
entwickeln kann man mit n + m Übersetzern auskommen, indem man eine geeignete
Zwischensprache ZS verwendet:
Q1 ... Qn
Q1 . . . Qn
↓& .↓
& .
↓ &. ↓
=⇒ ZS
↓ .& ↓
. &
↓. &↓
Z1 . . . Zm
Z1 ... Zm
Beispiel: Zur Illustration der grundsätzlichen Arbeitsweise eines Compilers zeigen
wir, wie das folgende Pascal-Programmfragment übersetzt wird:

3
1. Einleitung

Programm in Quellsprache

lexikalische Analyse

S ?
y
m Syntaxanalyse
b
o
l ?
t
a semantische Analyse
b
e ⇑ Analyse
l
l ⇓ Synthese
e ?

Zwischencode-Erzgg.

⇑ Frontend

⇓ Backend
?

Codeoptimierung

Codeerzeugung

Programm in Zielsprache

Abbildung 1: Phasen eines Compilers

4
1.2 Struktur eines Compilers bzw. Übersetzungsvorgangs

Symbol Infos Program


1 (“a”) ...  HH
 HH
2 (“b”) ...  HH
Declarations Statements
  HH
 H
Declaration Statements Statement
 H
 @ HH
@
var IdList Type semic Statement Assign
 H
 HH @@ @
@
IdList comma Id(#2) Int Assign Id(#2) becomes Expr
@
@
Id(#1) Id(#1) becomes Expr add
@
@
I “2” mul I “1”
@
@
Id(#1) Id(#1)

Abbildung 2: Syntaxbaum

var a,b: int;

a := 2;
b := a*a+1;

Der Scanner (lexikalische Analyse) erkennt aus dieser Eingabe (mit den hier dargestell-
ten Zeilenwechseln) zunächst die folgenden lexikalischen Einheiten, die sogenannten
Symbole:

id(‘‘var’’) sep id(‘‘a’’) comma id(‘‘b’’) colon sep id(‘‘int’’) sem sep sep
id(‘‘a’’) sep becomes sep num(‘‘2’’) sem sep
id(‘‘b’’) sep becomes sep id(‘‘a’’) mul id(‘‘a’’) add num(‘‘1’’) sem sep

Außerdem trägt der Scanner in die Symboltabelle die Bezeichner a und b etwa an den
Positionen 1 und 2 ein und kodiert die Bezeichner durch Verweise auf die Symbolta-
belle. Reservierte Symbole und eventuelle Compiler-Direktiven werden erkannt sowie
Leerzeichen und Kommentare eliminiert. Der für diese Aufgaben zuständige Teil des
Scanners wird Sieber genannt. Als Ausgabe des Scanners ergibt sich etwa die Symbol-
folge:

var id(1) comma id(2) colon int sem id(1) becomes num(‘‘2’’) sem
id(2) becomes id(1) mul id(1) add num(‘‘1’’) sem

Der Parser (Syntaxanalyse) analysiert die syntaktische Struktur, indem er Deklaratio-


nen und Anweisungen erkennt. Er erzeugt etwa den in Abbildung 2 gezeigten Syntax-
baum.

5
1. Einleitung

Die semantische Analyse überprüft die statischen semantischen Eigenschaften des Pro-
gramms wie Typkorrektheit, Deklariertheit der Bezeichner. Gegebenenfalls werden ver-
schiedene Analysen durchgeführt, etwa eine Abhängigkeitsanalyse oder eine Typinfe-
renz.
Im Beispiel liefert der Declarations-Unterbaum Informationen über deklarierte Bezeich-
ner und deren Typ:

id(1) → (var, int)


id(2) → (var, int)

In Statements wird etwa jeweils überprüft, ob in Wertzuweisungen links ein Variablen-


bezeichner und rechts ein Ausdruck steht, dessen Typ dem des Variablenbezeichners
entspricht. Hier ist gegebenenfalls auf Typüberladungen (overloading) zu achten.
Auf dem attributierten Syntaxbaum sind eventuell bereits erste optimierende Trans-
formationen möglich, beispielsweise:
• Konstantenpropagation, Konstantenfaltung
im Beispiel: In der Wertzuweisung b := a*a+1 hat a den konstanten Wert 2, d.h.
der Ausdruck a*a+1 kann schon zur Übersetzungszeit zu 5 ausgewertet werden.
• Herausziehen von schleifeninvarianten Berechnungen aus Schleifen
• Elimination redundanter Berechnungen
• Elimination von nicht-erreichbarem Code
Bei der Zwischencodererzeugung werden den Bezeichnern Speicher- oder Registeradres-
sen zugeordnet und es werden die Anweisungen in Maschinenbefehlssequenzen über-
setzt. Für eine einfache Registermaschine mit den Befehlen

LOAD reg adr


STORE adr reg
LOADI reg int
ADDI reg int
MUL reg adr
...

und der Adresszuordnung id(1) 7→ 0, id(2) 7→ 1 könnte der Zwischencode dann etwa
wie folgt aussehen:

LOADI R1 2 ; Zahl 2
STORE 0 R1 ; a := 2
LOAD R1 0 ; unnoetig! (Optimierung)
MUL R1 0 ; a*a
ADDI R1 1 ; a*a+1 eventuell billiger: INC R1
STORE 1 R1 ; b := a*a+1
/
Compiler bezeichnet man je nach Anzahl der Läufe, die das Programm durch das
Quellprogramm vornimmt, als One-Pass- bzw. n-Pass-Compiler.

6
1.3 Bootstrapping

1.3 Bootstrapping
Unter Bootstrapping versteht man die Benutzung eines Compilers zur eigenen Überset-
zung, also selbstreferenziell. Voraussetzung ist, dass der Compiler in der Quellsprache
geschrieben ist (Implementierungssprache = Quellsprache). Bootstrapping dient zum
einen der Erweiterung von eingeschränkten Compilern für Kernsprachen auf den vollen
Sprachumfang, zum anderen dem Erzeugen effizienterer Compiler oder neuer Compi-
lerversionen. Zur Darstellung werden sog. T-Diagramme benutzt (Q=Quellsprache, I
= Implementierungssprache, Z = Zielsprache).

Q → Z

Wir zeigen den Einsatz von Bootstrapping in zwei typischen Anwendungsfällen.

1.3.1 Hochziehen eines Compilers


Ziel ist ein ausführbarer Compiler mit I = Z = M (Maschinencode), der die Quellsprache
in Maschinencode übersetzt:

Q → M

Die folgenden Zwischencompiler müssen zur Vorbereitung des Bootstrapping-Schritts


entwickelt werden:
1. Compiler für eine Kernsprache Q− , der auch in Q− geschrieben ist

Q− → M

Q−

Q− ist eine einfache Teilsprache von Q, in der aber der Compiler ausgedrückt
werden kann.

2. Ein erster Compiler von Q− nach M wird von Hand oder mithilfe von 1) durch
Übertragung in eine Sprache mit existierendem Compiler auf M (beispielsweise
C) erzeugt werden.

Q− → M

7
1. Einleitung

3. Das Ergebnis von 1 wird nun mit 2 übersetzt, es ergibt den ersten vollwertigen
Q− -Compiler.

Q− → M Q− → M

Q− Q− → M M

4. Der erste Q− -Compiler wird zu einem Q-Compiler in Implementierungssprache


Q− erweitert und mit 3 übersetzt.

Q → M Q → M

Q− Q− → M M

5. Das Ergebnis wird zu einem Q-Compiler in der Implementierungssprache Q er-


weitert und mit der vorigen Version übersetzt.

Q → M Q → M

Q Q → M M

6. Der Compiler kann nun weiterentwickelt und anschließend mit der vorigen Version
übersetzt werden

1.3.2 Verbesserung der Effizienz


Hier soll ein existierender langsamer Compiler mit einer Optimierungsphase versehen
und beschleunigt werden. Der Compiler sei in der Quellsprache implementiert und liege
auch in ausführbarer Form vor (s.o.).

Q → Mslow Q → Mslow

Q Mslow

Allerdings sind sowohl die Compilation als auch der erzeugte Code langsam, was durch
den Index slow angezeigt wird. Ziel ist die Entwicklung eines schnellen Compilers, der
auch schnellen Code erzeugt:

8
1.3 Bootstrapping

Q → Mfast

Mfast

Hierzu ist folgende Vorgehensweise möglich:

1. Durch Modifikation des in der Quellsprache geschriebenen langsamen Compilers


wird ein optimierender Compiler entwickelt, d.h. ein Compiler, der besseren Ob-
jectcode erzeugt:

Q → Mfast

2. Dieser Compiler wird mit dem existierenden langsamen Compiler übersetzt.

Q → Mfast Q → Mfast

Q Q → Mslow Mslow

Mslow

Dies ergibt einen langsamen Compiler, der schnellen Objektcode erzeugt.

3. Zum Abschluss wird mit dem erzeugten Compiler nochmals übersetzt, um einen
schnellen Compiler zu bekommen.

Q → Mfast Q → Mfast

Q Q → Mfast Mfast

Mslow

Dies führt zu einem schnellen Compiler, der zudem schnellen Code erzeugt.

Bootstrapping kann stufenweise durchgeführt werden, um Compiler immer weiter zu


verbessern.

9
1. Einleitung

1.4 Vom Interpreter zum Compiler-Compiler mit partiellen


Auswertern
Ein Interpreter erhält als Eingabe ein Programm und seine Eingabe. Das Programm
wird für die spezielle Eingabe ausgeführt ( interpretiert“), um die Ausgabe zu ermit-

teln. Ein Interpreter unterscheidet sich von einem Compiler also dadurch, dass ein
Programm und seine Eingabe zur gleichen Zeit verarbeitet werden. Die Entwicklung
eines Interpreters ist weniger aufwendig als die eines Compilers. Er besteht aus einem
Analyse- und einem Simulationsteil. Beim Aufruf eines Interpreters werden im wesent-
lichen nur diejenigen Programmteile analysiert, die für die aktuelle Eingabe benötigt
werden, und die entsprechenden Anweisungen oder Ausdrücke werden ausgeführt.

intp :: Q × Input −→ Output


Da das Programm für verschiedene Eingaben unverändert bleibt, scheint eine Vor-
verarbeitung sinnvoll. Eine solche kann von einem sogenannten partiellen Auswerter
vorgenommen werden.
Ein partieller Auswerter erhält als Eingabe ein Programm und einen Teil der Ein-
gabe des Programms, den sogenannten statischen Input. Er produziert als Ausgabe
ein für die gegebene Eingabe spezialisiertes Programm, in dem alle Anweisungen und
Ausdrücke, die nur von dem bereits gegebenen Input abhängen, bereits ausgewertet
wurden. Wesentlich ist hier eine Aufteilung der Eingabe in einen statischen und einen
dynamischen Teil:
Input = Input st × Input d
Die statische Eingabe stellt den bereits vorhandenen oder unveränderlichen Teil der
Eingabe dar, während die dynamische Eingabe dem noch nicht bekannten oder ver-
änderlichen Teil entspricht. Der partielle Auswerter erhält als Eingabe ein Programm
und seine statische Eingabe und liefert ein spezialisiertes Programm:
(
Q × Input st −→ Q
pa ::
(p , inp st ) 7→ pspez
Dabei muss gelten:

MQ [[p]](inp st , inp d ) = MQ [[pspez ]]inp d = MQ [[pa(p, inp st )]]inp d .

1.4.1 Übersetzung durch partielle Auswertung


Ein Interpreter ist ein Programm, dessen Eingabe aus einem statischen Anteil, dem
Programm, und einem dynamischen Anteil, der Eingabe des Programms, besteht. Sei I
die Implementierungssprache des Interpreters, d.h. die Programmiersprache, in der der
Interpreter geschrieben ist. Mit einem partiellen Auswerter für I-Programme können
wir nun einen Q-Interpreter für ein gegebenes Q-Programm spezialisieren: pa(intp, p) =
intp spez . Es gilt:

MI [[intp spez ]]inp = MI [[pa(intp, p)]]inp


= MI [[intp]](p, inp) (Korrektheit des partiellen Auswerters)
= MQ [[p]]inp (Korrektheit des Interpreters)

10
1.4 Vom Interpreter zum Compiler-Compiler mit partiellen Auswertern

Damit folgt, dass der spezialisierte Interpreter intp spez ein zu dem Q-Programm p äqui-
valentes I-Programm ist. Die partielle Auswertung des Interpreters entspricht somit
einem Compilationsvorgang von Q nach I.

1.4.2 Erzeugung eines Compilers


Da sich die Eingabe eines partiellen Auswerters ebenfalls in natürlicher Weise in einen
statischen (das Programm bzw. im speziellen Kontext der Interpreter) und einen dy-
namischen Teil (der statische Input bzw. das zu interpretierende Programm) zerlegen
lässt, ist auch hier eine partielle Auswertung sinnvoll. Hierzu muss der partielle Aus-
werter auf sich selbst angewendet werden können, also in der Lage sein, den eigenen
Programmcode zu verarbeiten und zu spezialisieren. Daher muss der partielle Auswer-
ter in der Sprache geschrieben sein, deren Programme er spezialisiert.
Anwendung des partiellen Auswerters auf sich selbst und einen Interpreter liefert
einen spezialisierten partiellen Auswerter, der einem Q nach I-Compiler entspricht:
pa(pa, intp) = pa spez : Q → I. Es gilt:

MI [[MI [[pa spez ]]p]]inp = MI [[MI [[pa(pa, intp)]]p]]inp


= MI [[MI [[pa]](intp, p)]]inp
= MI [[intp]](p, inp)
= MQ [[p]]inp

Durch die Selbstanwendung des partiellen Auswerters gelingt es damit, einen Interpre-
ter in einen Compiler umzuwandeln. Den Prozess der Selbstanwendung kann man noch
weiter fortsetzen.

1.4.3 Erzeugung eines Compiler-Compilers


Ein Compiler-Compiler ist ein Programm, dass aus einer semantischen Beschreibung
einer Programmiersprache, die etwa in Form eines Interpreters gegeben sein kann, au-
tomatisch einen Compiler erzeugt. Wir zeigen hier kurz, wie man prinzipiell einen
Compiler-Compiler durch Anwendung eines partiellen Auswerters auf sich selbst her-
leiten kann. Wählt man als statischen Input des partiell auszuwertenden Auswerters
den partiellen Auswerter selbst, so führt dies zu einem spezialisierten partiellen Aus-
werter, der bei Eingabe eines Interpreters einen Compiler als Ausgabe liefert. Sei
pa(pa, pa) = cc. Was leistet das spezialisierte Programm cc? Bei Eingabe eines In-
terpreters wird gemäß dem vorherigen Abschnitt ein Compiler erzeugt, denn:

cc(intp) = pa(pa, intp) = pa spez : Q → I

Das Problem dabei ist, dass der partielle Auswerter I-Programme verarbeiten und
gleichzeitig in I implementiert sein muss, ebenso wie der Interpreter. Man muss al-
so entweder bereits einen funktionierenden I-Compiler besitzen, oder I muss sowohl
ausführbar als auch für die Implementierung geeignet sein. Die meisten existierenden
partiellen Auswerter erfüllen diese Anforderungen nicht. Insbesondere die Selbstappli-
kation ist schwierig zu realisieren.

11
1. Einleitung

1.5 Aufbau der Vorlesung


Die Vorlesung folgt dem Phasenaufbau von Compilern, der bereits in Abbildung 1 skiz-
ziert wurde. Im Zentrum stehen dabei die Wirkungsweise und grundlegende Techniken
zur Konstruktion von Compilern. Vornehmlich in den Übungen wird die funktionale
Sprache Haskell zur Entwicklung von Compilerteilen eingesetzt. Die funktionale Spe-
zifikation ist kompakt und zeigt die grundsätzlichen Transformationen:

compiler :: String -> MachineCode


compiler = codeGen . analyser . parser . scanner

scanner :: String -> [Token]


parser :: [Token] -> AbstractSyntaxTree
analyser :: AbstractSyntaxTree -> DecoratedTree
codeGen :: DecoratedTree -> MachineCode

Die bedarfgesteuerte Auswertung von Haskell hat zudem den Vorteil, dass trotz un-
abhängiger Entwicklung und Programmierung der einzelnen Compilerteile automatisch
eine verzahnte Abarbeitung der verschiedenen Compilerphasen erfolgt.

12
2. Lexikalische Analyse

2 Lexikalische Analyse
Die lexikalische Analyse wird durch einen sogenannten Scanner durchgeführt. Dieses
Programm erhält als Eingabe das Quellprogramm als Zeichenfolge p ∈ Σ∗ über dem
Zeichenvorrat Σ (beispielsweise ASCII oder Unicode). Aufgabe der lexikalischen Ana-
lyse ist das Zerlegen der Folge von Eingabezeichen (Q-Programm) in eine Symbolfolge,
die hinterher einer syntaktischen Analyse unterzogen wird.

scanner :: [Char] -> [Symbol]

Die Elemente von Σ bzw. Char heißen lexikalische Atome. Symbole sind syntaktische
Atome, d.h. spezielle Folgen lexikalischer Atome. Ein Programm p besitzt aufgrund
der Pragmatik der Programmiersprache eine lexikalische Struktur, die sogenannte Mi-
krosyntax. Um die Lesbarkeit und Wartbarkeit von Programmen zu verbessern, kann
die natürliche Sprache für Bezeichner und Schlüsselwörter verwendet werden, für Zah-
len und Formeln kann die übliche mathematische Notation eingesetzt werden. Mittels
Leerzeichen, Zeilenwechsel und Einrücken kann der Programmtext strukturiert werden.
Kommentare dienen zur Erläuterung von Programmteilen. All diese pragmatischen
Aspekte sind für die Semantik und die Übersetzung von Programmen irrelevant. Diese
ist meist syntaxorientiert, d.h. sie folgt dem hierarchischen Programmaufbau.
Zu den Teilaufgaben eines Scanners zählen

1. Zerlegung des Quellprogramms p ∈ Σ∗ in eine Folge von Lexemen, das sind Folgen
lexikalischer Atome, die syntaktische Atome (Symbole) darstellen.

2. Transformation einer Lexemfolge in eine Tokenfolge.

Für die syntaktische Analyse ist der Unterschied von Lexemen oft ohne Bedeutung.
Zum Beispiel müssen Bezeichner nicht unterschieden werden. Lexeme werden daher zu
Symbolklassen oder Token zusammengefasst. Die syntaktische Analyse bearbeitet eine
Tokenfolge. Für die semantische Analyse und Codegenerierung werden Symbole durch
zusätzliche Attribute identifiziert:

Symbol = h Token, Attribut(e) i

Die lexikalische Analyse hat demnach die Aufgabe, ein Programm in eine Folge von
Lexemen zu zerlegen und diese Folge in eine Folge von Symbolen zu transformieren.
Typische Symbolklassen sind beispielsweise:

• Bezeichner: Folge von Buchstaben und Ziffern, die mit einem Buchstaben beginnt
und kein Schlüsselwort ist, zur Bezeichnung von Konstanten, Variablen, Typen,
Funktionen, Klassen. . .

• Zahlwörter: bestimmte Folge von Ziffern, Sonderzeichen wie +, ∗, − und Buch-


staben (für Exponent- und Hexadezimaldarstellung)

• Schlüsselwörter: Bezeichner mit vorgegebener Bedeutung

• Sonderzeichen: +, ∗, −, ; , (, ), . . . bilden jeweils eine Symbolklasse

13
2. Lexikalische Analyse

• zusammengesetzte Symbole: Folgen von zwei oder mehr Sonderzeichen, z.B. :=


, <=, ∗∗, . . ., bilden ebenfalls jeweils eine Symbolklasse
• Leerzeichen: t, t . . . t, <CR>, <NL> sind meistens Trennzeichen, d.h. sie kommen
nicht innerhalb von Lexemen vor und führen zur Symboltrennung. Eine Aus-
nahme ist Fortran, wo Leerzeichen nur am Zeilenanfang beachtet werden und
ansonsten ohne Bedeutung sind.
• spezielle Symbole wie Kommentare, Pragmas zu Compiler-Direktiven
Leerzeichen und spezielle Symbole werden nicht an die syntaktische Analyse weiterge-
leitet, sondern gelöscht.
Token sind in Anlehnung an das Einführungsbeispiel aus Abschnitt 1.2 dementspre-
chend etwa id, sem, colon, becomes. Attribute sind Zeiger in die Symboltabelle, die
Binärdarstellung einer Zahl. Bei Symbolklassen mit nur einem Symbol gibt es keine
Attribute.
Symbolklassen sind reguläre Mengen, die durch reguläre Ausdrücke beschrieben und
durch endliche Automaten erkannt werden können. Dies ist die Basis für die Ent-
wicklung von Scannergeneratoren, d.h. Programmen, die aus einer Spezifikation der
Mikrosyntax automatisch Scanner generieren.

2.1 Endliche Automaten und reguläre Ausdrücke


In diesem Abschnitt werden zentrale Grundbegriffe und Ergebnisse der Theorie der
Automaten und formalen Sprachen wiederholt.

2.1.1 Reguläre Ausdrücke


Definition 1 (Reguläre Ausdrücke und Sprachen) Sei Σ ein Alphabet (Zeichen-
vorrat). Dann ist die Menge der regulären Ausdrücke über Σ, RA(Σ), definiert durch:
1. Λ ∈ RA(Σ) (der leere Ausdruck ist enthalten)
2. Für a ∈ Σ gilt auch a ∈ RA(Σ) (alle Zeichen sind enthalten)
3. Mit α, β ∈ RA(Σ) gilt auch (α|β), (α · β), (α∗ ) ∈ RA(Σ)
(Abschluss unter regulären Verknüpfungen)
wobei zum Einsparen von Klammern festgelegt wird, dass
∗ Präzedenz vor · und · Präzedenz vor | hat.
Regulären Ausdrücken werden als Semantik reguläre Sprachen, d.h. spezielle Teilmen-
gen aus Σ∗ , zugeordnet:
1. [[Λ]] := ∅
2. [[a]] := {a} für alle a ∈ Σ
3. [[α|β]] = [[α]] ∪ [[β]]
[[α · β]] := [[α]] · [[β]] (elementweise Verkettung)
[[α∗ ]] = [[α]]∗

14
2.1 Endliche Automaten und reguläre Ausdrücke

Für Λ∗ mit [[Λ∗ ]] = {ε} wird auch die Bezeichnung ε verwendet.

Beispiel: Der reguläre Ausdruck

α = (ε | − | +) · (1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9) · (0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9)∗ | 0

beschreibt die reguläre Sprache der ganzzahligen Konstanten mit oder ohne Vorzeichen.
/

Das Wortproblem für reguläre Ausdrücke (bzw. Sprachen) lautet:


Gegeben seien ein regulärer Ausdruck α ∈ RA(Σ) (bzw. eine reguläre Spra-
che L = [[α]]) und ein Wort w ∈ Σ∗ . Gilt w ∈ [[α]] oder nicht?
Das Wortproblem für reguläre Sprachen ist entscheidbar, d.h. es gibt einen Algorith-
mus, der die Frage w ∈ [[α]]?“ in Linearzeit entscheidet. Methodisch geht man meist

so vor, dass ein endlicher Automat zur Erkennung von [[α]] konstruiert wird.

2.1.2 Nichtdeterministische endliche Automaten


Ein nichtdeterministischer endlicher Automat ist ein Zustandsübergangsdiagramm mit
endlich vielen Zuständen.

Definition 2 (NFA) Ein nichtdeterministischer endlicher Automat (NFA) ist ein


Quintupel
A = (Q, Σ, δ, q0 , F )
mit endlicher Menge Q von Zuständen, Startzustand q0 ∈ Q, Endzustandsmenge F ⊆
Q und Zustandübergangsfunktion

δ : Q × (Σ ∪ {ε}) −→ ℘(Q).

Dabei bezeichne ℘(Q) die Potenzmenge von Q.

Der Automat hat einen Startzustand q0 sowie bestimmte Endzustände, die in F zusam-
mengefasst werden. Die Erkennung eines Worts besteht darin, ausgehend vom Start-
zustand mit den durch δ definierten Transitionen und der Eingabe des Wortes einen
der Endzustände zu erreichen. Zur Präzisierung dieses Erkennungsprozesses definieren
wir weiter:
Definition 3 (Epsilon-Hülle, von einem NFA erkannte Sprache) Sei T ⊆ Q,
dann ist die ε-Hülle von T, in Zeichen ε̂(T ), die kleinste Menge, für die gilt:
1. T ⊆ ε̂(T )
2. Mit q ∈ ε̂(T ) gilt auch δ(q, ε) ⊆ ε̂(T )
Die Funktion δ kann damit wie folgt zu der erweiterten Transitionsfunktion

δ̄ : ℘(Q) × Σ∗ −→ ℘(Q)

fortgesetzt werden:

15
2. Lexikalische Analyse

1. δ(T, ε) = ε̂(T )
 
[
2. δ(T, wa) = ε̂  δ(q, a)
q∈δ̄(T,w)

für alle T ⊆ Q, a ∈ Σ und w ∈ Σ∗ .


Die von einem NFA erkannte Sprache ist damit:

L(A) := w ∈ Σ∗ | δ̄({q0 }, w) ∩ F 6= ∅

Beispiel: Der folgende nichtdeterministische endliche Automat erkennt ganze Zahlen.


Die Zustände und übergänge ergeben sich direkt aus dem zuvor angegebenen regulären
Ausdruck.
2

0
0...9

ε, +, − 1...9
1 3 4

2.1.3 Deterministische Automaten, Potenzmengenkonstruktion


Deterministische Automaten sind Spezialfälle nichtdeterministischer Automaten, in de-
nen keine autonomen Transitionen möglich sind und in denen es zu einem Zustand und
einem Eingabesymbol genau einen Nachfolgezustand gibt.

Definition 4 (DFA) Ein NFA A heißt deterministischer endlicher Automat (DFA),


A ∈ DF A(Σ), falls
|δ(q, a)| = 1 für alle a ∈ Σ, q ∈ Q
|δ(q, ε)| = 0 für alle q ∈ Q

Die Zustandsübergangsfunktion eines DFAs ist somit eine totale Funktion

δ :: Q × Σ → Q

und die erweiterte Transitionsfunktion δ̄ hat für DFAs den Typ Q × Σ∗ → Q. Somit
gilt für DFAs 
L(A) := w ∈ Σ∗ | δ̄(q0 , w) ∈ F .
Ein DFA löst das Wortproblem in Linearzeit.

Zu jedem nichtdeterministischen endlichen Automaten A kann man mit der sogenannten


Potenzmengenkonstruktion (engl. subset construction) einen äquivalenten determini-
stischen Automaten Ad konstruieren.

16
2.1 Endliche Automaten und reguläre Ausdrücke

Satz 1 (Potenzmengenkonstruktion) Zu einem NFA A = (Q, Σ, δ, q0 , F ) wird ein


äquivalenter DFA wie folgt konstruiert:

Definiere: Ad = (Qd , Σ, δ d , q0d , F d ) durch


Qd = δ̄({q0 }, w) | w ∈ Σ∗ ⊆ ℘(Q)
q0d = ε̂({q0 })

F d = T ∈ Qd | T ∩ F 6= ∅
sowie δ d : Qd × Σ → Qd
δ d (T, a) = δ̄(T, a) für alle T ∈ Qd

Beispiel: Zu dem oben angegebenen NFA erhält man mit der Potenzmengenkon-
struktion den folgenden DFA:

{1, 3} 0 {2} Σ
∅ Σ

1...9 +, −
+, −

{3} 1...9 {4} 0...9

0, +, −
/

2.1.4 Satz von Kleene


Satz 2 (Kleene [Kli:ni]) Zu jedem regulären Ausdruck α ∈ RA(Σ) existiert ein NFA
A(α) mit L(A(α)) = [[α]].

Der Beweis wird konstruktiv und induktiv über den Aufbau der regulären Ausdrücke
geführt. Wir nehmen an, dass wir stets Automaten der Gestalt

konstruieren, also mit einem Startzustand, in den keine Transition zurückführt, und
mit genau einem Endzustand, aus dem keine Transition wieder herausführt.

Seien a ∈ Σ, α, β ∈ RA(Σ).

A(Λ)

17
2. Lexikalische Analyse

A(a)
a

A(α|β) Wir können den Automaten A(α|β) mit einigen ε-übergängen und neuem Start-
und Endzustand konstruieren:

ε β ε
ε ε
α

A(α · β)
ε α ε β ε

ε
A(α∗ )
ε α ε

Das Wortproblem lässt sich damit “kanonisch” mit der sog. DFA-Methode lösen:
α ∈ RA(Σ) 7→(1) A(α) ∈ NF A(Σ) 7→(2) Ad (α) ∈ DF A(Σ)
Nach dem Satz von Kleene wird zum regulären Ausdruck ein NFA konstruiert, der
anschließend in einen äquivalenten DFA umgewandelt wird. Der DFA kann noch mi-
nimiert werden (hier nicht besprochen, funktioniert durch sukzessives Verschmelzen
von äquivalenten Zuständen). Der Platzaufwand für den NFA ist O(|α|), denn der
Automat hat genau diese Anzahl Zustände. Die Potenzmengenkonstruktion bildet im
schlechtestens Fall O(2|α| ) Zustände, danach entscheidet aber der DFA in linearer Zeit,
ob w ∈ [[α]] oder nicht (Aufwand O(|w|) mit Eingabe w). Minimierung bringt keine
weiteren Zeitvorteile, höchstens einen Platzgewinn.
Die sog. NFA-Methode verzichtet auf die vollständige Potenzmengenkonstruktion. Dies
führt zu einer Verbesserung des Platzbedarfs auf O(| α |), aber zu einer Erhöhung
des Erkennungsaufwands. Die Potenzmengenkonstruktion wird (quasi “nach Bedarf”)
nur für den Lauf des Eingabewortes w durch A ∈ NF A(Σ)“ durchgeführt, indem

δ̄({q0 }, w)) (sukzessiv) berechnet wird. Der Zeitbedarf ergibt sich dabei zu O(|α|×|w|),
denn für jedes Zeichen von w muss ein Bild unter δ̄ berechnet werden (Aufwand O(|α|)).

2.2 Lexikalische Analyse mit endlichen Automaten


Nachdem das Wortproblem auf diese relativ einfache Weise gelöst werden kann, befas-
sen wir uns jetzt mit dem komplexeren Problem der lexikalischen Analyse als Ganzes.

18
2.2 Lexikalische Analyse mit endlichen Automaten

Hier existieren nicht nur einer, sondern viele reguläre Ausdrücke, die jeweils ein Token
charakterisieren.

Definition 5 (Zerlegung und Analyse) Gegeben seien α1 , . . . , αn ∈ RA(Σ) und


w ∈ Σ+ . Sei ∆ := {S1 , . . . , Sn } ein Alphabet von Symbolen. O.B.d.A. gelte zudem
ε 6∈ [[αi ]] 6= ∅ für 1 ≤ i ≤ n.
w = (w1 , . . . , wk ) heißt Zerlegung von w bzgl. α1 , . . . , αn , falls für alle j ∈ {1, . . . , k}
ein ij ∈ {1, . . . , n} existiert mit wj ∈ [[αij ]].
In diesem Fall korrespondiert w = w1 · . . . · wk mit einem Wort v = Si1 . . . Sik über
dem Symbolalphabet, welches als Analyse von w bzgl. α1 , . . . , αn bezeichnet wird. v
repräsentiert die lexikalische Struktur von w bzgl. α1 , . . . , αn

Das LA-Problem (LA steht dabei für lexikalische Analyse“):



Bestimmung einer Analyse für ein Wort w ∈ Σ+ bzgl. gegebener regulärer Aus-
drücke α1 , . . . , αn .

Bei Eingabe von w soll der Scanner also v ausgeben, was die lexikalische Struktur von w
in Symbolen wiedergibt (daneben wird die Symboltabelle aufgebaut). Im allgemeinen
sind weder die Zerlegung von w noch die Analyse von w eindeutig bestimmt, weshalb
man auf bestimmte Konventionen zurückgreift, um Eindeutigkeit zu forcieren.
Seien stets w, ∆, {α1 , . . . , αn } gegeben wie in Definition 5.

2.2.1 Principle of longest match


Das Prinzip des längsten passenden Musters (auch: maximal munch1 ) besagt, dass
der Scanner keine Ausgabe macht, solange er weitere Eingaben lesen kann und die
Möglichkeit eines längeren Treffers besteht.

Definition 6 (lm-Zerlegung) Eine Zerlegung w = (w1 , . . . , wk ) heißt longest-match-


Zerlegung (lm-Zerlegung), falls für alle j ∈ {1, . . . , k}, x, y ∈ Σ∗ und p, q ∈ {1, . . . , n}
gilt:

w = w1 · · · wj · x · y ∧ wj ∈ [[αp ]] ∧ wj · x ∈ [[αq ]] ⇒ x = ε.

Das bedeutet, dass kein wj mit Folgezeichen anders zerlegt werden könnte. Die einzige
Zeichenkette, um die wj verlängert werden kann, um wiederum ein αq zu finden, dem
die verlängerte Zeichenkette zugeordnet werden kann, ist das leere Wort ε.

Folgerung: Es existiert höchstens eine lm-Zerlegung für w bgzl. α1 , . . . , αn .

Zum Beweis betrachte man zwei lm-Zerlegungen. Falls diese sich an einer (ersten) Stelle
unterscheiden, ergibt sich ein Widerspruch zur Definition. Dadurch wird die Zerlegung
von w eindeutig, sofern sie überhaupt existiert.
lm-Zerlegungen sind durch Anwendungen motiviert: Anfangsstücke von Bezeichnern
sind beispielsweise ebenfalls Bezeichner.
1
Der Scanner “frisst” (engl. munch: mampfen, fressen) immer eine maximale Zahl von Eingabezei-
chen.

19
2. Lexikalische Analyse

2.2.2 Principle of first match


Auch wenn wir eine eindeutige Zerlegung haben, kann die Analyse noch mehrdeutig
sein, etwa wenn das Teilwort auf zwei reguläre Ausdrücke passt ([[αi ]] ∩ [[αj ]] 6= ∅). Ein
typisches Beispiel sind Schlüsselwörter wie begin, then, die von ihrer Gestalt her als
Bezeichner in Frage kommen würden. Dieses Problem wird gelöst, indem man die zu
erkennenden Symbole hierarchisch anordnet. Damit hat man ein Kriterium zur Wahl
des richtigen Symbols zur Verfügung.
Definition 7 (flm-Analyse) Sei w = w1 · . . . · wk eine lm-Zerlegung von w bzgl.
α1 , . . . , αn und v = Si1 · · · Sik eine korrespondierende Analyse.
v heißt first-longest-match-Analyse (flm-Analyse) von w (bzgl. α1 , . . . , αn ), falls für alle
j ∈ {1, . . . , k} und i ∈ {1, . . . , n} gilt:
wj ∈ [[αi ]] ⇒ ij ≤ i.
Dies impliziert, dass die {S1 , . . . , Sn } absteigend nach ihrer Priorität bei der Muster-
erkennung geordnet sind. Die Definition ist für jede Ordnung korrekt. Ebenso wie die
lm-Zerlegung ist die flm-Analyse eindeutig.
Folgerungen: 1. Es existiert höchstens eine flm-Analyse von w ∈ Σ+ .
2. Es existiert genau dann eine flm-Analyse von w, wenn eine lm-Zerlegung
existiert.

2.2.3 Berechnung der flm-Analyse


1. Schritt: Konstruiere für jeden regulären Ausdruck αi einen DFA:
Ai = (Qi , Σ, δi , q0(i) , Fi ) ∈ DF A(Σ)
mit L(Ai ) = [[αi ]].

2. Schritt: Daraus konstruieren wir den Produktautomaten wie folgt:


A = (Q, Σ, δ, q0 , F ) ∈ DF A(Σ)
mit: Q = Q1 × . . . × Qn
(1) (n)
q0 = (q0 , . . . , q0 )

δ (q (1) , . . . , q (n) ), a = (δ1 (q (1) , a), . . . , δn (q (n) , a)) für alle a ∈ Σ, q (i) ∈ Qi (1 ≤ i ≤ n)

F = (q (1) , . . . , q (n) ) | q (i) ∈ Qi , ∃j ∈ {1, . . . , n} : q (j) ∈ Fj
Die Transitionen werden in allen Teilautomaten simultan vollzogen; F ist so definiert,
dass (mindestens) einer dieser Teilautomaten
Sn Sein Symbol erkannt hat, wenn wir uns in
F befinden. Somit gilt: L(A) = i=1 L(Ai ) = i=1 [[αi ]].
n

Für das First-Match“-Prinzip wird die Menge F der Endzustände in disjunkte Teile

zerlegt, so dass stets klar ist, welches Symbol erkannt wurde.
n
]
F = F (k) mit
k=1
(1) (n) (i) (i)
(q , . . . , q )∈F ⇔ q ∈ Fi und q (j) 6∈ Fj für alle 1 ≤ j < i

20
2.2 Lexikalische Analyse mit endlichen Automaten

Die Priorität der Symbole wird also in der Zerlegung wiedergegeben, indem stets das
Symbol mit der höchsten Priorität über die Mengenzugehörigkeit des Endzustands
entscheidet.

3. Schritt: Erweitere A zu einem Backtrack-DFA B mit Ausgabe.


Das Longest-Match“-Prinzip wird durch eine modifizierte Arbeitsweise des Automa-

ten (Lookahead) sichergestellt. Diese Erweiterung des DFA um ein Lookahead wird
als Backtrack-DFA bezeichnet, denn der DFA kann in vorher durchlaufene Zustände
zurückkehren. Die Idee ist, auf der Eingabe eine zweite Position, den sogenannten
Backtrack-Kopf“ (außer der aktuellen Leseposition, dem sogenannten Lookahead-
” ”
Kopf“) festzuhalten, falls dort ein Endzustand erreicht wurde. Da wir sicherstellen
müssen, dass vor dem definitiven Endzustand die maximal mögliche Eingabe konsu-
miert wird, muss an einer solchen Stelle weiter gelesen werden, was aber evtl. nicht
mehr zu einem neuen Endzustand führt.
Der Backtrack-Kopf dient also zur Markierung des letzten Treffers, während der Look-
ahead-Kopf zur Suche nach längeren Treffern voraus geschoben wird.
Der Backtrack-Automat B arbeitet in zwei verschiedenen Modi. Er beginnt im Nor-
malmodus, in dem der erste Treffer gesucht wird, d.h. die Zuordnung eines Präfixes des
Eingabewortes zu einem der regulären Ausdrücke. Wurde ein Treffer gefunden, wird die
Arbeit im Lookahead-Modus fortgesetzt. In diesem Modus wird die gelesene Eingabe
nicht verworfen, sondern gespeichert. Der Backtrack-Kopf bleibt auf der Position des
letzten Treffers stehen, während der Lookahead-Kopf weiterbewegt wird. Das zum letz-
ten Treffer gehörige Symbol wird als Modusangabe gespeichert. Die Konfigurationen
des Automaten bestehen aus drei Komponenten: der Modusangabe, der Beschreibung
des Eingabebandes mitsamt der Zustandsangabe und der Ausgabe.
Die Konfigurationsmenge von B wird wie folgt definiert:

({N} ∪ ∆) × Σ∗ QΣ∗ × ∆∗ · {ε, lexerr}


| {z } | {z } | {z }
Modusangabe Einwegleseband Ausgabe

Der Automat beginnt im Normalmodus im Startzustand q0 mit dem Eingabewort w ∈


Σ∗ auf dem Leseband und einer leeren Ausgabe. Der Lesekopf des Eingabebandes steht
auf dem ersten Buchstaben des Eingabewortes. Die Startkonfiguration hat also die
Gestalt: (N, q0 w, ε).
Um schneller entscheiden zu können, ob vom aktuellen Zustand noch ein Endzustand
erreichbar ist oder nicht, wird im Produktautomaten A die Menge der produktiven
Zustände bestimmt:

P := q ∈ Q | ∃w ∈ Σ∗ : δ̄(q, w) ∈ F .

Der Backtrack-Automat verarbeitet die Eingabe mit den folgenden Transitionen. Es


seien stets q 0 := δ(q, a) sowie σ ∈ ∆∗ .

0
 (N, q w, σ)

 falls q 0 ∈ P \ F
Normalmodus: (N, qaw, σ) ` (Si , q 0 w, σ) falls q 0 ∈ F (i)


 Ausgabe: σ · lexerr falls q 0 6∈ P

21
2. Lexikalische Analyse


0
 (S, uaq w, σ)

 falls q 0 ∈ P \ F
Lookahead-Modus: (S, uqaw, σ) ` (Sj , q 0 w, σ) falls q 0 ∈ F (j)


 (N, q uaw, σ · S)
0 falls q 0 6∈ P

Eingabeende: (N, q, σ) ` Ausgabe: σ · lexerr falls q ∈ P \ F


(S, q, σ) ` Ausgabe: σ · S falls q ∈ F
(S, auq, σ) ` (N, q0 au, σ · S) falls q ∈ P \ F .

Somit gilt für w ∈ Σ∗ :

(N, q0 w, ε) `∗ Ausgabe: σ ∈ ∆∗ ⇔ σ ist flm-Analyse von w bzgl. α1 . . . αn



(N, q0 w, ε) ` Ausgabe: σ · lexerr ⇔ es gibt keine flm-Analyse von w bzgl. α1 . . . αn

Der Aufwand des Backtracking-Automaten zur Analyse von w ist im schlechtesten Fall
O(|w|2), weil im Falle P = Q stets bis zum Ende der Eingabe gelesen werden muss,
bevor ein Symbol definitiv erkannt ist.

Beispiel: Für die regulären Ausdrücke α1 = abc und α2 = (abc)∗ d und die Eingabe
w = (abc)m bestimmt der Backtrack-Automat die flm-Analyse S1m . Hierzu wird aller-
dings im Lookahead-Modus nach dem Erkennen jedes Treffers die gesamte Resteingabe
bis zum Ende gelesen, um zu überprüfen, ob nicht ein längerer Treffer existiert. /

Zur Verbesserung der Laufzeit kann man mit der sog. Tabularmethode2 arbeiten, welche
die erfolglosen Lookahead-Versuche protokolliert und abkürzt. Es wird eine Markie-

rungsmatrix“ aus Zustand und Eingabeposition aufgebaut, in der festgehalten wird,
wann ein Lookahead bis zum Ende zu keinem Ergebnis führt. Falls diese Situation er-
neut auftritt, wird der Lookahead sofort abgebrochen, wodurch der Scanner insgesamt
eine in der Eingabelänge lineare Laufzeit erreicht.

2.3 Praktische Aspekte der Scannerkonstruktion


Die Beschreibung von Symbolklassen (Token) durch reguläre Ausdrücke in ihrer reinen
Form ist recht umständlich. Daher wird RA(Σ) aus praktischen Gründen um einige
Konstrukte erweitert:

• Abkürzungen für häufig benutzte Kombinationen von Operatoren:


α+ := αα∗ ; α? := α|Λ∗; [abc] := a|b|c

• Abkürzungen für Zeichenklassen, die eine Ordnung aufweisen:


[a − z] = a|b|c| . . . |z; [0 − 9] = 0|1| . . . |9

• Die Präzedenzregeln für ∗ , ·, | lassen Klammern einsparen; sie werden erweitert


um die neu hinzukommenden Konstrukte (systemabhängig).
2
Thomas Reps: Maximal Munch Tokenisation in Linear Time, ACM-TOPLAS 20(1998)2, pp 259–
273.

22
2.4 Scannergeneratoren

• Reguläre Definitionen (Möglichkeit zur Definition eigener Abkürzungen (sofern


nicht rekursiv)):

id1 = α1 wobei: id1 , . . . , idn 6∈ Σ,


id2 = α2 αi ∈ RA(Σ∪{id1 , . . . , idi−1 }) für 1 ≤ i ≤ n.
..
.
idn = αn

Hier erfolgt die sukzessive Beschreibung von Symbolklassen (regulären Mengen)


durch zusätzliche frei gewählte Bezeichner (Metabezeichner). Solche regulären
Definitionen können entschachtelt werden, weil die in αi verwendeten Metabe-
zeichner vorher definiert sein müssen.

In der Praxis werden meistens (zusätzlich zur Tabularmethode) verbindlich definierte


Trennzeichen benutzt, die in keinem Symbol enthalten sind (etwa das Leerzeichen oder
der Tabulator). Lesen eines Trennzeichens führt dann ebenfalls zum sofortigen Abbruch
des Lookahead, was die Leistung nochmal beträchtlich steigert. Probleme gibt es in
Sprachen, wo das Leerzeichen nicht als verbindliches Trennzeichen benutzt wird, etwa
in FORTRAN, wo das flm-Prinzip aus diesem Grund nicht gilt.

Beispiel: In FORTRAN sind die folgenden beiden Zeichenfolgen möglich:

DO 5 I = 1.25 Zuweisung von 1.25 an den Bezeichner DO5I


DO 5 I = 1,25 Anfang einer for-Schleife
I variiert von 1 bis 25, 5 ist Anweisungsmarke.

Die lm-Zerlegung ist im ersten Fall korrekt, aber nicht im zweiten.


Für die Zeichenfolge 363.EQ.363 liefert die lm-Zerlegung die Einteilung 363.EQ.363.
Gemeint ist aber der Vergleich zweier Int-Zahlen mit dem Operator .EQ.. /

Ein Sieber dient der Beseitigung überflüssiger Tokens wie Blanks, Kommentare etc.
Symbolklassen mit mehreren Lexemen erfordern zusätzlich eine Attributberechnung
mit erneutem Lesen des Eingabestrings. Zu Dezimalzahlen muss etwa der Zahlwert
berechnet werden. Bezeichner müssen in die Symboltabelle eingetragen werden. Dabei
ist es notwendig die Gleichheit von Bezeichnern zu überprüfen. Als Attribut wird meist
der Symboltabellenindex des Bezeichners gewählt.

2.4 Scannergeneratoren
Das in den meisten Fällen eingesetzte Verfahren ist der tabellengesteuerte Scanner,
dessen Tabelle aus einer Definition der Mikrosyntax mit regulären Ausdrücken auto-
matisch erstellt wird. Die Tabelle und ein generischer Scanner-Treiber liefern zusammen
den gewünschten Scanner und müssen nicht neu implementiert werden, wenn sich die
Sprache geringfügig ändert.

23
2. Lexikalische Analyse

Alle Werkzeuge arbeiten so, dass sie aus einer Definition der Symbolklassen (und einem
generischen Standardscanner) einen Scanner in einer bestimmten Programmiersprache
erzeugen, wobei direkte Anweisungen in der Programmiersprache eingebaut werden, die
ausgeführt werden, sobald ein Symbol erkannt wurde (z.B. geschieht das Umwandeln
einer numerischen Konstante in ihren Zahlwert bereits hier).

reguläre Scanner
Sprachbeschreibung −→ Scannergenerator −→ (tabellengesteuerter
allgemeiner Treiber)

Bekannt ist das Unix/Linux-Werkzeug lex bzw. die GNU-Version flex, welches C-Code
erzeugt. Für Haskell-Code existiert ein ähnliches Werkzeug, alex, das (abgesehen vom
eingebetteten Code) die gleichen Eingabedateien verarbeitet. Die Funktionalität von
lex und alex sind vergleichbar. Die reguläre Sprachdefinition wird in einen DFA trans-
formiert, wobei ein NFA als Zwischenprodukt aufgebaut wird. Aus dem DFA werden
anschließend Tabellen zur Steuerung des allgemeinen tabellengesteuerten Scanners mit
Einbettung der benutzerdefinierten Routinen.
Die Scannererzeugung mit einem Scannergenerator erfolgt in den folgenden Schritten:

1. Der Scannergenerator erzeugt aus der regulären Sprachbeschreibung ein Pro-


gramm in einer höheren Programmiersprache, etwa C oder Haskell:

reguläre
Sprachbeschreibung −→ lex/alex −→ lex.yy.c/tokens.hs
tokens.x

2. Mittels eines C-Compilers cc oder eines Haskell-Compilers, etwa des Glasgow


Haskell Compiler ghc wird eine ausführbare Datei3 a.out erzeugt:

lex.yy.c −→ cc/ghc −→ a.out


tokens.hs

3. Der erzeugte Scanner kann im einfachsten Fall eine Zeichenfolge in eine Symbol-
folge übersetzen:

Zeichenkette −→ a.out −→ Symbolfolge

Die in Abbildung 3 angegebene Datei spezifiziert einen einfachen Scanner für Zahlen-
folgen und Bezeichner. Leerzeichenfolgen werden herausgesiebt. Die Spezifikation hat
den folgenden grundsätzlichen Aufbau:
3
oder ein Object-Modul, das in anderen Programmteilen verwendet werden kann

24
2.4 Scannergeneratoren

{
module Main where
}

%wrapper "basic"

$digit = 0-9 -- digits


$alpha = [a-zA-Z] -- letters

tokens :-

$white+ ;
$digit+ { \s -> Int (read s) }
$alpha [$alpha $digit \_ \’]* { \s -> Var s }

{
data Token = Int Int | Var String
deriving (Eq,Show)

main = do s <- getContents


print (alexScanTokens s)
}

Abbildung 3: Beispieleingabedatei tokens.x für alex

Haskell-Codefragmente: Am Anfang und am Ende stehen Haskell-Codefragmente


in geschweiften Klammern. Diese werden in das erzeugte Scannerprogramm ko-
piert. Zu Beginn steht die Moduleingangsdeklaration mit der Festlegung des Mo-
dulnamens (im Beispiel Main) und gegebenfalls import-Anweisungen. Im Code-
fragment am Ende stehen anwendungsspezifische Typdeklarationen und Funk-
tionsdefinitionen. Im Beispiel (Abbildung 3) wird ein Datentyp Token definiert
und die main-Funktion, die die Standardeingabe liest und mit der durch alex
vorgegebenen Funktion alexScanTokens in eine Tokenfolge umwandelt.

Wrapper: Alex stellt verschiedene Scannertypen zur Verfügung, die über die Wrap-
per-Deklaration vom Benutzer ausgewählt werden können. Je nach Wrapper-
Wahl wird verschiedener Scannercode durch Alex erzeugt. Die folgenden Wrapper
stehen zur Verfügung:

• Die basic Variante erzeugt einen Scanner mit dem Typ String -> [Token].
Dabei hängt der Resultattyp Token von den Aktionen ab, die in der Spezi-
fikation den regulären Ausdrücken zugeordnet werden. Diese sollten im Fall
des basic wrappers den Typ String -> Token haben.
• Die posn Variante erweitert den basic Scanner dadurch, dass die Zeilen-
und Spaltennummern der erkannten Token mit verwaltet werden. Die den

25
2. Lexikalische Analyse

regulären Ausdrücken zugeordneten Aktionen müssen bei dieser Variante


dementsprechend den Typ AlexPosn -> String -> Token haben. Dabei gilt
data AlexPosn = AlexPn !Int -- Zeichenzaehler
!Int -- Zeilennummer
!Int -- Spaltennummer

• Am flexibelsten ist der monad Wrapper, der über eine Zustandsmonade ver-
schiedene Zusatzinformationen verwaltet.

Makrodefinitionen: Abkürzende Schreibweisen für Zeichenmengen oder reguläre


Ausdrücke dienen der übersichtlichkeit der regulären Spezifikationen. Zeichen-
mengenmakros beginnen mit dem Zeichen $ (siehe Abbildung 3). Makros für
reguläre Ausdrücke beginnen mit @.

Reguläre Ausdrücke: Den zentralen Teil der Spezifikation bilden die regulären Aus-
drücke und die ihnen zugeordneten Aktionen. Nach der Eingangszeile identifier
:- folgen endliche viele Regeln der Form

regulärer Ausdruck { Aktionscode }

Der reguläre Ausdruck beschreibt eine Symbolklasse, während der Aktionscode


eine Haskell-Funktion ist, die angibt, wie eine Zeichenkette, die der Symbolklasse
angehört, verarbeitet werden soll. Im Beispiel in Abbildung 3 werden die Zei-
chenketten auf Elemente des Datentyps Token abgebildet. Wird anstelle eines
Aktionscode das Zeichen ; angegeben, so wird die erkannte Zeichenkette ver-
worfen. Im Beispiel wird für nicht-leere Folgen von Leerzeichen (vordefinierte
Makrozeichenmenge $white) kein Token erzeugt.

Der Aufruf alex tokens.x erzeugt eine Datei tokens.hs , die mit einem Haskell-
Compiler in ausführbaren Code übersetzt werden kann. Mit der Option -i erzeugt
Alex außerdem eine Datei tokens.info, in der der erzeugte DFA durch seine Zu-
standsübergänge beschrieben wird.
Der Alex-Scannergenerator bietet eine Reihe von weiteren Möglichkeiten der Zeichen-
kettenverarbeitung. über Links- und Rechtskontextangaben und sogenannte Startcodes
kann die Zuordnung einer Zeichenkette zu einer Symbolklasse vom Kontext der Zeichen-
kette abhängig gemacht werden. Interessierte Leserinnen und Leser seien für weitere
Informationen zu Alex auf die Webseite http://www.haskell.org/alex verwiesen.

26
3. Syntaktische Analyse

3 Syntaktische Analyse
Die Aufgabe der Syntaxanalyse ist die Zerlegung der Symbolfolge, die der Scanner
ausgibt, in syntaktische Einheiten. Falls eine solche Zerlegung nicht möglich ist, sollen
die (dann vorhandenen) syntaktischen Fehler ausgegeben werden. Syntaktische Ein-
heiten sind etwa Variablen, Ausdrücke, Funktionsdefinitionen, Typdeklarationen oder
Anweisungen. Der sogenannte Parser erzeugt wegen der Schachtelung syntaktischer
Einheiten aus der linearen Symbolfolge, die er als Eingabe erhält, einen Syntaxbaum
(oder er erkennt einen Syntaxfehler).

symbols - syntax tree -


Scanner Parser Analyser
6
?
Error Handler

Die Syntax wird mit Hilfe von kontextfreien Grammatiken beschrieben und mit Keller-
automaten (Stackmaschinen) analysiert. Das Problem ist insbesondere die effiziente de-
terministische Simulation der im allgemeinen nichtdeterministischen Konzepte. Im all-
gemeinen Fall können beliebige kontextfreie Sprachen mit dem Cocke-Younger-Kasami
(CYK)-Verfahren analysiert werden. Dieses hat eine Platzkomplexität von O(n2 ) und
eine Zeitkomplexität von O(n3 ) in der Länge n des Eingabewortes. Ein Compiler sollte
aber idealerweise in linearer Zeit arbeiten können, weshalb man sich auf bestimmte
Klassen von Grammatiken beschränkt und einen lookahead benutzt, um einen linearen
Platz- und Zeitaufwand für die syntaktische Analyse zu garantieren.
Grundsätzlich unterscheidet man zwischen der
Top-Down-Analyse, bei der der Syntaxbaum von der Wurzel zu den Blättern in
Form einer Linksanalyse konstruiert wird, und der
Bottom-Up-Analyse, bei der der Syntaxbaum von den Blättern zur Wurzel hin
aufgebaut und dabei eine gespiegelte Rechtsanalyse erzeugt wird.
Links- und Rechtsanalysen sind lineare Darstellungen eines Ableitungsbaums, die durch
bestimmte Traversierungsvorschriften ermittelt werden. Sie bestehen aus Produktio-
nenfolgen. Bei einer Linksanalyse wird der Syntaxbaum top-down depth-first left-to-
right (also in Präordnung von links nach rechts) durchlaufen, während er bei einer
Rechtsanalyse top-down von rechts nach links traversiert wird.

3.1 Kontextfreie Grammatiken und Kellerautomaten


Definition 8 (Kontextfreie Grammatik)
Eine kontextfreie4 Grammatik ist ein Quadrupel G = (N, Σ, P, S), in Zeichen: G ∈
CF G(Σ), falls gilt:
4
Wir benutzen nur kontextfreie Grammatiken für den Compilerbau. Daneben existieren auch kon-
textsensitive und allgemeine Grammatiken, bei denen die Produktionen eine allgemeinere Gestalt
haben.

27
3. Syntaktische Analyse

• Σ = {a, b, c, . . .} ist ein Eingabealphabet, bestehend aus Terminalen,


• N = {A, B, C, . . .} ist eine Menge von Nonterminalen,
• S ∈ N ist das Startsymbol der Grammatik
• P ⊆ {A → α|A ∈ N, α ∈ (N ∪ Σ)∗ } ist eine endliche Menge von Produktionen.
Ausgehend vom Startsymbol werden sukzessiv Nonterminale durch entsprechende rech-
te Seiten der Produktionen ersetzt, bis das Ergebnis nur noch Terminale enthält.
Definition 9 (Ableitungsrelation, erzeugte Sprache)
Die Grammatik G = (N, Σ, P, S) impliziert eine Ableitungsrelation
⇒ ⊆ (N ∪ Σ)∗ × (N ∪ Σ)∗
durch
α ⇒ β :⇔ α = α1 Aα2 , β = α1 γα2 , A → γ ∈ P
Ein Linksableitungsschritt, in Zeichen: ⇒l , liegt vor, falls α1 ∈ Σ∗ , also alles links von A
bereits ein Terminalwort ist. Analog handelt es sich um einen Rechtsableitungsschritt,
in Zeichen: ⇒r , falls α2 ∈ Σ∗ .
Die von der Grammatik erzeugte Sprache ist definiert durch
L(G) := {w ∈ Σ∗ | S ⇒∗ w} .
Es gilt: L(G) = {w ∈ Σ∗ | S ⇒∗l w} = {w ∈ Σ∗ | S ⇒∗r w}.
Beispiel: Ein einfaches Beispiel für kontextfreie Grammatiken ist

G = ({S}, {a, b}, S, {S → aSb | ε})

Die Sprache dieser Grammatik ist L(G) = {an bn | n ∈ N}. Eine mögliche Ableitung
ist:
S ⇒ aSb ⇒ aaSbb ⇒ aaaSbbb ⇒ aaaεbbb = aaabbb
Dies ist sowohl eine Links- als auch eine Rechtsableitung, da wir jeweils nur ein Non-
terminalsymbol in jeder Satzform erhalten. Eine Ableitung wird meist in einem Ablei-
tungsbaum dargestellt, der die syntaktische Struktur des abgeleiteten Wortes zeigt:

S
@ S
@@  A
 A
a S b  A
@ S
 L A
@@  L A
a S b  L A
@  S L A
@@   B L A
  B A
a S b  B L
 S L A
  B L A
ε a a a ε b b b

28
3.1 Kontextfreie Grammatiken und Kellerautomaten

Hier ist derselbe Ableitungsbaum in zwei alternativen Darstellungen gezeigt. Wenn der
Ableitungsbaum für ein Terminalwort eindeutig ist, sind es auch die Links- und Rechts-
ableitung, während eine Ableitung als linearisierte Darstellung eines Ableitungsbaums
aufgrund verschiedener Baumtraversierungen nicht eindeutig ist. /

Definition 10 (Eindeutigkeit/Mehrdeutigkeit)
Eine Grammatik G heißt eindeutig, falls es zu jedem w ∈ L(G) genau einen Ablei-
tungsbaum bzw. genau eine Linksableitung bzw. genau eine Rechtsableitung gibt.
G heißt mehrdeutig, falls G nicht eindeutig ist.

3.1.1 Links-/Rechtsanalyse
Zur Darstellung der Links- und Rechtsableitung bzw. der Links- und Rechtsanalyse
eines Wortes werden häufig Nummernfolgen verwendet.

Definition 11 (Links-/Rechtsanalyse)
Sei G = (N, Σ, S, P ) eine kontextfreie Grammatik mit |P | = p ∈ N. Sei [p] := {1, . . . , p}
und π : [p] → P eine Aufzählung von P , d.h. eine bijektive Abbildung. Es sei πi :=
π(i) ∈ P .
i i
Wir schreiben wAα ⇒l wγα bzw. αAw ⇒r αγw, falls mit der Produktion π(i) = A →
γ abgeleitet wurde.
Weiterhin sei für z = i1 . . . ik ∈ [p]+ (k ≥ 1)
z i i i
α ⇒l β :⇔ α ⇒1 l α1 ⇒2 l . . . ⇒k l αk = β für passende α1 , . . . , αk
ε
und ⇒l sei der leere Linksableitungsschritt.
z
z ∈ [p]∗ heißt Linksanalyse von α, falls S ⇒l α und analog Rechtsanalyse von α, falls
z
S ⇒r α.

Mit diesen Begriffen können wir das Problem der Syntaxanalyse allgemein formulieren:

Problem der Syntaxanalyse: Gegeben ist eine Grammatik G und ein Wort w ∈ Σ∗ .
Gesucht ist eine Links- oder Rechtsanalyse von w, falls w ∈ L(G), ansonsten eine
verständliche Fehlermeldung.

Beispiel: Grammatik für arithmetische Ausdrücke:


Gegeben sei die folgende Grammatik. Wir geben Grammatiken im folgenden durch
ihre Produktionen an, aus denen sich Terminale und Nonterminale ergeben. Die erste
angegebene Produktion definiert dabei stets das Startsymbol.

GAE : E → E + T | T (1, 2)
T → T ∗F | F (3, 4)
F → (E) | a | b | c (5 − 8)

Weiter sei das Wort w =(a)*c gegeben. Wir wollen feststellen, ob w ∈ L(GAE ) ist. Es
kann die in Abbildung 4 angegebene Linksableitung mit dem entsprechenden, ebenfalls

29
3. Syntaktische Analyse

E
2
T
@ 3

 @
2
E ⇒T @
T * F
3
⇒T ∗F 4 8
4
⇒F ∗F
5 F c
⇒ (E) ∗ F
@5
2
⇒ (T ) ∗ F @
4 ( E )
⇒ (F ) ∗ F 2
6
⇒ (a) ∗ F T
8 4
⇒ (a) ∗ c
F
6

a
Abbildung 4: Linksableitung und Ableitungsbaum

angegebenen Ableitungsbaum gefunden werden. Die Syntaxanalyse ergibt somit die


folgende Linksanalyse: 2 3 4 5 2 4 6 8. Diese entspricht einer top-down left-to-right
Traversierung des Ableitungsbaums. Die Rechtsanalyse ist 2 3 8 4 5 2 4 6, was sich
durch eine top-down right-to-left Traversierung ergibt. /

Üblicherweise verwendet man reduzierte Grammatiken; grob gesagt solche, die keine
überflüssigen Elemente enthalten.

Definition 12 (Reduzierte Grammatik) Sei G = (N, Σ, S, P ) eine kontextfreie


Grammatik, im folgenden kurz: G ∈ CF G(Σ).
Ein Nonterminal A ∈ N heißt erreichbar, falls es α, β ∈ (Σ ∪ N)∗ gibt, so dass

S ⇒ αAβ.

Es heißt produktiv, falls ein w ∈ Σ∗ existiert mit



A ⇒ w.

G ∈ CF G(Σ) heißt reduziert, falls alle A ∈ N erreichbar und produktiv sind.

3.1.2 Der Top-Down-Analyseautomat (TDA)


Wir definieren nun einen nichtdeterministischen Kellerautomaten, den sogenannten
Top-Down-Analyseautomaten, TDA, der zu einem eingegebenen Wort seinen Ablei-
tungsbaum top-down, d.h. von der Wurzel (dem Startsymbol) zu den Blättern (den
Terminalsymbolen) aufbaut und dabei eine Linksanalyse ausgibt.

30
3.1 Kontextfreie Grammatiken und Kellerautomaten

Definition 13 (Top-Down-Analyseautomat (TDA))


Der Top-Down-Analyseautomat arbeitet mit drei Zustandskomponenten: einem Einga-
beband, einem Keller und einer Ausgabe. Da der Kellerautomat nur einen Zustand
benötigt, entfällt die Zustandsmenge. Die Konfigurationsmenge hat dementsprechend
die folgende Gestalt:

Σ∗
|{z} × (Σ ∪ N)∗ × [p]∗
| {z } |{z}
Eingabeband Kellerspeicher Ausgabe
(Kellerspitze links!)
wobei die folgenden Alphabete unterschieden werden
Eingabealphabet: Σ
Kelleralphabet: Σ ∪ N (disjunkte Vereinigung!)
Ausgabealphabet: [p]
Der Automat beginnt mit der Startkonfiguration: (w, S, ε). Es werden zwei Arten von
Transitionen unterschieden:
Ableitungsschritt: (w, Aα, z) ` (w, βα, zi) falls πi = A → β ∈ P
Vergleichsschritt: (aw, aα, z) ` (w, α, z) für a ∈ Σ.
Ziel der Analyse ist es, eine Endkonfiguration (ε, ε, z) zu erreichen. Der Automat arbei-
tet nichtdeterministisch, falls in der Grammatik verschiedene Regeln für die Ersetzung
eines Nonterminals existieren: A → β | γ.
Beispiel: (Fortsetzung)
Zur Bestimmung der Linksanalyse des Ausdrucks (a)∗c (siehe Abbildung 4) durchläuft
der Automat die folgende Konfigurationsfolge:
( (a) ∗ c , E , ε )
` ( (a) ∗ c , T , 2 )
` ( (a) ∗ c , T ∗ F , 23 )
` ( (a) ∗ c , F ∗ F , 234 )
` ( (a) ∗ c , (E) ∗ F , 2345 )
` ( a) ∗ c , E) ∗ F , 2345 )
` ( a) ∗ c , T ) ∗ F , 23452 )
` ( a) ∗ c , F ) ∗ F , 234524 )
` ( a) ∗ c , a) ∗ F , 2345246 )
` ( )∗c , ) ∗ F , 2345246 )
` ( ∗c , ∗F , 2345246 )
` ( c , F , 2345246 )
` ( c , c , 23452468 )
` ( ε , ε , 23452468 )
Dies ist eine erfolgreiche Konfigurationsfolge. Es gibt viele Läufe des Automaten, die
in Sackgassen enden. /

31
3. Syntaktische Analyse

Satz 3 (Korrektheit und Vollständigkeit des TDAs)


z
(w, S, ε) `∗ (ε, ε, z) ⇐⇒ S ⇒l w (z ist Linksanalyse von w)
Die Richtung von links nach rechts nennt man die Korrektheit (⇒) und die Rückrich-
tung die Vollständigkeit (⇐) des TDA.
Beweis: Der Beweis erfolgt in beiden Richtungen induktiv über die Analyselänge k :=
|z|. Dabei wird die folgende verallgemeinerte Äquivalenz für beliebige Satzformen
α ∈ (Σ ∪ N)∗ und nicht nur für den Spezialfall α = S gezeigt:
z
(w, α, ε) `∗ (ε, ε, z) ⇐⇒ α ⇒l w.

=⇒: (Korrektheit des Automaten)


z
Wir zeigen: (w, α, ε) `∗ (ε, ε, z) impliziert α ⇒l w.
Induktionsanfang: Wegen k = 0 folgt, dass z = ε, d.h. der Automat hat nur Ver-
gleichsschritte durchgeführt, womit α = w folgt. Damit folgt die Behauptung wegen
ε
w ⇒l w.

Induktionsschluss: Sei z = iz 0 . Sei A das am weitestens links stehende Nonterminal


in α, also: α = uAβ. Bevor A auf der Kellerspitze erscheint, muss der Automat
Vergleichsschritte zur Abarbeitung von u durchführen, d.h. w hat die Gestalt w =
uv. Die i-te Regel muss A als linke Regelseite haben: πi = A → γ. Damit kann die
Transitionsfolge (w, α, ε) `∗ (ε, ε, z) wie folgt präzisiert werden:
(w, α, ε) = (uv, uAβ, ε) `∗ (v, Aβ, ε) ` (v, γβ, i) `∗ (ε, ε, iz 0 ).
Da die Ausgabe keinen Einfluss auf die Transitionsschritte des Automaten hat, gilt
insbesondere:
(v, γβ, ε) `∗ (ε, ε, z 0 ),
z0
d.h. nach Induktionsvoraussetzung folgt: γβ ⇒l v und damit:
i z0
α = uAβ ⇒l uγβ ⇒l uv = w,
was gleichbedeutend mit der Behauptung ist.

Die Rückrichtung wird analog gezeigt:


⇐=: (Vollständigkeit des Automaten)
z
Wir zeigen: α ⇒l w impliziert (w, α, ε) `∗ (ε, ε, z).
Induktionsanfang: z = ε bedeutet, dass kein Ableitungsschritt durchgeführt wurde,
d.h. α = w. Damit kann der Automat nur durch Vergleichsschritte zu der angege-
benen Endkonfiguration gelangen.
Induktionsschluss: Sei z = iz 0 . Mit der Regel πi = A → γ wird der erste Ablei-
tungsschritt von α durchgeführt, d.h. es muss gelten α = uAβ mit u ∈ Σ∗ und
z
β ∈ (Σ ∪ N)∗ und w = uv mit v ∈ Σ∗ . Für die Ableitung α ⇒l w gilt somit
i z0
α = uAβ ⇒l uγβ ⇒l uv = w.

32
3.2 Top-Down-Analyse

z0
Hieraus folgt, dass γβ ⇒l v. Laut Induktionsvoraussetzung gilt somit: (v, γβ, ε) `∗
(ε, ε, z 0 ). Somit folgt:

(uv, uAβ, ε) `∗ (v, Aβ, ε) `∗ (v, γβ, i) `∗ (ε, ε, iz 0 )

und damit die Behauptung. 

Wie bereits erläutert, arbeitet der TDA nichtdeterministisch. Falls mehrere Alternati-
ven existieren, ein Nonterminal durch eine rechte Seite zu ersetzen, ist kein Kriterium
für eine Auswahl gegeben. Bei einer deterministischen Simulation müssen alle Alter-
nativen untersucht werden. Dies ist aber für die Praxis zu ineffizient. Eine effizientere
Implementierung kann erreicht werden, wenn man durch eine Vorausschau (lookahead)
auf dem Eingabeband entscheiden kann, welche Alternative bei einem Ableitungsschritt
die richtige ist. Dies ist für die im folgenden betrachteten LL(k)-Grammatiken möglich.

3.2 Top-Down-Analyse
Das Ziel für die folgenden Betrachtungen besteht darin, den Nichtdeterminismus des
TDAs durch einen lookahead von k Symbolen auf der Eingabe zu beseitigen (k ∈
IN). Die Bezeichnung LL(k) ist eine Abkürzung für LeftToRight-Linksanalyse mit k-
lookahead. Für Sprachen, die durch LL(k)-Grammatiken erzeugt werden können, lassen
sich effiziente Top-Down-Parser konstruieren (die ja eine Linksanalyse erzeugen).
Zur Formalisierung des k-lookaheads werden zunächst die sogenannten first-Mengen
definiert:

Definition 14 (first-Menge) Seien G ∈ CF G(Σ), α ∈ (N ∪ Σ)∗ und k ∈ N. Dann


wird die k-first-Menge von α, in Zeichen first k (α) ⊆ Σ∗ , wie folgt definiert:

first k (α) = {v ∈ Σ∗ | ∃w ∈ Σ∗ : α ⇒ vw, |v| = k}

∪ {v ∈ Σ∗ | α ⇒ v, |v| < k}

Die Menge first k (α) enthält also die Präfixe bis zur Länge k aller Wörter, die sich aus α
ableiten lassen (dies sind evtl. die Wörter selbst, falls sie kürzer sind). Einige einfache
Folgerungen sind:

Folgerungen:

1. first k (α) 6= ∅ für alle α ∈ (N ∪ Σ)∗ , weil G reduziert ist.


2. ε ∈ first k (α) ⇐⇒ (k = 0 ∨ α ⇒∗ ε)

3. α ⇒ β =⇒ first k (β) ⊆ first k (α)

4. v ∈ first k (α) ⇐⇒ ∃w ∈ Σ∗ : α ⇒l w ∧ first k (w) = {v}

Die Idee ist nun, aus der Kenntnis eines Präfixes v der Eingabe die richtige Regel
auswählen zu können. Denn aus dem Nonterminal auf der Kellerspitze muss ein Präfix
der Eingabe ableitbar sein. Grammatiken, die dies erlauben, gehören einer bestimmten
Klasse an, um die es im folgenden geht.

33
3. Syntaktische Analyse

3.2.1 LL(k)-Grammatiken
Definition 15 (LL(k)-Grammatik) Sei k ∈ IN.
G ∈ CF G(Σ) heißt LL(k)-Grammatik, in Zeichen G ∈ LL(k), falls für alle Linksablei-
tungen der Form
( ∗
∗ ⇒l wβα ⇒l wx
S ⇒l wAα ∗ mit first k (x) = first k (y) gilt: β = γ
⇒l wγα ⇒l wy

Der nächste Linksableitungsschritt für wAα ist durch die nächsten k auf w folgenden
Eingabesymbole bestimmt. Für LL(k)-Grammatiken kann der nichtdeterministische
TDA mit einem lookahead von k Zeichen auf der Eingabe effizient deterministisch simu-
liert werden. Die obige Definition von LL(k)-Grammatiken ist allerdings nicht gut ge-
eignet, um damit zu arbeiten. Der folgende Satz charakterisiert die LL(k)-Grammatiken
auf andere Weise.
Satz 4 (Alternative Charakterisierung von LL(k)-Grammatiken)
G ∈ LL(k) ⇐⇒ Für alle Linksableitungen der Form
(
∗ ⇒l wβα
S ⇒l wAα mit β 6= γ
⇒l wγα

gilt first k (βα) ∩ first k (γα) = ∅.


Beweis: (⇒) Angenommen, es sei β 6= γ, aber v ∈ first k (βα)∩first k (γα). Dann muss
gelten:

βα ⇒l x
∗ mit first k (x) = first k (y) = {v}
γα ⇒l y
Dies führt zum Widerspruch, weil die LL(k)-Bedingung dann β = γ erzwingt.
(⇐) Angenommen, die Lemma-Eigenschaft gilt, aber G 6∈ LL(k), weil die Definiti-
onsbedingung mit β 6= γ verletzt ist. Dann muss gelten:
first k (βα) ∩ first k (γα) = ∅.
Dies führt aber zum Widerspruch, weil first k (x) ⊆ first k (βα) und first k (x) =
first k (y) ⊆ first k (γα). 
Hieraus folgt, dass die anzuwendende A-Regel bei A → β | γ mit den lookahead-Mengen
first k (βα) und first k (γα) bestimmt werden kann. Problematisch bei diesen Mengen ist
allerdings der Rechtskontext α. Dieser kann nicht einfach vernachlässigt werden, weil
nur in Spezialfällen gilt: first k (βα) = first k (β).
Um in der Lage zu sein, die lookahead-Mengen alleine aus den Regeln der Grammatik
zu bestimmen, vereinigt man mögliche Rechtskontexte zu sogenannten follow-Mengen.
Definition 16 (follow-Menge) Seien G ∈ CF G(Σ), A ∈ N und k ∈ IN. Dann heißt

follow k (A) := {v ∈ Σ∗ | S ⇒l wAα, v ∈ first k (α)} ⊆ Σ∗
die follow-Menge von A.

34
3.2 Top-Down-Analyse

follow k (A) enthält Wörter bis zur Länge k, die in einer Ableitung auf den aus A
abgeleiteten Teil folgen können. Die follow-Mengen sind nur für Nonterminale definiert.
Sie fassen alle möglichen Rechtskontexte zusammen. Im Fall k = 1 kann man mit diesen
Mengen eine sehr einfache Charakterisierung von LL(1)-Grammatiken angeben.

Satz 5 (Charakterisierung von LL(1)-Grammatiken)


Sei G ∈ CF G(Σ) reduziert. Dann gilt

G ∈ LL(1) ⇐⇒ für alle Regelpaare A → β | γ ∈ P mit β 6= γ gilt:

first 1 (β · follow 1 (A)) ∩ first 1 (γ · follow 1 (A)) = ∅.

Definition 17 (lookahead-Menge) Die lookahead-Menge von A → β, in Zeichen


la(A → β), wird definiert durch

la(A → β) := first 1 (β · follow 1 (A)).

Dabei sei für Γ ⊆ (Σ ∪ N)∗ :


[
first k (Γ) := first k (α).
α∈Γ

Es gelten die folgenden Aussagen:

• first 1 (α), follow 1 (A) ⊆ Σ ∪ {ε} für alle α ∈ (N ∪ Σ)∗ und A ∈ N

• β · follow 1 (A) ⊆ (Σ ∪ N)∗

• la(A → β) ⊆ Σ ∪ {ε}

• ε ∈ la(A → β) ⇐⇒ ε ∈ follow 1 (A) und β ⇒ ε

• a ∈ Σ, a ∈ la(A → β) ⇐⇒ a ∈ first 1 (β) oder (β ⇒ ε und a ∈ follow 1 (A))

Beweis: (Satz 5) (⇒) Annahme: A → β | γ mit β 6= γ und c ∈ la(A → β)∩la(A → γ)


für ein c ∈ Σ ∪ {ε}.

1. Fall: c = ε.
∗ ∗
Dann muss gelten: β ⇒ ε, γ ⇒ ε und ε ∈ follow 1 (A) d.h.
(
∗ ⇒l wβα
S ⇒l wAα mit ε ∈ first 1 (βα) ∩ first 1 (γα)
⇒l wγα

obwohl β 6= γ. Dies ist ein Widerspruch zu Satz 4.


2. Fall: c = a ∈ Σ.
Es sind vier Fälle möglich:

35
3. Syntaktische Analyse

1. a ∈ first(β) ∩ first 1 (γ).


Da G reduziert ist, gibt es eine Ableitung
(
∗ ⇒l wβα
S ⇒l wAα mit a ∈ first 1 (βα) ∩ first 1 (γα)
⇒l wγα

obwohl β 6= γ. Dies ist ein Widerspruch zu Satz 4.



2. a ∈ first(β), γ ⇒ ε, a ∈ follow 1 (A).
Auch hier gibt es eine Ableitung
(
∗ ⇒l wβα
S ⇒l wAα mit a ∈ first 1 (βα) ∩ first 1 (γα),
⇒l wγα


wobei a ∈ first 1 (γα) wegen γ ⇒ ε. Wie oben folgt der Widerspruch.

3. a ∈ first(γ), β ⇒ ε, a ∈ follow 1 (A).
wie unter 2.
∗ ∗
4. β ⇒ ε, γ ⇒ ε, a ∈ follow 1 (A).
analog zu 2.
(
∗ ⇒l wβα
(⇐) Sei S ⇒l wAα mit β 6= γ.
⇒l wγα
Nach Voraussetzung gilt: first 1 (βfollow 1 (A)) ∩ first 1 (γfollow 1 (A)) = ∅.
Damit folgt wegen first 1 (βα) ⊆ first 1 (βfollow 1 (A)), dass

first 1 (βα) ∩ first 1 (γα) = ∅

und nach Satz 4 die Behauptung. 

Für k = 1 kann der lokale Rechtskontext durch follow 1 (A) verallgemeinert werden.

Die Berechnung der lookahead-Mengen erfolgt induktiv. Es ist hilfreich als Hilfsopera-
tion die Konkatenation modulo k, in Zeichen: ⊕k , zu definieren:

Definition 18 (Konkatenation modulo k)

⊕k : Σ∗ × Σ∗ → Σ∗
mit u ⊕k w 7→ v mit {v} = first k (uw).

Für Wortmengen X, Y ⊆ Σ∗ gelte entsprechend:

X ⊕k Y := {v | ∃u ∈ X, w ∈ Y : v = u ⊕k w}.

Satz 6 (Berechnung der first- und follow-Mengen)


Sei k ∈ IN. Die Mengen first k (α) für α ∈ (Σ ∪ N)∗ und follow k (A) für A ∈ N sind die
kleinsten unter den folgenden Regeln 1.–3. abgeschlossenen Teilmengen von (Σ ∪ {ε})k .

36
3.2 Top-Down-Analyse

1. first k (X) für X ∈ Σ ∪ N ∪ {ε}.


first k (ε) = {ε}
first k (a) = {a} für alle a ∈ Σ.
[
first k (A) = first k (α) für alle A ∈ N.
A→α∈P
Hier ist eine simultane Berechnung für alle A ∈ N erforderlich.
2. first k (X1 . . . Xn ) für Xj ∈ Σ ∪ N, n ≥ 0.
first k (X1 . . . Xn ) = first k (X1 ) ⊕k · · · ⊕k first k (Xn )
3. follow k (A) für A ∈ N.
ε ∈ follow k (S)
B → αAβ ⇒ first k (β) ⊕k follow k (B) ⊆ follow k (A)
Ein einfacher LL(1)-Test für kontextfreie Grammatiken berechnet zu allen Produk-
tionen die lookahead-Mengen und testet, ob die lookahead-Mengen von Regeln mit
gleichem Nonterminalsymbol auf der linken Seite disjunkt sind.
Beispiel: Betrachten wir erneut die Grammatik für arithmetische Ausdrücke, hier in
einer vereinfachten Version ohne die Regeln F → b | c:
GAE : E → E + T | T
T → T ∗F |F
F → (E) | a

Es ergeben sich die folgenden lookahead-Mengen:


• la(F → (E) ) = first 1 ( (E) · follow 1 (F ) ) = {(}
• la( F → a ) = first 1 (a · follow 1 (F ) ) = {a}
• la( E → E + T ) = first 1 (E + T · follow 1 (E)) = first 1 (E) ⊕1 . . . (vernachlässigt

wegen E ⇒ 6 ε)
= first 1 (E + T ) ∪ first 1 (T )( direkte Rekursion: keine Vergrößerung)
= first 1 (T ) = . . . = first 1 (F ) = {(, a}
• Entsprechend zeigt man: la(E → T ) = first 1 (T ) = la(E → E + T ).
Die lookahead-Mengen sind nicht disjunkt. Dies liegt daran, dass die Grammatik links-
rekursiv ist. Mit einer veränderten Grammatik, in der die Linksrekursion eliminiert
wurde, erhält man disjunkte lookahead-Mengen und kann somit über die nächste Pro-
duktion entscheiden.
G0AE : E → T E0 (1)
E0 → +T E 0 | ε (2, 3)
T → FT0 (4)
T0 → ∗F T 0 | ε (5, 6)
F → (E) | a (7, 8)
Die lookahead-Mengen ergeben sich zu:

37
3. Syntaktische Analyse

1. la(E → T E 0 ) = {a, (}
2. la(E 0 → +T E 0 ) = {+}
3. la(E 0 → ε) = follow 1 (E 0 ) = {ε} ⊕1 follow 1 (E) = follow 1 (E) = {ε, )}
4. la(T → F T 0) = {a, (}
5. la(T 0 → ∗F T 0 ) = {∗}
6. la(T 0 → ε) = follow 1 (T 0 ) = follow 1 (T ) = first 1 (E 0 follow 1 (E)) = {+, ε, )}
7. la(F → (E)) = {(}
8. la(F → a) = {a}.

Hier sind die lookahead-Mengen der Regelalternativen mit identischen Nonterminalen


auf der linken Seite disjunkt. Die Grammatik ist laut Satz 5 also LL(1). /

3.2.2 Ein deterministischer LL(1)-TDA


Der in Definition 13 eingeführte Top-Down-Analyseautomat kann nun für LL(1)-Gram-
matiken deterministisch arbeiten. Die Zugehörigkeit des nächsten Eingabesymbols (1-
lookahead) zu einer la-Menge steuert die Regelauswahl: Der Ableitungsschritt wird wie
folgt modifiziert:

Ableitungsschritt: (aw, Aα, z) ` (aw, βα, zi) falls πi = A → β und a ∈ la(πi )


(ε, Aα, z) ` (ε, βα, zi) falls πi = A → β und ε ∈ la(πi )

Der so modifizierte Automat arbeitet für LL(1)-Grammatiken deterministisch. Dies


ist streng genommen kein Kellerautomat mehr, da die Eingabe bei einem Ableitungs-
schritt zwar gelesen, aber nicht gelöscht wird. Das ist aber kein Problem, da man
die Arbeitsweise mit einem gewöhnlichen Kellerautomaten simulieren kann. Man führt
mehrere Kontrollzustände ein und merkt sich gelesene Eingabesymbole in der endlichen
Kontrolle.
Üblicherweise wird die Funktion des deterministischen LL(1)-TDA durch eine Analyse-
tabelle (action-Funktion von G) beschrieben, welche ausgehend von Eingabe und Stack
den neuen Stack und die Ausgabe sowie andere Aktionen beschreibt.

Definition 19 (Analysetabelle von G) Die Analysetabelle von G ∈ CF G(Σ) wird


durch die folgende Funktion festgelegt.

act G : (Σ ∪ {ε}) × (N ∪ Σ ∪ {ε}) −→ ({α | A → α ∈ P } × [p])


| {z } | {z }
Eingabesymbol Kellerspitze ∪{pop, accept, error}

ist definiert durch

act G (x, A) := (α, i), falls πi = A → α, x ∈ la(πi )


act G (a, a) := pop
act G (ε, ε) := accept
act G (x, X) := error in allen übrigen Fällen

38
3.2 Top-Down-Analyse

Beispiel: Der TDA zu der modifizierten Grammatik G0AE hat die folgende Analyse-
tabelle:
Keller
Eingabe E E’ T T’ F a ( ) + * ε
a TE’,1 FT’,4 a,8 pop
( TE’,1 FT’,4 (E),7 pop
) ε,3 ε,6 pop
+ +TE’,2 ε,6 pop
* *FT’,5 pop
ε ε,3 ε,6 accept
Alle nicht ausgefüllten Felder führen zu einem Fehler. Die Tabelle ergibt sich aus den
lookahead-Mengen für die einzelnen Produktionen. Beispielsweise ist der lookahead für
die Regel 6 la(T 0 → ε) = {+, ), ε}, womit sich die Einträge für (+, T 0 ), (), T 0 ) und
(ε, T 0) ergeben.
Zur Erkennung der Eingabe (a∗a) durchläuft der deterministische Automat die folgende
Konfigurationsfolge:

( (a ∗ a) , E , ε )
( (a ∗ a) , T E0 , 1 )
( (a ∗ a) , F T 0E 0 , 14 )
( (a ∗ a) , (E)T 0 E 0 , 147 )
( a ∗ a) , E)T 0 E 0 , 147 )
( a ∗ a) , T E 0 )T 0 E 0 , 1471 )
( a ∗ a) , F T 0E 0 )T 0 E 0 , 14714 )
( a ∗ a) , aT 0 E 0 )T 0 E 0 , 147148 )
( ∗a) , T 0 E 0 )T 0 E 0 , 147148 )
( ∗a) , ∗F T 0E 0 )T 0 E 0 , 1471485 )
( a) , F T 0E 0 )T 0 E 0 , 1471485 )
( a) , aT 0 E 0 )T 0 E 0 , 14714858 )
( ) , T 0 E 0 )T 0 E 0 , 14714858 )
( ) , E 0 )T 0 E 0 , 147148586 )
( ) , )T 0 E 0 , 1471485863 )
( , T 0E 0 , 1471485863 )
( , E0 , 14714858636 )
( , ε , 147148586363 )
/
Der lookahead auf der Eingabe beseitigt nicht nur den Nichtdeterminismus des TDAs,
sondern erlaubt auch eine frühzeitige Fehlererkennung.
Für die Konstruktion von Parsern nach der Top-Down-Methode genügt es, (1) die
la-Mengen der Grammatik-Produktionen zu berechnen, (2) den LL(1)-Test durch-
zuführen, (3) die Analysetabelle aufzustellen und (4) einen tabellengesteuerten Parser
zu generieren.

39
3. Syntaktische Analyse

3.2.3 Eliminierung von Linksrekursion und Linksfaktorisierung


Häufig ist eine gegebene Grammatik G nicht LL(1). Somit stellt sich die Frage, ob und
gegebenenfalls wie man im allgemeinen Fall eine LL(1)-Grammatik aus einer gegebe-
nen Grammatik erhalten kann. Leider ist eine allgemeine automatische Transformation
nicht möglich, denn es existieren deterministisch-kontextfreie Sprachen, die für kein
k ∈ N durch LL(k)-Grammatiken beschreibbar sind:
L(LL(k)) $ L(LL(k + 1)) $ L(DP DA) $ L2 (Σ) = L(CF G)
Die durch LL(k)-Grammatiken beschreibbaren Sprachen bilden eine echte Hierarchie
innerhalb der deterministisch kontextfreien Sprachen.
Häufig kann man dennoch zu einer Grammatik G ∈ CF G(Σ) eine äquivalente LL(1)-
Grammatik finden. LL(k)-Grammatiken sind stets eindeutig und nie linksrekursiv. Da-
her können zwei Transformationen manchmal hilfreich sein: (1) Entfernen von Linksre-
kursion und (2) Linksfaktorisierung. Solche Grammatiktransformationen werden häufig
in parsererzeugenden Systemen eingesetzt. Dabei ist zu beachten, dass durch die Trans-
formationen die Struktur der Grammatik verändert wird.
Definition 20 (Linksrekursive Grammatik)
+
G ∈ CF G(Σ) heißt linksrekursiv, falls gilt: ∃A ∈ N : A ⇒ Aα (wobei α ∈ (N ∪ Σ)∗ ).
Satz 7 Für eine linksrekursive Grammatik G gilt: ∀k ∈ N : G 6∈ LL(k).
Der Grund hierfür liegt in der Tatsache, dass ein Top-Down-Parser Ableitungen der
Form
∗ + ∗
S ⇒l wAβ ⇒l wAαβ ⇒l wv
+
nicht simulieren kann, denn bei A ⇒l Aα wird keine Eingabe konsumiert. Der looka-
head ist somit identisch und es kommt zu einer Endlos-Schleife.
Für den Spezialfall der direkten Linksrekursion (Regeln der Form A → Aα) genügt die
folgende Ersetzung und Einführung von neuen Nonterminalen:
Alle Regeln der Form A → Aα | β (α 6= ε, β 6= Aγ) werden ersetzt durch
)
A → βA0
mit neuem A0 .
A0 → αA0 | ε
Die erzeugte Sprache bleibt bei dieser Transformation unverändert. Die grammatikali-
sche Struktur ändert sich aber.
Beispiel: Die zuvor betrachtete Grammatik G0AE (siehe Seite 37)

G0AE : E → T E0 ist durch die oben beschriebene Trans-


formation aus der Grammatik
E0 → +T E 0 | ε
T → FT0 GAE : E → E + T | T
T0 → ∗F T 0 | ε T → T ∗F |F
F → (E) | a F → (E) | a

entstanden. /

40
3.3 Top-Down Analyse mit rekursiven Prozeduren

Im allgemeinen Fall einer indirekten Linksrekursion kann die Grammatik in Greibach-


Normalform umgewandelt werden. Dann haben alle Produktionen die Form A →
aB1 . . . Bm mit (m ≥ 0, Bi 6= S) oder S → ε.
Die Linksfaktorisierung betrifft eine andere Situation, die sich ebenfalls verlängernd
auf den nötigen lookahead auswirkt: Regeln der Form A → αβ | αγ erfordern einen
lookahead, der über α hinaus geht. Sie werden in Regeln der Form A → αA0 ; A0 → β | γ
transformiert. Das klassische Beispiel ist die if-then-else-Verzweigung.
Beispiel: Die bedingte Verzweigung kann eine Alternative else enthalten, welche in
imperativen Sprachen aber auch fehlen kann.

Stmt → if Expr then Stmt


| if Expr then Stmt else Stmt
Durch Linksfaktorisierung ergibt sich:
Stmt → if Expr then Stmt Rest
Rest → else Stmt | ε
/
Elimination von Linksrekursion und Linksfaktorisierung führen nicht notwendig zu
einer LL(1)-Grammatik.

3.2.4 Komplexität der LL(1)-Analyse


Die Zeit- und Platzkomplexität der LL(1)-Analyse ist linear, denn:
Der deterministische TDA macht für eine Eingabe w ∈ Σ∗ insgesamt |w| Vergleichs-
schritte (pop). Da die Grammatik nicht linksrekursiv ist, kann jedes Nonterminal maxi-
mal einmal zwischen zwei Vergleichsschritten auf der Kellerspitze liegen. Daher macht
der Automat maximal |N| − 1 Ableitungsschritte pro Vergleichsschritt. Insgesamt er-
geben sich also maximal (|N| − 1)(|w| + 1) + |w| = |N| · (|w| + 1) − 1 Transitionen
(Zeitkomplexität O(|w|)). In jedem Schritt kommen maximal max{|α| | A → α ∈ P }
Elemente auf den Stack, somit ist die Platzkomplexität ebenfalls O(|w|).

3.3 Top-Down Analyse mit rekursiven Prozeduren


Unter recursive-descent-parsing“ (RD-parsing) versteht man die Durchführung einer

Top-Down-Analyse mit rekursiven Prozeduren. Die Kernidee ist die implizite Ver-
wendung des Laufzeitkellers rekursiver Prozeduren oder Funktionen anstelle des ex-
pliziten Kellers des deterministischen Top-Down-Analyseautomaten. Mit rekursiven
Prozeduren kann eine Top-Down-Analyse sehr elegant und einfach realisiert werden.
Im wesentlichen erfolgt eine automatisierbare Transformation der Grammatikregeln in
Prozedurdefinitionen. In Prolog können etwa sogenannte definite Klauselgrammatiken
direkt spezifiziert werden. In funktionalen Sprachen wie Haskell werden sogenannte
Parserkombinatoren eingesetzt, um Grammatikregeln in rekursive Parserfunktionen zu
transformieren. Generell besteht die Grundidee in einer direkten Interpretation der
Grammatikregeln als Parser.

41
3. Syntaktische Analyse

3.3.1 Festlegung des Typs der Parserfunktionen

Parser überführen eine Tokensequenz bzw. Zeichenkette in einen Syntaxbaum, der die
grammatikalische Struktur der Zeichenkette verdeutlicht:

type Parser = String -> Tree

Im allgemeinen konsumiert ein Parser nicht unbedingt die gesamte Eingabe, sondern
nur einen Teil und liefert den nicht konsumierten Teil mit dem Ergebnis zurück:

type Parser = String -> (Tree, String)

Da Parser auch scheitern können, ist es sinnvoll, als Resultattyp eine Liste zu wählen,
um Scheitern durch die leere Liste und Erfolg durch eine einelementige Liste zu re-
präsentieren:

type Parser = String -> [(Tree, String)]

Da unterschiedliche Parser unterschiedliche Baumkonstrukte zurückliefern und unter-


schiedliche Eingabeströme verarbeiten, ist es zweckmäßig, vom konkreten Resultattyp
sowie vom Tokentyp zu abstrahieren, so dass sich der folgende Typ für Parserfunktionen
ergibt:

type Parser token tree = [token] -> [(tree,[token])]

Der Tokentyp und der Resultattyp sind nun durch Typvariablen token und tree re-
präsentiert. Im folgenden werden zur vereinfachten Schreibweise meist die Typvariablen
a und b verwendet.

3.3.2 Elementare Parserfunktionen

1. result :: b -> Parser a b


result v inp = [(v,inp)]
konsumiert keine Eingabe und liefert das erste Argument als Resultat zurück.

2. zero :: Parser a b
zero inp = [] -- zero = \ inp -> []
schlägt unabhängig von der Eingabe immer fehl.

3. item :: Parser a a
item [] = [] -- Fehlschlag
item (c:cs) = [(c,cs)]
konsumiert bei nicht-leerem Eingabestring das erste Zeichen und liefert dieses als
Resultat zurück.

42
3.3 Top-Down Analyse mit rekursiven Prozeduren

3.3.3 Parserkombinatoren
Parserkombinatoren kombinieren elementare Parser entsprechend möglicher Gramma-
tik-Konstruktionen:

Sequenz — Konkatenation
Auswahl — Alternative Regeln
Wiederholung — Sternoperator

Die sequentielle Komposition von Parserfunktionen erfolgt mittels des bind-


Operators:

bind :: Parser a b -> (b->Parser a c) -> Parser a c


bind p f inp = concat [ f v inp’ | (v,inp’) <- p inp ]

Der bind-Operator nimmt einen Parser und eine Abbildung vom Ergebnistyp dieses
Parsers auf Parser mit dem selben Eingabetyp. Zunächst wird der erste Parser aufge-
rufen, p inp. Dies liefert eine Liste von Paaren (v,inp’). Für jedes Element der Liste
wird (f v) :: Parser a c gebildet und auf die restliche Eingabe inp’ angewendet.
Dies liefert Listen vom Typ [(c,[a])], die mittels concat :: [[a]] -> [a] zu einer
Liste zusammengefügt werden.
Für beliebige Funktionen f gilt etwa: bind zero f = zero.

Beispiel: Mittels bind kann ein Parser

sat :: (Char -> Bool) -> Parser Char Char

entwickelt werden, der ein einzelnes Zeichen erkennt, wenn es dem als Parameter über-
gebenen Prädikat genügt, und sonst scheitert.

sat p = bind item checksymbol


where checksymbol :: Char -> Parser Char Char
checksymbol x = if (p x) then result x else zero

Das Prädikat isDigit :: Char -> Bool liefert True, falls das Argument eine Ziffer
ist. Dementsprechend folgt:

sat isDigit "0AB" =>* [(’0’,"AB")]

Mithilfe von sat können weitere einfache Parser definiert werden:

• bestimmte Zeichen erkennen

char :: Char -> Parser Char Char


char c = sat (\ y -> y == c)

• Ziffern

43
3. Syntaktische Analyse

digit :: Parser Char Char


digit = sat digit

• Kleinbuchstaben

lower :: Parser Char Char


lower = sat (\ y -> y >= ’a’ && y <= ’z’)

• Großbuchstaben

upper :: Parser Char Char


upper = sat (\ y -> y >= ’A’ && y <= ’Z’)

Durch Komposition von sat-Parsern können Sequenzen von Zeichen erkannt werden,
beispielsweise zwei aufeinanderfolgende Kleinbuchstaben:

twolower :: Parser Char String


twolower = lower ‘bind‘ \ x ->
lower ‘bind‘ \ y ->
result [x,y]

Es folgt beispielsweise:
twolower "abcd" =>* [("ab","cd")]
twolower "aBcd" =>* [] /

Die Auswahl zwischen zwei Parserfunktionen erfolgt mittels des plus-Opera-


tors:

plus :: Parser a b -> Parser a b -> Parser a b


plus p q = \ inp -> (p inp) ++ (q inp)

Ein mittels plus kombinierter Parser liefert eventuell eine Resultatliste der Länge > 1,
falls p und q die Eingabe erfolgreich durchlaufen können.

Beispiel: Ein Parser für alphanumerische Zeichen, also Buchstaben und Ziffern, kann
wie folgt definiert werden:

letter :: Parser Char Char


letter = lower ‘plus‘ upper

alphanum :: Parser Char Char


alphanum = letter ‘plus‘ digit

Durch rekursive Verwendung von Parsern können Zeichenketten beliebiger Länge ana-
lysiert werden:

44
3.3 Top-Down Analyse mit rekursiven Prozeduren

Beispiel: Ein Parser für Wörter, also Buchstabenfolgen, hat die folgende rekursive
Definition:

word :: Parser Char String


word = neWord ‘plus‘ result ""
where neWord = letter ‘bind‘ \ x ->
word ‘bind‘ \ xs -> result (x:xs)

neWord steht für non-empty word“. Es gilt:



word "Yes!" =>* [("Yes","!"), ("Ye","s!"), ("Y","es!"), ("","Yes!")]

Es werden also alle möglichen Zerlegungen der Eingabekette in ein Wort und einen
Rest ermittelt. Dadurch dass die Erkennung des leeren Wortes (Parser result "")
als zweite Alternative angegeben ist, wird als erste mögliche Zerlegung die maximal
mögliche ermittelt. Ist man nur an dieser interessiert, wird durch die bedarfsgesteuerte
Auswertung in Haskell auch nur diese berechnet. /

Dem im vorigen Beispiel gezeigten Parser liegt ein allgemeineres Parserschema zugrun-
de, das die wiederholte Anwendung desselben Parsers beschreibt:

many :: Parser a b -> Parser a [b]


many p = ( p ‘bind‘ \ x ->
(many p) ‘bind‘ \ xs -> result (x:xs)
) ‘plus‘ result []

Es gilt word == many letter.


Ein weiteres nützliches Konstrukt ist die optionale Verwendung eines Parsers:

option :: Parser a b -> Parser a [b]


option p = ( p ‘bind‘ \ x -> result [x] )
‘plus‘ result []

3.3.4 Von der Grammatik zum RD-Parser


Mit diesen Konstrukten kann eine Grammatik in EBNF systematisch in Parserfunk-
tionen übersetzt werden. Neue Parserkombinatoren können je nach Bedarf eingeführt
werden. Dies zeugt von der hohen Flexibilität der Methode. Jedem Nonterminal der
Grammatik wird eine Parserfunktion zugeordnet. Diese wird entsprechend der Gram-
matikregeln definiert. Terminalsymbolfolgen kann mittels der folgenden elementaren
Parserfunktion tok ein Parser zugeordnet werden.

tok :: Eq a => [a] -> Parser a [a]


tok s cs = loop s cs
where loop [] cs = [(s,cs)]
loop (s:ss) (c:cs) | s==c = loop ss cs
loop _ _ = []

45
3. Syntaktische Analyse

Die den Symbolen bzw. Symbolfolgen einer rechten Regelseite zugeordneten Parser
werden mittels des bind-Operators verknüpft. Die Ergebnisse werden gesammelt und
zu einem Gesamtergebnis kombiniert. Die Parserfunktionen zu den verschiedenen rech-
ten Regelseiten eines Nonterminals werden schließlich mittels des plus-Kombinators
vereinigt. Abbildung 5 zeigt einen auf diese Weise erzeugten recursive-descent parser“

für arithmetische Ausdrücke. Hier werden die Operatoren bind und plus als rechtsas-
soziative Infixoperatoren definiert, wobei (&&&)=bind eine höhere Priorität erhält als
(|||)=plus. Letzteres spart Klammern. Als Ergebnis liefern die Parserfunktionen eine
Linksanalyse in Form einer Nummernfolge.

infixr 6 &&&
infixr 4 |||
(|||) = plus; (&&&) = bind

Der Aufruf parseRD "(a*a)" liefert das Ergebnis 147148586363. Bei einer fehlerhaften
Eingabe, etwa (aa) erfolgt ein durch die error-Funktion ausgelöster Laufzeitfehler mit
der Ausgabe syntax error in expression. Diese Fehlermeldung ist wenig spezifisch.
Eine Verbesserung der Fehlermeldungen und eine zielgerichtetere Abarbeitung wird
durch die Einbindung eines 1-lookaheads in die Parserfunktionen erzielt.

3.3.5 Einbindung von lookahead-Informationen


Die Abbildung 6 gezeigten Funktionen nutzen lookahead-Informationen zur frühzeiti-
gen Fehlererkennung bzw. zur Parserauswahl aus. Die Funktion lacontrol wird dem
Parser beim Vorhandensein nur einer Regel für ein Nonterminal vorgeschaltet, um zu
kontrollieren, ob das nächste Eingabesymbol zu der lookahead-Menge der Regel gehört.
Ist dies nicht der Fall wird eine Fehlermeldung der Art

Syntax error: expected: ... found: ...

ausgegeben, wobei die erwarteten Symbole die Elemente der lookahead-Menge sind,
während die gefundenen Symbole der aktuellen Eingabe entsprechen. Eine solche Feh-
lermeldung hilft dem Benutzer den aufgetretenen Syntaxfehler genau zu lokalisieren.
Fehler werden auf diese Weise frühzeitig erkannt.
Bei mehreren Regelalternativen dient die Funktion lachoice der Auswahl des zu-
gehörigen Parsers mithilfe der lookahead-Information. Hier wird zunächst getestet, ob
das nächste Eingabesymbol in den lookahead-Mengen vorkommt. In diesem Fall wird
mittels der Funktion findParser der dazugehörige Parser bestimmt und aufgerufen.
Anderenfalls wird eine entsprechende Fehlermeldung ausgegeben.
Abbildung 7 zeigt die Modifikation des RD-Parsers für arithmetische Ausdrücke unter
Berücksichtigung der auf Seite 37 bestimmten lookahead-Mengen der Grammatik G0AE .

3.3.6 Die Parser-Kombinator-Bibiothek Parsec


Parsec5 ist eine frei verfügbare Kombinatorbibliothek, die mit den gängigen Haskell-
Implementierungen GHC und Hugs ausgeliefert wird. Die Bibliothek stellt neben Ba-
5
siehe http://www.cs.uu.nl/~ daan/parsec.html

46
3.3 Top-Down Analyse mit rekursiven Prozeduren

expr, hexpr, term, hterm, factor :: Parser Char [Int]

-- E -> TH
expr = term &&& \lt ->
hexpr &&& \le ->
result (1:lt++le)

-- H -> +TH | eps


hexpr = ((tok "+") &&& \c ->
term &&& \lt ->
hexpr &&& \le ->
result (2:lt++le)) ||| result [3]

-- T -> FG
term = factor &&& \lf ->
hterm &&& \lt ->
result (4:lf++lt)

-- G -> *FG | eps


hterm = ((tok "*") &&& \c ->
factor &&& \lf ->
hterm &&& \lt ->
result (5:lf++lt)) ||| result [6]

-- F -> (E) | a
factor = ((tok "(") &&& \c ->
expr &&& \le ->
(tok ")") &&& \c ->
result (7:le)) ||| (tok "a" &&& \ c -> result [8])

parseRD :: String -> [Int]


parseRD s = case expr s of
((l,""):xs) -> l
_ -> error "syntax error in expression"

Abbildung 5: Ein Recursive-Descent Parser für arithmetische Ausdrücke

47
3. Syntaktische Analyse

-- check whether next input symbol belongs to la set


lacontrol :: Eq tok => [[tok]] -> [tok] -> [tok]
lacontrol la []
| elem [] la = []
| otherwise = error ("Syntax error: expected: "++ show la
++" found: eof")
lacontrol la inp@(c:cs)
| elem [c] la = inp
| otherwise = error ("Syntax error: expected: "++ show la
++" found: "++ show inp)

-- combine parsers using look ahead information


lachoice :: (Show a,Eq a) => [[[a]]] -> [Parser a b] -> Parser a b
lachoice las parsers inp
| (null inp) && (elem inp all_las) ||
not (null inp) && (elem [head inp] all_las)
= findParser las parsers inp
| otherwise = error ("Syntax error: expected: "++ show all_las
++" found: "++ show inp)
where all_las = concat las

findParser :: (Show a, Eq a)
=> [[[a]]] -> [Parser a b] -> Parser a b
findParser (la:las) (p:ps) []
| elem [] la = p []
| otherwise = findParser las ps []
findParser (la:las) (p:ps) inp@(c:cs)
| elem [c] la = p inp
| otherwise = findParser las ps inp
findParser [] ps inp = error "No Parser found - not possible!"

Abbildung 6: Funktionen zur Kombination von Parsern mit lookahead-Steuerung

48
3.3 Top-Down Analyse mit rekursiven Prozeduren

-- E -> TH, Look-ahead: ["(","a"]


expr2 :: Parser Char [Int]
expr2 = (term2 &&& \lt ->
hexpr2 &&& \le ->
result (1:lt++le))
.(lacontrol ["(","a"])

-- H -> +TH | eps, Look-ahead: ["+"] | [")",""]


hexpr2 = lachoice [["+"],[")",""]]
[(tok "+") &&& \c ->
term2 &&& \lt ->
hexpr2 &&& \le ->
result (2:lt++le),result [3]]

-- T -> FG, Look-ahead: ["(","a"]


term2 = (factor2 &&& \lf ->
hterm2 &&& \lt ->
result (4:lf++lt)).(lacontrol ["(","a"])

-- G -> *FG | eps, Look-ahead: ["*"] | ["+",")",""]


hterm2 = lachoice [["*"],["+",")",""]]
[ ((tok "*") &&& \c ->
factor2 &&& \lf ->
hterm2 &&& \lt ->
result (5:lf++lt)), result [6]]

-- F -> (E) | a, Look-ahead: ["("] | ["a"]


factor2 = lachoice [["("], ["a"]]
[((tok "(") &&& \c ->
expr2 &&& \le ->
(tok ")") &&& \c ->
result (7:le)), (tok "a" &&& \ c -> result [8])]

parseRDla :: String -> [Int]


parseRDla s
= case expr2 s of
((l,""):xs) -> l
((l,cs):xs) -> error ("Syntax error: expected: eof found:"
++show cs)
_ -> error "Syntax error: closing bracket missing"

Abbildung 7: Ein recursive-descent Parser für arithmetische Ausdrücke mit lookahead-


Kontrolle

49
3. Syntaktische Analyse

sisparsern und -kombinatoren Spezialbibliotheken für lexikalische Analyse, Ausdrucks-


parser und das Parsen von Permutationsphrasen bereit. Die Verwendung dieser Par-
serkombinatorbibliothek hat den Vorteil, dass die Parser direkt in Haskell spezifiziert
werden können, so dass das Typ- und Modulsystem sowie die Entwicklungsumgebung
von Haskell genutzt werden können. Die Bibliothek ist außerdem optimiert, so dass mit
geringem Aufwand mächtige und effiziente Parser entwickelt werden können.

3.4 Bottom-Up-Analyse
Die Bottom-Up-Analyse arbeitet umgekehrt zur Top-Down-Analyse: Statt aus dem
Startsymbol die Eingabe schrittweise herzuleiten, wird die Herleitung “rückwärts voll-
zogen”. Es erfolgt eine Bottom-Up-Berechnung des Ableitungsbaums in Form einer
gespiegelten Rechtsanalyse durch einen Kellerautomaten, der zwei Arten von Transi-
tionen durchführen kann und daher auch Shift-Reduce-Parser genannt wird:

Shift: Verschieben von Eingabesymbolen auf den Keller

Reduce: Umkehrung von Ableitungen zu Reduktionsschritten

Beispiel: Wir betrachten wieder die Grammatik GAE mit den von 1 bis 6 durchnum-
merierten Regeln:

E → E + T | T (1, 2)
T → T ∗ F | F (3, 4)
F → (E) | a (5, 6)

und verfolgen eine gespiegelte Rechtsanalyse des Ausdrucks (a) ∗ a in Abbildung 8. Die
Position des Lesekopfes auf dem Eingabeband wird durch % angezeigt, die Position
der Kellerspitze durch - . Das Beispiel zeigt, dass die Kellerspitze und die Position
des Eingabelesekopfes immer direkt benachbart sind und die Satzform in zwei Teile
zerlegen: ein bereits gelesenes Präfix, das auf dem Keller bereits reduziert wurde und
den noch nicht gelesenen Teil.
Die Rechtsanalyse endet, wenn auf dem Keller das Startsymbol reduziert werden konnte
und die Eingabe vollständig gelesen wurde. Die ausgegebene Nummernfolge entspricht
dann einer gespiegelten Rechtsanalyse, im Beispiel: 64254632. /

3.4.1 Der Bottom-Up-Analyseautomat


Definition 21 (Bottom-Up-Analyseautomat)
Der Bottop-Up-Analyse-Automat arbeitet mit drei Zustandskomponenten: einem Ein-
gabeband, einem Keller und einer Ausgabe. Gegeben seien die folgenden Alphabete:

Eingabealphabet: Σ
Kelleralphabet: N ∪Σ
Ausgabealphabet: [p]

50
3.4 Bottom-Up-Analyse

Shift
-% (a) ∗ a −→ (-% a) ∗ a
Shift
−→ (a-% ) ∗ a
Reduce
−→ (F-% ) ∗ a Ausgabe: 6
Reduce
−→ (T-% ) ∗ a 4
Reduce
−→ (E-% ) ∗ a 2
Shift
−→ (E)-% ∗ a
Reduce
−→ F-% ∗ a 5
Reduce
−→ T-% ∗ a 4
Shift
−→ T ∗-% a
Shift
−→ T ∗ a-%
Reduce
−→ T ∗ F-% 6
Reduce
−→ T-% 3
Reduce
−→ E-% 2

Abbildung 8: Gespiegelte Rechtsanalyse des Ausdrucks (a) ∗ a

Die Konfigurationsmenge wird definiert durch

(Σ ∪ N)∗ × Σ∗
|{z} × [p]∗
| {z } |{z}
Kellerspeicher Eingabeband Ausgabe
(Kellerspitze rechts!)

Der Automat beginnt mit der Startkonfiguration: (ε, w, ε). Es werden zwei Transitionen
unterschieden:

Shift-Schritt: (α, aw, z) ` (αa, w, z) für a ∈ Σ

Reduce-Schritt: (αβ, w, z) ` (αA, w, zi) falls πi = A → β ∈ P .

Im Shift-Schritt wird ein Zeichen von der Eingabe auf den Keller geschoben. Im Reduce-
Schritt wird eine rechte Regelseite auf dem Keller durch das Nonterminal der linken
Seite ersetzt. Ziel ist es, bei leerer Eingabe zum Startsymbol auf dem Keller reduziert
zu haben, also eine Endkonfiguration (S, ε, z) zu erreichen.

Satz 8 Korrektheit und Vollständigkeit des Bottom-Up-Analyseautomaten (ohne Be-


weis)
Der Bottom-Up-Analyseautomat berechnet eine gespiegelte Rechtsanalyse der Eingabe,
d.h. für w ∈ Σ∗ und z ∈ [p]∗ gilt


z
(ε, w, ε) `∗ (S, ε, z) gilt genau dann, wenn S ⇒r w.

51
3. Syntaktische Analyse

Beispiel: (Fortsetzung) Für die zuvor betrachtete Beispielanalyse des Ausdrucks (a)∗
a bzgl. der Grammatik GAE ergibt sich die folgende Konfigurationsfolge des Bottom-
Up-Analyseautomaten:

( ε , (a) ∗ a , ε )
Shift ` ( ( , a) ∗ a , ε )
Shift ` ( (a , )∗a , ε )
Reduce ` ( (F , )∗a , 6 )
Reduce ` ( (T , ) ∗ a , 64 )
Reduce ` ( (E , ) ∗ a , 642 )
Shift ` ( (E) , ∗a , 642 )
Reduce ` ( F , ∗a , 6425 )
Reduce ` ( T , ∗a , 64254 )
Shift ` ( T∗ , a , 64254 )
Shift ` ( T ∗a , ε , 64254 )
Reduce ` ( T ∗F , ε , 642546 )
Reduce ` ( T , ε , 6425463 )
Reduce ` ( E , ε , 64254632 )
/

Es gibt vier Quellen für Nichtdeterminismus in diesem Automaten:


1. Analyseende: Wie erkennt man das Ende der Analyse?
Falls nur noch das Startsymbol auf dem Keller liegt, könnten noch weitere Re-
duktionen möglich sein.

2. Shift-Reduce-Konflikt: Falls auf der Kellerspitze die rechte Seite einer Regel
liegt und noch Symbole auf der Eingabe sind, kann ein Reduktionsschritt oder
ein Shiftschritt durchgeführt werden.

3. Reduce-Reduce-Konflikte:

(a) linker Henkelrand: Eine auf der Kellerspitze befindliche rechte Regelseite
nennt man einen Henkel (engl. handle). Befindet sich auf dem Keller die
Satzform αβ und existieren in der Grammatik Regeln A → αβ und B → β,
so kann mit beiden Regeln reduziert werden.
(b) linke Regelseite: Tritt eine Satzform β als rechte Regelseite in verschie-
denen Regeln auf, A → β und B → β, so kann β zu A oder B reduziert
werden.

Das erste Problem lässt sich einfach lösen: wir “startseparieren” die Grammatiken
durch Hinzunahme einer zusätzlichen Startregel.
Definition 22 (Startseparierte Grammatik) G ∈ CF G(Σ) heißt startsepariert,
falls das Startsymbol S nur in einer Regel S → A mit A 6= S auftritt.

52
3.4 Bottom-Up-Analyse

Es ist offensichtlich, dass sich jede Grammatik G ∈ CF G(Σ) durch Hinzunahme eines
neuen Startsymbols S 0 und einer zusätzlichen Startregel S 0 → S in eine äquivalente
startseparierte Grammatik überführen lässt. Im folgenden sei generell vorausgesetzt,
dass die betrachteten Grammatiken startsepariert sind.
Für startseparierte Grammatiken ist das Analyseende eindeutig bestimmt. Die End-
konfiguration (S 0 , ε, z) kann wegen ε-Regeln zwar Folgekonfigurationen haben, aber
wegen der Startsepariertheit nicht erneut in eine Endkonfiguration übergehen.
Um den weiteren Nichtdeterminismus zu beseitigen, führen wir die Klasse der soge-
nannten LR(k)-Grammatiken ein, bei denen ein k-lookahead auf der Eingabe zur Ent-
scheidung bei Konfliktsituationen verwendet werden kann.

3.4.2 LR(k)-Grammatiken
Definition 23 (LR(k)-Grammatik) Sei G ∈ CF G(Σ), startsepariert durch S 0 → S
und k ≥ 0. G ist LR(k)-Grammatik (G ∈ LR(k)), falls für alle Rechtsableitungen der
Form ( ∗
⇒r αAw ⇒r αβw
S ∗ 0 0 0
mit first k (w) = first k (v)
⇒r α A w ⇒r αβv
gilt: A = A0 , α = α0 , w 0 = v

Das heißt (da rückwärts abgeleitet wird): Wenn auf dem Stack die Satzform αβ liegt
und der lookahead first k (w) ist, kann nur mit einer einzigen Regel reduziert werden,
nämlich A → β. Falls es sich nicht um eine LR(k)-Grammatik handelt, könnten dage-
gen z.B. die Regeln A → β und A0 → xβ existieren und α = α0 x sein. Die Bezeichnung
LR(k) ist motiviert durch Left-to-right-Rechtsanalyse mit k-lookahead“.

Im Fall einer LR(k)-Grammatik kann der Analyseautomat also unter Betrachtung eines
k-lookaheads deterministisch entscheiden, mit welcher Produktion reduziert werden
muss. Dies ist speziell im Fall k = 0 interessant, wo allein aufgrund des Kellerinhalts
entschieden werden kann.

3.4.3 LR(0)-Analyse
Für eine LR(0)-Analyse werden aus dem Kellerinhalt αβ Informationen abstrahiert,
die für die Entscheidung der nächsten Transition des Bottom-Up-Analyseautomaten
ausreicht. Dies ist vergleichbar zur Abstraktion von Rechtskontexten durch follow-
Mengen.

Definition 24 (LR(0)-Auskünfte, LR(0)-Informationen)



Sei G ∈ CF G(Σ) startsepariert mit S 0 → S und S 0 ⇒r αAw ⇒r αβ1 β2 w .
|{z} |{z}
Keller Eingabe
Dann heißt [A → β1 · β2 ] eine LR(0)-Auskunft für αβ1 .
Die Menge aller LR(0)-Auskünfte für γ ∈ (Σ ∪ N)∗ heißt LR(0)-Menge von γ oder
auch LR(0)-Information von γ, in Zeichen: LR(0)(γ).

Üblicherweise teilt man den Keller dabei in zwei Teile αβ, wobei der Henkel“ β der Teil

ist, welcher reduziert werden kann. Hier ist weiter die Anmerkung angebracht, dass es

53
3. Syntaktische Analyse

sich nicht um einen Keller im eigentlichen Sinn handelt, weil mehrere Elemente gelesen
und auf einmal ersetzt werden. Allerdings ist auch dieses Verhalten mit einem Keller-
automaten im engeren Sinn durch eine Erweiterung der Menge der Kontrollzustände
simulierbar.
Aus der obigen Definition folgen unmittelbar die folgenden Aussagen:
1. LR(0)(γ) ist endlich für alle γ ∈ (N ∪ Σ)∗ .
n o
2. LR(0)(G) := LR(0)(γ) | γ ∈ (N ∪ Σ)∗ ist endlich.

3. [A → β1 ·] ∈ LR(0)(γ) signalisiert eine Reduktionsmöglichkeit:


(αβ1 , w, z) ` (αA, w, zi) für πi = A → β1 und γ = αβ1 .

4. [A → β1 · β2 ] ∈ LR(0)(γ) mit β2 6= ε signalisiert eine Shift-Möglichkeit, da der


Henkel auf dem Stack noch unvollständig ist.
5. G ∈ LR(0) gilt genau dann, wenn die LR(0)-Mengen keine widersprüchlichen
Auskünfte zu Shift- und Reduce-Möglichkeiten enthalten.

Satz 9 (Berechnung der LR(0)-Mengen) Sei G ∈ LR(0) reduziert und startsepa-


riert mit Startregel S 0 → S. Dann gilt:
1. LR(0)(ε) ist die kleinste Teilmenge von LR(0)(G), die
(a) [S 0 → ·S] enthält und
(b) mit [A → ·Bδ] und B → β ∈ P auch [B → ·β] enthält (Abschlussbildung).
2. LR(0)(αX) mit X ∈ Σ∪N, α ∈ (Σ∪N)∗ ist die kleinste Teilmenge von LR(0)(G),
die
(a) [A → β1 X · β2 ] enthält, falls [A → β1 · Xβ2 ] ∈ LR(0)(α) ist und
(b) mit [A → γ · Bδ] und B → β ∈ P auch [B → ·β] enthält (Abschlussbildung).

Beispiel: Wir betrachten erneut die (nun startseparierte) Grammatik für arithmeti-
sche Ausdrücke:
(s)
GAE : E0 → E (0)
E → E + T | T (1, 2)
T → T ∗ F | F (3, 4)
F → (E) | a (5, 6)
Die Ermittlung der LR(0)-Mengen beginnt mit LR(0)(ε):
I0 := LR(0)( ε ) = {[E 0 → ·E]} ∪ Abschluss(E), wobei

Abschluss(E) = {[E → ·E + T ], [E → ·T ]} ∪ Abschluss(T )


Abschluss(T ) = {[T → ·T ∗ F ], [T → ·F ]} ∪ Abschluss(F )
Abschluss(F ) = {[F → ·(E)], [F → ·a]}.
Alle Auskünfte implizieren einen Shift.

54
3.4 Bottom-Up-Analyse

Ausgehend von den Symbolen rechts vom Punkt in LR(0)(ε) werden nun alle Mengen
für Satzformen der Länge 1 gebildet. Weil wir von LR(0)(ε) ausgehen, betrachten wir
nur die notwendigen Teile.
I1 := LR(0)( E) = {[E 0 → E·], [E → E · +T ]}
I2 := LR(0)( T ) = {[E → T ·], [T → T · ∗F ]}
I3 := LR(0)( F ) = {[T → F ·]}
I4 := LR(0)( () = {[F → (·E)]} ∪ Abschluss(E)
I5 := LR(0)( a) = {[F → a·]}
Hier bestehen Shift-Reduce-Konflikte (in den Mengen für E und T), die Grammatik
ist also keine LR(0)-Grammatik. Wir werden später sehen, dass sich die Konflikte mit
einem lookahead von 1 beseitigen lassen.
Nun werden die nötigen Mengen für Satzformen der Länge 2 bestimmt. Wiederum
werden alle Satzformen betrachtet, die in der vorigen Stufe einen Shift implizieren.
I6 := LR(0)( E + ) = {[E → E + ·T ]} ∪ Abschluss(T )
I7 := LR(0)( T ∗ ) = {[T → T ∗ ·F ]} ∪ Abschluss(F )
I8 := LR(0)( (E ) = {[F → (E·)], [E → E · +T ]}
LR(0)( (T ) = {[E → T ·], [T → T · ∗F ]} = I2
LR(0)( (F ) = {[T → F ·]} = I3
LR(0)( (( ) = {[F → (·E)]} ∪ Abschluss(E) = I4
LR(0)( (a ) = {[F → a·]} = I5
I9 := LR(0)( E + T ) = {[E → E + T ·], [T → T · ∗F ]}
LR(0)( E + F ) = {[T → F ·]} = I3
LR(0)( E + ( ) = {[F → (·E)]} ∪ Abschluss(E) = I4
LR(0)( E +a ) = {[F → a·]} = I5
I10 = LR(0)( T ∗F ) = {[T → T ∗ F ·]}
LR(0)( T ∗( ) = {[F → (·E)]} ∪ Abschluss(E) = I4
LR(0)( T ∗a ) = {[F → a·]} = I5
I11 = LR(0)( (E) ) = {[F → (E)·]}
LR(0)( (E + ) = I6
LR(0)( E +T ∗ ) = I7
Dies sind alle Informationsmengen, die sich für dieses Beispiel ergeben. Wie bereits
(s)
gesagt, ist GAE nicht LR(0), da sich Shift/Reduce-Konflikte in I1 , I2 und I9 zeigen. /
Mit den LR(0)-Mengen haben wir also die Möglichkeit geschaffen, im Falle einer LR(0)-
Grammatik eindeutige Entscheidungen über Shift- und Reduce-Operationen im Ana-
lyseautomaten aus dem Kellerinhalt abzuleiten. LR(0)(γ) liefert die Shift-/Reduce-
Entscheidung für den Bottom-Up-Analyseautomaten mit Kellerinschrift γ. Daher wird
im folgenden statt γ die LR(0)-Menge von γ auf dem Keller gespeichert. Als neues Kel-
leralphabet ergibt sich somit LR(0)(G). Wesentlich dabei ist, dass LR(0)(γX) durch
LR(0)(γ) und X bestimmt, aber unabhängig von γ ist. Daher können wir die folgende
Übergangsfunktion goto definieren.

55
3. Syntaktische Analyse

Definition 25 (goto-Funktion) Die goto-Funktion

goto : LR(0)(G) × (Σ ∪ N) −→ LR(0)(G)

wird festgelegt durch: goto(I, X) := I 0 genau dann, wenn ein γ ∈ (Σ ∪ N)∗ existiert
mit I = LR(0)(γ) und I 0 = LR(0)(γX).

Diese Definition wäre ohne die Klassenbildung nicht eindeutig, würde also keine Funk-
tion beschreiben. Durch die Zusammenfassung von Auskünften zu Mengen ist aber
gewährleistet, dass bei der Existenz zweier solcher γ die resultierende Menge die glei-
che ist:
Seien γ, γ 0 ∈ (Σ ∪ N)∗ mit γ 6= γ 0 ∧ LR(0)(γ) = LR(0)(γ 0 ). Betrachte [A → α · β] ∈
LR(0)(γX). Dann folgt:

• Fall 1: α = α0 X (per Def.) oder

• Fall 2: α = ε ∧ ∃[B → α0 · Aδ] ∈ LR(0)(γX)



In Fall 1 [A → α0 · Xβ] ∈ LR(0)(γ) = LR(0)(γ 0 ) folgt [A → α · β] ∈ LR(0)(γ 0 X)
In Fall 2 [B → α0 · Aδ] ∈ LR(0)(γX) setzt man die Überlegung fort, bis Fall 1 eintritt.

(s)
Beispiel: (Fortsetzung) Für die Grammatik GAE erhält man die folgende goto-Funk-
tion:

goto I0 I1 I2 I3 I4 I5 I6 I7 I8 I9 I10 I11


E I1 I8
T I2 I2 I9
F I3 I3 I3 I10
( I4 I4 I4 I4
a I5 I5 I5 I5
+ I6 I6
∗ I7 I7
) I11

Die freien Felder stehen für I12 := ∅.


Die goto-Funktion beschreibt einen DFA, der mit Startzustand I0 und allen Zuständen
(s)
bis auf I12 als Endzuständen bestimmte Präfixe von in GAE ableitbaren Satzformen
erkennt. /

Es besteht eine enge Analogie zwischen der Potenzmengenkonstruktion, mittels


der zu einem NFA ein äquivalenter DFA gebildet wird, und der Bestimmung der LR(0)-
Mengen sowie der goto-Funktion. Wir definieren zur Verdeutlichung einen ε−NFA, in
dem der Nichtdeterminismus nur in ε-Transitionen besteht. Sei G ∈ CF G(Σ) startse-
pariert mit S 0 → S. Dann wird A(G) ∈ NF Aε (Σ ∪ N) wie folgt definiert:

56
3.4 Bottom-Up-Analyse

Zustandsmenge: Q := {[A → α · β] | A → αβ ∈ P }
Eingabealphabet: X := Σ∪N
Startzustand: q0 := [S 0 → ·S]
Endzustände: F := Q (ohne Bedeutung)
Transitionen: δ :: Q × X −→ IP (Q)
mit [A → αX · β] ∈ δ([A → α · Xβ], X)
[B → ·β] ∈ δ([A → α · Bβ], ε) falls B → β ∈ P

Mit der zweiten Bedingung an δ wird der Abschluss vollzogen. Die ε-Hülle eines Zu-
stands (LR(0)-Auskunft) ist genau die LR(0)-Menge, welche ihn enthält. Die andere
Bedingung entspricht der Shift-Operation und korrespondiert ebenfalls mit der Defini-
tion der LR(0)-Menge.
Die Potenzmengenkonstruktion liefert zu diesem Automaten einen deterministischen
[ = (Q̂, X, δ̂, qˆ0 , Q̂ \ {∅}) mit
endlichen Automaten A(G)

Q̂ = LR(0)(G) und δ̂ = goto.

3.4.4 Der LR(0)-Analyseautomat


Im folgenden wird ein deterministischer Bottom-Up-Analyseautomat für eine Gramma-
tik G ∈ LR(0) konstruiert. Als Hilfsmittel werden die Menge der LR(0)-Informationen
zu G und die goto-Funktion verwendet. Die sogenannte action-Funktion von G gibt
die Shift-/Reduce-Entscheidung an. Falls G 6∈ LR(0) ist, liefert die action-Funktion
widersprüchliche Informationen (Konflikte).

Definition 26 (action-Funktion)


 LR(0)(G) −→ {shift, error , accept} ∪ {red i | i ∈ [p]}
 


 
 red i falls πi = A → α ∧ [A → α·] ∈ I


act :: shift falls ∃X ∈ (Σ ∪ N) : [A → α · Xβ] ∈ I
 I 7−→



 
 accept falls [S 0 → S·] ∈ I
 

error sonst d.h. falls I = ∅

Diese Funktion ist nur eindeutig, wenn G ∈ LR(0).

Die Funktionen goto und act bilden die LR(0)-Analysetabelle von G, die den LR(0)-
Analyseautomaten bestimmt.

Definition 27 (LR(0)-Analyseautomat) Gegeben seien die folgenden Alphabete:

Eingabealphabet : Σ
Kelleralphabet : Γ := LR(0)(G)
Ausgabealphabet : ∆ := [p] ∪ {error}

57
3. Syntaktische Analyse

Der LR(0)-Analyseautomat arbeitet über der Konfigurationsmenge

Γ∗
|{z} Σ∗ × |{z}
× |{z} ∆∗ .
Keller (Spitze rechts!) Eingabe Ausgabe

Die Startkonfiguration hat die Gestalt: (I0 , w, ε) mit I0 = LR(0)(ε).


Die folgenden Transitionen sind möglich:

Shift: (αI, aw, z) ` (αII 0, w, z) falls act(I) = shift und goto(I, a) = I 0 .

Reduce: Wir verwenden die Notation: wa1 . . . an |n := w, d.h. w̃|n bedeutet, dass
die letzten n Zeichen von w̃ entfernt werden.
˜ 0 , w, zi)
(αI, w, z) ` (α̃II
˜ goto(I,
falls act(I) = red i, πi = A → X1 . . . Xn , αI|n = α̃I, ˜ A) = I 0 .

Accept: (I0 I, ε, z) ` (ε, ε, z) falls act(I) = accept.


Error: (αI, w, z) ` (ε, ε, z · error) sonst.
Hier ist eine genaue Fehlerpositionierung möglich.

Satz 10 Wenn LR(0)(G) konfliktfrei, also act eindeutig ist, so arbeitet der LR(0)-
Analyseautomat deterministisch und es gilt für w ∈ Σ∗ und z ∈ [p]∗ :


z
• (I0 , w, ε) `∗ (ε, ε, z) genau dann, wenn S ⇒r w (←

z ist Rechtsanalyse von w)
• (I0 , w, ε) `∗ (ε, ε, z · error) genau dann, wenn w 6∈ L(G).

3.4.5 SLR(1)-Analyse
In der Praxis findet man oft Grammatiken mit widersprüchlichen LR(0)-Mengen. In
diesem Fall möchte man mit möglichst geringem Aufwand trotzdem eine determini-
stische Analyse ermöglichen, also den lookahead wenn möglich nicht komplett in die
Analyse einbauen. Dazu existiert der Ansatz der sogenannten SLR(1)-Analyse (Sim-
ple LR(1)), wo der lookahead benutzt wird, um Konflikte in den LR(0)-Informationen
durch das nächste Eingabesymbol zu beseitigen.
Der SLR(1)-Analyse liegen die folgenden Beobachtungen zugrunde:

1. [A → β1 · aβ2 ] ∈ LR(0)(αβ1 ) =⇒ S ⇒r αAw ⇒r αβ1 a
|{z} β2 w
|{z}
Keller Eingabesymbol
Der Shift sollte nur stattfinden, falls die restliche Eingabe mit dem Zeichen a
beginnt.

aw =⇒ a ∈ follow 1 (A)
2. [A → β·] ∈ LR(0)(αβ) =⇒ S ⇒r αAaw ⇒r αβ |{z}
|{z}
Keller Eingabe
Der Reduce-Schritt mit A → β sollte nur bei a ∈ follow 1 (A) stattfinden.
(s)
Beispiel: Im Beispiel der Grammatik für arithmetische Ausdrücke GAE lassen sich
(s)
alle Konflikte auf diese Weise auflösen, d.h. GAE ∈ SLR(1):
Die Shift-Reduce-Konflikte in den Mengen I1 und I2 lassen sich wie folgt beseitigen:

58
3.4 Bottom-Up-Analyse

I1 = LR(0)( E ) = { [E 0 → E·] , [E → E · +T ] }
| {z } | {z }
accept shif t
bei Eingabeende bei Eingabe von +
I2 = LR(0)( T ) = { [E → T ·] , [T → T · ∗F ] }
| {z } | {z }
red 2 shif t
bei Eingabe von +, ), ε bei Eingabe von ∗
I9 = LR(0)( E + T ) = { [E → E + T ·] , [T → T · ∗F ] }
| {z } | {z }
red 1 shif t
bei Eingabe von +, ), ε bei Eingabe von ∗

Die Berücksichtigung des 1-lookaheads auf der Eingabe ermöglicht eine genauere Kon-
trolle der Aktionen und eine frühe Fehlererkennung. /

Diese Überlegungen führen zu einer neuen action-Funktion.

Definition 28 (SLR(1)-action-Funktion, SLR(1)-Grammatik)




 LR(0)(G) × (Σ ∪ {ε}) −→ {shift, error , accept} ∪ {red i | i ∈ [p]}

 


 
 red i falls πi = A → α, [A → α·] ∈ I,

 


 a ∈ follow 1 (A)
act ::

 (I, a) 7−→ shift falls [A → α · aβ] ∈ I, a 6= ε

 






 accept falls [S 0 → S·] ∈ I

 
error sonst d.h. falls I = ∅

Eine Grammatik G, für die act(I, a) stets eindeutig ist, heißt SLR(1)-Grammatik, in
Zeichen: G ∈ SLR(1).

Beispiel: (Fortsetzung)
(s)
GAE : E 0 → E (0)
E → E + T | T (1, 2)
T → T ∗ F | F (3, 4)
F → (E) | a (5, 6)

Es gilt:

follow 1 (E 0 ) = {ε}, follow 1 (E) = {ε, +, )}, follow 1 (T ) = follow 1 (F ) = {ε, +, ), ∗}.

Mit den zuvor berechneten LR(0)-Informationen (siehe Seite 54) ergibt sich die folgende
(s)
SLR(1)-Analysetabelle zu GAE :

59
3. Syntaktische Analyse

action goto
+ ∗ ( ) a ε + ∗ ( ) a E T F
I0 shift shift I4 I5 I1 I2 I3
I1 shift accept I6
I2 red 2 shift red 2 red 2 I7
I3 red 4 red 4 red 4 red 4
I4 shift shift I4 I5 I8 I2 I3
I5 red 6 red 6 red 6 red 6
I6 shift shift I4 I5 I9 I3
I7 shift shift I4 I5 I10
I8 shift shift I6 I11
I9 red 1 shift red 1 red 1 I7
I10 red 3 red 3 red 3 red 3
I11 red 5 red 5 red 5 red 5

Die Eingabe a + a führt zu der folgenden Konfigurationsfolge des LR(0)-Analyseauto-


maten. Die jeweils ausgelöste Aktion ist in Klammern angegeben.

( I0 , a+a , ε )
` (shift) ( I0 I5 , +a , ε )
` (red 6 ) ( I0 I3 , +a , 6 )
` (red 4 ) ( I0 I2 , +a , 64 )
` (red 2 ) ( I0 I1 , +a , 642 )
` (shift) ( I0 I1 I6 , a , 642 )
` (shift) ( I0 I1 I6 I5 , ε , 642 )
` (red 6 ) ( I0 I1 I6 I3 , ε , 6426 )
` (red 4 ) ( I0 I1 I6 I9 , ε , 64264 )
` (red 1 ) ( I0 I1 , ε , 642641 )
` (accept) ( ε , ε , 642641 )
Der Automat akzeptiert die Eingabe und produziert die gespiegelte Rechtsanalyse
642641 als Ausgabe. /
Beispiel: Ein anderes Beispiel zeigt, dass SLR(1) nicht alle Probleme löst: Die fol-
gende Grammatik GLR ist eindeutig, aber GLR 6∈ SLR(1).

GLR : S 0 → S (0)
S → L = R | R (1, 2)
L → ∗R | id (3, 4)
R → L (5)

Berechnet man die LR(0)-Mengen, so stellt man fest:

60
3.4 Bottom-Up-Analyse

I0 := LR(0)(ε) = { [S 0 → ·S], [S → ·L = R], [S → ·R],


[L → · ∗ R], [L → ·id], [R → ·L]}
I1 := LR(0)(S) = {[S 0 → S·]}
I2 := LR(0)(L) = {[S → L· = R], [R → L·]}

Hier zeigt sich ein Shift-Reduce-Konflikt in I2 , GLR ist also nicht LR(0). Wegen =∈
follow 1 (L) ⊆ follow 1 (R) (weil R → L) ist dieser Konflikt nicht SLR(1)-lösbar. Durch
die Überschneidung der follow-Menge follow 1 (R) mit dem Rechtskontext von L kann
der Konflikt in diesem Fall nicht aufgelöst werden. In der Ableitung wird das “=” aber
nur nach vorhergehendem “∗” rechts von R auftreten, was im SLR(1)-Ansatz nicht
beachtet wird. /

3.4.6 LR(1)- und LALR(1)-Analyse


Bei der SLR(1)-Analyse werden potentielle Rechtskontexte in den follow-Mengen ver-
einigt. Eine genauere Verwaltung möglicher Folgezeichen (lookaheads) zu den Regeln
ermöglicht eine differenziertere Analyse. Bei der LR(1)-Analyse werden die Rechtskon-
texte zu den jeweiligen LR(0)-Auskünften getrennt betrachtet.

Definition 29 (LR(1)-Auskunft) Sei G ∈ CF G(Σ). [A → α·β, a] ∈ LR(1)(γ) heißt



LR(1)-Auskunft von γ, falls eine Rechtsableitung S 0 ⇒r δAw ⇒r δαβw existiert mit
γ = δα und {a} = first 1 (w), a ∈ Σ ∪ {ε}.

Das lookahead-Zeichen hat nur in Auskünften mit β = ε eine Bedeutung. Es verlangt


die Reduktion, falls das nächste Eingabezeichen dem lookahead-Zeichen entspricht.
Die LR(1)-Mengen werden ebenso bestimmt wie die LR(0)-Mengen, wobei die Rechts-
kontexte an der Stelle hinzukommen, wo der Abschluss gebildet wird. Bei der Ab-
schlussbildung von [A → α1 · Bα2 , a] ∈ LR(1)(γ) wird für jedes b ∈ f irst1 (α2 a) die
LR(1)-Auskunft [B → ·β, b] zur LR(1)-Menge hinzugenommen.

Beispiel: (Fortsetzung)
Zu der Grammatik GLR ergeben sich die folgenden LR(1)-Informationen:

• I0 := LR(1)(ε) = { [S 0 → ·S, ε], [S → ·L = R, ε], [S → ·R, ε],


[L → · ∗ R, = /ε], [L → ·id, = /ε], [R → ·L, ε]}
• I1 := LR(1)(S) = {[S 0 → S·, ε]}
• I2 := LR(1)(L) = {[S → L· = R, ε], [R → L·, ε]}
• . . . (siehe Abbildung 9)

Der Rechtskontext gibt an, unter welchem lookahead eine Reduktion durchzuführen
ist. In der LR(1)-Information LR(1)(L) wird klar, dass die Reduktion bei lookahead
ε durchzuführen ist, während der lookahead “=” einen Shift impliziert. Der Konflikt,
der sich bei der SLR(1)-Analyse ergab, wird also durch die genauere LR(1)-Analyse
aufgelöst. /

61
3. Syntaktische Analyse

Fasst man diejenigen LR(1)-Mengen mit identischem LR(0)-Anteil aber unterschiedli-


chen lookahead-Zeichen zusammen, so erhält man die Mengen für die sog. LALR(1)-
Analyse (Look-Ahead-LR(1)-Analyse). Die Anzahl der LALR(1)-Mengen ist genau
die der LR(0)-Mengen. Abbildung 9 stellt für die obige Beispiel-Grammatik GLR die
LR(0)-Mengen den LALR(1)-Mengen gegenüber.
Dies führt zu der folgenden LALR(1)-Parsertabelle für GLR :

action goto
Zustand = ∗ id ε = ∗ id S L R
I0 shift shift I4 I5 I1 I2 I3
I1 accept
I2 shift red 5 I6
I3 red 2
I4 shift shift I4 I5 I8 I7
I5 red 4 red 4
I6 shift shift I4 I5 I8 I9
I7 red 3 red 3
I8 red 5 red 5
I9 red 1

Der Übergang von LR(1)- zu LALR(1)-Grammatiken führt i.a. zu einer großen Reduk-
tion der Zustandszahl. Bei höheren Programmiersprachen wie etwa Algol erreicht man
beispielsweise eine Reduktion von 10000 auf 100 Zustände. Es gibt aber auch LR(1)-
Grammatiken, die nicht LALR(1) sind, d.h. bei denen beim Übergang zu LALR(1)-
Informationen reduce-reduce-Konflikte auftreten können.

Beispiel: Wir betrachten die Grammatik


G : S0 → S S → aAd | bBd | aBe | bAe
A → c B → c
Wie man leicht sieht, gilt: L(G) = {acd, bcd, ace, bce}. G ist LR(1), weil die LR(1)-
Informationen (siehe Abbildung 10) konfliktfrei sind.
Bei der Vereinigung der Informationen I6 und I9 ergibt sich ein reduce-reduce-Konflikt.
G ist also nicht LALR(1). /
Allgemein gelten die folgenden Beziehungen zwischen LL(k)- und LR(k)-Grammatiken
und -Sprachen:
Grammatiken: LL(0) $ LL(1) $ LR(1)
LR(0) $ SLR(1) $ LALR(1) $ LR(1)
Sprachen: L(LL(0)) $ Reg $ L(LL(1)) $ L(SLR(1)) = DetCFLs
L(LR(0)) $ L(SLR(1)) = L(LALR(1)) = L(LR(1)) = DetCFLs
$ eindCFLs $ CFLs
L(LR(0)) und L(LL(1)) sind nicht disjunkt und nicht vergleichbar.

62
3.4 Bottom-Up-Analyse

GLR : S 0 → S (0)
S → L = R | R (1, 2)
L → ∗R | id (3, 4)
R → L (5)

LR(0)-Mengen: LALR(1)-Mengen:

I0 : [S 0 → ·S] I0 : [S 0 → ·S, ε]
[S → ·L = R] [S → ·L = R, ε]
[S → ·R] [S → ·R, ε]
[L → · ∗ R] [L → · ∗ R, = /ε]
[L → ·id] [L → ·id, = /ε]
[R → ·L] [R → ·L, ε]

I1 : [S 0 → S·] I1 : [S 0 → S·, ε]

I2 : [S → L· = R] I2 : [S → L· = R, ε]
[R → L·] [R → L·, ε]

I3 : [S → R·] I3 : [S → R·, ε]

I4 : [L → ∗ · R] I4 : [L → ∗ · R, = /ε]
[R → ·L] [R → ·L, = /ε]
[L → · ∗ R] [L → · ∗ R, = /ε]
[L → ·id] [L → ·id, = /ε]

I5 : [L → id·] I5 : [L → id·, = /ε]

I6 : [S → L = ·R] I6 : [S → L = ·R, ε]
[R → ·L] [R → ·L, ε]
[L → · ∗ R] [L → · ∗ R, ε]
[L → ·id] [L → ·id, ε]

I7 : [L → ∗R·] I7 : [L → ∗R·, = /ε]

I8 : [R → L·] I8 : [R → L·, = /ε]

I9 : [S → L = R·] I9 : [S → L = R·, ε]

Abbildung 9: LR(0)- und LALR(1)-Mengen zu Beispielgrammatik GLR

63
3. Syntaktische Analyse

G : S0 → S
S → aAd | bBd | aBe | bAe
A → c
B → c

LR(1)-Mengen:

I0 : [S 0 → ·S, ε]
I6 : [A → c·, d]
[S → ·aAd, ε]
[B → c·, e]
[S → ·bBd, ε]
[S → ·aBe, ε]
I7 : [S → bB · d, ε]
[S → ·bAe, ε]

I8 : [S → bA · e, ε]
I1 : [S 0 → S·, ε]

I2 : [S → a · Ad, ε] I9 : [A → c·, e]
[S → a · Be, ε] [B → c·, d]
[A → ·c, d]
[B → ·c, e]
I10 : [S → aAd·, ε]

I3 : [S → b · Bd, ε]
I11 : [S → aBe·, ε]
[S → b · Ae, ε]
[A → ·c, e]
I12 : [S → bBd·, ε]
[B → ·c, d]
I13 : [S → bAe·, ε]
I4 : [S → aA · d, ε]
I14 : ∅
I5 : [S → aB · e, ε]

Abbildung 10: LR(1)-Informationen zu LR(1)-Grammatik, die nicht LALR(1) ist

64
3.4 Bottom-Up-Analyse

3.4.7 Mehrdeutige Grammatiken


Obwohl mehrdeutige Grammatiken auf keinen Fall durch lookaheadS eindeutig analy-
siert werden können — G ∈ CF G mehrdeutig ⇒ G 6∈ LR = k≥0 LR(k) —, werden
sie oft für Programmiersprachen benutzt, vor allem um aufwändige Klammerungen zu
vermeiden. Pragmatische Lösungsansätze im Parser liefern eine deterministische und
somit “hinreichend eindeutige” Analyse. So wird etwa für Operationssymbole oder an-
dere syntaktische Konstrukte eine Präzedenz und Assoziativität festgelegt, durch die
Analysekonflikte aufgelöst werden können.

Beispiel: Arithmetische Ausdrücke mit Präzedenzregeln im Parser:

Gm
AE : E → E + E | E ∗ E | (E) | a

Diese Grammatik hat eine sehr einfache Form. Wegen ihrer Mehrdeutigkeit ist sie keine
LR(k)-Grammatik. Die Präzedenzregel “∗ vor +” macht die Analyse von a ∗ a + a
eindeutig. Statt diese Regel aber in der verwendeten Grammatik zu verankern, kann
sie auch durch den Parser realisiert werden. Dies wird z.B. mit den Präzedenzstufen in
C so gemacht. Bei der Analyse von a + a + a wird durch die Assoziativität der Addition
festgelegt, wie zu reduzieren ist.
Die LR(0)-Informationen zu der obigen Grammatik haben die in Abbildung 11 ange-
gebene Gestalt. Die Informationen I1 , I7 und I8 weisen shift-reduce-Konflikte auf. Der
Konflikt in I1 ist SLR(1)-lösbar. Die Konflikte in I7 und I8 sind auf die Mehrdeutigkeit
zurückzuführen und daher nicht auflösbar. Beachten Sie, dass {∗, +} ⊆ follow (E).
Wie man in der Analysetabelle bzw. der action-Funktion in Abbildung 11 sieht, wurden
die Konflikte in I7 und I8 durch die Präzedenz ∗ m + — act(I7 , ∗) = shift, act(I8 , +) =
red 2 — bzw. durch die Festlegung von Linksassoziativität (Linksklammerung) —
act(I7 , +) = red 1, act(I8 , ∗) = red 2 aufgelöst.
Damit durchläuft der Analyseautomat für die Eingabe a + a ∗ a die folgende Konfigu-
rationsfolge
( I0 , a+a∗a , ε )
( I0 I3 , +a ∗ a , ε )
( I0 I1 , +a ∗ a , 4 )
( I0 I1 I4 , a∗a , 4 )
( I0 I1 I4 I3 , ∗a , 4 )
( I0 I1 I4 I7 , ∗a , 4 )
( I0 I1 I4 I7 I5 , a , 4 )
( I0 I1 I4 I7 I5 I3 , ε , 4 )
( I0 I1 I4 I7 I5 I8 , ε , 44 )
( I0 I1 I4 I7 , ε , 442 )
( I0 I1 , ε , 4421 )
( ε , ε , 4421 )

65
3. Syntaktische Analyse

Gm
AE : E → E + E | E ∗ E | (E) | a

I0 : [E 0 → ·E] I5 : [E → E ∗ ·E]
[E → ·E + E] [E → ·E + E]
[E → ·E ∗ E] [E → ·E ∗ E]
[E → ·(E)] [E → ·(E)]
[E → ·a] [E → ·a]

I1 : [E 0 → E·] I6 : [E → (E·)]
[E → E · +E] [E → E · +E]
[E → E · ∗E] [E → E · ∗E]

I2 : [E → (·E)] I7 : [E → E + E·]
[E → ·E + E] [E → E · +E]
[E → ·E ∗ E] [E → E · ∗E]
[E → ·(E)]
[E → ·a] I8 : [E → E ∗ E·]
[E → E · +E]
I3 : [E → a·] [E → E · ∗E]

I4 : [E → E + ·E] I9 : [E → (E)·]
[E → ·E + E]
[E → ·E ∗ E]
[E → ·(E)]
[E → ·a]

action goto
a + ∗ ( ) ε a + ∗ ( ) E
I0 shift shift I3 I2 I1
I1 shift shift accept I4 I5
I2 shift shift I3 I2 I6
I3 red 4 red 4 red 4 red 4
I4 shift shift I3 I2 I7
I5 shift shift I3 I2 I8
I6 shift shift shift I4 I5 I9
I7 red 1 shift red 1 red 1 I5
I8 red 2 red 2 red 2 red 2
I9 red 3 red 3 red 3 red 3

Abbildung 11: LR(0)-Informationen und Analysetabelle zu mehrdeutiger Grammatik

66
3.4 Bottom-Up-Analyse

Gif : S → if then S | if then S else S | a

I0 : [S 0 → ·S] I3 : [S → a·]
[S → ·if then S]
[S → ·if then S else S] I4 : [S → if then S·]
[S → ·a] [S → if then S · else S]

I1 : [S 0 → S·] I5 : [S → if then S else · S]


[S → ·if then S]
I2 : [S → if then · S] [S → ·if then S else S]
[S → if then · S else S] [S → ·a]
[S → ·if then S]
[S → ·if then S else S] I6 : [S → if then S else S·]
[S → ·a]

action goto
if then else a ε if then else a S
I0 shift shift I2 I3 I1
I1 accept
I2 shift shift I2 I3 I4
I3 red 3 red 3
I4 shift(!) red 2 I5
I5 shift shift I2 I3 I6
I6 red 1 red 1

Abbildung 12: LR(0)-Informationen und Analysetabelle zu if then else-Grammatik

Beispiel: Ein weiteres klassisches Beispiel ist die Paarung von if und else bei Schach-
telung: Gif : S → if then S | if then S else S | a

Die LR(0)-Informationen zu der obigen Grammatik S


haben die in Abbildung 12 angegebene Gestalt. Bei  \
 \
Erkennung von if then if then a else a kann das  \
if then S /
else nicht eindeutig einem if zugeordnet werden.  C
Pragmatische Lösung: wenn lookahead =else ist, 
 C
C
wird nie reduziert. Dies paart das else mit dem letz- if then S else S
ten if der Kette.

67
3. Syntaktische Analyse

3.4.8 Der Parsergenerator Happy


Happy ist ein frei verfügbarer Parsergenerator für Haskell, der ähnlich zu dem yacc-
Parsergenerator (yacc = yet another compiler compiler) für C funktioniert. Wie bei
yacc wird zu einer annotierten BNF-Spezifikation einer kontextfreien Grammatik ein
Modul erzeugt, das einen Parser für die Grammatik definiert.

grammar.y −→ happy −→ grammar.hs


↓ ’-i’
annotierte BNF-Spez.
grammar.info Haskell-Parser-Modul
der Grammatik
mit Aktions- und
goto-Tabelle des
shift-reduce-Parsers
Unten ist eine Spezifikation der bereits bekannten Grammatik für arithmetische Aus-
drücke in Happy-Syntax angegeben. In der linken Spalte sind der Funktionsname des
zu erzeugenden Parsers, die Token und die Grammatik spezifiziert. In der rechten
Spalte schließen sich Haskell Deklarationen einer Fehlerfunktion sowie des abstrakten
Syntaxbaums an, der durch den Happy-Parser erzeugt wird.
{ {
module Main where happyError :: [Token] -> a
} happyError _ = error "Parse error"
%name myparser
%tokentype { Token } data Exp
%token = Plus Exp Term
’+’ { TokenPlus } | Term Term
’*’ { TokenTimes } deriving Show
’a’ { TokenA } data Term
’(’ { TokenOB } = Times Term Factor
’)’ { TokenCB } | Factor Factor
%% deriving Show
Exp : Exp ’+’ Term { Plus $1 $3 } data Factor
| Term { Term $1 } = Id
Term : Term ’*’ Factor { Times $1 $3 } | Brack Exp
| Factor { Factor $1 } deriving Show
Factor data Token
: ’(’ Exp ’)’ { Brack $2 } = TokenA
| ’a’ { Id } | TokenPlus
| TokenTimes
| TokenOB
| TokenCB
deriving Show
}
Ausführliche Informationen über Happy sind auf der Internetseite

http://www.haskell.org/happy/

zu finden.

68
3.5 Konkrete und abstrakte Syntax

3.5 Konkrete und abstrakte Syntax


Unter der konkreten Syntax versteht man die Syntax, etwa in Form eines Syntaxbaums,
gemäß der die Sprache definierenden kontextfreien Grammatik. Es handelt sich meist
um eine benutzerfreundliche Mixfix-Notation, bei der natürliche Sprache für die Ter-
minalsymbole verwendet wird.

Beispiel: Gegeben sei die kontextfreie Grammatik GS mit den Produktionen:

S → id := E
| begin S; S end
| if B then S
E → E + E | id | num
B → E<E

Zu der Anweisung begin id := num + id; if id < num then id := id + num end
erhält man den folgenden konkreten Syntaxbaum:

begin id := num + id ; if id < num then id := id + num end


C B
C
C E E B E E
C E E
C B
C
C S  B SS 
C S 
S  S 
C E B B
C E
CC 

B
CC 

S B

 S
B 
Q B

Q
Q S 
Q 
Q 
Q 
& %
QQ 
S

Für die weiteren Compilerphasen (semantische Analyse, Codegenerierung) sind viele


Details der Eingabe irrelevant, etwa Terminalsymbole, die als syntaktischer Zucker vor
allem der besseren Lesbarkeit dienen wie begin end oder if then. Es genügt die von
der konkreten Darstellung unabhängige algebraische Struktur der Eingabe, die man
auch als abstrakte Syntax bezeichnet. In der abstrakten Syntax werden lediglich die im
Programmwort auftretenden Konstrukte und Schachtelungsbeziehungen identifiziert.
Man erhält eine abstrakte Syntax, wenn man den Regeln π der kontextfreien Gram-
matik Operationssymbole Fπ zuordnet:

π = A0 → w0 A1 w1 A2 . . . wn−1 An wn entspricht Fπ :: A1 × . . . An → A0

Im abstrakten Syntaxbaum werden an den Knoten nur noch die Operationssymbole


angegeben.

69
3. Syntaktische Analyse

Beispiel: (Fortsetzung)
Für die zuvor betrachtete Grammatik GS erhält man die folgenden Operationssymbole:

assign : E → S
seq : S×S → S
cond : B×S → S
plus : E×E → E
id : → E
num : → E
less : E×E → B

Der oben angegebene Ableitungsbaum vereinfacht sich zu folgendem abstrakten Syn-


taxbaum:

seq
 H


HH
H
assign cond
@
@
plus less assign
CC CC
num id id num plus
@
@
id num

Alternativ können anstelle von Operationssymbolen die Regeln selbst verwendet wer-
den. Dann erhält man einen sogenannten Regelbaum, der zum abstrakten Syntaxbaum
isomorph ist. Der oben spezifizierte Happy-Parser erzeugt ebenfalls bereits einen ab-
strakten Syntaxbaum anstelle des konkreten Ableitungsbaums für die arithmetischen
Ausdrücke.

70
4. Semantische Analyse

4 Semantische Analyse
Neben der (kontextfreien) Syntax existieren kontextabhängige Eigenschaften des Pro-
gramms, die während der Analyse geprüft werden müssen. Verlangt die Sprache etwa,
dass jeder Bezeichner deklariert sein muss oder alle Operationen typsicher sein müssen,
so muss diese Bedingung nach bzw. während der Syntaxanalyse zusätzlich geprüft wer-
den, ist aber nicht durch kontextfreie Grammatiken beschreibbar.
Es handelt sich um die sog. statische Semantik des Programms, d.h. die laufzeitun-
abhängigen kontextabhängigen Eigenschaften. Sie werden üblicherweise mit Hilfe von
sog. Attributgrammatiken spezifiziert. Der Ausgangspunkt ist eine kontextfreie Gram-
matik, deren Produktionen zusätzlich mit semantischen Informationen (Attributen)
versehen sind, die den Syntaxbaum ergänzen. Der sich ergebende attributierte Ablei-
tungsbaum dient als Ausgangspunkt für die Synthesephase (Codegenerierung).
In Attributgrammatiken werden Nonterminale A ∈ N mit Attributen versehen, zu de-
ren Berechnung semantische Regeln mit den Produktionen der Grammatik verknüpft
werden. Man unterscheidet synthetische Attribute, die bottom-up berechnet werden,
und inherite Attribute, die top-down berechnet werden. Die Kombination von synthe-
tischen und inheriten Attributberechnungen ermöglicht einen beliebigen Informations-
austausch im Ableitungsbaum.
Typische Attributwerte sind etwa die Symboltabelle, Typinformationen, Codeteile oder
Fehlerinformationen. Attributgrammatiken sind vielfältig einsetzbar. Sie beschreiben
eine syntaxgerichtete Programmierung und können auch bei Programmanalysen für
Optimierungen oder bei der Codegenerierung angewendet werden.

Beispiel: Attributgrammatik für Binärzahlen (Knuth 1968)


Wir betrachten die folgende kontextfreie Grammatik zur Beschreibung von Binärzah-
len:

GB : L → LB | B
B → 0 |1

Aus dem Startsymbol L wird eine Liste von Bits erzeugt. Der dezimale Wert der Zahl
kann parallel zur Syntaxanalyse sukzessiv aus den Anfangswerten 0 und 1 für die einzel-
nen Bits berechnet werden. Hierzu wird den Nonterminalen L und B ein synthetisches
Attribut v (value) zugeordnet und zu den Regeln der Grammatik werden die folgenden
semantischen Regeln zur Berechnung von v festgelegt:

GB : L → LB v.0 = 2 ∗ v.1 + v.2


L → B v.0 = v.1
B → 0 v.0 = 0
B → 1 v.0 = 1

Dabei bezeichnet v.0 den Wert des Attributs v für das Nonterminal links, v.n den Wert
des Attributs v des n-ten Zeichens (nicht nur Nonterminale!) auf der rechten Seite. Wie
man sieht, wird in diesem Beispiel das Attribut v des Startsymbols aus den Werten
der einzelnen Bits (Bottom-Up im Syntaxbaum) ermittelt.

71
4. Semantische Analyse

Wir erweitern die Grammatik um ein neues Startsymbol N, aus dem auch binäre
Festkommazahlen erzeugt werden können.
GB : N → L | L.L
L → LB | B
B → 0 |1
Der Wert einer Kommazahl muss aus den Attributen der beiden Nonterminale L
der rechten Seite der Regel N → L.L berechnet werden, wobei die Stellenlänge des
Nachkomma-Nonterminals eine Rolle spielt. Diese Länge kann mit einem weiteren syn-
thetischen Attribut l (bottom-up) berechnet werden. Es wird nur für das Nonterminal
L benötigt:
GB : N → L v.0 = v.1
N → L.L v.0 = v.1 + v.3 ∗ 2−l.3
L → LB v.0 = 2 ∗ v.1 + v.2 l.0 = l.1 + 1
L → B v.0 = v.1 l.0 = 1
B → 0 v.0 = 0
B → 1 v.0 = 1

Ein attributierter Ableitungsbaum für die Binärzahl 10.1 hat die folgende Gestalt:
N v : 2.5

 @

v:2 @@ v:1
l:2 L • L l:1
@
v:1 @
l:1 L B v:0 B v:1

v:1 B 0 1

Die obige Attributierung der Binärzahlengrammatik hat den Nachteil, dass die einzel-
nen Bits unabhängig von ihrer Wertigkeit bewertet werden. Die Wertigkeit hängt von
der Position eines Bits innerhalb der Binärzahl ab. Mit einem zusätzlichen inheriten
Attribut p (Position) für B und L kann die Attributierung von GB so geändert werden,
dass jedem Bit direkt der Wert entsprechend seiner Position zugeordnet wird:

GB : N → L v.0 = v.1 p.1 = 0


N → L.L v.0 = v.1 + v.3 p.1 = 0 p.3 = −l.3
L → LB v.0 = v.1 + v.2 l.0 = l.1 + 1 p.1 = p.0 + 1 p.2 = p.0
L → B v.0 = v.1 l.0 = 1 p.1 = p.0
B → 0 v.0 = 0
B → 1 v.0 = 2p.0

72
4.1 Attributgrammatiken

In Kombination der inheriten und synthetischen Attribute zeigt sich das Grundpro-
blem attributierter Grammatiken: die Reihenfolge der Auswertung ist nicht durch die
Definition festgelegt. In diesem Beispiel ist es sinnvoll, zunächst das synthetische At-
tribut l bottom-up, anschließend das inherite Attribut p top-down und schließlich das
synthetische Attribut v bottom-up zu ermitteln.
Für die Binärzahl 10.1 ergibt sich der folgende alternativ attributierte Ableitungsbaum:
N v : 2.5
v:2 @

 @
l:2 @ v : 0.5
p:0 L • L l:1
p : −1
@
v:2 @
l:1 L B v:0 B v : 0.5
p:1 p:0 p : −1

v:2 B 0 1
p:1

/
Im allgemeinen Fall lässt sich keine Auswertungsreihenfolge angeben, wohl aber ein
Gleichungssystem mit allen Attributen bestimmen, das es zu lösen gilt. Lösbarkeits-
und Eindeutigkeitseigenschaften des Systems sowie der zur Lösung erforderliche Auf-
wand charakterisieren Klassen von Attributgrammatiken. Im folgenden definieren wir
Attributgrammatiken formal.

4.1 Attributgrammatiken
Definition 30 (Attributgrammatiken) Seien G = (N, Σ, P, S) ∈ CF G(Σ) und Att
eine Menge von Attributen. Sei außerdem eine Familie von Wertebereichen für die
Attribute gegeben: A = (A(α) | α ∈ Att).
Sei att eine Attributzuordnung att :: (N ∪ Σ) −→ IP (Att), die jedem Symbol der
Grammatik eine Menge von Attributen zuordnet.
·
Sei Att = Syn ∪ Inh eine (disjunkte) Zerlegung der Attributmenge in synthetische
und inherite Attribute. Damit zerfällt auch att in zwei Teilabbildungen:
syn : (N ∪ Σ) −→ IP (Syn) mit syn(X) = att(X) ∩ Syn
inh : (N ∪ Σ) −→ IP (Inh) mit inh(X) = att(X) ∩ Inh.

Eine Regel π = X0 → X1 · · · Xr ∈ P mit X0 ∈ N und Xi ∈ (N ∪ Σ)(1 ≤ i ≤ r)


bestimmt die Menge V arπ = {α.i | α ∈ att(Xi ); i ∈ {0, . . . , r}} der formalen Attribut-
variablen von π mit den Teilmengen
OV arπ = {α.i | (i = 0 und α ∈ syn(X0 )) oder (i > 0 und α ∈ inh(Xi )}
IV arπ = V arπ \ OV arπ
= {α.i | (i = 0 und α ∈ inh(X0 )) oder (i > 0 und α ∈ syn(Xi )}

73
4. Semantische Analyse

der Ausgabe- und Eingabevariablen von π.


Eine Attributgleichung von π (semantische Regel) hat die Form:

α.i = f (α1 .i1 , . . . , αn .in )

mit f : Aα1 × . . . × Aαn −→ Aα , n ≥ 0 und α.i ∈ OV arπ ; αj .ij ∈ IV arπ für alle
j ∈ {1, . . . , n}.
Sei Eπ eine Menge von solchen Attributgleichungen von π, so dass jede Ausgabevariable
aus OV arπ genau einmal vorkommt (d.h. definiert wird, da nur links möglich). Dann
heißt
A = hG, {Eπ | π ∈ P }i
eine Attributgrammatik, in Zeichen: A ∈ AG.

Eine Attributgrammatik A induziert für jeden Ableitungsbaum t ∈ G ein Gleichungs-


system Et von Attributgleichungen.

Definition 31 (Attributgleichungssystem)
Sei Kn(t) die Knotenmenge von t, dann bestimmt Kn(t) die Menge V art = {α.k | α ∈
att(X); k ∈ Kn(t) markiert mit X}, die Menge der aktuellen Attributvariablen von t.
Wird an einem inneren Knoten k0 ∈ Kn(t) die Regel π : X0 → X1 · · · Xr angewandt
und sind k1 , . . . , kr die entsprechenden Nachfolgerknoten von k0 , so erhält man das
Attributgleichungssystem Ek0 von k0 aus Eπ durch die Indexsubstitution (i 7→ ki |0 ≤
i ≤ r) bei den Attributvariablen. Das Attributgleichungssystem Et von t ergibt sich
durch die Vereinigung: [
Et := Ek
(innerer Kn.)
k∈Kn(t)

Durch die Bedingung in der Definition existiert in Et zu jeder aktuellen Attributvariable


α.k (außer: inherite Variablen der Wurzel und synthetische Variablen der Blätter) genau
eine definierende Gleichung. Zur Vereinfachung wird im folgenden angenommen, dass
das Startsymbol keine inheriten und Terminalsymbole keine synthetischen Attribute
haben.

Beispiel: Das vorige Beispiel führt zu dem in Abbildung 13 gezeigten Gleichungssy-


stem mit dem nebenstehenden Ableitungsbaum für die Zahl 10.1.
Wie zuvor beschrieben besitzt das Gleichungsssystem eine eindeutige Lösung. /

Es stellt sich allerdings generell die Frage nach der eindeutigen Lösbarkeit von Attri-
butgleichungssystemen. Sie ist i.a. nicht gegeben.

4.2 Zirkularitäten
Das folgende Beispiel zeigt, dass Attributgleichungssysteme keine, genau eine oder
mehrere Lösungen besitzen können.

74
4.2 Zirkularitäten

Ek0 : v.k0 = v.k1 + v.k3


N k0 p.k1 =0
@ p.k3 = −l.k3

 @
Ek1 : v.k1 = v.k4 + v.k5
@
k1 k3
L • L l.k1 = l.k4 + 1
@ p.k4 = p.k1 + 1
@@ p.k5 = p.k1
L k4 B k5 B k6 Ek3 : v.k3 = v.k6
l.k3 =1
p.k6 = p.k3
B k7 0 1 Ek4 : v.k4 = v.k7
l.k4 =1
1 p.k7 = p.k4
Ek5 : v.k5 =0
Ek6 : v.k6 = 2p.k6
Ek7 : v.k7 = 2p.k7

Abbildung 13: Ableitungsbaum mit zugehörigem Attributgleichungssystem

Beispiel: Wir konstruieren eine einfache Attributgrammatik, für die bestimmte At-
tributgleichungssysteme pathologisch sind, da sie einen Zirkelbezug enthalten. G ent-
halte Regeln A → uBv (B sei i-tes Symbol der rechten Regelseite.) und B → w. Sei
α ∈ syn(B) und β ∈ inh(B). Wir betrachten die folgenden Attributgleichungen:

β.i = f (α.i) zu A-Regel


α.0 = g(β.0) zu B-Regel

A
HH
 H
Für einen Teilbaum der Gestalt u Bk v ergibt sich in Et die zirkuläre
w
(
α.k = g(β.k)
Abhängigkeit .
β.k = f (α.k)
Sei Aα = Aβ = N und g = idN . Dann können die folgenden drei Fälle unterschieden
werden:

Fall 1: f (x) = x + 1 ⇒ β.k = β.k + 1 ⇒ Es existiert keine Lösung.


Fall 2: f (x) = 2x ⇒ β.k = 2β.k ⇒ β.k = 0 Es existiert genau eine Lösung.
Fall 3: f (x) = x ⇒ β.k = β.k ⇒ Es existieren unendlich viele Lösungen.
/

75
4. Semantische Analyse

Solche Zirkularitäten sind unerwünscht. Sie entstehen erst in Et und sind in Eπ noch
nicht vorhanden.
Definition 32 (Zirkuläre Attributgrammatik) Eine Attributgrammatik A ∈ AG
heißt zirkulär, wenn es einen Ableitungsbaum t mit zirkulärem Attributgleichungssy-
stem Et in G gibt, d.h. eine aktuelle Attributvariable hängt von sich selbst ab.
Zirkularität ist entscheidbar (mit Aufwand O(2n )). Wir stellen einen Test vor, der “star-
ke Nichtzirkularität” entscheidet. Dabei benutzen wir als Hilfsmittel einen Abhängig-
keitsgraphen DGt zum Attributgleichungssystem Et . Die Knotenmenge entspricht der
Menge der Attributvariablen in t: Kn(DGt ) = V art , während die Kantenmenge angibt,
welche Abhängigkeiten zwischen den Attributvariablen bestehen. Es gilt:
E(DGt ) = {(x, y) | y = f (. . . x . . .) ∈ Et } .
Eine Kante von x nach y bedeutet, dass x direkt von y abhängt. Der Abhängigkeits-
graph zu Et setzt sich aus einzelnen bekannten Bestandteilen zusammen, nämlich den
Teilgraphen für die jeweils verwendeten Produktionen (wobei diese formale Parameter
haben, die durch Substitution in die aktuell verwendete Form gebracht werden). Die
Bestandteile kann man also ebenfalls als Graphen {DGπ | π ∈ P } betrachten und den
Gesamtgraphen analog zum Ableitungsbaum konstruieren.
Beispiel: Wir betrachten wiederum die Grammatik für binäre Gleitkommazahlen.
Wir zeigen hier nur die Abhängigkeitsgraphen für eine Auswahl von Produktionen:
N → L.L: B → 1:
N vu u B
p vu
 3]

 BB
0  J J B
6
 J
uL uu uLJu uB
  B
 . ? 1
p vl p vl

L → LB: L → B:
pu L vu lu u L u u
p v l
 Z 
> > BB
K
A 6 6
  Z  A B
Z A
 u u u Z
~ uB A u B u u
  Z B 1
L  ?B
p v l p v p v
Aus diesen Bestandteilen kann man nun den Abhängigkeitsgraphen zusammensetzen,
wobei jeweils die Variablen an der Oberseite mit den Variablen der Unterseite des
darüber liegenden Bausteins identifiziert werden (genau dies ist die oben formulierte
Substitution). /
Falls nun A ∈ AG zirkulär ist, existiert ein Ableitungsbaum, für den DGt einen Zyklus
enthält. Ein systematisches Testverfahren bestimmt alle möglichen Unterhalbverbin-
dungen zu Nonterminalen und testet dann alle Regelgraphen mit den Unterhalbver-
bindungen auf Zykel. Können auf diese Weise keine Zykel festgestellt werden, handelt es
sich um eine sogenannte stark nicht-zirkuläre Attributgrammatik. Es gibt Grammati-
ken, bei denen das skizzierte Testverfahren einen Zykel findet, obwohl keine Zirkulärität
vorliegt. Der Grund hierfür ist die Vereinigung aller möglichen Unterhalbverbindungen
zu Nonterminalen.

76
4.3 Verfahren zur Lösung von Attributgleichungssystemen

4.3 Verfahren zur Lösung von Attributgleichungssystemen


Zur Lösung von Attributgleichungssystemen Et existieren verschiedene Verfahren, die
im folgenden kurz skizziert werden.

4.3.1 Termersetzung
Dieses Verfahren geht top-down nach dem Prinzip “call by name” vor. Die Attribut-
variablen werden sukzessive durch die rechten Seiten ihrer definierenden Gleichungen
ersetzt.
Beispiel: Wir betrachten das reguläre Gleichungssystem:
 
x1 = f (x2 , x3 )
 x = g(x ) 
 2 3 
 
 x3 = h(x4 ) 
x4 = a

Die Lösung durch Termersetzung verläuft nun wie folgt:


2 2
x1 → f (x2 , x3 ) → f (g(x3 ), x3 ) → f (g(h(x4 )), h(x4 )) → f (g(h(a)), h(a)).
Für die übrigen Attributvariablen werden analoge Reduktionsschritte durchgeführt. /
Bei der Auswertung mittels Termersetzung können zwei Fälle auftreten. Bei einer nicht-
zirkulären Grammatik wird eine eindeutige Lösung bestimmt. Liegt eine Zirkularität
vor, so führt dies zu Nichttermination.

4.3.2 Wertübergabe
Dieses Verfahren arbeitet bottom-up nach dem “call-by-value”-Prinzip. Beginnend mit
xi = ⊥ ∀Attribute xi werden alle Variablen gleichzeitig iterativ bestimmt, indem ihre
Definitionen sukzessiv eingesetzt werden.
t0 = (⊥, . . . , ⊥)
ti+1 = (τ1 , . . . , τn )[xj 7→ tij ∀j∈{1,...,n}]
Dabei werden alle Funktionen als strikt angenommen, also alle Terme mit ⊥ sind
identisch mit ⊥.
Beispiel: Im obigen Beispiel ergibt sich:

         
x1 ⊥ f (⊥, ⊥) ⊥ ⊥ f (g(h(a)), h(a))
x2 ⊥  g(⊥)   ⊥   ⊥   g(h(a)) 
         
 → = →  → ... →  
x3 ⊥  h(⊥)   ⊥   h(a)   h(a) 
x4 ⊥ a a a a
/
Interessanter als die Berechnung für konkrete Ableitungsbäume sind Verfahren, die sich
auf die Grammatik selbst beziehen und allgemeine Lösungsverfahren implementieren.
Wir sprechen zwei Verfahren an, wobei wir das erste nur kurz umreißen.

77
4. Semantische Analyse

4.3.3 Uniforme Berechnung


Dieses Verfahren basiert auf zwei Veröffentlichungen6 . Die Idee ist, die Attributberech-
nung durch rekursive Prozeduren auszudrücken und so eine allgemein für die Gram-
matik gültige Methode automatisch zu erzeugen. Synthetische Attribute sind dabei
(rekursive) Funktionen, inherite Attribute ihre Parameter.

4.3.4 Kopplung an die Syntaxanalyse


Diese Methode macht sich zunutze, dass ohnehin zahlreiche Analysen beim Aufbau
des Syntaxbaums nötig sind und ermittelt die Attributwerte direkt bei der Syntax-
analyse. Dabei müssen allerdings Einschränkungen in Kauf genommen werden, denn
die Syntaxanalyse traversiert den Baum nur in einer Richtung (nämlich während der
Erzeugung). Etwa können bei Bottom-Up-Erzeugung mit einem Shift-Reduce-Parser
nur synthetische Attribute berechnet werden; inherite Attribute würden (mindestens)
eine nachfolgende Top-Down-Traversierung benötigen. Wir betrachten im folgenden
spezielle Grammatikklassen, um diese Möglichkeiten formal zu erfassen.

4.4 S-Attributgrammatiken
Definition 33 (S-Attributgrammatik) Sei A ∈ AG. A heißt S-attributierte Gram-
matik, A ∈ S − AG, falls Inh = ∅, in A also keine inheriten Attribute vorkommen.

Für S-attributierte Grammatiken lassen sich die Attributwerte mit einem Bottom-Up-
Lauf berechnen. Dies kann während der Reduce-Schritte auf dem Stack erfolgen: Wird
z.B. mit der Regel A → BcD reduziert, befinden sich auf dem Stack die Symbole B, c, D
und – so die Annahme – potenzielle synthetische Attributwerte xB , xc , xD sind bereits
berechnet worden. Synthetische Attribute von A können nur von diesen Attributwerten
abhängen und können beim Reduktionsschritt berechnet und wiederum auf dem Stack
gespeichert werden.

Beispiel: Stackcode für arithmetische Ausdrücke kann mit einem synthetischen At-
tribut c für Code ermittelt werden:

E → (E + E) c.0 = c.2;c.4;ADD
| (E ∗ E) c.0 = c.2;c.4;MULT
| id c.0 = LOAD c.1
| num c.0 = LIT c.1

Die Attributwerte c.1 sind lexikalische Attribute, die mit dem Token vom Scanner
übergeben werden:

((3 + x) ∗ 5 → Scanner → ((num, 3) + (id, x)) ∗ (num, 5)


6
Ken Kennedy, Scott K. Warren: Automatic Generation of Efficient Evaluators for Attribute Gram-
mars. POPL 1976, S.32-49, sowie Martin Jourdan: Strongly non-circular attribute grammars and their
recursive evaluation. ACM SIGPLAN Notices 19(6):81–93, June 1984.

78
4.5 L-Attributgrammatiken

Die Codegenerierung erfolgt durch die Attributauswertung während der Syntaxanalyse.


Bei einem Shift-Schritt wird das nächste Token vom Scanner geholt. Auf dem Keller
wird die LR(0)-Menge zu dem Token gespeichert und die zugehörigen lexikalischen
Attribute.
Bei einem Reduktionsschritt erfolgt bei der Reduktion auf dem Analysekeller eine si-
multane Attributberechnung. Beim Accept-Schritt werden schließlich die Wurzelattri-
bute berechnet.
Im Beispiel ergibt sich die Codesequenz: LIT 3; LOAD x; ADD; LIT 5; MULT. /

4.5 L-Attributgrammatiken
Definition 34 (L-Attributgrammatiken)
A = (G, hEπ | π ∈ P i) ist eine L-Attributgrammatik (A ∈ LAG) (L für “Left-to-
Right”), falls für jede Attributgleichung α.i = f (. . . β.j . . .) gilt: α ∈ Inh, β ∈ Syn ⇒
j < i.

Das bedeutet, das inherite Attribut α darf nur von synthetischen Attributen abhängen,
die in den Regeln links von ihm stehen. Eine depth-first“- Traversierung des Syntax-

baums von links nach rechts wird also zunächst β.j berechnen können, woraus sich
danach α.i mit i > j ergeben kann.
Es folgt:

• SAG ⊆ LAG.

• In jedem Syntaxbaum können die Attribute in einem Links-Rechts-Tiefendurch-


lauf (mit 2 Besuchen an jedem Knoten) berechnet werden. Im ersten Schritt (Top-
Down) werden die inheriten Attribute berechnet, im zweiten Schritt (Bottom-
Up) die synthetischen Attribute (erstere ergeben sich wegen der LAG-Bedingung
allenfalls aus bereits bekannten inheriten und synthetischen Attributen).

@
I
@
@
@
@
- @
-
I
@
@ I
@
@ @
I
@
-@ -@ -@
@
I
@
-@

Die Berechnung der Attribute einer L-Attributgrammatik kann mit einem Top-Down-
Parser verbunden werden, bei dem die ersetzten Nonterminale auf dem Stack verblei-
ben. Erst nach der Berechnung der synthetischen Attribute werden sie entfernt. Wir
definieren diesen modifizierten Automaten.

79
4. Semantische Analyse

Das Ziel ist eine Erweiterung der Top-Down-Analyse zur Berechnung der synthetischen
Wurzelattribute. Hierzu wird die Expansion (Ableitung) von A ∈ N auf dem Analy-
sekeller so gestaltet, dass eine spätere Reduktion mit Berechnung der synthetischen
Attribute möglich ist. Beim Top-Down-Lauf werden die inheriten Attribute berechnet,
beim Bottom-Up-Lauf die synthetischen Attribute.

Definition 35 (Top-Down Parser mit Attributauswertung für LAGs)


Sei A = (G, hEπ |π ∈ P i) ∈ LAG mit G ∈ LL(1).
Das Kelleralphabet wird wie folgt gewählt:
[
(LR(0)π (G) × V alπ )
π∈P

wobei V alπ := {valπ | valπ : V arπ 99K A} (Attributbelegungen)


und LR(0)π (G) = {[A → α.β] | π : A → αβ} (LR(0)-Auskünfte).
Startkonfiguration: (< [S 0 → .S], valπ >, w) mit: Def (valπ ) = inh(S) (meistens leer)
Zielkonfiguration: (< [S 0 → S., valπ+ >, ε) mit: syn(S) ⊆ Def (valπ+ )
Damit können die synthetischen Attribute von S bestimmt und ausgegeben werden.
Die Arbeitsweise des erweiterten Top-Down-Parsers wird durch die folgenden Transi-
tionen festgelegt:
• Expansion eines Nonterminals B (Ableitungsschritt):
(< [A → α.Bβ], valπ > σ, w) mit Def (valπ ) = inh(A) ∪ syn(α)
` (< [B → .γ], valπB >< [A → α.Bβ], valπ > σ, w) mit Def (valπB ) = inh(B).
Die inheriten Attribute von B hängen wegen der L-Attributierung nur von valπ
ab.

• Lesen eines Terminalsymbols a (Vergleichsschritt):


(< [A → α.aβ], valπ > σ, aw) mit Def (valπ ) = inh(A) ∪ syn(α)
` (< [A → αa.β], valπ+ > σ, w) mit Def (valπ+ ) = Def (valπ ) ∪ syn(a).
Die Attribute zu a werden dabei vom Scanner geliefert.

• Reduktion gemäß B → γ (neu):


(< [B → γ.], valπB >< [A → α.Bβ], valπA > σ, w)
mit Def (valπA ) = inh(A) ∪ syn(α)
Def (valπB ) = inh(B) ∪ syn(γ)
` (< [A → αB.β], valπA+ > σ, w) mit Def (valπA+ ) = Def (valπA ) ∪ syn(B)
= inh(A) ∪ syn(αB).

Dieser modifizierte TDA behält die vorigen Zustände auf dem Stack und benutzt des-
halb LR(0)-Auskünfte anstelle des einfachen (N ∪ Σ) als Kelleralphabet. Die partiellen
Abbildungen valπ sind die Stelle, wo die bisher ermittelten Attributwerte gespeichert
sind; ihr Definitionsbereich erweitert sich mit jedem Schritt. Am Ende der Analyse sind
die synthetischen Attributwerte der Wurzel S und somit aller Nachfolger bestimmt.

80
4.5 L-Attributgrammatiken

Beispiel: Wir betrachten eine einfache L-Attributgrammatik G mit Nonterminalen


S, A und B, denen je ein inherites und ein synthetisches Attribut i bzw. s zugeordnet
ist.

S → AB s.0 = suc(s.2)
i.1 = 0
i.2 = suc(s.1)

A → aA s.0 = suc(s.2)
i.2 = suc(i.0)

A→c s.0 = suc(i.0)

B→b s.0 = suc(i.0)

Syntaxanalyse mit Attributauswertung:

(h[S 0 → .S], ∅i, acb)


`Expansion (h[S → .AB], ∅ih[S 0 → .S], ∅i, acb)
`Expansion (h[A → .aA], i.0 7→ 0ih[S → .AB], ∅ih[S 0 → .S], ∅i, acb)
`Shift (h[A → a.A], i.0 7→ 0ih[S → .AB], ∅ih[S 0 → .S], ∅i, cb)
`Expansion (h[A → .c], i.0 7→ 1ih[A → a.A], i.0 7→ 0ih[S → .AB], ∅ih[S 0 → .S], ∅i, cb)
`Shift (h[A → c.], i.0 7→ 1ih[A → a.A], i.0 7→ 0ih[S → .AB], ∅ih[S 0 → .S], ∅i, b)
`Reduktion (h[A → aA.], i.0 7→ 0, s.2 7→ 2ih[S → .AB], ∅ih[S 0 → .S], ∅i, b)
`Reduktion (h[S → A.B], s.1 7→ 3ih[S 0 → .S], ∅i, b)
`Expansion (h[B → .b], i.0 7→ 4ih[S → A.B], s.1 7→ 3ih[S 0 → .S], ∅i, b)
`Shift (h[B → b.], i.0 7→ 4ih[S → A.B], s.1 7→ 3ih[S 0 → .S], ∅i, ε)
`Reduktion (h[S → AB.], s.1 7→ 3, s.2 7→ 5ih[S 0 → .S], ∅i, ε)
`Reduktion (h[S 0 → S.], s.1 7→ 6i, ε)

Damit kann das synthetische Attribut der Wurzel ausgegeben werden. Die Berechnung
ist in Abbildung 14 veranschaulicht.
/

Der LL-Parser weiß schon bei der Expansion eines Nichtterminals, welche Produktion
ausgewählt wird und kennt damit die für diese Alternative generierte Adressierung der
Attributvorkommen.
Die Kopplung der Attributauswertung einer L-attributierten Grammatik an einen LR-
Parser ist schwierig, da dieser erst bei der Reduktion weiß, welche Regel angewendet
wird. Alle Attributauswertungsaktionen werden daher an Reduktionen geknüpft (siehe
Buch von Wilhelm/Maurer).

81
4. Semantische Analyse

 




 


  
 





 

 




Abbildung 14: Veranschaulichung einer Syntaxanalyse mit Attributauswertung
Diese Abbildung wurde dankenswerterweise von Philipp Legrum zur Verfügung gestellt.

82
5. Übersetzung in Zwischencode (Synthesephase)

5 Übersetzung in Zwischencode (Synthesephase)


Mit der Generierung von Zwischencode verlassen wir die Analysephase des Compi-
lers. Wir befinden uns allerdings immer noch im Frontend, da der Zwischencode im
allgemeinen maschinenunabhängig ist. Der Zwischencode

• liefert eine allgemeine Schnittstelle, an die sich Backends für verschiedene Archi-
tekturen anschließen können (Modularisierung, Portabilität),

• lässt sich auf abstrakter Ebene durch Transformationen optimieren,

• macht dadurch die Hardware transparent.

Zwischencode wird seit der Entwicklung der ersten Pascal-Compiler eingesetzt (P-
Code). In Java-Compilern wird die Java Virtual Machine eingesetzt, in Prolog-Compi-
lern die Warren Abstract Machine (WAM) und im Glasgow Haskell Compiler die STG
Machine. Das Compiler-Backend überführt den bereits maschinennahen Zwischencode
schließlich in konkreten Maschinencode. Wichtige Teilaufgaben sind dabei die Register-
zuteilung sowie die Auswahl und Anordnung der Instruktionen.
In Abbildung 15 sind die Strukturen von höheren Programmiersprachen, Maschinenco-
de sowie Zwischencode gegenübergestellt. Der Zwischencode übernimmt Grundstruk-
turen von Programmiersprachen und beschreibt eine Realisierung abstrakter Konzepte
mit elementaren Kontroll- und Speicherstrukturen.
Wir beschreiben im folgenden die Übersetzung in einen Zwischencode inkrementell in
vier Stufen, wobei in jeder Stufe weitere Konzepte imperativer Sprachen hinzukommen.

1. Ausdrücke und Kontrollstrukturen

2. Prozeduren (und Blöcke)

3. Datenstrukturen

4. Modulstrukturen (inkrementell übersetzt)

5.1 Übersetzung von Ausdrücken und Anweisungen


Als Einstieg betrachten wir die Übersetzung von arithmetischen und Booleschen Aus-
drücken sowie von Kontrollstrukturen wie Sequenz, Verzweigung und Iteration. Hierzu
wird die einfache Modellsprache PSA für Programmiersprache mit Ausdrücken und
Anweisungen eingeführt. Es handelt sich um eine einfache universelle Sprache, die der
Modellsprache WHILE aus der Berechenbarkeitstheorie entspricht.

5.1.1 Syntax der Sprache PSA


Die Sprache PSA umfasst die in Abbildung 16 gezeigten syntaktischen Elemente. Die
Mehrdeutigkeit der Syntaxdefinition spielt keine Rolle, es kann auf jeden Fall eine äqui-
valente eindeutige Grammatik gefunden werden, die vom jeweils eingesetzten Verfahren
zur Syntaxanalyse abhängt. Wir gehen also hier davon aus, dass der eindeutige abstrak-
te Syntaxbaum bereits vorliegt; die Grammatik dient nur der Syntaxbeschreibung für

83
5. Übersetzung in Zwischencode (Synthesephase)

Strukturen von Programmiersprachen (PS)

• Basistypen und Basisoperationen


• statische und dynamische Datenstrukturen
• Kontrollstrukturen
• Ausdrücke, Funktionen, Prozeduren, Methoden
• Modulstrukturen: Blöcke, Module, Klassen

Strukturen von Maschinencode (MC)

• Speicherhierarchie: Register, Cache, Hauptspeicher, Hintergrundspeicher


• Befehle: ALU, Tests/Sprünge, Transfer, Ein-/Ausgabe
• Adressierung: relativ mit Index-/Basisregister, indirekt
• RISC / CISC

Strukturen von Zwischencode (Z)

• Typen und Operationen wie in PS


• Datenkeller mit Basisoperationen
• Sprungbefehle für Kontrollstrukturen
• Prozedurkeller für Blöcke/Prozeduren deletion strategy“

• Heap für dynamische Datenstrukturen, Objekte retention strategy“

Abbildung 15: Strukturen von Programmiersprachen, Maschinen- und Zwischencode

dieses Kapitel. Die vorkommenden Bezeichnungen wie AExp, Cmd werden als Mengen
aller nachstehend definierten Konstrukte aufgefasst und im folgenden in diesem Sinn
weiter verwendet.
Weiter wollen wir davon ausgehen, dass alle in Γ verwendeten Bezeichner vorher in ∆
deklariert und paarweise verschieden sind (kontextabhängige Eigenschaften).

5.1.2 Semantik von PSA-Programmen

Die im folgenden definierte Semantik ist denotationell, beschreibt also mit Abbildun-
gen die Effekte der Programmausführung (Zustandsänderungen in Speicher und Um-
gebung).
Die verwendeten semantischen Grundbereiche sind:

Speicherplätze (locations) Loc := {α1 , α2 , α3 , . . .}


Zustandsraum (states) S := {σ | σ : Loc 99K Z}
Umgebung (environment) Env := {ρ | ρ : Ide 99K Z ∪ Loc}

84
5.1 Übersetzung von Ausdrücken und Anweisungen

Ganze Zahlen Int : Z


Bezeichner Ide : I
Deklarationen Decl : ∆ ::= ∆C ∆V
∆C ::= ε | const I1 = Z1 ; . . . ; In = Zn
(n ≥ 1)
∆V ::= ε | var I1 , . . . , In ; (n ≥ 1)
(* nur Integervariablen *)
Arithmetische AExp : E ::= Z | I | (E1 aop E2 )
Ausdrücke (aop ∈ {+, −, ∗, . . .})
Boolesche BExp : B ::= E1 relop E2 |
Ausdrücke not B | (B1 and B2 ) | (B1 or B2 )
(relop ∈ {=, 6=, <, . . .})
Anweisungen Cmd : Γ ::= I := E | Γ1 ; Γ2
| if B then Γ1 else Γ2
| while B do Γ
Programme P rog : P ::= ∆Γ

Abbildung 16: Syntax von PSA

Der Zustandsraum ist die Menge der Abbildungen, welche den Speicherzustand be-
schreiben. Die Umgebung bildet die deklarierten Bezeichner auf die Speicherplätze ab.
Beides sind partielle Abbildungen (Notation: 99K); es ist weder jeder Bezeichner de-
klariert, noch jeder Speicherplatz belegt. Die Speicherplätze sind nur indirekt über die
Umgebung zugänglich.

Deklarationssemantik Die Deklarationssemantik

D :: Decl × Env 99K Env

baut die Umgebung aus den Deklarationen auf. Da wir die Deklaration aller benutzten
Bezeichner gefordert haben, ergibt sich die Umgebung vollständig daraus.

D[[∆C ∆V ]]ρ := D[[∆V ]](D[[∆C ]]ρ)

D[[ε]]ρ := ρ
D[[const I1 = Z1 ; . . . ; In = Zn ]]ρ := ρ [I1 /Z1 , . . . , In /Zn ]
D[[var I1 , I2 , . . . , In ]]ρ := ρ[I1 /α1 , . . . , In /αn ]

Aus der Deklarationssemantik der Variablendeklaration ist ersichtlich, dass eine einfa-
che Speicherverwaltung verwendet wird, bei der die ersten n Adressen sukzessive an
die Variablenbezeichner vergeben werden.

85
5. Übersetzung in Zwischencode (Synthesephase)

Ausdruckssemantik Die Semantik der arithmetischen Ausdrücke

E :: AExp × Env × S 99K Z

wird induktiv über die Struktur der Ausdrücke definiert, wobei die Semantik der
verfügbaren Operationen vorausgesetzt wird. Da die Bezeichner evtl. nicht deklariert
wurden, ist diese Abbildung partiell.

E[[Z]]ρσ := (
Z
ρ(I) falls ρ(I) ∈ Z
E[[I]]ρσ :=
σ(ρ(I)) falls ρ(I) ∈ Loc
E[[(E1 op E2 )]]ρσ := E[[E1 ]]ρσ [[op]] E[[E2 ]]ρσ

Bei der Semantik von Variablenbezeichnern I mit ρ(I) ∈ Loc unterscheidet man den
sogenannten l-Wert ρ(I), d.h. den dem Bezeichner zugeordneten Speicherplatz, der un-
veränderlich (statisch) ist, und den r-Wert σ(ρ(I)), d.h. den Inhalt des Speicherplatzes,
der während eines Programmlaufs veränderlich (dynamisch) ist. Die Bezeichnungen l-
und r-Wert beziehen sich auf die Bedeutung eines Variablenbezeichners im Kontext
einer Wertzuweisung: Auf der linken Seite ist der l-Wert gemeint, auf der rechten Seite
der r-Wert.
Die Semantik Boolescher Ausdrücke kann analog zu der arithmetischer Ausdrücke er-
folgen:
B :: BExp × Env × S 99K {true, false}.
Hierbei kann man allerdings strikte und nicht-strikte Varianten unterscheiden:

1. strikt: b and ⊥ = ⊥ and b = ⊥ für beliebige b ∈ {true, false}.


Analoges gilt für or.

2. nicht-strikt:

• sequentiell: false and ⊥ = false, true or ⊥ = true


sonst wie bei 1.
• parallel: ⊥ and false = false, ⊥ or true = true
sonst wie bei sequentiell

Die strikten Booleschen Junktoren erfordern die Auswertung beider Argumente. Sie
werden etwa in ISO-Pascal verwendet. Zur Implementierung der sequentiellen Semantik
genügt eventuell die Auswertung des ersten Arguments. Die sequentiellen Operatoren
werden in C implementiert. Die parallele Semantik erfordert eine parallele oder ver-
zahnte (Reißverschlussprinzip) Auswertung beider Argumente und wird daher selten
implementiert.

Beispiel: Welche Semantik bei den Booleschen Ausdrücken vorausgesetzt wird, kann
sich auch auf den Programmierstil auswirken. So arbeitet die folgende Anweisungsfolge
nur korrekt, wenn die logische Konjunktion and sequentiell implementiert ist.

86
5.1 Übersetzung von Ausdrücken und Anweisungen

/* Suche Element e in Feld a[1..n] */


i:=1; while i <= n and a[i] /= e do i:= i+1

Falls e nicht in a vorkommt und and strikt ist, kommt es zu einem Laufzeitfehler
wegen Indexüberschreitung. Bei sequentiellem and wird die Schleife in diesem Fall mit
i = n + 1 verlassen. /

Anweisungssemantik Die Semantik der Anweisungen

C :: Cmd × Env × S 99K S

entspricht den üblichen Vorstellungen. Da wir als elementare Anweisungen lediglich


Zuweisungen erlauben, sind dies einfach Veränderungen in den Zuständen (Speicher-
manipulationen). Die Kontrollstrukturen entsprechen passenden Iterationen dieser ele-
mentaren Anweisungen.

C[[I := E]]ρσ := σ [ρ(I) 7→ E[[E]]ρσ]


C[[Γ1 ; Γ2 ]]ρσ := C[[Γ
( 2 ]]ρ(C[[Γ1 ]]ρσ)
C[[Γ1 ]]ρσ falls B[[B]]ρσ = true
C[[if B then Γ1 else Γ2 ]]ρσ :=
C[[Γ2 ]]ρσ falls B[[B]]ρσ = false

 C[[while B do Γ]]ρ(C[[Γ]]ρσ)

C[[while B do Γ]]ρσ := falls B[[B]]ρσ = true

 σ falls B[[B]]ρσ = false

Programmsemantik Weiter erhalten wir schließlich die Programmsemantik

M :: P rog × S 99K S
M[[∆Γ]]σ = C[[Γ]](D[[∆]]ρ∅ )σ

Ein PSA-Programm P = ∆Γ bestimmt eine Zustandstransformation. Im Deklarati-


onsteil ∆ wird die zugehörige Umgebung, einschließlich des sichtbaren Zustandsraums,
festgelegt. ρ∅ bezeichne die leere, d.h. nirgendwo definierte Umgebung.
Diese Syntax und Semantik wird in den folgenden Abschnitten beibehalten und jeweils
passend ergänzt.

5.1.3 Zwischencode für PSA


Wir definieren nun eine abstrakte Maschine MA zur Ausführung von PSA-Program-
men. Sie sei mit einem (N-adressierten) Speicher und einem Datenkeller für ganze
Zahlen ausgestattet. Dieser übernimmt die Rolle der Register, speichert also die Argu-
mente.
Der Zustandsraum ZR der MA hat die Gestalt ZR = BZ × DK × HS mit

BZ := N (Befehlszähler)
DK := Z∗ (Datenkeller mit Zahlen, Kellerspitze rechts)
HS := ZN := {h : N → Z} (Hauptspeicher, als Abbildung)

87
5. Übersetzung in Zwischencode (Synthesephase)

C[[AOP]](m, d : z1 : z2 , h) := (m + 1, d : z, h) mit z = z1 [[aop]]z2

C[[RELOP]](m, d : z1 : z2 , h) := (m + 1, d(: z, h)
1 falls z1 [[relop]]z2 = true
mit z =
0 sonst.
C[[AND]](m, d : z1 : z2 , h) := (m + 1, d : z, h) mit z = min{z1 , z2 }
C[[OR]](m, d : z1 : z2 , h) := (m + 1, d : z, h) mit z = max{z1 , z2 }
C[[NOT]](m, d : z1 , h) := (m + 1, d : z, h) mit z = 1 − z1

C[[LOAD n]](m, d, h) := (m + 1, d : h(n), h) (


z falls x = n
C[[STORE n]](m, d : z, h) := (m + 1, d, h0 ) mit h0 (x) =
h(x) sonst.
C[[LIT z]](m, d, h) := (m + 1, d : z, h)

C[[JMP n]](m, d, h) := (n, d, h) (


n falls z = 0
C[[JPFALSE n]](m, d : z, h) := (x, d, h) mit x =
m + 1 sonst.

Abbildung 17: Semantik von MA-Befehlen

Ein Zustand ist dementsprechend ein Tripel s = (m, d, h) mit Befehlsmarke m ∈ N,


Datenkellerinhalt d ∈ Z∗ und Hauptspeicherbelegung h : N → Z.
MA stellt die folgenden Befehle bereit:
• Arithm. Befehle: AOP ∈ { ADD, SUB, MULT, ...} entsprechend aop,
• Logische Befehle: RELOP ∈ { EQ, NEQ, LESS, ...} entsprechend relop,
AND, OR, NOT

• Transportbefehle: LOAD n, STORE n, LIT z mit n ∈ N, z ∈ Z,


• Sprungbefehle: JMP n, JPFALSE n mit n ∈ N.
Alle arithmetischen und logischen Befehle sind parameterlos, lesen also ihre Argumente
vom Datenkeller. Falls zuwenig Argumente vorhanden sind, ist das Verhalten nicht
definiert. Die Transportbefehle erhalten hingegen als Argument Speicheradressen bzw.
Zahlen, die Sprungbefehle Programmzählerwerte. Die in Abbildung 17 angegebenen
Definitionen spezifizieren für jeden Befehl B die Semantik C[[B]] : ZR 99K ZR.
Programme für diese Maschine sind als Befehlsfolgen mit aufsteigenden Befehlsmarken
realisiert. Die Menge MA-Code enthält alle solchen Programme.
MA-Code := {a1 : B1 ; a2 : B2 ; . . . ap : Bp ; | Bi sei MA-Befehl, ai ∈ N,
ai = a1 + i − 1}

88
5.1 Übersetzung von Ausdrücken und Anweisungen

Die Semantik eines solchen Programms P wird als Iteration der Befehle entsprechend
dem Befehlszähler definiert:

I :: MA-Code × ZR 99K ZR
(
I[[P ]] (C[[Bm ]](m, d, h)) falls a1 ≤ m < ap
I[[P ]](m, d, h) :=
(m, d, h) sonst.

Das Programm wird beendet, wenn der Befehlszähler sich nicht mehr im Programm-
bereich befindet. Ansonsten wird die Ausführungsfunktion rekursiv mit dem resultie-
renden Maschinenzustand wieder aufgerufen.

5.1.4 Übersetzung von PSA-Programmen in MA-Code


Wir definieren nun eine Funktion, die das PSA-Programm in Maschinencode übersetzt.

trans :: Prog → MA-Code

Zu diesem Zweck verwenden wir als Hilfsmittel eine Symboltabelle, um die deklarierten
Bezeichner zu verwalten. Diese wird während des Scanning/Parsing aufgebaut, was wir
aber an dieser Stelle konkretisieren.

T ab := {st | st :: Ide 99K ({const} × Z) ∪ ({var } × N).

Die leere Symboltabelle bezeichnen wir mit st∅ .


Der Aufbau der Symboltabelle gemäß einer Deklaration wird durch die folgende Funk-
tion beschrieben:
up :: Decl × T ab 99K T ab
Es gilt:

up(∆C ∆V , st) := if pwv(∆C ∆V ) (Bezeichner paarw. versch.)


then up(∆V , up(∆C , st))
up(ε, st) := st
up(const I1 = Z1 ; . . . ; In = Zn , st) := st[I1 /(const, Z1 ), . . . , In /(const, Zn )]
up(var I1 , . . . , In , st) := st[I1 /(var, 1), . . . , In /(var, n)]

Die Speicherverwaltung der Variablenbezeichner ist trivial, weil es keine Blockschach-


telung gibt und nur Integer-Variablen vorhanden sind.
Die Ausdrucksübersetzung

et : AExp × T ab × Adr 99K MA-Code

verläuft in naheliegender Weise. Der Parameter vom Typ Adr ist jeweils die aktu-
elle Codeposition ∈ N. Streng genommen muss bei der Übersetzung fortlaufend die
Länge des erzeugten Codes berechnet und mitgeführt werden. Wir verzichten hier zur
Vereinfachung der Definitionen auf die Angabe dieser Komponente und erlauben die

89
5. Übersetzung in Zwischencode (Synthesephase)

Verwendung von neuen symbolischen Sprungmarken nach Bedarf.


et(z, st, a) := (
a : LIT z
a : LIT z falls st(I) = (const, z)
et(I, st, a) :=
a : LOAD n falls st(I) = (var, n)
et(E1 aop E2 , st, a) := et(E1 , st, a);
et(E2 , st, a0 );
a00 : AOP(aop)
Die durch die symbolischen Sprungmarken a0 und a00 repräsentierten Codeadressen
ergeben sich aus der fortlaufenden Nummerierung und können etwa aus der Codelänge
der vorigen Anweisungen berechnet werden.
Die Übersetzung Boolescher Ausdrücke bt :: BExp × T ab × Adr 99K MA-Code erfolgt
für strikten Stackcode völlig analog. Alle Argumente werden ausgewertet.
Die Übersetzung von Anweisungen erfolgt mittels der Funktion
ct :: Cmd × T ab × Adr 99K MA-Code
ct(I := E, st, a) := et(E, st, a); (falls st(I) = (var, i) )
0
a : STORE i

ct(Γ1 ; Γ2 , st, a) := ct(Γ1 , st, a);


ct(Γ2 , st, a0 )

ct(if B then Γ1 else Γ2 , st, a) := bt(B, st, a)


a : JPFALSE (a00 + 1)
0

ct(Γ1 , st, a0 + 1)
a00 : JMP (a000 )
ct(Γ2 , st, a00 + 1)
a000 : NOOP ; (nur Sprungziel)

ct(while B do Γ, st, a) := bt(B, st, a)


a : JPFALSE (a00 + 1)
0

ct(Γ, st, a0 + 1)
a00 : JMP a
Damit ergibt sich trans(∆Γ) := ct(Γ, up(∆, st∅), 1), wobei st∅ ≡ ⊥.
Beispiel: (Übersetzung von PSA in MA-Code)
Es sei das folgende Programm P gegeben:

const I = 3, J = 10;
var K, L;
K := I + L;
if K > L then K := K-L else K := 1

90
5.1 Übersetzung von Ausdrücken und Anweisungen

Seien ∆ = const I = 3, J = 10; var K, L; und Γ = Γ1 ; Γ2 mit Γ1 =K := I + L


und Γ2 = if K > L then K := K-L else K := 1.
Dann gilt:
trans(P ) = ct(Γ, up(∆, st∅ ), 1), wobei
up(const I = 3, J = 10; var K, L; , st∅ )
= up(var K, L; , up(const I = 3, J = 10;, st∅ ))
= up(var K, L; , st∅ [I/(const, 3), J/(const, 10)])
= st∅ [I/(const, 3), J/(const, 10), K/(var, 1), L/(var, 2)]
=: st1
und
ct(Γ, st1 , 1) = ct(Γ1 , st1 , 1); ct(Γ2, st1 , 5)

ct(Γ1 , st1 , 1) ct(Γ2 , st1 , 5)


= et(I + L, st1 ) = 5: LOAD 1;
4: STORE 1; 6: LOAD 2;
7: GREATER;
= 1: LIT 3; 8: JPFALSE 14;
2: LOAD 2; 9: LOAD 1;
3: ADD; 10: LOAD 2;
4: STORE 1 11: SUB;
12: STORE 1;
13: JMP 16;
14: LIT 1;
15: STORE 1. /

Es gilt der folgende Satz:

Satz 11 (Compilerkorrektheit) Für ein PSA-Programm P ist

M[[P ]] : S 99K S und I[[trans (P )]] : ZR 99K ZR.

Es gilt S 3 σ : Loc 99K Z und ZR = BZ × DK × HS mit HS 3 h : N 99K Z.


Mit der folgenden Beziehung zwischen S und HS
(
σ(αi ) falls σ(αi ) definiert.
σ 7→ hσ mit hσ (i) :=
0 sonst

gilt dann:
M[[P ]]σ = σ 0 ⇐⇒ I[[trans(P )]](1, ε, hσ ) = (m, ε, hσ0 ).

91
5. Übersetzung in Zwischencode (Synthesephase)

Jumping Code. Erfolgt die Übersetzung Boolescher Ausdrücke (oben nicht wieder-
gegeben) analog zu der der arithmetischen Ausdrücken, so wird eine strikte Semantik
realisiert. Wir geben im folgenden eine alternative Übersetzung von Booleschen Aus-
drücken an, den sogenannten Jumping Code, bei der längerer Code erzeugt wird, der
aber zu erheblich kürzeren Laufzeiten führen kann. Der Jumping Code realisiert eine
sequentielle nicht-strikte Semantik.
Die Sprungziele für eine positive und eine negative Auswertung des Booleschen Aus-
drucks innerhalb von Verzweigungen und Schleifen werden bereits vorher berechnet
und bei der Übersetzung der Booleschen Ausdrücke mit eingearbeitet:
bt0 :: BExp × T ab × Adr 3 −→ MCode

bt0 (E1 relop E2 , st, a, atrue , af alse ) = et(E1 , st, a);


et(E2 , st, a0 );
a00 : RELOP;
a00 + 1 : JPFALSE af alse ;
a00 + 2 : JMP atrue
bt0 (not B, st, a, atrue , af alse ) = bt0 (B, st, a, af alse , atrue )
bt0 (B1 and B2 , st, a, atrue , af alse ) = bt0 (B1 , st, a, a0 , af alse );
bt0 (B2 , st, a0 , atrue , af alse )
bt0 (B1 or B2 , st, a, atrue , af alse ) = bt0 (B1 , st, a, atrue , a0 );
bt0 (B2 , st, a0 , atrue , af alse )

Aus der Übersetzung von Kontrollstrukturen wird :


ct0 ( if B then Γ1 else Γ2 , st, a) = bt0 (B, st, a, a0 , a00 + 1);
ct0 (Γ1 , st, a0 );
a00 : JMP a000 ;
ct0 (Γ2 , st, a00 + 1);
a000 : NOOP (nur Sprungziel)
ct0 ( while B do Γ, st, a) = bt0 (B, st, a, a0 , a00 + 1);
ct0 (Γ, st, a0 );
a00 : JMP a;
a00 + 1 : NOOP (nur Sprungziel)
Interessant ist hier, dass die logischen Operationen der Maschine gar keine Rolle mehr
spielen. Die Semantik dieser Operationen ist durch die angegebenen Sprungziele bereits
in der Übersetzung codiert.
Beispiel: Wir stellen die beiden alternativen Übersetzungsmöglichkeiten am Beispiel
des folgenden Booleschen Ausdrucks gegenüber:
not (I = 0) and (J > I)
Die links angegebene Standardübersetzung umfasst 8 Befehle, die unabhängig von der
Belegung der Variablen immer alle ausgeführt werden. Hingegen besteht der rechts

92
5.2 Übersetzung von Blöcken und Prozeduren

angegebene Jumping Code zwar aus 10 Befehlen, falls aber beispielsweise σ(I) = 0
gilt, so werden nur 5 Befehle ausgeführt.
Standardübersetzung Jumping Code
(strikte Semantik)) (nicht-strikte Semantik))

LOAD I; LOAD I;
LIT 0; LIT 0;
EQ; EQ;
NOT; JPFALSE a;
LOAD J; JMP a_false;
LOAD I; a: LOAD J;
GREATER; LOAD I;
AND GREATER;
JPFALSE a_false;
JMP a_true
/

5.2 Übersetzung von Blöcken und Prozeduren


Eine strukturierte Programmentwicklung mit Blöcken und Prozeduren führt zu der
Unterscheidung von lokalen und globalen Objekten und damit zu einer variablen Um-
gebung, die eine dynamische Laufzeitverwaltung mit einem Laufzeitkeller erfordert.
Mit den Programmiersprachen wurde auch das Prozedurkonzept weiterentwickelt:

FORTRAN erlaubt die Definition von nicht geschachtelten Unterprogrammen oh-


ne Rekursion. Hierdurch konnte der Speicher statisch verwaltet werden. Der
Speicherbedarf eines Programms konnte zur Übersetzungszeit ermittelt werden.

C unterstützt nicht-geschachtelte rekursive Prozeduren. Wegen der Rekursion wird


der Speicher dynamisch als Laufzeitkeller ohne statische Verweise verwaltet. Der
Speicherbedarf eines Programms wird erst zur Laufzeit bekannt.

Algol, Pascal, Modula... enthalten geschachtelte rekursive Prozedurdeklarationen,


zu deren Verwaltung ein Laufzeitkeller mit statischen Verweisen eingesetzt wird.

5.2.1 PSP — eine Programmiersprache mit Prozeduren


PSP ist eine einfache Programmiersprache ohne Datenstrukturen, aber mit geschachtel-
ten rekursiven Prozedurdeklarationen. Zur Vereinfachung haben die Prozeduren keine
Parameter. Lokal können Konstanten und Variablen vom Typ Integer sowie Prozeduren
deklariert werden. Die Prozeduren benutzen einen Bezeichner I als Namen, auf den der
Prozedurblock B folgt. Das Programm als Ganzes besteht aus einem globalen Block
und erhält zusätzlich Ein-/Ausgabevariablen. Die Syntax von PSP ist in Abbildung 18
angegeben. PSP erweitert PSA um Prozedurdeklarationen, Blöcke und Prozeduraufru-
fe. Die arithmetischen und Booleschen Ausdrücke bleiben unverändert. Die folgenden
kontextsensitiven Bedingungen sind an PSP-Programme geknüpft:
• Bezeichner einer Deklaration ∆ müssen paarweise verschieden sein.

93
5. Übersetzung in Zwischencode (Synthesephase)

Ganze Zahlen Int : Z


Bezeichner Ide : I
Deklarationen Decl : ∆
::= ∆C ∆V ∆P
::= ε | const I1 = Z1 ; . . . ; In = Zn
∆C
(n ≥ 1)
∆V ::= ε | var I1 , . . . , In ; (n ≥ 1)
∆P ::= ε | proc I1 ; B1 ; . . . proc In ; Bn ;
(n ≥ 1)
Arithmetische AExp : E ::= Z | I | (E1 aop E2 )
Ausdrücke (aop ∈ {+, −, ∗, . . .})
Boolesche BExp : BE ::= E1 relop E2
Ausdrücke | not BE | (BE1 and BE2 )
| (BE1 or BE2 )
(relop ∈ {=, 6=, <, . . .})
Anweisungen Cmd : Γ ::= I := E | I() | Γ1 ; Γ2
| if BE then Γ1 else Γ2
| while BE do Γ
Blöcke Block : B ::= ∆Γ
Programme P rog : P ::= in/out I1 , . . . , In ; B

Abbildung 18: Syntax von PSP

• Ein im Anweisungsteil Γ eines Blocks ∆Γ verwendeter Bezeichner muss in ∆ oder


in der Deklarationsliste eines umschließenden Blocks deklariert sein.

• Mehrfachdeklaration eines Bezeichners ist auf verschiedenen Niveaus erlaubt: Die


innerste“ Deklaration ist für ein Auftreten gültig.

Der Gültigkeitsbereich (engl.: scope) eines Bezeichners bzw. einer Bezeichnerdeklaration


ist der Teil des Programms, in dem sich ein angewandtes Vorkommen des Bezeichners
auf diese Deklaration beziehen kann. Man unterscheidet die folgenden Festlegungen des
Gültigkeitbereichs von Bezeichnern:

Static scope bedeutet: Beim Aufruf einer Prozedur ist ihre


Deklarationsumgebung gültig.
Dynamic scope bedeutet: Beim Aufruf einer Prozedur ist ihre
Aufrufumgebung gültig.

Beispiel: Wir betrachten die folgende Programmstruktur:

94
5.2 Übersetzung von Blöcken und Prozeduren

in/out X; Es gilt:
const C = 10;
var Y; 1. static scope: Beim Prozeduraufruf A()
proc A; im Anweisungsteil von B bezeichnet X je-
var Y, Z; weils die Ein-/Ausgabevariable X und Z
proc B; die lokale Variable Z von A.
var X,Z; 2. dynamic scope: Beim Prozeduraufruf
[... A() ...] A() im Anweisungsteil von B bezeichnet X
[... B() ... D() ...] die lokale Variable von B und Z die lokale
proc D; Variable von A.
[... A() ...]
[... A() ...]. 3. D kann in A aufgerufen werden, obwohl
die Deklaration textuell später erfolgt.
/

Semantik von PSP. Die semantischen Bereiche Z der ganzen Zahlen, Loc der Spei-
cherplätze (locations) und S der Speicherzustände werden unverändert von PSA über-
nommen. Hinzu kommt der neue Bereich der Speichertransformationen (continuations)
als semantische Werte für Prozeduren. In der Umgebung werden Prozedurbezeichnern
Speichertransformationen als Semantik zugeordnet. Die semantischen Grundbereiche
von PSP sind dementsprechend:
Speicherplätze (locations) Loc := {α1 , α2 , α3 , . . .}
Zustandsraum (states) S := {σ | σ : Loc 99K Z}
Speichertransf. (continuations) C := {θ | θ : S 99K S}
Umgebung (environment) Env := {ρ | ρ : Ide 99K Z ∪ Loc ∪ C}
Während die Ausdruckssemantiken unverändert von PSA übernommen werden können,
müssen die Deklarationssemantik und die Anweisungssemantik erweitert werden. Durch
die geschachtelten Prozeduren und Blöcke muss die Umgebung dynamisch erweitert
werden und neuer Speicherplatz für lokale Variablen bestimmt werden. Hierzu erhält
die Deklarationssemantik den Zustand als Parameter und modifiziert diesen durch die
Initialisierung neuer Variablen:

Deklarationssemantik
D :: Decl × Env × S 99K Env × S
D[[∆C ∆V ∆P ]]ρσ := D[[∆P ]](D[[∆V ]](D[[∆C ]]ρ)σ)
D[[ε]]ρσ := ρσ
D[[const I1 = Z1 ; . . . ; In = Zn ]]ρσ := ρ [I1 /Z1, . . . , In /Zn ] σ
D[[var I1 , I2 , . . . , In ]]ρσ := ρ[I1 7→ αj+1 , . . . , In 7→ αj+n ]
σ[αj+1 7→ 0, . . . , αj+n 7→ 0]
wobei j höchster Index eines belegten
Speicherplatzes in σ sei,
falls σ = ∅, sei j = 0
D[[proc I1 ; B1 ; . . . proc In ; Bn ; ]]ρσ := ρ[I1 7→ θ1 , . . . , In 7→ θn ]σ

95
5. Übersetzung in Zwischencode (Synthesephase)

Dabei sei für 1 ≤ i ≤ n: θi (σ) := BL[[Bi ]]ρ[I1 7→ θ1 , . . . , In 7→ θn ]σ. Dies definiert eine
static scope“-Semantik, da ρ die Deklarationsumgebung der Prozeduren ist.

Eine Variablendeklaration hat den Seiteneffekt, dass die neuen Speicherplätze mit Null
initialisiert werden. Dies dient der Feststellung freier Speicherplätze.

Anweisungssemantik

C :: Cmd × Env × S 99K S

Die Semantik der Anweisungen wird lediglich um die Semantik für einen Prozeduraufruf
erweitert: Es gilt:
C[[I()]]ρσ := θ(σ) falls ρ(I) = θ ∈ C
Hier ist ρ die Aufrufumgebung von I(), während ρ(I) in der Deklarationsumgebung von
I berechnet wurde. Wollte man eine dynamic scope“-Semantik definieren, so müsste

man in der Aufrufumgebung den Aufruf I() durch den Prozedurrumpf ersetzen.

Blocksemantik

BL :: Block × Env × S 99K S

BL[[∆Γ]]ρσ := C[[Γ]](D[[∆]]ρ σ)
Die Blocksemantik entspricht der Programmsemantik von PSA, nur dass beim Betreten
eines Blocks die Umgebung nicht leer ist, sondern bereits mit umgebendem Kontext
belegt. Die Umgebung wird durch lokale Deklarationen erweitert und die Anweisungen
damit ausgeführt.

Programmsemantik

M :: P rog × Zn 99K Zn

Die Programmsemantik wird so erweitert, dass die Ein-/Ausgabevariablen in der An-


fangsumgebung platziert werden und im Anfangszustand mit den Eingabewerten belegt
werden.

M[[in/out I1 , . . . , In ; B]](z1 , . . . , zn ) := (σ(α1 ), . . . , σ(αn ))


mit σ := BL[[B]] ρ∅ [I1 7→ α1 , . . . , In 7→ αn ] σ∅ [α1 7→ z1 , . . . , αn 7→ zn ]
| {z }| {z }
Anfangsumgebung Anfangszustand

5.2.2 Zwischencode für PSP


Für die Ausführung eines Prozeduraufrufs muss es möglich sein, zusätzlichen Spei-
cherplatz für die lokalen Variablen anzulegen. Nach Beendigung des Aufrufs kann die-
ser Speicherplatz wieder freigegeben werden. Die Folge der Prozeduraufrufe ist lauf-
zeitabhängig und wegen der Rekursion unbeschränkt. Diese Beobachtungen führen
zu einer dynamischen Speicherverwaltung mit einem Laufzeit- oder Prozedurkeller.
Schwierig ist dabei vor allem die Verwaltung der Aufrufumgebung zur Realisierung
des static scope“-Prinzips. Auf dem Prozedurkeller wird eine statische Verweiskette

96
5.2 Übersetzung von Blöcken und Prozeduren

angelegt, über die der Zugriff auf globale Variablen erfolgt. Die abstrakte Maschine
MA aus dem vorigen Abschnitt wird daher so modifiziert, dass der Hauptspeicher als
Prozedurkeller verwaltet wird. Die neue Maschine nennen wir entsprechend MP . MP
ist eine abstrakte maschine mit einem Datenkeller und einem Prozedurkeller, die in
einem vorher reservierten Speicherbereich aufeinander zuwachsen.
Der Zustandsraum ZR der MP hat die Gestalt ZR = BZ × DK × P K mit

BZ := N (Befehlszähler)
DK := Z∗ (Datenkeller mit Zahlen, Kellerspitze rechts)
P K := Z∗ (Prozedurkeller mit Kellerspitze links)

Ein Zustand ist ein Tripel s = (m, d, p) mit Befehlsmarke m ∈ N, Datenkellerinhalt


d = d.r : . . . : d.1 ∈ Z∗ und Prozedurkellerzustand p = p.1 : . . . : p.s ∈ Z∗ . Dabei gilt
jeweils d.j ∈ Z und p.i ∈ Z für 1 ≤ i ≤ r und 1 ≤ j ≤ s. p zerfällt in Aktivierungsblöcke
der Form
[sv : dv : ra : l1 : . . . : lk ]

wobei:

sv = statischer Verweis: die Deklarationsumgebung


dv = dynamischer Verweis: die Aufrufumgebung
ra = Rücksprungadresse: ein Befehlszähler
li = lokale Variablen des Prozeduraufrufs.

Der statische Verweis muss bei jedem Erzeugen eines Aktivierungsblocks anhand der
Differenz zwischen Aufruf- und Deklarationsniveau, die die Länge der Verweiskette
angibt, bestimmt werden. Als Hilfsfunktion wird die folgende Funktion base definiert,
die bezüglich der Niveaudifferenz den Beginn der Deklarationsumgebung bestimmt:

base : P K × N → N
(p, 0) 7→ 1
(p, d + 1) 7→ base(p, d) + p.base(p, d)

An p.base(p, d) steht dabei jeweils der statische Verweis sv vom vorherigen Aufruf. Die
Niveaudifferenz gibt an, wie oft man den statischen Verweisen folgen muss, um zur
Deklarationsumgebung zu gelangen. Falls d = 0 ist die Aufrufumgebung die Deklara-
tionsumgebung. Falls d + 1 > 0 ermittelt man rekursiv die Deklarationsumgebung für
das Level d und folgt dann dem statischem Verweis, d.h. dem ersten Eintrag des ent-
sprechenden Aktivierungsblocks, p.base(p, d) um den Aktivierungsblock der Umgebung
mit dem Level d + 1 unterhalb der aktuellen Umgebung zu finden.

Beispiel: Wir betrachten erneut das PSP-Beispielprogramm:

97
5. Übersetzung in Zwischencode (Synthesephase)

in/out X;
const C = 10;
var Y;
proc A;
var Y, Z;
proc B;
var X,Z;
[... A() ...]
[... B() ... D() ...]
proc D;
[... A() ...]
[... A() ...].

Bei der Ausführung des Programms hat der Prozedurkeller nach dem zweiten Aufruf
von A die folgende Struktur:

?
? ? ?
? ? ? ?
15 4 5 4 5 4 4 3 0 0 0 12
sv dv ra Y Z sv dv ra X Z sv dv ra Y Z sv dv ra Y sv dv ra X
A() B() A() Main I/O

Der zweite Aufruf von A hat eine Niveaudifferenz von 2 zum Deklarationsniveau der
Prozedur A. Der statische Verweis des obersten Aktivierungsblocks wurde somit wie
folgt berechnet:
base(p, 0) = 1
base(p, 1) = 1 + p.1 = 6
base(p, 2) = 6 + p.6 = 11
Es folgt:
sv = base(p, 2) + |{z}
2 + |{z}
2 = 15.
dv,ra lv
/

MP-Befehle Die arithmetischen und logischen MA-Befehle sowie die Sprungbefeh-


le der MA werden in der MP unverändert übernommen. Hinzu kommen Befehle zur
Verwaltung des Prozedurkellers sowie neue Transfer-Befehle zum Laden und Speichern
von Variablen.

Prozedurkellerbefehle
• CALL (ca, diff, lv) erzeugt einen Aktivierungsblock mit Platz für lv lo-
kalen Variablen auf dem Prozedurkeller und springt zum Codeanfang ca des
Prozedurrumpfes. diff sei die Differenz zwischen Aufruf- und Deklarations-
niveau der Prozedur.

98
5.2 Übersetzung von Blöcken und Prozeduren

C[[CALL(ca, diff , lv)]](m, d, p)


:= (ca, d, (base(p, diff ) + 2 + lv) : lv + 2 : m + 1 : 0 : . . . : 0 : p)
| {z } | {z } | {z } | {z }
sv dv ra lv lok.Vars
| {z }
neuer Aktivierungsblock

Der statische Verweis wird beim Neuanlegen eines Aktivierungsblocks zu


einem Prozeduraufruf mit Niveaudifferenz d und lv lokalen Variablen auf
den Wert
sv = base(p, dif f ) + 2 + lv
gesetzt.
Der dynamische Verweis ergibt sich zu dv = lv +2, während als Rücksprung-
adresse die Adresse m + 1 des auf den Prozeduraufruf folgenden Befehls
gespeichert wird.
• RET löscht den obersten Aktivierungsblock und kehrt zur Aufrufstelle (Rück-
sprungadresse) zurück.

C[[RET]](m, d, p.1 : . . . : p.t) := if t ≥ 2 + p.2


then (p.3, d, p.(p.2 + 2) : . . . : p.t)

Transportbefehle dienen zum Laden und Speichern von Variablenwerten vom Pro-
zedurkeller auf den Datenkeller bzw. vom Datenkeller in den Prozedurkeller.
Der Zugriff auf Variablen erfolgt wegen der dynamischen Speicherverwaltung re-
lativ. Die Lade- und Speicherbefehle erhalten als Parameter die Niveaudifferenz
diff zwischen Auftreten und Deklaration der Variablen sowie den Offset off als
relative Adresse im Aktivierungsblock.

• LOAD (diff,off)
C[[LOAD(diff , off )]](m, d, p) := (m + 1, d : p.(base(p, diff ) + 2 + off ), p)
• STORE (diff,off)
C[[STORE(diff , off )]](m, d : z, p) := (m + 1, d, p[base(p, diff ) + 2 + off 7→ z])

Die Menge der MP-Programme sei analog zu der Menge der MA-Programme definiert.

5.2.3 Übersetzung von PSP-Programmen in MP-Code


Wir modifizieren die Funktion trans aus Abschnitt 5.1.4, so dass PSP-Programme in
MP-Maschinencode übersetzt werden:

trans :: PSP-Prog → MP-Code

Die Hilfsfunktionen zur Übersetzung von Blöcken, Deklarationen, Anweisungen und


Ausdrücken erhalten den folgenden Typ:

<SyntaxBereich> × Tab
|{z} × Adr
|{z} × Lev
|{z} 99K MP-Code
Symboltabelle Anfangsadresse Blockschachtelungstiefe

99
5. Übersetzung in Zwischencode (Synthesephase)

Dabei gilt Lev := N und Adr := N. Die Symboltabelle, in der Zusatzinformationen zu


allen deklarierten Bezeichnern bereitgestellt werden, wird wie folgt modifiziert:

T ab := {st | st : Ide 99K ({const} × Z)


∪ ({var} × Lev × Off )
∪ ({proc} × Adr × Lev × Size) }.

Die Einträge für Variablen- und Prozedurdeklarationen sind neu. Zu jeder Variablende-
klaration werden das Deklarationsniveau dl ∈ Lev und die Offsetadresse der Variablen
innerhalb des Aktivierungsblocks off ∈ Off := N in der Symboltabelle gespeichert.
Für Prozeduren werden die Startadresse a ∈ Adr , das Deklarationsniveau dl ∈ Lev
und die Anzahl lokaler Variablen lv ∈ Size := N gemerkt. Die leere Symboltabelle wird
wie bisher mit st∅ bezeichnet.

Aufbau der Symboltabelle. Die Funktion

up :: Decl × T ab × Adr × Lev 99K T ab

erhält als weitere Parameter eine Anfangsadresse sowie das aktuelle Niveau. Es gilt:

up(∆C ∆V ∆P , st, a, l) := if pwv(∆C ∆V ∆P ) (Bezeichner paarw. versch.)


then up(∆P , up(∆V , up(∆C , st, a, l), a, l), a, l))
up(ε, st, a, l) := st
up(const I1 = Z1 ; . . . ; In = Zn , st, a, l)
:= st[I1 /(const, Z1 ), . . . , In /(const, Zn )]
up(var I1 , . . . , In , st, a, l) := st[I1 /(var, l, 1), . . . , In /(var, l, n)]
up(proc I1 ; B1 ; . . . ; proc In ; Bn , st, a, l)
:= st[ I1 /(proc, a1 , l, size(B1 )), . . . ,
In /(proc, an , l, size(Bn ))]

Die Hilfsfunktion size : Block → N bestimme die Anzahl der lokal deklarierten Varia-
blen in einem Block. Statt einer direkten Adressberechnung mittels einer Codelängen-
funktion verwenden wir symbolische Adressen a1 , . . . , an für die Anfangsadressen der
Prozedurcodes.

Ein-/Ausgabe-Initialisierung. Sei P = in/out I1 , . . . , In ; B.


Es gilt M[[P ]] : Zn 99K Zn . Für (z1 , . . . , zn ) ∈ Zn wird der Anfangszustand

(1, ε, 0 : 0 : 0 : z1 : . . . : zn )

mit Startadresse 1 des MP-Codes, leerem Datenkeller ε und I/O-Block auf dem Proze-
durkeller festgelegt. Bei der Übersetzung wird entsprechend die folgende Anfangssym-
boltabelle verwendet:

stI/O (Ij ) = (var , 0, j) für 1 ≤ j ≤ n.

100
5.2 Übersetzung von Blöcken und Prozeduren

Start der Übersetzung.


trans(in/out I1 , . . . , In ; B)
:= 1 : CALL (aΓ , 0, size(B)); {Hauptprogramm aufrufen}
2 : JMP 0 {STOP}
bt(B, stI/O , aΓ , 1)

wobei aΓ die Startadresse des Anweisungscodes


von B = ∆Γ sei.

Blockübersetzung.
bt(∆Γ, st, a, l) := dt(∆, up(∆, st, a1 , l), a1 , l);
ct(Γ, up(∆, st, a1 , l), a, l);
a0 : RET
Dabei erzeugt dt Code für die Prozedurrümpfe in ∆ und ct erzeugt mit der angegebenen
Startadresse den Code für den Anweisungsteil des Blocks. Dieser Code endet mit einem
Rücksprung.

Übersetzung von Prozeduren.


dt(∆C ∆V ∆P , st, a, l) := dt(∆P , st, a, l)
mit
dt(ε, st, a, l) := ε
dt(proc I1 ; B1 ; . . . ; proc In ; Bn , st, a, l)
:= bt(B1 , st, a1 , l + 1)
..
.
bt(Bn , st, an , l + 1)
wobei st(Ij ) = (proc, aj , . . .) für 1 ≤ j ≤ n
Hierbei ist zu beachten, dass die Blockübersetzungsfunktion bt die Funktionen dt und
up mit dem gleichen Adressparameter aufruft und beide Funktionen aus diesem in
gleicher Weise die Adressen für die Prozedurrümpfe erzeugen. Außerdem sei darauf
hingewiesen, dass der Levelzähler in den Aufrufen von bt hier inkrementiert wird.

Übersetzung von Anweisungen. Die Funktion ct wird gegenüber der entsprechen-


den Funktion für die Übersetzung von PSA-Programmen nur in den folgenden Fällen
geändert:
ct(I := E, st, a, l) := et(E, st, a, l);
a0 : STORE (l − dl, off )
falls st(I) = (var, dl, off )
ct(I(), st, a, l) := a0 : CALL (ca, l − dl, lv)
falls st(I) = (proc, ca, dl, lv)

101
5. Übersetzung in Zwischencode (Synthesephase)

Übersetzung von Ausdrücken. Die Funktion et muss nur für Bezeichner neu de-
finiert werden:
(
a:LIT z falls st(I) = (const, z)
et(I, st, a, l) :=
a:LOAD(l − dl, off ) falls st(I) = (var, dl, off )

Beispiel: Gegeben sei das folgende Programm:


Bezeichne ∆Γ den Hauptblock des Pro-
in/out X; gramms und ∆F ΓF den Block der Prozedur
var E; F.
proc F; Dann gilt stI/O (X) = (var, 0, 1) und
if 1<X then begin trans(in/out X; ∆Γ)
E:=E*X; = 1 : CALL (aΓ , 0, 1);
X:=X-1; 2 : JMP 0;
F() bt(∆Γ, stI/O , aΓ , 1).
end;
bt(∆Γ, stI/O , aΓ , 1)
E:=1;
F(); = dt(∆, up(∆, stI/O , a1 , 1), a1 , 1)
X:=E. ct(∆, up(∆, stI/O , a1 , 1), aΓ , 1)
a2 : RET
Die Symboltabelle für die Übersetzung der Prozedur F und des Hauptanweisungsteils
Γ ergibt sich zu:

up(∆, stI/O , a1 , 1) = stI/O [E 7→ (var, 1, 1), F 7→ (proc, a11 , 1, 0)] .


| {z }
st

Damit folgt:
dt(∆, st, a1 , 1) = bt(∆F ΓF , st, a11 , 2);
= ct(ΓF , st, a11 , 2)
a3 : RET
und
ct(Γ, st, aΓ , 1) = aΓ : LIT 1; .
STORE (0, 1);
CALL (a11 , 0, 0);
LOAD (0, 1);
STORE (1, 1);
Es bleibt die Übersetzung der Prozedur F:
ct(ΓF , st, a11 , 2) = et(1 < X, st, a11 , 2);
a4 : JPFALSE a5 ;
ct(begin . . . end, st, a4 + 1, 2)
a5 :
Es gilt:

102
5.2 Übersetzung von Blöcken und Prozeduren

et(1 < X, st, a11 , 2) = a11 : LIT 1 und


LOAD (2, 1)
LESS
ct(begin . . . end, st, a4 + 1, 2) = = ct(E := E ∗ X, st, a4 + 1, 2)
ct(X := X − 1, st, a6 , 2)
ct(F(), st, a7 , 2)
= a4 + 1 : LOAD (1, 1);
LOAD (2, 1);
MULT;
STORE (1, 1);
LOAD (2, 1);
LIT 1;
SUB;
STORE (2, 1);
CALL (a11 , 1, 0)
Das Ergebnis der Übersetzung ist damit das folgende MP-Programm:

trans(in/out X; ∆Γ)
= 1: CALL (17, 0, 1);
2: JMP 0;
a11 = 3 : LIT 1;
4: LOAD (2, 1);
5: LESS;
a4 = 6 : JPFALSE 16;
7: LOAD (1, 1);
8: LOAD (2, 1);
9: MULT;
10 : STORE (1, 1);
11 : LOAD (2, 1);
12 : LIT 1;
13 : SUB;
14 : STORE (2, 1);
15 : CALL (3, 1, 0);
a3 = a5 = 16 : RET;
aΓ = 17 : LIT 1;
18 : STORE (0, 1);
19 : CALL (3, 0, 0);
20 : LOAD (0, 1);
21 : STORE (1, 1);
a2 = 22 : RET.

103
5. Übersetzung in Zwischencode (Synthesephase)

m ∈ BZ d ∈ DK p ∈ PK
1 ε 0:0:0:3
17 ε 4:3:2:0:0:0:0:3
18 1 4:3:2:0:0:0:0:3
19 ε 4:3:2:1:0:0:0:3
3 ε 3 : 2 : 20 : 4 : 3 : 2 : 1 : 0 : 0 : 0 : 3
4 1 3 : 2 : 20 : 4 : 3 : 2 : 1 : 0 : 0 : 0 : 3
5 1:3 3 : 2 : 20 : 4 : 3 : 2 : 1 : 0 : 0 : 0 : 3
6 1 3 : 2 : 20 : 4 : 3 : 2 : 1 : 0 : 0 : 0 : 3
7 ε 3 : 2 : 20 : 4 : 3 : 2 : 1 : 0 : 0 : 0 : 3
8 1 3 : 2 : 20 : 4 : 3 : 2 : 1 : 0 : 0 : 0 : 3
9 1:3 3 : 2 : 20 : 4 : 3 : 2 : 1 : 0 : 0 : 0 : 3
10 3 3 : 2 : 20 : 4 : 3 : 2 : 1 : 0 : 0 : 0 : 3
11 ε 3 : 2 : 20 : 4 : 3 : 2 : 3 : 0 : 0 : 0 : 3
12 3 3 : 2 : 20 : 4 : 3 : 2 : 3 : 0 : 0 : 0 : 3
13 3:1 3 : 2 : 20 : 4 : 3 : 2 : 3 : 0 : 0 : 0 : 3
14 2 3 : 2 : 20 : 4 : 3 : 2 : 3 : 0 : 0 : 0 : 3
15 ε 3 : 2 : 20 : 4 : 3 : 2 : 3 : 0 : 0 : 0 : 2
3 ε 6 : 2 : 16 : 3 : 2 : 20 : 4 : 3 : 2 : 3 : 0 : 0 : 0 : 2
4 1 6 : 2 : 16 : 3 : 2 : 20 : 4 : 3 : 2 : 3 : 0 : 0 : 0 : 2
5 1:2 6 : 2 : 16 : 3 : 2 : 20 : 4 : 3 : 2 : 3 : 0 : 0 : 0 : 2
6 1 6 : 2 : 16 : 3 : 2 : 20 : 4 : 3 : 2 : 3 : 0 : 0 : 0 : 2
7 ε 6 : 2 : 16 : 3 : 2 : 20 : 4 : 3 : 2 : 3 : 0 : 0 : 0 : 2
8 3 6 : 2 : 16 : 3 : 2 : 20 : 4 : 3 : 2 : 3 : 0 : 0 : 0 : 2
9 3:2 6 : 2 : 16 : 3 : 2 : 20 : 4 : 3 : 2 : 3 : 0 : 0 : 0 : 2
10 6 6 : 2 : 16 : 3 : 2 : 20 : 4 : 3 : 2 : 3 : 0 : 0 : 0 : 2
11 ε 6 : 2 : 16 : 3 : 2 : 20 : 4 : 3 : 2 : 6 : 0 : 0 : 0 : 2
12 2 6 : 2 : 16 : 3 : 2 : 20 : 4 : 3 : 2 : 6 : 0 : 0 : 0 : 2
13 2:1 6 : 2 : 16 : 3 : 2 : 20 : 4 : 3 : 2 : 6 : 0 : 0 : 0 : 2
14 1 6 : 2 : 16 : 3 : 2 : 20 : 4 : 3 : 2 : 6 : 0 : 0 : 0 : 2
15 ε 6 : 2 : 16 : 3 : 2 : 20 : 4 : 3 : 2 : 6 : 0 : 0 : 0 : 1
3 ε 9 : 2 : 16 : 6 : 2 : 16 : 3 : 2 : 20 : 4 : 3 : 2 : 6 : 0 : 0 : 0 : 1
4 1 9 : 2 : 16 : 6 : 2 : 16 : 3 : 2 : 20 : 4 : 3 : 2 : 6 : 0 : 0 : 0 : 1
5 1:1 9 : 2 : 16 : 6 : 2 : 16 : 3 : 2 : 20 : 4 : 3 : 2 : 6 : 0 : 0 : 0 : 1
6 0 9 : 2 : 16 : 6 : 2 : 16 : 3 : 2 : 20 : 4 : 3 : 2 : 6 : 0 : 0 : 0 : 1
16 ε 9 : 2 : 16 : 6 : 2 : 16 : 3 : 2 : 20 : 4 : 3 : 2 : 6 : 0 : 0 : 0 : 1
16 ε 6 : 2 : 16 : 3 : 2 : 20 : 4 : 3 : 2 : 6 : 0 : 0 : 0 : 1
16 ε 3 : 2 : 20 : 4 : 3 : 2 : 6 : 0 : 0 : 0 : 1
20 ε 4:3:2:6:0:0:0:1
21 6 4:3:2:6:0:0:0:1
22 ε 4:3:2:6:0:0:0:6
0 ε 0:0:0:6

Abbildung 19: Berechnungsprotokoll bei Eingabe von 3

104
5.3 PSPP: Übersetzung von parametrisierten Prozeduren

Deklarationen Decl : ∆ ::= ∆C ∆V ∆P


∆P ::= ε |
proc I (I1 , . . . , Ip ; varJ1 , . . . Jq ); B;
| {z }
Wert− und Variablenparameter
...
Anweisungen Cmd : Γ ::= I := E | I(E1 , . . . , Ep ; V1 , . . . , Vq )
| Γ1 ; Γ2
| if BE then Γ1 else Γ2
| while BE do Γ

Abbildung 20: Syntax von PSPP (nur Änderungen gegenüber PSP)

In Abbildung 19 ist das Berechnungsprotokoll für die Eingabe 3 gezeigt. /

Satz 12 Korrektheit der Übersetzung


Für jedes P ∈ PSP-Prog mit n Ein-/Ausgabevariablen und (z1 , . . . , zn ), (z10 , . . . , zn0 ) ∈
Zn gilt:

M[[P ]](z1 , . . . , zn ) = (z10 , . . . , zn0 )


⇐⇒ I[[trans (P )]](1, ε, 0 : 0 : 0 : z1 , . . . , zn ) = (0, ε, 0 : 0 : 0 : z10 , . . . , zn0 )

Der Beweis dieses Satzes wurde von M.Mohnen in der Zeitschrift Fundamentae Infor-
maticae7 publiziert.

5.3 PSPP: Übersetzung von parametrisierten Prozeduren


In diesem Abschnitt wird die Sprache PSP um Prozeduren mit Parametern zu der
Sprache PSPP (Programmiersprache mit parametrisierten Prozeduren) erweitert. In
Abbildung 20 sind die syntaktischen Erweiterungen von PSPP gegenüber PSP ange-
geben. In den Prozedurdeklarationen werden formale Wert- und Variablenparameter
eingeführt. Prozeduraufrufe werden entsprechend um aktuelle Parameterausdrücke er-
weitert.
Semantisch werden die formalen Prozedurparameter wie (in der Umgebung des Proze-
durrumpfs) lokal deklarierte Variablen behandelt, während in Prozeduraufrufen Wert-
parameter als lokale Variable (mit einem neuen Speicherplatz) und Variablenparameter
durch den vorhandenen Speicherplatz aktualisiert werden. Hierbei wird ein Zeiger an-
gelegt. Auf eine formale Spezifikation wird hier verzichtet.
7
M. Mohnen: A Compiler Correctness Proof for the Static Link Technique by means of Evolving
Algebras, Fundamenta Informaticae 29 (1997) pp. 257–303.

105
5. Übersetzung in Zwischencode (Synthesephase)

5.3.1 Zwischencode für PSPP


Im folgenden wird ein realistischeres Maschinenmodell MPP mit nur noch einem Keller
für Daten und Aktivierungsblöcke vorgestellt. In den Aktivierungsblöcken wird zusätz-
licher Speicherplatz für aktuelle Parameter benötigt.
Der Zustandsraum von MPP besteht aus einem Keller, einem Instruktionszähler, zwei
Zeigern in den Keller sowie einem Indexregister, das zur Berechnung statischer Verweise
verwendet wird.

ZRM P P := STACK × IC × SP × FP × R,
wobei

• STACK := [SAdr → Z]; SAdr := Z.

• IC := Adr (instruction counter)

• SP := FP := SAdr (stack pointer/frame pointer)

• R := SAdr Indexregister

Abbildung 21 visualisiert den Zustandsraum und den Aufbau eines Aktivierungsblocks


auf dem Keller. Der bisherige Daten- und Prozedurkeller werden zu einem einzigen Kel-
ler verschmolzen, wobei der Datenkeller oberhalb des Prozedurkellers platziert wird.
Dadurch kann insbesondere das Umspeichern zwischen Daten- und Prozedurkeller ein-
gespart werden. Der Keller wächst in Richtung niedriger Adressen. Der Kellerzeiger SP
(stack pointer) zeigt jeweils auf die Kellerspitze, während der Rahmenzeiger FP (frame
pointer) auf den obersten Aktivierungsblock zeigt und zwar auf die Zelle, in der der
dynamische Verweis gespeichert ist, d.h. der vorherige Wert von FP.
Aktivierungsblöcke werden gegenüber MP um Speicherplätze für die Wert- und Va-
riablenparameter einer Prozedur erweitert. Diese liegen zuunterst auf dem Keller,
weil sie vor der Ausführung eines Prozeduraufrufs dort bereitgestellt werden. Bei der
Ausführung eines Prozeduraufrufs werden die bisher schon verwendeten Informationen
eines Aktivierungsblocks auf dem Keller angelegt. Dazu gehören der statische Verweis,
die Rücksprungadresse, der dynamische Verweis sowie Platz für die lokalen Prozedur-
variablen.
Der Befehlssatz der MPP wird gegenüber der vorherigen Maschine MP wie folgt ange-
passt:

Arithmetisch/logische und Sprungbefehle werden aus der MA bzw. MP über-


nommen und so erweitert, dass der Stack Pointer gesetzt wird.

Keller- und Lade-/Speicherbefehle: Der LIT-Befehl wird übernommen. Die bis-


herigen Befehle
CALL(ca, diff , lv), RET, LOAD (diff , off ), STORE (diff , off )
werden durch die folgenden Befehle ersetzt:

106
5.3 PSPP: Übersetzung von parametrisierten Prozeduren

.. ↑ hohe Adressen
.
par1
)
.. aktuelle Wert- und
. Variablenparameter
parp+q

Instruction sv statischer Verweis


Counter
ra Rücksprungadresse

Frame Pointer - dv dynamischer Verweis

loc1 


Stack Pointer .. lokale Variablen
Q .
Q 
Q

Qs
Q locn

Indexregister .. ↓ niedrige Adressen


.

Abbildung 21: Zustandsraum der MPP

Befehl Bedeutung
CALL ca SP ← SP − 1; hSPi ← IC + 1; IC ← ca
RET k IC ← hSPi; SP ← SP + |k {z
+ 2}
pars+sv+ra
PUSH Z SP ← SP − 1; hSPi ← Z
PUSH FP SP ← SP − 1; hSPi ← F P
PUSH hFPi SP ← SP − 1; hSPi ← hFPi
PUSH hR + oi SP ← SP − 1; hSPi ← hR + oi
POP FP FP ← hSPi; SP ← SP + 1
POP hni Stack[n] ← hSPi; SP ← SP + 1
SUB SP,n SP ← SP − n
LOAD FP, SP FP ← SP
LOAD R, hni R ← Stack[n]
LOAD R, hR + 2i R ← hR + 2i

Während im Befehlssatz der MP der gesamte Aktivierungsblock bei der Ausführung


des CALL-Befehls angelegt wurde, arbeitet die MPP mit sehr viel einfacheren Befehlen.
Der neue CALL-Befehl legt nur noch die Rücksprungadresse auf dem Keller ab und
führt den Sprung zu dem Code-Anfang der Prozedur durch. Dementsprechend erwar-

107
5. Übersetzung in Zwischencode (Synthesephase)

tet der RET-Befehl, dass auf der Kellerspitze die Rücksprungadresse steht, die in das
Instruktionszählerregister gespeichert wird. Anschließend wird der Kellerzeiger inkre-
mentiert, was das Entfernen der Prozedurparameter, des statischen Verweises und der
Rücksprungadresse vom Keller bewirkt. Elementare PUSH- und POP-Befehle stehen
nun zur Verfügung, um den Keller zu modifizieren. Diese ersetzen auch die bisherigen
LOAD- und STORE-Befehle.
Vor einem Prozeduraufruf müssen die aktuellen Parameter sowie der statische Verweis
auf dem Keller erzeugt werden. Zur Berechnung des statischen Verweises wird das
Indexregister verwendet. Mittels der neuen LOAD-Befehle kann die Verweiskette im
Keller verfolgt werden und schließlich der aktuelle Inhalt des Indexregisters mittels eines
PUSH-Befehls auf den Keller geschrieben werden. Nach einem Prozeduraufruf muss
der Rahmenzeiger mittels PUSH als dynamischer Verweis gesichert und mittels POP
neu gesetzt werden und mittels SUB Speicherplatz für die lokalen Prozedurvariablen
reserviert werden.
Insgesamt erfolgt der Aufbau eines Aktivierungsblocks in zwei Codeabschnitten:
Code für Prozeduraufruf:

1. Berechnung der aktuellen Parameter


2. Berechnung des statischen Verweises mit dem Indexregister
3. Sprung zur Prozedur mit Ablage der Rücksprungadresse

Code für Prozedur:

4. Alten FP als dynamischen Verweis speichern


5. Speicherplatz für lokale Variablen bereitstellen

Ein PSPP-Programm P = in/out I1 , . . . , In ; B führt zu folgender Ein-/Ausgabe-


Abbildung
0 sl
0 ra
FP −→ 0 dl
(z1 , . . . , zn ) 7→ z1 7 → (z1 , . . . , zn )
..
.
SP −→ zn
..
. ↓

Die Ein-/Ausgabevariablen werden als lokale Variablen des initialen Aktivierungsblocks


behandelt. In diesem sind die Verweise und die Rücksprungadresse auf Null gesetzt.

5.3.2 Übersetzung von PSPP-Programmen


Aufgrund der neuen Befehlsstruktur der MPP ändern sich die Übersetzungsfunktionen
gegenüber der MP essentiell.

trans : PSPP-Prog 99K MPP-Code

108
5.3 PSPP: Übersetzung von parametrisierten Prozeduren

Erweiterung der Symboltabelle. Zur Behandlung von Variablenparametern wird


die Symboltabelle um entsprechende Einträge mit der Kennzeichnung vpar erweitert:

T ab := {st | st : Ide 99K ({const} × Z)


∪ ({var} × Lev × Off )
∪ ({proc} × Adr × Lev × Size)
∪ ({vpar} × Lev × Off ) }.

Wertparameter werden wie lokale Variablen behandelt. Lokale Variablen erhalten nun
negative Offsets durch den Bezug auf den Rahmenzeiger, während Wertparameter po-
sitive Offsets haben. Im Unterschied zu MP werden nun beliebige ganze Zahlen als
Offsets verwendet:
Off := Z.
Die Anfangstabelle stI/O enthält für die Ein-/Ausgabe-Variablen Ij entsprechend fol-
gende Einträge:
stI/O (Ij ) := (var, 0, −j).
Der Aufbau der Symboltabelle erfolgt wie bisher mit der Funktion up. Der einzige Un-
terschied besteht darin, dass für lokale Variablen negative Offsets eingetragen werden.
Beachten Sie, dass lokale Variablen durch up in die Symboltabelle eingetragen werden,
während dies für Parametervariablen durch die Übersetzungsfunktion dt geschieht.

Programmübersetzung. Bei der Übersetzung von Programmen wird vor dem CALL-
Befehl, der nur noch die Rücksprungadresse auf dem Keller speichert, mittels des Befeh-
les PUSH FP ein initialer statischer Verweis auf den Ein-/Ausgabe-Aktivierungsblock
auf dem Keller erzeugt.
trans(in/out I1 , . . . , In ; B) := 1 : PUSH FP %sv
2 : CALL aΓ %ra
3 : JMP 0; %ST OP
bt(B, stI/O , aΓ , 1, |{z}
0 ).
# P arameter

Die Blockübersetzungsfunktion bt erhält als zusätzlichen Parameter die Anzahl der


Prozedurparameter. Für das Hauptprogramm ist diese Null.

Blockübersetzung. Der Code für die Ausführung des Anweisungsteils eines Blocks
wird eingerahmt durch einen Entry- und Exit-Code. Im Entry-Code wird der Akti-
vierungsblock durch Anlegen des dynamischen Verweises und Reservieren von Spei-
cherplatz für die lokalen Variablen fertig gestellt. Der Befehl PUSH FP schreibt den
aktuellen Rahmenzeiger auf den Keller und lädt den aktuellen Kellerzeiger als neuen
Rahmenzeiger. Mittels des SUB-Befehls wird der Kellerzeiger nun dekrementiert, um
Platz für die lokalen Variablen zu schaffen. Beachten Sie, dass keine Initialisierung der
lokalen Variablen erfolgt. Der Exit-Code setzt den Kellerzeiger mittels LOAD SP, FP
auf die Stelle, an der der dynamische Verweis, d.h. der vorherige Inhalt von FP, steht.
Hierdurch wird der gesamte Kellerinhalt oberhalb dieser Zelle gelöscht. Der Befehl POP
FP restauriert den vorherigen Rahmenzeiger und entfernt dessen Wert vom Keller, so

109
5. Übersetzung in Zwischencode (Synthesephase)

dass nun die Rücksprungadresse auf der Kellerspitze steht. Der RET-Befehl verwendet
diese zum Rücksprung und entfernt außerdem die Parameter vom Keller. Die Anzahl
r der Parameter wird der Funktion bt als neuer Parameter übergeben. Damit kann der
RET-Befehl den gesamten restlichen Aktivierungsblock vom Keller entfernen.

bt(∆Γ, st, a, l, r) := dt(∆, up(∆, st, a1, l), a1 , l)


a : PUSH F P ;  Entry-Code:

: LOAD F P, SP ; Anlegen von dl


: SUB SP, size(∆Γ); & Platz für lok. Vars.
ct(Γ, up(∆, st, a1, l), a1 , 1),a + 3, l)
: LOAD SP, F P ;  Exit-Code:

: POP F P ; Freigabe des akt. Akt.-Block


: RET r; mit Rücksprung

Übersetzung von Prozeduren. Die Code-Erzeugung für Prozeduren wird so ver-


ändert, dass für die Variablen- und Wertparameter Einträge in der Symboltabelle vorge-
nommen werden, die die Position der Variablen im Aktivierungsblock angeben. Damit
diese Erweiterung der Symboltabelle keine Namenskonflikte erzeugt, wird durch eine
Hilfsfunktion diff id sichergestellt, dass die Variablenbezeichner pro Prozedur paarweise
verschieden sind.

dt(proc I(I1 , . . . , Ip ; var J1 , . . . Jq ); B; . . . , st, a, l)


:= if diff id (I1 , . . . , Ip , J1 , . . . Jq , B) and diff id (. . .)
then bt(B, st, ˜ a1 , l + 1, p + q)bt(. . .) . . .
where
˜ := st[ I1 /(var, l + 1, p + q + 2), . . . , Ip /(var, l + 1, p + 3),
st
J1 /(vpar, l + 1, q + 2), . . . , Jq /(vpar, l + 1, 3)]
Es gilt jeweils:
Parameterlevel ' Blocklevel ' Level lokaler Variablen

Hilfsfunktion zum Dereferenzieren der statischen Verweiskette Bei den Zu-


griffen auf Variablen muss jeweils die statische Verweiskette entlang gelaufen werden.
Dies geschieht durch eine Dereferenzierungsfunktion, die die Code-Sequenz definiert,
mit der die Verweiskette verfolgt wird. Die Funktion nimmt 4 Parameter: das Indexre-
gister R, das Rahmenregister FP, eine Zahl k, die angibt, wie oft der statische Verweis
verfolgt werden soll, und eine Codeanfangsadresse a. Nach der Ausführung dieser Se-
quenz befindet sich im Register R die Kellerposition (des statischen Verweises) des
Aktivierungsblocks, in dem sich die gesuchte Variable befindet.
deref (R, FP, k, a) := a : LOAD R, hFP + 2i;

: LOAD R, hR + 2i; 

..
. k-mal


: LOAD R, hR + 2i;

110
5.3 PSPP: Übersetzung von parametrisierten Prozeduren

Übersetzung von Anweisungen Änderungen gegenüber PSP und MP sind bei


der Übersetzung von Wertzuweisungen und Prozeduraufrufen erforderlich. Bei Wert-
zuweisungen sind auf der linken Seite auch Variablenparameter möglich. Der bisherige
STORE-Befehl wird durch den POP-Befehl ersetzt. Falls sich die Variable auf der lin-
ken Seite der Wertzuweisung im aktuellen Aktivierungsblock befindet (l = dl) wird das
Ergebnis der Ausdrucksauswertung, das auf der Kellerspitze steht, mit POP entfernt
und an die Stelle der Variablen im Aktivierungsblock kopiert. Im Fall von Variablenpa-
rametern wird diese Position indirekt über das Indexregister erreicht. Bei nicht lokalen
Variablen wird der mittels der Hilfsfunktion deref erzeugte Code zur Ermittlung des
zugehörigen globalen Aktivierungsblocks verwendet.
ct(I := E, st, a, l) := et(E, st, a, l)
if type(E) = int then
if st(I) = (var, dl, o) then
if l = dl then : POP hFP + oi
else deref (R, FP, l − dl − 1, a0 )
: POP hR + oi
else if st(I) = (vpar, dl, o) then
if l = dl then : LOAD R, hFP + oi;
: POP hRi
else deref (R, FP, l − dl − 1, a00 )
: LOAD R, hR + oi;
: POP hRi
In Prozeduraufrufen sind die aktuellen Parameter der Wertparameter beliebige Aus-
drücke, während für Variablenparameter Variablen angegeben sein müssen.
ct(I(E1 , . . . , Ep ; V1 , . . . , Vq ), st, a, l)
:= if st(I) = (proc, ca, dl, lv)
and type(Ei ) = int (1 ≤ i ≤ p)
and st(Vj ) = (var, lj , oj ) (1 ≤ j ≤ q)
then

et(E1 , st, a, l) 
.. Wertparameter
. 

et(Ep , st, a, l) 
if l = l1 then : PUSH (FP + o1 ) 

0 

else deref (R, FP, l − l1 − 1, a ) 

: PUSH (R + o1 ) Variablenparameter
.. 

. 




(* analog für j = 2 . . . q *) 
if l = dl then : PUSH FP 

00
else deref (R, FP, l − dl − 1, a ) statischer Verweis


: PUSH R;
CALL ca;

111
5. Übersetzung in Zwischencode (Synthesephase)

Die aktuellen Ausdrucksparameter werden nacheinander ausgewertet. Die Ergebnisse


bleiben in der Auswertungsreihenfolge auf dem Keller stehen. Anschließend werden für
die Variablenparameter Zeiger auf dem Keller erzeugt. Schließlich wird der statische
Verweis für den Prozeduraufruf auf dem Keller erzeugt. Damit ist der Teil des Aktivie-
rungsblocks, der vor dem Prozeduraufruf erzeugt wird, auf dem Keller fertiggestellt.
Der Code wird mit dem eigentlichen Prozeduraufruf durch CALL abgeschlossen. Der
CALL-Befehl legt auf dem Keller die Rücksprungadresse ab. Der noch fehlende Teil
des Aktivierungsblocks wird durch den für die Prozedur erzeugten Code generiert.
Der Fall von Variablenparametern (st(Vj ) = (vpar, lj , oj )) wird ähnlich behandelt.

Übersetzung von Ausdrücken In der Übersetzungsfunktion et muss die Überset-


zung von Bezeichnern angepasst werden. An die Stelle des bisherigen LOAD-Befehls
tritt der PUSH-Befehl. Die Bestimmung des passenden Aktivierungsblock erfolgt für
lokale Variablen mittels des Rahmenzeigers F P , während für globale Variablen eine ex-
plizite Dereferenzierung vorgenommen wird. Für Variablenparameter erfolgen indirekte
Zugriffe.

et(I, st, a, l) := if st(I) = (const, Z) then PUSH Z;


else if st(I) = (var, dl, o) then
if l = dl then a : PUSH hFP + oi
else deref (R, FP, l − dl − 1, a0 )
: PUSH hR + oi
else if st(I) = (vpar, dl, o) then
if l = dl then : LOAD R, hFP + oi;
: PUSH hRi
else deref (R, FP, l − dl − 1, a00 )
: LOAD R, hR + oi;
: PUSH hRi

Beispiel: Gegeben sei das folgende Programm:

in/out X; Das Programm definiert dieselbe Ein-


var E; proc F(X;var V); /Ausgabeabbildung wie das auf Seite 102
if 1<X then begin angegebene PSP-Beispielprogramm. Statt
V:=V*X; globale Variablen zu verwenden, wird die
F(X-1;V) Prozedur F hier allerdings mit Parametern
end; definiert. Beachten Sie die Verwendung der
E:=1; Ein-/Ausgabevariablen X als Wert- und der
F(X,E); Hauptprogrammvariablen E als Variablen-
X:=E. parameter.

Mit den Übersetzungsfunktionen wird das folgende MPP-Programm erzeugt:

112
5.3 PSPP: Übersetzung von parametrisierten Prozeduren

1: PUSH FP; % Aufruf des Hauptprogramms


2: CALL 26;
3: JMP 0;
4: PUSH FP; % Entry-Code von F
5: LOAD FP, SP;
6: SUB SP, 0;
7: LIT 1; % Übersetzung des Rumpfes
8: PUSH hFP+4i; % Lade X
9: LESS;
10: JPFALSE 23;
11: LOAD R, hFP+3i;
12: PUSH hRi; % Zugriff auf V
13: PUSH hFP+4i; % Lade X
14: MULT;
15: LOAD R, hFP+3i;
16: POP hRi; % Zuweisung an V
17: PUSH hFP+4i;
18: LIT 1;
19: SUB; % Wertparameter X−1
20: PUSH hFP+3i; % Variablenparameter V
21: PUSH FP; % statischen Verweis speichern
22: CALL 4; % rekursiver Aufruf von F
23: LOAD SP, FP; % Exit-Code von F
24: POP FP;
25: RET 2;
26: PUSH FP; % Entry-Code des Hauptprogramms
27: LOAD FP, SP;
28: SUB SP, 1;
29: LIT 1; % Anweisungsteil des Hauptprogramms
30: POP hFP-1i; % Zuweisung an E
31: LOAD R, hFP+2i;
32: PUSH hR−1i; % Wertparameter X
33: PUSH (FP−1); % Variablenparameter E
34: PUSH FP; % statischen Verweis anlegen
35: CALL 4; % Aufruf von F
36: PUSH hFP-1i;
37: LOAD R,hFP+2i;
38: POP hR-1i; % Zuweisung an X
39: LOAD SP, FP; % Exit-Code des Hauptprogramms
40: POP FP;
/
41: RET 0;
113
6. Codeoptimierung

6 Codeoptimierung
6.1 Gleitfenster-Optimierungen
Unter Gleitfenster-Optimierungen (engl. peephole - Schlüsselloch) versteht man eine
sehr einfache, aber effektive Optimierungsform. Mit einem Gleitfenster wird jeweils ein
Ausschnitt meist aufeinanderfolgender Instruktionen betrachtet. Dabei wird versucht,
für die jeweils im Gleitfenster befindlichen Instruktionen eine kürzere oder schnellere
Codefolge zu bestimmen. Typische Peephole-Optimierungen sind:

1. Elimination redundanten Codes

• aufeinanderfolgende LOAD/STORE-Anweisungen LOAD X; STORE X kann


vollständig eliminiert werden. In LOAD R1, X; STORE R1, X ist die STORE-
Anweisung überflüssig, falls diese kein Sprungziel ist.
Ist die Sequenz STORE X; LOAD X zu vereinfachen?
• unerreichbarer Code
Unerreichbar sind beispielsweise nicht markierte Befehle nach unbedingten
Sprüngen: goto Loop; OP.
Steht bei Verzweigungen das Ergebnis der Bedingung fest, so kann der Code
für die nicht gewählte Alternative eliminiert werden:
LIT True;JPFALSE L2; <code> L2: ... kann zu <code> vereinfacht wer-
den.

2. Kontrollflussoptimierungen
Eine typische Kontrollflussoptimierung ist die Komprimierung von Sprungfolgen:
JMP L1; ... L1: JMP L2; ... kann modifiziert werden zu
JMP L2; ... L1: JMP L2; .... Die mit der Sprungmarke L1 markierte Sprun-
ganweisung ist dann eventuell nicht mehr erreichbar und kann eliminiert werden.
Eine analoge Transformation ist möglich, falls der erste Sprungbefehl bedingt ist.

3. Algebraische Identitäten und Reduction in Strength“



Typische algebraische Identitäten, die bei der Codeoptimierung eingesetzt wer-
den, sind etwa: x + 0 = x, x ∗ 0 = 0 oder x ∗ 1 = x.
Ersetzt man teure Operationen durch billigere bzw. aufwendige durch einfachere,
so spricht man von einer Mächtigkeitsreduktion (engl: reduction in strength). Ty-
pische Beispiele sind die Verwendung einer speziellen Inkrementierungsoperation
anstelle der allgemeinen Addition von Eins, das Ersetzen von Multiplikation mit
Zweierpotenzen durch Shift-Anweisungen, Quadrieren statt Potenzierung mit 2,
Gleitkommadivision durch Konstante durch Gleitkommamultiplikation mit Kehr-
wert.

114
6.2 Allgemeine Analysetechniken

6.2 Allgemeine Analysetechniken


Allgemein basieren Codeoptimierungen auf der Analyse von Programmeigenschaften,
mit denen man z.B. konstante Ausdrücke, die Benutzung von Variablen, Terminations-
eigenschaften oder Striktheitseigenschaften bestimmt. Allerdings sind solche Analysen
in der Regel unentscheidbar, was unmittelbar aus der Unentscheidbarkeit des Halte-
problems sowie aus dem Satz von Rice folgt. Man begnügt sich daher in der Regel mit
einer approximativen Berechnung der gesuchten Eigenschaft E.
Man unterscheidet zwischen konservativen oder sicheren Approximationen und speku-
lativen. Konservative Approximationen nutzen die Semi-Entscheidbarkeit der meisten
nicht entscheidbaren Eigenschaften oder ihres Komplements.
E ist semi-entscheidbar. Ein einfaches Approximationsverfahren erhält man, indem
man das Semi-Entscheidungsverfahren nach einer fest vorgegebenen Schrittzahl stoppt.
Falls das Verfahren vorher terminiert, gilt die Eigenschaft E auf jedem Fall. In diesem
Fall stellt das Approximationsverfahren die Eigenschaft fest (Antwort ja). Falls das
Verfahren abgebrochen wird, weil die Schrittzahl erreicht wird, so kann die Eigenschaft
gelten (Entscheidung erfordert mehr Schritte) oder auch nicht (Verfahren terminiert
nicht). Das Approximationsverfahren antwortet nein. Im Neinfall, kann die Eigenschaft
also trotzdem gelten.

ja  
nein

E  ¬E

 


Ist ¬E semi-entscheidbar, so kann eine entsprechende sichere Approximation von ¬E


erfolgen. Falls die Approximation nein ausgibt, gilt E definitiv nicht. Sonst kann keine
Aussage gemacht werden.

ja 
A nein
A
A
E A
¬E

 A
A

Eine Approximation, die nie ein definitives Ergebnis liefert, ist trivialerweise sicher,
aber natürlich ein sinnloser Grenzfall. Man sollte stets bemüht sein, möglichst genaue
Approximationen zu entwickeln.
Spekulative Approximationen werden häufig bei Verifikationen eingesetzt. Hier gilt:
Nicht gemeldete Fehler sind schlimmer als gemeldete Nicht-Fehler“.

115
6. Codeoptimierung

ja   nein

E  ¬E

 


Für Compileroptimierungen kommen nur sichere Approximationen in Frage. Die Ap-


proximation setzt dabei Grenzen für die Leistungsfähigkeit von Optimierungen. Das
heißt, dass optimierte Programme in der Regel nicht wirklich optimal sind, weil nicht
alle Verbesserungsmöglichkeiten erkannt werden können. In der Praxis treten auch
Fälle auf, in denen optimierte Programme nicht einmal besser sind.
Beispiel: Unter Codeverschiebung (engl.: code motion) versteht man die Verschie-
bung von Anweisungen, beispielsweise aus Schleifenrümpfen.
for i:=1 to n do h := d+3;
h := d+3; for i:=1 to n do
y
a[i] := h+i a[i] := h+i
end end
Weil die Anweisung h := d+3 unabhängig von der Schleifenvariablen i ist, kann die
Berechnung aus der Schleife gezogen werden.
Aber Vorsicht: Falls n = 0 führt das optimierte Programm die herausgezogene An-
weisung trotzdem durch. Dies kann unter Umständen sogar fehlerhaft sein (fälschliche
Modifikation von h. /
Codeoptimierungen werden meist auf der Zwischencode-Ebene durchgeführt. Ein fle-
xibler, häufig betrachteter Zwischencode ist der Drei-Adress-Code.

6.3 Drei-Adress-Code
Die meisten heutigen Maschinen sind Registermaschinen, so dass als Zwischencode
meist sog. Drei-Adress-Code statt der Datenkellertechnik verwendet wird. In einem
Drei-Adress-Code sind die elementaren Anweisungen Zuweisungen der Form
x := y op z.
x, y und z sind drei Adressen bzw. Variablenbezeichner. Sie sind Exemplare eines unbe-
grenzten Registervorrats. Auf den rechten Zuweisungsseiten treten keine geschachtelten
Ausdrücke auf. Alle Zwischenergebnisse von komplexen Ausdrücken sind im Code als
Zuweisung erkennbar.
6.1 Definition (Drei-Adress-Anweisungen und -Programme)
Seien Var eine Menge von Variablenbezeichnern, Lab eine Menge von Sprungmar-
ken, Proc eine Menge von Prozedurbezeichnern und Op eine Menge von binären
und unären Operationssymbolen.
Die Menge der Drei-Adress-Anweisungen Instr enthält

116
6.3 Drei-Adress-Code

• Zuweisungen 
x := y op z 

x := op y (x, y, z ∈ Var, op ∈ Op)


x := y

• Sprünge )
goto l
(x, y ∈ Var, op ∈ Op, l ∈ Lab)
if x op y goto l

• Prozeduraufrufe

call p, x1 , . . . , xn 



call p, x1 , . . . , xn → y
(p ∈ Proc, x1 , . . . , xn , y, x ∈ Var
return 



return x

Sei LInstr := {l : i | l ∈ Lab, i ∈ Instr} die Menge der Anweisungen mit Marke.
Die Menge Prog der Drei-Adress-Programme ist definiert durch:

Prog := Pfin ({(p, w) | p ∈ Proc, w ∈ (Instr ∪ LInstr )∗ })

M ∈ Prog mit M = {(p1 , w1 ), . . . , (pn , wn )} heißt zulässig, falls gilt:

1. für jeden in wj (1 ≤ j ≤ n) auftretenden Prozedurbezeichner p existiert genau


ein i mit 1 ≤ i ≤ n und p = pi .
2. für jede in wj = i1 . . . im auftretende Sprungmarke l existiert genau ein k mit
1 ≤ k ≤ m und ik = l : i0k .

Zur Unterscheidung lokaler und globaler Variablen sowie Prozedurparametern werden


die folgenden Bezeichnungskonventionen vereinbart:

• Prozedurparameter: a, a0 , a1 . . .

• globale Variablen: g, g0 , g1 . . .

• lokale Variablen: alle anderen Bezeichner

Op enthalte neben den arithmetischen und logischen Operationen auch Operationen


für indizierten (Array-)Zugriff a[i], Zeigerdereferenzierung ∗y und Adressberechnung
&x.

Beispiel: Ein Drei-Adress-Programm zur Multiplikation von Zahlen ist etwa gegeben
durch

{(add, wadd ), (mult, wmult )},

117
6. Codeoptimierung

wobei wadd = if a1 == 0 goto L1 und wmult = r := 0 /


x := a1 − 1 L1 : if a1 > 0 goto L2
call add x, a2 → r goto L3
r := r + 1 L2 : call add r, a2 → r
goto L2 a1 := a1 − 1
L1 : r := a2 goto L1
L2 : return r L3 : return r

Eine Übersetzung von Drei-Adress-Code in MPP-Code ist sehr einfach. Die Überset-
zung von PSPP (ohne Variablenparameter) in Drei-Adress-Code erfordert
• das Entschachteln von Ausdrücken
E1 op E2 hCode für E1 mit Resultat in x1 i
hCode für E2 mit Resultat in x2 i
x3 := x1 op x2
• das Flachklopfen von Prozeduraufrufen
p(E1 , . . . , En ) hCode für E1 mit Resultat in x1 i
..
.
hCode für En mit Resultat in xn i
call p, x1 , . . . , xn

• die Übersetzung von Verzweigungen und Schleifen mit Sprungbefehlen.

Beispiel: Die Übersetzung der folgenden C-Prozedur mult ergibt den oben angege-
benen Drei-Adress-Code:

int mult (int a2, int a2)


{ int r = 0;
while (a1 > 0) {
r = add(r, a2);
a1--;
};
return r;
}

6.4 Basisblockdarstellung und Datenflussgraphen


Ein Nachteil des Drei-Adress-Codes oder jedes Zwischencodes ist der implizite Kontroll-
fluss durch gotos. Für Analysen und Optimierungen ist die Kenntnis des Kontrollflusses
essentiell. Daher werden Zwischencodeprogramme oft als Graphen dargestellt. Die Kno-
ten werden mit Sequenzen unverzweigten Codes beschriftet, sogenannten Basisblöcken.
Die Kanten beschreiben den expliziten Kontrollfluss.

118
6.4 Basisblockdarstellung und Datenflussgraphen

6.2 Definition Sei w ∈ (Instr ∪ LInstr)∗ .


Die Basisblockdarstellung bzw. der Datenflussgraph von w ist ein beschrifteter, ge-
richteter Wurzelgraph
G(w) = (N, E, r, σ)
mit

• Knotenmenge N ⊇ {l ∈ Lab | l kommt in w vor.}


• Kantenmenge E ⊆ N × N
• Wurzel r ∈ N
• Beschriftung σ : N → Instr ∗ ,

wobei gilt:

1. Das Abwickeln der Graphen ergibt w, bis auf das Vertauschen unabhängiger
Blöcke und das Hinzufügen zusätzlicher Label:

r : σ(r); l1 : σ(l1 ); . . . ; ln : σ(ln ) ∼


=w

2. Jedes σ(l) = i1 . . . in ist ein Basisblock, d.h. nur der letzte Befehl darf ein
Sprungbefehl sein:

i1 , . . . , in−1 ∈ Instr \ {goto l, if x op y goto l, return }


in ∈ Instr

Sei P ∈ P rog. Die Basisblockdarstellung von P ist

G(P ) = {(p, G(w)) | (p, w) ∈ P }.

Beispiel: Basisblockdarstellung von mult:

r := 0

? ?
L1 : if a1 > 0 goto L2
 A
 A
 AU

L2 : call add, r, a2 → r goto L3


a1 := a1 − 1
?
goto L1 L3 : return r

119
6. Codeoptimierung

Basisblöcke haben die folgenden wichtigen Eigenschaften:


• Der Kontrollfluss innerhalb eines Blocks ist linear.

• Es gibt keine Sprünge innerhalb von Basisblöcken. Nur am Ende ist ein Sprung
zugelassen, aber nicht notwendig.

• Es gibt keine Sprünge von anderen Basisblöcken in das Innere eines Basisblocks,
sondern nur an den Anfang.
Sprünge sind bedingte oder unbedingte Sprungbefehle und Prozedurrücksprünge. Pro-
zeduraufrufe werden nicht als Sprünge betrachtet, damit Basisblöcke möglichst groß
und die Graphen entsprechend klein werden. Da Prozeduren in der Regel am Ende
ihrer Ausführung die Kontrolle zurückgeben, können sie als komplexe Operationen ge-
sehen werden. Dies ist natürlich nur legitim, wenn die Prozeduren seiteneffektfrei sind.
Ansonsten müssen auch Prozeduraufrufe wie Sprünge behandelt werden.
Man bestimmt die Basisblockdarstellung zu einem Drei-Adress-Programm wie folgt:
1. Bestimme zunächst alle Instruktionen, die Anfänge von Basisblöcken sind. Man
nennt diese Instruktionen Leader.
Die Menge der Leader umfasst

• die ersten Anweisungen von Prozeduren


• alle Instruktionen, die Sprungziel sind
• alle Anweisungen unmittelbar nach Sprüngen.

2. Zu jedem Leader wird der zugehörige Basisblock konstruiert. Dieser umfasst die
Folge aller Anweisungen bis zum nächsten Leader oder bis zum Ende einer Pro-
zedur.

3. Die Kanten zwischen den Basisblöcken werden entlang der Sprünge und sequen-
tiellen Fortsetzungen nach bedingten Sprüngen eingefügt.
Dieses Verfahren liefert einen Basisblockgraphen mit minimaler Knotenzahl. Jede Zer-
teilung von Blöcken liefert wieder einen Basisblockgraphen. Der minimale Graph ist
bis auf Isomorphie eindeutig.

Beispiel: Im folgenden Code des Multiplikationsbeispielprogramms sind die Leader


unterstrichen:
wadd = if a1 == 0 goto L1 wmult = r := 0
x := a1 − 1 L1 : if a1 > 0 goto L2
call add x, a2 → r goto L3
r := r + 1 L2 : call add r, a2 → r
goto L2 a1 := a1 − 1
L1 : r := a2 goto L1
L2 : return r L3 : return r
Der Basisblockgraph zu wadd ergibt sich damit zu:

120
6.5 Lokale Analyse

if a1 == 0 goto L1
@
@
@R
@
L1 : r := a2 x := a1 − 1
call add, x, a2 → r
? r := r + 1
L2 : return r  goto L2

Der Basisblockgraph zu wmult wurde zuvor bereits gezeigt. /

6.5 Lokale Analyse


Als lokale Analyse bezeichnet man die Betrachtung und Optimierung einzelner Basis-
blöcke. Dabei werden zwei Analysearten unterschieden:

1. Vorwärtsanalyse: Analyse des Verhaltens eines Programms vor einer Instruktion,


d.h. es wird das vergangene Verhalten betrachtet.

Beispiel: Ein typisches Beispiel für eine Vorwärtsanalyse ist die Konstanten-
propagation. Ein Ausdruck heißt genau dann konstant, wenn er unabhängig von
der Ausführung des Programms immer den gleichen Wert hat.
Wir betrachten die Sequenz
...
x := 3
y := x + 4
...
Offensichtlich ist x konstant. y ist konstant, falls die Zuweisung an y kein Sprung-
ziel ist, sich also innerhalb eines Basisblocks befindet. /

2. Rückwärtsanalyse: Analyse des Programmverhaltens nach Ausführung einer In-


struktion, d.h. es wird das zukünftige Verhalten analysiert.

Beispiel: Ein Beispiel für eine Rückwärtsanalyse ist die Bestimmung nutzloser
Zuweisungen (dead code elimination). Wir betrachten die Sequenz

...
x := y + z
y := y + z
x := y − z
...

Befindet sich diese Sequenz innerhalb eines Basisblocks, so ist die erste Zuweisung
an x nutzlos, weil der zugewiesene Wert nicht verwendet wird, bevor er wieder
überschrieben wird. /

121
6. Codeoptimierung

Eine lokale Analyse kann wie folgt mathematisch modelliert werden. Zur Vereinfachung
verzichten wir hier auf die Betrachtung von Prozeduraufrufen. Der Ausgangspunkt ist
die Modellierung der zu analysierenden Eigenschaft als Menge M. Handelt es sich um
eine Eigenschaft von Variablen, so wird häufig M als Menge aller Abbildungen von
Variablen auf abstrakte Werte gewählt: M = V ar → AbsV al.
Ein Startwert m0 ∈ M beschreibt die Anfangssituation für die Analyse. Bei einer
Vorwärtsanalyse ist dies die Situation vor der Ausführung des Basisblocks. Bei einer
Rückwärtsanalyse beschreibt m0 die Situation nach Ausführung des Basisblocks.
Die einzelnen Instruktionen modifizieren die Werte von M, also die Eigenschaften m ∈
M. Eine abstrakte Semantik
A : Instr → M → M
formalisiert dies. Die abstrakte Semantik muss nur für Zuweisungen definiert werden,
denn Sprünge haben nur Auswirkungen auf den Kontrollfluss, nicht aber auf die Ei-
genschaften, d.h. die abstrakte Semantik entspricht der Identitätsfunktion idM :
A[[goto L]] = A[[if x op y goto L]] = A[[return x]] = A[[return]] = idM
Instruktionssequenzen i1 . . . in können einfach durch Komposition der abstrakten Se-
mantik der einzelnen Instruktionen interpretiert werden, d.h. soll die Eigenschaft vor
bzw. nach Auswertung der Instruktion ik bestimmt werden, so gilt:
• Vorwärtsanalyse: A : Instr + → M → M mit
A[[i1 . . . ik−1 ]] := A[[ik−1 ]] ◦ . . . ◦ A[[i1 ]].

Dies entspricht der natürlichen Reihenfolge.


• Rückwärtsanalyse: A : Instr + → M → M mit
A[[ik+1 . . . in ]] := A[[ik+1 ]] ◦ . . . ◦ A[[in ]].

Dies entspricht der umgekehrten Reihenfolge.


Damit folgt, dass für die Modellierung eines Analyseproblems nur die folgenden Fest-
legungen getroffen werden müssen:
1. die Menge M
2. der Startwert m0 ∈ M
3. die abstrakte Zuweisungssemantik
In den folgenden Beispielen werden wir eine abkürzende Notation zur argumentweisen
Modifikation von Funktionen verwenden:
Sei A → B := {f | f : A → B}. Für f : A → B, a ∈ A und b ∈ B sei
(
b falls a0 = a
f [a 7→ b] := a0 7→
f (a0 ) sonst
Desweiteren sei f [a1 7→ b1 , . . . , an 7→ bn ] := (. . . ((f [a1 7→ b1 ])[a2 7→ b2 ]) . . .)[an 7→ bn ].
Dabei ist zu beachten, dass im allgemeinen f [a1 7→ b1 , a2 7→ b2 ] 6= f [a2 7→ b2 , a1 7→ b1 ],
insbesondere, wenn etwa a1 = a2 .

122
6.5 Lokale Analyse

6.5.1 Lokale Konstantenpropagation — Vorwärtsanalyse


Sei C eine Menge von Konstanten und ? 6∈ C ein spezieller Wert. Wir definieren
1. M := V ar → C ∪ {?} und legen

2. m0 ∈ M mit m0 (x) =? für alle x ∈ V ar als Startwert fest.


Der Wert ? repräsentiert einen unbekannten Wert. Es wird somit davon ausgegan-
gen, dass alle Variablen zu Beginn einen unbekannten Wert haben. Wir verwenden die
Hilfsfunktion val : (V al ∪ C) × M → C ∪ {?} mit
(
m(y) falls y ∈ V ar
val(y, m) :=
y falls y ∈ C

3. Die abstrakte Zuweisungssemantik wird definiert durch




 m[x 7→ c] falls val(y, m) = cy =6 ?,

 val(z, m) = cz 6=?,
A[[x := y op z]]m :=

 c = cy op cz


m[x 7→?] sonst.

Beispiel: Wir betrachten die Sequenz

i1 x := 3
i2 y := x + 4
i3 z := z + y

Es folgt

• A[[i1 ]]m0 = m0 [x 7→ 3]
• A[[i1 ; i2 ]]m0 = m0 [x 7→ 3, y 7→ 7]
• A[[i1 ; i2 ; i3 ]]m0 = m0 [x 7→ 3, y 7→ 7]

Damit kann der Code wie folgt optimiert werden:

x := 3
y := 7
z := z + y
/

6.5.2 Nutzlose Anweisungen — Rückwärtsanalyse


Wir definieren
1. M := V ar → {0, 1} und legen

2. m0 ∈ M mit m0 (x) = 0 für alle x ∈ V ar als Startwert fest.

123
6. Codeoptimierung

m(x) = 1 bedeutet für eine Zuweisung x := y op z, dass die Zuweisung nutzlos ist, weil
der Wert von x bis zur nächsten Zuweisung an x nicht verwendet wird. Der Startwert
bedeutet, dass am Ende eines Blocks davon ausgegangen wird, dass alle Variablen
verwendet werden, also nützlich sind.
3. Die abstrakte Zuweisungssemantik wird wie folgt definiert:
A[[x := y op z]]m := m[x 7→ 1, y 7→ 0, z 7→ 0].
Dies bedeutet, dass vorherige x-Werte nutzlos werden, während y und z gebraucht
werden. Beachten Sie, dass die Reihenfolge relevant ist, da x, y und z nicht
verschieden sein müssen.
Beispiel: Wir betrachten die Sequenz
i1 x := y + z
i2 y := y + z
i3 x := y − z
Es folgt
• A[[i3 ]]m0 = m0 [x 7→ 1, y 7→ 0, z 7→ 0] = m0 [x 7→ 1]
• A[[i2 ; i3 ]]m0 = m0 [x 7→ 1][y 7→ 1, y 7→ 0, z 7→ 0] = m0 [x 7→ 1]
• A[[i1 ; i2 ; i3 ]]m0 = m0 [x 7→ 1][x 7→ 1, y 7→ 0, z 7→ 0] = m0 [x 7→ 1]
Wegen A[[i2 ; i3 ]]m0 = m0 [x 7→ 1] kann die erste Zuweisung an x (Instruktion i1 ) entfernt
werden. /

6.5.3 Elimination gemeinsamer Teilausdrücke — Vorwärtsanalyse


Beispiel: Die Beispielsequenz
i1 a := b ∗ c
i2 d := d − a
i3 e := b ∗ c
i4 f := d + e
i5 i := b ∗ c
i6 c := d + i
enthält mehrere identische (gemeinsame) Teilausdrücke, deren Mehrfachauswertung
durch Kopieranweisungen verhindert werden kann. Der Ausdruck b ∗ c tritt in den
Instruktionen i1 , i3 und i5 auf. Die rechten Seiten der Instruktionen i4 und i6 sind
ebenfalls identisch, obwohl sie syntaktisch verschieden sind. /
1. Als Menge M verwenden wir eine Menge von Abhängigkeitsgraphen, das sind
geordnete, gerichtete, beschriftete und azyklische Graphen. Die Beschriftung be-
steht an den Blattknoten aus einer Konstanten oder Variablen. Innere Knoten
sind mit Operationen und Mengen von Variablen beschriftet.
Anstelle einer formalen Definition von Abhängigkeitsgraphen begnügen wir uns
mit einem Beispiel:

124
6.5 Lokale Analyse

Beispiel: Der Graph


∗ {x,y}
 A
 A
 AU
z 5

beschreibt, dass die Variablen x und y den Wert z + 5 erhalten. /

2. Als Startwert m0 wird der leere Graph festgelegt. Ziel der abstrakten Semantik
ist der Abhängigkeitsgraph des gesamten Blocks.

Es wird eine Vorwärtsanalyse durchgeführt. Im folgenden verwenden wir für einen


Abhängigkeitsgraphen m die Notation m[x] für den ersten Knoten von der Wurzel aus
mit Beschriftung x, falls ein solcher existiert.

3. Damit legen wir die abstrakte Zuweisungssemantik wie folgt fest:

A[[x := y op z]]m := 1. 6 ∃m[y] y erzeuge neues Blatt mit Beschriftung y


2. 6 ∃m[z] y erzeuge neues Blatt mit Beschriftung z
3. 6 ∃ Knoten mit Beschriftung op,
linkem Kind m[y] und rechtem Kind m[z]
y erzeuge solchen Knoten
4. Füge Beschriftung x zu Knoten aus Schritt 3 hinzu.

Beispiel: Zu obiger Sequenz ergibt sich die folgende Graphfolge:

− {d}
∗ {a}  A

 AAU
A[[i1 ]]m0 =  A A[[i1 ; i2 ]]m0 = d

AA
U
∗ {a}
b c  A
 AAU
b c

+ {f}
− {d} @
@
 A @
− {d}

AA
U  A
A[[i1 ; i2 ; i3 ]]m0 = d ∗ {a,e} A[[i1 ; i2 ; i3 ; i4 ]]m0 =  AAU
 A d ∗ {a,e}

AA
U  A
b c 
 AAU
b c

125
6. Codeoptimierung

+ {f}
@
@@
− {d}
 A
A[[i1 ; i2 ; i3 ; i4 ; i5 ]]m0 = 
AA
U
d ∗ {a,e,i}
 A


 AA
U
b c
Final ergibt sich der folgende Graph

+ {f,c}
@
@@
− {d}
 A
A[[i1 ; i2 ; i3 ; i4 ; i5 ; i6 ]]m0 =  AAU
d ∗ {a,e,i}
 A
  AAU
b c

Mehrere Variablenbeschriftungen an inneren Knoten zeigen gemeinsame Teilausdrücke


an. Diese werden meist dadurch eliminiert, dass sie zunächst in Hilfsvariablen berechnet
werden und dann nur noch aus der Hilfsvariable in die Zielvariablen kopiert werden.
Im Beispiel ergibt sich die folgende optimierte Codesequenz:
temp := b ∗ c
a := temp
d := d − a
e := temp
temp2 := d + e
f := temp2
i := temp
c := temp2
/

6.6 Fallstudie: Code-Optimierung am Beispiel von Quicksort


Bevor wir die Analyse von Datenflussgraphen betrachten, stellen wir eine Reihe von
gängigen globalen Optimierungen an der bekannten, in Abbildung 22 gezeigten rekur-
siven Prozedur zur Sortierung eines global deklarierten Arrays a mit dem Quicksort-
verfahren vor.
Aus diesem Programm wird nur die While-Schleife mit den drei vorherigen und den drei
nachfolgenden Zuweisungen betrachtet. Für dieses Fragment erhält man den folgenden
Drei-Adress-Code:

126
6.6 Fallstudie: Code-Optimierung am Beispiel von Quicksort

void quicksort(int m, int n)


{ int i,j; int v,x;
if (n <= m) return;
i = m-1; j = n; v = a[n];
while (1) {
do i=i+1; while (a[i] < v);
do j=j-1; while (a[j] > v);
if (i>=j) break;
x = a[i]; a[i] = a[j]; a[j] = x;
}
x = a[i]; a[i] = a[n]; a[n] = x;
quicksort(m,j); quicksort(i+1,n);
}

Abbildung 22: Beispielprozedur quicksort

i = m-1 t7 = 4*i
j = n t8 = 4*j
t1 = 4*n t9 = a[t8]
v = a[t1] a[t7] = t9
L1: i = i+1 t10 = 4*j
t2 = 4*i a[t10] = x
t3 = a[t2] goto L1
if t3<v goto L1 L3: t11 = 4*i
L2: j = j-1 x = a[t11]
t4 = 4*j t12 = 4*i
t5 = a[t4] t13 = 4*n
if t5>v goto L2 t14 = a[t13]
if i>=j goto L3 a[t12] = t14
t6 = 4*i t15 = 4*n
x = a[t6] a[t15] = x

In diesem Code wird angenommen, dass die einzelnen Array-Werte als Worte gespei-
chert werden. Die indizierte Adressierung erfolgt allerdings byteweise. Daher werden
die Array-Indizes jeweils durch Multiplikation mit 4 in Byteadressen überführt. Zu
diesem Code erhält man die in Abbildung 23 gezeigte Basisblockdarstellung.

6.6.1 Elimination lokaler gemeinsamer Teilausdrücke

In den beiden längeren Basisblöcken in Abbildung 23 können die Indexberechnungen


als gemeinsame Teilausdrücke (wie in Abschnitt 6.5.3 gezeigt) erkannt und teilweise
eliminiert werden:

127
6. Codeoptimierung

i = m-1 - if i >= j goto L3


j = n
@
t1 = 4*n @
v = a[t1] R
@
t6 = 4*i L3: t11 = 4*i
x = a[t6] x = a[t11]
? 
L1: i = i+1  t7 = 4*i t12 = 4*i
t2 = 4*i t8 = 4*j t13 = 4*n
t3 = a[t2] t9 = a[t8] t14 = a[t13]
if t3<v goto L1 a[t7] = t9 a[t12] = t14
t10 = 4*j t15 = 4*n
a[t10] = x a[t15] = x
?
L2: j = j-1  goto L1
t4 = 4*j
t5 = a[t4]
if t5>v goto L2

Abbildung 23: Basisblockdarstellung zu Quicksort-Fragment

y L3: y L3:
t6 = 4*i t6 = 4*i t11 = 4*i t11 = 4*i
x = a[t6] x = a[t6] x = a[t11] x = a[t11]
t7 = 4*i t12 = 4*i
t8 = 4*j t8 = 4*j t13 = 4*n t13 = 4*n
t9 = a[t8] t9 = a[t8] t14 = a[t13] t14 = a[t13]
a[t7] = t9 a[t6] = t9 a[t12] = t14 a[t11] = t14
t10 = 4*j t15 = 4*n
a[t10] = x a[t8] = x a[t15] = x a[t13] = x
goto L1 goto L1

Die doppelten Berechnungen werden jeweils ersatzlos gestrichen. Referenzen auf die ent-
sprechenden Variablen werden ersetzt durch die Variable, die den Wert bereits enthält,
d.h. t6 wird z.B. für t7 eingesetzt.
Andere lokale Optimierungen sind in diesem Beispiel nicht möglich. Als Ausgangspunkt
für die globale Optimierung erhält man entsprechend den in Abbildung 24 angegebenen
lokal optimierten Datenflussgraphen.
An diesem Beispiel erläutern wir im folgenden eine Reihe typischer globaler Optimie-
rungen.

6.6.2 Global gemeinsame Teilausdrücke

Wenn sich ein Teilausdruck auf keinem möglichen Pfad durch den Datenflussgraphen
ändert, kann die Eliminierung gemeinsamer Teilausdrücke auch global durchgeführt

128
6.6 Fallstudie: Code-Optimierung am Beispiel von Quicksort

i = m-1 - if i >= j goto L3


j = n
@
t1 = 4*n @
v = a[t1] R
@
t6 = 4*i L3: t11 = 4*i
x = a[t6] x = a[t11]
? 
L1: i = i+1  t8 = 4*j t13 = 4*n
t2 = 4*i t9 = a[t8] t14 = a[t13]
t3 = a[t2] a[t6] = t9 a[t11] = t14
if t3<v goto L1 a[t8] = x a[t13] = x
goto L1
?
L2: j = j-1 
t4 = 4*j
t5 = a[t4]
if t5>v goto L2

Abbildung 24: Lokal optimierte Basisblockdarstellung zu Quicksort-Fragment

werden. Dabei werden neue temporäre Variablen zur Zwischenspeicherung gemeinsamer


Teilausdrücke verwendet, falls die Ursprungsvariable überschrieben wird.

Im Quicksort-Beispiel kann man feststellen, dass die Indexberechnungen auch global


gemeinsam sind. In den Blöcken L1 und L2 werden in den Variablen t2 und t4 die
Werte 4*i und 4*j berechnet. Werden die lokalen L1- und L2-Schleifen verlassen, so
werden die beiden längeren (bereits lokal optimierten) Blöcke erreicht, in denen 4*i
und 4*j nochmals berechnet werden. Stattdessen können t6 und t8 einfach durch t2
und t4 ersetzt werden. Auch die Neuberechnung von 4*n in t13 ist überflüssig. Der
Wert 4*n liegt noch in t1 vor. Nach diesen Optimierungen kann man desweiteren fest-
stellen, dass die Array-Zugriffe in den längeren Basisblöcken eliminiert werden können,
weil die entsprechenden Werte bereits in Registern vorliegen. So liegt der Wert a[t2]
in der Variablen t3 vor, so dass die beiden Zuweisungen x = a[t2] durch einfache
Kopieranweisungen x = t3 ersetzt werden können. Ebenso kann die Zuweisung t9 =
a[t4] durch t9 = t5 ersetzt werden.

Obwohl der Wert von t1 = 4*n sich bei der Ausführung des Codes nicht ändert, darf
man hingegen die Zuweisung t14 = a[t1] nicht durch t14 = v ersetzen, obwohl der
Wert von v während der gesamten Ausführung unverändert bleibt. Der Grund hierfür
ist, dass es möglich ist, dass das Array a zwischen dem ersten Zugriff auf a[t1] in
der Zuweisung v = a[t1] und dem späteren Zugriff in der Zuweisung t14 = a[t1]
modifiziert wurde. Dies ist bei den oben beschriebenen Ersetzungen nicht möglich. Es
ergibt sich der in Abbildung 25 gezeigte Datenflussgraph.

129
6. Codeoptimierung

i = m-1 - if i >= j goto L3


j = n
@
t1 = 4*n @
v = a[t1] R
@
x = t3 L3: x = t3
t9 = t5 t14 = a[t1]
? 
L1: i = i+1  a[t2] = t9 a[t2] = t14
t2 = 4*i a[t4] = x a[t1] = x
t3 = a[t2] goto L1
if t3<v goto L1

?
L2: j = j-1 
t4 = 4*j
t5 = a[t4]
if t5>v goto L2

Abbildung 25: Basisblockdarstellung nach Eliminierung globaler gemeinsamer Teilaus-


drücke

6.6.3 Kopienpropagation und Elimination nutzloser Anweisungen

Kopieranweisungen der Form u = v werden häufig bei der Elimination gemeinsamer


Teilausdrücke eingeführt. Häufig kann man Kopieranweisungen völlig eliminieren, in-
dem man v so oft wie möglich statt u verwendet.
Generell heißt eine Variable lebendig, falls ihr Wert in einem Programmlauf benutzt
werden kann. Ansonsten ist eine Variable tot. Nutzlose Anweisungen sind solche, deren
Wert nie benutzt wird. Solche nutzlosen Anweisungen werden häufig durch vorherige
Optimierungsphasen erzeugt. Im Quicksort-Beispiel können die Kopieranweisungen, die
beim Ersetzen der wiederholten Array-Zugriffe eingeführt wurden, wieder eliminiert
werden. Dies führt zu der in Abbildung 26 gezeigten Basisblockdarstellung.

6.6.4 Codeverschiebung

Codeverschiebung (engl. code motion) wird vor allem durchgeführt, um schleifeninva-


riante Berechnungen aus Schleifen herauszuziehen. Im Quicksort-Beispiel gibt es keine
solchen schleifeninvarianten Berechnungen. Daher betrachten wir ein allgemeines Bei-
spiel.

.
.
Beispiel: Wir betrachten die Sequenz L : .
x := y op z
.
.
.
if ...goto L

130
6.6 Fallstudie: Code-Optimierung am Beispiel von Quicksort

i = m-1 - if i >= j goto L3


j = n
@
t1 = 4*n @
v = a[t1] R
@
a[t2] = t5 L3: t14 = a[t1]
a[t4] = t3 a[t2] = t14
? 
L1: i = i+1  goto L1 a[t1] = t3
t2 = 4*i
t3 = a[t2]
if t3<v goto L1

?
L2: j = j-1 
t4 = 4*j
t5 = a[t4]
if t5>v goto L2

Abbildung 26: Basisblockdarstellung nach Kopienpropagation und Eliminierung nutz-


loser Anweisungen

Werden weder y noch z in den nicht gezeigten Code-Sequenzen modifiziert, so ist


die Wertzuweisung x := y op z schleifeninvariant. Um die mehrfache Ausführung bei
jedem Schleifendurchlauf zu verhindern, zieht man die Anweisung vor die Schleife. Dies
führt zu folgendem modifizierten Code:
x := y op z
.
.
L : .
.
.
.
if ...goto L
Dabei ist zu beachten, dass die Zuweisung x := y op z auf allen Pfaden, die zu L
führen, eingefügt werden muss. /

6.6.5 Induktionsvariablen und Operatorreduktion

Eine Variable x heißt Induktionsvariable einer Schleife, falls sich der Wert von x bei
Zuweisung um eine (positive oder negative) Konstante c erhöht. Dies muss in einer
Inkrementierung pro Schleifendurchlauf geschehen. Häufig existieren mehrere Indukti-
onsvariablen per Schleife. Manchmal ist es möglich, mehrere Induktionsvariablen durch
eine zu ersetzen.
Unter Operatorreduktion (engl. strength reduction) versteht man das Ersetzen von teu-
ren Operationen durch billigere. Standardbeispiele sind die Verwendung von Additio-
nen oder Shift-Operationen statt Multiplikationen, falls möglich, oder die Verwendung
von speziellen Inkrementierungsbefehlen statt allgemeiner Addition.

131
6. Codeoptimierung

i = m-1
j = n
t1 = 4*n
- if i >= j goto L3
v = a[t1]
...............
@
t2 = 4*i @
t4 = 4*j R
@
a[t2] = t5 L3: t14 = a[t1]
a[t4] = t3 a[t2] = t14
? 
L1: t2 = t2+4  goto L1 a[t1] = t3
t3 = a[t2]
if t3<v goto L1

?
L2: t4 = t4-4 

t5 = a[t4]
if t5>v goto L2

Abbildung 27: Basisblockdarstellung nach Elimination von Induktionsvariablen

Im Laufbeispiel (siehe Abbildung 26) erkennt man schnell, dass die Variablen i und j
in den Blöcken L1 und L2 Induktionsvariablen sind. Deswegen können die wiederholten
Multiplikationen von i und j mit 4 durch billigere Additionen t2 := t2+4 bzw. t4
:= t4-4 ersetzt werden. Hierfür ist es allerdings erforderlich, die Variablen t2 und
t4 im Anfangsblock zu initialisieren. Jetzt sind sowohl i und j als auch t2 und t4
Induktionsvariablen. Nach diesen Änderungen kommen i und j nur noch im Vergleich
des bedingten Sprungbefehls nach Block L2 vor. Nutzt man aus, dass i >= j genau
dann gilt, wenn t2 >= t4, können i und j vollständig aus den Blöcken L1 und L2
eliminiert werden. Dies führt zu dem in Abbildung 27 gezeigten Endergebnis unserer
Folge von Optimierungen.
Diese Fallstudie hat gezeigt, dass Optimierungen meistens von innen nach außen durch-
geführt werden sollten und dass Optimierungen weitere Optimierungsmöglichkeiten
nach sich ziehen können.

6.7 Globale Analyse: Datenflussanalyse


Die grundsätzliche Vorgehensweise bei einer Datenflussanalyse besteht aus den folgen-
den drei Schritten:

1. Festlegung der Modellierung und Analyse des lokalen Verhaltens der Basisblöcke
Ergebnis dieses Schrittes sind die

• Menge M und

132
6.7 Globale Analyse: Datenflussanalyse

• zu jedem Basisblock B die Transferfunktion fB : M → M.


2. Aufstellen eines Gleichungssystems zur Ermittlung des Gesamtverhaltens
• Für jeden Basisblock BBi wird eine Variable mi eingeführt.
• Der Startwert (Randbedingung) m0 ∈ M wird für die Wurzel (Vorwärts-
analyse) bzw. die Blätter (Rückwärtsanalyse) festgelegt.
• Es muss eine geeignete Zusammenfassung des Gesamtverhaltens entlang al-
ler Pfade von der Wurzel/den Blättern zum Knoten BBi erfolgen. Dies ge-
schieht durch lokale Zusammenfassung aller Vorgänger mittels einer Zusam-
menführungsfunktion (engl. meet function):

m1 m2

f1 f2
@
@ m3
R
@
f3
m3 = f1 (m1 )  f2 (m2 )
 : M × M → M ist die meet“-Funktion, die über verschiedene Pfade im Da-

tenflussgraphen bestimmte Informationen zusammenführt.

Beispiel: Eine mögliche Zusammenführungsfunktion für die Konstantenpropa-


gation ist
(
c falls m1 (x) = m2 (x) = c 6= ?
m1  m2 := x 7→
? sonst.
/

Beispiel: Wir stellen ein Gleichungssystem für eine Vorwärtsanalyse des Daten-
flussgraphen zum Quicksort-Programm (siehe Abbildung 27) auf. Zur Repräsen-
tation der abstrakten Werte zu Beginn der Basisblöcke führen wir die folgenden
Variablen ein:
Basisblock Anfang L1 L2 Sprung äußere Schleife L3 Ende
Variable m0 m1 m2 mS mB m3 mEXIT
Das Gleichungssystem in 6 Unbekannten hat dann die folgende Struktur:
m1 = fA (m0 )  m2  fB (mB )
m2 = fL1 (m1 )  mS
mS = fL2 (m2 )
mB = fS (mS )
m3 = fS (mS )
mEXIT = fL3 (m3 )
/

133
6. Codeoptimierung

3. Lösen des Gleichungssystems


Zur Lösung des Gleichungssystems ist eine Fixpunktbestimmung notwendig. Hier-
zu muss der Grundbereich M zu einem Verband, d.h. einer vollständigen Halb-
ordnung mit existierendem Supremum und Infimum für je zwei Werte. Letzteres
ist wichtig bei zusammenlaufenden Kanten. In Verbänden endlicher Höhe ist die
Existenz von Fixpunkten für monotone Funktionen garantiert. Wir werden dies
hier nicht weiter ausführen.

6.8 Taxonomie von Datenflussproblemen


Wir beenden dieses Kapitel mit einer Taxonomie von Datenflussproblemen. Man un-
terscheidet, wie bereits bei der Diskussion lokaler Analysen, Vorwärts- und Rückwärts-
analysen:
Vorwärts: • Datenfluss entlang der Pfeile
• Variablen am Anfang von Basisblöcken
• Randbedingungen an der Wurzel
Es werden Aussagen über die Vergangenheit der Berechnung gemacht.
Rückwärts: • Datenfluss entgegen den Kanten
• Variablen am Ende von Basisblöcken
• Randbedingungen an den Blättern
Es werden Aussagen über die Zukunft der Berechnung gemacht.
Außerdem werden All- und Existenzprobleme unterschieden:
Allprobleme: Eigenschaften müssen auf allen Pfaden erhalten bleiben.
y Meet-Funktion entspricht logischem Und bzw. einem Durchschnitt.
Existenzprobleme: Eigenschaften müssen auf mindestens einem Pfad gelten.
y Meet-Funktion entspricht logischem Oder bzw. einer Vereinigung.

Beispiel: ∀ ∃

vorwärts • Konstantenpropagation • Sichtbare Definitionen


• verfügbare Ausdrücke
• gemeinsame
Teilausdrücke

rückwärts • wichtige Ausdrücke • lebendige Variablen

Bei sichtbaren Definitionen fragt man nach der Existenz eines Pfades, auf dem ein
Zuweisungswert erhalten bleibt. Wichtige Ausdrücke sind solche, die auf jeden Fall,
d.h. bei jedem Programmlauf berechnet werden. Bei lebendigen Variablen fragt man,
ob ein Pfad existiert, auf dem eine Variable benutzt wird. /

134
7. Übersetzung objektorientierter Sprachen

7 Übersetzung objektorientierter Sprachen


In diesem Abschnitt werden die Unterschiede und Erweiterungen der Übersetzung ob-
jektorientierter Sprachen gegenüber der Übersetzung imperativer Sprachen behandelt.

7.1 Kernkonzepte objektorientierter Sprachen


Ein Objekt besteht aus:
• einem Objektzustand, der durch die aktuellen Werte der Objektattribute (Daten-
felder) bestimmt ist, und

• Methoden (Funktionen, Prozeduren), welche auf dem Objektzustand operieren


(verändern, projizieren).
Objektorientierte Sprachen kapseln Daten und Operationen. Prozeduren werden zu
Komponenten von Objekten. Im Unterschied zu prozeduralen Sprachen bilden Objekte
und damit die Daten das zentrale Strukturierungsmerkmal.
Gleichartige Objekte gehören einer Klasse an. Alle Objekte einer Klasse besitzen die
gleichen Attribute und Methoden. Durch Objektklassen werden die “Typen” von Ob-
jekten beschrieben. Eine Objektklasse spezifiziert Attribute und Methoden mit ihren
Typen und Prototypen (d.h. die Typen von Parametern und Rückgabewerten).
Ein Objekt kann nur zu einer Klasse gehören, wenn es alle in der Klasse definierten
Attribute und Methoden enthält.

7.1.1 Vererbung
Eine Klasse B erbt von einer Klasse A, falls alle Attribute und Methoden von A in B
übernommen werden. B kann darüber hinaus weitere Attribute und Methoden enthal-
ten, sowie unter gewissen Umständen ererbte Methoden überschreiben. Man sagt, B
wurde von A abgeleitet bzw. A ist Basisklasse von B.
Man unterscheidet
einfache Vererbung (single inheritance): Jede Klasse hat höchstens eine direkte
Oberklasse.

Mehrfachvererbung (multiple inheritance): Jede Klasse kann beliebig viele di-


rekte Oberklassen haben.
Durch die Vererbungsbeziehungen bilden alle Klassen eine Vererbungshierarchie. Bei
einfacher Vererbung handelt es sich um eine Baumstruktur, bei mehrfacher Vererbung
um einen gerichteten azyklischen Graphen. Vererbungshierarchien strukturieren Klas-
senbibliotheken und erlauben die Unterscheidung verschiedener Abstraktionsstufen.

Beispiel: Eine Vererbungshierarchie für graphische Objekte ist in Abb. 28 dargestellt.


Die Klasse GraphischesObjekt deklariert zwei abstrakte Methoden, die in dieser All-
gemeinheit noch nicht definiert werden können. Sinn und Zweck der Deklaration ist es
festzuhalten, dass diese Methoden für alle graphischen Objekte definiert sein sollen, so
wie etwa in der Subklasse Linienzug, für die konkrete Definitionen angegeben werden

135
7. Übersetzung objektorientierter Sprachen

::GraphischesObjekt

-verschieben(double,double)
-skalieren(double)

::Linienzug ::GeschlossenesObjekt

-länge() -flaeche()
-verschieben(double,double)
-skalieren(double)

::Polygon ::Ellipse

-flaeche() -flaeche()
-verschieben(double,double)
-skalieren(double)

::Rechteck ::Dreieck

-flaeche()

Abbildung 28: Vererbungshierarchie grafischer Objekte, aus: [Wilhelm/Maurer 97, S. 175]

können. Ebenso ist die Methode flaeche der Subklasse GeschlossenesObjekt nicht
allgemein definierbar. Erst Ellipse und Polygon ermöglichen eine Berechnung der
entsprechenden Methode. Polygon erbt dabei von zwei Oberklassen. Die Fläche eines
Rechtecks lässt sich effizienter berechnen als für ein beliebiges Polygon. Die Methode
der Oberklasse wird daher überschrieben. /

Vererbung ermöglicht (bzw. vereinfacht und strukturiert)

• die Wiederverwendung von Implementierungen

• die lokale Spezialisierung bestimmter Methoden

• die Beschreibung abstrakter Konzepte.

Subtypen. Falls Klasse B von Klasse A erbt, ist der Typ von B Subtyp des Typs von
A. Jedes Objekt vom Typ B ist gleichermaßen Objekt vom Typ A. Es gilt die

Subtypenregel: Wenn als Funktionsparameter, rechte Seite einer Zuweisung oder


Funktionsresultat ein Objekt eines bestimmten Typs verlangt wird, sind Objekte
eines beliebigen Subtyps ebenfalls erlaubt.

Als Laufzeittyp eines Objekts bezeichnet man die kleinste Klasse, zu der ein Objekt
gehört. Der Laufzeittyp ist eindeutig bestimmt. Ein Objekt gehört zu jeder Oberklasse
seines Laufzeittyps.

136
7.2 Übersetzung von Methoden

Methoden können auf Parameterpositionen Objekte mit verschiedenen Typen und da-
mit unterschiedlichem Aufbau akzeptieren. Dabei gilt die

Methodenauswahlregel: Überschreibt eine Klasse B eine Methode m einer Ober-


klasse A, dann muss für ein Objekt b aus B auch dann die von B definierte
Methode aufgerufen werden, wenn b als Element von A benutzt wird. Die even-
tuell von A definierte Methode darf für b nicht verwendet werden.

Es ist im allgemeinen nicht statisch entscheidbar, welche Methode jeweils aufgerufen


werden muss, da dies ggf. von Parametern abhängt, welche erst zur Laufzeit bekannt
sind.

Dynamische Bindung (late binding): Überschreibt eine Klasse B eine Methode


m ihrer Oberklasse, und wird diese Methode für ein Objekt aufgerufen, dessen
Klassenzugehörigkeit zur Übersetzungszeit nicht bekannt ist, so muss die Metho-
denimplementierung zur Laufzeit an das Objekt gebunden werden.

7.1.2 Generizität
Ein weiterer Aspekt von Objektorientierung ist die Möglichkeit generischer Definitio-
nen, wie man sie etwa mit den Klassen-Templates in C++ oder in Javas generischen
Containerklassen findet. Generizität erlaubt es, Mehrfachdefinitionen von Funktionen
für unterschiedliche Typen zu vermeiden, indem Funktionsdefinitionen oder Klassen-
definitionen mit Typen parametrisiert werden können.

7.2 Übersetzung von Methoden


Methoden unterscheiden sich von (Funktions-)Prozeduren imperativer Sprachen da-
durch, dass sie Objekten zugeordnet sind und auf die Attribute ihres Objektes zugrei-
fen können. Es wird im Gegensatz zu imperativen Prozeduren ein lokaler Namensraum
zugrundegelegt. Methoden können durch Überführung in äquivalente imperative Pro-
zeduren implementiert werden, was hier am Beispiel von C++ erklärt wird.

Beispiel: Sei etwa m Methode einer C++-Klasse C, dann wird für Methodentypen,
Aufrufe und Attributzugriffe das Objekt als zusätzlicher Parameter eingebaut:

Methode Funktionsprozedur
Typ: <returntype> m(<args>) → <returntype> fm(C &this,<args>)
Aufruf: obj.m(<args>) → fm (obj,<args>)
Attribut-
zugriff: field → this.field

Die Funktionsprozedur hat einen zusätzlichen formalen by-reference-Parameter &this


vom Typ C. Im Aufruf wird das Objekt als entsprechender aktueller Parameter einge-
setzt. Attributzugriffe werden explizit auf das Objekt bezogen. /

137
7. Übersetzung objektorientierter Sprachen

Da die Namen von Methoden in verschiedenen Klassen gleich sein könnten, müssen bei
einer solchen Übersetzung eindeutige Namen für die Prozeduren fm generiert werden
(sog. name mangling). Dies kann unter Verwendung des Klassennamens und seiner
Länge erfolgen, außerdem ist es sinnvoll, die Typsignatur mit im Namen zu kodieren.
Eine mögliche Kodierung ist etwa wie folgt:
fm = <Methodenname>__<LaengeKlassenname><Klassenname><Typcode>
In unserem Beispiel mit geometrischen Objekten erhielte beispielsweise die Prozedur
zur Methode skalieren(double) in Klasse GraphischesObjekt den Namen
skalieren 17GraphischesObjektd.
Diese Namensgebung (exemplarisch: gcc, Version 2.x) ist nicht eindeutig: beispielsweise
erhalten die Methode m in Klasse a 1a und die Methode m 5a in Klasse a den gleichen
Namen (nämlich welchen?), falls sie die gleiche Typsignatur haben.8

7.3 Übersetzung einfacher Vererbung


Wenn jede Klasse lediglich von einer Oberklasse erben darf, entsteht eine baumartige
Vererbungshierarchie.

Beispiel: Wir betrachten eine Hierarchie mit drei C++-Klassen GraphischesObjekt,


Rechteck und Linienzug (siehe Abbildung 29). Die Klasse GraphischesObjekt definiert
die Methoden verschieben und skalieren. Die Klasse Linienzug hat als Attribut eine
Liste von Punkten, eigene Implementierungen der geerbten Methoden verschieben und
skalieren sowie eine weitere Methode laenge. Die Klasse Rechteck verwendet spezielle
Attribute für die Seitenlängen und stellt effizientere Methodenimplementierungen für
skalieren und laenge. /

Das Hauptproblem bei einfacher Vererbung (wie überhaupt) ist die effiziente Realisie-
rung der dynamischen Methodenbindung. Wird etwa ein Rechteck als Linienzug an eine
Methode übergeben, welche wiederum dessen skalieren-Methode aufruft, so muss die
spezialisierte Version für Rechtecke verwendet werden, nicht das allgemeinere Skalieren
eines Linienzugs.
Der Compiler erzeugt für jede Klasse eine Methodentabelle (in C++: virtual function
table). Diese enthält Einträge für alle Methoden einer Klasse und aller Oberklassen,
die dynamisch gebunden werden können (und in C++ als virtual deklariert sind).
Zur Laufzeit besitzt jedes Objekt (im Heap) als erste Komponente einen Zeiger auf die
Methodentabelle seiner kleinsten“ Klasse (d.h. seines Laufzeittyps). Einem Methoden-

namen wird der entsprechende Index in der Methodentabelle zugeordnet, in Subklassen
überschriebene Methoden erhalten den gleichen Index.

Beispiel: Im Beispiel der einfachen Vererbungshierarchie aus Abbildung 29 entstehen


die Methodentabellen der Unterklassen dadurch, dass jeweils die Methodentabelle der
direkten Oberklasse kopiert, überschrieben bzw. erweitert wird.
Wir repräsentieren die Code-Adressen der Methoden hier zur Vereinfachung durch ihren
Namen und einen Zusatz für die Klasse, in der sich die Definition der Methode befindet.
verschieben L repräsentiert demnach die Code-Adresse der in der Klasse Linienzug
8
Wer allerdings mit derart kryptischen Namen programmiert, ist vielleicht selbst schuld.

138
7.3 Übersetzung einfacher Vererbung

::GraphischesObjekt

-verschieben(double,double)
-skalieren(double)

::Linienzug

punkte : Liste<Punkt>
-laenge()
-verschieben(double,double)
-skalieren(double)

::Rechteck

seite1:double
seite2:double
-flaeche()
-laenge()
-skalieren(double)

Abbildung 29: Beispiel für einfache Vererbungshierarchie

definierten Methode verschieben. Für die Erzeugung der Methodentabellen kann zur
Übersetzungszeit Programmcode generiert werden.

GraphischesObjekt y Linienzug y Rechteck


0 verschieben G 0 verschieben L 0 verschieben L
1 skalieren G 1 skalieren L 1 skalieren R
2 länge L 2 länge R

Für jedes Objekt gilt, dass die Sicht als GraphischesObjekt ein Präfix der Sicht als
Linienzug oder Rechteck ist:

( Rechteckobjekt im Heap
Zeiger auf Methodentab. } Sicht als graph. Objekt
Sicht als Linienzug
punkte
seite1
seite2

Der zusätzliche Aufwand für die Implementierung einfacher Vererbung besteht in ei-
nem Zeiger (auf die Methodentabelle) pro Objekt und eine Methodentabelle pro Klasse
(Platz) sowie einem zusätzlichen Dereferenzierungsschritt (Zeit) je Aufruf einer Metho-
de.

139
7. Übersetzung objektorientierter Sprachen

7.4 Behandlung von Mehrfachvererbung


Durch die Möglichkeit von Mehrfachvererbung besteht die Vererbungshierarchie aus
einem gerichteten azyklischen Graphen (DAG, directed acyclic graph).
Wir betrachten die Problemfelder und verschiedene Lösungsansätze an folgender ein-
fachen Beispielkonstellation:
:: A

:: B1 :: B2

:: C

Exemplarisch kann man sich die Klasse Polygon als Subklasse von GeschlossenesObjekt
und Linienzug vorstellen, welche beide von GraphischesObjekt erben.
Die folgenden Probleme treten bei mehrfacher Vererbung auf:

1. Widersprüche bzw. Konflikte – etwa Namenskonflikte – zwischen zwei Oberklas-


sen B1 und B2, im Beispiel Linienzug und GeschlossenesObjekt, die eine gemein-
same Oberklasse beerben.

2. Das wiederholte Erben in C (Polygon) auf verschiedenen Wegen, also über B1 und
B2, von einer gemeinsamen Oberklasse A, im Beispiel GraphischesObjekt.

ad 1: Namenskonflikte müssen durch eine geeignete Sprachdefinition gelöst werden.


Mögliche Lösungen sind:

• die Festlegung einer festen Ordnung (etwa durch die textuell frühere Angabe) in
einer Vererbungshierarchie und somit eines Hauptvorfahren einer Klasse, dessen
Methoden Priorität haben. Die Durchsuchung der Vererbungshierarchie erfolgt
dann in einer festen Reihenfolge, beispielsweise C → B1 → B2 → A. Die erste
passende Definition gilt.
Dieser Ansatz wird etwa in OO-Erweiterungen von Lisp verwendet. Eine Gefahr
besteht darin, dass die Suchreihenfolge einem Programmierer womöglich nicht
bewusst ist, insbesondere wenn Mehrfachdeklarationen unabsichtlich eingebaut
wurden.

• Umbenennng ererbter Methoden und Attribute d.h. Konflikte sind explizit durch
den Programmierer zu lösen.
Auf diese Weise wird das Problem in Eiffel gelöst.

140
7.4 Behandlung von Mehrfachvererbung

• Angabe einer expliziten Instanz durch spezielle Sprachkonstrukte, die die Verer-
bung verdeutlichen
In C++ etwa stellt ein Aufruf Linienzug::skalieren() einen expliziten Bezug
zur entsprechenden Oberklasse her.

Die Sprachansätze bestimmen die Vorgehensweise des Compilers bei der Verwaltung
und Organisation der Symboltabelle.

ad 2: Zum doppelten Erben von einer gemeinsamen Oberklasse zweier direkter Ober-
klassen existieren zwei diametrale Ansätze: eine tatsächlich mehrfache Instanzierung
oder eine Reduktion auf eine einzige Instanz.

1. mehrfache Instanzierung
A ADD(B1) A ADD(B2) ADD(C)
A
B1
B2
C

2. einfache Instanzierung
A ADD(B1) ADD(B2) ADD(C)
A
B1
B2
C

Diese Varianten schließen einander aus, können aber beide sinnvoll eingesetzt werden.

Beispiel: Doppelte Instanzierung ist wünschenswert, falls doppelt ererbte Methoden


in verschiedenen Kontexten benötigt werden.
Die GNU-C++-Bibliothek definiert zwei allgemeine Hilfsklassen für Statistik-Auswer-
tungen. SampleStatistics definiert Methoden zur Berechnung des Mittelwerts, der Va-
rianz und der Standardabweichung in einer Messreihe. Die Subklasse SampleHistogram
erstellt Histogramme und verwendet dazu Methoden der Oberklasse SampleStatistics.
Die Klassenhierarchie ist in Abbildung 30 (a) gezeigt.
Erbt nun beispielsweise eine Klasse zur Temperatur- und Druckmessung von beiden
Klassen, so ist es sinnvoll, SampleStatistics doppelt zu instanzieren, da die Metho-
den daraus zum einen direkt verwendet werden (Kontext: Temperaturkurven), zum
anderen vielleicht ein Histogramm des Druckverlaufs erstellt werden soll (Kontext: Hi-
stogramm).
In anderem Zusammenhang generiert eine mehrfache Instanzierung erheblichen zusätz-
lichen Aufwand, etwa beim Eingangsbeispiel (siehe Abbildung 30 (b)). Wir nehmen an,
ein GraphischesObjekt habe ein Attribut BoundingBox, welches einen Verweis auf das
umgebende Rechteck enthält – etwa zur Effizienzsteigerung bei Berührungsabfragen.

141
7. Übersetzung objektorientierter Sprachen

(a) mehrfache Instanzierung: (b) einfache Instanzierung:


::SampleStatistics ::GraphischesObjekt

... -verschieben(double,double)
-skalieren(double)

::Linienzug ::GeschlossenesObjekt
::SampleHistogram
-laenge() -flaeche()
... -verschieben(double,double)
-skalieren(double)

::TemperaturUndDruck
::Polygon
-Temperaturstatistik()
-Druckhistogramm() -flaeche()

Abbildung 30: Beispiele zu mehrfacher Vererbung

Falls Polygon nun dieses Attribut doppelt instanziert, müssen bei jeder Operation die
beiden Attribute konsistent gehalten werden. Insbesondere müssen ererbte Methoden
wie verschieben dafür erneut überschrieben werden. Hier ist die Lösung mit einfacher
Instanzierung eindeutig die sinnvollere. /

In manchen Fällen kann es sogar wünschenswert sein, gleichzeitig manche Attribute


mehrfach zu instanzieren, aber andere auf ein Exemplar zu reduzieren. Nur wenige
Sprachen wie etwa Eiffel sind derart flexibel.

7.5 Unabhängige Mehrfachvererbung


Falls zwischen zwei beerbten Oberklassen keinerlei Beziehung besteht bzw. eventuell
vorhandene gemeinsame Oberklassen nicht gesondert behandelt werden, spricht man
von unabhängiger Mehrfachvererbung. Eventuell mehrfach geerbte Komponenten wer-
den mehrfach instanziert. Für die Übersetzung wird das Schema der einfachen Verer-
bung passend erweitert: Objekte einer Subklasse enthalten jeweils vollständige Kopien
aller Oberklassen, von denen sie erben – und damit ggfs. auch doppelte Kopien.

:: B1 :: B2

:: C

Das Objekt der Subklasse C besteht dann aus Kopien der Komponenten aller Ober-
klassen und zusätzlich dem durch die Subklasse hinzugefügten Anteil. Die B1-Sicht
auf ein C-Objekt lässt sich problemlos als Präfix herstellen. Problematisch ist hinge-
gen die Bereitstellung einer B2-Sicht auf ein C-Objekt. Ein einfacher Ansatz besteht
darin, die B2-Methodentabelle zu kopieren und für die B2-Sicht einen zusätzlichen Zei-
ger auf die Kopie der B2-Methodentabelle vor den B2-Attributen einzufügen. Dies ist

142
7.5 Unabhängige Mehrfachvererbung

C-Objekt Methodentabelle
C-Ref./ zum C-Objekt
B1-Ref. B1-
B1 Methoden

B2-Ref.
B2-
B2 Methoden

C-
C Methoden
Methodentabelle
für die B2-Sicht

B2-
Methoden

Abbildung 31: Objektsichten bei unabhängiger mehrfacher Vererbung

in Abbildung 31 veranschaulicht. Der Compiler berechnet einen festen Offset für die
Bestimmung der B2-Sicht aus der C-Sicht.
Dieser einfache Ansatz hat folgende Probleme. Zum einen ist der Offset zur Berechnung
der passenden Sicht eines Objektes zur Compilezeit nicht immer bekannt. C-Methoden
benötigen immer eine C-Sicht von Objekten. Falls eine Methode aus B2 in C über-
schrieben wird, benötigt diese also eine C-Sicht auf das Objekt! Jede Methode in der
Methodentabelle für B2-Methoden muss somit ihrerseits wieder einen Offset enthalten,
mit dem die passende Objektsicht hergestellt werden kann. JB: Achtung, Diskrepanz
zw. Quelle und Darstellung. Wilh./Maurer befasst sich durchgehend mit doppelter
Vererbung
Ein weiterer Schwachpunkt ist, dass
der Speicherplatzbedarf bei vielen Ver- C-Ref./
erbungen exponenziell steigt, denn die B1-Ref.
B1-
Methodentabelle der B2-Sicht wird je- B1 Methoden
desmal kopiert. Dies kann vermieden
B2-Ref.
werden, indem die C-Methodentabelle C-
von vorn herein verteilt gespeichert B2 Methoden
C-Metho-
wird (siehe nebenstehende Abbildung); dentabelle
B2-Methoden werden an anderer Stelle C
gesucht als B1- und C-Methoden. Da- B2-
bei ist zu beachten, dass die Methoden- Methoden
tabellen für B1 und B2 ebenfalls wieder
verteilt gespeichert sind.

Pseudocode für einen Methodenaufruf. Wird nun für ein Objekt c eine Methode
c.m(args) aufgerufen, so gehört zu dieser Methode ein Index j in einem Teil, etwa Teil
i, der verteilten Methodentabelle. Der Zeiger auf Teil i besitzt einen bekannten Offset

143
7. Übersetzung objektorientierter Sprachen

oi in der C-Instanz. Die Methodentabelle enthält bei j einen Verweis auf die implemen-
tierende Prozedur sowie einen Offset für die von der Prozedur benötigte Sicht auf das
Objekt. Der folgende Pseudocode beschreibt die Realisierung eines Methodenaufrufs:

mtv = c + oi Sicht auf Teil i der Methodentabelle


und passende Objektsicht
mta = *mtv Anfangsadresse der Methodentabelle
fm = mta[j].proc Prozedur, welche m implementiert
v = mtv - mta[j].offset von fm erwartete Sicht auf das Objekt
(*fm) (v,args); Aufruf der Prozedur

Der Overhead für eine solche Implementierung eines Methodenaufrufs beträgt somit
eine Addition, eine Dereferenzierung, zwei indizierte Zugriffe und eine Subtraktion.

7.6 Abhängige Mehrfachvererbung


Wir diskutieren nun den Fall der abhängigen Mehrfachvererbung, bei der doppelte
Instanzierungen bei mehrfacher Vererbung vermieden werden.

:: A

:: B1 :: B2

:: C

In C sollen die über B1 und B2 vererbten Komponenten von A nur einmal auftreten. Bei
solcher einfachen Instanzierung bestehen die folgenden Probleme:

• Was geschieht, wenn sowohl B1, als auch B2 eine von A ererbte Methode überschrei-
ben? Die einfache Instanzierung müsste sich für die eine oder andere entscheiden.
Einzig sinnvoll ist in diesem Fall, dass die Methode auch in C überschrieben
werden muss.

• Die Mischung von B1 und B2 könnte Klasseninvarianten verletzen.


Die von B2 ererbten Methoden könnten von B1 ererbte Komponenten so modifi-
zieren, dass B1-Invarianten verletzt werden.

Im Folgenden wird ein hybrides Übersetzungsschema vorgestellt, welches die mehrfache


/ unabhängige und die einfache / abhängige Vererbung unterstützt. In der Sprache
Eiffel etwa wird einfach vererbt, falls Komponenten namensgleich sind, und mehrfach
bei verschiedenen Namen. Dabei können zusätzlich ererbte Komponenten umbenannt
werden, so dass der Programmierer Einfluss auf die Instanzierung hat.

144
7.6 Abhängige Mehrfachvererbung

Wir betrachten die folgende Objektstruktur aus Programmsicht:

B2 C erbt von B1 und B2.


Für überlappende Bereiche wird einfach instanziert.
Damit liegen die B2-Komponenten von C nicht mehr
B1 in zusammenhängendem Speicher, sondern werden von
Lücken (variabler Größe!) unterbrochen. Die B2-Sicht
B2 lässt sich nicht mehr durch einen einfachen Zeiger reali-
sieren.
Ferner sind die relativen Adressen der Komponenten
nicht statisch zu bestimmen und konstant, sondern kon-
B2 textabhängig und somit variabel:

• B2-Objekt: zusammenhängender Bereich

• B2-Sicht (eines C-Objekts): von Lücken unterbro-


C chener Bereich

Die Bindung von Komponentenadressen muss analog zur dynamischen Bindung von
Methoden (über die Methodentabelle) dynamisch mittels einer Indextabelle erfolgen:
die C-Referenz liefert eine Indextabelle, welche (zur Laufzeit) die Offsets aller Kompo-
nenten liefert (i.a. zusammen mit den Methoden).
Wie bei den Methoden werden hier zunächst mehrere Indextabellen für verschiedene
Sichten benötigt. Aber die Tabelle für die B2-Sicht auf C unterscheidet sich vom ent-
sprechenden Teil der C-Sicht nur durch eine konstante Differenz, nämlich der Stelle, an
der sich der Zeiger auf diese Tabelle befindet (der Zugriff wird ja generell mit Offsets,
also relativ zur Adresse der Tabelle selbst, realisiert). Hierdurch ist eine Kombination
von Methoden- und Indextabelle möglich.

Übersetzung eines Methodenaufrufs. Wie bei unabhängiger Mehrfachvererbung


wird ein Methodenaufruf c.m(args) eines Objekts c über die Indextabelle zur Laufzeit
gebunden, hier wie folgt (j sei Index der Methode):

it = *c Indextabelle für Klasse C


o = c - *it Der erste Eintrag der Indextabelle co = *it
liefert den Offset des C-Subobjektes, das über
c-*it referenziert wird
fm = it[j].proc Code-Adresse von m für das Objekt
v = o + it[j].offset von fm erwartete Sicht auf das Objekt
(*fm) (v,args); Aufruf der Prozedur

Hier beträgt der Overhead eine Addition, zwei Dereferenzierungen, zwei indizierte Zu-
griffe sowie eine Subtraktion.

Übersetzung eines Attributzugriffs c.a. j sei der Index von Attribut a in der
Indextabelle.

145
7. Übersetzung objektorientierter Sprachen

it = *c Indextabelle für Klasse C


o = c - *it Referenz des C-Subobjektes
ao = it[j] Offset von a in o
...o[ao] ... Zugriff auf c.a

Der Overhead besteht aus zwei Dereferenzierungen, einem indizierten Zugriff und einer
Subtraktion. Oft werden it und o fest sein und in Registern bereitgehalten, so dass
der Overhead drastisch reduziert werden kann.

146
Literatur
Grundlagen
Drachenbuch: Aho/Lam/Sethi/Ullman: Compiler — Prinzipien, Techniken, and Werk-
zeuge, Pearson Studium 2008.
Aho/Ullman: The Theory of Parsing, Translation, and Compiling, Vol. I: Parsing, Vol.
II: Compiling, Prentice Hall 1972.
Waite/Goos: Compiler Construction, Springer Verlag 1994.
Wilhelm/Maurer: Übersetzerbau: Theorie, Konstruktion, Generierung, Springer Verlag
1992.
Wirth: Grundlagen und Techniken des Compilerbaus, Addison-Wesley 1996.
Appel: Modern Compiler Implementation in ML/Java/C++, Addison-Wesley 1998.
Bauer/Höllerer: Übersetzung objektorientierter Programmiersprachen, Springer Verlag
1998.

Funktionale Techniken
Reade: Elements of Functional Programming, Addison-Wesley 1989.
Peña: Compiler Construction in a Functional Setting, Univ. Complutense de Madrid
1999.
Jones: Hugs, the interpreter for Haskell, http://www.haskell.org/hugs/
The Glasgow Haskell Compiler, http://www.haskell.org/ghc/

Auf der WWW-Seite


http://haskell.org/haskellwiki/Applications and libraries/Compiler tools
finden sich Hinweise auf Werkzeuge, die die Compiler-Entwicklung mit Haskell un-
terstützen, unter anderem:

• Dornan/Jones/Marlow: Alex: A lexical analyser generator for Haskell,


http://www.haskell.org/alex

• Gill/Marlow: Happy: the parser generator for Haskell, Univ. of Glasgow, 1996,
http://www.haskell.org/happy

• Leijen: Parsec, a fast combinator parser,


http://www.cs.ruu.nl/~daan/parsec.html
siehe hierzu auch: Hutton/Meijer: Monadic Parser Combinators,
http://www.cs.nott.ac.uk/Department/Staff/gmh/monparsing.ps

147

Das könnte Ihnen auch gefallen