IFM 3.1 (PO23) / IFM 5.21 (PO18): Compilerbau (Winter 2024/25)

Kursbeschreibung

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

  1. Lexikalische Analyse: Scanner/Lexer
    • Reguläre Sprachen
    • Generierung mit ANTLR
  2. Syntaxanalyse: Parser
    • Kontextfreie Grammatiken (CFG)
    • LL-Parser (Top-Down-Parser)
    • Generierung mit ANTLR
  3. Semantische Analyse: Attributierte Grammatiken und Symboltabellen
    • Namen und Scopes
    • Typen, Klassen, Polymorphie
  4. Zwischencode: Intermediate Representation (IR), LLVM-IR
  5. Backend:
    • LLVM
    • Interpreter: AST-Traversierung
  6. C++ als zu verarbeitende Programmiersprache

Team

Kursformat

Vorlesung (2 SWS) Praktikum (2 SWS)
Mi, 08:00 - 09:30 Uhr (online) S3, G1: Di, 11:30 - 13:00 Uhr (online/Präsenz J101)
(Carsten: Flipped Classroom, BC: Vorlesung) S3, G2: Fr, 14:00 - 15:30 Uhr (online/Präsenz J101)
S3, G3: Mo, 09:15 - 10:45 Uhr (online/Präsenz J101)
(Carsten: online, BC: Präsenz)

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:

  1. 09:45 - 10:30 Uhr: alle Studierenden, deren Nachname mit den Buchstaben "A" bis "L" beginnt,
  2. 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.

Hier finden Sie einen abonnierbaren Google Kalender IFM 3.1 CB (PO23, 3. Semester) mit allen Terminen der Veranstaltung zum Einbinden in Ihre Kalender-App.

Abgabe der Übungsblätter jeweils Montag bis 09:00 Uhr im ILIAS. Vorstellung der Lösung im jeweiligen Praktikum in der Abgabewoche.

Monat Woche Vorlesung Lead Abgabe Aufgabenblatt Vorstellung Praktikum
Oktober 41 09.: Orga (Zoom); Überblick, Sprachen, Anwendungen Carsten, BC
42 16.: Reguläre Sprachen BC
43 23.: CFG BC 21.: B01 Reguläre Sprachen 21. / 22. / 25. (BC, Präsenz)
44 29.: 18:00 - 19:30 Uhr (online): Edmonton I: ANTLR + Live-Coding Edmonton
44 30.: Lexer mit ANTLR, Parser mit ANTLR Carsten
November 45 06.: Dienstbesprechung 04.: B02 CFG 04. 07. 08:00 / 05. / 08. (BC, Präsenz)
46 13.: Überblick Symboltabellen, Symboltabellen: Scopes, Symboltabellen: Funktionen, Symboltabellen: Klassen Carsten 11.: B03 ANTLR 11. / 12. / 15. (Carsten, online)
47 20.: A-L: 09:45 - 10:30 Uhr, M-Z: 10:45 - 11:30 Uhr (B40): Parcoursprüfung: Station 1 ILIAS (Grammar, Lexing, Parsing), siehe Ankündigung 18.: B04 Semantische Analyse 18. / 19. / 22. (Carsten, online)
48 26.: 18:00 - 19:30 Uhr (online): Edmonton II: Vorträge Mindener Projekte Minden
48 27.: Überblick Zwischencode, Überblick Backend (LLVM) BC, Carsten
Dezember 49 03.: 18:00 - 19:30 Uhr (online): Edmonton III: Vorträge Edmontoner Projekte Edmonton
49 04.: AST-basierte Interpreter 1, AST-basierte Interpreter 2 Carsten
50 11.: C++ I: Basics, Pointer & Referenzen, Klassen, Big 3 Carsten 09.: B05 Interpreter 09. / 10. / 13. (Carsten, online)
51 18.: Projektwoche Semester 1+3 C++ II: Operatoren, Vererbung & Polymorphie, Templates
52 25.: Weihnachtspause
Januar 01 01.: Weihnachtspause
02 08.: Sprechstunde/Freies Arbeiten Carsten 06.: B06 C++ 06. / 07. / 10. (Carsten, online)
03 15.: Freies Arbeiten/Puffer
04 22.: Parcoursprüfung: Station 2 B07 (VL- und Praktika-Slots, siehe Ankündigung) Carsten, BC 20.: B07 Mini-Projekt 20. / 21. / 24. (15:00-16:30) (Carsten/BC, online)
(Prüfungsphase I) 05 30.: Feedback-Gespräche (15:30 - 18:00 Uhr, online)
(Prüfungsphase II) 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.

Hier finden Sie einen abonnierbaren Google Kalender IFM 5.21 CB (PO18, 5. Semester) mit allen Terminen der Veranstaltung zum Einbinden in Ihre Kalender-App.

Abgabe der Übungsblätter jeweils Mittwoch bis 09:00 Uhr im ILIAS. Vorstellung der Lösung im jeweiligen Praktikum in der Abgabewoche.

Monat Tag Vorlesung Lead Abgabe Aufgabenblatt & Vorstellung Praktikum
Oktober 09. Orga (Zoom); Überblick, Sprachen, Anwendungen Carsten, BC
16. Reguläre Sprachen BC
23. CFG BC B01 Reguläre Sprachen (BC, online)
29. 18:00 - 19:30 Uhr (online): Edmonton I: ANTLR + Live-Coding Edmonton
30. Lexer mit ANTLR, Parser mit ANTLR Carsten 16:00 Uhr: B01 Reguläre Sprachen (BC, online)
November 06. Dienstbesprechung B02 CFG (BC, online)
13. Überblick Symboltabellen, Symboltabellen: Scopes, Symboltabellen: Funktionen, Symboltabellen: Klassen Carsten B03 ANTLR (Carsten, online)
20.
26. 18:00 - 19:30 Uhr (online): Edmonton II: Vorträge Mindener Projekte Minden (S5)
27. Überblick Zwischencode, Überblick Backend (LLVM) BC, Carsten Parcoursprüfung: Station 1: Vorstellung der Lösung von B04x (online, 3 Teams, siehe Ankündigung)
Dezember 03. 18:00 - 19:30 Uhr (online): Edmonton III: Vorträge Edmontoner Projekte Edmonton
04. AST-basierte Interpreter 1, AST-basierte Interpreter 2 Carsten Parcoursprüfung: Station 1: Vorstellung der Lösung von B04x (online, 2 Teams, siehe Ankündigung)
11. C++ I: Basics, Pointer & Referenzen, Klassen, Big 3 Carsten B05x Interpreter (Carsten, online)
18. Projektwoche Semester 1+3 C++ II: Operatoren, Vererbung & Polymorphie, Templates Feedback zu Station 1
25. Weihnachtspause
Januar 01. Weihnachtspause
08. Sprechstunde/Freies Arbeiten Carsten B06x C++ und dyn. Speicherverwaltung (Carsten, online)
15. Freies Arbeiten/Puffer
22. Parcoursprüfung: Station 2 B07x (VL- und Praktika-Slots, siehe Ankündigung) Carsten, BC B07x Mini-Projekt (Carsten/BC, online)
(Prüfungsphase I) 30. Feedback-Gespräche (14:00 - 15:30 Uhr, online)
(Prüfungsphase II) 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

    1. Mindestens 4 der Übungsblätter B01, B02, B03, B04, B05 und B06 erfolgreich bearbeitet, und
    2. 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:

    1. ILIAS-Test (einzeln, 20.11.)
    2. 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:

    1. 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

    1. Mindestens 4 der Übungsblätter B01, B02, B03, B04x, B05x und B06x erfolgreich bearbeitet, und
    2. 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:

    1. Vortrag (3er Team, 27.11. bzw. 04.12. im Praktikum): Vorstellung der Lösung von B04x
    2. 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:

    1. Mündliche Prüfung (individuell, ca. 45 Minuten, zweiter Prüfungszeitraum)

    Die Note der mündlichen Prüfung ergibt die Gesamtnote.

Materialien

  1. "Compilers: Principles, Techniques, and Tools". Aho, A. V. und Lam, M. S. und Sethi, R. und Ullman, J. D. and Bansal, S., Pearson India, 2023. ISBN 978-9-3570-5488-1. Online über die O'Reilly-Lernplattform.
  2. "Crafting Interpreters". Nystrom, R., Genever Benning, 2021. ISBN 978-0-9905829-3-9. Online.
  3. "The Definitive ANTLR 4 Reference". Parr, T., Pragmatic Bookshelf, 2014. ISBN 978-1-9343-5699-9. Online über die O'Reilly-Lernplattform.
  4. "Writing a C Compiler". Sandler, N., No Starch Press, 2024. ISBN 978-1-0981-8222-9. Online über die O'Reilly-Lernplattform.

Förderungen und Kooperationen

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.)

Subsections of IFM 3.1 (PO23) / IFM 5.21 (PO18): Compilerbau (Winter 2024/25)

Subsections of Überblick

Struktur eines Compilers

TL;DR

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.

Videos (YouTube)
Videos (HSBI-Medienportal)
Lernziele
  • (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

Compiler: Big Picture

Quelle: A Map of the Territory (mountain.png) by Bob Nystrom on Github.com (MIT)

Begriffe und Phasen

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 ';' ;
void stat() {
    switch (<<current token>>) {
        case ID : assign(); break;
        case IF : ifstat(); break;
        ...
        default : <<raise exception>>
    }
}
void assign() {
    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.

{
    int x = 42;
    {
        int x = 7;
        x += 3;    // ???
    }
}
                                              = {type: real, loc: tmp1}
sp = 100;                                    / \
                                            /   \
                                          sp     inttofloat
                                  {type: real,       |
                                   loc: var b}      100

Zwischencode generieren

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.

                 = {type: real, loc: tmp1}
                / \
               /   \
             sp     inttofloat
     {type: real,       |
      loc: var b}      100

=> t1 = inttofloat(100)

Code optimieren

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
     ;

Wrap-Up

  • Compiler übersetzen Text in ein anderes Format

  • Typische Phasen:

    1. Lexikalische Analyse
    2. Syntaxanalyse
    3. Semantische Analyse
    4. Generierung von Zwischencode
    5. Optimierung des (Zwischen-) Codes
    6. Codegenerierung
Quellen

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.

Videos (YouTube)
Videos (HSBI-Medienportal)
Lernziele
  • (K1) Verschiedene Beispiele für verschiedene Programmiersprachen und Paradigmen

99 Bottles of Beer

99 bottles of beer on the wall, 99 bottles of beer. Take one down and pass it around, 98 bottles of beer on the wall.

98 bottles of beer on the wall, 98 bottles of beer. Take one down and pass it around, 97 bottles of beer on the wall.

[...]

2 bottles of beer on the wall, 2 bottles of beer. Take one down and pass it around, 1 bottle of beer on the wall.

1 bottle of beer on the wall, 1 bottle of beer. Take one down and pass it around, no more bottles of beer on the wall.

No more bottles of beer on the wall, no more bottles of beer. Go to the store and buy some more, 99 bottles of beer on the wall.

Quelle: nach "Lyrics of the song 99 Bottles of Beer" on 99-bottles-of-beer.net

Imperativ, Hardwarenah: C

 #define MAXBEER (99)
 void chug(int beers);
 main() {
    register beers;
    for(beers = MAXBEER; beers; chug(beers--))  puts("");
    puts("\nTime to buy more beer!\n");
 }
 void chug(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); else strcpy(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

class bottles {
    public static void main(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).

Quelle: "Language Prolog" by M@ on 99-bottles-of-beer.net

  • Deklarativ

  • Logisch: Definition von Fakten und Regeln; eingebautes Beweissystem

  • Einsatz: Theorem-Beweisen, Natural Language Programming (NLP), Expertensysteme, ...

Funktional: Haskell

bottles 0 = "no more bottles"
bottles 1 = "1 bottle"
bottles n = show n ++ " bottles"

verse 0   = "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]

Quelle: "Language Haskell" by Iavor on 99-bottles-of-beer.net

  • Deklarativ

  • Funktional

  • Lazy, pure

  • Statisches Typsystem

  • Typinferenz

  • Algebraische Datentypen, Patternmatching

  • Einsatz: Compiler, DSL, Forschung

Brainfuck

Quelle: Screenshot of "Language Brainfuck" by Michal Wojciech Tarnowski on 99-bottles-of-beer.net

  • Imperativ

  • Feldbasiert (analog zum Band der Turingmaschine)

  • 8 Befehle: Zeiger und Zellen inkrementieren/dekrementieren, Aus- und Eingabe, Sprungbefehle

Programmiersprache 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;
wuppie(4);
  • Die Sprache "Lox" finden Sie hier: craftinginterpreters.com/the-lox-language.html

  • C-ähnliche Syntax

  • 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

  • Berücksichtigung von unterschiedlichen

    • Sprachkonzepten (Programmierparadigmen)
    • Typ-Systemen
    • Speicherverwaltungsstrategien
    • Abarbeitungsstrategien
Quellen

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
Videos (YouTube)
Videos (HSBI-Medienportal)
Lernziele
  • (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.

... dieses Zwischenformat erzeugt, ...

{"blocks":[{"t":"Para","c":[{"t":"Str","c":"Dies"},{"t":"Space"},
           {"t":"Str","c":"ist"},{"t":"Space"},{"t":"Str","c":"ein"},
           {"t":"Space"},{"t":"Str","c":"Satz"},{"t":"Space"},
           {"t":"Str","c":"mit"}]},
           {"t":"BulletList","c":[[{"t":"Plain","c":[{"t":"Str","c":"einem"},{"t":"Space"},{"t":"Str","c":"Stichpunkt,"},{"t":"Space"},{"t":"Str","c":"und"}]}],[{"t":"Plain","c":[{"t":"Str","c":"einem"},{"t":"Space"},{"t":"Str","c":"zweiten"},{"t":"Space"},{"t":"Str","c":"Stichpunkt."}]}]]}],"pandoc-api-version":[1,17,0,4],"meta":{}}

... und daraus schließlich dieser TeX-Code.

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?
    • Statische Codeanalyse: Dead code elimination
  • Language-theoretic Security: LangSec
  • 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.

Statische Analyse, Type-Checking und Linter

Als Startpunkt für eigene Ideen. Oder Verbessern/Erweitern der Projekte ...

DSL (Domain Specific Language)

Hier noch ein Framework, welches auf das Erstellen von DSL spezialisiert ist:

Konverter von X nach Y

Odds and Ends

Als weitere Anregung: Themen der Mini-Projekte im W17

  • Java2UMLet
  • JavaDoc-to-Markdown
  • Validierung und Übersetzung von Google Protocol Buffers v3 nach JSON
  • svg2tikz
  • SwaggerLang -- Schreiben wie im Tagebuch
  • Markdown zu LaTeX
  • JavaDocToLaTeX
  • MySQL2REDIS-Parser

Wrap-Up

  • Compiler übersetzen formalen Text in ein anderes Format

  • Nicht alle Stufen kommen immer vor => unterschiedliche Anwendungen

    • "Echte" Compiler: Sourcecode nach Maschinencode
    • Interpreter: Interaktive Ausführung
    • Virtuelle Maschinen als Zwischending zwischen Compiler und Interpreter
    • Transpiler: formaler Text nach formalem Text
    • Analysetools: Parsen den Sourcecode, werten die Strukturen aus
Quellen

Subsections of Lexikalische Analyse

Reguläre Sprachen, Ausdrucksstärke

Motivation

Was muss ein Compiler wohl als erstes tun?

Hier entsteht ein Tafelbild.

Themen für heute

  • Endliche Automaten
  • Reguläre Sprachen
  • Lexer

Endliche Automaten

Alphabete

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:

  • Basis: $\delta^{\ast}(q, \epsilon) = q\ \forall q \in Q$

  • Induktion: $\delta^{\ast}(q, a_1, \ldots, a_n) = \delta(\delta^{\ast}(q, a_1, \ldots , a_{n-1}), a_n)$

Def.: Ein DFA akzeptiert ein Wort $w \in \Sigma^{\ast}$ genau dann, wenn $\delta^{\ast}(q_0, w) \in F.$

Beispiel

Hier entsteht ein Tafelbild.

Nichtdeterministische endliche Automaten

Def.: Ein nichtdeterministischer endlicher Automat (NFA) 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$

  • $q_0 \in Q$ : der Startzustand

  • $F \subseteq Q$ : die Menge der Endzustände

Akzeptierte Sprachen

Def.: Sei A ein DFA oder ein NFA. Dann ist L(A) die von A akzeptierte Sprache, d. h.

$L(A) = \lbrace \text{Wörter}\ w\ |\ \delta^*(q_0, w) \in F \rbrace$

Wozu NFAs im Compilerbau?

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
Quellen
Lernziele
  • (K1) DFAs
  • (K1) Reguläre Ausdrücke
  • (K1) Reguläre Grammatiken
  • (K2) Zusammenhänge und Gesetzmäßigkeiten bzgl. der oben genannten Konstrukte
  • (K3) DFAs, reguläre Ausdrücke, reguläre Grammatiken entwickeln
  • (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.

Videos (HSBI-Medienportal)
Lernziele
  • (K3) Lexer-Regeln in ANTLR formulieren und einsetzen
  • (K2) Verhalten des Lexers: längste Matches, Reihenfolge
  • (K3) Nutzung von Lexer-Aktionen

Lexer: Erzeugen eines Token-Stroms aus einem Zeichenstrom

Aus dem Eingabe(-quell-)text

/* demo */
a= [5  , 6]     ;

erstellt der Lexer (oder auch Scanner genannt) eine Sequenz von Token:

<ID, "a"> <ASSIGN> <LBRACK> <NUM, 5> <COMMA> <NUM, 6> <RBRACK> <SEMICOL>
  • Input: Zeichenstrom (Eingabedatei o.ä.)
  • 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

  1. 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!

  2. Operatoren

    • Ein eigenes Token für jeden Operator, oder
    • Gemeinsames Token für jede Operatoren-Klasse
  3. Bezeichner: Ein gemeinsames Token für alle Namen

  4. 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.

  5. String-Literale: Ein gemeinsames Token

  6. Komma, Semikolon, Klammern, ...: Je ein eigenes Token

  7. 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.

Hello World

grammar Hello;

start       : 'hello' GREETING ;

GREETING    : [a-zA-Z]+ ;
WHITESPACE  : [ \t\n]+ -> skip ;

Hinweis zur Grammatik (Regeln)

  • start ist eine Parser-Regel => Eine Parser-Regel pro Grammatik wird benötigt, damit man den generierten Parser am Ende auch starten kann ...
  • Die anderen beiden Regeln (mit großem Anfangsbuchstaben) aus der obigen Grammatik zählen zum Lexer

ANTLR einrichten

  • Aktuelle Version herunterladen: antlr.org, für Java als Zielsprache: "Complete ANTLR 4.x Java binaries jar"
  • CLASSPATH setzen: export CLASSPATH=".:/<pathToJar>/antlr-4.11.1-complete.jar:$CLASSPATH"
  • Aliase einrichten (.bashrc):
    • alias antlr='java org.antlr.v4.Tool'
    • alias grun='java org.antlr.v4.gui.TestRig'
  • Alternativ über den Python-Installer: pip install antlr4-tools
  • Im Web ohne lokale Installation: ANTLR Lab

(vgl. github.com/antlr/antlr4/blob/master/doc/getting-started.md)

"Hello World" übersetzen und ausführen

  1. Grammatik übersetzen und Code generieren: antlr Hello.g4
  2. Java-Code kompilieren: javac *.java
  3. Lexer ausführen:
    • grun Hello start -tokens (Grammatik "Hello", Startregel "start")

    • Alternativ mit kleinem Java-Programm:

      import org.antlr.v4.runtime.*;
      
      public class Main {
          public static void main(String[] args) throws Exception {
              Lexer l = new HelloLexer(CharStreams.fromStream(System.in));
              Token t = l.nextToken();
              while (t.getType() != Token.EOF) {
                  System.out.println(t);
                  t = l.nextToken();
              }
          }
      }

Generierte Dateien und Klassen

Nach dem Übersetzen finden sich folgende Dateien und Klassen vor:

.
├── bin
│   ├── HelloBaseListener.class
│   ├── HelloBaseVisitor.class
│   ├── HelloLexer.class
│   ├── HelloListener.class
│   ├── HelloParser.class
│   ├── HelloParser$RContext.class
│   ├── HelloVisitor.class
│   └── Main.class
├── Hello.g4
└── src
    ├── HelloBaseListener.java
    ├── HelloBaseVisitor.java
    ├── HelloLexer.java
    ├── HelloLexer.tokens
    ├── HelloListener.java
    ├── HelloParser.java
    ├── Hello.tokens
    ├── HelloVisitor.java
    └── Main.java

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

$ grun Hello start -tokens
hello world
<EOF>
[@0,0:4='hello',<'hello'>,1:0]
[@1,6:10='world',<GREETING>,1:6]
[@2,12:11='<EOF>',<EOF>,2:0]

Die erkannten Token werden jeweils auf einer eigenen Zeile ausgegeben.

  • @0: Das erste Token (fortlaufend nummeriert, beginnend mit 0)
  • 0:4: Das Token umfasst die Zeichen 0 bis 4 im Eingabestrom
  • ='hello': Das gefundene Lexem (Wert des Tokens)
  • <'hello'>: Das Token (Name/Typ des Tokens)
  • 1:0: Das Token wurde in Zeile 1 gefunden (Start der Nummerierung mit Zeile 1), und startet in dieser Zeile an Position 0

Entsprechend bekommt man mit

$ grun Hello start -tokens
hello
  world

<EOF>
[@0,0:4='hello',<'hello'>,1:0]
[@1,8:12='world',<GREETING>,2:2]
[@2,15:14='<EOF>',<EOF>,4:0]

ANTLR-Grammatik für die Lexer-Generierung

  • Start der Grammatik mit dem Namen "XYZ" mit

    grammar XYZ;
    

    oder (nur Lexer)

    lexer grammar XYZ;
    
  • Token und Lexer-Regeln starten mit großen Anfangsbuchstaben (Ausblick: Parser-Regeln starten mit kleinen Anfangsbuchstaben)

    Format: TokenName : Alternative1 | ... | AlternativeN ;

    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:

    import, fragment, lexer, parser, grammar, returns, locals, throws, catch, finally, mode, options, tokens
    

    Anmerkung: rule ist zwar kein Schlüsselwort, wird aber als Methodenname bei der Codegenerierung verwendet. => Wie ein Schlüsselwort behandeln!

(vgl. github.com/antlr/antlr4/blob/master/doc/lexicon.md)

Greedy und Non-greedy Lexer-Regeln

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 Empfehlung ist, non-greedy Lexer-Regeln nur sparsam einzusetzen (vgl. github.com/antlr/antlr4/blob/master/doc/wildcard.md).

Verhalten des Lexers: 1. Längster Match

Primäres Ziel: Erkennen der längsten Zeichenkette

CHARS   : [a-z]+ ;
DIGITS  : [0-9]+ ;
FOO     : [a-z]+ [0-9]+ ;

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.

(vgl. github.com/antlr/antlr4/blob/master/doc/wildcard.md)

Attribute und Aktionen

grammar Demo;

@header {
import java.util.*;
}

@members {
String s = "";
}

start   : TYPE ID '=' INT ';' ;

TYPE    : ('int' | 'float') {s = getText();} ;
INT     : [0-9]+            {System.out.println(s+":"+Integer.valueOf(getText()));};
ID      : [a-z]+            {setText(String.valueOf(getText().charAt(0)));} ;
WS      : [ \t\n]+ -> skip ;

Attribute bei Token (Auswahl)

Token haben Attribute, die man abfragen kann. Dies umfasst u.a. folgende Felder:

  • text: Das gefundene Lexem als String
  • type: Der Token-Typ als Integer
  • index: Das wievielte Token (als Integer)

(vgl. github.com/antlr/antlr4/blob/master/doc/actions.md)

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.

(vgl. github.com/antlr/antlr4/blob/master/doc/grammars.md)

Wrap-Up

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);

Erstellen Sie für diese fiktive Sprache einen Lexer mit ANTLR. Die genauere Sprachdefinition finden Sie unter craftinginterpreters.com/the-lox-language.html.

Pig-Latin mit ANTLR-Lexer

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?

Heinz 030 5346 983
Kalle +49 30 1234 567
Lina +49.571.8385-255
Rosi (0571) 8385-268

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.

Übungsblätter/Aufgaben
Quellen

Syntaktische Analyse

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.

Subsections of Syntaktische Analyse

CFG

Wiederholung

Endliche Automaten, reguläre Ausdrücke, reguläre Grammatiken, reguläre Sprachen

  • Wie sind DFAs und NFAs definiert?
  • Was sind reguläre Ausdrücke?
  • Was sind formale und reguläre Grammatiken?
  • 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:

Definition eines PDAs

Definition eines PDAs

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| \leq k: {First}_k (a) = \lbrace a \rbrace$
  • $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$

Algorithmus zur Generierung einer LL-Parsertabelle

Algorithmus zur Generierung einer LL-Parsertabelle

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

Algorithmus zum tabellengesteuerten LL-Parsen

Algorithmus zum tabellengesteuerten LL-Parsen

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.
Quellen
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.

Videos (HSBI-Medienportal)
Lernziele
  • (K2) Aufbau der Parser-Regeln
  • (K3) Alternativen und optionale/mehrfache Regelteile in Parser-Regeln
  • (K3) Vorrang von Alternativen (bei Mehrdeutigkeiten)
  • (K3) Benannte Alternativen und Regel-Elemente
  • (K2) Aufbau des Parse-Tree
  • (K3) Umgang mit Kontext-Objekten
  • (K3) Traversierung des Parse-Tree mit den generierten Listenern oder Visitors

Hello World

grammar Hello;

start : stmt* ;

stmt  : ID '=' expr ';' | expr ';' ;

expr  : term ('+' term)* ;
term  : atom ('*' atom)* ;

atom  : ID | NUM ;

ID    : [a-z][a-zA-Z]* ;
NUM   : [0-9]+ ;
WS    : [ \t\n]+ -> skip ;

Starten des Parsers

  1. Grammatik übersetzen und Code generieren: antlr Hello.g4
  2. Java-Code kompilieren: javac *.java
  3. Parser ausführen:
    • grun Hello start -tree oder grun Hello start -gui (Grammatik "Hello", Startregel "start")

    • Alternativ mit kleinem Java-Programm:

      import org.antlr.v4.runtime.CharStreams;
      import org.antlr.v4.runtime.CommonTokenStream;
      import org.antlr.v4.runtime.tree.ParseTree;
      
      public class Main {
          public static void main(String[] args) throws Exception {
              HelloLexer lexer = new HelloLexer(CharStreams.fromStream(System.in));
              CommonTokenStream tokens = new CommonTokenStream(lexer);
              HelloParser parser = new HelloParser(tokens);
      
              ParseTree tree = parser.start();  // Start-Regel
              System.out.println(tree.toStringTree(parser));
          }
      }

Startregeln

  • 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).

(vgl. github.com/antlr/antlr4/blob/master/doc/parser-rules.md)

Reihenfolge in Grammatik definiert Priorität

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.

(vgl. github.com/antlr/antlr4/blob/master/doc/parser-rules.md)

Expressions und Vorrang (Operatoren)

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:

expr  : expr '*' expr
      | expr '+' expr
      | ID
      ;

Die Regel expr ist links-rekursiv, was normalerweise bei LL-Parsern problematisch ist. ANTLR löst diese Links-Rekursion automatisch auf (vgl. github.com/antlr/antlr4/blob/master/doc/left-recursion.md).

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.

ANTLR kann nur direkte Links-Rekursion auflösen. Regeln wie r : r T U | V ; stellen in ANTLR also kein Problem dar.

Indirekte Links-Rekursion erkennt ANTLR dagegen nicht:

r : s T U | V ;
s : r W X ;

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
      ;
public static class StatContext extends ParserRuleContext { ... }
public static class ReturnContext extends StatContext {
    public EContext value;
    public EContext e() { ... }
}
public static class BreakContext extends StatContext { ... }

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.

expr : e1=expr '*' e2=expr      # MULT
     | e1=expr '+' e2=expr      # ADD
     | DIGIT                    # ZAHL
     ;

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.

public static class MyListener extends calcBaseListener {
    public void exitMULT(calcParser.MULTContext ctx) {
        ...
    }
    public void exitADD(calcParser.ADDContext ctx) {
        ...
    }
    public void exitZAHL(calcParser.ZAHLContext ctx) {
        ...
    }
}

Anschließend baut man das alles in eine Traversierung des Parse-Trees ein:

public class TestMyListener {
    public static class MyListener extends calcBaseListener {
        ...
    }

    public static void main(String[] args) throws Exception {
        calcLexer lexer = new calcLexer(CharStreams.fromStream(System.in));
        CommonTokenStream tokens = new CommonTokenStream(lexer);
        calcParser parser = new calcParser(tokens);

        ParseTree tree = parser.s();    // Start-Regel

        ParseTreeWalker walker = new ParseTreeWalker();
        MyListener eval = new MyListener();
        walker.walk(eval, tree);
    }
}

Arbeiten mit dem Visitor-Pattern

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.

expr : e1=expr '*' e2=expr      # MULT
     | e1=expr '+' e2=expr      # ADD
     | DIGIT                    # ZAHL
     ;

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()).

public static class MyVisitor extends 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:

public class TestMyVisitor {
    public static class MyVisitor extends calcBaseVisitor<Integer> {
        ...
    }

    public static void main(String[] args) throws Exception {
        calcLexer lexer = new calcLexer(CharStreams.fromStream(System.in));
        CommonTokenStream tokens = new CommonTokenStream(lexer);
        calcParser parser = new calcParser(tokens);

        ParseTree tree = parser.s();    // Start-Regel

        MyVisitor eval = new MyVisitor();
        eval.visit(tree);
    }
}

Eingebettete Aktionen und Attribute

s   : expr                      {System.err.println($expr.v);}
    ;

expr returns [int v]
    : e1=expr '*' e2=expr       {$v = $e1.v * $e2.v;}
    ;

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.

(Die genauere Sprachdefinition finden Sie bei Bedarf unter craftinginterpreters.com/the-lox-language.html.)

Übungsblätter/Aufgaben
Quellen

Semantische Analyse

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.

Subsections of Semantische Analyse

SymbTab0: Überblick Symboltabellen

TL;DR

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.

Videos (YouTube)
Videos (HSBI-Medienportal)
Lernziele
  • (K2) Aufgaben der semantischen Analyse
  • (K2) Bedeutung von Symboltabellen: Aufgaben, Verbindung zu Compiler-Phasen

Was passiert nach der Syntaxanalyse?

int x = 42;
int f(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

Übungsblätter/Aufgaben
Quellen

SymbTab1: Nested Scopes

TL;DR

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().

Videos (YouTube)
Videos (HSBI-Medienportal)
Lernziele
  • (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.

Quelle: Eigene Modellierung nach einer Idee in [Parr2010, p. 142]

Alternative Implementierung über einen Stack

  • 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

class Scope:
    Scope enclosingScope    # None if global (outermost) scope
    Symbol<String, Symbol> symbols

    def resolve(name):
        # do we know "name" here?
        if symbols[name]: return symbols[name]
        # if not here, check any enclosing scope
        if enclosingScope: return enclosingScope.resolve(name)
        else: return None     # not found

    def bind(symbol):
        symbols[symbol.name] = symbol
        symbol.scope = self     # track the scope in each symbol

Quelle: Eigene Implementierung nach einer Idee in [Parr2010, p. 169]

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; }
class MyListener(BaseListener):
    Scope scope

    def enterStart(Parser.FileContext ctx):
        globals = Scope()
        globals.bind(BuiltIn("int"))
        globals.bind(BuiltIn("float"))
        scope = globals

    def enterBlock(Parser.BlockContext ctx):
        scope = Scope(scope)
    def exitBlock(Parser.BlockContext ctx):
        scope = scope.enclosingScope

    def exitVarDecl(Parser.VarDeclContext ctx):
        t = scope.resolve(ctx.type().getText())
        var = Variable(ctx.ID().getText(), t)
        scope.bind(var)
    def exitVar(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)

  • Blöcke: Nested Scopes => hierarchische Organisation

  • Binden von Bezeichner gleichen Namens an ihren jeweiligen Scope => bind()

  • Abrufen von Bezeichnern aus dem aktuellen Scope oder den Elternscopes => resolve()

Übungsblätter/Aufgaben
Quellen

SymbTab2: Funktionen

TL;DR

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.

Videos (YouTube)
Videos (HSBI-Medienportal)
Lernziele
  • (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;
void f() {
    int x;
    x = 1;
    y = 2;
    { int y = x; }
}
void g(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

Quelle: Eigene Modellierung nach einer Idee in [Parr2010, p. 147]

Funktionen sind Symbole und Scopes

class Function(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

int f(int x) {
    int y = 9;
}

int x = f(x);
def enterFuncDecl(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

def exitFuncDecl(Parser.FuncDeclContext ctx):
    scope = scope.enclosingScope
def exitParam(Parser.ParamContext ctx):
    t = scope.resolve(ctx.type().getText())
    var = Variable(ctx.ID().getText(), t)
    scope.bind(var)

def exitCall(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)

  • Funktionen: Nested Scopes => hierarchische Organisation

  • Umgang mit dem Funktionsnamen, den Parametern und dem Funktionskörper

Challenges

Diskutieren Sie folgende Fragen:

  • Warum werden überhaupt Symboltabellen eingesetzt?
  • Warum muss man zwischen Deklaration und Definition unterscheiden?
  • Erklären Sie die Verbindung einer Symboltabelle zu den einzelnen Phasen einer Compiler-Pipeline.
  • Wo liegen die Grenzen der semantischen Analyse?
  • Warum kann man im Allgemeinen nicht die Symboltabellen nutzen, um die Werte von Symbolen mit zu speichern?
  • Wieso sind Funktionen Scope und Symbol gleichzeitig?
  • Skizzieren Sie für eine Funktionsdeklaration mit Parametern die resultierende Symboltabelle.
  • Erklären Sie, wie man beim Funktionsaufruf vorgehen würde. Werden dabei Einträge in der Symboltabelle erzeugt?
Übungsblätter/Aufgaben
Quellen

SymbTab3: Strukturen und Klassen

TL;DR

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.

Videos (YouTube)
Videos (HSBI-Medienportal)
Lernziele
  • (K3) Aufbau von Symboltabellen für Nested Scopes inkl. Strukturen/Klassen mit einem Listener
  • (K3) Attribute von Klassen und Strukturen auflösen

Strukturen

struct A {
    int x;
    struct B {int x;};
    B b;
    struct C {int z;};
};
A a;
void f() {
    A a;
    a.b.x = 42;
}

Strukturen: Erweiterung der Symbole und Scopes

Quelle: Eigene Modellierung nach einer Idee in [Parr2010, p. 162]

Strukturen stellen wie Funktionen sowohl einen Scope als auch ein Symbol dar.

Zusätzlich stellt eine Struktur (-definition) aber auch einen neuen Typ dar, weshalb Struct auch noch das Interface Type "implementiert".

Strukturen: Auflösen von Namen

class Struct(Scope, Symbol, Type):
    def resolveMember(name):
        return symbols[name]

=> 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

class A {
public:
    int x;
    void foo() { ; }
};
class B : public A {
public
    int y;
    void foo() {
        int z = x+y;
    }
};

Klassen: Erweiterung der Symbole und Scopes

Quelle: Eigene Modellierung nach einer Idee in [Parr2010, p. 167]

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

class Clazz(Struct):
    Clazz parentClazz   # None if base class

    def resolve(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 class
            if enclosingScope: return enclosingScope.resolve(name)
            else: return None     # not found

    def resolveMember(name):
        if symbols[name]: return symbols[name]
        # NEW: check parent class
        if parentClazz: return parentClazz.resolveMember(name)
        else: return None

Quelle: Eigene Implementierung nach einer Idee in [Parr2010, p. 172]

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;
class A {
public:
    int x;
    void foo() { ; }
};
class B : public A {
public
    int y;
    void foo() {
        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:

  1. Umkreisen Sie alle Symbole.
  2. Zeichen Sie Pfeile von Symbol-Referenzen zur jeweiligen Definition (falls vorhanden).
  3. Identifizieren Sie alle benannten Scopes.
  4. Identifizieren Sie alle anonymen Scopes.
  5. Geben Sie die resultierende Symboltabelle an (Strukturen wie in VL besprochen).
package a.b;

import u.Y;

class X extends Y {
    int f(int x) {
        int x,y;
        { int x; x - y + 1; }
        x = y + 1;
    }
}

class Z {
    class W extends X {
        int x;
        void foo() { f(34); }
    }
    int x,z;
    int f(int x) {
        int y;
        y = x;
        z = x;
    }
}
Übungsblätter/Aufgaben
Quellen

Zwischencode

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.

Subsections of Zwischencode

Überblick Zwischencode

TL;DR

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.

Videos (YouTube)
Videos (HSBI-Medienportal)
Lernziele
  • (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.
{"blocks":[{"t":"Para","c":[{"t":"Str","c":"Dies"},
                            {"t":"Space"},
                            {"t":"Str","c":"ist"},
                            {"t":"Space"},
                            {"t":"Str","c":"ein"},
                            {"t":"Space"},
                            {"t":"Str","c":"Absatz"},
                            {"t":"Space"},
                            {"t":"Str","c":"mit"}]},
           {"t":"BulletList","c":[
               [{"t":"Plain","c":[{"t":"Str","c":"einem"},
                                  {"t":"Space"},
                                  {"t":"Str","c":"Stichpunkt,"},
                                  {"t":"Space"},
                                  {"t":"Str","c":"und"}]}],
               [{"t":"Plain","c":[{"t":"Str","c":"einem"},
                                  {"t":"Space"},
                                  {"t":"Str","c":"zweiten"},
                                  {"t":"Space"},
                                  {"t":"Str","c":"Stichpunkt."}]}]]}],
"pandoc-api-version":[1,17,0,4],"meta":{}}

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.

Zwischenformat: Drei-Adressen-Code

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:  ...

LLVM IR

Low Level Virtual Machine

int main() {
    int x = 7;
    int y = x + 35;

    return 0;
}
define i32 @main() #0 {
  %1 = alloca i32, align 4
  %2 = alloca i32, align 4
  %3 = alloca i32, align 4
  store i32 0, i32* %1, align 4
  store i32 7, i32* %2, align 4
  %4 = load i32, i32* %2, align 4
  %5 = add nsw i32 %4, 35
  store i32 %5, i32* %3, align 4
  ret i32 0
}

Der obige Output ist auf die relevanten Zeilen gekürzt; der gesamte Output im LLVM-Format sieht wie folgt aus:

; ModuleID = 'hello.c'
source_filename = "hello.c"
target datalayout = "e-m:e-i64:64-f80:128-n8:16:32:64-S128"
target triple = "x86_64-pc-linux-gnu"

; Function Attrs: noinline nounwind optnone uwtable
define i32 @main() #0 {
  %1 = alloca i32, align 4
  %2 = alloca i32, align 4
  %3 = alloca i32, align 4
  store i32 0, i32* %1, align 4
  store i32 7, i32* %2, align 4
  %4 = load i32, i32* %2, align 4
  %5 = add nsw i32 %4, 35
  store i32 %5, i32* %3, align 4
  ret i32 0
}

attributes #0 = { noinline nounwind optnone uwtable "correctly-rounded-divide-sqrt-fp-math"="false" "disable-tail-calls"="false" "less-precise-fpmad"="false" "no-frame-pointer-elim"="true" "no-frame-pointer-elim-non-leaf" "no-infs-fp-math"="false" "no-jump-tables"="false" "no-nans-fp-math"="false" "no-signed-zeros-fp-math"="false" "no-trapping-math"="false" "stack-protector-buffer-size"="8" "target-cpu"="x86-64" "target-features"="+fxsr,+mmx,+sse,+sse2,+x87" "unsafe-fp-math"="false" "use-soft-float"="false" }

!llvm.module.flags = !{!0}
!llvm.ident = !{!1}

!0 = !{i32 1, !"wchar_size", i32 4}
!1 = !{!"clang version 6.0.0-1ubuntu2 (tags/RELEASE_600/final)"}

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).

Vgl. auch LLVM Language Reference Manual und blog.regehr.org/archives/1453.

Bytecode (Beispiel Python)

x = 7
y = x + 35
  1  0 LOAD_CONST    0 (7)
     3 STORE_NAME    0 (x)

  2  6 LOAD_NAME     0 (x)
     9 LOAD_CONST    1 (35)
    12 BINARY_ADD
    13 STORE_NAME    1 (y)
    16 LOAD_CONST    2 (None)
    19 RETURN_VALUE

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)

public class Hello {

    void wuppie() {
        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.

Vgl. auch dzone.com/articles/introduction-to-java-bytecode und www.beyondjava.net/java-programmers-guide-java-byte-code.

Assembler

int main() {
    int x = 7;
    int y = x + 35;

    return 0;
}
    .file   "hello.c"
    .text
    .globl  main
    .type   main, @function
main:
.LFB0:
    .cfi_startproc
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    movl    $7, -8(%rbp)
    movl    -8(%rbp), %eax
    addl    $35, %eax
    movl    %eax, -4(%rbp)
    movl    $0, %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE0:
    .size       main, .-main
    .ident      "GCC: (Ubuntu 7.3.0-27ubuntu1~18.04) 7.3.0"
    .section    .note.GNU-stack,"",@progbits

Die Ausgabe unterscheidet sich je nach Architektur, auf dem der C-Code in Assembler-Code compiliert wird!

Mit .text beginnt das Textsegment. main: ist eine Sprungmarke, die hier auch als Startpunkt für das Programm dient.

Auf X86-64 stehen %rbp und %rsp für 8-Byte-Register. Mit %eax greift man auf die Bytes 0 bis 3 des 8-Byte-Registers %rax zu.

Da in %rbp Werte übergeben werden (können), wird das Register mit pushq %rbp auf den Stack gesichert und am Ende mit popq %rbp wiederhergestellt.

Ansonsten kann man die Bedeutung erraten: movl $7, -8(%rbp) entspricht mem[rbp-8] = 7, movl -8(%rbp), %eax entspricht eax = mem[rbp-8], addl $35, %eax entspricht eax = eax + 35, movl %eax, -4(%rbp) entspricht mem[rbp-4] = eax.

Vgl. auch cs.brown.edu/courses/cs033/docs/guides/x64_cheatsheet.pdf und en.wikibooks.org/wiki/X86_Assembly/GAS_Syntax.

Wrap-Up

  • Compiler generieren aus AST Zwischencode ("IC" oder "IR")
  • Kein allgemein definiertes Format, große Bandbreite:
    • AST als IR
    • LLVM IR
    • Drei-Adressen-Code
    • Diverse Arten von Bytecode
    • Assemblercode
Übungsblätter/Aufgaben
Quellen

LLVM als IR

Annotierte Folien

Motivation

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):

Crack      Go     Haskell      Java      Julia      Kotlin

Lua      Numba      Python     Ruby      Rust      Swift      ...

Für weitere Projekte siehe Projects built with LLVM

Unterstützte Prozessorarchitekturen

x86      AMD64      PowerPC      PowerPC 64Bit      Thumb

SPARC      Alpha      CellSPU      PIC16      MIPS

MSP430     System z      XMOS      Xcore      ...

Einige Komponenten von LLVM

Einige Komponenten (Projekte) von LLVM

  • Der LLVM-Kern incl. Optimierer
  • MLIR für IR-Dialekte
  • Der Compiler Clang
  • Die Compiler-Runtime-Bibliothek

Der LLVM-Kern

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.

Die wichtigsten Bestandteile:

  • der Zwischencode LLVM IR
  • der LLVM Optimierer
  • der Codegenarator mit Sanitizern
Quellen
Lernziele
  • (K1) Konzept von LLVM
  • (K1) Module von LLVM
  • (K1) SSA

Interpreter

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.)

Subsections of Interpreter

AST-basierte Interpreter: Basics

TL;DR

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.

Videos (HSBI-Medienportal)
Lernziele
  • (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;
int f(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?

AST-basierte Interpreter: Visitor-Dispatcher

def eval(self, AST t):
    if   t.type == Parser.BLOCK  : block(t)
    elif t.type == Parser.ASSIGN : assign(t)
    elif t.type == Parser.RETURN : ret(t)
    elif t.type == Parser.IF     : ifstat(t)
    elif t.type == Parser.CALL   : return call(t)
    elif t.type == Parser.ADD    : return add(t)
    elif t.type == Parser.MUL    : return mul(t)
    elif t.type == Parser.INT    : return Integer.parseInt(t.getText())
    elif t.type == Parser.ID     : return load(t)
    else : ...  # catch unhandled node types
    return None;

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.

Auswertung von Literalen und Ausdrücken

  • Typen mappen: Zielsprache => Implementierungssprache

    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.

  • Literale auswerten:

    INT: [0-9]+ ;
    elif t.type == Parser.INT : return Integer.parseInt(t.getText())

    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.

  • Ausdrücke auswerten:

    add: e1=expr "+" e2=expr ;
    def add(self, AST t):
        lhs = eval(t.e1())
        rhs = eval(t.e2())
        return (double)lhs + (double)rhs  # Semantik!

    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 ...

Kontrollstrukturen

ifstat: 'if' expr 'then' s1=stat ('else' s2=stat)? ;
def ifstat(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.

class Interpreter(BaseVisitor<Object>):
    __init__(self, AST t):
        BaseVisitor<Object>.__init__(self)
        self.root = t
        self.env = Environment()

Quelle: Eigener Code basierend auf einer Idee nach Interpreter.java by Bob Nystrom on Github.com (MIT)

Ausführen einer Variablendeklaration

varDecl: "var" ID ("=" expr)? ";" ;
def varDecl(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)

    return None

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;
def assign(self, AST t):
    lhs = t.ID().getText()
    value = eval(t.expr())

    self.env.assign(lhs, value)  # Semantik!
}

class Environment:
    def assign(self, String n, Object v):
        if self.values[n]: self.values[n] = v
        elif self.enclosing: self.enclosing.assign(n, v)
        else: raise RuntimeError(n, "undefined variable")

Quelle: Eigener Code basierend auf einer Idee nach Environment.java by Bob Nystrom on Github.com (MIT)

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* '}' ;
def block(self, AST t):
    prev = self.env

    try:
        self.env = Environment(self.env)
        for s in t.stat(): eval(s)
    finally: self.env = prev

    return None;

Quelle: Eigener Code basierend auf einer Idee nach Interpreter.java by Bob Nystrom on Github.com (MIT)

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)

Übungsblätter/Aufgaben
Quellen

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.

Lernziele
  • (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

int foo(int a, int b, int c) {
    print a + b + c;
}

foo(1, 2, 3);
def makeCounter():
    var i = 0
    def count():
        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 ...

Ausführen einer Funktionsdeklaration

funcDecl : type ID '(' params? ')' block ;
funcCall : ID '(' exprList? ')' ;
def funcDecl(self, AST t):
    fn = Fun(t, self.env)
    self.env.define(t.ID().getText(), fn)

Quelle: Eigener Code basierend auf einer Idee nach LoxFunction.java by Bob Nystrom on Github.com (MIT)

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).

Ausführen eines Funktionsaufrufs

funcDecl : type ID '(' params? ')' block ;
funcCall : ID '(' exprList? ')' ;
def funcCall(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

Quelle: Eigener Code basierend auf einer Idee nach LoxFunction.java by Bob Nystrom on Github.com (MIT)

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.

Funktionsaufruf: Rückgabewerte

def funcCall(self, AST t):
    ...

    eval(fn.decl.block())

    ...
    return None  # (Wirkung)
class ReturnEx(RuntimeException):
    __init__(self, v): self.value = v

def return(self, AST t):
    raise ReturnEx(eval(t.expr()))

def funcCall(self, AST t):
    ...
    erg = None
    try: eval(fn.decl.block())
    except ReturnEx as r: erg = r.value
    ...
    return erg;

Quelle: Eigener Code basierend auf einer Idee nach Return.java und LoxFunction.java by Bob Nystrom on Github.com (MIT)

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

class Callable:
    def call(self, Interpreter i, List<Object> a): pass
class Fun(Callable): ...
class NativePrint(Fun):
    def call(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())

def funcCall(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)
    ...

Quelle: Eigener Code basierend auf einer Idee nach LoxCallable.java und LoxFunction.java by Bob Nystrom on Github.com (MIT)

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* "}" ;
def classDef(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)

Quelle: Eigener Code basierend auf einer Idee nach Interpreter.java by Bob Nystrom on Github.com (MIT)

Anmerkung: In dieser Darstellung wird der Einfachheit halber nur auf Methoden eingegangen. Für Attribute müssten ähnliche Konstrukte implementiert werden.

Klassen und Instanzen II

class Clazz(Callable):
    __init__(self, Map<String, Fun> methods):
        self.methods = methods

    def call(self, Interpreter i, List<Object> a):
        return Instance(self)

    def findMethod(self, String name):
        return self.methods[name]

class Instance:
    __init__(self, Clazz clazz):
        self.clazz = clazz

    def get(self, String name):
        method = self.clazz.findMethod(name)
        if method != None: return method.bind(self)
        raise RuntimeError(name, "undefined method")

Quelle: Eigener Code basierend auf einer Idee nach LoxClass.java und LoxInstance.java by Bob Nystrom on Github.com (MIT)

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 ;
def getExpr(self, AST t):
    obj = eval(t.obj())

    if isinstance(obj, Instance):
        return ((Instance)obj).get(t.ID().getText())

    raise RuntimeError(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.

Methoden und this oder self

class Fun(Callable):
    def bind(self, Instance i):
        e = Environment(self.closure)
        e.define("this", i)
        e.define("self", i)
        return Fun(self.decl, e)

Quelle: Eigener Code basierend auf einer Idee nach LoxFunction.java by Bob Nystrom on Github.com (MIT)

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;
int f(int x) {
    int y = 9;
    return y+x;
}

x = f(x);
  1. Geben Sie den AST an.
  2. Stellen Sie die Strukturen der Symboltabelle dar.
  3. Stellen Sie die Strukturen im Interpreter dar.
Übungsblätter/Aufgaben
Quellen

Programmiersprachen und -konzepte

Unterschiedliche Programmiersprachen weisen nicht nur verschiedene Syntaxelemente auf, sondern haben eine teilweise stark unterschiedliche Semantik. Beides hat Auswirkungen auf die Bausteine eines Compilers.

Subsections of Programmiersprachen und -konzepte

Einführung in C++ (Erinnerungen an C)

TL;DR

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.

Lernziele
  • (K1) Wie hängen C und C++ zusammen?
  • (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>

using namespace std;

int main() {
    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.

Variablen, Operatoren, Kontrollfluss

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

      • flags: hängt von der konkreten Ausgabe ab

      • width: Feldbreite

      • precision: Anzahl der Dezimalstellen

      • conversion: (Beispiele)

        c Zeichen (Char)
        d Integer (dezimal)
        f Gleitkommazahl
  • Standardkanäle: cin (Standardeingabe), cout (Standardausgabe), cerr (Standardfehlerausgabe)

    • 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;
    // Einzelne Zeichen (auch Whitespaces) lesen
    char c; cin.get(c);

Sichtbarkeit und Gültigkeit und Namespaces

Wie in Java:

  • Namen sind nur nach Deklaration und innerhalb des Blockes, in dem sie deklariert wurden, gültig
  • Namen sind auch gültig für innerhalb des Blockes neu angelegte innere Blöcke
  • Namen in inneren Blöcken können Namen aus äußeren Scopes überdecken

Zusätzlich gibt es noch benannte Scopes und einen Scope-Operator.

  • C++ enthält den Scope-Operator :: => Zugriff auf global sichtbare Variablen

    int a=1;
    int main() {
        int a = 10;
        cout << "lokal: " << a << "global: " << ::a << endl;
    }
  • Alle Namen aus XYZ zugänglich machen: using namespace XYZ;

    using namespace std;
    cout << "Hello World" << endl;
  • Alternativ gezielter Zugriff auf einzelne Namen: XYZ::name

    std::cout << "Hello World" << std::endl;
  • Namensraum XYZ deklarieren

    namespace XYZ {
        ...
    }

Arrays und Vektoren in C++

  • Syntax: Typ Name[AnzahlElemente];

    int myArray[100];
    int myArray2[] = {1, 2, 3, 4};
    • Compiler reserviert sofort Speicher auf dem Stack => statisch: im Programmlauf nicht änderbar

    • Zugriff über den Indexoperator []

    • Achtung: "roher" Speicher, d.h. keinerlei Methoden

    • Größe nachträglich bestimmen mit sizeof:

      int myArray[100], i;
      int cnt = sizeof(myArray)/sizeof(myArryay[0]);

    Guter Stil: Anzahl der Elemente als Konstante deklarieren: Statt int myArray[100]; besser

    #define LENGTH 100
    int myArray[LENGTH];
  • Vordefinierter Vektor-Datentyp vector

    • Einbinden über #include <vector>
    • Parametrisierter Datentyp (C++: Templates) - Nutzung analog wie in Java (Erstellung von Templateklassen und -methoden aber deutlich anders!)
    • Anlegen eines neuen Arrays mit 10 Elementen für Integer:
    vector<int> v(10);
    vector<double> meinVektor = {1.1, 2.2, 3.3, 4.4};
    meinVektor.push_back(5.5);
    cout << meinVektor.size() << endl;
    • Zugriff auf Elemente:
    cout << v[0] << endl;        // ohne Bereichspruefung!
    cout << v.at(1000) << endl;  // mit interner Bereichspruefung
    
    • Zuweisung (mit Kopieren):
    vector<double> andererVektor;
    andererVektor = meinVektor;
    • Dynamische Datenstruktur:
    vector<int> meineDaten;      // initiale Groesse: 0
    meineDaten.push_back(123);   // Wert anhaengen
    
    meineDaten.pop_back();  // Wert loeschen
    meineDaten.empty();     // leer?
    

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++)

    typedef unsigned long 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++)

    typedef unsigned long uint32;       // C, C++
    using uint32 = unsigned long;       // C++11
    
    typedef std::vector<int> foo;       // C, C++
    using foo = std::vector<int>;       // C++11
    
    typedef void (*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:

  1. Länge im Speicher

    short Speicher: halbe Wortlänge
    long Speicher: doppelte/dreifache Wortlänge
  2. 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.

vgl. en.wikipedia.org/wiki/C_data_types

Größe eines Datentyps ist maschinenabhängig

Der reservierte Speicherbereich und damit auch der Zahlenbereich für einen einfachen Typ in C/C++ ist maschinenabhängig!

  • Zahlenbereiche für konkrete Implementierung in Header-Files definiert

    limits.h und float.h: Konstanten INT_MAX, INT_MIN, ...

  • Alternativ Herausfinden der Größe in Bytes: Operator sizeof

    Syntax: sizeof(Typ)

Es gilt in C/C++:

  • sizeof(unsigned char) $=$ 1
  • sizeof(short int) $=$ 2
  • sizeof(short int) $\le$ sizeof(int) $\le$ sizeof(long int)
  • sizeof(float) $\le$ sizeof(double) $\le$ sizeof(long double)

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++;
} else if(y<=1) {
    y = y-x;
} else {
    y = 2*x;
}


while (y>0) {
    y--;
}


for (x=0; x<10; x++) {
    y = y*y;
}

Funktionen in C und C++

  • Funktionen sind mit Methoden in Java vergleichbar

    => sind aber unabhängig von Klassen bzw. Objekten

  • Syntax:

    Rueckgabetyp Funktionsname(Parameterliste) {
        Anweisungen (Implementierung)
    }
  • 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

    void machWas(int, int);
  • Definition: Implementierung der Funktion

    void machWas(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/...

One Definition Rule (für Funktionen)

Jede Funktion darf im gesamten Programm nur einmal definiert sein!

Funktionen und Parameter

  • Funktionen "ohne" Parameter:

    Leere Parameter-Liste[^1] oder Schlüsselwort void

    void fkt();
    void fkt(void);
  • Funktionen mit Parameter:

    • Deklaration: Variablennamen können weggelassen werden
    • Definition: Variablennamen müssen angegeben werden
    void fkt(int, char);
    void fkt(int a, char b);
    
    void fkt(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
int wuppie();                   // Deklaration: "Ich verrate Dir nicht, wieviele Parameter wuppie() hat."
int wuppie(int x) { return x; } // Aufruf mit Argumenten => ist okay

// Fehler in C
int fluppie(void);               // Deklaration: fluppie() hat KEINE Parameter!
int fluppie(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 void gleichwertig

void fkt();
void fkt(void);

Defaultparameter in C++

  • Parameter mit Defaultwerten am Ende der Parameterliste
  • Bei Trennung von Deklaration und Definition: Defaultparameter nur in Deklaration
// Deklaration
void f(int i, int j=1, int k=2);

// Definition
void f(int i, int j, int k) { ... }

Überladen von Funktionen

  • Funktionen im gleichen Gültigkeitsbereich können überladen werden
  • Zu beachten:
    1. Funktionsname identisch
    2. Signatur (Anzahl, Typen der Parameter) muss unterschiedlich sein
    3. Rückgabewert darf variieren

=> Warnung: Überladene Funktionen sollten gleichartige Operationen für unterschiedliche Datentypen bereitstellen!

Probleme beim Überladen von Funktionen

  1. Defaultparameter

    int maximum(int, int);
    int maximum(int, int, int=10);
  2. Identische Signatur, Unterschied nur im Rückgabewert

    int maximum(int, int);
    double maximum(int, int);
  3. Überladen nur für Funktionen des selben Gültigkeitsbereichs!

    #include <iostream>
    using namespace std;
    
    void f(char c) {
        cout << "f(char): " << c << endl;
    }
    void f(int i) {
        cout << "f(int): " << i << endl;
    }
    
    
    int main() {
        void f(int i);  // f(char) nicht mehr sichtbar!
        f('a');
    
        return 0;
    }

Parameterübergabe in C/C++: Call-by-Value

int add_5(int x) {
    x += 5;
    return x;
}

int main() {
    int erg, i=0;
    erg = add_5(i);
}
 Aufrufer-Sicht
              i                      erg
           +-----+                 +-----+
           |     |                 |     |
           +--+--+                 +--^--+
              |                       |
              |                       |
--------------+-----------------------+-----
  Kopie bei   |                Kopie  |
  Aufruf      |                bei    |
              |                return |
           +--v--+                    |
           |     +--------------------+
           +-----+
              x
 Funktionssicht
  • Default in C/C++ ist die call-by-value Semantik:
    • Argumente werden bei Übergabe kopiert
    • Ergebniswerte werden bei Rückgabe kopiert
  • Folgen:
    • Keine Seiteneffekte durch Verändern von übergebenen Strukturen
    • Negative Auswirkungen auf Laufzeit bei großen Daten

Ausnahme: Übergabe von C++-Referenzen oder Pointern (wobei Pointer streng genommen auch kopiert werden, also per call-by-value übergeben werden ...)

Unterschiedliche Variablenarten

Lokale Variablen ("automatische Variablen")

int b = 1;

void f() {
    int b = 42;
}

int main() {
    int b = 3;

    {
        int b = 7;
    }
}
  • Innerhalb einer Funktion (oder Blockes) definierte Variablen

  • Gilt auch für Variablen aus Parameterliste

  • Überdecken globale Variablen gleichen Namens

  • Sichtbarkeit:

    • Außerhalb der Funktion/Blockes nicht zugreifbar
    • Beim Betreten der Funktion Reservierung von Speicherplatz für lokale Variablen
    • Dieser wird beim Verlassen des Blockes/Funktion automatisch wieder freigegeben
    • Namen sind nur nach Deklaration und innerhalb des Blockes, in dem sie deklariert wurden, gültig
    • Namen sind auch gültig für innerhalb des Blockes neu angelegte innere Blöcke

    Software Engineering: Vermeiden Sie lokale Namen, die Namen aus einem äußeren Scope überdecken!

=> Werden auch als automatische Variablen bezeichnet

Globale Variablen ("externe Variablen")

/* ======== Datei main.cpp (einzeln kompilierbar) ======== */
int main() {
    extern int global;  // Deklaration
}

int global;             // Definition
/* ======== Datei foo.cpp (einzeln kompilierbar) ======== */
extern int global;      // Deklaration

void foo() {
    global = 45;
}
  • Globale Variablen: Außerhalb jeder Funktion definierte Variablen
  • 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.

Statische lokale Variablen

void foo() {
    static int x = 42;
    x++;
}

int main() {
    foo();  foo();  foo();
}
  • Lokale Variablen mit "Gedächtnis": Definition mit dem vorangestellten Schlüsselwort "static"

    static int callCount;
  • Eigenschaften:

    • Wert bleibt für die folgenden Funktionsaufrufe erhalten
    • Wert kann in der Funktion verändert werden
    • Dennoch: lokale Variable, d.h. von außen nicht sichtbar/gültig

Hinweis: static für globale Variablen bedeutet etwas anderes! (s.u. "Sichtbarkeit")

Initialisierung von Variablen

(Automatische) Initialisierung von Variablen hängt von ihrer Speicherklasse ab!

  • Automatisch
    • Werden nicht automatisch initialisiert (!)
    • Bei vorgegebenem Wert ab Aufruf der Funktion
  • Extern
    • Mit dem Wert 0 oder vorgegebenem Wert
    • Bereits vor Programmstart (im Code enthalten)
  • Statisch
    • Mit dem Wert 0 oder vorgegebenem Wert
    • Ab erstem Aufruf der Funktion

Sichtbarkeit globaler Variablen (und Funktionen) beschränken

  • 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 ======== */
    const int PI=123;       // Definition OHNE "extern" (C)
    
    int main() {
        fkt_a1();
        int x = PI;
        ...
    }
    /* ======== Datei a.c ======== */
    extern const int PI;    // (erneute) Deklaration mit "extern"
    void fkt_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 ======== */
    extern const int PI=123;    // Definition MIT "extern" (C++)
    
    int main() {
        fkt_a1();
        int x = PI;
        ...
    }
    /* ======== Datei a.cpp ======== */
    extern const int PI;        // (erneute) Deklaration mit "extern"
    void fkt_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 ======== */
extern const int PI;    // (Vorwärts-) Deklaration mit "extern"
const int PI=123;       // Definition OHNE "extern"

int main() {
    fkt_a1();
    int x = PI;
    ...
}
/* ======== Datei a.c ======== */
extern const int PI;    // (erneute) Deklaration mit "extern"
void fkt_a1() {
    int x = PI;
    ...
}

Automatisieren der Buildvorgänge: GNU Make

Makefile: Textdatei mit Regeln für das Programm make

  • Abläufe automatisieren: Kompilieren, testen, Pakete bauen, aufräumen, ...
  • Java: ant, C/C++: 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

  • Passendes Makefile:

    CXXFLAGS = -Wall
    
    .PHONY: all
    all: tollesProgramm
    
    tollesProgramm: main.o
        $(CXX) $(LDFLAGS) $< $(LDLIBS) -o $@
    
    %.o: %.cpp
        $(CXX) $(CXXFLAGS) -c $< -o $@
    
    .PHONY: clean
    clean:
        rm -rf tollesProgramm *.o *.~
    

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?

    unsigned char a;
    int b;
    
    long long x[10];
    long long y[] = {1, 2, 3};
    long long z[7] = {3};
  • 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");
    else
        printf("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:

    int maximum(int, int);
    double maximum(int, int);
    char maximum(int, int, int=10);
  • Erklären Sie die Probleme bei folgendem Code-Schnipsel:

    int maximum(int, int);
    double maximum(double, double);
    
    int main() {
        cout << maximum(1, 2.2) << endl;
    }
  • Erklären Sie den Unterschied zwischen

    int a=1;
    int main() {
        extern int a;
        return 0;
    }

    und

    int a=1;
    int main() {
        int a = 4;
        return 0;
    }
Quellen

C++: Pointer und Referenzen

TL;DR

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.

Videos (YouTube)
Lernziele
  • (K1) Virtueller Speicher, Segmente: Text, Data, Stack
  • (K2) Pointer sind Variablen, Wert wird als Adresse interpretiert
  • (K2) Pointer als spezielle Variablen: Wert des Pointers als Adresse interpretieren
  • (K2) Initialisierung und Scopes bei Pointern
  • (K3) Zuweisen einer Adresse an einen Pointer
  • (K3) Dereferenzierung eines Pointers und Zugriff auf das referenzierte Element
  • (K3) Pointer als Funktionsparameter: Call-by-Reference mit Hilfe von Pointern
  • (K2) Memory Leaks und Stale Pointer und deren Vermeidung
  • (K3) C++-Operatoren new und delete, Unterschied zu malloc(), free()
  • (K3) Referenzen in C++ (Deklaration, Initialisierung, Nutzung)
  • (K3) Zusammenhang und Unterschied Pointer und Arrays
  • (K3) Rechnen mit Pointern, Berücksichtigung des Typs

Virtueller Speicher

            +-----------------------------------------+
            |          Text                           | 0x0000
            |                                         |    |
            |-----------------------------------------|    |
            |          Heap (Data)                    |    |
            |                                         |    |
            |--------------------+--------------------|    |
            |                    |                    |    |
            |                    v                    |    |
            |                                         |    |
            |                                         |    v
            |                    ^                    |
            |                    |                    |
            |--------------------+--------------------|
            |                                         |
            |          Stack                          |
            +-----------------------------------------+
  • Kernel weist jedem Prozess seinen eigenen virtuellen Speicher zu
  • Linearer Adressbereich, beginnend mit Adresse 0 bis zu einer maximalen Adresse
  • Verwaltung durch MMU (Memory Management Unit)
    • MMU bildet logische Adressen aus virtuellem Speicher auf den physikalischen Speicher ab
    • Transparent für den Prozess

Segmente des virtuellen Speichers: Text (read-only)

  • Programm Code
  • Konstanten, String Literale

zusätzlich (nicht in Abbildung dargestellt):

  • Bereich initialisierter Daten (globale und static Variablen (explizit initialisiert))
  • Bereich uninitialisierter Daten (globale und static Variablen (uninitialisiert) => Wert 0)

Segmente des virtuellen Speichers: Stack

  • Dynamisch wachsend und schrumpfend
  • Stackframe je Funktionsaufruf:
    • Lokale Variablen ("automatische" Variablen)
    • Argumente und Return-Werte
  • Automatische Pflege
    • Nach Funktionsrückkehr wird der Stackpointer ("Top of Stack") weiter gesetzt
    • Dadurch "Bereinigung": Speicher der lokalen Variablen wird freigegeben

Segmente des virtuellen Speichers: Data (Heap)

  • Dynamisch wachsend und schrumpfend
  • Bereich für dynamischen Speicher (Allokation während der Laufzeit)
  • Zugriff und Verwaltung aus laufendem Programm => Pointer
    • malloc()/calloc()/free() (C)
    • new/delete (C++)
    • typischerweise Pointer
  • KEINE automatische Pflege - Programmierer ist selbst verantwortlich!

Konzept eines Pointers

int i = 99;
int *iptr;

iptr = &i;  /* Wert von iptr ist gleich Adresse von i */
*iptr = 2;  /* Deferenzierung von iptr => Veränderung von i */
        Variable    Speicheraddresse    Inhalt

                                        |          |
                                        +----------+
        i           10125               | 99       |  <--+
                                        +----------+     |
                                        |          |     |
                    ....                 ....            |
                                        |          |     |
                                        +----------+     |
        iptr        27890               | 10125    |  ---+
                                        +----------+
                                        |          |

Pointer sind Variablen

  • haben Namen und Wert
  • können mit Operatoren verändert werden
  • 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
    

Pointer: Schreibweisen

  • Position des * zwischen Typ und Name beliebig

    /* aequivalente Schreibweisen */
    int* iptr;
    int * iptr;
    int *iptr;
    /* Vorsicht Mehrfachdeklaration */
    int* iptr, ptr2;      /* ptr2 ist nur ein int! */
  • Dereferenzierung von Pointern auf Klassen/Structs: Operator ->

    /* aequivalente Schreibweisen */
    (*iptr).attribut;
    iptr->attribut;

Pointer: Zuweisungen an andere Pointer

int i=99, *iptr, *ptr2;

iptr = &i;

ptr2 = iptr;

*ptr2 = 2;

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
  • Dereferenzierung uninitialisierter Pointer problematisch

Explizite Null-Pointer:

  • Wert 0 zuweisen
  • Besser: Symbolische Konstante NULL aus stdio.h bzw. cstdio bzw. in C++ nullptr

Speicherverwaltung

  • C: Funktionen zur Verwaltung dynamischen Speichers: malloc(), free(), ... (in <stdlib.h>)

    void* malloc(size_t size)
    • Alloziert size Bytes auf dem Heap und liefert Adresse zurück
    • Pointer auf void, da Typ unbekannt - vor Nutzung auf korrekten Typ umcasten
    • Im Fehlerfall wird ein Null-Pointer zurückgeliefert: NULL
    • Achtung: Speicher ist nicht initialisiert!
    int *p = (int*) malloc(sizeof(int));
    int *pa = (int*) malloc(4*sizeof(int));
    
    free(p);
    free(pa);
  • C++: Operatoren: new und delete

    • Direkte Angabe des Zieltyps
    • Rückgabe eines Pointers auf diesen Typ
    • Exception, wenn kein Speicher verfügbar
    • Form mit []-Operator für Arrays
    • Mit new allozierter Speicher muss mit delete freigegeben werden
    • Mit new [] allozierter Speicher muss mit delete [] freigegeben werden
    int *p = new int;
    int *pa = new int[4];
    
    delete p;
    delete [] pa;

Speicher allozieren: Standardidiom

In C müssen Sie die Rückgabe von malloc prüfen:

int *i, *x;

i = (int *) malloc(sizeof(int));
x = (int *) malloc(sizeof(*x));   /* Stern wichtig */

if (!i) {
    /* Fehlerbehandlung */
} else {
    /* mach was */
}

In C++ bekommen Sie eine Exception, falls new nicht erfolgreich war:

int *i;

try {
    i = new int;
    /* mach was */
} catch (...) { /* Fehlerbehandlung */ }

Hinweis: Pointer-Variablen i und x liegen auf Stack, angeforderter Speicher im Heap!

Pointer und Typen

  • Typ eines Zeigers relevant, wird vom Compiler geprüft
  • Zuweisung ohne expliziten Cast nur an allgemeinere Typen/Oberklassen
    • Jeder Zeiger auf Typ T kann automatisch zum void-Pointer konvertiert werden

    • Für Zuweisung von void-Pointern an Pointer auf Typ T expliziter Cast nach T* nötig (siehe auch nachfolgenden Hinweis zu C11)

      char *cp;
      void *vp;
      
      vp = cp;          /* OK */
      cp = vp;          /* problematisch */
      cp = (char *) vp; /* OK */

Fallstricke dynamischer Speicherverwaltung

Nur new und delete kombinieren bzw. malloc und free

  • delete darf nur auf mit new erzeugte Objekte angewendet werden

    • Vorsicht bei Pointern auf Stack-Variablen!
    • NIE mischen mit malloc()/calloc()/free()!
    int *p = (int *) malloc(sizeof(int));
    delete p;  // FEHLER! Absturzgefahr
    

delete[] genau nur bei new[]

  • 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;
}

Tipp: y[i] += ... setzt sinnvolle Werte in y[i] voraus ...

Überschreiben von Speicher I

Allokation von falschen Größen

int *p;

p = malloc(N*sizeof(int));

for (int i=0; i<N; i++) {
    p[i] = malloc(M*sizeof(int));
}

Tipp: Jedes p[i] kann einen int speichern, bekommt aber einen Pointer zugewiesen (könnte deutlich breiter im Speicher sein als ein int) ...

Überschreiben von Speicher II

Indexberechnung kaputt, sogenannte "off-by-one-errors"

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) ...

Vgl. auch die Diskussion in eli.thegreenplace.net/2009/10/21/are-pointers-and-arrays-equivalent-in-c

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.

Array-Namen sind wie konstante Pointer

Array-Namen können NICHT umgebogen werden!

int a[], *pa=a, k;

/* erlaubt */
a + k;
pa++;

/* VERBOTEN */
a++;

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!
double d[10];
double *d1 = &d[2];
double *d2 = d1;

d2++;

printf("%ld\n", d2-d1);               // ergibt 1
printf("%ld\n", (long)d2 - (long)d1); // double -> zB. 8 Bytes

printf("%ld\n", sizeof(d1));  // Breite Pointervariable
printf("%ld\n", sizeof(*d1)); // Breite Pointerdatentyp

Referenzen in C++

Typ & Name = Objekt;

int i=2;
int j=9;

int &r=i;    // Referenz: neuer Name fuer i
r=10;        // aendert i: i==10
r=j;         // aendert i: i==9

int &s=r;    // aequivalent zu int &s = i;

Referenzen bilden Alias-Namen

int i = 99;
int *iptr = &i;

int &iref = i;   // Referenz: neuer Name fuer i
        Variable    Speicheraddresse    Inhalt

                                        |          |
                                        +----------+
        i, iref     10125               | 99       |  <--+
                                        +----------+     |
                                        |          |     |
                    ....                 ....            |
                                        |          |     |
                                        +----------+     |
        iptr        27890               | 10125    |  ---+
                                        +----------+
                                        |          |
  • Referenz bildet Alias-Namen für ein Objekt
  • 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:

    void fkt(int&, char);
    void fkt(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.

void add_5(int *x) {
    *x += 5;
}

int main() {
    int i=0, *ip=&i;

    add_5(ip);

    add_5(&i);
}
  • 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.

int add_5(int &x) {
    x += 5;
    return x;
}

int main() {
    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):

    void fkt(const int&, char);
    void fkt(const int &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(const int &, const char *);
int *fkt2(const int &, const char *);
  • Vorsicht mit lokalen Variablen (Gültigkeit)!

    int &fkt1(const int &i, const char *j) {
        int erg = i+1;
        return erg;   // Referenz auf lokale Variable!
    }
    int *fkt2(const int &i, const char *j) {
        int erg = i+2;
        return &erg;  // Pointer auf lokale Variable!
    }
    int main() {
        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

Wrap-Up

  • Virtueller Speicher: Kernel stellt Prozessen linearen Adressraum bereit, Segmente: Text, Stack, Heap

  • Pointer sind Variablen, deren Wert als Adresse interpretiert wird

    • Deklaration mit * zwischen Typ und Name
    • Adressoperator & liefert die Adresse eines Objekts
    • Dereferenzierung eines Pointers mit * vor dem Namen
  • Verwandtschaft zw. Arrays und Pointern: Array-Name ist konstanter Pointer auf Array-Anfang

  • Pointer haben Typ: Pointerarithmetik berücksichtigt Speicherbreite des Typs

  • C++-Referenzen als Alias-Namen für ein Objekt

    • Deklaration: Typ &ref = obj;
    • Fest mit Objekt verbunden
    • Zugriff auf Referenz: Direkter Zugriff auf das Objekt
Challenges

Pointer

  • Erklären Sie das Problem bei folgender Deklaration: int* xptr, yptr;

  • Seien p1 und p2 Pointer auf int. Was ist der Unterschied zwischen den beiden Code-Zeilen?

    p2  = p1;
    *p2 = *p1;
  • Ist *&x immer identisch mit x?

  • Ist &*x immer identisch mit x?

  • Wann kann die Funktion void f(int*) so aufgerufen werden: f(&x);?

Swap ...

  • Warum funktioniert die folgende swap()-Funktion nicht? Wie müsste sie korrigiert werden?

    void swap(int x, int y) {
        int tmp; tmp=x; x=y; y=tmp;
    }
  • Was ist mit dieser Version dieser swap()-Funktion?

    void swap(int *x, int *y) {
        int *tmp;
        tmp=x; x=y; y=tmp;
    }

C++: new und delete

Betrachten Sie folgende Code-Schnipsel. Erklären Sie die Wirkung der jeweiligen Anweisungen.

void fkt() {
    char *cp = new char[100];
    cp[0] = 'a';
}
int i=10;
int *p = &i;
delete p;
char *p;
{
    char *cp = new char[100];
    p = cp;
    free(cp);
}
delete p;

Referenzen vs. Pointer: Welche der Aufrufe sind zulässig?

void f1(int*);
void f2(int&);

int main() {
    int i=0, *ip=&i, &ir=i;

    f1(i);      f1(&i);     f1(*i);
    f1(ip);     f1(&ip);    f1(*ip);
    f1(ir);     f1(&ir);    f1(*ir);

    f2(i);      f2(&i);     f2(*i);
    f2(ip);     f2(&ip);    f2(*ip);
    f2(ir);     f2(&ir);    f2(*ir);
}

C++-Referenzen und Pointer

Betrachten Sie folgende Code-Schnipsel. Erklären Sie die Wirkung der jeweiligen Anweisungen.

int x=5, &y=x;
int *ptr1 = &x;
int *ptr2 = &y;

*ptr1 += 1;
*ptr1++;

ptr2  = ptr1;
*ptr2 = *ptr1;

ptr1  == ptr2;
*ptr1 == *ptr2;

Fallstricke mit C++-Referenzen

Betrachten Sie folgende Code-Ausschnitte. Welchen Wert haben die Variablen nach der Ausführung? Begründen Sie Ihre Antwort.

int i=2, j=9;
int &r=i, &s=r;
s=200;
int &versuch(int i, int j) {
    int erg = i+j;
    return erg;
}
int main() {
    int &z = versuch(2, 10);
    return 0;
}

Referenzen in C++

Betrachten Sie folgende Code-Ausschnitte (C++). Erklären Sie, ob sich dort Fehler verstecken und falls ja, wie diese zu beheben wären.

  1. Versuch

    int &versuch(int&, int&);
    
    int main() {
        int a=10, b=20;
        int &z = versuch(a, b);
        return 0;
    }
    
    int &versuch(int &i, int &j) {
        int &erg = i+j;
        return erg;
    }
  2. Versuch

    int &versuch(int&, int&);
    
    int main() {
        int a=10, b=20;
        int &z = versuch(a, b);
        return 0;
    }
    
    int &versuch(int &i, int &j) {
        int erg = i+j;
        return erg;
    }
  3. Versuch

    int &versuch(int&, int&);
    
    int main() {
        int a=10, b=20;
        int &z = versuch(a, 10);
        return 0;
    }
    
    int &versuch(int &i, int &j) {
        j += i;
        return j;
    }

Pointer und Arrays

  • Erklären Sie die Unterschiede folgender Anweisungen. Welche sind erlaubt, welche nicht? Welche führen möglicherweise zu Fehlern?

    int a[10], *pa, *pb, x;
    pa = a;
    pb = new 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++);

Typ eines Pointers bei Adressarithmetik

  • Was ist der Unterschied zwischen den beiden folgenden Statements?

    ((char *)ptr)+1
    ((double *)ptr)+1
Quellen

C++: Klassen

TL;DR

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
class Fluppie {
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.)

Videos (YouTube)
Lernziele
  • (K2) Attribute von C++-Klassen sind Speicherplatz im Objekt
  • (K2) Explizite Konstruktoren
  • (K2) Problematik mit Defaultkonstruktoren/-operatoren (Pointer)
  • (K3) Konstruktoren (eigene, Default)
  • (K3) Unterschied Initialisierungslisten vs. Initialisierung im Body

OOP in C++

public abstract class Dummy {
    public Dummy(int v) { value = v; }
    public abstract int myMethod();

    private int value;
}
class Dummy {
public:
    Dummy(int v = 0);
    int myMethod();
    virtual ~Dummy();
private:
    int value;
};

OOP in C++: Unterschiede zu Java

  • 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

class Student {
    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

class Student {
    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:

class Student {
private:
    string *name;
    Date *birthday;
    double credits;
}

Warum nicht Referenzen?

Objekte erzeugen mit Konstruktoren

class Dummy {
public:
    Dummy(int c=0) { credits = c; }
private:
    int credits;
};

Erzeugen neuer Objekte:

Dummy a;
Dummy b(37);
Dummy c=99;

=> Kein Aufruf von new!

(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)

C++: Trennung .h und .cpp

// .h
class Dummy {
public:
    Dummy(int c=0);
private:
    int credits;
};
// .cpp
Dummy::Dummy(int c) {
    credits = c;
}

Klassenname ist der Scope für die Methoden

Konstruktoren: Normale (Java-like) Initialisierung

class Student {
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:

  1. Attribut wird angelegt und mit Defaultwert/-konstruktor des Datentyps initialisiert
  2. 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.

Konstruktoren: Initialisierungslisten

class Student {
public:
    Student(const string &n, const Date &d, double c)
    : name(n), birthday(d), credits(c)
    {}
private:
    string name;
    Date birthday;
    double credits;
};

In diesem Fall erfolgt die Initialisierung in nur einem Schritt:

  1. Attribut wird angelegt und direkt mit übergebenen Wert (Kopie) initialisiert

Das klappt natürlich nur, wenn ein passender Konstruktor für das Attribut existiert.

Achtung: Die Reihenfolge der Auswertung der Initialisierungslisten wird durch die Reihenfolge der Attribut-Deklarationen in der Klasse bestimmt!!!

Beispiel oben: Beim Anlegen von birthday im Speicher wird direkt der übergebene Wert kopiert.

Zwang zu Initialisierungslisten

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.

C++11 und delegierende Konstruktoren

class C {
    // 1: Normaler Konstruktor
    C(int x) { }

    // 2: Delegiert zu (1)
    C() : C(42) { }

    // 3: Rekursion mit (4)
    C(char c) : C(42.0) { }

    // 4: Rekursion mit (3)
    C(double d) : C('a') { }
};

Delegierende Konstruktoren gibt es ab C++11:

  • 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:

    class Dummy {
    public:
        Dummy(int c=0);
    };
    
    Dummy a;
    a = 37;     // Zuweisung(!)
    

    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

    explicit Dummy(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++):

class Student {
    private String name;
    private Date birthday;
    private double credits;
}
class Student {
private:
    string name;
    Date birthday;
    double credits;
};

Konstruktoren

  • Wie kann der implizite Aufruf eines Konstruktors verhindert werden (beispielsweise in Dummy b; b=3;)?
  • In welchen Fällen muss eine Initialisierung von Attributen in der Initialisierungsliste stattfinden?
  • Wie können/müssen static Attribute initialisiert werden?
Quellen

C++: Big 3

TL;DR

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 static nicht 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.

Videos (YouTube)
Lernziele
  • (K2) Problematik mit Defaultkonstruktoren/-operatoren (Pointer)
  • (K2) Problematik konstanter Funktionen, wann werden diese aufgerufen
  • (K3) 'Big Three': Destruktor, Copy-Konstruktor, Zuweisungsoperator

Big Three

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".

class Dummy {
public:
    Dummy(int a=0);
    Dummy(const Dummy &d);
    ~Dummy();
    Dummy &operator=(const Dummy &d);
private:
    int value;
};
Dummy::Dummy(int a): value(a) {}
Dummy::Dummy(const Dummy &d): value(d.value) {}
Dummy::~Dummy() {}
Dummy::Dummy &operator=(const Dummy &d) {
    if (this != &d) { value = d.value; }
    return *this;
}

Big Three: Destruktor

  • Syntax: Dummy::~Dummy(); (Konstruktor mit vorgesetzter Tilde)
  • Wird aufgerufen:
    • wenn ein Objekt seinen Scope verlässt, oder
    • wenn explizit delete für einen Pointer auf ein Objekt (auf dem Heap!) aufgerufen wird
  • Default-Destruktor ruft Destruktoren der Objekt-Attribute auf

Big Three: Copy-Konstruktor

  • Syntax: Dummy::Dummy(const Dummy &);
  • Wird aufgerufen bei:
    • Deklaration mit Initialisierung mit Objekt
    • Objektübergabe und -rückgabe mit Call-by-Value
    • Nicht bei Zuweisung
  • Default-Copy-Konstruktor kopiert einfach elementweise => bei Pointern also nur flache Kopie

"Merkregel": Linke Seite unfertiges Objekt (noch zu bauen), rechte Seite fertiges Objekt.

Big Three: Zuweisungsoperator

  • Syntax: Dummy &Dummy::operator=(const Dummy &)
  • Wird aufgerufen:
    • bei Zuweisung bereits initialisierter Objekte
  • Default-Zuweisungsoperator kopiert einfach elementweise => bei Pointern also nur flache Kopie

"Merkregel": Linke Seite fertiges Objekt, rechte Seite fertiges Objekt.

Big Three: Defaults

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:

class Dummy {
public:
    Dummy(int initValue = 0) {
        value = new int(initValue);
    }

    int getValue() {
        return *value;
    }
    void setValue(int a) {
        *value = a;
    }
private:
    int *value;
};

void main() {
    // oberer Teil der Abbildung
    Dummy a(2);
    Dummy b = a;
    Dummy c;

    // unterer Teil der Abbildung
    c=b;
    a.setValue(4);
}

Analyse:

  1. Es sind Pointer im Spiel. Es wurde ein eigener Konstruktor definiert, aber kein Copy-Konstruktor, d.h. diesen "spendiert" der Compiler.
  2. Beim Anlegen von a wird auf dem Heap Speicher für einen int reserviert und dort der Wert 2 hineingeschrieben.
  3. 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.
  4. 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?

class Foo {
public:
    ~Foo() {
        delete this;
    }
};

Analyse: Wir haben hier gleich zwei Probleme:

  1. 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!

  2. 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 kein delete auf "sich selbst" (das ist wie oben demonstriert sogar schädlich)!

Warnung

Auch wenn es zunächst irgendwie sinnvoll aussieht - rufen Sie niemals nie delete this im Destruktor auf!

C++11: default und delete

class Dummy {
public:
    Dummy() = default;
    Dummy(int a) { value = a; }
    Dummy(const Dummy &a) = delete;

private:
    int value;
    Dummy &operator=(const Dummy &d);
};
  • C++ erzeugt etliche triviale Methoden/Operatoren, sofern man diese nicht selbst definiert:
    • Methoden:
      • Standardkonstruktor
      • Copy-Konstruktor
      • Zuweisungsoperator
      • Destruktor
    • Operatoren:
      • Operator new
      • Operator delete
      • Adresse von
      • Indirektion
      • Elementzugriff
      • Elementindirektion
  • Vor C++11: Default-Methode/-Operator verbieten: Sichtbarkeit auf private setzen (Definition nicht nötig)
  • Ab C++11: Schlüsselwort delete: Entfernt Default-Methode/-Operator
  • C++11: Default-Methode/-Operator zusätzlich zu selbst implementierten: Schlüsselwort default

Statische Methoden und Attribute

class Studi {
    static int getCount();
    static int count;
};
int Studi::count = 0;

int Studi::getCount() {
    return Studi::count;
}
  • Deklaration als static nicht in Implementierung wiederholen
  • Statische Attribute: Initialisierung immer außerhalb der Klasse!

Konstante Methoden und Kontexte

class Studi {
    int getCredits() const;
    int getCredits();
};
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.

Wrap-Up

  • Klassen: Destruktoren, Copy-Konstruktor, Zuweisungsoperator
  • Vorsicht mit Default-*struktoren/-operatoren
  • Statische Methoden und Attribute:
    • Deklaration als static nicht in Implementierung wiederholen
    • Statische Attribute: Initialisierung außerhalb der Klasse!
  • Konstante Methoden und Kontexte
    • const gehört zur Signatur der Methode!
    • Konstante Methoden dürfen auf konstanten Objekten/Referenzen aufgerufen werden
Challenges

Konstruktor, Copy-Konstruktor, Zuweisungsoperator?

  • 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"

  1. Beschreiben Sie den Unterschied der folgenden beiden Codeblöcke (A sei eine beliebige Klasse):

    A a, b = a;
    A a, b; b = a;
  2. 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.

  3. 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?
    

Quiz: Was passiert bei den folgenden Aufrufen?

class Foo {
public:
    const Foo &bar(const vector<Foo> &a) { return a[0]; }
};

int main() {
    Foo f;  vector<Foo> a = {"hello", "world", ":)"};

    Foo s1 = f.bar(a);
    const Foo &s2 = f.bar(a);
    Foo &s3 = f.bar(a);
    Foo s4;
    s4 = f.bar(a);

    return EXIT_SUCCESS;
}
Quellen

C++: Operatoren

TL;DR

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.

Videos (YouTube)
Lernziele
  • (K2) Implizite Typkonvertierungen bei Operatoren
  • (K3) Überladen von Operatoren (innerhalb bzw. außerhalb einer Klasse)
  • (K3) Anwendung der Deklaration als friend
  • (K3) Implementierung von Post- und Präfix-Operatoren

Überladen von Operatoren in Klassen

MyString a, b("hallo");
a = b;      // ???
a.operator=(b);

Aufruf a=b ist äquivalent zu a.operator=(b)

Überladen ähnlich wie bei Methoden:

class MyString {
    MyString &operator=(const MyString &s) {
        if (this != &s) {
            // mach was :-)
        }
        return *this;
    }
};

Analog weitere Operatoren, etwa operator==, operator+, ... überladen

Überladen von Operatoren außerhalb von Klassen

MyString a("hallo");
cout << a << endl;
class MyString {
    ostream &operator<<(ostream &o) { return o << str; }
};

So funktioniert das leider nicht!

  • Erinnerung: cout << a entspricht cout.operator<<(a)
    • Operator kann nicht in MyString überladen werden!
    • Klasse ostream müsste erweitert werden => Geht aber nicht, da System-weite Klasse!

=> Lösung: Operator außerhalb der Klasse überladen => 2 Parameter

Überladen von Operatoren außerhalb von Klassen (cnt.)

Operator außerhalb der Klasse überladen => 2 Parameter

ostream &operator<<(ostream &out, const MyString &s) {
    return out << s.str;
}
  • Nachteil: Benötigt Zugriff auf Klassen-Interna
    • entweder umständlich über Getter-Funktionen

    • oder als friend der Klasse MyString deklarieren

      Alternativ Zugriffsmethoden (aka Getter) nutzen wie toString() ...

Anmerkung: Rückgabe der Referenz auf den Stream erlaubt die typische Verkettung: cout << s1 << s2 << endl;

Meine Freunde dürfen in mein Wohnzimmer

void test();

class TestDummy {
    int ganzTolleMethode();
};


class Dummy {
    private:
        int *value;

    friend class TestDummy;
    friend int TestDummy::ganzTolleMethode();
    friend void test();
};

(Fast) alle Operatoren lassen sich überladen

  • Alle normalen arithmetischen Operatoren

  • Zuweisung, Vergleich, Ein-/Ausgabe

  • Index-Operator [], Pointer-Dereferenzierung * und ->, sowie (), new und delete (auch in []-Form)

  • Ausnahmen:

    1. .
    2. ::
    3. ?:
    4. sizeof
  • Anmerkungen:

    • Beim Überladen muss die Arität erhalten bleiben
    • Nur existierende Operatoren lassen sich überladen => Es lassen sich keine neuen Operatoren erschaffen

Vgl. Tabelle 9.1 (S. 318) im [Breymann2011]

Implizite Typkonvertierungen bei Aufruf

MyString s;
s != "123";     // ???
"123" != s;     // ???
  • Operatoren in Klasse überladen: Typ der linken Seite muss exakt passen

    class MyString {
    public:
        MyString(const char *s = "");
        bool operator!=(const MyString&);
    };
    
    MyString s;
    s != "123";    // impliziter Aufruf des Konstruktors, danach MyString::operator!=
    "123" != s;    // KEIN operator!=(char*, MyString&) vorhanden!
    

    Das ist letztlich wie bei einem Methodenaufruf: Um die richtige Methode aufzurufen, muss der Typ (die Klasse) des Objekts bekannt sein.

  • Operatoren außerhalb überladen: Konvertierung auf beiden Seiten möglich

    class MyString {
    public:
        MyString(const char *s = "");
    };
    bool operator!=(const MyString&, const MyString&);

NIEMALS beide Formen gleichzeitig für einen Operator implementieren!

Anmerkung zu "++" und "-$\,$-" Operatoren: Präfix und Postfix

  • Präfix: o1 = ++o2;

    • Objekt soll vor Auswertung inkrementiert werden
    • Signatur: Typ &operator++()
  • Postfix: o1 = o2++;

    • Objekt soll erst nach Auswertung inkrementiert werden
    • Signatur: Typ operator++(int) (=> int dient nur zur Unterscheidung der Präfix-Variante, wird nie benutzt)

Weitere Anmerkungen

  • Operatoren werden nicht vom System zusammengesetzt

    • operator+ und operator+= sind zwei verschiedene Operatoren!
    • Implementierung ist prinzipiell unabhängig! => Erwartung: operator+= $\;==\;$ (operator+ $\;+\;$ operator=)
  • Operatoren lassen sich in C++ verketten:

    Dummy a(0); Dummy b(1); Dummy c(2);
    a = b = c;  // a.operator=(b.operator=(c));
    
  • Übertreiben Sie nicht!

    Firma f;
    Person p;
    f += p;  // ??!
    

    Nutzen Sie im Zweifel lieber Methoden mit aussagekräftigen Namen!

Wrap-Up

  • Überladen von Operatoren (innerhalb und außerhalb einer Klasse)
    • Innerhalb: 1 Parameter (Objekt auf der rechten Seite)
    • Außerhalb: 2 Parameter
  • Zugriff auf Attribute: friend einer Klasse
  • Implementierung von Post- und Präfix-Operatoren
Challenges

Operator "++"

Betrachten Sie die folgende Klasse:

class Studi {
public:
    Studi(int credits);
    ~Studi();
private:
    int *credits;
};

Implementieren Sie den operator++ sowohl in der Präfix- als auch in der Postfix-Variante.

C'toren und Operatoren: Was muss noch deklariert werden?

class Studi {
public:
    Studi(int credits);
private:
    int *credits;
};

int main() {
    Studi a(1), b, *c = new Studi(99);
    b = *c+a+1;
    std::cout << "b: '" << b << "' credits" << std::endl;

    return 0;
}

Schreiben Sie Code, damit folgender Code kompiliert:

test wuppie;
bool fluppie = wuppie(3);
Quellen

C++: Vererbung und Polymorphie

TL;DR

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.

Lernziele
  • (K2) Unterschied zwischen public- und private-Vererbung
  • (K2) Unterschied Überladen und Überschreiben
  • (K2) Slicing in C++
  • (K2) Probleme bei Mehrfachvererbung und Einsatz virtueller Basisklassen
  • (K3) public-Vererbung in C++
  • (K3) Verkettung von Operatoren und *struktoren
  • (K3) Statische und dynamische Polymorphie in C++
  • (K3) Abstrakte Klassen in C++
  • (K2) Probleme bei Mehrfachvererbung und Einsatz virtueller Basisklassen
  • (K3) Praktischer Umgang mit Mehrfachvererbung

Vererbung: "IS-A"-Beziehung zw. Klassen

class Student : public Person { ... }
Student(const string &name = "", double c = 0.0)
: Person(name), credits(c) { }

Student(const Student &s)
: Person(s), credits(s.credits) { }

Analog zu Java:

  • Student: abgeleitete Klasse
  • Person: Basisklasse
  • : public: Vererbungsbeziehung (analog zu extends in Java)
  • public-Vererbung: Verhalten wie in Java
  • Hinweis: Es gibt weitere Spielarten (protected, private), vgl. Semesterliteratur
  • Ab C++11:
    • Schlüsselwort override: Die Methode muss eine virtuelle Methode der Klassenhierarchie überschreiben.
    • Schlüsselwort final: Die virtuelle Methode darf nicht in abgeleiteten Klassen überschrieben werden.

Vererbung und Konstruktoren

  • Defaultkonstruktoren werden automatisch richtig verkettet
    • zuerst Aufruf des Basisklassen-Konstruktors
    • anschließend Behandlung der zusätzlichen Attribute
  • Eigene Konstruktoren verketten:
    • Zuerst Basisklassen-Konstruktor aufrufen (in Initialisierungsliste!) => Konkreten Konstruktor nehmen, nicht super wie in Java

Vererbung und Destruktoren

  • Defaultdestruktoren werden automatisch richtig verkettet
    • zuerst werden die Destruktoren der zusätzlichen Attribute aufgerufen
    • dann der Destruktor der Basisklasse
  • Eigene Destruktoren werden automatisch verkettet
  • Destruktor abgeleiteter Klasse muss sich nur um zusätzliche Attribute kümmern

Vererbung und Operatoren

  • Defaultoperatoren werden automatisch richtig verkettet
    • zuerst Aufruf des Basisklassen-Operators
    • anschließend Behandlung der zusätzlichen Attribute
  • Eigene Operatoren am Beispiel Zuweisungsoperator:
    • Zuerst den Zuweisungsoperator der Basisklasse aufrufen

    • Zugriff über Superklassennamen und Scope-Operator (nicht mit super!)

      const Student &operator=(const Student &s) {
          if (this != &s) {
              Person::operator=(s);
              credits = s.credits;
          }
          return *this;
      }

Vererbung von Freundschaften

  • Freundschaften werden nicht vererbt!
  • friends der Basisklasse haben keinen Zugriff auf zusätzliche private Attribute/Methoden der Unterklassen
  • Aber: weiterhin Zugriff auf die geerbten privaten Elemente!

Abstrakte Klassen

  • Eine Klasse ist abstrakt, wenn sie mindestens eine abstrakte Methode hat
  • Eine Methode ist in C++ abstrakt, wenn sie
    1. als virtuell deklariert ist, und
    2. der Deklaration ein "=0" folgt

Abstrakte Methoden können Implementierung haben! => Implementierung außerhalb der Klassendeklaration

class Person {
public:
    virtual string toString() const = 0;
...
};

string Person::toString() const { ... }  // Implementierung :-)

Polymorphie: Was passiert im folgenden Beispiel?

IS-A Beziehung: Objekte können als Objekte ihrer Oberklasse behandelt werden

class Person { ... }
class Student : public Person { ... }

Student s("Heinz", "heizer");
Person &p = s;

cout << s.toString() << endl;
cout << p.toString() << endl;

Antwort: Es wird die falsche Methode aufgerufen!

  • s.toString() => Student::toString() => wie erwartet
  • p.toString() => Person::toString() => unerwartet!

Polymorphie: statisch und dynamisch

  • C++ entscheidet zur Kompilierzeit, welche Methode aufgerufen wird

    • p ist vom Typ Person => p.toString() => Person::toString()
    • Dieses Verhalten wird statisches Binden genannt.
  • Von Java her bekannt: dynamisches Binden

    • Typ eines Objektes wird zur Laufzeit ausgewertet

Dynamisches Binden geht auch in C++ ...

Für dynamische Polymorphie müssen in C++ drei Bedingungen erfüllt sein:

  1. Methoden in Basisklasse als virtuelle Funktion deklarieren => Schlüsselwort virtual

  2. Virtuelle Methoden in Subklasse normal überschreiben (gleiche Signatur)

    Zusätzlich muss der Rückgabetyp exakt übereinstimmen (Ausnahme: Rückgabe Pointer/Referenz auf abgeleitete Klasse)

  3. Objekte mittels Basisklassen-Referenzen bzw. -Pointer zugreifen (siehe nächste Folie)

class Person {
    virtual string toString() const { ... }
};

Vorsicht Slicing

Student s("Heinz", 10.0);
Person p("Holger");

p = s;
cout << "Objekt s (Student): " << s.toString() << endl;
cout << "Objekt p (Person):  " << p.toString() << endl;

=> p ist vom Typ Person

  • 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) und virtuelle 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++

class HiWi: public Student, public Angestellter {...};

Hinweis Speicherlayout ...

Problem 1: Gleichnamige Methoden aus Basisklassen geerbt

Namenskollision bei Mehrfachvererbung auflösen:

  • Scope-Operator :: nutzen:

    HiWi h("Anne", 23.0, 40.0);
    
    cout << h.Student::toString() << endl;
    cout << h.Angestellter::toString() << endl;
    cout << h.Student::getName() << endl;
    cout << h.Angestellter::getName() << endl;
  • Methode in abgeleiteter Klasse überschreiben

    HiWi h("Anne", 23.0, 40.0);
    
    cout << h.toString() << endl;
    cout << h.Student::toString() << endl;
    cout << h.Angestellter::toString() << endl;

Problem 2: Gemeinsam geerbte Attribute sind mehrfach vorhanden

Mehrfachvererbung in C++: Virtuelle Basisklassen

class Angestellter: virtual public Person {...};
class Student: virtual public Person {...};

class HiWi: 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

Sonderregeln bei virtueller Ableitung

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:

  1. Für virtuelle Basisklassen ist Mechanismus des Weiterreichens von Initialisierungswerten deaktiviert

  2. 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?!

Student *s3 = new Student("Holger", 1.0);
Person  *p  = s3;

delete p;

Vererbung

  • Welche Formen der (einfachen) Vererbung gibt es in C++ neben der public-Form noch? Was bewirken diese Formen?
  • Warum wird in C++ die public-Form der Vererbung vorgezogen (zumindest, wenn man dynamische Polymorphie nutzen will)?
  • Wie müssen Konstruktoren/Destruktoren richtig verkettet werden?
  • Arbeiten Sie das Beispiel auf S. 274 im [Breymann2011]: "Der C++ Programmierer" durch.

Virtuelle Methoden, Dynamische Polymorphie in C++

  1. Was sind virtuelle Methoden und wie setze ich diese ein?
  2. Wozu brauche ich in C++ virtuelle Klassen? Was muss beachtet werden?
  3. Was passiert in C++, wenn eine virtuelle Methode innerhalb von Konstruktoren verwendet wird? Schreiben Sie ein kurzes Programm zur Verdeutlichung.
  4. Wie verhält es sich mit der Problematik aus (a) in Java?
  5. Wie unterscheiden sich in C++ virtuelle und nicht virtuelle Destruktoren? Schreiben Sie ein kurzes Programm zur Verdeutlichung.
  6. Was passiert, wenn in C++ aus einem Destruktor heraus eine virtuelle Methode aufgerufen wird?

Hinweis: Möglicherweise müssen jeweils mehrere Fälle betrachtet werden!

Quellen

C++: Templates

TL;DR

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:

  1. Zuerst die exakt passende normale Funktion,
  2. dann ein passendes spezialisiertes Template (bei mehreren passenden spezialisierten Templates das am meisten spezialisierte Template),
  3. dann das allgemeine ("primäre") Template,
  4. 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".

Lernziele
  • (K2) Unterschied zu Java bei der Nutzung von Templates
  • (K3) Erstellen und spezialisieren von Funktions-Templates
  • (K3) Unterschied zwischen überladenen Funktionen und Funktions-Templates
  • (K3) Aufruf (Nutzung) von Funktions-Templates
  • (K2) Unterschied zu Java bei der Nutzung von Templates
  • (K3) Erstellen und spezialisieren von Klassen-Templates
  • (K3) Nutzung von Methoden-Templates innerhalb von Klassen-Templates
  • (K3) Aufruf (Nutzung) von Klassen-Templates

Vergleichsfunktion für zwei Integer?

bool cmp(const int &a, const int &b) {
    return a<b;
}
  • Und für double?
  • Und für string?
  • ...

=> Präprozessor-Makro?

=> Funktionen überladen?

  • Überladen von Funktionen:
    • Ähnliche Funktionalität für unterschiedliche Datentypen
    • Mühselig, wenn exakt gleiche Funktionalität!
  • (bessere) Antwort: Funktions-Templates
    • Templates: Funktionen mit parametrisierten Datentypen
    • Deklaration/Definition für (zunächst) unbestimmte Datentypen
    • Bei Verwendung der Funktion:
    • Konkretisierung der Datentypen
    • Compiler erzeugt automatisch passende Funktionsinstanz

Definition von Funktions-Templates

template <typename T>
bool cmp(const T &a, const T &b) {
    return a<b;
}
  • Statt typename kann auch class geschrieben werden

  • Konvention:

    • typename wenn sowohl Klassen als auch Basistypen
    • class falls eher Klassen-Typen

    => class gilt als veraltet, deshalb immer typename verwenden!

  • Bei mehreren Typen "typename NAME" wiederholen (Komma-separierte Liste in < und >)

    beispielsweise so: (Achtung, soll nur die Verwendung demonstrieren, hat sonst keinen Sinn)

    template <typename T1, typename T2, typename T3>
    T1 cmp(const T2 &a, const T3 &b) {
        return a<b;
    }

Vorsicht: Im Beispiel oben muss operator< für die verwendeten Typen T implementiert sein! (sonst Fehler zur Compile-Zeit)

Bestimmung der Template-Parameter I: Typ-Inferenz

Das Funktions-Template wird wie eine normale Funktion aufgerufen ... => Der Compiler inferiert Typen und erzeugt passende Funktionsinstanz(en).

template <typename T>
bool cmp(const T &a, const T &b) {
    return a<b;
}

int main() {
    cmp(3, 10);                         // cmp(int, int)
    cmp(2.2, 10.1);                     // cmp(double, double)
    cmp(string("abc"), string("ABC"));  // cmp(string, string)
    cmp(3, 3.4);                        // Compiler-FEHLER!!!
}

Vorsicht bei Typ-Inferenz: Typen müssen exakt passen!

Bestimmung der Template-Parameter II: Explizite Angabe

template <typename T>
bool cmp(const T &a, const T &b) {
    return a<b;
}

int main() {
    cmp<int>('a', 'A');     // cmp(int, int)
    cmp<int>(3, 3.4);       // cmp(int, int)
}

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) { ... }


int main() {
    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() {
    return 0;
}

int main() {
    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;
}
// Spezialisiertes Template
template <>
bool cmp<int>(const int &a, const int &b) {
    return abs(a)<abs(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>():

// Spezialisiertes Template
template <>
bool cmp(const int &a, const int &b) {
    return abs(a)<abs(b);
}

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;
}

bool cmp(int a, int b) {
    return abs(a)<abs(b);
}

int main() {
    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):
    1. Zuerst exakt passende normale Funktion,
    2. dann passendes spezialisiertes Template (bei mehreren passenden spezialisierten Templates das am meisten spezialisierte Template, ohne Casts),
    3. dann das allgemeine ("primäre") Template (ohne Casts),
    4. 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>(const int &a, const int &b);

Damit könnte man die cmp-Funktion nicht mehr für int benutzen (Compiler- bzw. Linker-Fehler).

Klassen-Templates in C++

template <typename T>
class Matrix {
    Matrix(unsigned rows = 1, unsigned cols = 1);
    vector<vector<T> > xyField;
};

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.

int main() {
    Matrix<int> m1;
    Matrix<double> m2(12, 3);
}

Template-Parameter gehören zur Schnittstelle und müssen bei der Instantiierung angegeben werden. Matrix m; würde im obigen Beispiel nicht funktionieren.

template <typename T>
Matrix<T>::Matrix(unsigned rows, unsigned cols) { ... }

Klassen-Templates in C++ (Variante mit Konstanten)

template <typename T, unsigned rows, unsigned cols>
class Matrix {
    Matrix();
    vector<vector<T> > xyField;
};

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.

int main() {
    Matrix<int, 1, 1> m1;
    Matrix<double, 12, 3> m2;
    Matrix<string, 1, 1> m3;
}

Beispiel: Konstanten als Template-Parameter

template <int I>
void print() {
    cout << I;
}

print<5>();
template <unsigned u>
struct MyClass {
    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):

template <unsigned u>
struct MyClass {
    enum { X = u };
    static unsigned Y;
    unsigned Z;

    MyClass() : Z(u) {}
};

template <unsigned u>
int MyClass<u>::Y = u;

int main() {
    cout << MyClass<2>::X << endl;
    cout << MyClass<2>::Y << endl;
    cout << MyClass<2>().Z << endl;
}

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!

Klassen-Templates mit Defaults

template <typename T = int, unsigned rows = 1, unsigned cols = 1>
class Matrix {
    Matrix();
    vector<vector<T> > xyField;
};
int main() {
    Matrix<> m1;    // Leere spitze Klammern Pflicht!
    Matrix<double, 12, 3> m2;
    Matrix<string> m3;
}

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.

Klassen-Templates in C++ spezialisieren

template <typename T>
class Matrix {
    Matrix(unsigned rows, unsigned cols);
    vector< vector<T> > xyField;
};
template <>
class Matrix<uint> {
    Matrix(unsigned rows, unsigned cols);
    vector< vector<uint> > xyField;
};
Hinweis auf Implementierung außerhalb der Klasse

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 ... ;-)

// Implementierung fuer primaeres Template
template <typename T>
Matrix<T>::Matrix(unsigned rows, unsigned cols) { ... }

// Implementierung fuer vollstaendig spezialisiertes Template
Matrix<uint>::Matrix(unsigned rows, unsigned cols) { ... }

Partielle Spezialisierung

template <typename T1, typename T2>
class Array {
    Array();
    vector<T1> v;
    vector<T2> w;
};
template <typename T>
class Array<T, int> {
    Array();
    vector<T> v;
    vector<int> w;
};

ACHTUNG: Implementierung außerhalb der Klasse: Bei den Methoden des partiell spezialisierten Templates muss das "template<T>" wieder benutzt werden!

// Implementierung fuer primaeres Template
template <typename T1, typename T2>
Array<T1, T2>::Array() {}

// Implementierung fuer partiell spezialisiertes Template
template <typename T>
Array<T, int>::Array() {}

Vergleich verschiedene Spezialisierungen

Allgemeine Templates vs. partiell spezialisierte Templates vs. vollständig spezialisierte Templates

// Primaeres (allgemeines) Template
template <unsigned line, unsigned column>
class Matrix {
public:
    Matrix();
};

// Partiell spezialisiertes Template
template <unsigned line>
class Matrix<line, 1> {
public:
    Matrix();
};

// Vollstaendig spezialisiertes Template
template <>
class Matrix<3, 3> {
public:
    Matrix();
};


// Aufrufe
int main() {
    Matrix<3, 4> matrix;        // allg. Template
    Matrix<20, 1> vector;       // partiell spez. Templ.
    Matrix<3, 3> dreiKreuzDrei; // vollst. spez. Templ.
}


// Implementierung
template <unsigned line, unsigned column>
Matrix<line, column>::Matrix() { cout << "allgemeines Template" << endl; }

template <unsigned line>
Matrix<line, 1>::Matrix() { cout << "partiell spezialisiertes Template" << endl; }

Matrix<3, 3>::Matrix() { cout << "vollstaendig spezialisiertes Template" << endl; }

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>
class Array {
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++

  1. 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.

  2. 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.

  3. 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.

  4. 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);
  5. Erklären Sie nun folgenden Code-Schnipsel:

    template<typename T2, typename T1, typename T3>
    void fkt(T2 a, T3 b, T2 c, int d) { ... }
    void fkt(int a, int b, int c, int d) { ... }
    
    int main() {
        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:

template <typename T, size_t size, typename alloc_t = std::allocator<T>>
class RingBuffer {
public:
    typedef AllocatorType alloc_t;

    RingBuffer(const alloc_t &rb_allocator = alloc_t());
    ~RingBuffer();
    void writeBuffer(T *data);

private:
    alloc_t m_allocator;
    size_t count;
    size_t head;
    T **elems;
};
Quellen