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