Der Compiler ist das wichtigste Werkzeug in der Informatik. In der Königsdisziplin der
Informatik schließt sich der Kreis, hier kommen die unterschiedlichen Algorithmen und
Datenstrukturen und Programmiersprachenkonzepte zur Anwendung.
In diesem Modul geht es um ein grundlegendes Verständnis für die wichtigsten Konzepte
im Compilerbau. Wir schauen uns dazu relevante aktuelle Tools und Frameworks an und
setzen diese bei der Erstellung eines kleinen Compiler-Frontends für C++ ein.
Überblick Modulinhalte
Lexikalische Analyse: Scanner/Lexer
Reguläre Sprachen
Generierung mit ANTLR
Syntaxanalyse: Parser
Kontextfreie Grammatiken (CFG)
LL-Parser (Top-Down-Parser)
Generierung mit ANTLR
Semantische Analyse: Attributierte Grammatiken und Symboltabellen
Online-Sitzungen per Zoom (Zugangsdaten siehe ILIAS IFM 3.1 CB (PO23, 3. Semester)).
Sie können hierzu den Raum J101 (siehe Stundenplan) nutzen.
Vorlesung (2 SWS)
Praktikum (2 SWS)
Mi, 08:00 - 09:30 Uhr (online)
S5, G1: Mi, 09:45 - 11:15 Uhr (online)
(Carsten: Flipped Classroom, BC: Vorlesung)
S5, G2: Mi, 09:45 - 11:15 Uhr (online)
S5, G3: Mi, 09:45 - 11:15 Uhr (online)
(Carsten: online, BC: online)
Online-Sitzungen per Zoom (Zugangsdaten siehe IFM 5.21 CB (PO18, 5. Semester)).
Sie können hierzu den Raum J101 (siehe Stundenplan) nutzen.
Fahrplan
News
Parcoursprüfung: Feedbackgespräche am Do, 30.01., 15:30-18:00 Uhr
Wir bieten am Donnerstag, 30.01.25, von 15:30 bis 18:00 Uhr Feedbackgespräche zur Bewertung
der Ergebnisse von B07 (Parcoursprüfung, Station 2) an.
Es ist eine vorherige eine Anmeldung im ILIAS notwendig.
Planung Parcoursprüfung Station 2: Bitte tragt euch als Team im Etherpad ein
Wie bereits angekündigt, finden KW4 (Mo. 20.01., Di. 21.01., Fr. 24.01.) die Vorstellungen von B07
im Rahmen der Parcoursprüfung statt (Station 2). Jedes Team hat 20 Minuten für die Vorstellung der
wichtigsten Aspekte zum Projekt, danach sind 10 Minuten für Fragen und Diskussion eingeplant.
Bitte tragt euch bis zum 12. Januar als Team jeweils zu einem der Zeitslots im Etherpad ein. Ihr
findet den Link im ILIAS.
Wenn die vorgeschlagenen Termine nicht reichen sollten, meldet euch bitte zeitnah per E-Mail!
Nachtrag zur Anpassung der Gewichtung der beiden Stationen der Parcoursprüfung (war 29.11.)
Es war ursprünglich vorgesehen, die Gesamtnote als Mittelwert der Noten der beiden
Parcours-Stationen zu berechnen: Gewichtung 50% (Station I) und 50% (Station II).
Am 29.11. haben wir auf vielfachen Wunsch hin die Gewichtung der Stationen der
Parcoursprüfung angepasst: Gewichtung 33 Punkte (Station I) und 50 Punkte (Station II).
Um eine potentielle Benachteiligung zu vermeiden, werden wir eine individuelle
Günstigerprüfung vornehmen: Wir werden für jede Person jeweils beide Verhältnisse
berechnen und automatisch die bessere Note werten.
Zusätzlich werden für Station I 3 Punkte Überhang gewährt. Von den 33 maximal
erreichbaren Punkten werden 30 Punkte als 100% gewertet, darüber hinausgehende
Punkte bleiben als Bonuspunkte erhalten.
Dies gilt für die Prüfung im ersten Zeitraum.
Da die Projektwoche (16.-20.12.2024) mangels Interesse nicht stattfinden wird, biete ich eine
zusätzliche Sprechstunde im Vorlesungsslot am 18.12. an.
Anpassung der Gewichtung der Parcours-Stationen
Auf vielfachen Wunsch passen wir die Gewichtung der Stationen der Parcoursprüfung an:
Station I: 33 Punkte
Station II: 50 Punkte
Ergebnisse und Einsicht Parcoursprüfung: Station 1 ILIAS
Die erste Station der Parcoursprüfung vom 20.11. ist korrigiert.
Die Einsicht findet von Montag, 02.12., 08:00 Uhr bis Donnerstag, 05.12., 16:00 Uhr online statt. Gehen
Sie dazu bitte selbstständig auf den E-Assessment-Server (https://eassessment.hsbi.de/goto.php?target=crs_7284,
via VPN) und schauen sich unter "Ergebnisse" die Bewertung Ihrer Antworten an. Wenn dabei Fragen auftreten,
schicken Sie bitte bis Donnerstag, 05.12., 16:00 Uhr eine kurze Mail an Carsten Gips mit Ihren konkreten Fragen.
Wir werden dann mit Ihnen für Freitag (06.12., zw. 15 und 17 Uhr) einen kurzen Gesprächstermin vereinbaren.
Organisation Parcoursprüfung: Station 1 ILIAS
Die erste Station der Parcoursprüfung findet wie im Fahrplan beschrieben am Mittwoch,
den 20. November im B40 statt. Wir schreiben in zwei Durchgängen:
09:45 - 10:30 Uhr: alle Studierenden, deren Nachname mit den Buchstaben "A" bis "L" beginnt,
10:45 - 11:30 Uhr: alle Studierenden, deren Nachname mit den Buchstaben "M" bis "Z" beginnt.
Thematisch geht es um die Themen der Wochen 41 bis 45, also insbesondere Lexer, CFG, LL-Parser
und ANTLR.
Die Prüfung wird auf einem ILIAS-System durchgeführt. Bitte denkt an euren Usernamen und das Passwort.
Erlaubtes Hilfsmittel: Ein handschriftlich beschriebenes DIN-A4-Blatt (Vorder- und Rückseite können
genutzt werden).
Verschiebung Praktikum zu B02 für Gruppe 3: Do, 07.11., 08:00 Uhr
Wie bereits in der Vorlesung angekündigt, muss das Praktikum für Blatt 02 für Gruppe 3 aus
gesundheitlichen Gründen leider von Montag, 04.11. auf Donnerstag, 07.11. verschoben werden.
Start ist um 08:00 Uhr.
Info zum ANTLR-Meeting mit Edmonton am Di, 29.10.
Am Dienstag, den 29.10., treffen wir uns wie angekündigt um 18 Uhr zum ersten Meeting mit den Studis und
Kollegen von der University of Alberta (Edmonton, Kanada). Dazu nutzen wir unseren Zoom (vgl.
ILIAS IFM 3.1 CB (PO23, 3. Semester)).
Bitte fügt eurem im Zoom angezeigten Namen ein " (DE)" hinten an.
Beispiel: Euer angezeigter Name wäre normalerweise Vorname Nachname. Für die Sitzung am Dienstag hängt
ihr bitte ein "(DE)" hinten dran und habt entsprechend den Anzeigenamen Vorname Nachname (DE).
Wir freuen uns auf eine spannende Einführung in ANTLR und ein lustiges Meeting!
Zeitänderung Gruppe 2 am 24.01.2025
Das letzte Praktikum von Gruppe 2 (am Fr, 24.01.2025) findet eine Stunde später
statt als sonst: 15:00 - 16:30 Uhr.
Update B01
Bitte beachten Sie die Aktualisierung von B01.
Die bisherigen Aufgaben 5 und 6 kamen versehentlich doppelt vor. Dies wurde
korrigiert, d.h. es gibt jetzt eine Aufgabe weniger und Aufgabe 2 hat einen
dritten Punkt neu dazu bekommen.
Teams
Bitte bearbeiten Sie die Aufgaben in 3er Teams.
Es ist empfehlenswert, wenn alle Personen im Team in der selben Stundenplangruppe
(Praktikumsgruppe) sind. Sie können aber auch Teams über verschiedene Gruppen
hinweg bilden. Geben Sie individuell im ILIAS ab und stellen Sie die Lösung dann
individuell in Ihrer jeweiligen Praktikumsgruppe vor.
Sie können Ihre Teams jederzeit selbstständig im Semester ändern/wechseln.
Achtung: Den Vortrag zu B07 müssen Sie aber gemeinsam als Team halten.
Wenn Ihre Teammitglieder aus verschiedenen Stundenplangruppen kommen sollten,
findet der Vortrag in der Gruppe statt, aus der die meisten der Teammitglieder
kommen. Bitte beachten Sie das bei der Teamwahl!
Abgabe ILIAS
Das Abgabeverfahren im ILIAS wurde deutlich vereinfacht: Jede Person gibt für
jedes Blatt individuell direkt im ILIAS an, welche Aufgaben das Team bearbeitet
hat (also keine Teambildung und keine Textdatei mehr im ILIAS).
Bitte darauf achten, dass Sie das die angegebenen Aufgaben auch vorstellen können
müssen! Achten Sie bitte auch auf die mind. 60% für das Testat.
Praktikum
Sie gehen einfach in das Praktikum, welches Ihrer Stundenplangruppe zugeordnet ist
und stellen dort individuell die Lösungen Ihres Teams vor.
Den Vortrag zu B07 (letzte VL-Woche) halten Sie dann in die Praktikumsgruppe, in
der die meisten Teammitglieder sind. Bitte prüfen Sie, ob das für Sie passt (vom
Stundenplan her möglich ist) und wählen Sie ggf. ein anderes Team. Wir werden
rechtzeitig eine entsprechende Planung organisieren. Einige Vorträge werden wir
aber auch im Vorlesungsslot machen müssen, d.h. hier besteht bei Bedarf eine
Ausweichmöglichkeit.
Parcoursprüfung: Do, 27. Mar 2025, 08-18 Uhr, mdl. Prüfung (alle Themen) (je Prüfung ca. 45', Vergabe ca. 2 Wochen vorher)
News
Parcoursprüfung: Feedbackgespräche am Do, 30.01., 14:00-15:30 Uhr
Wir bieten am Donnerstag, 30.01.25, von 14:00 bis 15:30 Uhr Feedbackgespräche zur Bewertung
der Ergebnisse von B07x (Parcoursprüfung, Station 2) an.
Es ist eine vorherige eine Anmeldung im ILIAS notwendig.
Planung Parcoursprüfung Station 2: Bitte tragt euch als Team im Etherpad ein
Wie bereits angekündigt, finden am Mittwoch, den 22. Januar die Vorstellungen von B07x im
Rahmen der Parcoursprüfung statt (Station 2). Jedes Team hat 20 Minuten für die Vorstellung
der wichtigsten Aspekte zum Projekt, danach sind 10 Minuten für Fragen und Diskussion
eingeplant.
Bitte tragt euch bis zum 12. Januar als Team jeweils zu einem der Zeitslots im Etherpad ein.
Ihr findet den Link im ILIAS.
Anpassung der Gewichtung der beiden Stationen der Parcoursprüfung
Es war ursprünglich vorgesehen, die Gesamtnote als Mittelwert der Noten der beiden
Parcours-Stationen zu berechnen: Gewichtung 50% (Station 1) und 50% (Station 2).
In Anlehnung an die Anpassung der Notenberechnung für das dritte Semester bieten
wir an, die Gesamtnote alternativ mit der Gewichtung 40% (Station 1) und 60%
(Station 2) zu berechnen.
Wir werden für jede Person jeweils beide Verhältnisse berechnen und automatisch die
bessere Note werten (individuelle Günstigerprüfung).
Dies gilt für die Prüfung im ersten Zeitraum.
Feedback zu den Ergebnissen der Station 1 der Parcoursprüfung
Wir möchten Ihnen im Praktikum am 18.12. ein Feedback zu den Ergebnissen der Station 1
der Parcoursprüfung geben.
Umplanung der Vorträge zur ersten Station der Parcoursprüfung
Die Vorträge zur Station 1 der Parcoursprüfung waren ursprünglich für den 20.11. (zur Probe) und
dann live auf dem zweiten Edmonton-Meeting (26.11., mit Bewertung) geplant.
Aus zeitlichen Gründen müssen wir leider etwas umplanen und die Vorträge um eine bzw. zwei Woche(n)
verschieben. Wir werden dazu die Teams auf zwei Termine aufteilen: Die ersten drei Teams werden im
Praktikum am 27.11. vortragen und die restlichen zwei Teams im Praktikum am 04.12. ... Abgabe für
alle Teams im ILIAS ist aber bereits der 27.11.(!)
Info zum ANTLR-Meeting mit Edmonton am Di, 29.10.
Am Dienstag, den 29.10., treffen wir uns wie angekündigt um 18 Uhr zum ersten Meeting mit den Studis und
Kollegen von der University of Alberta (Edmonton, Kanada). Dazu nutzen wir unseren Zoom (vgl.
IFM 5.21 CB (PO18, 5. Semester)).
Bitte fügt eurem im Zoom angezeigten Namen ein " (DE)" hinten an.
Beispiel: Euer angezeigter Name wäre normalerweise Vorname Nachname. Für die Sitzung am Dienstag hängt
ihr bitte ein "(DE)" hinten dran und habt entsprechend den Anzeigenamen Vorname Nachname (DE).
Wir freuen uns auf eine spannende Einführung in ANTLR und ein lustiges Meeting!
Verschiebung des Praktikums zu Blatt 01 auf 30.10., 16 Uhr
Das Praktikum zur Vorstellung von Blatt 01 musste aus gesundheitlichen Gründen
um eine Woche verschoben werden.
Neuer Termin für die Vorstellung von Blatt 01: Mi, 30.10., 16:00 - 17:30 Uhr.
Update B01
Bitte beachten Sie die Aktualisierung von B01.
Die bisherigen Aufgaben 5 und 6 kamen versehentlich doppelt vor. Dies wurde
korrigiert, d.h. es gibt jetzt eine Aufgabe weniger und Aufgabe 2 hat einen
dritten Punkt neu dazu bekommen.
Teams
Bitte bearbeiten Sie die Aufgaben in 3er Teams.
Es ist empfehlenswert, wenn alle Personen im Team in der selben Stundenplangruppe
(Praktikumsgruppe) sind. Sie können aber auch Teams über verschiedene Gruppen
hinweg bilden. Geben Sie individuell im ILIAS ab und stellen Sie die Lösung dann
individuell in Ihrer jeweiligen Praktikumsgruppe vor.
Sie können Ihre Teams jederzeit selbstständig im Semester ändern/wechseln.
Achtung: Den Vortrag zu B07x müssen Sie (wie auch den Vortrag zu B04x) aber
gemeinsam als Team halten. Wenn Ihre Teammitglieder aus verschiedenen
Stundenplangruppen kommen sollten, findet der Vortrag in der Gruppe statt, aus der
die meisten der Teammitglieder kommen. Bitte beachten Sie das bei der Teamwahl!
Abgabe ILIAS
Das Abgabeverfahren im ILIAS wurde deutlich vereinfacht: Jede Person gibt für
jedes Blatt individuell direkt im ILIAS an, welche Aufgaben das Team bearbeitet
hat (also keine Teambildung und keine Textdatei mehr im ILIAS).
Bitte darauf achten, dass Sie das die angegebenen Aufgaben auch vorstellen können
müssen! Achten Sie bitte auch auf die mind. 60% für das Testat.
Praktikum
Sie gehen einfach in das Praktikum, welches Ihrer Stundenplangruppe zugeordnet ist
und stellen dort individuell die Lösungen Ihres Teams vor.
Den Vortrag zu B07x (letzte VL-Woche) halten Sie dann in die Praktikumsgruppe, in
der die meisten Teammitglieder sind. Bitte prüfen Sie, ob das für Sie passt (vom
Stundenplan her möglich ist) und wählen Sie ggf. ein anderes Team. Wir werden
rechtzeitig eine entsprechende Planung organisieren. Einige Vorträge werden wir
aber auch im Vorlesungsslot machen müssen, d.h. hier besteht bei Bedarf eine
Ausweichmöglichkeit.
Parcoursprüfung: Do, 27. Mar 2025, 08-18 Uhr, mdl. Prüfung (alle Themen) (je Prüfung ca. 45', Vergabe ca. 2 Wochen vorher)
Prüfungsform, Note und Credits
Parcoursprüfung plus Testat, 5 ECTS (PO23)
Testat: Vergabe der Credit-Points
Mindestens 4 der Übungsblätter B01, B02, B03, B04, B05 und B06 erfolgreich bearbeitet, und
aktive Teilnahme an allen 3 Edmonton-Terminen.
("erfolgreich bearbeitet": Bearbeitung in 3er Teams, je mindestens 60% bearbeitet,
fristgerechte Abgabe der Lösungen im ILIAS, Vorstellung der Lösungen im Praktikum)
Stationen:
ILIAS-Test (einzeln, 20.11.)
Vorstellung Mini-Projekt B07 (3er Teams, letzte VL-Woche)
Note für das Modul: Beide Stationen ergeben zu je 50%oder in der Gewichtung
30 Punkte (Station I) und 50 Punkte (Station II) die Gesamtnote (individuelle
Günstigerprüfung).
Für Station I werden 3 Punkte Überhang gewährt: Von den 33 maximal erreichbaren
Punkten werden 30 Punkte als 100% gewertet, darüber hinausgehende Punkte bleiben
als Bonuspunkte erhalten.
Stationen:
Mündliche Prüfung (individuell, ca. 45 Minuten, zweiter Prüfungszeitraum)
Die Note der mündlichen Prüfung ergibt die Gesamtnote.
Parcoursprüfung plus Testat, 5 ECTS (PO18)
Testat: Vergabe der Credit-Points
Mindestens 4 der Übungsblätter B01, B02, B03, B04x, B05x und B06x erfolgreich bearbeitet, und
aktive Teilnahme an allen 3 Edmonton-Terminen.
("erfolgreich bearbeitet": Bearbeitung in 3er Teams, je mindestens 60% bearbeitet,
fristgerechte Abgabe der Lösungen im ILIAS, Vorstellung der Lösungen im Praktikum)
Stationen:
Vortrag (3er Team, 27.11. bzw. 04.12. im Praktikum): Vorstellung der Lösung von B04x
Vorstellung Mini-Projekt B07x (3er Teams, letzte VL-Woche)
Note für das Modul: Beide Stationen ergeben zu je 50%oder in der Gewichtung
40% (Station 1) und 60% (Station 2) die Gesamtnote (individuelle Günstigerprüfung).
Stationen:
Mündliche Prüfung (individuell, ca. 45 Minuten, zweiter Prüfungszeitraum)
Die Note der mündlichen Prüfung ergibt die Gesamtnote.
Kooperation mit University of Alberta, Edmonton (Kanada)
Über das Projekt "We CAN virtuOWL" der Fachhochschule Bielefeld ist im Frühjahr 2021 eine
Kooperation mit der University of Alberta (Edmonton/Alberta, Kanada) im Modul "Compilerbau"
gestartet.
Wir freuen uns, auch in diesem Semester wieder drei gemeinsame Sitzungen für beide
Hochschulen anbieten zu können. (Diese Termine werden in englischer Sprache durchgeführt.)
Compiler übersetzen (formalen) Text in ein anderes Format.
Typischerweise kann man diesen Prozess in verschiedene Stufen/Phasen einteilen. Dabei
verarbeitet jede Phase den Output der vorangegangenen Phase und erzeugt ein (kompakteres)
Ergebnis, welches an die nächste Phase weitergereicht wird. Dabei nimmt die Abstraktion
von Stufe zu Stufe zu: Der ursprüngliche Input ist ein Strom von Zeichen, daraus wird ein
Strom von Wörtern (Token), daraus ein Baum (Parse Tree), Zwischencode (IC), ...
Die gezeigten Phasen werden traditionell unterschieden. Je nach Aufgabe können verschiedene
Stufen zusammengefasst werden oder sogar gar nicht auftreten.
(K2) Struktur eines Compilers: Phasen und deren Aufgaben
Sprachen verstehen, Texte transformieren
The cat runs quickly.
=> Struktur? Bedeutung?
Wir können hier (mit steigender Abstraktionsstufe) unterscheiden:
Sequenz von Zeichen
Wörter: Zeichenketten mit bestimmten Buchstaben, getrennt durch bestimmte
andere Zeichen; Wörter könnten im Wörterbuch nachgeschlagen werden
Sätze: Anordnung von Wörtern nach einer bestimmten Grammatik, Grenze: Satzzeichen
Hier (vereinfacht): Ein Satz besteht aus Subjekt und Prädikat. Das Subjekt
besteht aus einem oder keinen Artikel und einem Substantiv. Das Prädikat
besteht aus einem Verb und einem oder keinem Adverb.
Sprache: Die Menge der in einer Grammatik erlaubten Sätze
Die obige Bergsteige-Metapher kann man in ein nüchternes Ablaufdiagramm mit verschiedenen
Stufen und den zwischen den Stufen ausgetauschten Artefakten übersetzen:
Frontend, Analyse
Die ersten Stufen eines Compilers, die mit der Analyse des
Inputs beschäftigt sind. Dies sind in der Regel der Scanner, der
Parser und die semantische Analyse.
Scanner, Lexer, Tokenizer, Lexikalische Analyse
Zerteilt den Zeichenstrom in eine Folge von Wörtern. Mit regulären
Ausdrücken kann definiert werden, was Klassen gültiger Wörter ("Token")
sind. Ein Token hat i.d.R. einen Namen und einen Wert.
Parser, Syntaxanalyse
Der Parser erhält als Eingabe die Folge der Token und versucht mit
Hilfe einer Grammatik zu bestimmen, ob es sich bei der Tokensequenz
um gültige Sätze im Sinne der Grammatik handelt. Hier gibt es viele
Algorithmen, die im Wesentlichen in die Klassen "top-down" und "bottom-up"
fallen.
Semantische Analyse, Kontexthandling
In den vorigen Stufen wurde eher lokal gearbeitet. Hier wird über den
gesamten Baum und die Symboltabelle hinweg geprüft, ob beispielsweise
Typen korrekt verwendet wurden, in welchen Scope ein Name gehört etc.
Mit diesen Informationen wird der AST angereichert.
Symboltabellen
Datenstrukturen, um Namen, Werte, Scopes und weitere Informationen zu
speichern. Die Symboltabellen werden vor allem beim Parsen befüllt und
bei der semantischen Analyse gelesen, aber auch der Lexer benötigt u.U.
diese Informationen.
Backend, Synthese
Die hinteren Stufen eines Compilers, die mit der Synthese der
Ausgabe beschäftigt sind. Dies sind in der Regel verschiedene
Optimierungen und letztlich die Code-Generierung
Codegenerierung
Erzeugung des Zielprogramms aus der (optimierten) Zwischendarstellung.
Dies ist oft Maschinencode, kann aber auch C-Code oder eine andere
Ziel-Sprache sein.
Optimierung
Diverse Maßnahmen, um den resultierenden Code kleiner und/oder schneller
zu gestalten.
Symboltabellen
Datenstrukturen, um Namen, Werte, Scopes und weitere Informationen zu
speichern. Die Symboltabellen werden vor allem beim Parsen befüllt und
bei der semantischen Analyse gelesen, aber auch der Lexer benötigt u.U.
diese Informationen.
Weitere Begriffe
Parse Tree, Concrete Syntax Tree
Repräsentiert die Struktur eines Satzes, wobei jeder Knoten dem Namen einer
Regel der Grammatik entspricht. Die Blätter bestehen aus den Token samt
ihren Werten.
AST, (Abstract) Syntax Tree
Vereinfachte Form des Parse Tree, wobei der Bezug auf die Element der
Grammatik (mehr oder weniger) weggelassen wird.
Annotierter AST
Anmerkungen am AST, die für spätere Verarbeitungsstufen interessant
sein könnten: Typ-Informationen, Optimierungsinformationen, ...
Zwischen-Code, IC
Zwischensprache, die abstrakter ist als die dem AST zugrunde liegenden
Konstrukte der Ausgangssprache. Beispielsweise könnten while-Schleifen
durch entsprechende Label und Sprünge ersetzt werden. Wie genau dieser
Zwischen-Code aussieht, muss der Compilerdesigner entscheiden. Oft findet
man den Assembler-ähnlichen "3-Adressen-Code".
Sprache
Eine Sprache ist eine Menge gültiger Sätze. Die Sätze werden aus
Wörtern gebildet, diese wiederum aus Zeichenfolgen.
Grammatik
Eine Grammatik beschreibt formal die Syntaxregeln für eine
Sprache. Jede Regel in der Grammatik beschreibt dabei die Struktur eines
Satzes oder einer Phrase.
Lexikalische Analyse: Wörter ("Token") erkennen
Die lexikalische Analyse (auch Scanner oder Lexer oder Tokenizer genannt)
zerteilt den Zeichenstrom in eine Folge von Wörtern ("Token"). Die geschieht
i.d.R. mit Hilfe von regulären Ausdrücken.
Dabei müssen unsinnige/nicht erlaubte Wörter erkannt werden.
Überflüssige Zeichen (etwa Leerzeichen) werden i.d.R. entfernt.
sp = 100;
<ID, sp>, <OP, =>, <INT, 100>, <SEM>
Anmerkung: In der obigen Darstellung werden die Werte der Token ("Lexeme")
zusammen mit den Token "gespeichert". Alternativ können die Werte der Token
auch direkt in der Symboltabelle gespeichert werden und in den Token nur der
Verweis auf den jeweiligen Eintrag in der Tabelle.
Syntaxanalyse: Sätze erkennen
In der Syntaxanalyse (auch Parser genannt) wird die Tokensequenz
in gültige Sätze unterteilt. Dazu werden in der Regel kontextfreie
Grammatiken und unterschiedliche Parsing-Methoden (top-down, bottom-up)
genutzt.
Dabei müssen nicht erlaubte Sätze erkannt werden.
<ID, sp>, <OP, =>, <INT, 100>, <SEM>
statement : assign SEM ;
assign : ID OP INT ;
statement =
/ \ / \
assign SEM sp 100
/ | \ |
ID OP INT ;
| | |
sp = 100
Mit Hilfe der Produktionsregeln der Grammatik wird versucht, die Tokensequenz
zu erzeugen. Wenn dies gelingt, ist der Satz (also die Tokensequenz) ein
gültiger Satz im Sinne der Grammatik. Dabei sind die Token aus der lexikalischen
Analyse die hier betrachteten Wörter!
Dabei entsteht ein sogenannter Parse-Tree (oder auch "Syntax Tree"; in der
obigen Darstellung der linke Baum). In diesen Bäumen spiegeln sich die Regeln
der Grammatik wider, d.h. zu einem Satz kann es durchaus verschiedene
Parse-Trees geben.
Beim AST ("Abstract Syntax Tree") werden die Knoten um alle später nicht
mehr benötigten Informationen bereinigt (in der obigen Darstellung der rechte
Baum).
Anmerkung: Die Begriffe werden oft nicht eindeutig verwendet. Je nach
Anwendung ist das Ergebnis des Parsers ein AST oder ein Parse-Tree.
Anmerkung: Man könnte statt OP auch etwa ein ASSIGN nutzen und müsste
dann das "=" nicht extra als Inhalt speichern, d.h. man würde die Information
im Token-Typ kodieren.
Vorschau: Parser implementieren
stat : assign | ifstat | ... ;
assign : ID '=' expr ';' ;
voidstat() {
switch (<<current token>>) {
case ID : assign(); break;
case IF : ifstat(); break;
...
default : <<raise exception>> }
}
voidassign() {
match(ID);
match('=');
expr();
match(';');
}
Der gezeigte Parser ist ein sogenannter "LL(1)"-Parser und geht von oben nach unten
vor, d.h. ist ein Top-Down-Parser.
Nach dem Betrachten des aktuellen Tokens wird entschieden, welche Alternative
vorliegt und in die jeweilige Methode gesprungen.
Die match()-Methode entspricht dabei dem Erzeugen von Blättern, d.h. hier
werden letztlich die Token der Grammatik erkannt.
Semantische Analyse: Bedeutung erkennen
In der semantischen Analyse (auch Context Handling genannt) wird der AST
zusammen mit der Symboltabelle geprüft. Dabei spielen Probleme wie Scopes,
Namen und Typen eine wichtige Rolle.
Die semantische Analyse ist direkt vom Programmierparadigma der zu
übersetzenden Sprache abhängig, d.h. müssen wir beispielsweise das Konzept
von Klassen verstehen?
Als Ergebnis dieser Phase entsteht typischerweise ein annotierter AST.
Aus dem annotierten AST wird in der Regel ein Zwischencode ("Intermediate Code",
auch "IC") generiert. oft findet man hier den Assembler-ähnlichen "3-Adressen-Code",
in manchen Compilern wird als IC aber auch der AST selbst genutzt.
An dieser Stelle verlassen wir das Compiler-Frontend und begeben uns in das
sogenannte Backend. Die Optimierung des Codes kann sehr unterschiedlich
ausfallen, beispielsweise kann man den Zwischencode selbst optimieren, dann
nach sogenanntem "Targetcode" übersetzen und diesen weiter optimieren, bevor
das Ergebnis im letzten Schritt in Maschinencode übersetzt wird.
Die Optimierungsphase ist sehr stark abhängig von der Zielhardware. Hier kommen
fortgeschrittene Mengen- und Graphalgorithmen zur Anwendung. Die Optimierung
stellt den wichtigsten Teil aktueller Compiler dar.
Aus zeitlichen und didaktischen Gründen werden wir in dieser Veranstaltung den
Fokus auf die Frontend-Phasen legen und die Optimierung nur grob streifen.
t1 = inttofloat(100) => t1 = 100.0
x = y*0; => x = 0;
Code generieren
Maschinencode:
STD t1, 100.0
Andere Sprache:
Bytecode
C
...
Probleme
5*4+3
AST?
Problem: Vorrang von Operatoren
Variante 1: +(*(5, 4), 3)
Variante 2: *(5, +(4, 3))
stat : expr ';'
| ID '(' ')' ';'
;
expr : ID '(' ')'
| INT
;
[Grune2012] Modern Compiler Design Grune, D. und van, Reeuwijk, K. und Bal, H. E. und Jacobs, C. J. H. und Langendoen, K., Springer, 2012. ISBN 978-1-4614-4698-9. Kapitel 1 Introduction
Bandbreite der Programmiersprachen
TL;DR
Am Beispiel des Abzählreims "99 Bottles of Beer" werden (ganz kurz) verschiedene
Programmiersprachen betrachtet. Jede der Sprachen hat ihr eigenes Sprachkonzept
(Programmierparadigma) und auch ein eigenes Typ-System sowie ihre eigene Strategie
zur Speicherverwaltung und Abarbeitung.
Auch wenn die Darstellung längst nicht vollständig ist, macht sie doch deutlich,
dass Compiler teilweise sehr unterschiedliche Konzepte "verstehen" müssen.
#define MAXBEER (99)
voidchug(int beers);
main() {
register beers;
for(beers = MAXBEER; beers; chug(beers--)) puts("");
puts("\nTime to buy more beer!\n");
}
voidchug(register beers) {
char howmany[8], *s;
s = beers !=1?"s":"";
printf("%d bottle%s of beer on the wall,\n", beers, s);
printf("%d bottle%s of beeeeer . . . ,\n", beers, s);
printf("Take one down, pass it around,\n");
if(--beers) sprintf(howmany, "%d", beers); elsestrcpy(howmany, "No more");
s = beers !=1?"s":"";
printf("%s bottle%s of beer on the wall.\n", howmany, s);
}
Quelle: "Language C" by Bill Wein on 99-bottles-of-beer.net
Imperativ
Procedural
Statisches Typsystem
Resourcenschonend, aber "unsicher": Programmierer muss wissen, was er tut
Relativ hardwarenah
Einsatz: Betriebssysteme, Systemprogrammierung
Imperativ, Objektorientiert: Java
classbottles {
publicstaticvoidmain(String args[]) {
String s ="s";
for (int beers=99; beers>-1;) {
System.out.print(beers +" bottle"+ s +" of beer on the wall, ");
System.out.println(beers +" bottle"+ s +" of beer, ");
if (beers==0) {
System.out.print("Go to the store, buy some more, ");
System.out.println("99 bottles of beer on the wall.\n");
System.exit(0);
} else System.out.print("Take one down, pass it around, ");
s = (--beers == 1)?"":"s";
System.out.println(beers +" bottle"+ s +" of beer on the wall.\n");
}
}
}
Quelle: "Language Java" by Sean Russell on 99-bottles-of-beer.net
Imperativ
Objektorientiert
Multi-Threading
Basiert auf C/C++
Statisches Typsystem
Automatische Garbage Collection
"Sichere" Architektur: Laufzeitumgebung fängt viele Probleme ab
Architekturneutral: Nutzt Bytecode und eine JVM
Einsatz: High-Level All-Purpose Language
Logisch: Prolog
bottles:-bottles(99).
bottles(1) :-
write('1 bottle of beer on the wall, 1 bottle of beer,'), nl,
write('Take one down, and pass it around,'), nl,
write('Now they are all gone.'), nl,!.
bottles(X) :-
write(X), write(' bottles of beer on the wall,'), nl,
write(X), write(' bottles of beer,'), nl,
write('Take one down and pass it around,'), nl,
NX is X -1,
write(NX), write(' bottles of beer on the wall.'), nl, nl,
bottles(NX).
Logisch: Definition von Fakten und Regeln; eingebautes Beweissystem
Einsatz: Theorem-Beweisen, Natural Language Programming (NLP), Expertensysteme, ...
Funktional: Haskell
bottles0="no more bottles"bottles1="1 bottle"bottles n = show n ++" bottles"verse0="No more bottles of beer on the wall, no more bottles of beer.\n"++"Go to the store and buy some more, 99 bottles of beer on the wall."verse n = bottles n ++" of beer on the wall, "++ bottles n ++" of beer.\n"++"Take one down and pass it around, "++ bottles (n-1)
++" of beer on the wall.\n"main= mapM (putStrLn . verse) [99,98..0]
Imperativ, objektorientiert, Funktionen als First Class Citizens, Closures
Dynamisch typisiert
Garbage Collector
Statements und Expressions
(Kleine) Standardbibliothek eingebaut
Die Sprache ähnelt stark anderen modernen Sprachen und ist gut geeignet, um an ihrem
Beispiel Themen wie Scanner/Parser/AST, Interpreter, Object Code und VM zu studieren :)
Wrap-Up
Compiler übersetzen formalen Text in ein anderes Format
[Grune2012] Modern Compiler Design Grune, D. und van, Reeuwijk, K. und Bal, H. E. und Jacobs, C. J. H. und Langendoen, K., Springer, 2012. ISBN 978-1-4614-4698-9. Kapitel 1 Introduction
Anwendungen
TL;DR
Es gibt verschiedene Anwendungsmöglichkeiten für Compiler. Je nach Bedarf wird dabei
die komplette Toolchain durchlaufen oder es werden Stufen ausgelassen. Häufig genutzte
Varianten sind dabei:
"Echte" Compiler: Übersetzen Sourcecode nach ausführbarem Maschinencode
Interpreter: Interaktive Ausführung von Sourcecode
Virtuelle Maschinen als Zwischending zwischen Compiler und Interpreter
Transpiler: Übersetzen formalen Text nach formalem Text
Analysetools: Parsen den Sourcecode, werten die Strukturen aus
(K1) Verschiedene Anwendungen für Compiler durch Einsatz bestimmter Stufen der Compiler-Pipeline
Anwendung: Compiler
Wie oben diskutiert: Der Sourcecode durchläuft alle Phasen des Compilers,
am Ende fällt ein ausführbares Programm heraus. Dieses kann man starten und
ggf. mit Inputdaten versehen und erhält den entsprechenden Output. Das
erzeugte Programm läuft i.d.R. nur auf einer bestimmten Plattform.
Beispiele: gcc, clang, ...
Anwendung: Interpreter
Beim Interpreter durchläuft der Sourcecode nur das Frontend, also die Analyse.
Es wird kein Code erzeugt, stattdessen führt der Interpreter die Anweisungen
im AST bzw. IC aus. Dazu muss der Interpreter mit den Eingabedaten beschickt
werden. Typischerweise hat man hier eine "Read-Eval-Print-Loop" (REPL).
Beispiele: Python
Anwendung: Virtuelle Maschinen
Hier liegt eine Art Mischform aus Compiler und Interpreter vor: Der Compiler
übersetzt den Quellcode in ein maschinenunabhängiges Zwischenformat
("Byte-Code"). Dieser wird von der virtuellen Maschine ("VM") gelesen und
ausgeführt. Die VM kann also als Interpreter für Byte-Code betrachtet werden.
Beispiel: Java mit seiner JVM
Anwendung: C-Toolchain
Erinnern Sie sich an die LV "Systemprogrammierung" im dritten Semester :-)
Auch wenn es so aussieht, als würde der C-Compiler aus dem Quelltext direkt das
ausführbare Programm erzeugen, finden hier dennoch verschiedene Stufen statt.
Zuerst läuft ein Präprozessor über den Quelltext und ersetzt alle #include
und #define etc., danach arbeitet der C-Compiler, dessen Ausgabe wiederum
durch einen Assembler zu ausführbarem Maschinencode transformiert wird.
Beispiele: gcc, clang, ...
Anwendung: C++-Compiler
C++ hat meist keinen eigenen (vollständigen) Compiler :-)
In der Regel werden die C++-Konstrukte durch cfront nach C übersetzt, so dass
man anschließend auf die etablierten Tools zurückgreifen kann.
Dieses Vorgehen werden Sie relativ häufig finden. Vielleicht sogar in Ihrem
Projekt ...
Beispiel: g++
Anwendung: Bugfinder
Tools wie FindBugs analysieren den (Java-) Quellcode und suchen nach bekannten
Fehlermustern. Dazu benötigen sie nur den Analyse-Teil eines Compilers!
Auf dem AST kann dann nach vorab definierten Fehlermustern gesucht werden
(Stichwort "Graphmatching"). Dazu fällt die semantische Analyse entsprechend
umfangreicher aus als normal.
Zusätzlich wird noch eine Reporting-Komponente benötigt, da die normalen
durch die Analysekette erzeugten Fehlermeldungen nicht helfen (bzw. sofern
der Quellcode wohlgeformter Code ist, würden ja keine Fehlermeldungen durch
die Analyseeinheit generiert).
Beispiele: SpotBugs, Checkstyle, ESLint, ...
Anwendung: Pandoc
Pandoc ist ein universeller und modular aufgebauter
Textkonverter, der mit Hilfe verschiedener Reader unterschiedliche
Textformate einlesen und in ein Zwischenformat (hier JSON) transformieren
kann. Über verschiedene Writer können aus dem Zwischenformat dann Dokumente
in den gewünschten Zielformaten erzeugt werden.
Die Reader entsprechen der Analyse-Phase und die Writer der Synthese-Phase
eines Compilers. Anstelle eines ausführbaren Programms (Maschinencode) wird
ein anderes Textformat erstellt/ausgegeben.
Beispielsweise wird aus diesem Markdown-Schnipsel ...
Dies ist ein Satz mit
* einem Stichpunkt, und
* einem zweiten Stichpunkt.
Dies ist ein Satz mit
\begin{itemize}
\tightlist\item einem Stichpunkt, und
\item einem zweiten Stichpunkt.
\end{itemize}
Im Prinzip ist Pandoc damit ein Beispiel für Compiler, die aus einem formalen Text nicht
ein ausführbares Programm erzeugen (Maschinencode), sondern einen anderen formalen Text.
Dieser werden häufig auch "Transpiler" genannt.
Weitere Beispiele:
Lexer-/Parser-Generatoren: ANTLR, Flex, Bison, ...: formale Grammatik nach Sourcecode
CoffeeScript: CoffeeScript (eine Art "JavaScript light") nach JavaScript
Emscripten: C/C++ nach LLVM nach WebAssembly (tatsächlich kann LLVM-IR auch direkt
als Input verwendet werden)
Fitnesse: Word/Wiki nach ausführbare Unit-Tests
Was bringt mir das?
Beschäftigung mit dem schönsten Thema in der Informatik ;-)
Auswahl einiger Gründe für den Besuch des Moduls "Compilerbau"
Erstellung eigener kleiner Interpreter/Compiler
Einlesen von komplexen Daten
DSL als Brücke zwischen Stakeholdern
DSL zum schnelleren Programmieren (denken Sie etwa an CoffeeScript ...)
Wie funktionieren FindBugs, Lint und ähnliche Tools?
Verständnis für bestimmte Sprachkonstrukte und -konzepte (etwa virtual in C++)
Vertiefung durch Besuch "echter" Compilerbau-Veranstaltungen an Uni möglich :-)
Wie funktioniert:
ein Python-Interpreter?
das Syntaxhighlighting in einem Editor oder in Doxygen?
ein Hardwarecompiler (etwa VHDL)?
ein Text-Formatierer (TeX, LaTeX, ...)?
CoffeeScript oder Emscripten?
Wie kann man einen eigenen Compiler/Interpreter basteln, etwa für
MiniJava (mit C-Backend)
Brainfuck
Übersetzung von JSON nach XML
Um eine profundes Kenntnis von Programmiersprachen zu erlangen, ist eine
Beschäftigung mit ihrer Implementierung unerlässlich.
Viele Grundtechniken der Informatik und elementare Datenstrukturen wie
Keller, Listen, Abbildungen, Bäume, Graphen, Automaten etc. finden im
Compilerbau Anwendung. Dadurch schließt sich in gewisser Weise der Kreis
in der Informatikausbildung ...
Aufgrund seiner Reife gibt es hervorragende Beispiele von formaler
Spezifikation im Compilerbau.
Mit dem Gebiet der formalen Sprachen berührt der Compilerbau interessante
Aspekte moderner Linguistik. Damit ergibt sich letztlich eine Verbindung
zur KI ...
Die Unterscheidung von Syntax und Semantik ist eine grundlegende Technik
in fast allen formalen Systeme.
Parser-Generatoren (Auswahl)
Diese Tools könnte man beispielsweise nutzen, um seine eigene Sprache zu basteln.
ANTLR (ANother Tool for Language Recognition) is a powerful parser generator for reading, processing, executing, or translating structured text or binary files: github.com/antlr/antlr4
Flex, the Fast Lexical Analyzer - scanner generator for lexing in C and C++: github.com/westes/flex
Bison is a general-purpose parser generator that converts an annotated context-free grammar into a deterministic LR or generalized LR (GLR) parser employing LALR(1) parser tables: gnu.org/software/bison
[Grune2012] Modern Compiler Design Grune, D. und van, Reeuwijk, K. und Bal, H. E. und Jacobs, C. J. H. und Langendoen, K., Springer, 2012. ISBN 978-1-4614-4698-9. Kapitel 1 Introduction
Lexikalische Analyse
In der lexikalischen Analyse soll ein Lexer (auch "Scanner") den Zeichenstrom in eine
Folge von Token zerlegen. Zur Spezifikation der Token werden in der Regel reguläre
Ausdrücke verwendet.
Def.: Ein Alphabet$\Sigma$ ist eine endliche, nicht-leere Menge von Symbolen. Die Symbole eines Alphabets heißen Buchstaben.
Def.: Ein Wort$w$über einem Alphabet$\Sigma$ ist eine endliche Folge von Symbolen aus $\Sigma$.
$\epsilon$ ist das leere Wort.
Die Länge$\vert w \vert$ eines Wortes $w$ ist die Anzahl von Buchstaben, die es enthält (Kardinalität).
Def.: Eine Sprache$L$über einem Alphabet$\Sigma$ ist eine Menge von Wörtern über diesem Alphabet.
Sprachen können endlich oder unendlich viele Wörter enthalten.
Beispiel
Hier entsteht ein Tafelbild.
Deterministische endliche Automaten
Hier entsteht ein Tafelbild.
Def.: Ein deterministischer endlicher Automat (DFA) ist ein 5-Tupel
$A = (Q, \Sigma, \delta, q_0, F)$ mit
$Q$ : endliche Menge von Zuständen
$\Sigma$ : Alphabet von Eingabesymbolen
$\delta$ : die (eventuell partielle) Übergangsfunktion$(Q \times \Sigma) \rightarrow Q$,
$\delta$ kann partiell sein
$q_0 \in Q$ : der Startzustand
$F \subseteq Q$ : die Menge der Endzustände
Die Übergangsfunktion
Def.: Wir definieren $\delta^{\ast}: (Q \times \Sigma^{\ast}) \rightarrow Q$: induktiv wie folgt:
Pattern Matching (Erkennung von Schlüsselwörtern, Bezeichnern, ...) geht mit NFAs.
NFAs sind so nicht zu programmieren, aber:
Satz: Eine Sprache $L$ wird von einem NFA akzeptiert $\Leftrightarrow L$ wird von einem DFA akzeptiert.
D. h. es existieren Algorithmen zur
Umwandlung von NFAs in DFAS
Minimierung von DFAs
Reguläre Sprachen
Reguläre Ausdrücke definieren Sprachen
Def.: Induktive Definition von regulären Ausdrücken (regex) und der von ihnen repräsentierten Sprache L:
Basis:
$\epsilon$ und $\emptyset$ sind reguläre Ausdrücke mit $L(\epsilon) =
\lbrace \epsilon\rbrace$, $L(\emptyset)=\emptyset$
Sei $a$ ein Symbol $\Rightarrow$$a$ ist ein regex mit $L(a) = \lbrace a\rbrace$
Induktion: Seien $E,\ F$ reguläre Ausdrücke. Dann gilt:
$E+F$ ist ein regex und bezeichnet die Vereinigung $L(E + F) = L(E)\cup L(F)$
$EF$ ist ein regex und bezeichnet die Konkatenation $L(EF) = L(E)L(F)$
$E^{\ast}$ ist ein regex und bezeichnet die Kleene-Hülle $L(E^{\ast})=(L(E))^{\ast}$
$(E)$ ist ein regex mit $L((E)) = L(E)$
Vorrangregeln der Operatoren für reguläre Ausdrücke: *, Konkatenation, +
Beispiel
Hier entsteht ein Tafelbild.
Wichtige Identitäten
Satz: Sei $A$ ein DFA $\Rightarrow \exists$ regex $R$ mit $L(A) = L(R)$.
Satz: Sei $E$ ein regex $\Rightarrow \exists$ DFA $A$ mit $L(E) = L(A)$.
Formale Grammatiken
Hier entsteht ein Tafelbild.
Formale Definition formaler Grammatiken
Def.: Eine formale Grammatik ist ein 4-Tupel $G=(N,T,P,S)$ aus
$N$: endliche Menge von Nichtterminalen
$T$: endliche Menge von Terminalen, $N \cap T = \emptyset$
$S \in N$: Startsymbol
$P$: endliche Menge von Produktionen der Form
$\qquad X \rightarrow Y$ mit $X \in (N \cup T)^{\ast} N (N \cup T)^{\ast}, Y \in (N \cup T)^{\ast}$
Ableitungen
Def.: Sei $G = (N, T, P, S)$ eine Grammatik, sei $\alpha A \beta$ eine Zeichenkette über
$(N \cup T)^{\ast}$ und sei $A$$\rightarrow \gamma$ eine Produktion von $G$.
Wir schreiben:
$\alpha A \beta \Rightarrow \alpha \gamma \beta$ ($\alpha A \beta$ leitet $\alpha \gamma \beta$ ab).
Def.: Wir definieren die Relation $\overset{\ast}{\Rightarrow}$ induktiv wie folgt:
Basis: $\forall \alpha \in (N \cup T)^{\ast} \alpha \overset{\ast}{\Rightarrow} \alpha$ (Jede Zeichenkette leitet sich selbst ab.)
Induktion: Wenn $\alpha \overset{\ast}{\Rightarrow} \beta$ und
$\beta\Rightarrow \gamma$ dann $\alpha \overset{\ast}{\Rightarrow} \gamma$
Def.: Sei $G = (N, T ,P, S)$ eine formale Grammatik.
Dann ist $L(G) = \lbrace \text{Wörter}\ w\ \text{über}\ T \mid S \overset{\ast}{\Rightarrow} w\rbrace$ die von $G$ erzeugte Sprache.
Beispiel
Hier entsteht ein Tafelbild.
Reguläre Grammatiken
Def.: Eine reguläre (oder type-3-) Grammatik ist eine formale Grammatik mit den folgenden Einschränkungen:
Alle Produktionen sind entweder von der Form
$X \to aY$ mit $X \in N, a \in T, Y \in N$ (rechtsreguläre Grammatik) oder
$X \to Ya$ mit $X \in N, a \in T, Y \in N$ (linksreguläre Grammatik)
$X\rightarrow\epsilon$ ist erlaubt
Beispiel
Hier entsteht ein Tafelbild.
Reguläre Sprachen und ihre Grenzen
Satz: Die von endlichen Automaten akzeptiert Sprachklasse, die von regulären Ausdrücken beschriebene Sprachklasse und die von regulären Grammatiken erzeugte Sprachklasse sind identisch und heißen reguläre Sprachen.
Reguläre Sprachen
einfache Struktur
Matchen von Symbolen (z. B. Klammern) nicht möglich, da die fixe Anzahl von Zuständen eines DFAs die Erkennung solcher Sprachen verhindert.
Wozu reguläre Sprachen im Compilerbau?
Reguläre Ausdrücke
definieren Schlüsselwörter und alle weiteren Symbole einer Programmiersprache, z. B. den Aufbau von Gleitkommazahlen
werden (oft von einem Generator) in DFAs umgewandelt
sind die Basis des Scanners oder Lexers
Lexer
Ein Lexer ist mehr als ein DFA
Ein Lexer
wandelt mittels DFAs aus regulären Ausdrücken die Folge von Zeichen der Quelldatei in eine Folge von sog. Token um
bekommt als Input eine Liste von Paaren aus regulären Ausdrücken und Tokennamen, z. B. ("while", WHILE)
Kommentare und Strings müssen richtig erkannt werden. (Schachtelungen)
liefert Paare von Token und deren Werte, sofern benötigt, z. B. (WHILE, _), oder (IDENTIFIER, "radius") oder (INTEGERZAHL, "334")
Wie geht es weiter?
Ein Parser
führt mit Hilfe des Tokenstreams vom Lexer die Syntaxanalyse durch
basiert auf einer sog. kontextfreien Grammatik, deren Terminale die Token sind
liefert die syntaktische Struktur in Form eines Ableitungsbaums (syntax tree, parse tree), bzw. einen AST (abstract syntax tree) ohne redundante Informationen im Ableitungsbaum (z. B. Semikolons)
liefert evtl. Fehlermeldungen
Wrap-Up
Wrap-Up
Definition und Aufgaben von Lexern
DFAs und NFAs
Reguläre Ausdrücke
Reguläre Grammatiken
Zusammenhänge zwischen diesen Mechanismen und Lexern, bzw. Lexergeneratoren
(K3) Einen DFA entwickeln, der alle Schlüsselwörter, Namen und weitere Symbole einer Programmiersprache akzeptiert
Lexer mit ANTLR generieren
TL;DR
ANTLR ist ein Parser-Generator, der aus einer Grammatik einen Parser in verschiedenen
Zielsprachen (Java, Python, C++, ...) generieren kann.
In der ANTLR-Grammatik werden die Parser-Regeln klein geschrieben, die Lexer-Regeln werden
mit Großbuchstaben geschrieben. Jede Lexer-Regel liefert ein Token zurück, dabei
ist der Tokenname die linke Seite der Regel. Wie bei Flex gewinnt der längste Match,
und bei Gleichstand (mehrere längste Regeln matchen) gewinnt die zuerst definierte Regel.
Die Lexer-Regeln können mit Aktionen annotiert werden, die beim Matchen der jeweiligen Regel
abgearbeitet werden. Diese Aktionen müssen in der Zielprogrammiersprache formuliert werden,
da sie in die generierte Lexerklasse in die jeweiligen Methoden eingebettet werden.
Verarbeitung: Finden sinnvoller Sequenzen im Zeichenstrom ("Lexeme"),
Einteilung in Kategorien und Erzeugen von Token (Paare: Typ/Name, Wert)
Ausgabe: Tokenstrom
Normalerweise werden für spätere Phasen unwichtige Elemente wie White-Space
oder Kommentare entfernt.
Durch diese Vorverarbeitung wird eine höhere Abstraktionsstufe erreicht und es
können erste grobe Fehler gefunden werden. Dadurch kann der Parser auf einer
abstrakteren Stufe arbeiten und muss nicht mehr den gesamten ursprünglichen
Zeichenstrom verarbeiten.
Anmerkung: In dieser Phase steht die Geschwindigkeit stark im Vordergrund:
Der Lexer "sieht" alle Zeichen im Input. Deshalb findet man häufig von
Hand kodierte Lexer, obwohl die Erstellung der Lexer auch durch Generatoren
erledigt werden könnte ...
Anmerkung: Die Token sind die Terminalsymbole in den Parserregeln (Grammatik).
Definition wichtiger Begriffe
Token: Tupel (Tokenname, optional: Wert)
Der Tokenname ist ein abstraktes Symbol, welches eine lexikalische
Einheit repräsentiert (Kategorie). Die Tokennamen sind die Eingabesymbole
für den Parser.
Token werden i.d.R. einfach über ihren Namen referenziert. Token werden
häufig zur Unterscheidung von anderen Symbolen in der Grammatik in
Fettschrift oder mit großen Anfangsbuchstaben geschrieben.
Ein Token kann einen Wert haben, etwa eine Zahl oder einen Bezeichner, der
auf das zum Token gehörende Pattern gematcht hatte (also das Lexem). Wenn
der Wert des Tokens eindeutig über den Namen bestimmt ist (im Beispiel oben
beim Komma oder den Klammern), dann wird häufig auf den Wert verzichtet.
Lexeme: Sequenz von Zeichen im Eingabestrom, die auf ein Tokenpattern
matcht und vom Lexer als Instanz dieses Tokens identifiziert wird.
Pattern: Beschreibung der Form eines Lexems
Bei Schlüsselwörtern oder Klammern etc. sind dies die Schlüsselwörter oder
Klammern selbst. Bei Zahlen oder Bezeichnern (Namen) werden i.d.R.
reguläre Ausdrücke zur Beschreibung der Form des Lexems formuliert.
Typische Muster für Erstellung von Token
Schlüsselwörter
Ein eigenes Token (RE/DFA) für jedes Schlüsselwort, oder
Erkennung als Name und Vergleich mit Wörterbuch
und nachträgliche Korrektur des Tokentyps
Wenn Schlüsselwörter über je ein eigenes Token abgebildet werden, benötigt
man für jedes Schlüsselwort einen eigenen RE bzw. DFA. Die Erkennung als
Bezeichner und das Nachschlagen in einem Wörterbuch (geeignete Hashtabelle)
sowie die entsprechende nachträgliche Korrektur des Tokentyps kann die
Anzahl der Zustände im Lexer signifikant reduzieren!
Operatoren
Ein eigenes Token für jeden Operator, oder
Gemeinsames Token für jede Operatoren-Klasse
Bezeichner: Ein gemeinsames Token für alle Namen
Zahlen: Ein gemeinsames Token für alle numerischen Konstante
(ggf. Integer und Float unterscheiden)
Für Zahlen führt man oft ein Token "NUM" ein. Als Attribut speichert man
das Lexem i.d.R. als String. Alternativ kann man (zusätzlich) das Lexem in
eine Zahl konvertieren und als (zusätzliches) Attribut speichern. Dies kann
in späteren Stufen viel Arbeit sparen.
String-Literale: Ein gemeinsames Token
Komma, Semikolon, Klammern, ...: Je ein eigenes Token
Regeln für White-Space und Kommentare etc. ...
Normalerweise benötigt man Kommentare und White-Spaces in den folgenden
Stufen nicht und entfernt diese deshalb aus dem Eingabestrom. Dabei könnte
man etwa White-Spaces in den Pattern der restlichen Token berücksichtigen,
was die Pattern aber sehr komplex macht. Die Alternative sind zusätzliche
Pattern, die auf die White-Space und anderen nicht benötigten Inhalt
matchen und diesen "geräuschlos" entfernen. Mit diesen Pattern werden
keine Token erzeugt, d.h. der Parser und die anderen Stufen bemerken nichts
von diesem Inhalt.
Gelegentlich benötigt man aber auch Informationen über White-Spaces,
beispielsweise in Python. Dann müssen diese Token wie normale Token
an den Parser weitergereicht werden.
Jedes Token hat i.d.R. ein Attribut, in dem das Lexem gespeichert wird. Bei
eindeutigen Token (etwa bei eigenen Token je Schlüsselwort oder bei den
Interpunktions-Token) kann man sich das Attribut auch sparen, da das Lexem
durch den Tokennamen eindeutig rekonstruierbar ist.
Token
Beschreibung
Beispiel-Lexeme
if
Zeichen i und f
if
relop
< oder > oder <= oder >= oder == oder !=
<, <=
id
Buchstabe, gefolgt von Buchstaben oder Ziffern
pi, count, x3
num
Numerische Konstante
42, 3.14159, 0
literal
Alle Zeichen außer ", in " eingeschlossen
"core dumped"
Anmerkung: Wenn es mehrere matchende REs gibt, wird in der Regel das längste
Lexem bevorzugt. Wenn es mehrere gleich lange Alternativen gibt, muss man mit
Vorrangregeln bzgl. der Token arbeiten.
Anmerkung: Die Ordnerstruktur wurde durch ein ANTLR-Plugin für Eclipse
erzeugt. Bei Ausführung in der Konsole liegen alle Dateien in einem Ordner.
Anmerkung: Per Default werden nur die Listener angelegt, für die Visitoren
muss eine extra Option mitgegeben werden.
Die Dateien Hello.tokens und HelloLexer.tokens enthalten die Token samt
einer internen Nummer. (Der Inhalt beider Dateien ist identisch.)
Die Datei HelloLexer.java enthält den generierten Lexer, der eine
Spezialisierung der abstrakten Basisklasse Lexer darstellt. Über den
Konstruktor wird der zu scannende CharStream gesetzt. Über die Methode
Lexer#nextToken() kann man sich die erkannten Token der Reihe nach
zurückgeben lassen. (Diese Methode wird letztlich vom Parser benutzt.)
Die restlichen Dateien werden für den Parser und verschiedene Arten der
Traversierung des AST generiert (vgl.
AST-basierte Interpreter).
Bedeutung der Ausgabe
Wenn man dem Hello-Lexer die Eingabe
hello world
<EOF>
(das <EOF> wird durch die Tastenkombination STRG-D erreicht) gibt, dann
lautet die Ausgabe
Rekursive Lexer-Regeln sind erlaubt. Achtung: Es dürfen keine
links-rekursiven Regeln genutzt werden, etwa wie ID : ID '*' ID ; ...
(Eine genauere Definition und die Transformation in nicht-linksrekursive
Regeln siehe CFG).
Alle Literale werden in einfache Anführungszeichen eingeschlossen
(es erfolgt keine Unterscheidung zwischen einzelnen Zeichen und Strings
wie in anderen Sprachen)
Zeichenmengen: [a-z\n] umfasst alle Zeichen von 'a' bis 'z' sowie
'\n'
'a'..'z' ist identisch zu [a-z]
Schlüsselwörter: Die folgenden Strings stellen reservierte Schlüsselwörter
dar und dürfen nicht als Token, Regel oder Label genutzt werden:
Die regulären Ausdrücke (...)?, (...)* und (...)+ sind greedy und
versuchen soviel Input wie möglich zu matchen.
Falls dies nicht sinnvoll sein sollte, kann man mit einem weiteren ? das
Verhalten auf non-greedy umschalten. Allerdings können non-greedy Regeln
das Verhalten des Lexers u.U. schwer vorhersehbar machen!
Die Regel, die den längsten Match für die aktuelle Eingabesequenz produziert,
"gewinnt".
Im Beispiel würde ein "foo42" als FOO erkannt und nicht als CHARS DIGITS.
Verhalten des Lexers: 2. Reihenfolge
Reihenfolge in Grammatik definiert Priorität
FOO : 'f'.*?'r' ;
BAR : 'foo'.*?'bar' ;
Falls mehr als eine Lexer-Regel die selbe Inputsequenz matcht, dann
hat die in der Grammatik zuerst genannte Regel Priorität.
Im Beispiel würden für die Eingabe "foo42bar" beide Regeln den selben längsten
Match liefern - die Regel FOO ist in der Grammatik früher definiert und
"gewinnt".
Verhalten des Lexers: 3. Non-greedy Regeln
Non-greedy Regeln versuchen so wenig Zeichen wie möglich zu matchen
FOO : 'foo'.*?'bar' ;
BAR : 'bar' ;
Hier würde ein "foo42barbar" zu FOO gefolgt von BAR erkannt werden.
Achtung: Nach dem Abarbeiten einer non-greedy Sub-Regel in einer Lexer-Regel
gilt "first match wins"
.*? ('4' | '42')
=> Der Teil '42' auf der rechten Seite ist
"toter Code" (wegen der non-greedy Sub-Regel .*?)!
Die Eingabe "x4" würde korrekt erkannt, währende "x42" nur als "x4" erkannt wird und für
die verbleibende "2" würde ein token recognition error geworfen.
Zur Auswertung in den Lexer-Regeln muss man anders vorgehen als in
Parser-Regeln: Nach der Erstellung eines Tokens kann man die zum Attribut
gehörenden getX() und setX()-Methoden aufrufen, um die Werte abzufragen
oder zu ändern.
Dies passiert im obigen Beispiel für das Attribut text: Abfrage mit
getText(), Ändern/Setzen mit setText().
Die Methodenaufrufe wirken sich immer auf das gerade erstellte Token aus.
Achtung: Bei Aktionen in Parser-Regeln gelten andere Spielregeln!
Aktionen mit den Lexer-Regeln
Aktionen für Lexer-Regeln sind Code-Blöcke in der Zielsprache, eingeschlossen
in geschweifte Klammern. Die Code-Blöcke werden direkt in die generierten
Lexer-Methoden kopiert.
Zusätzlich:
@header: Package-Deklarationen und/oder Importe (wird vor der
Klassendefinition eingefügt)
@members: zusätzliche Attribute für die generierten Lexer- (und
Parser-) Klassen.
Mit @lexer::header bzw. @lexer::members werden diese Codeblöcke nur in den
generierten Lexer eingefügt.
Anmerkung: Lexer-Aktionen müssen am Ende der äußersten Alternative erscheinen.
Wenn eine Lexer-Regel mehr als eine Alternative hat, müssen diese in runde
Klammern eingeschlossen werden.
Lexer mit ANTLR generieren: Lexer-Regeln werden mit Großbuchstaben geschrieben
Längster Match gewinnt, Gleichstand: zuerst definierte Regel
non greedy-Regeln: versuche so wenig Zeichen zu matchen wie möglich
Aktionen beim Matchen
Challenges
Token und Lexer-Regeln mit ANTLR
Formulieren Sie für ANTLR Lexer-Regeln, mit denen folgende Token erkannt werden:
White-Space: Leerzeichen, Tabs, Zeilenumbrüche
Vergleichsoperatoren: <, >, <=, >=, ==, <>
If: if
Then: then
Else: else
Namen: Ein Buchstabe, gefolgt von beliebig vielen weiteren Buchstaben und/oder Ziffern
Numerische Konstanten: Mindestens eine Ziffer, gefolgt von maximal einem Paar bestehend aus einem Punkt und mindestens einer Ziffer, gefolgt von maximal einem Paar bestehend aus dem Buchstaben "E" gefolgt von einem "+" oder "-" und mindestens einer Ziffer.
Formulieren Sie Hilfskonstrukte zur Verwendung in mehreren Lexer-Regeln als ANTLR-Fragmente.
White-Spaces sollen entfernt werden und nicht als Token weitergereicht werden.
Real-World-Lexer mit ANTLR: Programmiersprache Lox
Betrachten Sie folgenden Code-Schnipsel in der Sprache "Lox":
fun fib(x) {
if (x == 0) {
return 0;
} else {
if (x == 1) {
return 1;
} else {
fib(x - 1) + fib(x - 2);
}
}
}
var wuppie = fib(4);
Schreiben Sie eine Lexer-Grammatik mit eingebetteten Aktionen für ANTLR sowie ein passendes Programm zur Einbindung des generierten Lexers, welches einen Text nach Pig Latin übersetzt:
Ist der erste Buchstabe eines Wortes ein Konsonant, schiebe ihn ans Ende des Wortes und füge "ay" an.
Ist der erste Buchstabe eines Wortes ein Vokal, hänge an das Wort ein "ay" an.
Lexing mit ANTLR
In einem Telefonbuch sind zeilenweise Namen und Telefonnummern gespeichert.
Definieren Sie eine Lexer-Grammatik für ANTLR, mit der Sie die Zeilen einlesen können. Können Sie dabei verschiedene Formate der Telefonnummern berücksichtigen?
Können Sie die Grammatik so anpassen, dass Sie nur möglichst wenige verschiedene Token an den Parser weitergeben?
Ergänzen Sie Ihre Grammatik um Lexer-Aktionen, so dass Sie die Zeilen, die Zeichen (in den Namen) und die Ziffern (in den Telefonnummern) zählen können.
Lexing mit ANTLR
IBAN für Deutschland bestehen aus dem Kürzel "DE" sowie einer zweistelligen Checksumme, gefolgt von 2x 4 Ziffern für die
Bank (ehemalige Bankleitzahl) sowie 2x 4 Ziffern für die ehemalige Kontonummer sowie zwei weiteren Ziffern. Typisch sind
zwei Formate:
Menschenlesbares Format: DEcc bbbb bbbb kkkk kkkk xx
Maschinenlesbares Format: DEccbbbbbbbbkkkkkkkkxx
Definieren Sie eine Lexer-Grammatik für ANTLR, mit der Sie die verschiedenen IBAN-Formate für Deutschland einlesen können.
In der syntaktischen Analyse arbeitet ein Parser mit dem Tokenstrom, der vom Lexer kommt.
Mit Hilfe einer Grammatik wird geprüft, ob gültige Sätze im Sinne der Sprache/Grammatik
gebildet wurden. Der Parser erzeugt dabei den Parse-Tree. Man kann verschiedene Parser
unterscheiden, beispielsweise die LL- und die LR-Parser.
In welchem Zusammenhang stehen all diese Begriffe?
Wie werden DFAs und reguläre Ausdrücke im Compilerbau eingesetzt?
Motivation
Wofür reichen reguläre Sprachen nicht?
Für z. B. alle Sprachen, in deren Wörtern Zeichen über eine Konstante hinaus gezählt werden müssen. Diese Sprachen lassen sich oft mit Variablen im Exponenten beschreiben, die unendlich viele Werte annehmen können.
$a^ib^{2*i}$ ist nicht regulär
$a^ib^{2*i}$ für $0 \leq i \leq 3$ ist regulär
Wo finden sich die oben genannten Variablen bei einem DFA wieder?
Warum ist die erste Sprache oben nicht regulär, die zweite aber?
Themen für heute
PDAs: mächtiger als DFAs, NFAs
kontextfreie Grammatiken und Sprachen: mächtiger als reguläre Grammatiken und Sprachen
DPDAs und deterministisch kontextfreie Grammatiken: die Grundlage der Syntaxanalyse im Compilerbau
Der Einsatz kontextfreier Grammatiken zur Syntaxanalyse mittels Top-Down-Techniken
Einordnung: Erweiterung der Automatenklasse DFA, um komplexere Sprachen als die regulären akzeptieren zu können
Wir spendieren den DFAs einen möglichst einfachen, aber beliebig großen, Speicher, um zählen und matchen zu können. Wir suchen dabei konzeptionell die "kleinstmögliche" Erweiterung, die die akzeptierte Sprachklasse gegenüber DFAs vergrößert.
Der konzeptionell einfachste Speicher ist ein Stack. Wir haben keinen wahlfreien Zugriff auf die gespeicherten Werte.
Es soll eine deterministische und eine indeterministische Variante der neuen Automatenklasse geben.
In diesem Zusammenhang wird der Stack auch Keller genannt.
Kellerautomaten (Push-Down-Automata, PDAs)
Def.: Ein Kellerautomat (PDA) $P = (Q,\ \Sigma,\ \Gamma,\ \delta,\ q_0,\ \perp,\ F)$
ist ein Septupel mit:
Ein PDA ist per Definition nichtdeterministisch und kann spontane Zustandsübergänge durchführen.
Was kann man damit akzeptieren?
Strukturen mit paarweise zu matchenden Symbolen.
Bei jedem Zustandsübergang wird ein Zeichen (oder $\epsilon$) aus der Eingabe gelesen, ein Symbol von Keller genommen. Diese und das Eingabezeichen bestimmen den Folgezustand und eine Zeichenfolge, die auf den Stack gepackt wird. Dabei wird ein Symbol, (z. B. eines, das später mit einem Eingabesymbol zu matchen ist,) auf den Stack gepackt. Soll das automatisch vom Stack genommene Symbol auf dem Stack bleiben, muss es wieder gepusht werden.
Beispiel
Ein PDA für $L=\lbrace ww^{R}\mid w\in \lbrace a,b\rbrace^{\ast}\rbrace$:
Deterministische PDAs
Def. Ein PDA $P = (Q, \Sigma, \Gamma, \delta, q_0, \perp, F)$ ist deterministisch$: \Leftrightarrow$
$\delta(q, a, X)$ hat höchstens ein Element für jedes $q \in Q, a \in\Sigma$ oder $(a = \epsilon$ und $X \in \Gamma)$.
Wenn $\delta (q, a, x)$ nicht leer ist für ein $a \in \Sigma$, dann muss $\delta (q, \epsilon, x)$ leer sein.
Deterministische PDAs werden auch DPDAs genannt.
Der kleine Unterschied
Satz: Die von DPDAs akzeptierten Sprachen sind eine echte Teilmenge der von
PDAs akzeptierten Sprachen.
Die regulären Sprachen sind eine echte Teilmenge der von DPDAs akzeptierten Sprachen.
Kontextfreie Grammatiken und Sprachen
Kontextfreie Grammatiken
Def. Eine kontextfreie (cf-) Grammatik ist ein 4-Tupel $G = (N, T, P, S)$ mit $N, T, S$ wie in
(formalen) Grammatiken und $P$ ist eine endliche Menge von Produktionen der Form:
$X \rightarrow Y$ mit $X \in N, Y \in {(N \cup T)}^{\ast}$.
$\Rightarrow, \overset{\ast}{\Rightarrow}$ sind definiert wie bei regulären Sprachen. Bei cf-Grammatiken nennt man die Ableitungsbäume oft Parse trees.
Beispiel
$S \rightarrow a \mid S\ +\ S\ |\ S \ast S$
Ableitungsbäume für $a + a \ast a$:
Hier entsteht ein Tafelbild.
Nicht jede kontextfreie Grammatik ist eindeutig
Def.: Gibt es in einer von einer kontextfreien Grammatik erzeugten Sprache ein
Wort, für das mehr als ein Ableitungsbaum existiert, so heißt diese Grammatik
mehrdeutig. Anderenfalls heißt sie eindeutig.
Satz: Es ist nicht entscheidbar, ob eine gegebene kontextfreie Grammatik eindeutig ist.
Satz: Es gibt kontextfreie Sprachen, für die keine eindeutige Grammatik existiert.
Kontextfreie Grammatiken und PDAs
Satz: Die kontextfreien Sprachen und die Sprachen, die von PDAs akzeptiert werden, sind dieselbe Sprachklasse.
Satz: Eine von einem DPDA akzeptierteSprache hat eine eindeutige Grammatik.
Vorgehensweise im Compilerbau: Eine Grammatik für die gewünschte Sprache definieren und schauen, ob sich daraus ein DPDA generieren lässt (automatisch).
Syntaxanalyse
Was brauchen wir für die Syntaxanalyse von Programmen?
einen Grammatiktypen, aus dem sich manuell oder automatisiert ein Programm zur deterministischen Syntaxanalyse (=Parser) erstellen lässt
einen Algorithmus zum Parsen von Programmen mit Hilfe einer solchen Grammatik
Syntax
Wir verstehen unter Syntax eine Menge von Regeln, die die Struktur von Daten (z. B. Programmen) bestimmen.
Diese vorgegebene Syntax wird im Compilerbau mit einer kontextfreien Grammatik beschrieben und mit einem sogenannten Parser analysiert.
Heute: LL-Parsing, mit dem man eine Teilmenge der eindeutigen kontextfreien Grammatiken syntaktich analysieren kann.
Dabei wird der Ableitungsbaum von oben nach unten aufgebaut.
Ziele der Syntaxanalyse
Bestimmung der syntaktischen Struktur eines Programms
aussagekräftige Fehlermeldungen, wenn ein Eingabeprogramm syntaktisch nicht korrekt ist
Erstellung des AST (abstrakter Syntaxbaum): Der Parse Tree ohne Symbole, die nach der Syntaxanalyse inhaltlich irrelevant sind (z. B. Semikolons, manche Schlüsselwörter)
die Symboltablelle(n) mit Informationen bzgl. Bezeichner (Variable, Funktionen und Methoden, Klassen, benutzerdefinierte Typen, Parameter, ...), aber auch die Gültigkeitsbereiche.
LL(k)-Grammatiken
First-Mengen
$S \rightarrow A \ \vert \ B \ \vert \ C$
Welche Produktion nehmen?
Wir brauchen die "terminalen k-Anfänge" von Ableitungen von Nichtterminalen, um eindeutig die nächste zu benutzende Produktion festzulegen. $k$ ist dabei die Anzahl der Vorschautoken.
Def.: Wir definieren $First$ - Mengen einer Grammatik wie folgt:
$a \in T^\ast, |a| > k: {First}_k (a) = \lbrace v \in T^\ast \mid a = vw, |v| = k \rbrace$
$\alpha \in (N \cup T)^\ast \backslash T^\ast: {First}_k (\alpha) = \lbrace v \in T^\ast \mid \alpha \overset{\ast}{\Rightarrow} w,\text{mit}\ w \in T^\ast, First_k(w) = \lbrace v \rbrace \rbrace$
Linksableitungen
Def.: Bei einer kontextfreien Grammatik $G$ ist die Linksableitung von $\alpha \in (N \cup T)^{\ast}$ die Ableitung, die man erhält, wenn in jedem Schritt das am weitesten links stehende Nichtterminal in $\alpha$ abgeleitet wird.
Man schreibt $\alpha \overset{\ast}{\Rightarrow}_l \beta.$
LL(k)-Grammatiken
Def.: Eine kontextfreie Grammatik $G = (N, T, P, S)$ ist genau dann eine LL(k)-Grammatik, wenn für alle Linksableitungen der Form:
$S \overset{\ast}{\Rightarrow}_l\ wA \gamma\ {\Rightarrow}_l\ w\alpha\gamma \overset{\ast}{\Rightarrow}_l wx$
und
$S \overset{\ast}{\Rightarrow}_l wA \gamma {\Rightarrow}_l w\beta\gamma \overset{\ast}{\Rightarrow}_l wy$
mit $(w, x, y \in T^\ast, \alpha, \beta, \gamma \in (N \cup T)^\ast, A \in N)$ und $First_k(x) = First_k(y)$
gilt:
$\alpha = \beta$
LL(1)-Grammatiken
Hier entsteht ein Tafelbild.
LL(k)-Sprachen
Die von LL(k)-Grammatiken erzeugten Sprachen sind eine echte Teilmenge der deterministisch parsbaren Sprachen.
Die von LL(k)-Grammatiken erzeugten Sprachen sind eine echte Teilmenge der von LL(k+1)-Grammatiken erzeugten Sprachen.
Für eine kontextfreie Grammatik $G$ ist nicht entscheidbar, ob es eine LL(1) - Grammatik $G'$ gibt mit $L(G) = L(G')$.
In der Praxis reichen LL(1) - Grammatiken oft. Hier gibt es effiziente Parsergeneratoren (hier: ANTLR), deren Eingabe eine LL-Grammatik ist, und die als Ausgabe den Quellcode eines (effizienten) tabellengesteuerten Parsers generieren.
Was brauchen wir zur Erzeugung eines LL(k)-Parsers?
eine LL(k)-Grammatik
die $First_k$-Mengen der rechten Seiten aller Produktionsregeln
die $Follow_k$-Mengen aller Nichtterminale und der rechten Seiten aller Produktionsregeln
das Endezeichen $\perp$ hinter dem Eingabewort
Def.: Wir definieren $Follow$ - Mengen einer Grammatik wie folgt:
$Follow_k(\beta) = \lbrace w \in T^\ast\ |\ \exists \alpha, \gamma \in (N \cup T)^\ast\ \text{mit}\ S \overset{\ast}{\Rightarrow}_l\ \alpha \beta \gamma\ \text{und}\ w \in First_k(\gamma) \rbrace$
Beispiel: First- und Follow-Mengen
Hier entsteht ein Tafelbild.
Algorithmus: Konstruktion einer LL-Parsertabelle
Eingabe: Eine Grammatik $G = (N, T, P, S)$
Ausgabe: Eine Parsertabelle $P$
Statt $First_1(\alpha)$ wird oft nur $First(\alpha)$ geschrieben.
Beispiel: LL-Parsertabellen
Hier entsteht ein Tafelbild.
LL-Parser
Rekursive Programmierung bedeutet, dass das Laufzeitsystem einen Stack benutzt. Diesen Stack kann man auch "selbst programmieren", d. h. einen PDA implementieren. Dabei wird ebenfalls die oben genannte Tabelle zur Bestimmung der nächsten anzuwendenden Produktion benutzt. Der Stack enthält die zu erwartenden Eingabezeichen, wenn immer eine Linksableitung gebildet wird. Diese Zeichen im Stack werden mit dem Input gematcht.
Algorithmus: Tabellengesteuertes LL-Parsen mit einem PDA
Eingabe: Eine Grammatik $G = (N, T, P, S)$, eine Parsertabelle $P$ mit "$w\perp$" als initialem Kellerinhalt
Ausgabe: Wenn $w \in L(G)$, eine Linksableitung von $w$, Fehler sonst
Beispiel: LL-Parsen
Hier entsteht ein Tafelbild.
Ergebnisse der Syntaxanalyse
eventuelle Syntaxfehler mit Angabe der Fehlerart und des -Ortes
Format für die Weiterverarbeitung:
Ableitungsbaum oder Syntaxbaum oder Parse Tree
abstrakter Syntaxbaum (AST): Der Parse Tree ohne Symbole, die nach der Syntaxanalyse inhaltlich irrelevant sind (z. B. ;, Klammern, manche Schlüsselwörter, $\ldots$)
Wrap-Up
Das sollen Sie mitnehmen
Die Struktur von gängigen Programmiersprachen lässt sich nicht mit regulären Ausdrücken beschreiben und damit nicht mit DFAs akzeptieren.
Das Automatenmodell der DFAs wird um einen endlosen Stack erweitert, das ergibt PDAs.
Kontextfreie Grammatiken (CFGs) erweitern die regulären Grammatiken.
Deterministisch parsebare Sprachen haben eine eindeutige kontextfreie Grammatik.
Es ist nicht entscheidbar, ob eine gegebene kontextfreie Grammatik eindeutig ist.
Syntaxanalyse wird mit deterministisch kontextfreien Grammatiken durchgeführt.
Eine Teilmenge der dazu gehörigen Sprachen lässt sich top-down parsen.
Ein effizienter LL(k)-Parser realisiert einen DPDA und kann automatisch aus einer LL(k)-Grammatik generiert werden.
Der Parser liefert in der Regel einen abstrakten Syntaxbaum.
[hopcroft2003] Einführung in die Automatentheorie, formale Sprachen und Komplexitätstheorie Hopcroft, J. E. und Motwani, R. und Ullman, J. D., Pearson Education Deutschland GmbH, 2003. ISBN 978-3-8273-7020-4.
Lernziele
(K1) PDAs
(K1) Deterministische PDAs
(K1) Kontextfreie Grammatiken
(K1) Deterministisch kontextfreie Grammatiken
(K1) Top-Down-Analyse
(K1) LL-Parser
(K2) Zusammenhang zwischen PDAs und kontextfreien Grammatiken
Parser mit ANTLR generieren
TL;DR
Mit ANTLR kann aus einer Grammatik ein LL(*)-Parser generiert werden. Die Parser-Regeln
in der Grammatik fangen dabei mit einem Kleinbuchstaben an (Erinnerung: Lexer-Regel
starten mit einem Großbuchstaben).
Regeln haben einen Namen (linke Seite) und eine Produktion (rechte Seite). Dabei
können beliebige Abfolgen von Lexer- und Parser-Regeln auf der rechten Seite
einer Parser-Regel auftauchen. Die Token müssen jeweils matchen, die Parser-Regeln
werden in einen Aufruf der jeweiligen generierten Funktion übersetzt.
Parser-Regeln können aus mehreren Alternativen bestehen, diese werden per | separiert.
Dabei hat bei Mehrdeutigkeiten die erste passende Alternative Vorrang. Wie bei Lexer-Regeln
können Teile per ? ein- oder keinmal vorkommen, per * beliebig oft oder per + ein-
oder mehrfach.
ANTLR erlaubt im Gegensatz zu allgemeinen LL-Parsern direkte Links-Rekursion. (Indirekte
Links-Rekursion funktioniert allerdings nicht.)
Der von ANTLR generierte Parser erzeugt auf der Eingabe einen Parse-Tree, der die Strukturen
der Grammatik widerspiegelt: Die Token bilden die Blätter und jede erfolgreich durchlaufene
Parser-Regel bildet einen entsprechenden Knoten im Baum.
Für die Traversierung des Parse-Tree kann man die generierten Listener- oder Visitor-Klassen
nutzen. Beim Einsatz der Listener nutzt man die vorgegebene Klasse ParseTreeWalker, die
mit dem Parse-Tree und dem Listener den Baum per Tiefensuche traversiert und immer die
jeweiligen enterRegel- und exitRegel-Methoden aufruft. Beim Visitor muss die Traversierung
selbst erledigt werden, hier steht die aus der Klassenhierarchie geerbte Methode visit
als Startpunkt zur Verfügung. In dieser Methode wird basierend auf dem Knotentyp die in den
Visitor-Klassen implementierte visitRegel-Methode aufgerufen und man muss darauf achten,
die Kindknoten durch passende Aufrufe zu traversieren. Sowohl bei den generierten Listener-
als auch den Visitor-Klassen kann man die leeren Defaultmethoden bei Bedarf selbst überschreiben.
Für den Zugriff auf die Regel-Elemente werden die sogenannten Kontextobjekte als Parameter
übergeben.
Benannte Alternativen und Regel-Elemente sind nützlich, weil für die benannten Alternativen
zusätzliche Kontextklassen erzeugt werden, über die dann auf die Bestandteile der Alternativen
zugegriffen werden kann. Außerdem werden zusätzlich passende enterAlternative- und exitAlternative-
bzw. visitAlternative-Methoden generiert. Für benannte Regel-Elemente wird ein entsprechend
benanntes Attribut im Kontextobjekt angelegt, welches public sichtbar ist.
start ist eine Parser-Regel
=> Eine Parser-Regel pro Grammatik wird benötigt, damit man den generierten
Parser am Ende auch starten kann ...
Alle Regeln mit kleinem Anfangsbuchstaben sind Parser-Regeln
Alle Regeln mit großem Anfangsbuchstaben sind Lexer-Regeln
Formen der Subregeln
stmt : ID'=' expr ';' ;
Um die Regel stmt anwenden zu können, müssen alle Elemente auf der rechten
Seite der Regel erfüllt werden. Dabei müssen die Token wie ID, = und ;
matchen und die Subregel expr muss erfüllt werden können. Beachten Sie das
abschließende Semikolon am Ende einer ANTLR-Regel!
stmt : ID'=' expr ';'| expr ';' ;
Alternativen werden durch ein | getrennt. Hier muss genau eine Alternative
erfüllt werden. Falls nötig, trennt man die Alternativen durch Einschließung
in runden Klammern vom Rest der Regel ab: r : a (b | c) d ;.
expr : term ('+' term)* ;
Der durch den * gekennzeichnete Teil kann beliebig oft vorkommen oder auch
fehlen. Bei einem + müsste der Teil mind. einmal vorkommen und bei einem
? entsprechend einmal oder keinmal.
Auch hier kann man die Operatoren durch ein zusätzliches ? auf non-greedy
umschalten (analog zu den Lexer-Regeln).
Falls mehr als eine Parser-Regel die selbe Input-Sequenz matcht, löst ANTLR
diese Mehrdeutigkeit auf, indem es die erste Alternative nimmt, die an der
Entscheidung beteiligt ist.
start : stmt ;
stmt : expr |ID ;
expr : ID|NUM ;
Bei der Eingabe "foo" würde die Alternative ID in der Regel expr "gewinnen",
weil sie in der Grammatik vor der Alternative ID in der Regel stmt kommt und
damit Vorrang hat.
Parse-Tree
Betrachten wir erneut die obige Grammatik.
Die Eingabe von "a = 42;" führt zu folgendem Parse-Tree:
Diese Eingabe führt zur Erkennung der Token [ID, WS, =, WS, NUM, ;], wobei die
WS-Token verworfen werden und der Parser den Tokenstream [ID, =, NUM, ;]
erhält.
Die Startregel hat auf der rechten Seite kein oder mehrere stmt-Regeln. Die
stmt-Regel fordert auf der rechten Seite entweder die Token IDund = sowie
die Regel expr gefolgt vom Token ;, oder die Regel expr gefolgt vom Token
;. In unserem Beispiel kann für das "a" das Token ID produziert werden, das
"=" matcht ebenfalls. Die "42" wird erklärt, indem für expr ein term und
dort ein atom aufgerufen wird. Für das atom muss entweder ein Token ID
oder NUM als nächstes Token kommen - hier wird die "42" wird als Token NUM
verarbeitet. Da die weiteren Regelteile in term und expr optional sind,
haben wir damit ein expr erfüllt und das nachfolgende ;-Token schließt die
erste Alternative der Regel stmt erfolgreich ab.
Im entstehenden Parse-Tree sind diese Abläufe und grammatikalischen Strukturen
direkt erkennbar. Jede erfolgreich durchlaufene Parserregel wird zu einem
Knoten im Parse-Tree. Die Token werden als Terminale (Blätter) in den Baum
eingehängt.
Anmerkung: Der Parse-Tree ist das Ergebnis der Parsers-Phase im Compiler und
dient damit als Input für die folgenden Compilerstufen. In der Regel benötigt
man die oft recht komplexen Strukturen aber später nicht mehr und vereinfacht
den Baum zu einem Abstract Syntax Tree (AST). Im Beispiel könnte man den Zweig
stmt - expr - term - atom - 42 zu stmt - 42 vereinfachen.
Betrachten wir nun die Eingabe foo = 2+3*4; bar = 3*4+2;. Diese führt zu
folgendem Parse-Tree:
Wie man sehen kann, sind in der Grammatik die üblichen Vorrangregeln für die
Operationen + und * berücksichtigt - die Multiplikation wird in beiden
Fällen korrekt "unter" der Addition im Baum eingehängt.
To EOF not to EOF?
Startregeln müssen nicht unbedingt den gesamten Input "konsumieren". Sie müssen
per Default nur eine der Alternativen in der Startregel erfüllen.
Betrachten wir noch einmal einen leicht modifizierten Ausschnitt aus der obigen
Grammatik:
start : stmt ;
Die Startregel wurde so geändert, dass sie nur noch genau ein Statement
akzeptieren soll.
In diesem Fall würde die Startregel bei der Eingabe "aa; bb;" nur den ersten
Teil "aa;" konsumieren (als Token ID) und das folgende "bb;" ignorieren.
Das wäre in diesem Fall aber auch kein Fehler.
Wenn der gesamte Eingabestrom durch die Startregel erklärt werden soll,
dann muss das vordefinierte Token EOF am Ende der Startregel eingesetzt
werden:
start : stmt EOF;
Hier würde die Eingabe "aa; bb;" zu einem Fehler führen, da nur der Teil "aa;"
durch die Startregel abgedeckt ist (Token ID), und der Rest "bb;" zwar sogar
ein gültiges Token wären (ebenfalls ID und ;), aber eben nicht mehr von der
Startregel akzeptiert. Durch das EOF soll die Startregel aber den gesamten
Input konsumieren und erklären, was hier nicht geht und entsprechend zum Fehler
führt.
Betrachten wir noch einmal den Ausschnitt für die Ausdrücke (Expressions) in
der obigen Beispielgrammatik:
expr : term ('+' term)* ;
term : atom ('*' atom)* ;
atom : ID ;
Diese typische, etwas komplex anmutende Struktur soll sicher stellen, dass die
Vorrangregeln für Addition und Multiplikation korrekt beachtet werden, d.h. dass
2+3*4 als 2+(3*4) geparst wird und nicht fälschlicherweise als (2+3)*4
erkannt wird.
Zusätzlich muss bei LL-Parsern Links-Rekursion vermieden werden: Die Parser-Regeln
werden in Funktionsaufrufe übersetzt, d.h. bei einer Links-Rekursion würde man die
selbe Regel immer wieder aufrufen, ohne ein Token aus dem Token-Strom zu entnehmen.
ANTLR (ab Version 4) kann mit beiden Aspekten automatisch umgehen:
ANTLR kann direkte Linksrekursion automatisch auflösen. Die Regel r : r T U | V ;
kann also in ANTLR verarbeitet werden.
ANTLR besitzt einen Mechanismus zur Auflösung von Mehrdeutigkeiten. Wie oben
geschrieben, wird bei der Anwendbarkeit von mehreren Alternativen die erste
Alternative genutzt.
Damit lässt sich die typische Struktur für Expression-Grammatiken deutlich lesbarer
gestalten:
Da bei Mehrdeutigkeit in der Grammatik, also bei der Anwendbarkeit mehrerer Alternativen
stets die erste Alternative genommen wird, lassen sich die Vorrangregeln durch die
Reihenfolge der Alternativen in der expr-Regel implementieren: Die Multiplikation
hat Vorrang von der Addition, und diese hat wiederum Vorrang von einer einfachen ID.
Direkte vs. indirekte Links-Rekursion
ANTLR kann nur direkte Links-Rekursion auflösen. Regeln wie r : r T U | V ; stellen
in ANTLR also kein Problem dar.
Hier würden sich die Regeln r und s gegenseitig aufrufen und kein Token aus dem
Tokenstrom entfernen, so dass der generierte LL-Parser hier in einer Endlosschleife
stecken bleiben würde. Mit indirekter Links-Rekursion kann ANTLR nicht umgehen.
Konflikte in Regeln
Wenn mehrere Alternativen einer Regel anwendbar sind, entscheidet sich ANTLR für die
erste Alternative.
Wenn sich mehrere Tokenregeln überlappen, "gewinnt" auch hier die zuerst definierte
Regel.
def : 'func'ID'('')' block ;
FOR : 'for' ;
ID : [a-z][a-zA-Z]* ;
Hier werden ein implizites Token 'func' sowie die expliziten Token FOR und ID
definiert. Dabei sind die Lexeme für 'func' und FOR auch in ID enthalten.
Dennoch werden 'func' und FOR erkannt und nicht über ID gematcht, weil sie
vor der Regel ID definiert sind.
Tatsächlich sortiert ANTLR die Regeln intern um, so dass alle Parser-Regeln vor den
Lexer-Regeln definiert sind. Die impliziten Token werden dabei noch vor den expliziten
Token-Regeln angeordnet. Im obigen Beispiel hat also 'func' eine höhere Priorität
als FOR, und FOR hat eine höhere Priorität als ID. Aus diesem Grund gibt es die
Konvention, die Parser-Regeln in der Grammatik vor den Lexer-Regeln zu definieren - dies
entspricht quasi der Anordnung, die ANTLR bei der Verarbeitung sowieso erzeugen würde.
Aus diesem Grund würde auch eine Umsortierung der obigen Grammatik funktionieren:
FOR : 'for' ;
ID : [a-z][a-zA-Z]* ;
def : 'func'ID'('')' block ;
Intern würde ANTLR die Parser-Regel def wieder vor den beiden Lexer-Regeln anordnen,
und zwischen den Parser-Regeln und den Lexer-Regeln die impliziten Token (hier 'func').
Kontext-Objekte für Parser-Regeln
s : expr {List<EContext> x = $expr.ctx.e();}
;
expr : e '*' e ;
Jede Regel liefert ein passend zu dieser Regel generiertes Kontext-Objekt
zurück. Darüber kann man das/die Kontextobjekt(e) der Sub-Regeln abfragen.
Die Regel s liefert entsprechend ein SContext-Objekt und die Regel
expr liefert ein ExprContext-Objekt zurück.
In der Aktion fragt man das Kontextobjekt über ctx ab, in den Listener-
und Visitor-Methoden erhält man die Kontextobjekte als Parameter.
Für einfache Regel-Aufrufe liefert die parameterlose Methode nur ein
einziges Kontextobjekt (statt einer Liste) zurück.
Anmerkung: ANTLR generiert nur dann Felder für die Regel-Elemente im
Kontextobjekt, wenn diese in irgendeiner Form referenziert werden. Dies
kann beispielsweise durch Benennung (Definition eines Labels, siehe nächste
Folie) oder durch Nutzung in einer Aktion (siehe obiges Beispiel) geschehen.
Benannte Regel-Elemente oder Alternativen
stat : 'return' value=e ';'#Return|'break'';'#Break ;
Mit value=e wird der Aufruf der Regel e mit dem Label value belegt,
d.h. man kann mit $e.text oder $value.text auf das text-Attribut von
e zugreifen. Falls es in einer Produktion mehrere Aufrufe einer anderen
Regel gibt, muss man für den Zugriff auf die Attribute eindeutige Label
vergeben.
Analog wird für die beiden Alternativen je ein eigener Kontext erzeugt.
Arbeiten mit ANTLR-Listeners
ANTLR (generiert auf Wunsch) zur Grammatik passende Listener (Interface und
leere Basisimplementierung). Beim Traversieren mit dem Default-ParseTreeWalker
wird der Parse-Tree mit Tiefensuche abgelaufen und jeweils beim Eintritt in
bzw. beim Austritt aus einen/m Knoten der passende Listener mit dem passenden
Kontext-Objekt aufgerufen.
Damit kann man die Grammatik "für sich" halten, d.h. unabhängig von einer
konkreten Zielsprache und die Aktionen über die Listener (oder Visitors, s.u.)
ausführen.
ANTLR kann zu dieser Grammatik calc.g4 einen passenden Listener (Interface
calcListener) generieren (Option -listener beim Aufruf von antlr).
Weiterhin generiert ANTLR eine leere Basisimplementierung (Klasse calcBaseListener):
(Nur "interessante" Methoden gezeigt.)
Von dieser Basisklasse leitet man einen eigenen Listener ab und implementiert
die Methoden, die man benötigt.
ANTLR (generiert ebenfalls auf Wunsch) zur Grammatik passende Visitoren
(Interface und leere Basisimplementierung).
Hier muss man im Gegensatz zu den Listeners allerdings selbst für eine geeignete
Traversierung des Parse-Trees sorgen. Dafür hat man mehr Freiheiten im Vergleich
zum Einsatz von Listeners, insbesondere im Hinblick auf Rückgabewerte.
ANTLR kann zu dieser Grammatik einen passenden Visitor (Interface calcVisitor<T>)
generieren (Option -visitor beim Aufruf von antlr). Weiterhin generiert ANTLR
eine leere Basisimplementierung (Klasse calcBaseVisitor<T>):
(Nur "interessante" Methoden gezeigt.)
Von dieser Basisklasse leitet man einen eigenen Visitor ab und überschreibt
die Methoden, die man benötigt. Wichtig ist, dass man selbst für das "Besuchen"
der Kindknoten sorgen muss (rekursiver Aufruf der geerbten Methode visit()).
publicstaticclassMyVisitorextends calcBaseVisitor<Integer> {
public Integer visitMULT(calcParser.MULTContext ctx) {
return ...
}
public Integer visitADD(calcParser.ADDContext ctx) {
return ...
}
public Integer visitZAHL(calcParser.ZAHLContext ctx) {
return ...
}
}
Anschließend baut man das alles in eine manuelle Traversierung des Parse-Trees ein:
Auch die Parser-Regeln können mit eingebetteten Aktionen ergänzt werden, die
in die (für die jeweilige Regel) generierte Methode eingefügt werden und bei
erfolgreicher Anwendung der Parser-Regel ausgeführt werden.
Über returns [int v] fügt man der Regel expr ein Attribut v (Integer)
hinzu, welches man im jeweiligen Kontext abfragen bzw. setzen kann (agiert
als Rückgabewert der generierten Methode). Auf diesen Wert kann in den
Aktionen mit $v zugegriffen werden.
Anmerkung: Durch den Einsatz von eingebetteten Aktionen und Attributen wird
die Grammatik abhängig von der Zielsprache des generierten Lexers/Parsers!
Ausblick
Damit haben wir die sprichwörtliche "Spitze des Eisbergs" gesehen. Mit ANTLR
sind noch viele weitere Dinge möglich. Bitte nutzen Sie aktiv die Dokumentation
auf github.com/antlr/antlr4.
Wrap-Up
Parser mit ANTLR generieren: Parser-Regeln werden mit Kleinbuchstaben geschrieben
Regeln können Lexer- und Parser-Regeln "aufrufen"
Regeln können Alternativen haben
Bei Mehrdeutigkeit: Vorrang für erste Alternative
ANTLR erlaubt direkte Links-Rekursion
ANTLR erzeugt Parse-Tree
Benannte Alternativen und Regel-Elemente
Traversierung des Parse-Tree: Listener oder Visitoren, Zugriff auf Kontextobjekte
Challenges
Lexer und Parser mit ANTLR: Programmiersprache Lox
Betrachten Sie folgenden Code-Schnipsel in der Sprache "Lox":
fun fib(x) {
if (x == 0) {
return 0;
} else {
if (x == 1) {
return 1;
} else {
fib(x - 1) + fib(x - 2);
}
}
}
var wuppie = fib(4);
Erstellen Sie für diese fiktive Sprache einen Lexer+Parser mit ANTLR.
Implementieren Sie mit Hilfe des Parse-Trees und der Listener oder Visitoren einen einfachen Pretty-Printer.
Auf die lexikalische Analyse und die Syntaxanalyse folgt die semantische Analyse. Nach dem
Parsen steht fest, dass ein Programm syntaktisch korrekt ist. Nun muss geprüft werden, ob
es auch semantisch korrekt ist. Dazu gehören u.a. die Identifikation und Sammlung von
Bezeichnern und die Zuordnung zur richtigen Ebene (Scopes) sowie die die Typ-Prüfung
und -Inferenz.
In dieser Phase zeigen sich die Eigenschaften der zu verarbeitenden Sprache sehr deutlich,
beispielsweise müssen Bezeichner deklariert sein vor der ersten Benutzung, welche Art von
Scopes soll es geben, gibt es Klassen und Vererbung ...
Da hier der Kontext der Symbole eine Rolle spielt, wird diese Phase oft auch "Context Handling"
oder "Kontext Analyse" bezeichnet. Neben attributierten Grammatiken sind die Symboltabellen
wichtige Werkzeuge.
Auf die lexikalische Analyse und die Syntaxanalyse folgt die semantische Analyse. Nach dem
Parsen steht fest, dass ein Programm syntaktisch korrekt ist. Nun muss geprüft werden, ob
es auch semantisch korrekt ist. Dies umfasst in der Regel die Identifikation und Sammlung
von Bezeichnern und die Zuordnung zur richtigen Ebene (Scopes). Außerdem muss die Nutzung
von Symbolen validiert werden: Je nach Sprache müssen beispielsweise Variablen und Funktionen
vor ihrer Benutzung zumindest deklariert sein; Funktionen sollten sich nicht wie Variablen
benutzen lassen, ...
Als Werkzeug werden (hierarchische) Tabellen eingesetzt, um die verschiedenen Symbole und
Informationen darüber zu verwalten. Dabei werden die Symboltabelleneinträge oft an verschiedenen
Stellen im Compiler generiert und benutzt.
(K2) Bedeutung von Symboltabellen: Aufgaben, Verbindung zu Compiler-Phasen
Was passiert nach der Syntaxanalyse?
int x =42;
intf(int x) {
int y =9;
return y+x;
}
x =f(x);
Nach der Syntaxanalyse braucht der Compiler für die darauf folgenden Phasen
semantische Analyse, Optimierung und Codegenerierung Informationen
über Bezeichner, z.B.
Welcher Bezeichner ist gemeint?
Welchen Typ hat ein Bezeichner?
Auf dem Weg zum Interpreter/Compiler müssen die Symbole im AST korrekt zugeordnet
werden. Dies geschieht über Symboltabellen. Im Folgenden werden wir verschiedene
Aspekte von Symboltabellen betrachten und eine mögliche Implementierung erarbeiten,
bevor wir uns um die Auswertung (Interpretation) des AST kümmern können.
Logische Compilierungsphasen
Die lexikalische Analyse generiert eine Folge von Token.
Die Syntaxanalyse generiert einen Parse Tree.
Die semantische Analyse macht folgendes:
Der Parse Tree wird in einen abstrakten Syntaxbaum (AST) umgewandelt.
Dieser wird häufig mit Attributen annotiert.
Dabei sind oft mehrere Baumdurchläufe nötig (z.B. wegen der Abhängigkeiten
der Attribute).
Nachfolgende Stufen:
Der AST wird in einen Zwischencode umgewandelt mit Registern und virtuellen
Adressen.
Der Zwischencode wird optimiert.
Aus dem optimierten Zwischencode wird der endgültige Code, aber immer noch
mit virtuellen Adressen, generiert.
Der generierte Code wird nachoptimiert.
Der Linker ersetzt die virtuellen Adressen durch reale Adressen.
Abgrenzung der Phasen
Diese Phasen sind oft nicht klar unterscheidbar. Schon allein zur Verbesserung der
Laufzeit baut der Parser oft schon den abstrakten Syntaxbaum auf, der Lexer trägt schon
Bezeichner in Symboltabellen ein, der Parser berechnet beim Baumaufbau schon Attribute,
...
Oft werden gar nicht alle Phasen und alle Zwischendarstellungen benötigt.
Semantische Analyse und Symboltabellen
Syntax und Semantik
Syntaxregeln: Formaler Aufbau eines Programms
Semantik: Bedeutung eines (syntaktisch korrekten) Programms
=> Keine Codegenerierung für syntaktisch/semantisch inkorrekte Programme!
Zur Erinnerung: Die Syntaxregeln einer Programmiersprache bestimmen den formalen
Aufbau eines zu übersetzenden Programms. Die Semantik gibt die Bedeutung eines
syntaktisch richtigen Programms an.
Lexikalische und syntaktische Analyse können formalisiert mit regulären Ausdrücken und
endlichen Automaten, sowie mit CFG und Parsern durchgeführt werden.
Die Durchführung der semantischen Analyse ist stark von den Eigenschaften der zu
übersetzenden Sprache, sowie der Zielsprache abhängig und kann hier nur beispielhaft
für einige Eigenschaften erklärt werden.
Es darf kein lauffähiges Programm erstellt werden können, dass nicht syntaktisch und
semantisch korrekt ist. Ein lauffähiges Programm muss syntaktisch und semantisch korrekt
sein!
Aufgaben der semantischen Analyse
Identifikation und Sammlung der Bezeichner
Zuordnung zur richtigen Ebene (Scopes)
Typ-Inferenz
Typkonsistenz (Ausdrücke, Funktionsaufrufe, ...)
Validieren der Nutzung von Symbolen
Vermeidung von Mehrfachdefinition
Zugriff auf nicht definierte Bezeichner
(Lesender) Zugriff auf nicht initialisierte Bezeichner
Funktionen werden nicht als Variablen genutzt
...
Die semantische Analyse überprüft die Gültigkeit eines syntaktisch korrekten Programms
bzgl. statischer semantischer Eigenschaften und liefert die Grundlage für die (Zwischen-)
Codeerzeugung und -optimierung. Insbesondere wird hier die Typkonsistenz (in Ausdrücken,
von Parametern, ...) überprüft, und implizite Typumwandlungen werden vorgenommen. Oft
müssen Typen automatisch bestimmt werden (z.B. bei Polymorphie, Typinferenz). Damit
Typen bestimmt oder angepasst werden können, müssen Bezeichner zunächst identifiziert
werden, d.h. bei namensgleichen Bezeichnern der richtige Bezug bestimmt werden.
=> Ein wichtiges Hilfsmittel dazu sind Symboltabellen
Identifizierung von Objekten
Beim Compiliervorgang müssen Namen immer wieder den dazugehörigen Definitionen
zugeordnet, ihre Eigenschaften gesammelt und geprüft und darauf zugegriffen werden.
Symboltabellen werden im Compiler fast überall gebraucht (siehe Abbildung unter
"Einordnung").
Welche Informationen zu einem Bezeichner gespeichert und ermittelt werden, ist dann
abhängig von der Klasse des Bezeichners.
Validieren der Nutzung von Symbolen
Hier sind unendlich viele Möglichkeiten denkbar. Dies reicht von den unten aufgeführten
Basisprüfungen bis hin zum Prüfen der Typkompatibilität bei arithmetischen Operationen.
Dabei müssen für alle Ausdrücke die Ergebnistypen berechnet werden und ggf. automatische
Konvertierungen vorgenommen werden, etwa bei 3+4.1 ...
Zugriff auf Variablen: Müssen sichtbar sein
Zugriff auf Funktionen: Vorwärtsreferenzen sind OK
Variablen werden nicht als Funktionen genutzt
Funktionen werden nicht als Variablen genutzt
Da Funktionen bereits vor dem Bekanntmachen der Definition aufgerufen werden dürfen, bietet
sich ein zweimaliger Durchlauf (pass) an: Beim ersten Traversieren des AST werden alle
Definitionen in der Symboltabelle gesammelt. Beim zweiten Durchlauf werden dann die Referenzen
aufgelöst.
Das Mittel der Wahl: Tabellen für die Symbole (= Bezeichner)
Def.:Symboltabellen sind die zentrale Datenstruktur zur Identifizierung und
Verwaltung von bezeichneten Elementen.
Die Organisation der Symboltabellen ist stark anwendungsabhängig. Je nach Sprachkonzept
gibt es eine oder mehrere Symboltabellen, deren Einträge vom Lexer oder Parser angelegt
werden. Die jeweiligen Inhalte jedes einzelnen Eintrags kommen aus den verschiedenen
Phasen der Compilierung. Symboltabellen werden oft als Hashtables oder auch als Bäume
implementiert, manchmal als verkettete Listen. In seltenen Fällen kommt man auch mit
einem Stack aus.
Eine Symboltabelle enthält benutzerdefinierte Bezeichner (oder Verweise in eine Hashtable
mit allen vorkommenden Namen), manchmal auch die Schlüsselwörter der Programmiersprache.
Die einzelnen Felder eines Eintrags variieren stark, abhängig vom Typ des Bezeichners
(= Bezeichnerklasse).
Manchmal gibt es für Datentypen eine Extra-Tabelle, ebenso eine für die Werte von Konstanten.
Manchmal werden die Namen selbst in eine (Hash-) Tabelle geschrieben. Die Symboltabelle
enthält dann statt der Namen Verweise in diese (Hash-) Tabelle.
Einfache Verwaltung von Variablen primitiven Typs
int x =0;
int i =0;
for (i=0; i<10; i++) {
x++;
}
Bsp.: Die zu übersetzende Sprache hat nur einen (den globalen) Scope und kennt nur
Bezeichner für Variablen.
Eine Symboltabelle für alle Bezeichner
Jeder Bezeichner ist der Name einer Variablen
Symboltabelle wird evtl. mit Einträgen aller Schlüsselwörter initialisiert -- warum?
Scanner erkennt Bezeichner und sucht ihn in der Symboltabelle
Ist der Bezeichner nicht vorhanden, wird ein (bis auf den Namen leerer) Eintrag angelegt
Scanner übergibt dem Parser das erkannte Token und einen Verweis auf den
Symboltabelleneintrag
Die Symboltabelle könnte hier eine (Hash-) Tabelle oder eine einfache verkettete Liste sein.
Was kann jetzt weiter passieren?
int x =0;
int i =0;
for (i=0; i<10; i++) {
x++;
}
a =42;
In vielen Sprachen muss überprüft werden, ob es ein definierendes Vorkommen des Bezeichners oder
ein angewandtes Vorkommen ist.
Definitionen und Deklarationen von Bezeichnern
Def.: Die Definition eines (bisher nicht existenten) Bezeichners in einem Programm
generiert einen neuen Bezeichner und legt für ihn seinem Typ entsprechend Speicherplatz an.
Def.: Unter der Deklaration eines (bereits existierenden) Bezeichners verstehen wir
seine Bekanntmachung, damit er benutzt werden kann. Er ist oft in einem anderen Scope
definiert und bekommt dort Speicherplatz zugeteilt.
Insbesondere werden auch Typen deklariert. Hier gibt es in der Regel gar keine
Speicherplatzzuweisung.
Ein Bezeichner kann beliebig oft deklariert werden, während er in einem Programm nur einmal
definiert werden kann. Oft wird bei der Deklarationen eines Elements sein Namensraum mit
angegeben.
Vorsicht: Die Begriffe werden auch anders verwendet. Z.B. findet sich in der
Java-Literatur der Begriff Deklaration anstelle von Definition.
Anmerkung:
Deklarationen beziehen sich auf Definitionen, die woanders in einer Symboltabelle stehen, evtl.
in einer anderen Datei, also in diesem Compilerlauf nicht zugänglich sind und erst von Linker
aufgelöst werden können. Beim Auftreten einer Deklaration muss die dazugehörige Definition gesucht
werden,und wenn vorhanden, im Symboltabelleneintrag für den deklarierten Bezeichner festgehalten
werden. Hier ist evtl. ein zweiter Baumdurchlauf nötig, um alle offenen Deklarationen, die sich
auf Definitionen in derselben Datei beziehen, aufzulösen.
Wird bei objektorientierten Sprachen ein Objekt definiert, dessen Klassendefinition in einer anderen
Datei liegt, kann man die Definition des Objekts gleichzeitig als Deklaration der Klasse auffassen
(Java).
Wo werden Verweise in Symboltabellen gebraucht?
=> Parse Tree und AST enthalten Verweise auf Symboltabelleneinträge
Im Parse Tree enthält der Knoten für einen Bezeichner einen Verweis auf den
Symboltabelleneintrag.
Parser und semantische Analyse (AST) vervollständigen die Einträge.
Attribute des AST können Feldern der Symboltabelle entsprechen, bzw. sich aus
ihnen berechnen.
Für Debugging-Zwecke können die Symboltabellen die ganze Compilierung und das
Linken überleben.
Grenzen der semantischen Analyse
Welche semantischen Eigenschaften einer Sprache kann die semantische Analyse nicht überprüfen?
Wer ist dann dafür verantwortlich?
Wie äußert sich das im Fehlerfall?
Dinge, die erst durch eine Ausführung/Interpretation eines Programms berechnet werden können.
Beispielsweise können Werte von Ausdrücken oft erst zur Laufzeit bestimmt werden. Insbesondere
kann die semantische Analyse in der Regel nicht feststellen, ob ein Null-Pointer übergeben wird
und anschließend dereferenziert wird.
Wrap-Up
Semantische Analyse:
Identifikation und Sammlung der Bezeichner
Zuordnung zur richtigen Ebene (Scopes)
Validieren der Nutzung von Symbolen
Typ-Inferenz
Typkonsistenz (Ausdrücke, Funktionsaufrufe, ...)
Symboltabellen: Verwaltung von Symbolen und Typen (Informationen über Bezeichner)
Symboltabelleneinträge werden an verschiedenen Stellen des Compilers generiert und benutzt
In Symboltabellen werden Informationen über Bezeichner verwaltet. Wenn es in der zu
übersetzenden Sprache Nested Scopes gibt, spiegelt sich dies in den Symboltabellen
wider: Auch hier wird eine entsprechende hierarchische Organisation notwendig. In der
Regel nutzt man Tabellen, die untereinander verlinkt sind.
Eine wichtige Aufgabe ist das Binden von Bezeichner gleichen Namens an ihren jeweiligen
Scope => bind(). Zusätzlich müssen Symboltabellen auch das Abrufen von Bezeichnern
aus dem aktuellen Scope oder den Elternscopes unterstützen => resolve().
(K3) Aufbau von Symboltabellen für Nested Scopes inkl. Strukturen/Klassen mit einem Listener
(K3) Auflösen von Symbolen über die Scopes
(K3) Einfache statische Prüfungen anhand der Symboltabellen
Scopes und Name Spaces
Def.: Unter dem Gültigkeitsbereich (Sichtbarkeitsbereich, Scope) eines
Bezeichners versteht man den Programmabschnitt, in dem der Bezeichner sichtbar
und nutzbar ist. Das ist oft der kleinste umgebende Block, außer darin enthaltene
Scopes, die ein eigenes Element dieses Namens benutzen.
Scopes sind fast immer hierarchisch angeordnet.
Def.: Unter einem Namensraum (name space) versteht man die Menge der zu
einem Zeitpunkt sichtbaren Bezeichner.
Es gibt Sprachen, in denen man eigene Namensräume explizit definieren kann (z.B.
C++).
Vorsicht: Diese Begriffe werden nicht immer gleich definiert und auch gerne
verwechselt.
Symbole und (nested) Scopes
int x =42;
float y;
{
int x;
x =1;
y =2;
{ int y = x; }
}
Aufgaben:
bind(): Symbole im Scope definieren
resolve(): Symbole aus Scope oder Eltern-Scope abrufen
Hinzunahme von Scopes
Bsp.: Die zu übersetzende Sprache ist scope-basiert und kennt nur Bezeichner
für Variablen
Scopes können ineinander verschachtelt sein. Die Spezifikation der zu übersetzenden
Sprache legt fest, in welcher Reihenfolge Scopes zu durchsuchen sind, wenn auf einen
Bezeichner Bezug genommen wird, der nicht im aktuellen Scope definiert ist.
Insgesamt bilden die Scopes oft eine Baumstruktur, wobei jeder Knoten einen Scope
repräsentiert und seine Kinder die direkt in ihm enthaltenen Scopes sind. Dabei ist
es in der Regel so, dass Scopes sich entweder vollständig überlappen oder gar nicht.
Wenn ein Bezeichner nicht im aktuellen Scope vorhanden ist, muss er in der Regel in
umschließenden Scopes gesucht werden. Hier kann ein Stack aller "offenen" Scopes
benutzt werden.
Grundlegendes Vorgehen
Das Element, das einen neuen Scope definiert, steht selbst in dem aktuell
behandelten Scope. Wenn dieses Element selbst ein Bezeichner ist, gehört
dieser in den aktuellen Scope. Nur das, was nur innerhalb des oben genannten
Elements oder Bezeichners definiert wird, gehört in den Scope des Elements
oder Bezeichners.
Nested Scopes: Symbole und Scopes
Implementierung mit hierarchischen (verketteten) Tabellen
Pro Scope wird eine Symboltabelle angelegt, dabei enthält jede Symboltabelle zusätzlich
einen Verweis auf ihre Vorgängersymboltabelle für den umgebenden Scope. Die globale
Symboltabelle wird typischerweise mit allen Schlüsselwörtern initialisiert.
Wenn ein neuer Scope betreten wird, wird eine neue Symboltabelle erzeugt.
Scanner: Erkennt Bezeichner und sucht ihn in der Symboltabelle des aktuellen
Scopes bzw. trägt ihn dort ein und übergibt dem Parser das erkannte Token und
einen Verweis auf den Symboltabelleneintrag (Erinnerung: Der Scanner wird
i.d.R. vom Parser aus aufgerufen, d.h. der Parser setzt den aktuellen Scope!)
Parser:
Wird ein neues Element (ein Bezeichner) definiert, muss bestimmt werden, ob
es einen eigenen Scope hat. Wenn ja, wird eine neue Symboltabelle für den
Scope angelegt. Sie enthält alle Definitionen von Elementen, die in diesem
Scope liegen. Der Bezeichner selbst wird in die aktuelle Symboltabelle eingetragen
mit einem Verweis auf die neue Tabelle, die all die Bezeichner beinhaltet, die
außerhalb dieses Scopes nicht sichtbar sein sollen. Die Tabellen werden untereinander
verzeigert.
Wird ein Element deklariert oder benutzt, muss sein Eintrag in allen sichtbaren
Scopes in der richtigen Reihenfolge entlang der Verzeigerung gesucht (und je nach
Sprachdefinition auch gefunden) werden.
Der Parse-Tree enthält im Knoten für den Bezeichner den Verweis in die Symboltabelle
Klassenhierarchie für Scopes
Für die Scopes wird eine Klasse Scope definiert mit den Methoden bind() (zum
Definieren von Symbolen im Scope) und resolve() (zum Abrufen von Symbolen aus
dem Scope oder dem umgebenden Scope).
Für lokale Scopes wird eine Instanz dieser Klasse angelegt, die eine Referenz auf
den einschließenden Scope im Attribut enclosingScope hält. Für den globalen Scope
ist diese Referenz einfach leer (None).
Klassen und Interfaces für Symbole
Für die Symbole gibt es die Klasse Symbol, wo für jedes Symbol Name und Typ gespeichert
wird. Variablensymbole leiten direkt von dieser Klasse ab. Für die eingebauten Typen wird
ein "Marker-Interface" Type erstellt, um Variablen- und Typ-Symbole unterscheiden zu
können.
Der Parse Tree bzw. der AST enthalten an den Knoten, die jeweils einen ganzen
Scope repräsentieren, einen Verweis auf die Symboltabelle dieses Scopes.
Die Scopes werden in einem Stack verwaltet.
Wird ein Scope betreten beim Baumdurchlauf, wird ein Verweis auf seine
Symboltabelle auf den Stack gepackt.
Die Suche von Bezeichnern in umliegenden Scopes erfordert ein Durchsuchen
des Stacks von oben nach unten.
Beim Verlassen eines Scopes beim Baumdurchlauf wird der Scope vom Stack entfernt.
Nested Scopes: Definieren und Auflösen von Namen
classScope:
Scope enclosingScope # None if global (outermost) scope Symbol<String, Symbol> symbols
defresolve(name):
# do we know "name" here?if symbols[name]: return symbols[name]
# if not here, check any enclosing scopeif enclosingScope: return enclosingScope.resolve(name)
else: returnNone# not founddefbind(symbol):
symbols[symbol.name] = symbol
symbol.scope = self # track the scope in each symbol
Anmerkung: In der Klasse Symbol kann man ein Feld scope vom Typ Scope
implementieren. Damit "weiss" jedes Symbol, in welchem Scope es definiert ist und
man muss sich auf der Suche nach dem Scope eines Symbols ggf. nicht erst durch
die Baumstruktur hangeln. Aus technischer Sicht verhindert das Attribut das
Aufräumen eines lokalen Scopes durch den Garbage Collector, wenn man den lokalen
Scope wieder verlässt: Jeder Scope hat eine Referenz auf den umgebenden (Eltern-)
Scope (Feld enclosingScope). Wenn man den aktuellen Scope "nach oben" verlässt,
würde der eben verlassene lokale Scope bei nächster Gelegenheit aufgeräumt, wenn
es keine weiteren Referenzen auf diesen gäbe. Da nun aber die Symbole, die in
diesem Scope definiert wurden, auf diesen verweisen, passiert das nicht :)
Nested Scopes: Listener
Mit einem passenden Listener kann man damit die nötigen Scopes aufbauen:
enterStart:
erzeuge neuen globalen Scope
definiere und pushe die eingebauten Typen
exitVarDecl:
löse den Typ der Variablen im aktuellen Scope auf
definiere ein neues Variablensymbol im aktuellen Scope
exitVar:
löse die Variable im aktuellen Scope auf
enterBlock:
erzeuge neuen lokalen Scope, wobei der aktuelle Scope der Elternscope ist
ersetze den aktuellen Scope durch den lokalen Scope
exitBlock:
ersetze den aktuellen Scope durch dessen Elternscope
start : stat+ ;
stat : block | varDecl | expr ';' ;
block : '{' stat* '}' ;
varDecl : type ID ('=' expr)? ';' ;
expr : var '=' INT ;
var : ID ;
type : 'float' | 'int' ;
Relevanter Ausschnitt aus der Grammatik
int x =42;
{ int y =9; x =7; }
classMyListener(BaseListener):
Scope scope
defenterStart(Parser.FileContext ctx):
globals = Scope()
globals.bind(BuiltIn("int"))
globals.bind(BuiltIn("float"))
scope = globals
defenterBlock(Parser.BlockContext ctx):
scope = Scope(scope)
defexitBlock(Parser.BlockContext ctx):
scope = scope.enclosingScope
defexitVarDecl(Parser.VarDeclContext ctx):
t = scope.resolve(ctx.type().getText())
var = Variable(ctx.ID().getText(), t)
scope.bind(var)
defexitVar(Parser.VarContext ctx):
name = ctx.ID().getText()
var = scope.resolve(name)
if var ==None: error("no such var: "+ name)
Anmerkung: Um den Code auf die Folie zu bekommen, ist dies ein Mix aus Java und Python geworden. Sry ;)
In der Methode exitVar() wird das Variablensymbol beim Ablaufen des AST
lediglich aufgelöst und ein Fehler geworfen, wenn das Variablensymbol (noch)
nicht bekannt ist. Hier könnte man weiteres Type-Checking und/oder -Propagation
ansetzen.
Später im Interpreter muss an dieser Stelle dann aber auch der Wert der
Variablen abgerufen werden ...
Löschen von Symboltabellen
Möglicherweise sind die Symboltabellen nach der Identifizierungsphase der Elemente
überflüssig, weil die zusammengetragenen Informationen als Attribute im AST stehen.
Die Knoten enthalten dann Verweise auf definierende Knoten von Elementen, nicht mehr
auf Einträge in den Symboltabellen. In diesem Fall können die Symboltabellen nach der
Identifizierung gelöscht werden, wenn sie nicht z.B. für einen symbolischen Debugger
noch gebraucht werden.
Wrap-Up
Symboltabellen: Verwaltung von Symbolen und Typen (Informationen über Bezeichner)
Eine Funktion sind selbst ein Symbol, welches in einem Scope gilt und entsprechend
in der Symboltabelle eingetragen wird. Darüber hinaus bildet sie einen neuen
verschachtelten Scope, in dem die Funktionsparameter und der Funktionskörper definiert
werden müssen.
Entsprechend müssen die Strukturen für die Symboltabellen sowie das Eintragen und das
Auflösen von Symbolen erweitert werden.
(K3) Aufbau von Symboltabellen für Nested Scopes inkl. Strukturen/Klassen mit einem Listener
(K3) Attribute von Klassen und Strukturen auflösen
Funktionen und Scopes
int x =42;
int y;
voidf() {
int x;
x =1;
y =2;
{ int y = x; }
}
voidg(int z){}
Behandlung von Funktionsdefinitionen
Jeder Symboltabelleneintrag braucht ein Feld, das angibt, ob es sich um eine
Variable, eine Funktion, ... handelt. Alternativ eine eigene Klasse ableiten ...
Der Name der Funktion steht als Bezeichner in der Symboltabelle des Scopes, in dem
die Funktion definiert wird.
Der Symboltabelleneintrag für den Funktionsnamen enthält Verweise auf die Parameter.
Der Symboltabelleneintrag für den Funktionsnamen enthält Angaben über den Rückgabetypen.
Jede Funktion wird grundsätzlich wie ein neuer Scope behandelt.
Die formalen Parameter werden als Einträge in der Symboltabelle für den Scope der
Funktion angelegt and entsprechend als Parameter gekennzeichnet.
Behandlung von Funktionsaufrufen
Der Name der Funktion steht als Bezeichner in der Symboltabelle des Scopes, in dem
die Funktion aufgerufen wird und wird als Aufruf gekennzeichnet.
Der Symboltabelleneintrag für den Funktionsnamen enthält Verweise auf die aktuellen
Parameter.
Die Definition der Funktion wird in den zugänglichen Scopes gesucht (wie oben) und
ein Verweis darauf in der Symboltabelle gespeichert.
Erweiterung des Klassendiagramms für Funktions-Scopes
classFunction(Scope, Symbol):
def __init__(name, retType, enclScope):
Symbol.__init__(name, retType) # we are "Symbol" ... enclosingScope = enclScope # ... and "Scope"
Funktionen: Listener
Den Listener zum Aufbau der Scopes könnte man entsprechend erweitern:
enterFuncDecl:
löse den Typ der Funktion im aktuellen Scope auf
lege neues Funktionssymbol an, wobei der aktuelle Scope der Elternscope ist
definiere das Funktionssymbol im aktuellen Scope
ersetze den aktuellen Scope durch das Funktionssymbol
exitFuncDecl:
ersetze den aktuellen Scope durch dessen Elternscope
exitParam: analog zu exitVarDecl
löse den Typ der Variablen im aktuellen Scope auf
definiere ein neues Variablensymbol im aktuellen Scope
exitCall: analog zu exitVar
löse das Funktionssymbol (und die Argumente) im aktuellen Scope auf
funcDecl : type ID '(' params? ')' block ;
params : param (',' param)* ;
param : type ID ;
call : ID '(' exprList? ')' ;
exprList : expr (',' expr)* ;
Relevanter Ausschnitt aus der Grammatik
intf(int x) {
int y =9;
}
int x =f(x);
defenterFuncDecl(Parser.FuncDeclContext ctx):
name = ctx.ID().getText()
type = scope.resolve(ctx.type().getText())
func = Function(name, type, scope)
scope.bind(func)
# change current scope to function scope scope = func
defexitFuncDecl(Parser.FuncDeclContext ctx):
scope = scope.enclosingScope
defexitParam(Parser.ParamContext ctx):
t = scope.resolve(ctx.type().getText())
var = Variable(ctx.ID().getText(), t)
scope.bind(var)
defexitCall(Parser.CallContext ctx):
name = ctx.ID().getText()
func = scope.resolve(name)
if func ==None:
error("no such function: "+ name)
if func.type == Variable:
error(name +" is not a function")
Anmerkung: Um den Code auf die Folie zu bekommen, ist dies wieder ein Mix aus Java und Python geworden. Sry ;)
Im Vergleich zu den einfachen nested scopes kommt hier nur ein weiterer
Scope für den Funktionskopf dazu. Dieser spielt eine Doppelrolle: Er ist
sowohl ein Symbol (welches im Elternscope bekannt ist) als auch ein eigener
(lokaler) Scope für die Funktionsparameter.
Um später im Interpreter eine Funktion tatsächlich auswerten zu können, muss
im Scope der Funktion zusätzlich der AST-Knoten der Funktionsdefinition
gespeichert werden (weiteres Feld/Attribut in Function)!
Wrap-Up
Symboltabellen: Verwaltung von Symbolen und Typen (Informationen über Bezeichner)
Strukturen und Klassen bilden jeweils einen eigenen verschachtelten Scope, worin
die Attribute und Methoden definiert werden.
Bei der Namensauflösung muss man dies beachten und darf beim Zugriff auf Attribute
und Methoden nicht einfach in den übergeordneten Scope schauen. Zusätzlich müssen
hier Vererbungshierarchien in der Struktur der Symboltabelle berücksichtigt werden.
=> Auflösen von "a.b" (im Listener in exitMember()):
a im "normalen" Modus mit resolve() über den aktuellen Scope
Typ von a ist Struct mit Verweis auf den eigenen Scope
b nur innerhalb des Struct-Scopes mit resolveMember()
In der Grammatik würde es eine Regel member geben, die auf eine Struktur
der Art ID.ID anspricht (d.h. eigentlich den Teil .ID), und entsprechend
zu Methoden enterMember() und exitMember() im Listener führt.
Das Symbol für a hat als type-Attribut eine Referenz auf die Struct,
die ja einen eigenen Scope hat (symbols-Map). Darin muss dann b aufgelöst
werden.
Klassen
classA {
public:int x;
voidfoo() { ; }
};
classB:public A {
publicint y;
voidfoo() {
int z = x+y;
}
};
Bei Klassen kommt in den Tabellen ein weiterer Pointer parentClazz auf die
Elternklasse hinzu (in der Superklasse ist der Wert None).
Klassen: Auflösen von Namen
classClazz(Struct):
Clazz parentClazz # None if base classdefresolve(name):
# do we know "name" here?if symbols[name]: return symbols[name]
# NEW: if not here, check any parent class ...if parentClazz and parentClazz.resolve(name): return parentClazz.resolve(name)
else:
# ... or enclosing scope if base classif enclosingScope: return enclosingScope.resolve(name)
else: returnNone# not founddefresolveMember(name):
if symbols[name]: return symbols[name]
# NEW: check parent classif parentClazz: return parentClazz.resolveMember(name)
else: returnNone
Hinweis: Die obige Implementierungsskizze soll vor allem das Prinzip demonstrieren - sie ist aus
Gründen der Lesbarkeit nicht besonders effizient: beispielsweise wird parentClazz.resolve(name)
mehrfach evaluiert ...
Beim Auflösen von Attributen oder Methoden muss zunächst in der Klasse selbst gesucht werden,
anschließend in der Elternklasse.
Beispiel (mit den obigen Klassen A und B):
B foo;
foo.x =42;
Hier wird analog zu den Structs zuerst foo mit resolve() im lokalen Scope aufgelöst. Der Typ
des Symbols foo ist ein Clazz, was zugleich ein Scope ist. In diesem Scope wird nun mit
resolveMember() nach dem Symbol x gesucht. Falls es hier nicht gefunden werden kann, wird in
der Elternklasse (sofern vorhanden) weiter mitresolveMember() gesucht.
Die normale Namensauflösung wird ebenfalls erweitert um die Auflösung in der Elternklasse.
Beispiel:
int wuppie;
classA {
public:int x;
voidfoo() { ; }
};
classB:public A {
publicint y;
voidfoo() {
int z = x+y+wuppie;
}
};
Hier würde wuppie als Symbol im globalen Scope definiert werden. Beim Verarbeiten von
int z = x+y+wuppie; würde mit resolve() nach wuppie gesucht: Zuerst im lokalen Scope
unterhalb der Funktion, dann im Funktions-Scope, dann im Klassen-Scope von B. Hier sucht
resolve() auch zunächst lokal, geht dann aber die Vererbungshierarchie entlang (sofern
wie hier vorhanden). Erst in der Superklasse (wenn der parentClazz-Zeiger None ist),
löst resolve() wieder normal auf und sucht um umgebenden Scope. Auf diese Weise kann man
wie gezeigt in Klassen (Methoden) auf globale Variablen verweisen ...
Anmerkung: Durch dieses Vorgehen wird im Prinzip in Methoden aus dem Zugriff auf ein Feld
x implizit ein this.x aufgelöst, wobei this die Klasse auflöst und x als Attribut darin.
Wrap-Up
Symboltabellen: Verwaltung von Symbolen und Typen (Informationen über Bezeichner)
Strukturen und Klassen bilden eigenen Scope
Strukturen/Klassen lösen etwas anders auf: Zugriff auf Attribute und Methoden
Challenges
Symboltabellen praktisch
Betrachten Sie folgenden Java-Code:
Umkreisen Sie alle Symbole.
Zeichen Sie Pfeile von Symbol-Referenzen zur jeweiligen Definition (falls vorhanden).
Identifizieren Sie alle benannten Scopes.
Identifizieren Sie alle anonymen Scopes.
Geben Sie die resultierende Symboltabelle an (Strukturen wie in VL besprochen).
package a.b;
import u.Y;
classXextends Y {
intf(int x) {
int x,y;
{ int x; x - y + 1; }
x = y + 1;
}
}
classZ {
classWextends X {
int x;
voidfoo() { f(34); }
}
int x,z;
intf(int x) {
int y;
y = x;
z = x;
}
}
Die Schnittstelle zwischen dem "Frontend" und dem "Backend" ist der "Zwischencode"
(intermediate code (IC), auch intermediate representation (IR) genannt).
Für den Zwischencode gibt es kein allgemein definiertes Format. In der Praxis
trifft man auf eine große Bandbreite an verschiedenen Formaten, besonders verbreitet
sind beispielsweise die Formate AST, SSA, LLVM-IR und MLIR.
Für den Zwischencode (IR) gibt es kein allgemein definiertes Format. In der Praxis
trifft man auf eine große Bandbreite an Formaten, wobei teilweise bereits der AST selbst
als "Zwischencode" betrachtet/benutzt wird. Typische Vertreter für IR sind beispielsweise
der LLVM IR, diverse Arten von Bytecode (nebst passender virtueller Maschine) und
schließlich als Vorstufe für die Erzeugung von Maschinencode der sogenannte "Drei-Adressen-Code"
und Assemblercode.
(K1) Varianten von Zwischencode, Vor- und Nachteile
Einordnung
Die Schritte in der letzten Phase der Compiler-Pipeline können sehr unterschiedlich
ausfallen.
Beispielsweise könnte direkt aus dem AST der Ziel-Machine-Code erzeugt werden. Auf
der anderen Seite könnte aus dem AST ein Zwischenformat erzeugt werden, darauf
Optimierungen vorgenommen werden, daraus ein weiteres Zwischenformat erzeugt werden,
darauf weitere Optimierungen vorgenommen werden, ..., bis schließlich nach mehreren
Zwischenstufen das Zielformat erzeugt wird.
Nachfolgend betrachten wir verschiedene Beispiele, wie das Zwischenformat aussehen
kann.
AST als Zwischencode (Beispiel Pandoc)
Häufig wird der AST selbst als Zwischencode verwendet. Ein Beispiel dafür ist
Pandoc.
Dies ist ein Absatz mit
* einem Stichpunkt, und
* einem zweiten Stichpunkt.
Der Pandoc-AST spiegelt direkt die Dokumentstruktur wider. Im obigen Beispiel
haben wir einen Absatz mit dem Text "Dies ist ein Absatz mit", der als Para
repräsentiert wird mit einer Liste von Strings (Str) und Leerzeichen (Space).
Die Stichpunktliste besteht pro Stichpunkt aus einem Plain-Knoten mit dem
eigentlichen Inhalt (wieder Strings und Leerzeichen).
Dieser AST ist der Dreh- und Angelpunkt in Pandoc. Verschiedene Reader können
unterschiedliche Textformate parsen und in einen AST überführen.
Auf diesem kann man mit Filtern Transformationen
vornehmen.
Anschließend können diverse Writer den AST in das gewünschte Zielformat überführen.
Eine weitere häufig eingesetzte Zwischenform kurz vor der Code-Generierung ist der sogenannte
"Drei-Adressen-Code". Dieser besteht jeweils aus einer Operation auf bis zu drei Adressen.
Im Prinzip handelt es sich hier um eine Art "High-Level Assembler" mit beliebig vielen Registern ...
Adressen sind dabei Namen, Konstanten oder vom Compiler generierte temporäre Werte. Die typische Form
ist x = y op z (binäre Operationen) oder x = op z (unäre Operationen). Werte werden mit x = y
kopiert. Jeder Teilausdruck erhält typischerweise eine eigene temporäre Variable zur Speicherung des
Ergebnisses. Weiterhin gibt es bedingte und unbedingte Sprünge und Prozedur-Aufrufe.
Index-Zugriffe werden über Pointerarithmetik aufgelöst (s.u.).
Eine Spezialform ist die sogenannte "Static Single-Assignment"-Form (SSA). Hierbei wird für jede
Zuweisung eine neue temporäre Variable generiert, d.h. jede im IR-Code verwendete Adresse (temporäre
Variable) hat genau eine Zuweisung. Dies wirkt sich günstig auf spezielle Optimierungen aus.
i = i+1;
if (a[i] >= v) {
i = 0;
}
t1 = i + 1
i = t1
t2 = i * 8
t3 = a + t2
if t3 >= v goto L1
goto L2
L1: i = 0
L2: ...
Im obigen Beispiel wurde davon ausgegangen, dass die Einträge im Array a 8 Bit breit sind. Das
muss der Compiler wissen, um jeweils den korrekten Offset zu benutzen.
Außerdem könnte man den Code gleich noch optimieren und die Anzahl der Sprünge reduzieren:
t1 = i + 1
i = t1
t2 = i * 8
t3 = a + t2
if t3 < v goto L
i = 0
L: ...
Es werden drei "virtuelle Register" (Variablen) %1, %2 und %3 auf dem
Stack angelegt (32-bit Integer; align 4: alle Adressen sind Vielfache von 4).
Mit store i32 0, ... wird in %1 der Wert 0 geschrieben (vergleichbar mit
*p = 0). In %2 wird analog der Wert 7 geschrieben (x=7).
Dann wird der Wert aus %2 in eine neue Variable %4 geladen und das Ergebnis
der Addition aus %4 und dem Wert 35 in eine weitere neue Variable %5
geschrieben. Der Wert dieser Variablen wird dann auf dem Stack in %3 gespeichert
(y = x+35).
Python pflegt 3 Listen: co_names für die Namen plus co_values für die dazugehörigen Werte sowie
co_consts für Konstanten. Die Listen der Namen und Werte sind gleich lang, ein Index bezieht sich
jeweils auf das selbe Symbol. Werte werden über einen Stack verarbeitet. Die Opcodes stehen in einer
weiteren Liste co_code. (Die Opcodes sind oben der besseren Lesbarkeit halber als Text ausgegeben,
LOAD_CONST hat beispielsweise den Wert 100.)
Nach dem Laden des Programms ist x in co_names[0], y in co_names[1]. Der Wert 7 steht in
co_const[0], die 35 in co_const[1].
Das LOAD_CONST 0 (co_code[0]) lädt den Inhalt von co_consts[0] auf den Stack (push()), d.h.
der Wert 7 wird auf den Stack gepusht. Mit STORE_NAME 0 (co_code[3]) wird der Inhalt des obersten
Stackeintrags in co_values[0] geschrieben und der Eintrag vom Stack entfernt (pop()). Dies entspricht
Zeile 1 im Quellcode: x = 7.
LOAD_NAME 0 pusht co_values[0] auf den Stack (Wert von x), gefolgt von der 35 per LOAD_CONST 1
(co_const[1]). Das BINARY_ADD entfernt die beiden obersten Einträge, addiert die Werte und pusht
das Ergebnis wieder auf den Stack. Mit STORE_NAME 1 wird der Wert in co_values[1] geschrieben, d.h.
y bekommt den Wert zugewiesen.
Bytecode (Beispiel Java)
publicclassHello {
voidwuppie() {
int x = 7;
int y = x + 35;
}
}
Compiled from "Hello.java"
public class Hello {
public Hello();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
void wuppie();
Code:
0: bipush 7
2: istore_1
3: iload_1
4: bipush 35
6: iadd
7: istore_2
8: return
}
Für jeden Methodenaufruf wird ein entsprechender Frame auf den Stack gepusht.
Dieser enthält ein Array mit den lokalen Variablen, durchnummeriert von 0 bis
n-1. (long und double bekommen je 2 lokale Variablen)
Zusätzlich gibt es im Frame einen Operandenstack, auf dem Funktionsparameter und
-rückgabewerte übergeben werden und auf dem die Operanden für die auszuführenden
Operationen sowie deren Zwischenergebnisse hinterlegt werden.
bipush 7: Pushe den Integer-Wert 7 auf den Stack
istore_1: Poppe den ersten Wert vom Stack und speichere ihn in der lokalen
Integer-Variable mit Index 1 (x=7)
iload_1: Pushe lokale Integer-Variable mit Index 1 auf den Stack (x)
bipush 35: Pushe den Integer-Wert 35 auf den Stack
iadd: Führe Integer-Addition aus mit den beiden obersten Werten auf Stack
und ersetze diese mit dem Ergebnis
istore_2: Poppe den ersten Wert vom Stack und speichere ihn in der lokalen
Integer-Variable mit Index 2 (y=x+35)
Die Konstanten n für iconst_ funktionieren nur für kleinere Integer. Größere Werte
muss man mit bipush auf den Stack pushen.
[Grune2012] Modern Compiler Design Grune, D. und van, Reeuwijk, K. und Bal, H. E. und Jacobs, C. J. H. und Langendoen, K., Springer, 2012. ISBN 978-1-4614-4698-9. Kapitel 7
Es ist ein neuer Prozessor entwickelt worden mit einem neuen Befehlssatz, und es
sollen für zwei Programmiersprachen Compiler entwickelt werden, die diesen Befehlssatz
als Ziel haben.
Was tun?
Thema für heute: Ein Zwischencodeformat für verschiedene Programmiersprachen und Prozessoren
Hier entsteht ein Tafelbild.
LLVM - Ein Überblick
Was ist LLVM?
ursprünglich: Low Level Virtual Machine
Open-Source-Framework
zur modularen Entwicklung von Compilern u. ä.
für Frontends für beliebige Programmiersprachen
für Backends für beliebige Befehlssatzarchitekturen
"Macht aus dem Zwischencode LLVR IR automatisch Maschinencode oder eine VM."
Kernstücke des LLVM:
ein virtueller Befehlssatz
ein virtuelle Maschine
LLVM IR: eine streng typisierte Zwischensprache
ein flexibel konfigurierbarer Optimierer
ein Codegenerator für zahlreiche Architekturen
LMIR: mit Dialekten des IR arbeiten
Was kann man damit entwickeln?
Debugger
JIT-Systeme (virtuelle Maschine)
AOT-Compiler
virtuelle Maschinen
Optimierer
Systeme zur statischen Analyse
etc.
mit entkoppelten Komponenten, die über APIs kommunizieren (Modularität)
Wie arbeitet man damit?
(mit Generatoren) ein Frontend entwickeln, das Programme über einen AST in
LLVM IR übersetzt
mit LLVM den Zwischencode optimieren
mit LLVM Maschinencode oder VM-Code generieren
Was bringt uns das?
n Sprachen für m Architekturen übersetzen:
n Frontends entwickeln
Optimierungen spezifizieren
m Codegeneratoren spezifizieren
statt n x m Compiler zu schreiben.
Wer setzt LLVM ein?
Adobe AMD Apple ARM Google
IBM Intel Mozilla Nvidia Qualcomm
Samsung ...
Externe LLVM-Projekte
Für folgende Sprachen gibt es Compiler oder Anbindungen an LLVM (neben Clang):
LLVM Core: Optimierer und Codegenerator für viele CPU- und auch GPU-Architekturen
Optimierer arbeitet unabhängig von der Zielarchitektur (nur auf der LLVM IR)
sehr gut dokumentiert
verwendete Optimierungspässe fein konfigurierbar
Optimierer auch einzeln als Tool opt aufrufbar
wird für eigene Sprachen als Optimierer und Codegenerator eingesetzt
Wozu ein Optimierer?
zur Reduzierung der Codegröße
zur Generierung von möglichst schnellem Code
Zur Generierung von Code, der möglichst wenig Energie verbraucht
Allgegenwärtig in LLVM: Der Optimierer
Der Optimierer in LLVM
Teil von LLVM Core
kann zur Compilezeit, Linkzeit und Laufzeit eingesetzt werden
nutzt auch Leerlaufzeit des Prozessors
läuft in einzelnen unabhängig konfigurierbaren Pässen über den Code
Einige Optimierungen in LLVM
Dead Code Elimination
Aggressive Dead Code Elimination
Dead Argument Elimination
Dead Type Elimination
Dead Instruction Elimination
Dead Store Elimination
Dead Global Elimination
MLIR
Framework zur Definition eigener Zwischensprachendialekte
zur high-level Darstellung spezieller Eigenschaften der zu übersetzenden Sprache
erleichtert die Umsetzung des AST in Zwischencode
z. B. für domänenspezifische Sprachen (DSLs)
z. B. für bestimmte Hardware
mehrere Abstraktionen gleichzeitig benutzbar
Der Compiler Clang
Clang: schneller C/C++/Objective-C - Compiler auf Basis von LLVM mit aussagekräftigen
Fehlermeldungen und Warnungen
Die Sanitizer in der Compiler-Runtime-Bibliothek
Sanitizer: Methoden zur Instrumentierung (Code der in das kompilierte Programm eingebettet wird) zur Erleichterung der Lokalisierung und Analyse verschiedenster Fehlerquellen, z. B.:
Speicherfehler und Speicherlecks (z. B. use-after-free)
Race Conditions
undefiniertes Verhalten (Overflows, Benutzung von Null-Pointern)
Benutzung von nicht-initialisierten Variablen
Wrap-Up
LLVM ist eine (fast) komplette Infrastruktur zur Entwicklung von Compilern
und compilerähnlichen Programmen.
Ein Interpreter erzeugt keinen Code, sondern führt Source-Code (interaktiv) aus. Die einfachste
Möglichkeit ist der Einsatz von attributierten Grammatiken, wo der Code bereits beim Parsen
ausgeführt wird ("syntaxgesteuerte Interpretation"). Mehr Möglichkeiten hat man dagegen bei der
Traversierung des AST, beispielsweise mit dem Visitor-Pattern. Auch die Abarbeitung von Bytecode
in einer Virtuellen Maschine (VM) zählt zur Interpretation.
(Register- und Stack-basierte Interpreter betrachten wir im Rahmen der Veranstaltung aktuell nicht.)
Ein AST-basierter Interpreter besteht oft aus einem "Visitor-Dispatcher": Man traversiert
mit einer eval()-Funktion den AST und ruft je nach Knotentyp die passende Funktion auf.
Dabei werden bei Ausdrücken (Expressions) Werte berechnet und zurückgegeben, d.h. hier
hat man einen Rückgabewert und ein entsprechendes return im switch/case, während man
bei Anweisungen (Statements) keinen Rückgabewert hat.
Der Wert von Literalen ergibt sich direkt durch die Übersetzung des jeweiligen Werts in den
passenden Typ der Implementierungssprache. Bei Ausdrücken interpretiert zunächst die Teilausdrücke
durch den Aufruf von eval() für die jeweiligen AST-Kindknoten und berechnet daraus das
gewünschte Ergebnis.
Für Blöcke und Variablen muss man analog zum Aufbau von Symboltabellen wieder Scopes
berücksichtigen, d.h. man benötigt Strukturen ähnlich zu den Symboltabellen (hier "Umgebung"
(Environment) genannt). Es gibt eine globale Umgebung, und mit dem Betreten eines neuen
Blocks wird eine neue Umgebung aufgemacht, deren Eltern-Umgebung die bisherige Umgebung ist.
Zu jedem Namen kann man in einer Umgebung einen Wert definieren bzw. abrufen. Dabei muss man
je nach Semantik der zu interpretierenden Sprache unterscheiden zwischen der "Definition" und
der "Zuweisung" einer Variablen: Die Definition erfolgt i.d.R. in der aktuellen Umgebung, bei
der Zuweisung sucht man ausgehend von der aktuellen Umgebung bis hoch zur globalen Umgebung
nach dem ersten Vorkommen der Variablen und setzt den Wert in der gefundenen Umgebung. Bei
Sprachen, die Variablen beim ersten Zugriff definieren, muss man dieses Verhalten entsprechend
anpassen.
(K3) Traversierung von Parse-Trees und Implementierung von Aktionen mit Hilfe des Visitor-Patterns
(K3) Interpreter müssen Namen und Werte speichern: Environment-Strukturen analog zu den Symboltabellen
(K3) Code-Ausführung im Interpreter durch eine Read-Eval-Schleife: Implementierung mit einem Visitor
Aufgaben im Interpreter
Im Allgemeinen reichen einfache syntaxgesteuerte Interpreter nicht aus. Normalerweise simuliert
ein Interpreter die Ausführung eines Programms durch den Computer. D.h. der Interpreter muss
über die entsprechenden Eigenschaften verfügen: Prozessor, Code-Speicher, Datenspeicher, Stack ...
int x =42;
intf(int x) {
int y =9;
return y+x;
}
x =f(x);
Aufbauen des AST ... => Lexer+Parser
Auflösen von Symbolen/Namen ... => Symboltabellen, Resolving
Type-Checking und -Inference ... => Semantische Analyse (auf Symboltabellen)
Speichern von Daten: Name+Wert vs. Adresse+Wert (Erinnerung: Data-Segment und Stack im virtuellen Speicher)
Ausführen von Anweisungen Text-Segment im virtuellen Speicher; hier über den AST
Aufruf von Funktionen und Methoden Kontextwechsel nötig: Was ist von wo aus sichtbar?
Nach dem Aufbau des AST durch Scanner und Parser und der semantischen Analyse
anhand der Symboltabellen müssen die Ausdrücke (expressions) und Anweisungen
(statements) durch den Interpreter ausgewertet werden. Eine Möglichkeit dazu
ist das Traversieren des AST mit dem Visitor-Pattern. Basierend auf dem Typ
des aktuell betrachteten AST-Knotens wird entschieden, wie damit umgegangen
werden soll. Dies erinnert an den Aufbau der Symboltabellen ...
Die eval()-Methode bildet das Kernstück des (AST-traversierenden) Interpreters.
Hier wird passend zum aktuellen AST-Knoten die passende Methode des Interpreters
aufgerufen.
Hinweis: Im obigen Beispiel wird nicht zwischen der Auswertung von
Ausdrücken und Anweisungen unterschieden, es wird die selbe Methode eval()
genutzt. Allerdings liefern Ausdrücke einen Wert zurück (erkennbar am return
im jeweiligen switch/case-Zweig), während Anweisungen keinen Wert liefern.
In den folgenden Beispielen wird davon ausgegangen, dass ein komplettes
Programm eingelesen, geparst, vorverarbeitet und dann interpretiert wird.
Für einen interaktiven Interpreter würde man in einer Schleife die Eingaben
lesen, parsen und vorverarbeiten und dann interpretieren. Dabei würde jeweils
der AST und die Symboltabelle ergänzt, damit die neuen Eingaben auf frühere
verarbeitete Eingaben zurückgreifen können. Durch die Form der Schleife
"Einlesen -- Verarbeiten -- Auswerten" hat sich auch der Name "Read-Eval-Loop"
bzw. "Read-Eval-Print-Loop" (REPL) eingebürgert.
Die in der Zielsprache verwendeten (primitiven) Typen müssen
auf passende Typen der Sprache, in der der Interpreter selbst
implementiert ist, abgebildet werden.
Beispielsweise könnte man den Typ nil der Zielsprache auf den
Typ null des in Java implementierten Interpreters abbilden, oder
den Typ number der Zielsprache auf den Typ Double in Java
mappen.
Das ist der einfachste Teil ... Die primitiven Typen der
Zielsprache, für die es meist ein eigenes Token gibt, müssen
als Datentyp der Interpreter-Programmiersprache ausgewertet
werden.
Die meisten möglichen Fehlerzustände sind bereits durch den Parser
und bei der semantischen Analyse abgefangen worden. Falls zur Laufzeit
die Auswertung der beiden Summanden keine Zahl ergibt, würde eine
Java-Exception geworfen, die man an geeigneter Stelle fangen und
behandeln muss. Der Interpreter soll sich ja nicht mit einem Stack-Trace
verabschieden, sondern soll eine Fehlermeldung präsentieren und danach
normal weiter machen ...
defifstat(self, AST t):
if eval(t.expr()): eval(t.s1())
else:
if t.s2(): eval(t.s2())
Analog können die anderen bekannten Kontrollstrukturen umgesetzt werden,
etwa switch/case, while oder for.
Dabei können erste Optimierungen vorgenommen werden: Beispielsweise könnten
for-Schleifen im Interpreter in while-Schleifen transformiert werden,
wodurch im Interpreter nur ein Schleifenkonstrukt implementiert werden
müsste.
Zustände: Auswerten von Anweisungen
int x =42;
float y;
{
int x;
x =1;
y =2;
{ int y = x; }
}
Das erinnert nicht nur zufällig an den Aufbau der Symboltabellen :-)
Und so lange es nur um Variablen ginge, könnte man die Symboltabellen für das
Speichern der Werte nutzen. Allerdings müssen wir noch Funktionen und Strukturen
bzw. Klassen realisieren, und spätestens dann kann man die Symboltabelle nicht
mehr zum Speichern von Werten einsetzen. Also lohnt es sich, direkt neue
Strukturen für das Halten von Variablen und Werten aufzubauen.
Detail: Felder im Interpreter
Eine mögliche Implementierung für einen Interpreter basierend auf einem
ANTLR-Visitor ist nachfolgend gezeigt.
Hinweis: Bei der Ableitung des BaseVisitor<T> muss der Typ T
festgelegt werden. Dieser fungiert als Rückgabetyp für die Visitor-Methoden.
Entsprechend können alle Methoden nur einen gemeinsamen (Ober-) Typ zurückliefern,
weshalb man sich an der Stelle oft mit Object behilft und dann manuell
den konkreten Typ abfragen und korrekt casten muss.
defvarDecl(self, AST t):
# deklarierte Variable (String) name = t.ID().getText()
value =None; # TODO: Typ der Variablen beachten (Defaultwert)if t.expr(): value = eval(t.expr())
self.env.define(name, value)
returnNone
Wenn wir bei der Traversierung des AST mit eval() bei einer Variablendeklaration
vorbeikommen, also etwa int x; oder int x = wuppie + fluppie;, dann wird im
aktuellen Environment der String "x" sowie der Wert (im zweiten Fall) eingetragen.
Ausführen einer Zuweisung
assign: ID"=" expr;
defassign(self, AST t):
lhs = t.ID().getText()
value = eval(t.expr())
self.env.assign(lhs, value) # Semantik!}
classEnvironment:
defassign(self, String n, Object v):
if self.values[n]: self.values[n] = v
elif self.enclosing: self.enclosing.assign(n, v)
else: raiseRuntimeError(n, "undefined variable")
Wenn wir bei der Traversierung des AST mit eval() bei einer Zuweisung
vorbeikommen, also etwa x = 7; oder x = wuppie + fluppie;, dann wird
zunächst im aktuellen Environment die rechte Seite der Zuweisung ausgewertet
(Aufruf von eval()). Anschließend wird der Wert für die Variable im
Environment eingetragen: Entweder sie wurde im aktuellen Environment früher
bereits definiert, dann wird der neue Wert hier eingetragen. Ansonsten wird
entlang der Verschachtelungshierarchie gesucht und entsprechend eingetragen.
Falls die Variable nicht gefunden werden kann, wird eine Exception ausgelöst.
An dieser Stelle kann man über die Methode assign in der Klasse Environment
dafür sorgen, dass nur bereits deklarierte Variablen zugewiesen werden dürfen.
Wenn man stattdessen wie etwa in Python das implizite Erzeugen neuer
Variablen erlaubten möchte, würde man statt Environment#assign einfach
Environment#define nutzen ...
Anmerkung: Der gezeigte Code funktioniert nur für normale Variablen, nicht
für Zugriffe auf Attribute einer Struct oder Klasse!
Blöcke: Umgang mit verschachtelten Environments
block: '{' stat*'}' ;
defblock(self, AST t):
prev = self.env
try:
self.env = Environment(self.env)
for s in t.stat(): eval(s)
finally: self.env = prev
returnNone;
Beim Interpretieren von Blöcken muss man einfach nur eine weitere
Verschachtelungsebene für die Environments anlegen und darin dann
die Anweisungen eines Blockes auswerten ...
Wichtig: Egal, was beim Auswerten der Anweisungen in einem Block
passiert: Es muss am Ende die ursprüngliche Umgebung wieder hergestellt
werden (finally-Block).
Wrap-Up
Interpreter simulieren die Programmausführung
Namen und Symbole auflösen
Speicherbereiche simulieren
Code ausführen: Read-Eval-Loop
Traversierung des AST: eval(AST t) als Visitor-Dispatcher
Scopes mit Environment (analog zu Symboltabellen)
Interpretation von Blöcken und Variablen (Deklaration, Zuweisung)
[Grune2012] Modern Compiler Design Grune, D. und van, Reeuwijk, K. und Bal, H. E. und Jacobs, C. J. H. und Langendoen, K., Springer, 2012. ISBN 978-1-4614-4698-9. Kapitel 6
[Nystrom2021] Crafting Interpreters Nystrom, R., Genever Benning, 2021. ISBN 978-0-9905829-3-9. Kapitel Kapitel: A Tree-Walk Interpreter, insb. 8. Statements and State
AST-basierte Interpreter: Funktionen und Klassen
TL;DR
Üblicherweise können Funktionen auf die Umgebung zurückgreifen, in der die Definition der
Funktion erfolgt ist ("Closure").
Deshalb wird beim Interpretieren einer Funktionsdefinition der jeweilige AST-Knoten (mit
dem Block des Funktionskörpers) und die aktuelle Umgebung in einer Struktur zusammengefasst.
Zusätzlich muss in der aktuellen Umgebung der Name der Funktion zusammen mit der eben erzeugten
Struktur ("Funktionsobjekt") als Wert definiert werden.
Beim Funktionsaufruf löst man den Funktionsnamen in der aktuellen Umgebung auf und erhält
das Funktionsobjekt mit dem AST der Funktion und der Closure. Die Funktionsparameter werden
ebenfalls in der aktuellen Umgebung aufgelöst (Aufruf von eval() für die AST-Kindknoten
des Funktionsaufrufs). Zur Interpretation der Funktion legt man sich eine neue Umgebung an,
deren Eltern-Umgebung die Closure der Funktion ist, definiert die Funktionsparameter (Name
und eben ermittelter Wert) in dieser neuen Umgebung und interpretiert dann den AST-Kindknoten
des Funktionsblocks in dieser neuen Umgebung. Für den Rückgabewert muss man ein wenig tricksen:
Ein Block hat normalerweise keinen Wert. Eine Möglichkeit wäre, bei der Interpretation eines
return-Statements eine Exception mit dem Wert des Ausdruck hinter dem "return" zu werfen
und im eval() des Funktionsblock zu fangen.
Für Klassen kann man analog verfahren. Methoden sind zunächst einfach Funktionen, die in einem
Klassenobjekt gesammelt werden. Das Erzeugen einer Instanz einer Klasse ist die Interpretation
eines "Aufrufs" der Klasse (analog zum Aufruf einer Funktion): Dabei wird ein spezielles
Instanzobjekt erzeugt, welches auf die Klasse verweist und welches die Werte der Attribute hält.
Beim Aufruf von Methoden auf einem Instanzobjekt wird der Name der Funktion über das Klassenobjekt
aufgelöst, eine neue Umgebung erzeugt mit der Closure der Funktion als Eltern-Umgebung und das
Instanzobjekt wird in dieser Umgebung definiert als "this" oder "self". Anschließend wird
ein neues Funktionsobjekt mit der eben erzeugten Umgebung und dem Funktions-AST erzeugt und
zurückgeliefert. Dieses neue Funktionsobjekt wird dann wie eine normale Funktion aufgerufen
(interpretiert, s.o.). Der Zugriff in der Methode auf die Attribute der Klasse erfolgt dann
über this bzw. self, welche in der Closure der Funktion nun definiert sind und auf das
Instanzobjekt mit den Attributen verweisen.
(K3) Traversierung von Parse-Trees und Implementierung von Aktionen mit Hilfe des Visitor-Patterns
(K3) Interpreter müssen Namen und Werte speichern: Environment-Strukturen analog zu den Symboltabellen
(K3) Code-Ausführung im Interpreter durch eine Read-Eval-Schleife: Implementierung mit einem Visitor
Funktionen
intfoo(int a, int b, int c) {
print a + b + c;
}
foo(1, 2, 3);
defmakeCounter():
var i =0defcount():
i = i +1 print i
return count;
counter = makeCounter()
counter() # "1"counter() # "2"
Die Funktionsdeklaration muss im aktuellen Kontext abgelegt werden,
dazu wird der AST-Teilbaum der Deklaration benötigt.
Beim Aufruf muss man das Funktionssymbol im aktuellen Kontext
suchen, die Argumente auswerten, einen neuen lokalen Kontext
anlegen und darin die Parameter definieren (mit den eben ausgewerteten
Werten) und anschließend den AST-Teilbaum des Funktionskörpers im
Interpreter mit eval() auswerten ...
Man definiert im aktuellen Environment den Funktionsnamen und hält dazu
den aktuellen Kontext (aktuelles Environment) sowie den AST-Knoten mit
der eigentlichen Funktionsdefinition fest.
Für Closures ist der aktuelle Kontext wichtig, sobald man die
Funktion ausführen muss. In [Parr2010, S.236] wird beispielsweise
einfach nur ein neuer Memory-Space (entspricht ungefähr hier einem
neuen lokalen Environment) angelegt, in dem die im Funktionskörper
definierten Symbole angelegt werden. Die Suche nach Symbolen erfolgt
dort nur im Memory-Space (Environment) der Funktion bzw. im globalen
Scope (Environment).
deffuncCall(self, AST t):
fn = (Fun)eval(t.ID())
args = [eval(a) for a in t.exprList()]
prev = self.env; self.env = Environment(fn.closure)
for i in range(args.size()):
self.env.define(fn.decl.params()[i].getText(), args[i])
eval(fn.decl.block())
self.env = prev
Zunächst wird die ID im aktuellen Kontext ausgewertet. In der obigen Grammatik
ist dies tatsächlich nur ein Funktionsname, aber man könnte über diesen Mechanismus
auch Ausdrücke erlauben und damit Funktionspointer bzw. Funktionsreferenzen
realisieren ... Im Ergebnis hat man das Funktionsobjekt mit dem zugehörigen AST-Knoten
und dem Kontext zur Deklarationszeit.
Die Argumente der Funktion werden nacheinander ebenfalls im aktuellen Kontext
ausgewertet.
Um den Funktionsblock auszuwerten, legt man einen neuen temporären Kontext über
dem Closure-Kontext der Funktion an und definiert darin die Parameter der Funktion
samt den aktuellen Werten. Dann lässt man den Interpreter über den Visitor-Dispatch
den Funktionskörper evaluieren und schaltet wieder auf den Kontext vor der
Funktionsauswertung zurück.
Rückgabewerte für den Funktionsaufruf werden innerhalb von block berechnet,
wo eine Reihe von Anweisungen interpretiert werden, weshalb block ursprünglich
keinen Rückgabewert hat. Im Prinzip könnte man block etwas zurück geben lassen,
was durch die möglicherweise tiefe Rekursion relativ umständlich werden kann.
An dieser Stelle kann man den Exceptions-Mechanismus missbrauchen und bei
der Auswertung eines return mit dem Ergebniswert direkt zum Funktionsaufruf
zurück springen. In Methoden, wo man einen neuen lokalen Kontext anlegt und
die globale env-Variable temporär damit ersetzt, muss man dann ebenfalls
mit try/catch arbeiten und im finally-Block die Umgebung zurücksetzen und
die Exception erneut werfen.
Native Funktionen
classCallable:
defcall(self, Interpreter i, List<Object> a): passclassFun(Callable): ...classNativePrint(Fun):
defcall(self, Interpreter i, List<Object> a):
for o in a: print a # nur zur Demo, hier sinnvoller Code :-)# Im Interpreter (Initialisierung):self.env.define("print", NativePrint())
deffuncCall(self, AST t):
...# prev = self.env; self.env = Environment(fn.closure)# for i in range(args.size()): ...# eval(fn.decl.block()); self.env = prev fn.call(self, args)
...
Normalerweise wird beim Interpretieren eines Funktionsaufrufs der
Funktionskörper (repräsentiert durch den entsprechenden AST-Teilbaum)
durch einen rekursiven Aufruf von eval ausgewertet.
Für native Funktionen, die im Interpreter eingebettet sind, klappt
das nicht mehr, da hier kein AST vorliegt.
Man erstellt ein neues Interface Callable mit der Hauptmethode call()
und leitet die frühere Klasse Fun davon ab: class Fun(Callable).
Die Methode funcCall() des Interpreters ruft nun statt der eval()-Methode
die call()-Methode des Funktionsobjekts auf und übergibt den Interpreter
(== Zustand) und die Argumente. Die call()-Methode der Klasse Fun muss
nun ihrerseits im Normalfall den im Funktionsobjekt referenzierten AST-Teilbaum
des Funktionskörpers mit dem Aufruf von eval() interpretieren ...
Für die nativen Funktionen leitet man einfach eine (anonyme) Klasse
ab und speichert sie unter dem gewünschten Namen im globalen Kontext
des Interpreters. Die call()-Methode wird dann entsprechend der
gewünschten Funktion implementiert, d.h. hier erfolgt kein weiteres
Auswerten des AST.
Klassen und Instanzen I
classDef : "class"ID"{" funcDecl*"}" ;
defclassDef(self, AST t):
methods = HashMap<String, Fun>()
for m in t.funcDecl():
fn = Fun(m, self.env)
methods[m.ID().getText()] = fn
clazz = Clazz(methods)
self.env.define(t.ID().getText(), clazz)
Anmerkung: In dieser Darstellung wird der Einfachheit halber nur auf Methoden eingegangen.
Für Attribute müssten ähnliche Konstrukte implementiert werden.
Instanzen einer Klasse werden durch den funktionsartigen "Aufruf" der Klassen
angelegt (parameterloser Konstruktor). Eine Instanz hält die Attribute (hier
nicht gezeigt) und eine Referenz auf die Klasse, um später an die Methoden
heranzukommen.
Zugriff auf Methoden (und Attribute)
getExpr : obj "."ID ;
defgetExpr(self, AST t):
obj = eval(t.obj())
if isinstance(obj, Instance):
return ((Instance)obj).get(t.ID().getText())
raiseRuntimeError(t.obj().getText(), "no object")
Beim Zugriff auf Attribute muss das Objekt im aktuellen Kontext evaluiert
werden. Falls es eine Instanz von Instance ist, wird auf das Feld per
interner Hash-Map zugriffen; sonst Exception.
Nach dem Interpretieren von Klassendefinitionen sind die Methoden in der Klasse
selbst gespeichert, wobei der jeweilige closure auf den Klassenkontext zeigt.
Beim Auflösen eines Methodenaufrufs wird die gefundene Methode an die
Instanz gebunden, d.h. es wird eine neue Funktion angelegt, deren closure
auf den Kontext der Instanz zeigt. Zusätzlich wird in diesem Kontext noch die
Variable "this" definiert, damit man damit auf die Instanz zugreifen kann.
In Python wird das in der Methodensignatur sichtbar: Der erste Parameter ist
eine Referenz auf die Instanz, auf der diese Methode ausgeführt werden soll ...
Wrap-Up
Interpreter simulieren die Programmausführung
Namen und Symbole auflösen
Speicherbereiche simulieren
Code ausführen: Read-Eval-Loop
Traversierung des AST: eval(AST t) als Visitor-Dispatcher
Scopes mit Environment (analog zu Symboltabellen)
Interpretation von Funktionen (Deklaration/Aufruf, native Funktionen)
Interpretation von Klassen und Instanzen
Challenges
Wie interpretiert man Code?
Warum kann man die Werte nicht einfach in Symboltabellen ablegen?
Wie geht man mit Funktionen um? Warum? Kann man diese mehrfach aufrufen?
Wieso erzeugt man eine neue Environment mit der Closure in der Funktion?
Wie gehen native Funktionen?
Betrachten Sie folgenden Code-Ausschnitt:
int x =42;
intf(int x) {
int y =9;
return y+x;
}
x =f(x);
[Grune2012] Modern Compiler Design Grune, D. und van, Reeuwijk, K. und Bal, H. E. und Jacobs, C. J. H. und Langendoen, K., Springer, 2012. ISBN 978-1-4614-4698-9. Kapitel 6
Unterschiedliche Programmiersprachen weisen nicht nur verschiedene Syntaxelemente auf,
sondern haben eine teilweise stark unterschiedliche Semantik. Beides hat Auswirkungen
auf die Bausteine eines Compilers.
Für C wurde ein paar Jahre nach der Entstehung ein objektorientierter Aufsatz entwickelt: C++.
Beide Sprachversionen werden aktiv weiterentwickelt, vor allem in C++ gibt es ca. alle 3 Jahre
einen neuen Standard mit teilweise recht umfangreichen Ergänzungen. Hier fließen analog zu Java
immer mehr Programmierkonzepte mit ein, die aus anderen Sprachen stammen (etwa funktionale
Programmierung). Das macht das Erlernen und Beherrschen der Sprache nicht unbedingt leichter.
Die für uns wichtigsten Neuerungen kamen mit C11 und C++11 bzw. C++14.
C und C++ versuchen (im Gegensatz zu Java) ressourcenschonende Sprachen zu sein: Ein korrektes
Programm soll so schnell wie möglich ausgeführt werden können und dabei so effizient wie möglich
sein (etwa in Bezug auf den Speicherbedarf). Deshalb gibt es keine Laufzeitumgebung, der Quellcode
wird direkt in ein ausführbares (und damit Betriebssystem-abhängiges) Binary compiliert. Beide
Sprachen erlauben dem Programmierer den Zugriff auf die Speicherverwaltung und damit viele Freiheiten.
Die Kehrseite ist natürlich, dass Programmierfehler (etwa bei der Speicherallokation oder bei
Indexberechnungen) nicht von der Laufzeitumgebung entdeckt und abgefangen werden können.
C-Programme sehen auf den ersten Blick Java-Code relativ ähnlich. Das ist nicht verwunderlich,
da Java zeitlich nach C/C++ entwickelt wurde und die Syntax und große Teile der Schlüsselwörter
von C und C++ übernommen hat. C++ hat die C-Syntax übernommen und fügt neue objektorientierte
Konzepte hinzu. Mit gewissen Einschränkungen funktioniert also C-Code auch in C++.
In C++ gibt es Klassen (mit Methoden und Attributen), und zusätzlich gibt es Funktionen. Der
Einsprungpunkt in ein Programm ist (analog zu Java) die Funktion main(), die ein int als
Ergebnis zurückliefert. Dieser Integer kann vom Aufrufer ausgewertet werden, wobei der Wert 0
typischerweise als Erfolg interpretiert wird. Achtung: Das ist eine Konvention, d.h. es kann
Programme geben, die andere Werte zurückliefern. Die Werte müssen dokumentiert werden.
Bevor der Compiler den Quelltext "sieht", wird dieser von einem Präprozessor bearbeitet. Dieser
hat verschiedene Aufgaben, unter anderem das Einbinden anderer Dateien. Dabei wird ein
#include "dateiname" (sucht im aktuellen Ordner) bzw. #include <dateiname> (sucht im
Standardverzeichnis) ersetzt durch den Inhalt der angegebenen Datei.
C++-Code muss kompiliert werden. Dabei entsteht ein ausführbares Programm. Mit Make kann man den
Kompiliervorgang über Regeln automatisieren (denken Sie an ANT in der Java-Welt, nur ohne XML).
Eine Regel besteht aus einem Ziel (Target), einer Liste von Abhängigkeiten sowie einer Liste mit
Aktionen (Anweisungen). Um ein Ziel zu "bauen" müssen zunächst alle Abhängigkeiten erfüllt sein
(bzw. falls sie es nicht sind, erst noch "gebaut" werden - es muss entsprechend weitere Regeln
geben, um diese Abhängigkeiten "bauen" zu können). Dann wird die Liste der Aktionen abgearbeitet.
Ziele und Abhängigkeiten sind in der Regel Namen von Dateien, die existieren müssen bzw. über die
Aktionen erzeugt werden sollen. Die Aktionen sind normale Befehlssequenzen, die man auch in einer
Konsole eingeben könnte. Make berücksichtigt den Zeitstempel der Dateien: Ziele, die bereits
existieren und deren Abhängigkeiten nicht neuer sind, werden nicht erneut gebaut.
Die gute Nachricht: In Bezug auf Variablen, Operatoren und Kontrollfluss verhalten sich C und C++
im Wesentlichen wie Java.
Es gibt in C++ den Typ bool mit den Werten true und false. Zusätzlich werden Integerwerte
im boolschen Kontext (etwa in einer if-Abfrage) ausgewertet, wobei der Wert 0 einem false
entspricht und alle anderen Integer-Werte einem true. (Dies steht etwas im Widerspruch zu den
Werten, die in der main-Funktion per return zurückgeliefert werden: Hier bedeutet 0 in der
Regel, dass alles OK war.)
Die Basisdatentypen sind (bis auf char und bool) in ihrer Größe maschinenabhängig. Es kann
also sein, dass Code, der auf einem 64bit-Laptop ohne Probleme läuft, auf einem Raspberry PI
Überläufe verursacht! Um besonders ressourcenschonend zu arbeiten, kann man die Speichergröße
für einige Basisdatentypen durch die Typmodifikatoren short und long beeinflussen sowie
die Interpretation von Zahlenwerten mit oder ohne Vorzeichen (signed, unsigned) einstellen.
Die Anzahl der für einen Typ oder eine Variable/Struktur benötigten Bytes bekommt man mit
dem Operator sizeof heraus.
Mit typedef kann man einen neuen Namen für bereits existierende Typen vergeben.
In C++ gibt es Funktionen (analog zu Methoden in Java), diese existieren unabhängig von Klassen.
Wenn eine Funktion aufgerufen wird, muss dem Compiler die Signatur zur Prüfung bekannt sein. Das
bedeutet, dass die Funktion entweder zuvor komplett definiert werden muss oder zumindest zuvor
deklariert werden muss (die Definition kann auch später in der Datei kommen oder in einer anderen
Datei). Das Vorab-Deklarieren einer Funktion nennt man auch "Funktionsprototypen".
Eine Deklaration darf (so lange sie konsistent ist) mehrfach vorkommen, eine Definition immer nur
exakt einmal. Dabei werden alle Code-Teile, die zu einem Programm zusammencompiliert werden,
gemeinsam betrachtet. => Das ist auch als One-Definition-Rule bekannt.
In C++ gilt beim Funktionsaufruf immer zunächst immer die Parameterübergabe per call-by-value
(dito bei der Rückgabe von Werten). Wenn Referenzen oder Pointer eingesetzt werden, wird dagegen
auch ein call-by-reference möglich. (Dazu später mehr.)
Unterscheidung in globale, lokale und lokale statische Variablen mit unterschiedlicher Lebensdauer
und unterschiedlicher Initialisierung durch den Compiler.
(K1) Wichtigste Unterschiede und Gemeinsamkeiten zu Java
(K1) Wichtigste Aufgaben des Präprozessors
(K3) Aufbau, Übersetzen und Starten von einfachen C++-Programmen
(K3) Standard-Ein-/Ausgabe-Kanäle in C++ sowie die Operatoren >> und <<
(K3) Nutzung der Basisdatentypen einschließlich der Modifikatoren
(K3) Deklaration von Variablen, Nutzung von Kontrollstrukturen und Operatoren
(K3) Interpretation von Integers im booleschen Kontext
(K3) Nutzung des Scope-Operators ::, Namensräume
(K3) Benutzung von sizeof zur Bestimmung des Speicherbedarfs
(K3) Benutzung von typedef zur Definition neuer Typen (Aliase bestehender Typen)
(K3) Erinnerung: Automatisiertes Übersetzen mit Hilfe von GNU Make und einfachsten Makefiles
(K2) Unterschied zwischen Deklaration und Definition, One Definition Rule
(K2) Problematik bei der Deklaration parameterloser Funktionen
(K2) Call-by-Value-Semantik bei der Parameterübergabe
(K2) Sichtbarkeit und Initialisierung von Variablen
(K3) Definition und Deklaration von Funktionen
(K3) Nutzung lokaler und globaler und lokaler statischer Variablen
Warum?
C++ erlaubt ressourcenschonende Programmierung
Objektorientierter "Aufsatz" auf C
Verbreitet bei hardwarenaher und/oder rechenintensiver Software
Sie werden C++ im Modul "Computergrafik" brauchen!
Geschichte
1971-73: Ritchie entwickelt die Sprache C
Ab 1979: Entwicklung von C++ durch Bjarne Stroustrup bei AT&T
Erweiterung der prozeduralen Sprache C
Ursprünglich "C mit Klassen", später "C++" (Inkrement-Operator)
Bis heute: Fortlaufende Erweiterungen: alle 3 Jahre neuer Standard (C++11, C++14, ...)
C/C++ vs. Java
Java: Fokus auf Sicherheit und Robustheit
Diverse Sicherheitschecks durch Compiler und VM (zb. Array-Zugriff)
Speicherverwaltung (Garbage Collection), kein Speicherzugriff über Pointer
Automatische Initialisierung von Variablen
C/C++: Fokus auf Effizienz (Speicher, Laufzeit) für korrekte Programme
Vergleichsweise schwache Sicherheitschecks durch Compiler, keine VM
(d.h. keine Prüfung von Array-Indizes u.a.)
Keine Garbage Collection, Programmierer hat direkten Zugriff auf Speicher
Keine automatische Initialisierung von Variablen
Hello World!
/*
* HelloWorld.cpp (g++ -Wall HelloWorld.cpp)
*/#include<cstdio>#include<iostream>#include<cstdlib>usingnamespace std;
intmain() {
printf("Hello World from C++ :-)\n");
cout <<"Hello World from C++ :-)"<< endl;
std::cout <<"Hello World from C++ :-)"<< std::endl;
return EXIT_SUCCESS;
}
Beobachtungen
Jedes (ausführbare) C++-Programm hat genau eine main()-Funktion. Die main()-Funktion ist
keine Methode einer Klasse: In C/C++ gibt es Funktionen auch außerhalb von Klassen.
In C++ gibt es Namespaces (dazu später mehr). Die aus der Standardbibliothek importierten
Funktionen sind in der Regel im Namespace std definiert. Mit using namespace std; können
Sie auf die Elemente direkt zugreifen. Wenn Sie das using namespace std; weglassen, müssten
Sie bei jeder Verwendung eines Symbols den Namensraum explizit dazu schreiben
std::cout << "Hello World from C++ :-)" << std::endl;.
Sie können im C++-Code auch Funktionen aus C benutzen, d.h. Sie können für die Ausgabe
beispielsweise printf nutzen (dazu müssen Sie den Header <cstdio> importieren). Die
"richtige" Ausgabe in C++ ist aber die Nutzung des Ausgabestreams cout und des
Ausgabeoperators <<. Das endl sorgt für einen zum jeweiligen Betriebssystem passenden
Zeilenumbruch.
Der Rückgabewert signalisiert Erfolg bzw. Fehler der Programmausführung. Dabei steht der Wert
0 traditionell für Erfolg (Konvention!). Besser Makros nutzen: EXIT_SUCCESS bzw.
EXIT_FAILURE (in cstdlib).
Präprozessor
Der Präprozessor transformiert den Quellcode vor dem Compiler-Lauf. Zu den wichtigsten
Aufgaben gehören dabei die Makrosubstitution (#define Makroname Ersatztext) und das Einfügen
von Header-Dateien (und anderen Dateien) per #include. Es gibt dabei zwei Formen, die an
unterschiedlichen Orten nach der angegebenen Datei suchen:
#include "dateiname" sucht im aktuellen Ordner
#include <dateiname> sucht im Standardverzeichnis
Das #include kann wie in C genutzt werden, aber es gibt auch die Form ohne die Dateiendung
".h". Da es in C keine Funktionsüberladung gibt (in C++ dagegen schon), müssen die C-Header
speziell markiert sein, um sie in C++ verwenden zu können. Für die Standard-Header ist dies
bereits erledigt, Sie finden diese mit einem "c" vorangestellt:
Include in C: #include <stdio.h>
Include in C++: #include <cstdio>
Übersetzen, Linken, Ausführen
C++-Dateien werden üblicherweise mit der Endung ".cpp" oder ".cxx" oder ".cc"
abgespeichert, Header-Dateien mit den Endungen ".hpp" oder ".hxx" oder ".hh".
Zum Übersetzen und Linken in einem Arbeitsschritt rufen Sie den Compiler auf:
g++ HelloWorld.cpp bzw. besser g++ -Wall -o helloworld HelloWorld.cpp. Die Option
-Wall sorgt dafür, dass alle Warnungen aktiviert werden.
Ausführen können Sie das erzeugte Programm in der Konsole mit: ./helloworld. Der aktuelle
Ordner ist üblicherweise (aus Sicherheitsgründen) nicht im Suchpfad für ausführbare Dateien
enthalten. Deshalb muss man explizit angeben, dass ein Programm im aktuellen Ordner (.)
ausgeführt werden soll.
Im Wesentlichen wie von C und Java gewohnt ... :-)
Wichtig(st)e Abweichung:
Im booleschen Kontext wird int als Wahrheitswert interpretiert:
Alle Werte ungleich 0 entsprechen true (!)
Anmerkung: Dies steht im Widerspruch zu den Werten, die in der main-Funktion per
return zurückgeliefert werden: Hier bedeutet 0 in der Regel, dass alles OK war.
=> Vorsicht mit
int c;
if (c=4) { ... }
Ein- und Ausgabe mit printf und cin/cout
printf(formatstring, ...)
string foo ="fluppie";
printf("hello world : %s\n", foo.c_str());
Einbinden über #include <cstdio>
Format-String: Text und Formatierung der restlichen Parameter: %[flags][width][.precision]conversion
Genauer: cout ist ein Ausgabestrom, auf dem der Operator << schreibt
Einbinden über #include <iostream>
Implementierung der Ein- und Ausgabeoperatoren (>>, <<) für Basistypen und Standardklassen vorhanden
Automatische Konvertierungen für Basistypen und Standardklassen
// Ausgabe, auch verkettet
string foo ="fluppie";
cout <<"hello world : "<< foo << endl;
// liest alle Ziffern bis zum ersten Nicht-Ziffernzeichen
// (fuehrende Whitespaces werden ignoriert!)
int zahl; cin >> zahl;
Vorsicht!vector<int> arr(); ist kein Vektor der Länge 0,
sondern deklariert eine neue Funktion!
Alias-Namen für Typen mit typedef und using
Syntax: typedef existTyp neuerName; (C, C++)
typedefunsignedlong uint32;
uint32 x, y, z;
Im Beispiel ist uint32 ein neuer Name für den existierenden Typ unsigned long, d.h.
die Variablen x, y und z sind unsigned long.
Syntax: using neuerName = existTyp; (C++)
typedefunsignedlong uint32; // C, C++
using uint32 =unsignedlong; // C++11
typedef std::vector<int> foo; // C, C++
using foo = std::vector<int>; // C++11
typedefvoid (*fp)(int,double); // C, C++
using fp =void (*)(int,double); // C++11
Seit C++11 gibt es das Schlüsselwort using für Alias-Deklarationen (analog zu typedef).
Dieses funktioniert im Gegensatz zu typedef auch für Templates mit (teilweise) gebundenen
Template-Parametern.
Erinnerungen an C - Vergleich mit C++
Erinnerungen an C - Vergleich mit C++
Basisdatentypen
char
Zeichen (ASCII, 8 Bit bzw. 1 Byte)
int
Ganze Zahl (16, 32 oder 64 Bit)
float
Gleitkommazahl (typ. 32 Bit)
double
Doppelt genaue Gleitkommazahl (typ. 64 Bit)
void
Ohne/kein Wert
bool
true, false
Außerdem sind Arrays und Pointer mit diesen Typen möglich.
Typmodifikatoren ändern Bedeutung
Vorangestellte Modifikatoren ändern Bedeutung:
Länge im Speicher
short
Speicher: halbe Wortlänge
long
Speicher: doppelte/dreifache Wortlänge
Vorzeichen
signed
mit Vorzeichen (Default bei Zahlen)
unsigned
ohne Vorzeichen
Anwendung auf ganze Zahlen:
short und long sind Synonyme für short int und long int
long long ist typischerweise eine ganze Zahl mit 8 Byte
unsigned char sind Zahlen von 0, ..., 255 (1 Byte)
zusätzlich: long double (nur diese Form)
Sie können short, long und long long nur für ganze Zahlen (int) nutzen, mit der Ausnahme long double.
Dagegen können signed und unsigned sowohl für char als auch für int benutzt werden.
Hinweis Arrays: sizeof gibt immer die Anzahl der Bytes für einen Typ oder eine
Variable zurück. Bei Array ist das nicht unbedingt die Anzahl der Elemente im Array!
Beispiel:
char a[10];
double b[10];
sizeof(a) würde den Wert 10 als Ergebnis liefern, da ein char in C/C++ immer exakt ein
Byte benötigt und entsprechend 10 char 10 Byte. sizeof(b) ist maschinenabhängig und
liefert die Anzahl der Bytes, die man für die Darstellung von 10 Double-Werten benötigt.
Wenn man die Anzahl der Elemente im Array mit sizeof herausfinden will, muss man den
Gesamtwert für das Array noch durch den Speicherbedarf eines Elements teilen, also
beispielsweise sizeof(b)/sizeof(b[0]).
(Beispiele für) Schleifen und Kontrollstrukturen in C/C++
int x=5, y=1;
if (x>5) {
x++;
} elseif(y<=1) {
y = y-x;
} else {
y =2*x;
}
while (y>0) {
y--;
}
for (x=0; x<10; x++) {
y = y*y;
}
Aufruf: Nennung des Namens (mit Argumenten) im Programmcode
int x = foo(42);
Anmerkung: Unterschied "Parameter" und "Argument":
Funktion hat "Parameter" in ihrer Parameterliste, auch "formale Parameter" genannt
Beim Aufruf werden "Argumente" übergeben, auch "aktuelle Parameter" genannt
In der Praxis verwendet man beide Begriffe i.d.R. synonym.
Funktionen: Deklaration vs. Definition
Deklaration: (Funktions-) Prototyp: Festlegen von
Signatur (d.h. Funktionsname und Anzahl, Typ, Reihenfolge der Parameter) u. Rückgabetyp
voidmachWas(int, int);
Definition: Implementierung der Funktion
voidmachWas(int a, int b) {
cout <<"a: "<< a <<", b: "<< b << endl;
}
Compiler "liest" Quellcode von oben nach unten
Funktionen müssen (wie alle anderen Symbole auch) vor ihrer Verwendung zumindest
deklariert sein, d.h. es muss zumindest ihre Signatur bekannt sein (siehe nächste Folie)
Deklaration: Variablennamen können weggelassen werden
Deklaration vs. Definition
Deklaration: Macht einen Namen bekannt und legt den Typ der Variablen bzw.
die Schnittstelle der Funktionen fest.
Definition: Deklaration plus Reservierung von Speicherplatz für die
Variable oder Implementierung einer Funktion/Struktur/...
Jede Funktion darf im gesamten Programm nur einmal definiert sein!
Funktionen und Parameter
Funktionen "ohne" Parameter:
Leere Parameter-Liste[^1] oder Schlüsselwort void
voidfkt();
voidfkt(void);
Funktionen mit Parameter:
Deklaration: Variablennamen können weggelassen werden
Definition: Variablennamen müssen angegeben werden
voidfkt(int, char);
voidfkt(int a, char b);
voidfkt(int a, char b) { ... }
Leere Parameterliste in C
Wenn eine Funktion keine Parameter hat, können Sie wie in C die Parameterliste
entweder einfach leer lassen (int fkt();) oder das Schlüsselwort void
nutzen (int fkt(void);).
Betrachten Sie folgendes Beispiel:
// Legal in C
intwuppie(); // Deklaration: "Ich verrate Dir nicht, wieviele Parameter wuppie() hat."
intwuppie(int x) { return x; } // Aufruf mit Argumenten => ist okay
// Fehler in C
intfluppie(void); // Deklaration: fluppie() hat KEINE Parameter!
intfluppie(int x) { return x; } // Aufruf mit Argumenten => Compiler-Fehler
Wenn Sie eine mit leerer Parameterliste deklarierte Funktion definieren bzw.
aufrufen, akzeptiert der C-Compiler dennoch alle übergebenen Parameter. Dies
kann zu schwer verständlichen Fehlern führen! Sobald eine Funktion explizit
mit dem Schlüsselwort void in der Parameterliste deklariert wird, muss
diese dann auch ohne Parameter aufgerufen werden.
=> Bevorzugen Sie in C die Variante mit dem Schlüsselwort void!
Leere Parameterliste in C++
Keine Parameter: Leere Liste und Schlüsselwort voidgleichwertig
voidfkt();
voidfkt(void);
Defaultparameter in C++
Parameter mit Defaultwerten am Ende der Parameterliste
Bei Trennung von Deklaration und Definition: Defaultparameter
nur in Deklaration
// Deklaration
voidf(int i, int j=1, int k=2);
// Definition
voidf(int i, int j, int k) { ... }
Überladen von Funktionen
Funktionen im gleichen Gültigkeitsbereich können überladen werden
Zu beachten:
Funktionsname identisch
Signatur (Anzahl, Typen der Parameter) muss unterschiedlich sein
Globale Variablen gelten in allen Teilen des Programms
Auch in anderen Dateien! => müssen bei Nutzung in Funktionen als extern deklariert werden
Existieren die gesamte Programmlebensdauer über
=> Werden auch als externe Variablen bezeichnet
Die Dateien sind einzeln kompilierbar (extern sagt dem Compiler, dass
die Variable woanders definiert ist) => erst der Linker löst das auf.
Hinweis: Bei globalen Konstanten in C++ brauchen Sie zusätzlich auch bei der Definition ein "extern",
da die Konstante sonst nur in ihrer Datei sichtbar ist.
Beschränkung der Gültigkeit von globalen Variablen auf die Datei, wo
sie definiert sind: Schlüsselwort static
werden (weiterhin) automatisch mit 0 initialisiert
sind nun nur in der Datei sichtbar/gültig, wo sie definiert sind
dient zur Vermeidung von Namenskonflikten bei globalen Variablen
Sichtbarkeitsbeschränkung gilt auch für Funktionen
static für globale Variablen beschränkt deren Sichtbarkeit auf die Datei,
wo sie definiert sind. D.h. man kann diese dann nicht in einer anderen Datei
nutzen, nicht mal mit extern ...
static für Funktionen beschränkt deren Sichtbarkeit ebenfalls auf die Datei,
wo sie definiert sind. Man kann sie dann nur in anderen Funktionen, die
ebenfalls in der selben Datei definiert werden, nutzen. In anderen Dateien sind
die static Funktionen nicht sichtbar. D.h. es macht auch keinen Sinn, sie
in einer Header-Datei zu deklarieren! (In der Praxis liefert der gcc dann sogar
einen Fehler!). Das ist mit private Methoden vergleichbar.
Globale Konstanten
In C funktionieren globale Konstanten wie globale Variablen
Definition in einer Übersetzungseinheit ohne "extern"
=> Definition als "extern" wird in C mit einer Warnung quittiert!
Nutzung in anderen Übersetzungseinheiten durch (erneute)
Deklaration als "extern"
Beispiel:
/* ======== Datei main.c ======== */constint PI=123; // Definition OHNE "extern" (C)
intmain() {
fkt_a1();
int x = PI;
...
}
/* ======== Datei a.c ======== */externconstint PI; // (erneute) Deklaration mit "extern"
voidfkt_a1() {
int x = PI;
...
}
In C++ sind globale Konstanten per Default nur in ihrer Definitionsdatei sichtbar!
Abhilfe: Definieren und Deklarieren mit extern
Beispiel:
/* ======== Datei main.cpp ======== */externconstint PI=123; // Definition MIT "extern" (C++)
intmain() {
fkt_a1();
int x = PI;
...
}
/* ======== Datei a.cpp ======== */externconstint PI; // (erneute) Deklaration mit "extern"
voidfkt_a1() {
int x = PI;
...
}
Alternativ: In beiden Sprachen Konstanten vorwärts deklarieren
Folgende Definition und (Vorwärts-) Deklaration der Konstanten PI
funktioniert sowohl in C als auch in C++:
/* ======== Datei main.c ======== */externconstint PI; // (Vorwärts-) Deklaration mit "extern"
constint PI=123; // Definition OHNE "extern"
intmain() {
fkt_a1();
int x = PI;
...
}
/* ======== Datei a.c ======== */externconstint PI; // (erneute) Deklaration mit "extern"
voidfkt_a1() {
int x = PI;
...
}
Automatisieren der Buildvorgänge: GNU Make
Makefile: Textdatei mit Regeln für das Programm make
Achtung: Verschiedene Make-Dialekte! Wir nutzen GNU Make!
# Kommentar Ziel1: AbhaengigkeitenListe1
Aktionen1
Ziel2: AbhaengigkeitenListe2
Aktionen2
# ... und so weiter :-)# ACHTUNG:# Vor den Aktionen <TAB> benutzen, keine Leerzeichen!!!# Vorsicht mit Editor-Einstellungen!
Bedeutung: Um das Ziel Ziel1 zu erzeugen, müssen alle Abhängigkeiten der Liste
AbhaengigkeitenListe1 erfüllt sein. Dann werden die Aktionen in Aktionen1 durchgeführt,
um Ziel1 zu erzeugen. Aber nur, falls das Ziel Ziel1 nicht existiert oder veraltet ist!
Falls die Abhängigkeiten nicht erfüllt sind, wird nach Regeln gesucht, um diese zu erzeugen.
Das bedeutet, dass u.U. zunächst weitere Targets "gebaut" werden, bevor die Aktionenliste
ausgeführt wird.
Die Ziele und Abhängigkeiten sind i.d.R. Dateien (müssen es aber nicht sein).
Makefiles: Fiktives Beispiel
Annahme: Projekt besteht aus der Datei main.cpp, daraus soll das Programm
"tollesProgramm" erzeugt werden
Bedeutung: Um das Ziel all zu erzeugen, muss die Abhängigkeit tollesProgramm erfüllt sein.
Beachten Sie, dass im Beispiel all kein Dateiname ist, tollesProgramm dagegen schon.
Um tollesProgramm zu erzeugen, muss die Datei main.o vorhanden sein. Falls sie es nicht
ist, wird sie mit Hilfe des dritten Targets erzeugt. Das % ist dabei ein Patternmatcher,
d.h. wenn nach einem main.o gesucht ist, matcht %.o (das % bindet sich dabei an "main")
und auf der rechten Seite des Targets steht als Abhängigkeit main.cpp.
Die Variablen CXX, CXXFLAGS, LDFLAGS und LDLIBS sind vordefinierte Variablen:
CXX: C++-Compiler, Default: g++
CXXFLAGS Extra Flags für den C++-Compiler (nur für Kompilieren)
LDFLAGS: Extra Flags, die für das Linken genutzt werden (Beispiel: -L.; nicht-lm)
LDLIBS: Bibliotheken, die für das Linken genutzt werden (Beispiel: -lm -lfoo; nicht-L.)
Die Variablen $<, $^ und $@ lösen auf das Ziel bzw. die Abhängigkeiten eines Targets auf:
$< => gibt die erste Abhängigkeit an
$^ => gibt alle Abhängigkeiten an
$@ => gibt das Ziel an
Falls die Datei tollesProgramm nicht existiert oder aber älter ist als main.o, wird die
Regel des Targets tollesProgramm ausgeführt, um die Datei tollesProgramm zu erzeugen:
g++ main.o -o tollesProgramm.
Hinweis: Das Beispiel entspricht den minimalen Kenntnissen, die Sie über Make haben müssen.
Makefiles: Typische Aufrufe
make
Sucht nach Datei mit dem Namen "GNUmakefile", "makefile" oder "Makefile" und erzeugt das
erste Ziel in der Datei
Konvention: Das erste Ziel hat den Namen all
make -f <datei>
Sucht die Datei mit dem angegebenen Namen, erzeugt das erste Ziel in der Datei
make -f <datei> <ziel>
Sucht die Datei mit dem angegebenen Namen, erzeugt das Ziel <ziel>
make <ziel>
Sucht nach Datei mit dem Namen "GNUmakefile", "makefile" oder "Makefile" und erzeugt das
Ziel <ziel>
Wrap-Up
C/C++ sind enge Verwandte: kompilierte Sprachen, C++ fügt OO hinzu
Funktionsweise einfachster Make-Files
Wichtigste Unterschiede zu Java
Kontrollfluss wie in Java
Basisdatentypen vorhanden
Typ-Modifikatoren zur Steuerung des Speicherbedarfs/Wertebereich
Integer können im booleschen Kontext ausgewertet werden
Operator sizeof zur Bestimmung des Speicherbedarfs
Alias-Namen für existierende Typen mit typedef definierbar
Funktionen mit Default-Parametern und Überladung
Challenges
Wie groß ist der Bereich der Basisdatentypen (Speicherbedarf, Zahlenbereich)?
Wie können Sie das feststellen?
Erklären Sie den Unterschied sizeof(x) vs. sizeof(x)/sizeof(x[0])!
Warum ist der folgende Code-Schnipsel gefährlich?
if (i=3)
printf("Vorsicht");
elseprintf("Vorsicht (auch hier)");
Limits kennen: Datentypen, Wertebereiche
Schreiben Sie ein C-Programm, welches die größtmögliche unsigned int Zahl
auf Ihrem System berechnet.
Verwenden Sie hierzu nicht die Kenntnis der systemintern verwendeten Bytes
(sizeof, ...). Nutzen Sie auch nicht die Konstanten/Makros/Funktionen aus
limits.h oder float.h oder anderen Headerdateien!
Erklären Sie die Probleme bei folgendem Code-Schnipsel:
Es gibt viele Arten Speicher, die sich vor allem in der Größe und Geschwindigkeit unterscheiden
(Cache, RAM, SSD, Festplatte, ...). Der Kernel stellt jedem Prozess einen linearen Adressraum
bereit und abstrahiert dabei von den darunter liegenden physikalischen Speichermedien (es gibt
eine Abbildung auf die jeweiligen Speichermedien durch die MMU, dies ist aber nicht Bestandteil
dieses Kurses).
Den virtuellen Speicher kann man grob in drei Segmente aufteilen: Text (hier befindet sich der
Programmcode des Prozesses), Stack (automatische Verwaltung, für Funktionsaufrufe und lokale
Variablen) und Heap (Verwaltung durch den Programmierer, dynamische Bereitstellung von Speicher
während der Laufzeit des Programms).
Pointer sind Variablen, deren Wert als Adresse (im virtuellen Speicher) interpretiert wird.
Pointer können auf andere Objekte bzw. Variablen zeigen: Der Adressoperator "&" liefert die
Adresse eines Objekts im virtuellen Speicher, diese kann einem Pointer zugewiesen werden (der
Wert des Pointers ist dann die zugewiesene Adresse). Pointer können mit "*" dereferenziert
werden, d.h. es wird an der Speicherstelle im virtuellen Speicher nachgeschaut, deren Adresse
im Pointer gespeichert ist. Dadurch erfolgt der Zugriff auf das verwiesene Objekt. (Dies hat
noch nichts mit dynamischer Speicherverwaltung zu tun!) Die Deklaration eines Pointers erfolgt
mit einem * zwischen Typ und Pointername: int *p;. Da Pointer normale Variablen sind, unterliegen
Pointer-Variablen den üblichen Gültigkeitsbedingungen (Scopes).
In C++ gibt es zusätzlich Referenzen. Diese stellen Alias-Namen für ein Objekt (oder eine Variable)
dar, d.h. ein Zugriff auf eine Referenz bewirkt den direkten Zugriff auf das verbundene Objekt.
Referenzen müssen bei der Deklaration initialisiert werden (Typ &ref = obj;) und sind dann
fest mit diesem Objekt verbunden.
In C und C++ werden Funktionsparameter immer per Call-by-Value übergeben: Der Wert des Arguments wird
in die lokale Variable des Funktionsparameters kopiert. Wenn ein Pointer übergeben wird, wird entsprechend
der Wert des Pointers kopiert, also die gespeicherte Adresse. Mit der Adresse eines Objekts kann man
aber auch in der Funktion direkt auf dieses Objekt zugreifen und dieses auslesen und verändern, d.h.
durch die Übergabe eines Pointers hat man zwar immer noch Call-by-Value (die Adresse wird kopiert), die
Wirkung ist aber wie bei Call-by-Reference (also als ob eine Referenz auf das Objekt übergeben wurde).
Bei der Verwendung von C++-Referenzen hat man dagegen echtes Call-by-Reference.
Zur Laufzeit kann man Speicher auf dem Heap reservieren (allozieren). Im Gegensatz zu Speicher auf dem
Stack ist man selbst auch für die Freigabe des reservierten Speichers zuständig - wenn man dies nicht beachtet,
läuft irgendwann der Heap voll. Allokation und Freigabe kann entweder mit den C-Funktionen malloc und free
erfolgen oder mit den C++-Operatoren new und delete. Mischen Sie niemals nie malloc()/free() mit
new/delete!
Zwischen Pointern und Arrays gibt es eine enge Verwandschaft. Die einzelnen Elemente eines Arrays
werden vom Compiler direkt aufeinanderfolgend im Speicher angeordnet, der Array-Name ist wie
ein (konstanter) Pointer auf das erste Element. Tatsächlich übersetzt der Compiler Indexzugriffe
für ein Array in die passende Pointerdereferenzierung: a[i] wird zu *(a+i). Ein Pointer kann
wiederum auch auf das erste Element eines zusammenhängenden Speicherbereichs zeigen, etwa wenn man
über malloc Speicherplatz für mehrere Elemente anfordert. Da der Compiler aus einem Indexzugriff
ohnehin die Pointerdereferenzierung macht, könnte man so einen Pointer auch per Indexzugriff
abfragen. Dies ist aber gefährlich: Es funktioniert auch, wenn der Pointer nur auf ein anderes
Objekt zeigt und nicht auf einen Speicherbereich ... Ein Arrayname wird vom Compiler fest der
ersten Speicheradresse des Arrays zugeordnet und kann nicht verändert werden, der Inhalt eines
(nicht-konstanten) Pointer dagegen schon (der Pointer selbst wird auch fest im Speicher angelegt).
Pointer haben einen Typ: Die Pointerarithmetik berücksichtigt die Speicherbreite des Typs! Damit
springt man mit ptr+1 automatisch zum nächsten Objekt und nicht notwendigerweise zum nächsten
Byte.
sind einer Speicheradresse im virtuellen Speicher zugeordnet
Im Beispiel:
Variable i:
Name: "i"
Wert: 99
Speicherzelle (Adresse): 10125
Variable iptr:
Name: "iptr"
Wert: 10125
Speicherzelle (Adresse): 27890
Pointer sind besondere Variablen
Der Wert eines Pointers wird als Adresse im Speicher behandelt
Der Wert von iptr ist nicht ein beliebiger Integer, sondern eine Adresse. In
diesem Fall handelt es sich um die Adresse im virtuellen Speicher, wo die
Variable i abgelegt ist.
Wirkung/Interpretation: Variable iptr "zeigt" auf die Adresse von Variable i.
Pointer und Adressen (Syntax)
Deklaration
Typ * Name;
Zuweisung einer Adresse über den &-Operator:
int i =99;
int*iptr;
iptr =&i; /* Wert von iptr ist gleich Adresse von i */
iptr ist ein Pointer auf eine (beliebige) Speicherzelle mit Inhalt vom Typ int
Nach Zuweisung: iptr ist ein Pointer auf die Speicherzelle der Variablen i
Dereferenzierung: Zugriff auf Ziel
Dereferenzierung mit *:
int i =99;
int*iptr;
iptr =&i;
*iptr =2; // Zugriff auf verwiesene Speicherzelle i
Jetzt zeigen zwei Pointer auf die Speicherzelle von Variable i: iptr (wegen iptr = &i), und
weil der Wert von iptr in ptr2 kopiert wurde (ptr2 = iptr), zeigt nun auch ptr2 auf i.
Der Wert von iptr ist die Adresse von i. Wenn dieser Wert kopiert oder zugewiesen wird, ändert
sich an dieser Adresse nichts. ptr2 bekommt diesen Wert zugewiesen, d.h. bei einer Dereferenzierung
von ptr2 würde auf die Adresse von i zugriffen werden und dort gelesen/geschrieben werden.
Pointer und Scopes
Nicht auf Variablen außerhalb ihres Scopes zugreifen!
int i=9;
int*ip =&i;
*ip =8;
{ /* neuer Block */int j=7;
ip =&j;
}
*ip =5; /* AUTSCH!!! */
int*murks() {
int i=99;
return&i; /* AUTSCH!!! */}
Hotelzimmer-Analogie
Wenn Sie in ein Hotel einchecken, bekommen Sie den Schlüssel zu Ihrem Zimmer
Pointer == Schlüssel
Variable auf die Pointer zeigt == Zimmer
Wenn Sie auschecken, geben Sie normalerweise Ihr Zimmer auf und den Schlüssel ab
Pointer wird ungültig
Variable wird ungültig
Wenn Sie beim Auschecken den Schlüssel nicht abgeben, gehört das Zimmer
dennoch nicht mehr Ihnen
Sie haben noch den Pointer
Die Variable, auf die der Pointer zeigt, ist ungültig
Wenn Sie jetzt auf das Zimmer gehen, kommen Sie (evtl.) noch rein
Evtl. ist das Zimmer noch nicht wieder belegt, und Sie finden Ihr vergessenes Handy
Bei Dereferenzierung erhalten Sie noch den alten Wert der Variablen
Evtl. wurde das Zimmer bereits wieder vergeben => Sie "brechen" bei
einem Fremden ein!
Bei Dereferenzierung greifen Sie auf "fremde" Variablen (Speicherbereiche) zu!
Pointer und Initialisierung
Pointer werden vom Compiler nicht initialisiert!
Zeigen ohne explizite Initialisierung auf zufällige Adresse
delete[] darf nur auf mit new[] erzeugte Objekte angewendet werden
(und muss dort auch angewendet werden)
delete auf mit new[] erzeugtes Array würde nur
erstes Element freigeben!
Vorsicht mit Pointern auf lokale Variablen
Funktioniert technisch, ist aber gefährlich:
int*murks() {
int i=99;
return&i; /* SO NICHT: Pointer auf lokale Variable! */}
Etwas besser:
int*wenigerMurks() {
int*p = (int*) malloc(sizeof(int)); /* neuer Speicher */*p=99;
return p; /* das geht */}
Warum nur "etwas besser"?
Jetzt haben Sie aber ein neues Problem: Der Aufrufer der Funktion muss wissen,
dass diese Speicher alloziert und muss sich selbst um die Freigabe kümmern.
Dies ist unschön, da die Allokation und Freigabe in unterschiedlicher
Verantwortung liegen! Dadurch können sehr schnell Fehler passieren.
Besser wäre, wenn der Aufrufer einen Pointer übergibt, mit dem dann in der
Funktion gearbeitet wird. Dann liegt die Verantwortung für die Erstellung und
Freigabe des Pointers komplett in der Hand des Aufrufers.
Memory Leaks
Pointer-Variablen unterliegen den Gültigkeitsregeln für Variablen
Mit malloc() reservierter Speicher existiert bis Programmende
{
int*i;
i = (int*) malloc(sizeof(*i));
*i =99;
}
/* hier existiert die Variable i nicht mehr *//* aber der Speicher auf dem Heap bleibt belegt *//* ist aber nicht mehr zugreifbar -> SPEICHERLOCH! */
Double Free und Stale Pointer
free() darf nur einmal pro Objekt aufgerufen werden
Hintergrund: Intern wird eine Freispeicherliste verwaltet
Nach free() ist der Zeiger undefiniert:
Zeigt immer noch in den Heap (alte Adresse!)
Ist nicht gleich NULL oder 0
Zugriff ist möglich, aber gefährlich: Speicher kann wieder vergeben und
überschrieben werden (Hotelzimmer-Analogie)
Mehrere Pointer auf ein Objekt: Einmal free() reicht!
Die anderen Pointer dürfen anschließend aber auch nicht mehr
dereferenziert werden (stale/dangling pointer)
Beispiel Stale Pointer
int*i, *k; i = (int*) malloc(sizeof(*i)); k = i;
free(i);
free(i); /* EINMAL reicht! */*k =42; /* Speicher ist bereits frei - stale pointer */free(k); /* Speicher ist bereits frei - double free */*i =99; /* Speicher ist bereits frei */
Anmerkung: Anwendung auf NULL-Pointer bewirkt nichts und ist unschädlich
Dereferenzieren von "Bad Pointern"
Der klassische Scanf-Bug :)
int i;
scanf("%d", i);
Tipp: i ist kein Pointer :)
Auslesen von nicht-initialisiertem Speicher
Wenn Programmierer denken, dass irgendwer den Heap zwischendurch immer mal
wieder auf 0 setzt ...
/* return y = Ax */int*matvec(int**A, int*x, int N) {
int*y =malloc(N*sizeof(int));
for (int i=0; i<N; i++) {
for (int j=0; j<N; j++) {
y[i] += A[i][j] * x[j];
}
}
return y;
}
int**p;
p =malloc(N*sizeof(int));
for (int i=0; i<=N; i++) {
p[i] =malloc(M*sizeof(int));
}
Tipp: Hier läuft i um einen Platz zu weit ...
Überschreiben von Speicher III
Einlesen von Strings, zu kleine Buffer
char s[8];
gets(s);
Tipp: Wenn hier mehr als 7 Zeichen eingegeben werden, gibt es Probleme :)
Überschreiben von Speicher IV
Pointerarithmetik falsch verstanden
int*search(int*p, int val) {
while (*p &&*p != val)
p +=sizeof(int);
return p;
}
Tipp: Jeder Pointer hat einen Typ, und der Ausdruck "Pointer + 1" rutscht um
so viele Bytes im Speicher weiter, wie der Typ breit ist. D.h. mit einem
"Pointer + 1" gelangt man zum nächsten Element, während der obige Ausdruck
p += sizeof(int); um sizeof(int) Elemente weiterspringt!
Pointer und Arrays
Ein Array-Name ist wie ein konstanter Pointer auf Array-Anfang: a[i] == *(a+i)
Ein Array-Name ist nur ein Label, welches der Adresse des ersten Array-Elements entspricht.
Die Wirkung ist entsprechend die eines konstanten Pointers auf den Array-Anfang.
=> Der Compiler übersetzt Array-Zugriffe per Indexoperator in Pointerarithmetik: a[i] wird zu *(a+i) ...
char a[6], c, *cp;
&a[0] == a;
cp = a;
c = a[5];
c =*(a+5);
c =*(cp+5);
c = cp[5];
a = cp; /* FEHLER */a =&c; /* FEHLER */
Iteration durch Arrays (Varianten)
int a[10], *pa=a;
for (int k=0; k<10; k++) /* Iteration, Variante 1 */printf("%d ", a[k]);
for (int k=0; k<10; k++) /* Iteration, Variante 2 */printf("%d ", *(a+k));
pa = a;
for (int k=0; k<10; k++) /* Iteration, Variante 3 */printf("%d ", *pa++);
/* Iteration, KEINE Variante */for (int k=0; k<10; k++)
printf("%d ", *a++); /* DAS GEHT NICHT */
*pa++: Operator ++ hat Vorrang vor *, ist aber die Postfix-Variante. D.h.
++ wirkt auf pa (und nicht auf *pa), aber zunächst wird für die Ausgabe
*pa ausgewertet ...
*a++ ist nicht erlaubt, weil dadurch der Name des Arrays (== Adresse des ersten
Array-Elements == konstanter Zeiger auf den Anfang des Arrays) verändert würde.
Selbsttest: Was bedeutet was, was ist erlaubt/nicht erlaubt, was kommt raus? Warum?
int a[10], *pa, *pb, x;
pa = a; pb = (int*) malloc(sizeof(int));
x = a[1];
x =*(a+1);
x =*(a++);
x = pa[1];
x =*(pa+1);
x =*(pa++);
x = pb[1];
x =*(pb+1);
x =*(pb++);
=> Arrays können wie konstante Pointer behandelt werden.
=> Pointer dürfen nicht immer wie Arrays behandelt werden!
(Syntaktisch zulässig, semantisch normalerweise nicht!)
Pointerarithmetik: Typen beachten
Pointer zeigen auf Objekte mit einem bestimmten Typ
Typen haben unterschiedliche Speicherbreite
Inkrementierung/Dekrementierung: Pointer zeigt nicht auf nächste
Speicheradresse, sondern auf die Adresse des nächsten Werts!
Objekt hat damit mehrere Namen, über die es ansprechbar ist
Referenzen in C++ mit Hilfe des &-Operators deklarieren
Eigenschaften von Referenzen in C++
Referenzen müssen bei Deklaration initialisiert werden
Referenzen können nicht um-assigned werden
Referenzen brauchen keinen eigenen Speicherplatz
Vorsicht bei gleichzeitiger Deklaration mehrerer Referenzen:
int i=2;
int j=9;
int& r=i, s=j; // SO NICHT!!!
int&r=i, &s=j; // korrekt
Referenzen als Funktionsparameter
Signatur:
voidfkt(int&, char);
voidfkt(int&a, char b); // a per Referenz
Aufruf: ganz normal (ohne extra &) ...
int x=3;
char y='a';
fkt(x, y); // x per Referenz
Im Beispiel werden die Variablen x und y an die Funktion fkt übergeben. Der
erste Parameter wird per Referenz (call-by-reference), der zweite per Kopie
(call-by-value) übergeben.
Der Funktionsparameter a bindet sich an x, ist eine Referenz auf/für x - jeder
Zugriff auf a ist wie ein Zugriff auf x. Änderungen von a sind also Änderungen
von x.
Der zweite Parameter bindet sich an den Wert von y, d.h. b hat den Wert 'a'.
Zwar kann auch b verändert werden, das hat dann aber nur Auswirkungen innerhalb der
Funktion und nicht auf die Variable y im äußeren Scope.
Call-by-Reference Semantik in C++
Variante A: Pointer (C und C++)
Mit Hilfe von Pointern lässt sich die Call-by-Reference Semantik in C und in C++ simulieren.
Bei der Übergabe eines Pointers wird der Wert des Pointers kopiert (call-by-value!). Im Inneren
der Funktion kann diese Adresse dereferenziert werden und so auf das außerhalb der Funktion "lebende"
Objekt zugegriffen werden. Damit bekommt man in der Wirkung call-by-reference.
Pointer wird nach wie vor per call-by-value übergeben:
Wert wird bei Übergabe kopiert (hier Adresse von i)
Kopierter Wert ist immer noch ein Pointer (hier Pointer auf i, da
Adresse von i)
Dereferenzierung des kopierten Pointers: Zugriff auf das
Original-Objekt (hier i)
Variante B: Referenzen (nur C++)
Referenzen müssen bei der Deklaration initialisiert werden und binden sich an das dabei genutzte
Objekt. Sie stellen letztlich lediglich einen neuen Namen für das Objekt dar.
Bei der Übergabe von Variablen an Referenz-Parameter einer Funktion binden sich diese Parameter an
die übergebenen Objekte. Jeder Zugriff innerhalb der Funktion auf einen Referenz-Parameter bewirken
einen Zugriff auf das ursprüngliche Objekt.
intadd_5(int&x) {
x +=5;
return x;
}
intmain() {
int i=0, erg;
erg = add_5(i);
}
Funktionsparameter x ist eine Referenz
Bei Aufruf der Funktion wird dieser Parameter initialisiert - die Referenz x bindet sich
im Beispiel an die Variable i
Zugriffe auf x in der Funktion sind also Zugriffe auf das Original-Objekt i - x += 5
ist nichts anderes als i += 5
Bei weiteren Aufrufen wird x dann neu gebunden
Call-by-Reference: const
Nachteil bei Call-by-Reference:
Übergebenes Objekt könnte durch die Funktion (unbeabsichtigt) verändert werden
Abhilfe: Deklaration der Parameter als konstant (Schlüsselwort const):
voidfkt(constint&, char);
voidfkt(constint&a, char b);
// a wird per Referenz uebergeben, darf aber in der Funktion nicht veraendert werden
=> const-heit ist Bestandteil der Signatur!
Arbeiten Sie (wo möglich/sinnvoll) mit (konstanten) Referenzen!
Rückgabe von Werten per Referenz
Normalerweise per call-by-value (Kopie)
Mit Referenzen oder Pointern auch als call-by-reference
int&fkt1(constint&i, constchar*j) {
int erg = i+1;
return erg; // Referenz auf lokale Variable!
}
int*fkt2(constint&i, constchar*j) {
int erg = i+2;
return&erg; // Pointer auf lokale Variable!
}
intmain() {
int&x = fkt1(2, "a"); // AUTSCH!!!
int*y = fkt2(2, "b"); // AUTSCH!!!
int z = fkt1(2, "c"); // OK
}
Die Zuweisung int &x = fkt1(2, "a"); ist syntaktisch erlaubt. Semantisch aber nicht: Die
Referenz x bindet sich an das zurückgelieferte lokale erg - dieses existiert aber nicht
mehr, da der Scope von erg beendet ist ...
=> Nur Pointer auf Speicher zurückliefern, der nach Beendigung des Funtionsaufrufes noch existiert!
(Dies könnte beispielsweise Speicher aus malloc oder new oder ein Pointer auf das eigene Objekt
(*this) sein.)
Die Zuweisung int *y = fkt2(2, "b"); ist syntaktisch erlaubt. Semantisch aber nicht: Der
Pointer y übernimmt die zurückgelieferte Adresse des lokalen erg - dieses existiert aber
nicht mehr, da der Scope von erg beendet ist ...
=> Nur Referenzen zurückliefern, die nach Beendigung des Funtionsaufrufes noch gültig sind!
(Dies könnten beispielsweise Referenz-Inputparameter oder eine Referenz auf das eigene Objekt
(*this) sein.)
Die Zuweisung int z = fkt1(2, "c"); ist unbedenklich, da z eine normale Integervariable
ist und hier das übliche Kopieren der Rückgabe von ftk1 in die Variable stattfindet.
Diskussion
In C++ können Sie Call-by-Reference über Pointer und/oder über Referenzen erreichen.
In den obigen Beispielen wurde dies für die Parameter einer Funktion gezeigt - es sind
aber auch Pointer und/oder Referenzen als Rückgabetypen möglich. Beachten Sie dabei,
ob das jeweils wirklich Sinn ergibt! Eine Referenz oder ein Pointer auf eine lokale
Variable ist eine große Fehlerquelle.
In C++ werden Referenzen über Pointer bevorzugt. Wenn Sie die Wahl zwischen den beiden
Signaturen bar foo(wuppie&, bar) und bar foo(wuppie*, bar) haben, sollten Sie sich
für bar foo(wuppie&, bar) entscheiden.
Vergleich Pointer mit Referenzen
Referenzen
Pointer
Alias-Name für Objekte/Variablen, kein eigener Speicherplatz
"Echte" Variablen mit eigenem Speicherplatz (für den Wert des Pointers)
Können nicht auf andere Objekte "umgebogen" werden
Können auf andere Objekte zeigen (falls nicht const)
Operationen agieren direkt auf dem referenzierten Objekt
Operationen auf referenzierten Objekt als auch auf dem Pointer selbst
Nur in C++
In C und in C++
Mit Pointern ist dynamische Speicherverwaltung möglich: Manipulation von Speicherbereichen im Heap
Klassen werden in C++ mit dem Schlüsselwort class definiert. Dabei müssen Klassendefinitionen immer
mit einem Semikolon abgeschlossen werden(!). Bei Trennung von Deklaration und Implementierung muss die
Definition der Methoden mit dem Namen der Klasse als Namespace erfolgen:
// .h
classFluppie {
public:int wuppie(int c=0);
};
// .cpp
int Fluppie::wuppie(int c) { ... }
Die Sichtbarkeiten für die Attribute und Methoden werden blockweise definiert. Für die Klassen selbst
gibt es keine Einstellungen für die Sichtbarkeit.
Objekt-Layout: Die Daten (Attribute) liegen direkt im Objekt (anderenfalls Pointer nutzen). Sofern der
Typ der Attribute eine Klasse ist, kann man diese Attribute nicht mit NULL initialisieren (kein Pointer,
keine Referenz).
Für den Aufruf eines Konstruktors ist kein new notwendig, es sei denn, man möchte das neue Objekt
auf dem Heap haben (inkl. Pointer auf das Objekt).
Beachten Sie den Unterschied der Initialisierung der Attribute bei einer Initialisierung im Body des
Konstruktors vs. der Initialisierung über eine Initialisierungsliste. (Nutzen Sie in C++ nach
Möglichkeit Initialisierungslisten.)
Klassendefinition muss mit Semikolon beendet werden
Sichtbarkeit wird immer blockweise eingestellt (per Default immer private)
Wie bei Funktionen: Deklaration muss vor Verwendung (= Aufruf) bekannt sein
this ist keine Referenz, sondern ein Pointer auf das eigene Objekt
Objektlayout: Java vs. C++
Java: Referenzen auf Objekte
classStudent {
String name;
Date birthday;
double credits;
}
In Java werden im Objektlayout lediglich die primitiven Attribute direkt gespeichert.
Für Objekte wird nur eine Referenz auf die Objekte gehalten. Die Attribute selbst
liegen aber außerhalb der Klasse, dadurch benötigt das Objekt selbst nur relativ wenig
Platz im Speicher.
C++: Alles direkt im Objekt
classStudent {
string name;
Date birthday;
double credits;
};
In C++ werden alle Attribute innerhalb des Objektlayouts gespeichert. Ein Objekt mit
vielen oder großen Feldern braucht also auch entsprechend viel Platz im Speicher.
Wollte man eine Java-ähnliche Lösung aufbauen, müsste man in C++ entsprechend Pointer
einsetzen:
classStudent {
private: string *name;
Date *birthday;
double credits;
}
(new würde zwar auch ein neues Objekt anlegen, aber auf dem Heap!)
Default-Konstruktoren
Der C++-Compiler generiert einen parameterlosen Defaultkonstruktor - sofern man
nicht selbst mindestens einen Konstruktor definiert.
Dieser parameterlose Defaultkonstruktor wendet für jedes Attribut dessen parameterlosen
Konstruktor an, für primitive Typen erfolgt keine garantierte Initialisierung!
Achtung: Default-Konstruktor wird ohne Klammern aufgerufen!
Dummy a; // Korrekt
Dummy a(); // FALSCH!!! (Deklaration einer Funktion `a()`, die ein `Dummy` zurueckliefert)
classStudent {
public: Student(const string &n, const Date &d, double c) {
name = n;
birthday = d;
credits = c;
}
private: string name;
Date birthday;
double credits;
};
Hier erfolgt die Initialisierung in zwei Schritten:
Attribut wird angelegt und mit Defaultwert/-konstruktor des Datentyps initialisiert
Anschließend wird die Zuweisung im Body des Konstruktors ausgeführt
Das klappt natürlich nur, wenn es einen parameterlosen Konstruktor für das Attribut
gibt.
Beispiel oben:
Beim Anlegen von birthday im Speicher wird der Defaultkonstruktor für
Date aufgerufen. Danach wird im Body der übergebene Datumswert zugewiesen.
In manchen Fällen muss man die Initialisierung der Attribute per
Initialisierungsliste durchführen.
Hier einige Beispiele:
Attribut ohne parameterfreien Konstruktor
Bei "normaler" Initialisierung würde zunächst der parameterfreie Konstruktor für das
Attribut aufgerufen, bevor der Wert zugewiesen wird. Wenn es keinen parameterfreien
Konstruktor für das Attribut gibt, bekommt man beim Kompilieren einen Fehler.
Konstante Attribute
Bei "normaler" Initialisierung würde das Attribut zunächst per parameterfreiem Konstruktor
angelegt (s.o.), danach existiert es und ist konstant und darf nicht mehr geändert werden
(müsste es aber, um die eigentlich gewünschten Werte im Body zu setzen) ...
Attribute, die Referenzen sind
Referenzen müssen direkt beim Anlegen initialisiert werden.
Vor C++11: Ein Objekt ist fertig konstruiert, wenn der Konstruktor durchgelaufen ist
Ab C++11: Ein Objekt ist fertig konstruiert, wenn der erste Konstruktor fertig
ausgeführt ist
=> Jeder weitere aufgerufene Konstruktor agiert auf einem "fertigen" Objekt.
Vorsicht mit rekursiven Aufrufen: Compiler kann warnen, muss aber nicht.
C++ und explizite Konstruktoren
Implizite Konvertierung mit einelementigen Konstruktoren:
Auf der linken Seite der Zuweisung steht der Typ Dummy, rechts ein int.
Der Compiler sucht nach einem Weg, aus einem int einen Dummy zu machen
und hat durch die Gestaltung des Konstruktors von Dummy diese Möglichkeit.
D.h. in dieser Zuweisung wird implizit aus der 37 ein Objekt vom Typ Dummy
gebaut (Aufruf des Konstruktors) und dann die Zuweisung ausgeführt.
Dieses Verhalten ist in vielen Fällen recht praktisch, kann aber auch zu
unerwarteten Problemen führen. Zur Abhilfe gibt es das Schlüsselwort explicit.
Falls unerwünscht: Schlüsselwort explicit nutzen
explicitDummy(int c=0);
Wrap-Up
Klassendefinition mit Semikolon abschließen (!)
Sichtbarkeiten blockweise, keine für Klasse
Daten liegen direkt im Objekt (anderenfalls Pointer nutzen)
Attribute sind echte Objekte: Initialisieren mit NULL nicht möglich
Konstruktoren: Kein new nötig (würde Objekt auf Heap anlegen und Pointer liefern)
Challenges
C++: Klassen
Erklären Sie die Unterschiede zwischen den Klassendefinitionen (Java, C++):
Für C++-Klassen kann man Destruktoren, Copy-Konstruktoren und Zuweisungsoperatoren definieren.
Wenn man keine eigenen definiert, erzeugt C++ Default-Varianten. Diese bereiten u.U. Probleme,
wenn man Pointertypen für die Attribute verwendet: Dann werden u.U. nur flache Kopien erzeugt
bzw. es wird u.U. der Platz auf dem Heap nicht freigegeben.
Der Default-Destruktor ruft die Destruktoren der Objekt-Attribute auf. Der Copy-Konstruktor wird
aufgerufen, wenn die linke Seite (einer scheinbaren "Zuweisung") ein unfertiges Objekt ist (noch
zu bauen) und die rechte Seite ein fertiges Objekt ist. Der Zuweisungs-Operator wird dagegen
aufgerufen, wenn auf beiden Seiten ein fertiges Objekt vorliegt.
Innerhalb einer Klasse kann man über den Pointer this auf das eigene Objekt zugreifen
(analog zu self in Python oder this in Java, dort aber Referenzen).
Bei statischen Methoden und Attributen wird die Deklaration als staticnicht in der
Implementierung wiederholt! Statische Attribute müssen außerhalb der Klassendefinition einmal
initialisiert werden!
Methoden können als "konstant" ausgezeichnet werden (const rechts von der Parameterliste). Das
const gehört zur Signatur der Methode! Konstante Methoden dürfen auf konstanten Objekten/Referenzen
aufgerufen werden.
Neben dem eigentlichen Konstruktor existieren in C++ weitere wichtige Konstruktoren/Operatoren:
die sogenannten "Big Three":
Copy-Konstruktor
Destruktor: Gegenstück zum Konstruktor
Zuweisungsoperator (operator=)
Anmerkung: Für Fortgeschrittenere sei hier auf die in C++11 eingeführte und den Folgeversionen verbesserte und
verfeinerte Move-Semantik und
die entsprechenden Varianten der Konstruktoren und Operatoren verwiesen. Man spricht deshalb mittlerweile auch gern
von den "Big Five" bzw. der "rule of five".
Analog zum Default-Konstruktor kann der Compiler auch Defaults für die Big Three
(Copy-Konstruktor, Destruktor, Zuweisungsoperator) generieren. Das funktioniert
nur, so lange Sie nicht selbst einen Copy-Konstruktor, Destruktor oder Zuweisungsoperator
definiert haben.
Diese Defaults passen normalerweise, wenn die Data-Member vom Typ int, double,
vector<int>, string, vector<string> o.ä. sind.
Problematisch wird es, wenn Pointer dabei sind: Dann werden flache Kopien erzeugt bzw.
Speicher auf dem Heap nicht oder mehrfach freigegeben! Sobald Sie für die Attribute
Pointer verwenden, sollten Sie eigene Konstruktoren, Copy-Konstruktoren, Destruktoren
und Zuweisungsoperatoren definieren!
Hier ein Beispiel für die Wirkung:
classDummy {
public: Dummy(int initValue =0) {
value =newint(initValue);
}
intgetValue() {
return*value;
}
voidsetValue(int a) {
*value = a;
}
private:int*value;
};
voidmain() {
// oberer Teil der Abbildung
Dummy a(2);
Dummy b = a;
Dummy c;
// unterer Teil der Abbildung
c=b;
a.setValue(4);
}
Analyse:
Es sind Pointer im Spiel. Es wurde ein eigener Konstruktor definiert, aber kein
Copy-Konstruktor, d.h. diesen "spendiert" der Compiler.
Beim Anlegen von a wird auf dem Heap Speicher für einen int reserviert und
dort der Wert 2 hineingeschrieben.
Beim Anlegen von b wird der Default-Copy-Konstruktor verwendet, der einfach
elementweise kopiert. Damit zeigt der Pointer value in b auf den selben
Speicher wie der Pointer value in a.
Der Ausdruck c=b ist eine Zuweisung (warum?). Auch hier wird der vom Compiler
bereitgestellte Default genutzt (elementweise Zuweisung). Damit zeigt nun auch
der Pointer value in c auf den selben Speicher wie die value-Pointer in
a und b.
Hinweis Abarbeitungsreihenfolge
Dummy a(0); Dummy b(1); Dummy c(2); Dummy d(3);
a = b = c = d; // entspricht: a.operator=(b.operator=(c.operator=(d)));
delete this?
Erinnerung:
this ist ein Pointer auf das eigene Objekt
delete darf nur für Pointer auf Objekte, die mit new angelegt wurden,
aufgerufen werden => Freigabe von Objekten auf dem Heap!
delete ruft den Destruktor eines Objekts auf ...
Frage: Ist das folgende Konstrukt sinnvoll? Ist es überhaupt erlaubt? Was
passiert dabei?
classFoo {
public:~Foo() {
deletethis;
}
};
Analyse: Wir haben hier gleich zwei Probleme:
delete ruft den Destruktor des verwiesenen Objekts auf. Da this ein
Pointer auf das eigene Objekt ist, ruft delete this; den eigenen
Destruktor auf, der dann wiederum delete this; aufruft und so weiter.
=> Endlosschleife!
Außerdem wissen wir im Destruktor bzw. im Objekt gar nicht, ob das Objekt
wirklich mit new auf dem Heap angelegt wurde! D.h. wenn wir nicht in
die Endlosschleife eintreten würden, würde das Programm abstürzen.
Der Destruktor wird aufgerufen, wenn ein Objekt zerstört wird, d.h. wenn ein
Objekt seine Lebensdauer beendet (Verlassen des Scopes, in dem das Objekt
definiert wurde) bzw. wenn explizit ein delete auf das Objekt aufgerufen
wird (d.h. delete auf einen Pointer auf das Objekt, wobei dieses mit new
angelegt wurde).
Im Destruktor sollten durch das Objekt verwaltete Resourcen freigegeben
werden, d.h. sämtliche im Objekt mit new oder malloc allozierten Resourcen
auf dem Heap müssen freigegeben werden. Außerdem sollten ggf. offene Verbindungen
(offene Dateien, Datenbankverbindungen, Kommunikation, ...) geschlossen werden,
wenn sie durch das Objekt geöffnet wurden bzw. in der Verantwortung des Objekts
stehen. Einfache Datentypen oder Objekte, die nicht per Referenz oder Pointer
im Objekt verwaltet werden, werden automatisch freigegeben (denken Sie an das
Speichermodell - diese Daten "stehen" direkt im Speicherbereich des Objekts).
Der Speicherbereich für das Objekt selbst wird nach Beendigung des Destruktors
automatisch freigegeben (auf dem Stack wegen des Verlassen des Scopes (=>
automatische Variable), auf dem Heap durch das vorherige Aufrufen von delete
auf den Pointer auf das Objekt im Heap), d.h. Sie brauchen im Destruktor keindelete auf "sich selbst" (das ist wie oben demonstriert sogar schädlich)!
Warnung
Auch wenn es zunächst irgendwie sinnvoll aussieht - rufen Sie niemals niedelete this im Destruktor auf!
int Studi::getCredits() const {
return credits;
}
int Studi::getCredits() {
return credits;
}
Das const gehört zur Signatur der Methode!
So wie im Beispiel gezeigt, gibt es jetzt zwei Methoden getCredits() - eine davon
ist konstant. Konstante Methoden dürfen auf konstanten Objekten/Referenzen aufgerufen
werden.
Was passiert, wenn das const auf der linken Seite steht? Dann bezieht es sich
auf den Rückgabewert:
const foo wuppie(foo&, foo&);
Hier darf der Rückgabewert nicht als L-Wert benutzt werden: wuppie(a,b) = c; ist verboten.
Erklären Sie die folgenden Anweisungen, worin liegt der Unterschied?
Dummy a;
Dummy b =3;
Dummy c(4);
Erklären Sie die folgenden Anweisungen:
Dummy a;
Dummy b =3;
Dummy c(4);
Dummy d = b;
Dummy e(b);
Dummy f;
f = b;
Destruktor
Erklären Sie die Wirkungsweise eines Destruktors.
Wann wird ein Destruktor aufgerufen?
Warum ist delete this keine gute Idee (nicht nur im Destruktor)?!
Was sollten Sie im Destruktor aufräumen, was nicht?
Die "Großen Drei"
Beschreiben Sie den Unterschied der folgenden beiden Codeblöcke (A sei
eine beliebige Klasse):
A a, b = a;
A a, b; b = a;
Erläutern Sie an einem Beispiel die Regel der "Big Three":
Ist ein Copy-Konstruktor, ein Destruktor oder ein eigener
Zuweisungsoperator notwendig, muss man in der Regel die jeweils anderen
beiden ebenfalls bereit stellen.
Beim Zuweisungsoperator werden Selbstzuweisungen, d.h. ein Objekt soll an
sich selbst zugewiesen werden, üblicherweise durch eine entsprechende
Prüfung vermieden.
Begründen Sie diese Praxis, indem Sie ein Beispiel konstruieren, bei
dem es zu Datenverlust kommt, wenn die Selbstzuweisung nicht unterbunden
wird.
Wenn vor der Wertzuweisung der alte Inhalt freigegeben werden muss, führt
Selbstzuweisung zum Fehler.
Können Sie ein konkretes Beispiel angeben?
In C++ können existierende Operatoren überladen werden, etwa für die Nutzung mit eigenen Klassen. Dabei
kann die Überladung innerhalb einer Klassendefinition passieren (analog zur Implementierung einer Methode)
oder außerhalb der Klasse (analog zur Definition einer überladenen Funktion).
Beim Überladen in einer Klasse hat der Operator nur einen Parameter (beim Aufruf das Objekt auf der rechten
Seite) und man kann auf die Attribute der Klasse direkt zugreifen. Bei der Überladung außerhalb der Klasse
hat der Operator zwei Parameter und darf nicht auf die Attribute der Klasse zugreifen.
Man kann Funktionen, Methoden/Operatoren und Klassen als friend einer Klasse deklarieren. Damit bricht
man die Kapselung auf und erlaubt den Freunden den direkten Zugriff auf die internen Attribute einer Klasse.
Um bei der Implementierung von Post- und Präfix-Operatoren die Variante für den Compiler unterscheidbar zu
machen, hat die Signatur der Postfix-Variante einen Dummy-Parameter vom Typ int. Dieser wird beim Aufruf
aber nicht genutzt.
Vererbung analog zu Java passiert in C++ über die "public-Vererbung": Subklasse : public Superklasse.
Dabei gibt es in C++ keine gemeinsame Oberklasse wie Object und entsprechend kein super. (Es
kann auch private Vererbung geben.)
Operatoren und *struktoren werden in den vom Compiler erzeugten Defaults richtig verkettet. Bei der
eigenen Implementierung von Operatoren und Konstruktoren muss zunächst der Operator/Konstruktor der
Basisklasse aufgerufen werden (Basisklassen-Konstruktoren dabei in der Initialisierungsliste!), danach
erfolgt die Implementierung für die eigenen Attribute der abgeleiteten Klasse. Der Zugriff auf die
Elemente der Elternklasse erfolgt dabei über den Namen der Elternklasse und den Scope-Operator (nicht
mit super!). Destruktoren von abgeleiteten Klassen müssen sich dagegen nur um die zusätzlichen
Attribute der abgeleiteten Klasse kümmern, der Basisklassendestruktor wird automatisch verkettet bzw.
aufgerufen.
Abstrakte Klassen in C++ haben mindestens eine abstrakte Methode. Eine Methode ist abstrakt, wenn sie
als "virtual" deklariert ist und der Deklaration ein "=0" folgt.
In C++ hat man aus Effizienzgründen per Default statische Polymorphie. Bei der Zuweisung eines Objekts
einer abgeleiteten Klasse (rechte Seite) an ein Objekt vom Typ der Oberklasse (linke Seite) erfolgt
dabei "Slicing", d.h. alle zusätzlichen Eigenschaften der abgeleiteten Klasse gehen dabei verloren.
Dynamische Polymorphie kann man in C++ nutzen, indem man (a) die gewünschten Methoden in der Basisklasse
als virtual deklariert und (b) für den Zugriff auf die Objekte der abgeleiteten Klasse Pointer oder
Referenzen vom Basisklassen-Typ benutzt.
In C++ ist Mehrfachvererbung möglich, d.h. eine Klasse kann von mehreren anderen Klassen erben. Damit
erbt sie auch das Objekt-Layout aller Elternklassen.
Bei rautenförmigen Vererbungsbeziehung führt dies zu Problemen, da Attribute und Methoden der gemeinsamen
Basisklasse mehrfach vorhanden (über jeden Zweig der Raute).
Zur Umgehung des Problems kann man die gemeinsam genutzten Basisklassen "virtual" deklarieren. Dadurch
sind gemeinsam genutzte Attribute und Methoden nur noch einfach vorhanden. Da die Klassen "in der Raute"
ihrerseits den Konstruktor der Basisklasse aufrufen (könnten) und es dadurch zu Konflikten beim Setzen
der Attribute der Basisklasse kommen kann, gelten bei virtueller Ableitung Sonderregeln: Für die virtuelle
Basisklasse wird die Weiterleitung der Werte aufgehoben (es muss also ein parameterloser Konstruktor existieren,
der durch die direkten Unterklassen aufgerufen wird) und die Klasse am "unteren Ende der Raute" kann direkt
den Konstruktor der virtuellen Basisklasse am "oberen Ende der Raute" aufrufen.
Zuweisung von Objekten vom Typ Student ist erlaubt (Polymorphie)
p hat aber nur Speicherplatz für genau eine Person
=> "Abschneiden" aller Elemente, die nicht Bestandteil von
Person sind!
Slicing passiert immer beim Kopieren/Zuweisen von Objekten
=> Dyn. Polymorphie in C++ immer über Referenzen
(bzw. Pointer) undvirtuelle Methoden
Wir hatten die Methode toString in der Basisklasse Person zwar als virtual deklariert,
und wir hatten diese Methode in der ableitenden Klasse Studi passend überschrieben.
Damit haben wir aber nur zwei der drei Bedingungen für dynamische Polymorphie in C++
erfüllt. Wenn wir Objekte vom Typ Studi über eine normale Variable vom Typ Person
handhaben, haben wir immer noch statische Polymorphie - uns stehen also nur die Methoden
aus und in Person zur Verfügung.
Zusätzlich haben wir durch die Zuweisung p = s; das Objekt s in den Speicherbereich
von p "gequetscht". Dieses ist vom Typ Person und hat auch nur (Speicher-) Platz für
Elemente dieses Typs. Alles andere wird bei der Zuweisung "abgeschnitten", d.h. p ist
immer noch ein Objekt vom Typ Person, der zusätzliche Rest aus Studi fehlt ...
Wir könnten das durch Pointer oder Referenzen heilen:
// Variante mit Basisklassen-Pointer
Student s("Heinz", 10.0);
Person *p;
p =&s;
cout <<"Objekt s (Student): "<< s.toString() << endl;
cout <<"Objekt p (Person): "<< p->toString() << endl;
Anmerkung: Der Operator -> ist die zusammengefasste Dereferenzierung des Pointers und
der nachfolgende Zugriff auf Methoden oder Attribute. Man könnte also entsprechend auch
(*p).toString() statt p->toString() schreiben.
// Variante mit Basisklassen-Referenz
Student s("Heinz", 10.0);
Person &p = s;
cout <<"Objekt s (Student): "<< s.toString() << endl;
cout <<"Objekt p (Person): "<< p.toString() << endl;
Erst damit erfüllen wir die dritte Bedingung und haben echte dynamische Polymorphie in C++.
Anmerkungen zu Polymorphie in C++
Gestaltung der API:
Zum Überschreiben gedachte Methoden als virtuell deklarieren
Nicht virtuelle Methoden aus der Basisklasse nicht überschreiben
Trennung von Deklaration und Implementierung:
Deklaration als virtuelle Funktion nur im Deklarationsteil
Keine Wiederholung im Implementierungsteil (analog zu Defaultwerten)
"Virtualität vererbt sich":
Virtuelle Funktionen sind virtuell in der Vererbungshierarchie hinab ab
der ersten Deklaration als virtuell
Virtualität ist "teuer": Es muss eine Tabelle aller virtuellen Funktionen aufgebaut werden und zur
Laufzeit geprüft werden, welche Funktion genommen werden soll
Mehrfachvererbung in C++
classHiWi:public Student, public Angestellter {...};
Hinweis Speicherlayout ...
Problem 1: Gleichnamige Methoden aus Basisklassen geerbt
classAngestellter:virtualpublic Person {...};
classStudent:virtualpublic Person {...};
classHiWi:public Student, public Angestellter {...};
Person ist jetzt eine virtuelle Basisklasse
Auswirkungen erst in Klasse HiWi
Dadurch sind gemeinsam genutzte Anteile nur einfach vorhanden
Student s("Heinz", 10.0); // wie vorher: nur EIN name-Feld
Angestellter a("Holger", 80.5); // wie vorher: nur EIN name-Feld
HiWi h("Anne", 23.0, 40.0); // jetzt auch nur EIN name-Feld
Virtuelle Ableitung: Potentiell Konflikte zwischen Konstruktoren!
Gemeinsam geerbtes Attribut nur noch einmal vorhanden
Konstruktoren werden nacheinander aufgerufen, alle wollen das
gemeinsame Attribut initialisieren (durch Aufruf des Konstruktors der
jeweiligen Basisklasse)
Zuletzt aufgerufener Konstruktor würde "gewinnen"
Deshalb gibt es bei virtueller Ableitung folgende Sonderregeln:
Für virtuelle Basisklassen ist Mechanismus des Weiterreichens von
Initialisierungswerten deaktiviert
Konstruktor einer virtuellen Basisklasse kann in Initialisierungsliste von
indirekten Unterklassen aufgerufen werden
Sonst wird der Defaultkonstruktor der virtuellen Basisklasse genutzt!
Mehrfachvererbung in C++ ist ein recht kompliziertes Thema
Warum ist die Möglichkeit dennoch nützlich?
In Java kann man nur von einer Klasse erben, aber viele Interfaces
implementieren. In C++ gibt es keine Interfaces ...
=> Interfaces mit abstrakten Klassen Interfaces simulieren
=> Mehrfachvererbung!
Tatsächlich dürfen Java-Interfaces mittlerweile auch Verhalten implementieren
und vererben, wodurch eine ähnliche Situation wie hier in C++ entsteht und es
ausgefeilte Regeln für die Konfliktauflösung braucht. Allerdings ist das in
Java auf Verhalten beschränkt, d.h. Attribute (Zustand) ist in Java-Interfaces
(noch) nicht erlaubt.
Wrap-Up
public-Vererbung in C++: Subklasse : public Superklasse
Keine gemeinsame Oberklasse wie Object, kein super
Verkettung von Operatoren und *struktoren
Abstrakte Klassen in C++
Statische und dynamische Polymorphie in C++
Methoden in Basisklasse als virtual deklarieren
Dyn. Polymorphie nur mittels Pointer/Referenzen
Slicing in C++ (bei Call-by-Value)
Konzept der Mehrfachvererbung
Problem bei rautenförmiger Vererbungsbeziehung: Attribute und Methoden mehrfach vorhanden
Virtuelle Basisklassen: Gemeinsam genutzte Attribute nur noch einfach vorhanden
Challenges
Destruktoren und Vererbung
Welcher Destruktor würde im folgenden Beispiel aufgerufen?!
In C++ können Funktionen über Funktions-Templates definiert werden. Dafür stellt man
ein template <typename T> mit einer Aufzählung typename T aller Template-Parameter der
Funktionsdefinition voran. In der Funktion kann man dann den Typ T wie einen normalen
anderen Typ nutzen.
Funktions-Templates sind (vollständig) spezialisierbar. Dazu wiederholt man die komplette
Funktionsdefinition (inkl. der dann leeren Template-Deklaration template <>) und legt alle
Template-Parameter über die Funktionssignatur fest. Alle Spezialisierungen müssen nach dem
eigentlichen ("primären") Funktions-Template formuliert werden.
Funktions-Templates sind überladbar mit "normalen" Funktionen und anderen Funktions-Templates.
Beim Aufruf kann man die Template-Parameter entweder selbst festlegen (über eine Auflistung der
Typen in spitzen Klammern hinter dem Funktionsnamen) oder den Compiler inferieren lassen. Dabei
wird die am besten "passende" Variante genutzt:
Zuerst die exakt passende normale Funktion,
dann ein passendes spezialisiertes Template (bei mehreren passenden spezialisierten
Templates das am meisten spezialisierte Template),
dann das allgemeine ("primäre") Template,
ansonsten die normale Funktion mit impliziten Casts.
In C++ können Klassen als Klassen-Templates definiert werden. Dafür stellt man ein
template <typename T> mit einer Aufzählung typename T aller Template-Parameter der
Klassendefinition voran. In der Klasse kann man dann den Typ T wie einen normalen
anderen Typ nutzen. Bei der Implementierung der Methoden außerhalb der Klassendeklaration
muss die Template-Deklaration (template <typename T>) wiederholt werden.
Klassen-Templates sind spezialisierbar (vollständig und partiell). Dazu wiederholt man
die komplette Klassendefinition (inkl. der Template-Deklaration template <typename T>)
und entfernt aus der Template-Deklaration alle Typen, die man konkret festlegen möchte.
Hinter dem Klassennamen werden dann in spitzen Klammern alle Typen (verbleibende
Typ-Parameter aus der Template-Deklaration sowie die konkretisierten Typen) in der
Reihenfolge angegeben, wie sie im primären Template vorkamen. Spezialisierungen müssen
nach dem eigentlichen ("primären") Klassen-Template formuliert werden.
Klassen- und Funktions-Templates können gemischt werden.
Bei der Instantiierung werden die Template-Parameter in spitzen Klammern hinter dem
Klassennamen spezifiziert.
Template-Parameter können einen konkreten (aufzählbaren) Typ haben (beispielsweise
int). Template-Parameter können Default-Werte haben.
Im Unterschied zu Java gibt es keine Type-Erasure. Der C++-Compiler stellt je instantiiertem
Template eine konkrete Funktion bzw. Klasse bereit! Im resultierenden Code sind also nur diejenigen
Funktionen und Klassen enthalten, die aus einem tatsächlichen Aufruf resultieren, das Template selbst
ist nicht im Code enthalten. Dies gilt auch für Bibliotheken, weshalb sich diese beiden Konzepte etwas
"quer liegen".
Bei expliziter Angabe der Typen beim Aufruf (cmp<int>) kann der Compiler automatisch casten.
Typ-Inferenz und explizite Bestimmung mischen
Compiler nutzt die vorgegebenen Typ-Parameter, ...
... inferiert die restlichen, und ...
... castet notfalls die Parameter
template<typename T1, typename T2, typename T3>void fkt(T2 a, T3 b, T2 c, int d) { ... }
intmain() {
fkt<void*, int>(42, "HUHU", 'a', 99);
}
=> In Parameterliste nicht vorkommende Typ-Parameter explizit angeben!
Reihenfolge der Angabe der Typen in spitzen Klammern beim Aufruf wie in
Template-Deklaration
Wenn ein Typ-Parameter nicht in der Parameterliste der Funktion vorkommt, ist
eine Inferenz für den Compiler unmöglich. Deshalb muss dieser Typ beim
Aufruf explizit in der Liste mit den spitzen Klammern angegeben werden!
Im Beispiel oben:
fkt<a, b, c>(...): a wäre der Typ für T1, b für T2, c für T3
Mit fkt<..., int>(...) beim Aufruf wird T2 zu int und damit für
Parameter c der Char als int interpretiert (T3 wird inferiert)
Ohne <..., int> beim Aufruf gibt es ein Problem beim Erkennen von T2: int vs. char (a=42, c='a') ...
Typ-Inferenz funktioniert nicht immer!
template<typename T>T zero() {
return0;
}
intmain() {
int x, y;
x = zero(); // Fehler: couldn't deduce template parameter 'T'
y = zero<int>(); // korrekter Aufruf
}
Die Funktion hat keine Parameter - der Compiler hat also keine Chance, den Typ T zu
inferieren. In diesem Fall muss der Typ beim Aufruf explizit angegeben werden.
Spezialisierung von Funktions-Templates
// Primaeres Template
template<typename T>bool cmp(const T &a, const T &b) {
return a<b;
}
Spezialisierte Templates nach "primärem" Template definieren
Achtung: Reihenfolge der Deklaration/Definition ist wichtig. Immer zuerst das
allgemeine ("primäre") Template definieren, danach dann die Spezialisierungen!
Anderenfalls gibt es "seltsame" Fehlermeldungen vom Compiler oder sogar seltsames
Verhalten.
Achtung: Im Unterschied zu Klassen-Templates können Funktions-Templates nur
vollständig spezialisiert werden (d.h. bei mehreren Template-Parametern müssen
dann alle Template-Parameter konkret spezifiziert werden)!
Anmerkung: Die Angabe der Typen in spitzen Klammern nach dem Funktionsnamen ist
freiwillig, so lange alle Typ-Parameter in der Parameterliste der Funktion auftauchen.
Man könnte die obige Spezialisierung also auch so schreiben (cmp( statt cmp<int>():
Alternativ: Überladen der Funktions-Templates mit normalen Funktionen
Überladen mit normalen Funktionen funktioniert wie bei spezialisierten
Templates, d.h. auch hier zuerst das primäre Template definieren, danach
eventuelle Spezialisierungen und danach Überladungen mit normalen Funktionen.
Allerdings gibt es Unterschiede für eventuell nötige Typumwandlungen der
Parameter beim Aufruf der Funktionen:
In gewöhnlichen Funktionen sind automatische Typumwandlungen möglich
In (spezialisierten) Templates sind keine automatischen Typumwandlungen
erlaubt (sofern man mit Typ-Inferenz arbeitet, d.h. die Template-Typen
nicht beim Aufruf explizit angegeben werden)
template<typename T>bool cmp(T a, T b) {
return a<b;
}
boolcmp(int a, int b) {
return abs(a)<abs(b);
}
intmain() {
cmp(3, 6); // true: überladene normale Funktion
cmp(3, 3.4); // FALSE: überladene normale Funktion (Cast)
cmp<int>(3, 3.4); // FALSE: Template
}
Aufruf: Compiler nimmt die am besten "passende" Variante:
Keine Template-Parameter beim Aufruf angegeben (d.h. Typ-Inferenz):
Zuerst exakt passende normale Funktion,
dann passendes spezialisiertes Template (bei mehreren passenden spezialisierten
Templates das am meisten spezialisierte Template, ohne Casts),
dann das allgemeine ("primäre") Template (ohne Casts),
ansonsten normale Funktion mit impliziten Casts
Template-Parameter beim Aufruf angegeben: am besten passendes Template
Hinweis: Durch reine Deklaration von Spezialisierungen (d.h. ohne die
entsprechende Implementierung) lässt sich die Instantiierung einer
Templatefunktion für bestimmte Typen verhindern. Beispiel:
template<typename T>bool cmp(const T &a, const T &b) {
return a<b;
}
template<>bool cmp<int>(constint&a, constint&b);
Damit könnte man die cmp-Funktion nicht mehr für int benutzen (Compiler-
bzw. Linker-Fehler).
Hinweis:
Template-Parameter innerhalb von Template-Parametern verursachen bei den
schließenden spitzen Klammern u.U. Parser-Probleme. Diese lassen sich durch
ein extra Leerzeichen (hat sonst keine Funktion!) umgehen: Statt
vector<vector<T>> xyField; besser vector<vector<T> > xyField;
schreiben.
Template-Parameter gehören zur Schnittstelle und müssen bei der Instantiierung
angegeben werden. Matrix m; würde im obigen Beispiel nicht funktionieren.
Template-Parameter können neben Typen auch konstante Werte (Basisdatentypen,
außer float und double) sein. Innerhalb der Klasse Matrix kann auf
die Werte von rows und cols zugegriffen werden.
Achtung: rows und cols sind keine Attribute der Klasse Matrix!
Hinweis: Konstanten als Template-Parameter funktioniert auch bei
Funktions-Templates.
template<unsigned u>structMyClass {
enum { X = u };
};
cout << MyClass<2>::X << endl;
Konstante muss explizit übergeben werden
Wert muss eine zur Compile-Zeit bekannte Konstante sein
Nur aufzählbare Typen für derartige Konstanten erlaubt
(z.B. int, aber nicht double)
Anmerkung:
Durch Konstruktion mit dem anonymen enum in der Klasse MyClass wird der
Wert der Konstanten "gespeichert" und kann später (von außen) abgefragt werden.
(Innerhalb der Klasse selbst können Sie natürlich jederzeit auf den Wert von
u zugreifen.)
Wollte man dies über ein normales Attribut erledigen, sähe der Code deutlich
komplexer aus (zusätzlich zur oben gezeigten Variante mit dem enum einmal
als statisches Attribut Y und einmal als "normales" Attribut Z):
Falls man mit :: zugreifen wollte, müsste das Attribut static sein und
entsprechend außerhalb der Klasse initialisiert werden. Für ein "normales"
Attribut braucht man dann einen extra Konstruktor und muss den Aufruf dann
extra klammern: MyClass<2>().Z statt MyClass<2>.Z.
Die Variante mit dem enum werden Sie entsprechend sehr häufig in C++ finden!
Leere spitze Klammern bei Klassen-Templates mit Default-Parameter Pflicht!
Hinweis: Defaults für Template-Parameter waren zunächst nur für Klassen-Templates
erlaubt. Seit C++11 kann man solche Defaults auch bei Funktions-Templates
einsetzen.
ACHTUNG: Implementierung außerhalb der Klasse: Bei den Methoden des voll
spezialisierten Templates das "template<>" weglassen! Alles andere ist
ein Syntax-Fehler.
Der Grund dafür bleibt ein Geheimnis des C++-Standards ... ;-)
Regel: Das am meisten spezialisierte Template wird verwendet.
Mischen von Klassen- und Funktions-Templates
Sie können innerhalb eines Klassen-Templates auch ein Funktions-Template
(Methoden-Template) definieren. Bei der Implementierung außerhalb der
Klasse müssen entsprechend alle Template-Deklarationen wiederholt werden!
template<typename T, unsigned n>classArray {
public:enum { size = n };
template<typename C >void copy_from(const C &c);
private: T data[n];
};
template<typename T, unsigned n>template<typename C>void Array<T,n>::copy_from(const C &c) { ... }
Templates: Java vs. C++
Templates sind nur Schablonen!
Die Definitionen der Templates werden nicht in den Object-Code kompiliert!
Erst bei der Instantiierung von Templates wird durch den Compiler eine
passende Instanz erzeugt und im Object-Code zur Nutzung abgelegt.
Unterschied zu Java
C++: Für jeden Aufruf/Typ eine passende Instanz (!)
Java: Nur eine Klasse mit gemeinsamen Obertyp
Offener Code: Templates im .h-File implementieren!
Ohne die Template-Definition kann der Compiler keine Instanzen anlegen!
Bibliotheken und Templates passen nicht recht
Templates werden immer bei der ersten Verwendung instantiiert! Wird ein
Template nicht im zu kompilierenden Code verwendet, dann generiert der
Compiler auch nichts, was man in den Objektdateien finden könnte ...
Nur instantiierte Templates sind in einer dynamischen/statischen
Bibliothek enthalten!
Falls Einsatz nur für wenige Typen vorgesehen
=> Explizite Instantiierung:
Entweder mit "template": template class Matrix<5,5>;, oder
mit "typedef": typedef Matrix<5,5> Matrix5x5;
=> Dann aber nur in exakt diesen Versionen in der Bibliothek
enthalten und entsprechend nur so nutzbar (sofern die
Template-Definition nicht zur Verfügung steht)
Wrap-Up
Generische Programmierung (Funktions-Templates)
template <typename T> der Funktionsdefinition voranstellen
Funktions-Templates sind spezialisierbar und überladbar
Aufruf: Compiler nimmt die am besten "passende" Variante
Generische Programmierung (Klassen-Templates)
Funktionsweise analog zu Funktions-Templates
Bei Implementierung außerhalb der Deklaration: Template-Deklaration mitführen!
Klassen-Templates lassen sich partiell spezialisieren
Compiler stellt je instantiiertes Template eine konkrete Funktion/Klasse bereit
Challenges
Funktions-Templates
Wie kann eine Funktion als Funktions-Template definiert werden?
Wie wird ein Funktions-Template benutzt, d.h. wie wird es aufgerufen?
Worin liegt der Unterschied zu Java?
Wie kann ein Funktions-Template spezialisiert werden? Was ist dabei zu beachten?
Kann man Funktions-Templates überladen?
Worin liegt der Unterschied zwischen einem spezialisierten Funktions-Template
und einem überladenen Funktions-Template?
Funktions-Templates in C++
Schreiben Sie in C++ eine Funktion invert(), die zu einem übergebenen
numerischen Wert den negativen Wert zurückgibt. Realisieren Sie die
Funktion als Funktions-Template.
Testen Sie Ihre Implementierung mit den Typen int, double und short.
Spezialisieren Sie das eben erstellte Funktions-Template, so daß invert()
auch für string aufgerufen werden kann. In diesem Fall soll der String
umgekehrt werden und zurückgeliefert werden, d.h. für die Eingabe von
"abcde" soll "edcba" zurück geliefert werden.
Testen Sie die Funktionen wiederum mit int, double, short und nun
auch string.
Schreiben Sie ein weiteres Funktions-Template string getType(T t) mit
Template-Spezialisierungen, die den Typ des Parameters t als String
zurückgibt. Für nicht bekannte Typen soll der String "unbekannter Typ"
zurückgeliefert werden.
Implementieren Sie mind. 3 Spezialisierungen.
Hinweis: Verwenden Sie an dieser Stelle keine explizite Typüberprüfung (in
der Funktion)! Realisieren Sie die Reaktion auf unterschiedliche
Parametertypen ausschließlich mit Hilfe von Templates.
Erklären Sie folgenden Code-Schnipsel:
// Definition
template<typename T2, typename T1>void f(T1 a, T2 b, int c) {
...
}
// Aufruf
f<char*>(99, "Hello World", 42);
Erklären Sie nun folgenden Code-Schnipsel:
template<typename T2, typename T1, typename T3>void fkt(T2 a, T3 b, T2 c, int d) { ... }
voidfkt(int a, int b, int c, int d) { ... }
intmain() {
fkt<int, void*>(42, "HUHU", 'a', 99);
fkt<int, int, int>(1,2,3,4);
fkt(1,2,3,4);
}
Klassen-Templates und partielle Spezialisierung
Definieren Sie ein Klassen-Template zum Umgang mit Vektoren. Diese sollen
Elemente eines unbestimmten Typs (Typ-Variable) aufnehmen. Die Größe des
Vektors soll ebenfalls als Typ-Variable in die Template-Definition eingehen.
Definieren Sie den operator* für das Skalarprodukt von zwei Vektoren.
Erstellen Sie eine partielle Spezialisierung des Klassen-Templates zur
Repräsentation von einstelligen Vektoren (Skalare).
Schreiben Sie ein main()-Funktion, instantiieren einige Vektoren und berechnen
Sie die Skalarprodukte.
Beispiel aus dem echten Leben
Erklären Sie das folgende Beispiel eines Klassen-Templates RingBuffer: