pdf-Version - Beispielhaftes Institut

Werbung
Gottfried Wilhelm Leibniz Universität Hannover
Fakultät für Elektrotechnik und Informatik
Institut für Praktische Informatik
Fachgebiet Programmiersprachen und Übersetzer
Bachelorarbeit
Untersuchung der Grenzen eines
Typinferenz-Systems am Beispiel von Pascal
Paul-Gabriel Müller
17. August 2007
Erstprüfer: Prof. Dr. Rainer Parchmann
Zweitprüfer: Prof. Dr. Kurt Schneider
Hiermit versichere ich, dass ich diese Arbeit selbstständig verfasst und keine außer den angegebenen Quellen oder Hilfsmitteln verwendet habe.
______________________________
(Paul-Gabriel Müller)
Inhaltsverzeichnis
1
Einleitung
1.1 Aufgabenstellung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.2 Thema der Arbeit . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.3 Aufbau der Arbeit . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
2
Grundlagen
2.1 Typinferenz und Typprüfung . . .
2.1.1 Typen . . . . . . . . . . .
2.1.2 Typisierung . . . . . . . .
2.1.3 Typinferenz . . . . . . . .
2.1.4 Polymorphismus . . . . .
2.2 ML . . . . . . . . . . . . . . . .
2.3 Pascal . . . . . . . . . . . . . . .
2.3.1 Syntax . . . . . . . . . .
2.3.2 Das Typsystem von Pascal
2.3.3 Besonderheiten von „TIP“
2.4 Compiler . . . . . . . . . . . . .
2.4.1 Lexikalische Analyse . . .
2.4.2 Syntaktische Analyse . . .
2.4.3 Semantische Analyse . . .
2.4.4 Code-Erzeugung . . . . .
3
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
Realisierung
3.1 Aufbau . . . . . . . . . . . . . . . . . . . . . . . .
3.2 Typen . . . . . . . . . . . . . . . . . . . . . . . . .
3.3 Operationen . . . . . . . . . . . . . . . . . . . . . .
3.4 semantische Analyse . . . . . . . . . . . . . . . . .
3.4.1 Integer und Real . . . . . . . . . . . . . . .
3.4.2 Substitutionstabelle . . . . . . . . . . . . . .
3.4.3 Drei Implementationen des Semantic Walkers
3.4.4 Umgang mit Funktionen . . . . . . . . . . .
3
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
5
5
6
6
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
8
8
8
9
10
12
15
17
18
19
20
21
22
22
23
23
.
.
.
.
.
.
.
.
25
25
27
30
31
32
33
35
38
4
5
Ergebnisse
4.1 Testprogramme . . . . . . . . . . . . . . . . . . . . . . .
4.1.1 „ueberschreiben.pas“ . . . . . . . . . . . . . . . .
4.1.2 „array.pas“ . . . . . . . . . . . . . . . . . . . . .
4.1.3 „set.pas“ . . . . . . . . . . . . . . . . . . . . . .
4.1.4 „record.pas“ . . . . . . . . . . . . . . . . . . . .
4.1.5 „zeiger.pas“ . . . . . . . . . . . . . . . . . . . . .
4.1.6 „intreal.pas“ . . . . . . . . . . . . . . . . . . . .
4.1.7 „intreal2.pas“ . . . . . . . . . . . . . . . . . . . .
4.1.8 „rekursion.pas“ . . . . . . . . . . . . . . . . . . .
4.1.9 „member.pas“ . . . . . . . . . . . . . . . . . . . .
4.1.10 „avg.pas“ . . . . . . . . . . . . . . . . . . . . . .
4.1.11 „kmp.pas“ . . . . . . . . . . . . . . . . . . . . . .
4.2 Beobachtungen und Erfahrungen . . . . . . . . . . . . . .
4.2.1 Drei Implementationen der semantischen Analyse .
4.2.2 Substitutionen von variablen Typen miteinander . .
4.2.3 Rückblick . . . . . . . . . . . . . . . . . . . . . .
4.2.4 Ausblick . . . . . . . . . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
40
40
40
41
41
42
43
44
44
45
46
47
48
49
49
50
51
52
Schluss
5.1 Fazit . . . . . . . . . . . . . .
5.2 Installation & Bedienung . . .
5.2.1 Installation mit Eclipse
5.2.2 Compiler-Argumente .
5.3 Quellen und Danksagungen . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
53
53
54
54
54
56
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
Kapitel 1
Einleitung
1.1 Aufgabenstellung
Untersuchung der Grenzen eines Typinferenz-Systems
Hintergrund
Bei Haskell oder ML handelt es sich um Sprachen mit einer strengen Typprüfung, d.h. Fehler,
die durch die falsche Verwendung von Datentypen entstehen, werden bereits beim Kompilieren aufgedeckt. Allerdings findet man im Programmcode häufig überhaupt keinen Hinweis auf
die verwendeten Typen. Dies liegt daran, dass ML und Haskell nicht einfach nur auf korrekte
Verwendung der Typen prüft, sondern mit Typinferenz die korrekten Typen berechnen. Durch
diese Vorgehensweise können Programme oft viel einfacher formuliert werden. Nun stellt sich
die Frage, ob sich dieses Verfahren auch auf andere Sprachen mit strenger Typprüfung anwenden
lässt.
Aufgabe
Im Rahmen dieser Arbeit soll untersucht werden, ob in Pascal-Programmen eine strenge Typprüfung auch dann möglich ist, wenn für einzelne Variablen nicht explizit angegeben ist, welche
Typen sie besitzen. Dafür soll versucht werden ein Pascal-Programm ohne Typinformationen
zu parsen. Dieses Programm soll dann wiederum mit Hilfe von Typinferenz untersucht werden
und dann als vollständiges Programm inklusive Typinformationen wieder ausgegeben werden.
Aufgabe ist es zu zeigen, ob es Grenzen für die Typinferenz in Pascal gibt und wo genau diese
liegen.
5
1.2 Thema der Arbeit
Pascal ist eine Sprache mit statischer Typisierung und Deklarationspflicht. In einer statisch getypten Sprache sind einmal getroffene Typfestlegungen für das ganze Programm gültig und der
Typ von Ausdrücken steht schon zur Kompilierzeit fest. Deklarationspflicht heißt, dass die Typen der verwendeten Objekte vor der ersten Benutzung angegeben werden müssen, etwa so: VAR
x:integer. Wenn es gelingt, mit Hilfe der Typinferenz die Typen der Objekte aus dem Kontext,
in dem sie benutzt werden, zu ermitteln, könnte man diese Deklarationen weglassen, ohne auf
die statische Typisierung verzichten zu müssen. Man möchte also wie zum Beispiel in PHP die
Typen der Variablen nicht explizit angeben müssen, aber trotzdem die Typen der Variablen schon
zur Laufzeit kennen. Viele funktionale Programmiersprachen wie ML oder Haskell können das
bereits, bei imperativen Programmiersprachen wie Java oder Pascal müssen die Typen in der Regel angegeben werden, wenn das Typsystem statisch ist.
Gegenstand der Betrachtungen ist Pascal mit einigen Abwandlungen. Diese Modifikation von
Pascal wird in dieser Arbeit mit „TIP“ für Typ-Inferenz Pascal bezeichnet. Wenn ich von dem
von mir geschriebenen Übersetzer schreibe, werde ich auch den Ausdruck „TIP-Compiler“ benutzen. Der TIP-Compiler ist in Java geschrieben und übersetzt ein Pascal-Programm ohne Typdeklaration in eines, in dem die Typen, soweit sie vom Compiler ermittelt werden konnten, angegeben sind.
1.3 Aufbau der Arbeit
Im Kapitel „Grundlagen“ werden einige Begriffe erklärt, die zum Verständnis der weiteren Ausführungen notwendig sind: Typen, Typsystem, Typinferenz und Polymorphismus. Es folgt ein kurzer Abstecher zur funktionalen Programmiersprache ML und insbesondere dem Typinferenzsystem dieser Sprache. Ein kurzer Abschnitt zu Pascal und den Besonderheiten der speziell für diese
Arbeit angepassten Implementation „TIP“ folgt. Schließlich wird der grundsätzliche Aufbau eines Compilers knapp erklärt.
Im Kapitel „Realisierung“ geht es um den „TIP-Compiler“. Zuerst wird der Aufbau grob umrissen, später folgen Abschnitte, die sich mit den wichtigsten Teilen des Compilers intensiver
beschäftigen.
Das Kapitel „Ergebnisse“ besteht zu einem großen Teil aus kurzen Pascal-Testprogrammen und
der dazugehörigen Ausgabe des TIP-Compilers, um beispielhaft aufzuzeigen, wozu die Typinferenz in der Lage ist, und welche Typen sie nicht heraus bekommt. Es folgt ein kurze Zusammen6
fassung der Ergebnisse.
Im Kapitel „Schluss“ wird das Fazit aus der Arbeit gezogen und die Bedienung des TIP-Compilers
beschrieben.
7
Kapitel 2
Grundlagen
2.1 Typinferenz und Typprüfung
2.1.1
Typen
Der Typ eines Objekts legt fest, welche Werte dieses Objekt annehmen kann und welche Operationen auf ihn angewendet werden können. Typen in Programmiersprachen dienen dazu, die
Korrektheit des Programms zu prüfen. Mit was für Datentypen haben wir es hier zu tun? Zum
einen geht es natürlich um einfache Basistypen wie Wahrheitswerte (boolean), ganze Zahlen
(integer), Fließkommazahlen/ Gleitkommazahlen (real) oder Zeichenketten (string). Zwischen den verschiedenen Längen von Zahlendarstellungen, etwa longint und shortint wird in
dieser Arbeit nicht unterschieden, obwohl dieser Aspekt zur Reservierung von Speicherblöcken
bei der Übersetzung in Maschinensprache natürlich wichtig ist. Der Aufwand für diese Unterscheidungen wäre angesichts der Tatsache, dass sie kein mächtigeres Typsystem ermöglichen,
für diese Arbeit unangemessen.
Zusätzlich gibt es aber auch Datentypen wie Zeiger oder Records, für die es nicht genügt zu
wissen, dass sie vom Typ pointer oder record sind, da es ebenfalls notwendig ist, den Typ
oder die Typen zu kennen, die sie charakterisieren. Mit pointer(string) könnte man also zum
Beispiel einen Zeiger auf eine Speicheradresse für den Typ string bezeichnen.
Auch Funktionen und Prozeduren haben einen Typ. γ : α1 × α2 → β ist zum Beispiel die Beschreibung des Typs einer Funktion vom Typ γ, die Argumente vom Typ α1 und α2 bekommt
und als Ergebnis einen Wert vom Typ β zurückgibt. Die griechischen Buchstaben stehen dabei
als Platzhalter für konkrete Typen wie integer oder pointer(set(integer)).
8
2.1.2
Typisierung
Um Laufzeitfehler zu vermeiden, wird in den meisten Programmiersprachen eine Typprüfung
vorgenommen. Im Kontext bestimmter Funktionsaufrufe oder Operationen wird etwa ein bestimmter Typ oder ein Element einer Menge von Typen erwartet. Befindet sich an dieser Stelle
im Programm ein Objekt eines anderen Typs, liegt ein Fehler vor, der behandelt werden muss, je
nach Programmiersprache und Implementation z.B. mit dem Abbruch des Programms, automatischer Umwandlung des Typs oder der Ausgabe einer Warnung.
Wird diese Prüfung beim Kompilieren durchgeführt, so spricht man von statischer Typprüfung,
wird die Typprüfung erst zur Laufzeit vorgenommen, so bezeichnet man das als dynamische Typprüfung. Statische Typprüfung zwingt den Programmierer zu mehr Disziplin und kann zu einem
effizienteren Laufzeitverhalten führen, weil die Typen nicht erst zur Laufzeit ermittelt werden
müssen. Andererseits schränkt es die Flexibilität bei der Programmierung ein, wenn der genaue
Typ für jedes Objekt bekannt sein muss, und der Compiler wird durch die aufwändigere Analyse
umfangreicher. Java, C oder Pascal sind alle statisch typisiert, Perl, Python und PHP dynamisch.
Eine weitere Eigenschaft, die Typsysteme unterscheidet, ist starke und schwache Typisierung.
In stark oder streng typisierten Sprachen ist eine einmal vollzogene Zuordnung einer Variable
zu einem Datentyp endgültig. Auch implizite Typumwandlungen werden größtenteils vermieden. Durch strenge Typisierung wird angestrebt, das Laufzeitverhalten eines Programms sicher
voraussagen zu können. Pascal und Java gelten beide als relativ streng typisiert, wobei in Pascal
implizite Typumwandlungen von Integer zu Real vorkommen können. Das Gegenstück zu stark
typisierten Sprachen sind Sprachen mit schwacher Typisierung, wie etwa PHP oder Perl.
In Pascal oder Java muss außerdem der Typ einer Variablen vor der ersten Verwendung angegeben werden. Ist das der Fall, spricht man von expliziter Typisierung. Im Gegensatz dazu wird
bei implizit getypten Sprachen der Typ eines Objekts aus dem Kontext, in dem er benutzt wird,
geschlossen. Diesen Vorgang nennt man Typableitung oder Typinferenz.
9
2.1.3
Typinferenz
Wenn eine Typprüfung durchgeführt werden soll, ohne dass die Typen explizit angegeben werden müssen, so spricht man von Typinferenz. Oft ist es nämlich möglich, aus den Operationen,
die auf ein Objekt angewendet werden, auf dessen Typ zu schließen. Beispielsweise ist offensichtlich, dass die Variable name vom Typ string sein muss, wenn etwa folgende Zuweisung
angewendet wird: name := 'Mueller'. In einer streng typisierten Sprache dürfte jetzt name nur
vom Typ string sein, eine spätere Zuweisung name := 7 zum Beispiel muss bei der Typprüfung entdeckt werden und zu einer Fehlermeldung führen.
Dazu werden den Variablen zwischenzeitlich variable Typen zugewiesen, die man dann abhängig
von den Informationen im Programm durch konkrete Typen ersetzt. Diese Ersetzungen werden
als Substitutionen bezeichnet.
Als kurzes Beispiel zur Vorgehensweise bei der Typinferenz betrachten wir folgende Wertzuweisung:
a[i] := 2*(b - i);
Diese Anweisung lässt sich auch als Baum darstellen:
:=A
z
zz
zz
z
z
AA
AA
AA
a[i]2
a
22
22
i
2
∗0
000
00
−0
000
0
i
b
Wir nehmen an, dass vor der Analyse der Anweisung nichts über die Typen bekannt ist. αn dient
als Bezeichner für variable Typen. In der folgenden Abbildung kann man sehen, welche Typen
die einzelnen Variablen bisher haben.
:= SSSS
SSS
qqq
q
SSS
q
q
SSS
q
q
SSS
qq
S
a[i]
w ∗ 77
:
w
:
77
w
::
ww
77
::
ww
w
7
w
a(α1 )
i(α2 )
2(integer)
−
777
77
7
b(α3 )
i(α2 )
10
Betrachten wir zuerst den Feldzugriff links:
:= SSSS
SSS
qqq
q
SSS
q
q
SSS
q
q
SSS
qq
S
a[i]
w ∗ 77
A \::
w
77
w
::α2
α1 ww
77
::
ww
w
7
w
a(α1 )
i(α2 )
2(integer)
−
777
77
7
b(α3 )
i(α2 )
Da i als Index des Feldes dient, ist klar, dass es sich um eine Variable des Typs integer handeln
muss. Man kann also die Substitution α2 7→ integer vornehmen. Auch ist aus der Benutzung
von a ersichtlich, dass a ein Array ist, der genaue Typ des Arrays kann aber noch nicht bestimmt
werden, also wird hier ein neuer variabler Typ eingeführt: α4 . α1 wird folgendermaßen ersetzt:
α1 7→ array(α4 ). Daraus folgt, dass der Rückgabetyp des Feldzugriffs a[i] α4 ist.
5 := SSS
SSS
jjjj
j
j
SSS
j
jj
SSS
j
j
j
SSS
jjj
SS
a[i](α4I)
w ∗ 77
III
r
77
ww
r
w
r
III
r
77
ww
r
w
r
I
7
ww
rr
α4
a(array(α4 ))
i(integer)
2(integer)
−A
b(α3 )
AA
AA
AA
i(integer)
Schauen wir nun auf die rechte Seite der Wertzuweisung:
j5 := SSSS
SSS
jjjj
j
j
SSS
j
SSS
jjj
j
j
j
SSS
j
S
a[i](α4I)
w ∗ 77
w
III
r
77
w
r
w
r
w
I
77
III
rr
ww
r
w
r
7
r
w
α4
a(array(α4 ))
i(integer)
2(integer)
−
C `AAA integer
AA
AA
α3
b(α3 )
i(integer)
Wie jetzt mit dem Ausdruck b-i umzugehen ist, und welche Typen für die Operanden b und
i möglich sind, hängt von der Spezifikation der konkreten Programmiersprache ab. „-“ könnte
etwa eine Operation mit dem Typ integer × integer → integer sein, aber auch real × real →
real als Typ haben, oder sogar auf ganz andere Typen definiert sein. Machen wir es uns für dieses
Beispiel mal einfach und nehmen an, dass „-“ die Subtraktion zweier ganzer Zahlen ist, dessen
Ergebnis ebenfalls eine ganze Zahl ist: integer × integer → integer.
11
Dann bekommt man also heraus, dass die Substitution α3 7→ integer möglich ist. Der Typ von
i ist uns ja schon vorher bekannt gewesen.
5 := UUUU
UUUU
jjjj
j
j
j
UUUU
jj
j
j
UUUU
j
UUUU
jjj
a[i](α4I)
9∗
rr `BBB
III
r
r
integer rrr
BBinteger
r
III
r
BB
rr
rr
r
I
r
r
B
r
r
α4
a(array(α4 ))
i(integer)
2(integer)
||
||
|
||
−A
AA
AA
AA
i(integer)
b(integer)
Analog zu „-“ nehmen wir an, dass auch „*“ als Operation mit dem Typ integer × integer →
integer definiert ist.
5 := jUUUU
UUUUinteger
jjjj
j
j
j
UUUU
jj
j
j
UUUU
j
UUUU
jjj
∗
a[i](α4I)
rr BBB
III
r
rr
r
BB
r
r
III
r
BB
rr
rr
r
I
r
r
B
r
r
α4
a(array(α4 ))
i(integer)
2(integer)
||
||
|
||
−A
AA
AA
AA
b(integer)
i(integer)
Nun kann endlich auch der Typ von a[i], also α4 substituiert werden: Da a[i] ein Wert des Typs
integer zugewiesen wird, wird dessen Typ ebenfalls auf integer gesetzt: α4 7→ integer. Auch
diese Zuweisung ist nicht unbedingt selbstverständlich. In Pascal etwa kann ein integer-Wert
auch in einen Wert vom Typ real gewandelt werden. Dann wäre dieser Schluss nicht möglich.
Für dieses Beispiel wird aber angenommen, dass ganzzahlige Werte nicht umgewandelt werden.
Durch die Typinferenz konnte hier also der Typ von a als array(integer) bestimmt werden.
2.1.4
Polymorphismus
Die Begriffe „Polymorphismus“ und „Polymorphie“ haben die gleiche Bedeutung und werden
im Folgenden auch äquivalent benutzt. Man spricht von „Polymorphismus“, wenn einem Symbol
mehr als ein Datentyp zugeordnet werden kann.
12
Verschiedene Spielarten von Polymorphismus
parametric
iiii4
universal
8
VVVV
*
ppp
p
p
p
p
inclusion
p
ppp
polymorphism
M
MMM
MMM
MMM
M&
overloading
i4
iiii
adhoc VVVVV
V*
coercion
Viele Informationen zum Polymorphismus und auch der obige Baum sind aus einem Aufsatz von
Cardelli und Wegner übernommen. [LC85]
Hinter dem Begriff „Polymorphismus“ können sich eine ganze Menge unterschiedlicher Probleme verstecken. Die beiden bekanntesten Begriffe sind wohl parametrischer Polymorphismus
und Ad-hoc Polymorphismus. Beim parametrischem Polymorphismus ist eine Funktion für eine
ganze Menge von Typen mit ähnlicher Struktur gültig. Ein Beispiel könnte etwa eine Funktion
Maximum(a, b) sein, die sowohl die größte ganze Zahl, die größte Fließkommazahl oder auch
die lexikographisch größte Zeichenkette ausgibt. Inklusions-Polymorphismus (inclusion polymorphism) ist recht ähnlich; hier wird die aus der objektorientierten Programmierung bekannte
Vererbung ausgenutzt. Ist z.B. die Funktion greifen() für die Objekte der Klasse Primat implementiert, dann kann sie auch von Objekten der Klasse Gorilla genutzt werden, die eine
Unterklasse von Primat ist. Da Pascal keine Vererbung vorsieht, ist diese Art von Polymorphismus in dieser Arbeit nicht weiter zu betrachten.
Mit Ad-hoc Polymorphismus bezeichnet man die Situation, dass ein Objekt, zum Beispiel eine
Funktion, eine endliche Anzahl von Typen größer eins haben kann Dabei bezeichnet der Begriff
überladender Polymorphismus das Problem, dass sich bei überladenden Funktionen ergibt. Ein
Beispiel für überladenden Polymorphismus sehen wir im folgenden, zugegebenermaßen etwas
konstruierten Beispiel:
FUNCTION fibonacci(n:integer):integer;
VAR a,i:integer;
BEGIN
a:=1;
fibonacci:=0;
FOR i:=1 TO n DO BEGIN
a:=a+fibonacci;
fibonacci:=a-fibonacci;
END
END;
FUNCTION fibonacci(n:integer;nminus1:integer):boolean;
BEGIN
13
WHILE n>1 DO BEGIN
nminus1:=n-nminus1;
n:=n-nminus1;
END;
IF (n = 1) AND (nminus1=1) THEN fibonacci:=true ELSE fibonacci:=false
END;
Die erste Funktion gibt die n-te Fibonacci-Zahl zurück, die zweite gibt true zurück, wenn die
beiden Argumente aufeinander folgende Fibonacci-Zahlen sind, und sonst false. Möchte man
nun Typinferenz betreiben, so muss man für das Symbol fibonacci zwei verschiedene Funktionstypen finden. Dabei ist das obige Beispiel noch relativ unproblematisch für die Typinferenz,
da sich schon die Anzahl der Eingabetypen bei den beiden Funktionen unterscheiden. integer →
integer ist folglich der Typ vom Funktionsaufruf fibonacci(10), und integer × integer →
boolean der Typ von fibonacci(8, 5). Schwieriger wird die Situation, wenn die Anzahl der
Eingabetypen identisch ist.
In Pascal ist ein solches Überladen von Funktionen gar nicht erlaubt, da das Symbol „fibonacci“
nur von einem Typ sein darf. Allerdings gibt es in Pascal eine ganze Reihe von Standardfunktionen und Prozeduren, die verschiedene Eingabe- und Ausgabetypen haben können. So kann
write etwa jeden Basistypen als Argument bekommen und auch eine beliebige Anzahl von Argumenten verarbeiten.
Der englische Begriff coercion polymorphism bezeichnet das automatische Umwandeln eines
Typs als Argument einer Funktion in einen anderen, mit dem die Funktion arbeiten kann. In Pascal betrifft das ausschließlich Integer-Werte, die sich gegebenenfalls in Real-Werte umwandeln
lassen, etwa in dem Ausdruck y:=x+1.5. Sollte x vom Typ integer sein, so wird es implizit zu
einem real umgerechnet und mit diesem Wert dann die Addition durchgeführt. Wohlgemerkt
gilt das nicht andersherum, da man bei einer Umwandlung von real zu integer Genauigkeitsverluste hätte.
Motivation zur Beschäftigung mit Polymorphismus
Gäbe es keinen Polymorphismus, wäre die Typinferenz für Pascal, dessen Quelldateien nur sequentiell von oben nach unten geparst werden müssen, bedeutend einfacher. Die polymorphen
Standard-Prozeduren lassen keinen Schluss auf die verwendeten Typen zu, und auch viele Operatoren haben verschiedene Bedeutungen für verschiedene Typen, werden aber trotzdem durch
das gleiche Symbol dargestellt. Die Einführung von überladenden Funktionen ermöglicht das
Definieren der Standard-Funktionen und erweitert die Möglichkeiten der Sprache. Die implizi14
ten Typumwandlungen von integer auf real erhöhen die Komplexität des Problems zusätzlich. Tatsächlich sind der Typinferenz bei so vielen verschiedenen Arten von Polymorphismus
Grenzen gesetzt. Sich diesen Grenzen zu nähern und möglichst viel über die Typen der Objekte
herauszufinden, ist das Thema dieser Arbeit.
2.2 ML
ML steht für meta language. In den späten siebziger Jahren wurde ML vom Informatiker Robin Milner an der Universität von Edinburgh entwickelt, um Theoreme mit Hilfe der Logik und
des Computers zu beweisen (LCF theorem prover). Dabei handelt es sich um eine funktionale
Programmiersprache, das heißt, dass Funktionen im mathematischen Sinn das Programm ausmachen.
Was ML für diese Arbeit interessant macht, ist die Tatsache, dass in dieser Sprache zwar eine strenge Typprüfung durchgeführt wird, die Typen vom Benutzer aber nicht angegeben werden müssen, sondern durch den Compiler ermittelt werden. Eine typisierte Variante von Alonzo
Churchs’ Lambda-Kalkül, das Damas-Milner-Kalkül, dient dabei als Grundlage für die Typinferenz von ML.
Ich habe bei meinen Versuchen die Implementation „Standard ML of New Jersey“ benutzt.
Statt einer Folge von durch Kontrollstrukturen gesteuerter Rechenoperationen und Wertzuweisungen, wie in einer strukturierten Programmiersprache üblich, besteht ein Programm in ML im
Wesentlichen aus Funktionen. Im Gegensatz zu Pascal unterscheidet ML zwischen Groß- und
Kleinschreibung. Ähnlich wie bei Scheme oder Prolog werden Berechnungen häufig mit Hilfe
der Rekursion durchgeführt:
fun
|
|
val
fibonacci 0 =
fibonacci 1 =
fibonacci n =
x = fibonacci
1
1
fibonacci (n-1) + fibonacci(n-2);
5;
Die Funktion fibonacci gibt also 1 aus, wenn die Eingabe 0 oder 1 lautet, ansonsten die Summe aus den beiden nächstkleineren Fibonacci-Zahlen. x bekommt dann den Wert der fünften
Fibonacci-Zahl. Der Compiler gibt dazu Folgendes aus:
val fibonacci = fn : int -> int
val x = 8 : int
Der Compiler erkennt also, dass „0“ und „1“ Integer-Werte sind, und bestimmt den Typ der
Funktion fibonacci zu integer → integer. Für Fälle, in denen sich der Typ nicht so klar
festlegen lässt, kennt ML auch variable Typen:
fun identitaet x = x;
15
---compiler-ausgabe:--val identitaet = fn : 'a -> 'a
Das Einzige, was über den Typen der Funktion identitaet ausgesagt werden kann, ist, dass
der Ein- und Ausgabetyp identisch sein müssen. Diese Funktion kann also mit verschiedensten
Eingabewerten aufgerufen werden.
val zwei = identitaet 2;
val auchzwei = identitaet "zwei";
ML verhält sich sehr restriktiv bei der Bestimmung der Typen. So kennt diese Sprache keine
impliziten Typumwandlungen, sondern legt die Typen immer so früh wie möglich fest, abhängig
von den benutzten Operationen.
Das folgende Beispielprogramm berechnet den Kontostand, nachdem ein gewisser „Startbetrag“
für einige „Jahre“ mit einem bestimmten „Zinssatz“ angelegt wurde:
fun Kontostand (startbetrag, zinssatz, 0) = startbetrag
| Kontostand (startbetrag, zinssatz, jahre) =
(zinssatz+1.0) * Kontostand (startbetrag, zinssatz, (jahre-1));
val y = Kontostand (2000.0, 0.02, 10);
Man sieht auch hier wieder, wie der rekursive Aufruf der Funktion zur Berechnung benutzt wird.
Der Compiler gibt dazu Folgendes aus:
val kontostand = fn : real * real * int -> real
val y = 2437.98883999 : real
Interessant ist hier, dass es tatsächlich notwendig ist, zinssatz+1.0 und nicht zinssatz+1
zu schreiben, da sonst der Typ von zinssatz bereits auf int festgelegt wäre, was beim Aufruf mit einem real-Wert natürlich zu einem Kompilierfehler führen würde. Andererseits wurde
durch die Analyse der Funktionsdeklaration von kontostand der Typ des ersten Arguments
(startbetrag) auf real gesetzt. Der Aufruf val y = kontostand (2000 0.02 10) würde
also zu einem Kompilierfehler führen, da die Funktion kontostand ein Argument vom Typ real
erwartet, aber eines vom Typ int bekommt.
Die Typinferenz von ML ist außerdem in der Lage, Tupel von Typen zu erkennen, wie man in
dem folgenden Beispielprogramm zur Berechnung der Abstände zweier Punkte sieht:
fun
fun
val
val
val
square (x:real) = x*x;
abstand (x1, y1) (x2, y2) = Math.sqrt( square(x1 - x2)+square(y1-y2) );
abstandvonnull = abstand (0.0, 0.0);
punkt = (3.0, 4.0);
z = abstandvonnull(punkt);
16
Die Angabe des Typs (x:real) ist in der ersten Zeile notwendig, sonst würde ML die Funktion
square mit dem Typ int → int belegen, was sich wiederum mit der Funktion Math.sqrt beißen
würde, die real-Werte als Eingabe erwartet.
Der Compiler gibt für dieses Programm das Folgende aus:
val
val
val
val
val
square = fn : real -> real
abstand = fn : real * real -> real * real -> real
abstandvonnull = fn : real * real -> real
punkt = (3.0, 4.0) : real * real
x = 5.0 :real
Der Wert von punkt wird als real × real erkannt. Interessant ist hier auch der Typ der Funktion
abstand: Es handelt sich nicht um eine Funktion, die vier Fließkommazahlen als Argument bekommt und einen real-Wert ausgibt, sondern um eine Funktion über real×real, die als Ausgabe
eine Funktion des Typs real × real → real hat. Wohlgemerkt unterscheidet sich das Ergebnis
von abstand(0.0, 0.0, 3.0, 4.0) nicht von dem von abstand 0.0 0.0 3.0 4.0!
Durch diese Möglichkeiten kann jetzt eine Funktion abstandvonnull definiert werden, die die
Koordinaten des ersten Punkts bereits festlegt, deren Typ also real × real → real ist.
Vergleicht man die Typinferenz von ML mit der, die der TIP-Compiler beherrschen soll, dann
gibt es sowohl gute wie schlechte Nachrichten:
• Mit Funktionstypen oder Tupeln als Eingabe- oder Ausgabetypen von Funkionen braucht
man sich nicht auseinander zu setzen, weil es solche Funktionen in Pascal nicht gibt.
• Mit parametrischem Polymorphismus braucht man sich in Pascal ebenfalls nicht herumzuschlagen.
• Der extrem restriktive Umgang mit integer und real von ML ist, wenn man mehr oder
minder „realistische“ Pascal-Programmen parsen möchte, nicht akzeptabel, da in Pascal
eine implizite Typumwandlung von integer in real stattfinden kann.
2.3 Pascal
Da diese Arbeit sich mit Pascal beziehungsweise einer angepassten Modifikation dieser Sprache
beschäftigt, folgt hier eine kurze Einführung in diese Sprache.
In den späten sechziger und frühen siebziger Jahren entwickelte der Informatiker Niklaus Wirth
17
von der Eidgenössischen Technischen Hochschule Zürich die Programmiersprache Pascal. Pascal war eine Weiterentwicklung der Programmiersprache ALGOL und wurde von vornherein
als Lehrsprache konzipiert. Pascal gehört zur Klasse der strukturierten Programmiersprachen.
Programme in strukturierten Programmiersprachen bestehen in der Regel aus einer Folge von
mit Kontrollstrukturen wie IF - THEN -ELSE oder WHILE-Schleifen gegliederter Anweisungen.
Solche Anweisungen können in Pascal auch Aufrufe von Prozeduren sein, weswegen man Pascal
auch als prozedurale Programmiersprache bezeichnet. Der Begriff imperative Programmierung
wird im Zusammenhang mit Pascal ebenfalls benutzt. Imperative Programmiersprachen kann
man als Obermenge der prozeduralen und strukturierten Programmiersprachen betrachten, zu
der zusätzlich noch Sprachen, die keine Prozeduren kennen (also nicht prozedural sind), und
Programmiersprachen, die mit absoluten Sprunganweisungen (GOTO) arbeiten (also nicht strukturiert sind), gehören.
Die ursprüngliche Definition der Sprache wurde von verschiedenen Personen und Organisationen erweitert. Neben den Standards „Standard Pascal“ und „Extended Pascal“ hat es vor allem
die Implementierung „Turbo Pascal“ der Firma Borland zu großer Verbreitung gebracht. Einige
moderne Programmiersprachen wie Oberon und Delphi basieren auf Pascal.
2.3.1
Syntax
Niklaus Wirth hat bei der Konzeption der Sprache versucht, recht lesbare Programme zu ermöglichen, die auch Programmieranfänger nicht abschrecken. Wo in C oder Java geschwungene
Klammern stehen, werden in Pascal Begriffe wie begin, end benutzt. Ein sehr einfaches Programm kann etwa so aussehen:
PROGRAM PROGRAMM1;
CONST pi =3.141593;
FUNCTION umfang(radius:real):real;
BEGIN
umfang := radius *2*pi
END;
VAR u:real;
r:real;
BEGIN
readln(r); {Hier wird ein Wert fuer Radius eingegeben}
u:=umfang(r);
writeln('Der Umfang betraegt ', u) {Umfang ausgeben}
END.
18
In dem Beispiel wurde übrigens kein Semikolon vergessen. In Pascal dient das Semikolon als
Trennzeichen zwischen zwei Anweisungen. Da auf die letzte Anweisung innerhalb eines Blocks
keine Anweisung mehr folgt, wird auch kein „;“ benötigt.
Eine oben definierte Funktion kann von einer weiter unten definierten Funktion aufgerufen werden, aber nicht anders herum. Zwischen Groß- und Kleinschreibung unterscheidet Pascal weder
bei Schlüsselwörtern noch bei Bezeichnern. In den Beispielen verwende ich durchgehend Großbuchstaben für Schlüsselwörter und Kleinbuchstaben für selbst gewählte Bezeichner. Wie in den
meisten Programmiersprachen haben whitespaces, also Leerzeichen und Zeilenumbrüche, keine semantische Bedeutung, außer der Trennung zwischen zwei Bezeichnern. c:=a MOD b bedeutet etwas anderes als c:=aMODb, aber wie man den Text einrückt oder wie viele Ausdrücke
der Programmierer in einer Zeile unterbringt, interessiert den Compiler nicht. Alles, was in geschwungenen Klammern {} eingeschlossen ist, wird vom Compiler als Kommentar erkannt und
ebenfalls ignoriert.
Die Reihenfolge, in der Funktionen definiert werden, ist im Gegensatz zu Java in Pascal relevant.
Überladende Funktionen sind in Pascal nicht programmierbar, jedoch gibt es reichlich polymorphe vordefinierte Standardfunktionen und Operationen.
2.3.2
Das Typsystem von Pascal
i CHAR
iiii
T EGER
h StandardTTTTIN
T
hhhhh
Auf zaehlungstyp
einf ach
hh
hhhhh
VVVV explizit
VV
REAL
{{
{{
SET
{
{
hhhh
{{
hhhh
T yp
NNNVVVVV RECORD
CC strukturiert
CC
NNN VV
CC
NNN ARRAY
CC
NN
P OIN T ER
BOOLEAN
T eilbereich
F ILE
Aus dem „Grundkurs Pascal“ von Dr. P. Böhme unter „Datentypen in Standard Pascal“. [Böh96]
Es wird unterschieden zwischen elementaren Datentypen wie char oder integer und abgeleiteten Datentypen. Vordefinierte abgeleitete Datentypen, wie etwa real oder string, können
praktisch wie elementare Datentypen benutzt werden, während es mit Konstruktoren für Zeigeroder Record-Typen möglich ist, eigene Typen zu erstellen.
In Pascal gilt Deklarationspflicht, das heißt, vor der Benutzung muss zu jeder Variable der Typ
angegeben werden. Diese Deklarationen folgen auf das Schlüsselwort „VAR“ vor Beginn des
19
Hauptteils eines Programms oder einer Funktion.
Ein wichtiges Merkmal von Pascal ist die statische Typisierung, die es erlaubt, den Typ einer
Variablen zur Übersetzungszeit bereits zu kennen. Man sagt auch, dass Pascal streng getypt ist,
da die Typkorrektheit zur Laufzeit garantiert werden kann, eventuelle Typfehler also bereits vom
Compiler gefunden werden.
2.3.3
Besonderheiten von TIP
„TIP“(Typ-Inferenz Pascal) lehnt sich stark an „Standard Pascal“ an, mit einigen Einschränkungen und Erweiterungen. Diese Änderungen haben hauptsächlich den Sinn, die Typinferenz zu
vereinfachen beziehungsweise erst möglich zu machen.
• Es gibt einen unbekannten Variablentyp auto.Wenn der Programmierer etwa angibt:
VAR zahl : auto;
dann heißt das, dass der Typ der Variablen zahl vom Compiler gefunden werden soll. Die
Angabe von bekannten Datentypen wie integer oder real ist jedoch weiterhin möglich
und in einigen Fällen immer noch notwendig, weil der Typ nicht in jedem Fall aus dem
weiteren Programm ermittelt werden kann. Doch dazu später mehr.
Variablen müssen also auch in TIP deklariert werden, und es muss ebenfalls immer ein
Typ oder „auto“ angegeben werden.
• Als Hilfsmittel für die Typprüfung kann man den Typ von Ausdrücken mit in DollarZeichen eingeschlossenen Typnamen festlegen:
x:= $real$(y*z);
In diesem Beispiel ist also bekannt, dass der Ausdruck (y*z) vom Typ real sein muss.
Dies kann manchmal hilfreich sein, um der Typinferenz etwas auf die Sprünge zu helfen.
• Im Gegensatz zum herkömmlichen Pascal ist das Definieren von überladenden Funktionen
prinzipiell möglich.
• Der Einsatz von Label und Goto ist in TIP nicht implementiert.
• Mehrdimensionale Arrays werden in TIP anders geschrieben als in Standard Pascal: Statt
Arrayname[x, y] schreibt man Arrayname[x][y].
20
• read, readln, write, writeln können in Standard Pascal beliebig viele Argumente
bekommen. In TIP können diese Prozeduren nur mit einem oder keinem Argument des
Typs real, integer, string oder boolean aufgerufen werden.
• Aufgrund der Schwierigkeiten, die polymorphe Operatoren verursachen, werden in TIP einige Operatoren anders dargestellt als in Pascal. Dafür wurde der Zeichensatz der Sprache
auch gleich etwas vergrößert, und einige neue reservierte Wörter wurden eingeführt.
Altes Symbol
neues Symbol
Typen
Bedeutung
+
||
string × string → string
Verkettung von Zeichenketten
+
union
set(α) × set(α) → boolean
Vereinigung von Mengen
-
minus
set(α) × set(α) → boolean
Mengendifferenz
*
intersect
set(α) × set(α) → boolean
Schnitt von Mengen
• Wertebereichsfestlegungen, wie sie bei Arrays oder bei der Deklaration neuer Typen gemacht werden können, werden ignoriert beziehungsweise wie integer behandelt. Der
Grund dafür ist, dass die Prüfung dieser Bereiche eine recht anspruchsvolle Aufgabe ist,
da man ja praktisch das gesamte Programm schon direkt ausführen müsste, bevor es überhaupt kompiliert wurde, um prüfen zu können, ob diese Wertebereiche überschritten werden. Wenn dann noch interaktive Elemente oder externe Daten dazu kommen, ist diese
Prüfung sowieso nicht mehr möglich. Das bedeutet also, dass es den „TIP-Compiler“ nicht
stört, wenn VAR a : ARRAY [1..10] OF REAL deklariert wurde, und später ein Zugriff
der Form a[11] stattfindet. Ebenso wird aus TYPE einstellig : 1..9 für den Compiler einfach TYPE einstellig : INTEGER.
2.4 Compiler
Ein Compiler hat in der Regel die Aufgabe, eine Programmiersprache (hier: Pascal) in Maschinensprache oder wie z.B. bei Java in Bytecode für eine virtuelle Maschine zu übersetzen. Bei
dem TIP-Compiler ist es etwas anders. Die Ausgabesprache ist keine Maschinensprache, sondern
ebenfalls Pascal. Zumindest die ersten Phasen des Kompiliervorgangs sind jedoch identisch mit
denen, die ein „gewöhnlicher“ Compiler durchlaufen muss. Diese Phasen werden unter anderem
im Script zur Vorlesung „Programmiersprachen und Übersetzer“ an der Universität Hannover
beschrieben. [Par05]
Zur Veranschaulichung der einzelnen Schritte dient das folgende kurze Beispielprogramm in
Pascal:
21
PROGRAM test;
VAR x:auto;
BEGIN
x:=5.5;
writeln(x);
END.
2.4.1
Lexikalische Analyse
Ziel der lexikalischen Analyse ist es, in einer unstrukturierten Folge von Zeichen einzelne Elemente, sogenannte Token, zu erkennen. Dabei wird der Text einmal vom Anfang zum Ende zeichenweise gelesen, es wird eine Folge von Token gefunden. Illegale Zeichen kann ein Lexical
Scanner (oder auch Lexer) erkennen und dann ggf. den Kompiliervorgang abbrechen. Einige Token, wie etwa Zahlen oder Bezeichner, werden zusätzlich durch ein Lexem unterschieden. Dieses
enthält den Wert einer Zahl oder den Namen eines Bezeichners.
Beispiel: Aus dem Beispielprogramm wird nach der lexikalischen Analyse die Tokenfolge (in
Klammern das Lexem)
PROGRAM(), IDENT("test"), SEMIK(), VAR(),IDENT("x"),COLON(),
AUTO(), SEMIK(),BEGIN(), IDENT("x"), ASSIGN(),
FLOATING_POINT_NUMBER(5.5), SEMIK(),IDENT("writeln"),
LBRACKET(), IDENT("x"), RBRACKET(), SEMIK(), END(), DOT()
2.4.2
Syntaktische Analyse
In der nächsten Phase muss diese Folge von Token nun auf ihre syntaktische Korrektheit hin
überprüft werden. Nach festen Regeln ähnlich wie den folgenden wird ein Syntax-Baum des Programms erstellt. Wie in der Informatik üblich, ist die Wurzel des Baums dabei „oben“ und die
Blätter sind „unten“.
< programm >→ program < bezeichner >; < programmblock > .
< programmblock >→< deklarationen >< anweisungsblock >
< deklarationen >→< konstantendeklaration >< variablendeklaration > ...
< anweisungsblock >→ begin(< anweisung >)∗ end
Den Bestandteil eines Compilers, der die syntaktische Analyse durchführt, wird als Parser bezeichnet. Es wird unterschieden zwischen Top-Down- und Bottom-Up-Parsern. Erstere bauen den
Syntaxbaum von der Wurzel aus auf, letztere von den Blättern. Aus unserem kurzen BeispielProgramm wird also folgender (hier vereinfacht dargestellter) Syntaxbaum gebildet:
22
program(name = “test“)
WWWWW
WWWWW
WW
eeee
eeeeee
e
e
e
e
e
e
V ariablen − Deklaration
S
l
lll
lll
l
l
l
xl
2.4.3
SSS
SSS
SSS
Anweisungen
O
s
sss
s
s
s
kk := KKK
k
k
KKK
kk
kkk
K
kkk
k
x
5.5
auto
OOO
OOO
writeln
x
Semantische Analyse
Die wichtigste Aufgabe der semantischen Analyse ist die Prüfung der Typkorrektheit, was bei
Sprachen, die Typinferenz beherrschen, bedeutet, dass die Typen soweit möglich erst einmal ermittelt werden müssen. Dabei wird der Syntaxbaum von der Wurzel zu den Blättern durchlaufen.
Danach muss der Baum wieder nach oben durchlaufen werden, wobei die ermittelten Typen mitgegeben und verglichen werden. Wenn ohne Fehler die Wurzel erreicht wird, ist das Program
typkorrekt. Nach der semantischen Analyse könnte der Syntaxbaum unseres Beispiels also so
aussehen:
program(name = “test“)
WWWWW
WWWWW
WW
ffff
ffffff
ffffff
Anweisungen
O
V ariablen − Deklaration
S
lll
lll
l
l
ll
xl
SSS
SSS
SSS
real
x
t
ttt
ttt
kk := KKK
kkk
KKK
k
k
K
kkk
k
k
k
OOO
OOO
writeln
5.5
x
Eine weitere Aufgabe der semantischen Analyse ist die Prüfung, ob benutzte Variablen und Funktionen bereits vorher deklariert wurden. Nach der lexikalen und syntaktischen Analyse ist die
semantische Analyse der letzte Abschnitt der sogenannten Analysephase.
2.4.4
Code-Erzeugung
Die Synthesephase wird in der Literatur meistens ebenfalls in drei Abschnitte getrennt, nämlich
Zwischencode-Erzeugung, Codeoptimierung und Maschinencode-Erzeugung. Diese Aufteilung
macht aber für den TIP-Compiler wenig Sinn, da aus dem Syntax-Baum direkt der Pascal-Code
erzeugt wird. Als Ausgabe liefert der TIP-Compiler also das folgende Pascal-Programm:
PROGRAM test;
VAR x:real;
BEGIN
23
x:=5.5;
writeln(x);
END.
24
Kapitel 3
Realisierung
Die Umsetzung des Parsers erfolgte in Java. Anfangs plante ich, den Parser-Generator ANTLR
zu benutzen, habe mich aber schließlich doch dagegen entschieden. Ich hatte den Eindruck, dass
ANTLR den Einstieg in die Compiler-Entwicklung nicht wirklich einfacher macht und eher als
Hilfsmittel für mit der Thematik vertraute Entwickler zu verstehen ist. Jedenfalls hatte ich große
Schwierigkeiten, Elemente der semantischen Analyse zu dem bereits bestehenden Parser und
Lexer hinzuzufügen.
Ich verzichtete also auf den Gebrauch von Parser-Generatoren. Glücklicherweise musste ich
trotzdem nicht den gesamten Compiler aus dem Nichts von Hand bauen, da mir ein in Java realisierter Compiler zu der Phantasie-Sprache „BPS“ zur Verfügung stand. Dieser Compiler wurde
von Torben Wichers geschrieben und mir freundlicherweise zur Verfügung gestellt. Da „BPS“
praktisch ein abgespecktes Pascal ist, konnte ich große Teile insbesondere des Scanners, Parsers
und des für die Ausgabe zuständigen „ToStringWalker“ übernehmen und an gegebenen Stellen
erweitern.
3.1 Aufbau
Die Klasse Start bekommt als Argument den Namen einer zu kompilierenden Pascal-Datei.
Diese wird in einen StringBuffer umgewandelt, welches als Argument von lexical.Scanner
verwendet wird. Die dabei entstehende Tokenfolge wird wiederum in der Klasse syntactical.
RecursiveDescentParser in einen Syntax-Baum umgewandelt. Dabei werden Instanzen der
verschiedenen Klassen des Pakets il erzeugt, die größtenteils Sprachkonstrukte von Pascal symbolisieren. Der folgende Baum zeigt, wie diese Klassen voneinander abhängen:
25
4 Array
jFjjunctionCall
j
j
j
jjjjfffff3
jfcjfcjfcjfcfcfcfcccccc1 Identif ier
LV alue \
X\X\X\X\X\\\\\\\- / P ointer
9
XXXXX RecordCall
ss
s
X+
ss
s
V alueof P ointer
s
s
ss
s
Binary
ss gggggg3
ss
ss gcgcgcgcgccc1 ExplicitT ypeCast
/
Expression [[[
@
OSOSOWSWWW[W[[[[[F- P N umber
S
W
O
S
OOOSSSWWWW+ N egation
OOOSSSS N umber
OOO S)
OO' T ext
Set2
Assignment
=
{{;
N ode
{Compund
3=3=
{
ConstDeclaration
{{vvv9
33==
{{vvvDeclarations
33===
{
rrr
{v{vrvrrrooo7 F or
33 ==
{
{ v r ookkk5
33 ===
k
{v{rvrvororkookFkunctionDeclaration
{
v
33 ==
r
{
vr ookk ggg3
33 = {vr{ovr{kovrkogckogckgckgcgcgcgcccccc1 If
/
3Statement
KOKSOSOW[SW[SW[W[W[[[[[P[[rocedureCall
33
KKOOSSSWWWW - Repeat
33
KKOO SSS W+
KKOOO SSStatementlist
3
KK OO S)
KKT ypeDeclaration
P rogram
KKOOO'
KK
V arDeclaration
K%
W hile
Diese Sprachkonstrukte lassen sich grob in zwei verschiedene Arten aufteilen. Statements sind
Anweisungen, die für sich stehen und in PASCAL in der Regel durch Semikolon getrennt werden. Beispiele für Statements sind Prozeduraufrufe, Wertzuweisungen und IF-THEN-ELSEBlöcke. Der Rückgabetyp dieser Anweisungen ist leer (blank/ void). Expressions hingegen
sind Ausdrücke wie die Argumente von Funktionsaufrufen oder Werte und Variablen in Wertzuweisungen. Bei der Typprüfung durch den SemanticWalker ist es wichtig, den Rückgabetyp
dieser Ausdrücke zu ermitteln.
Bei der Deklaration von Variablen oder Funktionen werden Einträge in der Symboltabelle util.
SymbolTable erzeugt. Wenn diese dann später wieder verwendet werden, kann der Parser in
der Symboltabelle nachschauen, ob diese Symbole bereits deklariert wurden. In Funktionen und
Prozeduren können wiederum Variablen, Funktionen oder Prozeduren deklariert werden, die allerdings nur innerhalb dieser Funktion gültig sind. Deswegen hat jede Funktion ihre eigene Symboltabelle, in der auch Namen benutzt werden können, die in der übergeordneten Symboltabelle bereits vergeben wurden. Jede Symboltabelle bis auf die Programm-Symboltabelle hat eine
Vater-Symboltabelle.
Bereits vor dem eigentlichen Beginn des Parsing befinden sich erste Einträge in der obersten
Symboltabelle, da einige geläufige Standardfunktionen (write(), read(), length()) oder Bezeichner wie true und false durch den Aufruf der Klasse syntactical.StandardSymbols
hinzugefügt werden.
26
Neben der Symboltabelle spielt auch die Typentabelle util.TypeTable eine wichtige Rolle. In
ihr werden alle Typen, deren Namen im Programmverlauf angegeben werden könnten, gespeichert, also z.B. der Standardtyp integer oder ein beliebiger durch TYPE name:<typbeschreibung>
definierter Typ. Variable Typen brauchen in dieser Tabelle nicht gespeichert zu werden.
Der Parser produziert also eine oder mehrere Symboltabelle(n), eine Typtabelle und natürlich
den Programmbaum. An der Wurzel des Baums befindet sich immer die Klasse il.Program,
die Instanz von Program enthält also das gesamte Pascal-Programm. Dieses Programm kann also nun als Argument in den semantical.SemanticWalker gesteckt werden.
Der SemanticWalker stellt die Implementation der semantischen Analyse dar. Hier wird also
die Typsicherheit geprüft und Typinferenz betrieben. Tritt in dieser Phase ein Fehler auf, ist die
Typkorrektheit nicht gegeben. Damit sich der SemanticWalker merken kann, welche Substitutionen bereits vorgenommen wurden, werden die erfolgten Substitutionen in einer weiteren
Tabelle, der SubstTable gespeichert. Das Aufräumen und Organisieren dieser Tabelle kann unter Umständen recht knifflig sein. Zu den Details der Implementation der semantischen Analyse
folgt später mehr.
Nach der semantischen Analyse wird der Programm-Baum von der Klasse codegen. ToStringWalker verarbeitet, wobei die Ausgabe des Programms in Pascal-Syntax entsteht. An diesem
Programm kann man schnell erkennen, welche Typen der SemanticWalker ermitteln konnte.
3.2 Typen
Da die verschiedenen Datentypen eine zentrale Bedeutung für diese Arbeit haben, ist es angebracht, die Typen, die TIP kennt, vorzustellen. Alle Typ-Klassen in „TIP“ erben von der Klasse Type, deren Attribute der Name des Typs und ein Wahrheitswert zur Unterscheidung von
Konstanten- und Variablentypen sind. Der einzige Unterschied zwischen einer Konstante und
einer Variablen ist also der Wert dieses Bits. Von Type abgeleitet werden folgende Typen:
• BasisType: Als Basistypen gelten in Pascal integer, also ganze Zahlen, real, also Gleitkommazahlen, boolean und char. Ein string wird in Pascal als Array von char-Werten
gespeichert. In meiner Implementation habe ich darauf verzichtet, char als Basistyp zu
verwenden, sondern benutze den Typ string sowohl für einzelne Zeichen als auch für
Folgen von Zeichen. Den BasisTyp char hinzuzufügen, um zwischen Folgen von Zeichen
und einzelnen Zeichen zu unterscheiden, wäre sicherlich keine größere Schwierigkeit, ist
27
aber für das Problem der Typprüfung nicht besonders interessant.
Zusätzlich zu den vier Basistypen integer, real, boolean, string habe ich noch
einige andere Typen als Basistypen deklariert. Ein Typ blank symbolisiert einen leeren
Typen, also z.B. den Ausgabetyp einer Prozedur. Der Begriff void ist dafür gebräuchlicher,
da dieser aber bereits in JAVA ein reserviertes Wort ist, habe ich zur Vermeidung von Fehlern einen anderen Begriff gewählt.
In einer bestimmten Implementation des SemanticWalker wird zudem ein Phantasietyp
intorreal benutzt. In vielen Situationen kann nicht sofort entschieden werden, ob eine
Variable oder ein Ausdruck vom Typ integer oder real ist. Zum Beispiel kann x im
Ausdruck x:=4 sowohl vom Typ integer sein, da 4 ja eine ganze Zahl ist, aber auch vom
Typ real, da in Pascal Integer-Werte implizit in Real-Werte umgewandelt werden können. In diesem Fall wird dem Objekt erst einmal der Typ intorreal zugewiesen. Man
kann den Typen intorreal auch als Menge {real, integer} sehen. Wenn nicht im späteren Programmverlauf klar wird, dass der Typ der Variablen eindeutig entweder real oder
integer ist, dann werden nach Durchlauf des Programmbaumes alle Symbole vom Typ
intorreal dem Typen integer zugeschlagen. intorreal ist also nur ein Hilfskonstrukt
und kommt als Typ im fertig kompilierten Programm nicht mehr vor.
• VariableType: Über einen variablen Typen ist am Anfang nichts bekannt, die Typinformation wird, wenn möglich, beim Durchlaufen des Programmbaumes ermittelt. Durch Angabe von auto anstatt der üblichen Typbezeichner wie boolean oder array of string
wird ein neuer variabler Typ erzeugt.
• ArrayType: Ein ArrayType hat als zusätzliches Attribut den Typ der Elemente des Feldes.
Aus dem Ausdruck x[0] := 'Hallo' lässt sich etwa leicht ermitteln, dass x ein Array
vom Typ string sein muss.
• RecordType: Ein Record ist eine selbst definierte Datenstruktur. Zum Beispiel der Typ
„person“:
TYPE person = RECORD
vorname:string;
nachname:string;
geburtsjahr:integer;
...
END;
Ein RecordType besitzt zusätzlich zu seinem Namen auch eine Tabelle mit allen seinen
Attributen und deren Typen. Diese Attribut-Bezeichner, wie im obigen Beispiel vorname
28
oder geburtsjahr, werden in der Symboltabelle gespeichert, womit diese Namen im Programm nicht mehr als Bezeichner von Variablen oder Ähnliches benutzt werden dürfen.
• PointerType: Zeiger ermöglichen den Einsatz dynamischer Variablen. Der Speicherplatz
für diese Variablen wird erst dann angefordert, wenn er auch gebraucht wird. Als einzigen
zusätzlichen Parameter benötigt PointerType den Typ des Zeigers, der ein beliebiger
anderer Typ sein kann.
• EnumType: Ein Aufzählungstyp oder Enumeration besteht aus einer Menge von Symbolen. Zum Beispiel könnte man folgenden Aufzählungstypen definieren:
PROGRAM ampel;
TYPE ampelphase = (rot, gelb, gruen);
VAR ampelzustand : ampelphase;
BEGIN
ampelzustand := rot;
....
Die Symbole rot, gelb und gruen werden in der Symboltabelle gespeichert und dürfen folglich nicht mehr als Bezeichner für etwas anderes im Programm verwendet werden. In dem obigen Beispiel könnte man folglich auch ohne die Angabe ampelzustand
: ampelphase erkennen, dass ampelzustand vom Typ ampelphase sein muss, da das
Symbol rot vom Typ ampelphase ist.
Ein EnumType hat als Attribut eine Tabelle mit allen Symbolen, die zu diesem Typ gehören.
Der Typ dieser Symbole ist wiederum der Aufzählungstyp, zu dem sie gehören.
• FunctionType: Der Typ einer Funktion wird durch eine Menge von Eingabetypen und
einen Ausgabetypen beschrieben. Der Typ δ einer Funktion lässt sich auch auf folgende
Weise darstellen:
δ : α1 × .. × αn → β
Dabei sind αj die Eingabetypen und β der Ausgabetyp. Im TIP-Compiler werden die Typen von Prozeduren als speziellen Funktionstypen behandelt, bei denen der Ausgabetyp β
dem leeren Typ blank entspricht.
• SetofTypes: Im Gegensatz zu Pascal erlaubt TIP das Überladen von Funktionen und Prozeduren. Insbesondere für die Definition der Standard-Funktionen wie write oder read
ist es sehr hilfreich, einem Symbol mehrere Funktionstypen zuordnen zu können. Diese
verschiedenen FunctionType-Objekte werden als Liste im Typ SetofTypes gespeichert.
29
Es handelt sich also um ein reines Hilfskonstrukt und hat keine Entsprechung in einem
Typen in Pascal selbst.
• SetType: Nicht zu Verwechseln mit dem Hilfskonstrukt SetofTypes. Mengen werden in
Pascal folgendermaßen beschrieben:
TYPE gummibaerchen = SET OF (rot, orange, gelb, gruen, weiss);
VAR thomas_isst : gummibaerchen;
BEGIN
thomas_isst := [rot, gruen]
....
Als einziges zusätzliches Attribut braucht SetType den Typen der Elemente der Menge.
Dabei handelt es sich meistens entweder um integer oder um einen EnumType.
3.3 Operationen
Neben Funktions- und Prozeduraufrufen sind Operationsaufrufe gute Möglichkeiten, um den
Wert von Variablen zu ermitteln. Schauen wir uns mal die in TIP vorhandenen Operatoren an
und was sie uns über den Typ der Operanden verraten:
• := : Das Zeichen für die Wertzuweisung. α × α → blank
• + : (Addition) real × real → real oder integer × integer → integer
• - : (Subtraktion) real × real → real oder integer × integer → integer
• * : (Multiplikation) real × real → real oder integer × integer → integer
• / : (reellwertige Division) real × real → real
• DIV : (ganzzahlige Division) integer × integer → integer
• MOD : (Modularoperation) integer × integer → integer
• AND : (logisches UND) boolean × boolean → boolean
• OR : (logisches ODER) boolean × boolean → boolean
• XOR : (ausschließendes ODER) boolean × boolean → boolean
• IN : (Enthaltensein eines Objekts in einer Menge) α × set(α) → boolean
30
• <=, <, >=, > : (Größer/Kleiner Vergleichsoperationen) real × real → boolean oder
integer × integer → boolean
• = : (Prüfen auf Gleichheit) α × α → boolean
• >< : (Prüfen auf Ungleichheit) α × α → boolean
• || : (Verketten von string) string × string → string
• UNION, MINUS, INTERSECT : (Vereinigung, Differenz und Schnitt von Mengen) set(α)×
set(α) → set(α)
Operationen wie DIV oder XOR sind recht pflegeleicht bei der Typprüfung, da der Typ ihrer Operanden sofort klar ermittelt werden kann. Schwieriger ist die Situation bei den überladenden
Operatoren wie + oder dem Vergleichsoperator =. Das Problem wurde durch die im Kapitel zu
„TIP“ beschriebenen Veränderungen bereits vereinfacht, da sich jetzt +, - und * nicht auf Mengen beziehen können.
3.4 semantische Analyse
Die semantische Analyse ist offensichtlich der wichtigste Abschnitt des Compilers im Kontext
dieser Arbeit. Dabei wird der Baum vom TIP-Compiler in drei Phasen durchlaufen.
• SemanticWalker: Von oben nach unten wird der Programmbaum durchlaufen und dabei
werden die Typen von Operanden in Operationen oder der Argumente eines Funktionsaufrufs ermittelt und mit den in diesem Zusammenhang erwarteten Typen verglichen. Auf
diese Weise lässt sich (hoffentlich) den variablen Typen (zuvor mit auto als Typ deklariert) ein konkreter Wert zuordnen. Diese Zuordnungen werden nicht sofort als Typen der
entsprechenden Symbole festgeschrieben, sondern erst einmal in einer Substitutionstabelle
gespeichert.
• TypeWalker: Diese Substitutionstabelle wird im nächsten Abschnitt vom TypeWalker
benutzt, um die Typen tatsächlich für die verschiedenen Symbole festzuschreiben. Dabei
muss der Programmbaum erneut top-down durchlaufen werden.
• IntToRealWalker : Die implizite Typumwandlung von integer zu real wird hier sichtbar gemacht, indem an geeigneter Stelle im Programmbaum ein Aufruf der Funktion
„INT2REAL“ eingefügt wird. INT2REAL ist eine Funktion vom Typ integer → real, die
außer der Umwandlung des Datentyps nichts tun soll.
31
Diese Reihenfolge gilt nicht für Funktions- und Prozedurdeklarationen. Hier müssen die Typen
sofort nach dem Durchlaufen des Funktionsdeklarations-Teilbaumes festgeschrieben werden, da
die Information über den Typ der Funktion zwar zur Inferenz der Typen der Argumente bei einem
Aufruf dienen soll, der Typ der Funktion aber nicht von den Argumenten, mit denen er später
aufgerufen wird, abhängig sein darf.
3.4.1
Integer und Real
Das größte Problem, mit dem ich bei der Implementierung der Typinferenz zu kämpfen hatte,
war wohl der Umgang mit integer und real. Ein paar Beispiele verdeutlichen die Problematik
vielleicht am besten:
a := 5;
a := 2.5;
{1}
{2}
Nach der ersten Zeile könnte man denken, dass a vom Typ integer sein muss. In der nächsten Zeile wird a aber ein Wert vom Typ real zugewiesen. Da sich integer in real wandeln
lassen, aber nicht anders herum, ist wohl klar, dass der Typ, den man für a herausbekommen
möchte, real ist. Einfach die Typzuweisung α 7→ integer durch α 7→ real zu überschreiben,
ist dabei natürlich keine Option, denn sonst würde der Semantic Walker inkorrekte Programme
akzeptieren.
a := 1;
b[a] := 1.5;
a := 2.5;
{1}
{2}
{3}
Der Index eines Feldzugriffs muss vom Typ integer sein. Hier liegt also ein Fehler vor, der in
der semantischen Analyse abgefangen werden muss. Um zwischen der ersten Situation, in der a
vom Typ real sein muss, und der zweiten, die zu einer Fehlermeldung und zum Abbruch des
Kompiliervorgangs führen sollte, zu unterscheiden, habe ich den Pseudo-Basistyp intorreal
eingeführt. a wäre also so lange vom Typ intorreal, bis es beispielsweise durch die Benutzung
als Index eines Arrays oder als Laufindex einer FOR-Schleife eindeutig als integer oder durch
Wertzuweisungen als real identifiziert werden kann.
x:=0;
y:=x;
feld[y] := 5.5;
x:=feld[y];
Dieses Beispiel zeigt, dass mit nur einem Durchlauf des SemanticWalker gewissen Problemen
nicht beizukommen ist:
32
Nach der ersten Zeile nimmt man an, dass x integer oder real sein kann. Nach der zweiten würde man für y erst einmal das Gleiche annehmen. Dann wird festgestellt, dass y vom Typ
integer sein muss, und in der vierten Zeile schließlich, dass x ein real-Wert ist. Aber in der
zweiten Zeile steht doch: y := x, also wird einer Variable vom Typ integer eine Gleitkommazahl zugewiesen, und das muss natürlich zu einem Typfehler führen. Mit nur einem Durchlauf
wäre der SemanticWalker aber nicht in der Lage, das zu erkennen. In einem zweiten Durchlauf
kann dieser Widerspruch dann aber doch gefunden werden, und es wird ein Typfehler ausgegeben.
x:=4*y;
y:=5.5;
Hier gibt es nun eigentlich keinen Widerspruch, x und y müssten beide Gleitkommazahlen sein.
Nur leider steht in der Substitutionstabelle nichts vom Bezug der Typen von x und y zueinander, und so würde, wenn der SemanticWalker nur einmal ausgeführt würde, aus dem Eintrag
„α1 7→ intorreal“ später die Information abgeleitet, dass x vom Typ integer sein muss. Das
ist natürlich falsch und würde tatsächlich als Typfehler durch den IntToRealWalker erkannt
werden. Ein zweiter Durchlauf löst auch dieses Problem. Aber auch bei zwei Durchläufen kann
es weiter Probleme geben:
x:=4*y;
y:=4*z;
z:=5.5;
Dieses Programm würde erst nach dem dritten Durchlauf korrekt geparst werden. Dieses Problem ließe sich natürlich endlos fortsetzen. Mit einer Anzahl von Durchläufen, die der Anzahl
von Variablen im Programm entspricht, sollte man allerdings auf der sicheren Seite sein.
3.4.2
Substitutionstabelle
Kommen wir noch einmal auf die Substitutionstabelle zurück: In dieser werden jeweils Paare
von Typen als Einträge gespeichert, wobei das erste Element dieses Paares als Schlüsselelement
dient. Genutzt wird dafür die Java-Klasse java.util.HashMap. Ein solches Schlüsselelement
muss natürlich eindeutig sein. Folglich können zwar (a, b) und (c, b) in der gleichen Tabelle
gespeichert werden, nicht aber (a, b) und (a, d). Semantisch sollte man die Einträge in der Tabelle
als Substitutionen sehen. (a, b) steht also für eine Substitution a 7→ b. In den meisten Fällen
steht auf der linken Seite dieser Substitutionen ein variabler Typ αi , auf gar keinen Fall aber ein
Basistyp wie string oder boolean.
Ein kurzes Beispiel zeigt, wie diese Substitutionstabelle gefüllt wird:
x:=y;
33
x:='text';
IF y IN z THEN ...
Nehmen wir an, dass über die Typen von x, y und z bisher nichts bekannt ist, sie also mit dem
Typ „auto“ deklariert wurden. Sei „x“ also bei der Deklaration der variable Typ α1 , „y“ α2 und
„z“ α3 zugewiesen worden.
Nach dem Durchlaufen der ersten Zeile durch den SemanticWalker stünde dann in der Substitutionstabelle der folgende Eintrag: α1 7→ α2 . Der Typ von x und der von y müssten also gleich
sein. Da der Ausdruck 'text' eindeutig vom Typ string ist, würde die zweite Zeile, isoliert
betrachtet, folgenden Eintrag zur Substitutionstabelle hinzufügen: α1 7→ string. Da aber α1 bereits als Schlüsselelement verwendet wird, ist das so nicht möglich. Statt dessen wird der Eintrag
α2 7→ string eingefügt. Indirekt über α2 wird α1 so ebenfalls auf string gemappt.
In der dritten Zeile wird die Operation „IN“ benutzt. Über diese ist bekannt, dass ihr zweites
Argument vom Typ set(β) ist, wenn das erste Argument vom Typ β ist. Es kommen also zwei
weitere Einträge in die Tabelle hinzu: α3 7→ set(α4 ) und α4 7→ string. Der aufmerksame Leser
mag sich nun natürlich fragen: Wo kommt α4 denn plötzlich her?
Wenn als Typ des zweiten Elements der Operation IN kein Typ set(α), sondern ein variabler Typ
zurückgegeben wird, erzeugt der SemanticWalker einen neuen Typ set(αj ), in diesem Fall also
set(α4 ). α3 wird also der Typ set(α4 ) zugewiesen. Der Typ vom Argument y(α2 ) ist auf string
gemappt, so kann also die Substitution α4 7→ string hinzugefügt werden.
Statt dessen den Eintrag α4 7→ α2 einzufügen, wäre genauso richtig. Es ist aber vorteilhaft, lieber
den konkreten Typ zuzuweisen, falls er bekannt ist. Ketten der Form α2 7→ α5 7→ α4 7→ α10 7→
integer können nämlich durchaus für Probleme sorgen, weswegen man solche Ketten so kurz
wie möglich halten sollte. Insbesondere muss man aufpassen, dass man keine Zyklen in diese
Folge von Substitutionen bekommt, sonst gerät der Compiler in eine Endlosschleife. Und wenn
durch irgendwelche neuen Informationen die Kette „durchbrochen“ wird, können Informationen
verloren gehen.
Nehmen wir noch mal einen abschließenden Blick auf das obige Beispiel. Nach dem Durchlaufen des dargestellten Programmabschnitts steht in der SubstTable:
α1
α2
α3
α4
7
→
α2
7→
string
7
→
set(alpha4 )
7→
string
34
3.4.3
Drei Implementationen des Semantic Walkers
Im Zuge der Implementierung des TIP-Compilers wurden verschiedene Ansätze zur Lösung der
Typinferenz ausprobiert. Drei dieser Varianten des SemanticWalker werden in dieser Arbeit
verwendet. Dabei ist der „SemanticWalker2“ mit Sicherheit die belastbarste und ausgereifteste
Variante, aus dem einfachen Grund, dass in diese mit Abstand am meisten Zeit investiert wurde.
• SemanticWalker1: Inspiriert von dem restriktiven Verhalten beim Bestimmen von Typen
in ML, wird beim „SW1“ ein ganzzahliger Wert sofort als integer erkannt. Die Konsequenz daraus ist, dass es des Pseudo-Typen intorreal nicht bedarf. Wichtig ist hier die
Reihenfolge der Anweisungen: Die erste Typzuweisung gilt programmweit.
x:=1;
x:=1.2;
Dieses Beispiel würde zu einem Fehler führen, da die Variable x nun mal vom Typ integer
wäre und ihr folglich kein real-Wert zugewiesen werden dürfte.
x:=1.2;
x:=1;
Dieses zweite Beispiel hingegen würde funktionieren. x wäre vom Typ real und 1 könnte
mit Hilfe der Funktion INT2REAL() in eine Fließkommazahl umgewandelt werden.
• SemanticWalker2: Der „SW2“ benutzt den Typen intorreal, wenn nicht klar ist, ob ein
bestimmter Wert vom Typ integer oder real sein muss. Durch diesen einen zusätzlichen
Basistypen wird die Komplexität der Typinferenz etwas erhöht, dafür ist die Typprüfung
weniger restriktiv, wodurch schon deutlich mehr „realistische“ Programme kompiliert werden können. In Situationen, in denen ein Ausdruck also sowohl vom Typ integer als auch
real sein könnte, wird ihm der Typ intorreal zugewiesen. Der Typ intorreal wird
in der Substitutionstabelle nach Durchlauf des Programmbaums an allen Stellen durch
integer ersetzt.
Auch zu diesem Ansatz gibt es ein kurzes Beispiel:
x:=0;
y:=0;
x:=1.0;
Über die Typen von x(α1 ) und y(α2 ) sei vor dem Erreichen der entsprechenden Stelle im
Programmbaum nichts bekannt. Nach dem Lesen der ersten und zweiten Zeile stünde also
35
Folgendes in der Substitutionstabelle: α1 7→ intorreal, α2 7→ intorreal. Nach der Analyse der dritten Zeile kann der erste Eintrag einfach überschrieben werden: α1 7→ real. Der
SemanticWalker2 erreicht dann das Ende des Programmbaums, ohne weitere Informationen zum Typen von y zu erhalten, der zweite Eintrag wird also zu α2 7→ integer.
• SemanticWalker3: Im „SW3“ gibt es nicht mehr nur eine Substitutionstabelle, sondern
etliche. Ein Kindknoten übergibt seinem Vaterknoten beim Durchlaufen des Programmbaums nicht nur einen Typen und die aktuelle Version der Substitutionstabelle, sondern
eine Menge von (Typ, Substitutionstabelle)-Tupeln. Das macht die Implementation noch
einmal komplizierter. Aufgrund dieser Komplexität und mangelnder Zeit habe ich den
SW3 nur für einen sehr kleinen Teilbereich von Pascal implementiert, mit dem sich kaum
sinnvolle Programme realisieren lassen. Dadurch lässt sich wenigstens zeigen, dass dieser Ansatz prinzipiell funktionieren kann. Zu der grundsätzlichen Funktionsweise dieser
Implementation der semantischen Analyse gibt es ebenfalls ein Beispiel:
IF x=y THEN x:=y+1;
Auch diese Anweisung lässt sich als Baum darstellen:
statement
mm IF RRRRR
RRR
mmm
m
m
RRR
mm
m
R
m
m
then − part
condition
x
zz
zz
z
zz
=D
DD
DD
DD
y
x
vv
vv
v
v
vv
:= NN
NNN
NNN
NNN
+1
111
1
y
1
Betrachten wir zuerst die linke Seite, die Bedingung der IF-Anweisung: x ist zu Beginn
der variable Typ α1 zugewiesen und y der Typ α2 . Diese Typen werden zusammen mit bisher leeren Substitutionstabellen vom SemanticWalker3 auch jeweils an den Vaterknoten
übergeben. Um auf Gleichheit zu prüfen müssen beide Typen gleich sein, daher kommt die
Substitution α1 7→ α2 in die Substitutionstabelle, der Rückgabetyp von „=“ ist boolean.
Interessanter ist wahrscheinlich, was auf der rechten Seite passiert: Für den Wert „1“ wird
sowohl integer wie real als möglicher Typ angegeben, schließlich kann der Typ gegebenenfalls automatisch umgewandelt werden. Da der Operator „+“ ebenfalls beide Typen
akzeptiert, gibt es hier mehrere Möglichkeiten:
36
y ist vom Typ integer, der Ergebnistyp der Addition ist ebenfalls integer.
y ist vom Typ real, genauso wie das Ergebnis der Addition.
y ist vom Typ integer, aber das Ergebnis der Addition ist vom Typ real, weil entweder die beiden Operanden oder das Ergebnis der Addition auf real gecastet wird.
Die Variable x muss natürlich den gleichen Typ haben wie der Wert, der ihr zugewiesen
wird, und der Rückgabetyp der Wertzuweisung ist der leere Typ blank.
Wenn man die Substitutionstabelle aus der Bedingung der IF-Anweisung mit den drei Substitutionstabellen, die der THEN-Abschnitt liefert, vergleicht, stellt man fest, dass α1 7→ α2
und verschiedene Typen für x und y widersprüchlich sind. Von den drei (Typ, Substititionstabelle)-Paaren bleiben also nur zwei als Ergebnis über. Folglich hätte dieses Beispiel auch
zwei denkbare Lösungen:
x:integer
x:real;
oder
y:integer
y:real;
Wenn der nachfolgende Baum auch etwas überladen ist, so sollte er doch recht anschaulich
zeigen, was für Informationen bei der semantischen Analyse mit „SW3“ verarbeitet werden. Jede runde Klammer enthält dabei einen Rückgabetypen und eine (eventuell leere)
Substitutionstabelle und steht für ein mögliches Ergebnis aus diesem Ast des Programmbaums.
statement
O
(blank,{α1 7→int,α2 7→int}),(blank,{α1 7→real,α2 7→real})
(boolean,{α1 7→α2 })
8 IF g
(blank,{α1 7→int,α2 7→int}),(blank,{α1 7→real,α2 7→real}),(blank,{α1 7→real,α2 7→int})
then −O part
condition
O
(blank,{α1 7→int,α2 7→int}),(blank,{α1 7→real,α2 7→real}),
.
.
(blank,{α1 7→real,α2 7→int})
(boolean,{α1 7→α2 })
@=^
(α1 ,{})
(α2 ,{})
x
y
:=
> d
(int,{α2 7→int}),(real,{α2 7→real}),(real,{α2 7→int})
(α2 ,{})
x
+
I U
(α2 ,{})
y
37
(real,{}),(int,{})
1
3.4.4
Umgang mit Funktionen
Funktionstypen stellen ganz eigene Probleme bei der Implementierung der Typinferenz, da man
sie etwas anders behandeln muss als andere Typen:
Zuerst einmal ist es in TIP möglich, Funktionen zu überladen. Das sorgt für einiges an zusätzlichen Sonderfällen, die man beachten muss, und verdirbt einem auch manchmal die Möglichkeit,
aus der Verwendung einer Funktion auf die Argumente des Funktionsaufrufes zu schließen, da
es mehr als eine Möglichkeit gibt, wie etwa hier:
writeln(x);
x kann gleichermaßen integer, real, boolean oder string sein, denn für all diese Typen ist
die Funktion definiert. Der TIP-Compiler gibt in solchen Fällen eine Warnung aus: „WARNING:
FunctionType: more than one possibly matching function found for writeln“.
Zwischenzeitlich habe ich auch überlegt, parametrischen Polymorphismus für Funktionen allgemein zuzulassen. Die Idee dahinter wäre, aus einer Definition einer Funktion mehrere überladende Funktionen zu ermitteln. Von diesem Vorhaben habe ich mich entfernt, denn sehr schnell
gäbe es sehr viele Möglichkeiten:
function mult (x1:auto; x2:auto:x3:auto):auto;
begin
mult := x1*x2*x3;
end;
Alleine diese Funktion könnte schon 23 + 1 = 9 verschiedene Typen haben. Jedes xi könnte
integer oder real als Typ haben, und wenn alle drei vom Typ integer wären, dann könnte
der Ausgabetype sowohl real als auch integer sein. Wenn man also alle Kombinationsmöglichkeiten abspeichern wollte, macht dieses exponentielle Wachstum der Kombinationsmöglichkeiten Probleme, schließlich steigt mit der Komplexität nicht nur der Bedarf an Speicher, sondern
vor allem auch die Fehleranfälligkeit. Deswegen hat ein Funktionssymbol, das n mal deklariert
wurde, auch nur genau n Typen.
Aus den Typen der Argumente bei einem Aufruf der Funktion auf den Typen der Funktion zu
schließen, ist ebenfalls nicht möglich. Sonst könnte eine recht allgemein formulierte Funktion
durch den späteren Gebrauch „spezialisiert“ werden. Die Konsequenz daraus ist, dass der Typ
einer Funktion, nachdem sie durchlaufen wurde, bereits festgeschrieben werden muss und später
nicht mehr geändert werden darf. Wenn diese Funktion dann im späteren Verlauf des Programms
benutzt wird, dann kann aus dem Typen der Funktion auf die Typen der Argumente des Funktionsaufrufs geschlossen werden, aber nicht anders herum. Bei der Implementierung führt das
zu Komplikationen, da der einfache Ansatz, den Programmbaum strikt von oben nach unten
zu durchlaufen und aus den Typen in den Blättern auf weiter oben liegende Typen zu schließen,
38
nicht mehr so simpel durchgeführt werden kann. Insbesondere gab es Probleme, sobald ich mehrfache Durchläufe des SemanticWalker durchführen wollte, was ja, wie bereits beschrieben, in
einigen Fällen notwendig ist um alle Typen zu ermitteln. Diese Durchläufe müssen durchgeführt
werden, bevor der Rest des Programms geprüft wird. Mit einem unsauberen „Hack“, in dem
globale Variablen benutzt werden, gelang mir dies ansatzweise, es gibt aber gerade im Umgang
mit verschachtelten Funktionen noch eine Menge Bugs im Programm, etwa wenn Typen zu früh
festgelegt werden.
39
Kapitel 4
Ergebnisse
4.1 Testprogramme
Es folgen einige kurze Testprogramme, um zu zeigen, welche Typen der Compiler herausbekommt und welche nicht. Die meisten dieser Programme erfüllen überhaupt keinen Zweck außer
diesem, berechnen also nichts Nützliches o. Ä.
Sofern nichts Gegenteiliges angeführt wird, werden alle Tests mit der Variante „SW2“ ausgeführt. Dabei wird immer zuerst das Eingabe-Programm vorgestellt und dann die Ausgabe des
Compilers betrachtet. Diese ist in einigen Fällen gekürzt dargestellt.
4.1.1
ueberschreiben.pas
PROGRAM ueberschreiben;
FUNCTION square(x:auto):auto;
BEGIN
square := x*x;
END;
VAR x:auto;
y:auto;
BEGIN
x:=square(y)*2.0;
END.
Dieses Beispielprogramm zeigt die Notwendigkeit, für Funktionen eigene Symboltabellen zu
halten. Innerhalb der Funktion square hat x einen anderen Typen als außerhalb. Der TIP-Compiler
gibt folgendes Programm zurück:
40
program UEBERSCHREIBEN;
var
X : real;
Y : integer;
function SQUARE(X : integer) : integer;
begin
SQUARE := X * X
end;
begin
X := INT2REAL(SQUARE(Y)) * 2.0;
end.
Für die Funktion square wird also der Typ integer → integer festgesetzt. Damit ist klar, dass
y vom Typ integer sein muss. Das externe Vorkommen von x hingegen ist vom Typ real.
4.1.2
array.pas
PRGRAM arrays;
VAR array1:auto;
array2:auto;
array3:auto;
array4:ARRAY [13..37] OF boolean;
int:auto;
BEGIN
array1[int] := 1;
int:=array1[1];
array2[0]:=array1;
array3[1] := 'test';
END.
Man sieht also, dass Feld-Typen recht gut herauszubekommen sind, vorausgesetzt, sie werden im
Verlauf des Programms einmal mit einem Indexzugriff aufgerufen. Die Länge der Felder wird
allerdings nicht geprüft, es werden einfach beliebige integer-Werte als Index akzeptiert.
ARRAY1 : array
ARRAY2 : array
ARRAY3 : array
ARRAY4 : array
INT : integer;
4.1.3
of
of
of
of
integer;
array of integer;
string;
boolean;
set.pas
PROGRAM menge;
41
TYPE setofint=SET of 1..20;
VAR prim:setofint;
x:auto;
y:auto;
BEGIN
prim := [2, 3, 5, 7, 11, 13, 17, 19];
readln(x);
y:= prim UNION y;
IF x IN prim THEN writeln('x ist eine Primzahl kleiner als 20');
END.
Durch die Angabe von prim : setofint; wird dieser Typ sofort festgelegt. Danach ist die
Typinferenz in der Lage, die Typen von x und y aufgrund der Operationen, mit denen sie benutzt
werden, korrekt zu bestimmen. Die Ausgabe ist dann:
type setofint = set of integer;
var
PRIM : setofint;
X : integer;
Y : setofint;
Kann man die Angabe prim : setofint; auch weglassen? Der TIP-Compiler würde ebenfalls
zu einem Ergebnis kommen, welches nicht direkt falsch, aber doch recht problematisch wäre:
PRIM : set of integer;
X : integer;
Y : set of integer;
„setofint“ ist definiert als eine Menge von integer-Werten zwischen 1 und 20, während „set
of integer“ nichts weiter ist als eine Menge von beliebigen integer-Werten. Das Problem,
das sich ergibt, ist also klar:
TYPE lottozahlen: SET OF 1..49;
TYPE tage_im_monat: SET OF 1..31;
TYPE geburtsjahre: SET OF 1900..2007;
Diese verschiedenen Mengentypen könnten nicht unterschieden werden, wenn sie nicht explizit
zugewiesen werden.
4.1.4
record.pas
PROGRAM recordtest;
TYPE person = RECORD
42
name:auto;
alter:auto;
vater:auto;
end;
VAR ich : auto;
andreas:auto;
BEGIN
ich.name := 'Paul-Gabriel Mueller';
ich.alter := 24;
andreas.name:='Andreas Mueller';
ich.vater := andreas;
END.
Records haben die für die Typinferenz günstige Eigenschaft, dass Ihre Attributbezeichner eindeutige Namen haben müssen. Deswegen kann man aus der Benutzung eines solchen Namens
direkt auf den genauen Record-Typen schließen. Hier gelingt es sogar, die rekursive Benutzung
des Typen „person“ zu entdecken. Es muss also nur angegeben werden, welche Attributnamen
ein Record hat, die Typen dieser Attribute können in der Regel ermittelt werden.
type person = record
VATER : person;
ALTER : integer;
NAME : string;
end;
var
ICH : person;
ANDREAS : person;
4.1.5
zeiger.pas
PROGRAM zeiger;
VAR zahl:auto;
BEGIN
zahl^:=6.6;
END.
Der Typ von zahl, also ein Zeiger vom Typ real, kann durch den SemanticWalker ermittelt
werden.
var ZAHL:^real;
43
4.1.6
intreal.pas
PROGRAM intreal;
VAR x:auto;
y:auto;
BEGIN
x:=5;
y:=x;
x:=y+0.5;
END.
Welches Ergebnis wünscht man sich hier überhaupt? Der Typ von „x“ muss wohl real sein,
schließlich hat x zum Schluss den Fließkomma-Wert „5.5“. „y“ hingegen bekommt nur einen
ganzzahligen Wert, könnte es also auch vom Typ integer sein? Da x vom Typ real ist, muss y
in dem Ausdruck y:=x; ebenfalls eine Gleitkommazahl sein. Schließlich ist Pascal eine statisch
und streng getypte Sprache, und in einer solchen darf sich der Typ einer Variablen im Verlauf
des Programms nicht ändern.
Der „SemanticWalker1“ legt x sofort nach dem Durchlaufen der ersten Zeile auf integer fest
und verabschiedet sich entsprechend mit einer Fehlermeldung, wenn man dieser Variablen später
einen real-Wert zuweisen möchte. Der „SemanticWalker2“ hingegen liefert folgendes Ergebnis:
program INTREAL;
var
X : real;
Y : real;
begin
X := INT2REAL(5);
Y := X;
X := Y + 0.5;
end.
Der „IntToRealWalker“ erkennt hier, dass „5“ in eine Fließkommazahl umgewandelt werden
muss.
4.1.7
intreal2.pas
PROGRAM intreal;
VAR x:auto;
y:auto;
BEGIN
x:=5;
y:=5;
44
x:=y+0.5;
END.
Das Programm unterscheidet sich nur an einer winzigen Stelle vom vorherigen, es wird „y“
direkt der Wert „5“ zugewiesen. Für die Typinferenz bedeutet das, dass diesmal integer der
„gewünschte“ Typ von „y“ ist. TIP übersetzt das Programm also folgendermaßen (SW2):
program INTREAL;
var
X : real;
Y : integer;
begin
X := INT2REAL(5);
Y := 5;
X := INT2REAL(Y) + 0.5;
end.
4.1.8
rekursion.pas
PROGRAM rekursion;
FUNTION x_hoch_n(x:real; n:auto):auto;
BEGIN
if n = 0 then x_hoch_n:=1 else
x_hoch_n := x_hoch_n(x, n-1)*x;
END;
BEGIN
writeln(x_hoch_n(5, 3));
readln;
END.
Die Funktion berechnet xn . Den Typ von x wurde hier manuell auf real festgelegt, da die Typinferenz ansonsten integer × integer → integer als Typen der Funktion ermitteln würde.
Der Ausgabetyp von x_hoch_n muss eine Fließkommazahl sein, schließlich wird er durch eine Multiplikation mit einem real-Wert ermittelt. Da es keinen Hinweis darauf gibt, dass n ein
real-Wert sein muss, wird der Typ dieser Variablen als integer angenommen.
program REKURSION;
function X_HOCH_N(X : real;N : integer) : real;
begin
if N = 0 then X_HOCH_N := INT2REAL(1)
else X_HOCH_N := X_HOCH_N(X, N - 1) * X
end;
45
begin
WRITELN(X_HOCH_N(INT2REAL(5), 3));
READLN();
end.
4.1.9
member.pas
PROGRAM member;
VAR b:auto;
c:auto;
FUNCTION member (x:auto; a:auto):auto;
VAR i:auto;
BEGIN
member:=false;
FOR i:=0 TO (length(a)-1) DO
BEGIN
IF (a[i]=x) THEN member:=true;
END;
END;
BEGIN
c[1]:=5.4;
IF member(b, c) THEN writeln(b);
END.
Die Funktion member bekommt als Argument ein Element vom Typ α und ein Array von αWerten und gibt true zurück, wenn das Argument x in a ist. Aussagen darüber, wie das α beschaffen ist, kann man aufgrund der Definition der Funktion nicht treffen. Nun wird member mit
einem real-Wert als erstes Argument aufgerufen. Kann man daraus schließen, dass α 7→ real
eine sinnvolle Substitution ist?
Wenn man zusätzlich member('hallo', d), wobei d natürlich vom Typ array(string) wäre,
aufrufen wollte, dann würde die Inkompatibilität von real und string zu einem Fehler führen,
obwohl die Funktion member für beliebige α definiert ist. Es ist also sinnvoll, über die Eingabetypen von member keine genaueren Aussagen zu treffen, sondern den Typen von member auf
α × array(α) → boolean zu belassen. Ein solches Programm wäre allerdings natürlich zumindest in Pascal nicht kompilierbar.
program MEMBER;
var
B : real;
C : array of real;
46
function MEMBER(X : alpha1;A : array of alpha1) : boolean;
var
I : integer;
...
Wenn c vom Typ array of real ist und als das zweite Argument einer Funktion mit dem Typ
α × array(α) → boolean benutzt wird, ist klar, dass das erste Argument, also b, vom Typ real
sein muss. Obwohl der Fall also anschaulich ganz einfach erscheint, ist es doch weit weniger
offensichtlich, wie man diesen Sachverhalt praktisch umsetzt: α1 7→ real einfach in die Substitutionstabelle zu schreiben kann nicht die Lösung sein, sonst würde es einen Fehler geben, wenn
man die Funktion member etwa mit einer Zeichenkette und einem Array von Zeichenketten aufrufen würde. Eine Lösung könnte sein, Substitutionen der Form α1 7→ δ kurzzeitig zuzulassen,
und nach dem Durchlaufen des Funktionsaufrufs wieder zu löschen. Es bleibt jedenfalls festzuhalten, dass der Umgang mit parametrischem Polymorphismus nicht einfach ist.
4.1.10
avg.pas
PROGRAM durchschnitt;
FUNCTION avg(a:auto):auto;
VAR i:auto;
sum:auto;
BEGIN
FOR i:=1 TO length(a) DO sum := sum + a[i];
avg:= sum / length(a);
END;
FUNCTION avg(a:auto;b:auto):auto;
VAR feld:auto;
BEGIN
feld[1] := a;
feld[2] := b;
avg:=avg(feld);
END;
BEGIN
END.
Hier liegt ein Beispiel für Ad-hoc Polymorphie, genauer überladenden Polymorphismus vor. Die
Funktion avg bekommt also entweder ein oder zwei Argumente. Wenn man genauer hinsieht,
wird man erkennen, dass das Argument von der ersten Definition von avg wohl ein array von
integer-Werten sein wird. Mit dieser Information lässt sich dann auch der Typ der zweiten
Definition ermitteln: integer×integer → real. Der TIP-Compiler ermittelt das gleich Ergebnis:
47
program DURCHSCHNITT;
function AVG(A : array of integer) : real;
var
I : integer;
SUM : integer;
begin
for I := 1 TO LENGTH(A) DO
SUM := SUM + A[I];
AVG := INT2REAL(SUM) / INT2REAL(LENGTH(A))
end;
function AVG(A : integer;B : integer) : real;
var
FELD : array of integer;
begin
FELD[1] := A;
FELD[2] := B;
AVG := AVG(FELD)
end;begin
end.
Erstaunlicherweise funktioniert die Typinferenz genausogut, wenn die beiden Funktionen in umgekehrter Reihenfolge definiert werden.
4.1.11
kmp.pas
KMP steht für Knuth-Morris-Pratt, ein Algorithmus zur Suche von Zeichenketten in Texten, der
in der Vorlesung „Zeichenketten“ an der Universität Hannover besprochen wurde.[Par04]
Der TIP-Compiler soll zum Abschluss dieses Abschnitts noch einmal mit einem „echten“ Programm gefüttert werden. Alle Variablen (i, j, m, next, p) werden mit auto deklariert.
BEGIN
i := 1;
j := 0;
NEXT[1] := 0;
WHILE i <= m DO BEGIN
WHILE (j > 0 AND p[i] <> p[j]) DO
j := next[j];
i := i+1;
j := j+1;
IF (i <= m) AND (p[i] = p[j]) THEN
next[i] := next[j]
ELSE
next[i] := j;
END
48
END.
Man sieht, dass das Beispiel sehr Typinferenz-freundlich ist: gleich in den ersten drei Zeilen
werden Variablen ganzzahlige Werte zugewiesen, die später auch nicht durch Gleitkommazahlen überschrieben werden. So ist es auch kein Wunder, dass das Ergebnis den Vorstellungen
entspricht:
var i: integer;
j: integer;
next: array of integer;
p: array of integer;
m: integer;
An diesem Beispiel sieht man das Risiko des Gefühls der falschen Sicherheit, wenn man Tests
hauptsächlich mit realen Programmen durchführt, die auf die besonderen Probleme bei der Typinferenz nicht weiter eingehen. Das Finden der wirklich problematischen Testfälle ist eine nicht
zu unterschätzende Aufgabe.
4.2 Beobachtungen und Erfahrungen
4.2.1
Drei Implementationen der semantischen Analyse
Der bisher ausgereifteste Ansatz, der hier präsentiert werden kann, ist jener, in dem der PseudoTyp intorreal benutzt wird, „SW2“. Solange nichts Unmögliches, wie etwa den Typen einer
Variablen, die niemals benutzt wird, zu raten, verlangt wird, ist die Typinferenz prinzipiell meistens in der Lage den „richtigen“ Typen zu ermitteln. Als wichtig hat sich dabei erwiesen, mehr als
nur einen Durchlauf des „SemanticWalker“ durchzuführen und so nach und nach die unklaren
Typen zu ermitteln. Bei Records geht das sehr gut, bei Mengen braucht der Compiler etwas mehr
Unterstützung, zumindest wenn man nicht nur „ähnliche“, sondern tatsächlich gleiche MengenTypen erkennen möchte.
„SW1“ ist durch die frühe Festlegung auf integer recht restriktiv, ansonsten aber mit „SW2“
beinahe baugleich und sollte entsprechend bei Programmen, in denen sich das Problem mit
integer und real nicht stellt, das Gleiche leisten. Solche Programme sind aber doch recht
selten, wenn man von einem Programmierer ausgeht, der sich nicht weiter mit dieser Problematik auseinander gesetzt hat und entsprechend Programme ohne Rücksicht auf Schwierigkeiten
bei der Typprüfung schreibt.
„SW3“ wäre, wenn vollständig entwickelt, wahrscheinlich die leistungsfähigste Lösung, da die
49
an der Sprache vorgenommenen Änderungen (Ersetzen der Operatoren durch andere Symbole,
um überladende Funktionen größtenteils zu vermeiden) nicht nötig wären. Leider ist die Implementierung dieses Ansatzes recht aufwändig und ist entsprechend fehleranfällig, weswegen ich
in der gegebenen Zeit nur sehr rudimentäre Ansätze der Typinferenz implementieren konnte.
So kann SW3 in dieser Version nur extrem simple Programme prüfen. Schwierigkeiten verursacht insbesondere, dass variable Typen, die etwa beim Durchlaufen eines Feldzugriffs entstehen
können, in einer Substitutionstabelle benutzt werden, die beim nächsten Zugriff auf das gleiche
Objekt nicht zur Verfügung steht. Der Umgang mit variablen Typen, die erst während der semantischen Analyse erzeugt werden, ist also ein Problem, das ich bisher nicht lösen konnte.
4.2.2
Substitutionen von variablen Typen miteinander
Erst kurz vor der Abgabe bemerkte ich ein Problem für die Typprüfung, welches ich vorher übersehen hatte. Schon der folgende Dreizeiler führte beim TIP-Compiler zu einem Fehlverhalten,
das nicht so einfach zu beheben war:
x:=y;
z[y]:='a';
x:=6.6;
Dieses Beispiel mag harmlos aussehen, hat es aber in sich. Aus der ersten Zeile bekommt man
die Substitution α1 7→ α2 , was also bedeutet, dass x und y den gleichen Typ haben müssen.
Durch die zweite Zeile kommt der Eintrag α2 7→ integer hinzu. In Zeile drei meldet der Compiler daraufhin einen Typfehler, schließlich wird x mit dem Typ α1 7→ α2 7→ integer ein Wert
vom Typ real zugewiesen.
Nun ist klar, dass x und y wegen der schon häufig erwähnten automatischen Typumwandlung
nicht zwingend den selben Typ haben müssen. Aber was für Konsequenzen zieht man daraus?
Für den Sonderfall mit integer und real spezielle Methoden auf die Substitutionstabelle anwenden? Das erschien mir unmöglich, da in der Substitutionstabelle die Information über den
Kontext eines Eintrags fehlt. Substitutionen der Art αi 7→ αj , wobei also beide Typen variable
Typen sind, grundsätzlich nicht mehr zulassen? Das erschien mir etwas zu drastisch, schließlich
würde man doch sehr viele Informationen dadurch einfach verlieren.
Zum Glück ist es ohnehin vorgesehen, dass der SemanticWalker das Programm mehrfach
durchläuft, um die Informationen nach und nach „einzusammeln“. Wenn man dieses Prinzip
beachtet, kann man es sich leisten, die Information, dass zwei variable Typen (wahrscheinlich)
gleich sind, erst einmal zu ignorieren. Bei dem obigen Beispiel jedenfalls klappt es gut:
Der erste Durchlauf liefert bereits alle Substitutionen: α2 7→ integer, α3 7→ array(string),
α1 7→ real. In den folgenden Durchläufen kann dann die Typkorrektheit der Zuweisung x:=y
geprüft werden, die in diesem Fall gegeben ist. Der Compiler liefert das gewünschte Ergebnis:
50
X : real;
Y : integer;
Z : array of string;
begin
X := INT2REAL(Y);
Z[Y] := 'a';
X := 6.6;
end.
Ein weiterer positiver Effekt dieser einfachen Maßnahme ist, dass Schleifen bei den Substitutionen nicht mehr so leicht auftreten können.
4.2.3
Rückblick
In diesem Abschnitt möchte ich gerne ein paar Worte zu meinen persönlichen Erfahrungen sagen. Recht früh während der viermonatigen Bearbeitungszeit gelang es mir bereits, sehr einfache
Programme zu parsen, auf Typkorrektheit zu prüfen und die erhofften Typen auch tatsächlich zu
ermitteln. Diese Erfolgserlebnisse waren schon wichtig für die Motivation, verdeckten aber auch
etwas den Blick für die Details. Das richtige Erbgebnis bei der Typinferenz für ein bestimmmtes Programm zu erzielen heißt ja nicht, dass der Weg dahin auch „richtig“ ist, was sich dann
aber eventuell erst sehr viel später bei komplexeren Problemen zeigt. So funktionierte mein Programm zu Beginn so, dass es einfach annahm, die beiden Operanden einer binären Operation
seien vom gleichen Typ, ohne zu prüfen, ob dieser Typ für diese Operation überhaupt gültig ist.
Solange man die Operatoren nur verwendet, wie sie eigentlich verwendet werden sollen, fällt
dieser grundsätzliche Fehler gar nicht auf.
Leider erkannte ich erst sehr spät die Notwendigkeit, eine variable Anzahl von Durchläufen
für die semantische Analyse zu implementieren. Dadurch wurden einige Probleme, für dich ich
vorher umständliche Lösungen erarbeitete, gelöst, und natürlich tauchten andere auf, die in der
kurzen Zeit bis zu Abgabe dann nicht mehr alle gelöst werden konnten.
Insgesamt bin ich aber recht zufrieden mit dem Ergebnis. Wenn sich der TIP-Compiler auch in
bestimmten Fällen fehlerhaft verhält, so ist er doch prinzipiell in der Lage, auch recht knifflige
Programme mit überladenden Operatoren und Funktionen, automatischer Typumwandlung und
vielen verschiedenen zusammengesetzten Datentypen zu parsen, auf Typkorrektheit zu prüfen
und das Programm in TIP-Syntax wieder auszugeben.
51
4.2.4
Ausblick
Da das Hauptaugenmerk bei dieser Arbeit auf der Implementation und nicht auf der Theorie lag,
ist diese hier nur wenig in den Blick genommen worden. Eine wichtige Aufgabe für die Zukunft
könnte daher sein, die Theorie hinter der Typinferenz für imperative Sprachen in formaler Weise
zu formulieren. Dafür kann es eventuell nötig oder sinnvoll sein, die Sprache etwas abzuändern,
wie ich es für Pascal in dieser Arbeit ebenfalls getan habe. Die Frage, die sich dann stellt, ist ob
die Kosten solcher Änderungen in einem guten Verhältnis zum Nutzen der Typinferenz stehen.
Eine Typinferenz, die nicht immer funktioniert, ist sicherlich kaum besser als gar keine, da der
Programmierer so nie genau weiß, wann er einen Typen angeben muss und wann nicht.
52
Kapitel 5
Schluss
5.1 Fazit
Die Aufgabe war es, die Grenzen eines Typinferenz-Systems aufzuzeigen. Meiner Erfahrung
nach sind diese Grenzen eher die Auffassungsgabe des Programmierers und die durch die vielen möglichen Situationen bei der Substitution gegebene Fehleranfälligkeit als feste technische
Grenzen. Das Management der Substitutionstabelle läuft beispielsweise noch lange nicht optimal: Um Schleifen zuverlässig zu vermeiden, muss man entweder verbieten, dass etwas anderes als variable Typen auf der linken Seite auftauchen, oder man muss Array- oder Funktionstypen aufwändig auf Strukturäquivalenz überprüfen, um Schleifen der Form array(α1 ) 7→
array(α2 ) 7→ array(α1 ) zu verhindern. Solche Situationen gibt es häufiger, denen nur mit Beispielen ohne theoretische Grundlage schwer beizukommen ist.
Der Ansatz mit intorreal hat sich als recht brauchbar erwiesen, solange man bei den überladenden Operationen nur einen dieser beiden Typen zu erwarten hat. Wichtig ist hier allerdings, den Programmbaum dabei oft genug zu durchlaufen, da man sonst Typfehler angezeigt
bekommt, wo eigentlich gar keine sind, oder, was noch schlimmer ist, falsche Programme akzeptiert werden. Wenn man mit mehr überladenden Operatoren und mehr möglichen Typen zu
tun hat, ist es wohl unvermeidlich, mit Mengen von möglichen Typen und dazugehörigen Substitutionen zu arbeiten, wie im SemanticWalker3 angedeutet. Dutzende von Pseudotypen der Art
int_or_real_or_set_or_string jedenfalls sind keine realistische Lösung.
Beim Prüfen von Funktionen wird die Komplexität des Problems besonders deutlich. Auch ohne parametrischen Polymorphismus sorgt schon die Kombination aus dem überladenden Polymorphismus, Typumwandlungen (coercion polymorphism), verschachtelten Funktionen und
dem Überschreiben von Symbolen innerhalb der Funktionen für einige schwierige Situationen
53
und leider auch für einige Bugs in der Implementation des TIP-Parsers.
Ein Problem, das auch ohne Programmierfehler bliebe, wäre auf jeden Fall die fehlende Bereichsprüfung bei Feld-Indizes oder den Objekten von Mengen. Hier führt wohl kein Weg daran
vorbei, dass der Programmierer die Typen weiterhin manuell angibt oder selber die Werte zur
Laufzeit prüfen lässt.
5.2 Installation & Bedienung
5.2.1
Installation mit Eclipse
• Auf der CD befindet sich der Ordner „tip/“, in dem sich das gesamte Java-Projekt inklusive
Pascal-Testprogrammen befindet. Diesen Ordner bitte einfach in das Arbeitsverzeichnis
(„workspace“) von Eclipse kopieren.
• In Eclipse wählt man nun F ile → N ew → P roject → JavaP roject.
• Als „Project Name“ „tip“ eingeben, und „Create new Project in workspace“ markieren.
• Eclipse sollte nun automatisch die Verzeichnisstruktur erkennen.
• Nun sollte man den Compiler schon benutzen können: Run → Run... mit folgenden
Einstellungen:
Project
tip
Main class
tip.Start
Arguments → Program arguments test.pas
• Falls es Fehler beim Kompilieren gibt, muss man vielleicht die Version des Compilers umstellen: Unter W indow → P ref erences → Java → Compiler „Compiler compliance
level“ auf „5.0“ stellen.
5.2.2
Compiler-Argumente
tip.Start erwartet folgende Argumente:
dateiname zwischenausgabe-flag sw-version iterationen vartype-flag
54
Die Argumente werden einfach durch Leerzeichen voneinander getrennt. Sie haben folgende
Bedeutung:
• dateiname: Der Name der Pascal-Datei abhängig von der Position des Ordners „PAS“.
Etwa „beispiele/boolean.pas“.
• zwischenausgabe-flag: Wenn der Wert „1“ ist, wird das Program auf der Konsole schon
ausgegeben, bevor die Typinferenz erfolgt ist. Das kann bei später auftretenden Fehlern
helfen zu erkennen, ob das Programm wenigstens richtig geparst wurde.
• sw-version: Hier kann man einstellen, welche der drei Versionen des „SemanticWalker“
benutzt werden soll. Gültige Werte sind folglich 1, 2 oder 3. Wenn man das Argument
weglässt, wird SemanticWalker2 ausgeführt.
• iterationen: Diese Einstellung legt fest, wie viele Durchläufe der SemanticWalker durch
den Programmbaum machen soll. Gültige Werte sind 0-9. Standard-Wert ist 3. Dieser Parameter hat nur Auswirkungen bei Benutzung von SemanticWalker1 oder SemanticWalker2.
• vartype-flag: Wenn der Wert auf „1“ gesetzt ist, werden Einträge der Art α1 7→ α2 in
der Substitutionstabelle akzeptiert, steht er auch „0“, werden sie nicht gesetzt.
55
5.3 Quellen und Danksagungen
Die mit Abstand wichtigste Hilfe bei der Erstellung dieser Arbeit war mir Torben Wichers, der
mir nicht nur mit dem „BPS“-Compiler das Gerüst für die weitere Programmierung zur Verfügung gestellt hat, sondern auch jederzeit ansprechbar für meine Fragen war. Viele Aspekte und
Probleme der Typinferenz wurden mir erst beim Gespräch mit ihm verständlich.
Beim Lernen der Syntax von Pascal und dem Entdecken der verschiedenen Sprachkonstrukte hat
mir der HTML-Pascalkurs von Dr. P. Böhme sehr geholfen. Dieser lässt sich mühelos „googlen“
und sei jedem ans Herz gelegt, der sich in irgendeiner Form mit PASCAL beschäftigen möchte.
[Böh96]
In Niklaus Wirths „Algorithmen und Datenstrukturen“, aus dem Jahr 1975 fand ich einige Beispielprogramme, um mein Programm damit auf die Probe zu stellen, wobei es sich schließlich
doch als nützlicher erwiesen hat, selbst kurze Testprogramme zu schreiben, die direkt einen speziellen Aspekt der Typinferenz prüfen.[Wir75]
Beim Klären etlicher Begriffe und zum besseren Verständnis von Zusammenhängen waren Google und vor allem Wikipedia natürlich ebenfalls eine große Hilfe. Bei der Suche nach Rechtschreibund Grammatikfehlern half mir mein Vater sehr, der mich auch hin und wieder darauf hinwies,
wenn Begriffe widersprüchlich verwendet oder gar nicht erklärt wurden.
56
Literaturverzeichnis
[Böh96]
: Online-Tutorial „Programmiersprache Pascal“. Fachbereich Mathematik und Informatik, Martin-Luther-Universität Halle-Wittenberg„ 1996.
[LC85]
Luca Cardelli, Peter Wegner
[Par04]
Parchmann, Rainer
[Par05]
Parchmann, Rainer
[Wir75]
Wirth, Niklaus
Böhme, P.
: On Understanding Types, Data Abstraction,
and Polymorphism. Computing Surveys, Vol. 17, No. 4, 1985.
: Skript zur Vorlesung „Zeichenketten“. FG Programmiersprachen und Übersetzer, Universität Hannover, 2004.
: Skript zur Vorlesung „Programmiersprachen und Übersetzer“. FG Programmiersprachen und Übersetzer, Universität Hannover, 2005.
: Algorithmen und Datenstrukturen. B.G.Teubner Stuttgart, 1975.
57
Herunterladen