Westfälische Wilhelms-Universität Münster Ausarbeitung Typüberprüfung (im Rahmen des Seminars: Übersetzung von künstlichen Sprachen) Patrick Förster Themensteller: Prof. Dr. Herbert Kuchen Betreuer: Christian Hermanns Institut für Wirtschaftsinformatik Praktische Informatik in der Wirtschaft Inhaltsverzeichnis 1 Einleitung ................................................................................................................. 3 2 Datentypen ................................................................................................................ 4 2.1 Primitive Datentypen ...........................................................................................4 2.2 Zusammengesetzte Datentypen ...........................................................................4 2.3 Rekursive Datentypen ..........................................................................................5 3 Typsysteme ............................................................................................................... 6 3.1 Statische und Dynamische Typsysteme ..............................................................6 3.2 Parametrischer Polymorphismus .........................................................................6 4 Anwendung: Einfache Typüberprüfung ..................................................................8 4.1 Beispielsprache .................................................................................................... 8 4.2 Übersetzungsschema ...........................................................................................9 4.2.1 4.2.2 4.2.3 4.2.4 Überprüfung von eingebaute Datentypen ................................................9 Überprüfung von Operatoren ..................................................................9 Überprüfung von Ausdrücken .................................................................9 Überprüfung von Anweisungen ............................................................10 4.3 Äquivalenz von Typen .......................................................................................10 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 5 Strukturelle Äquivalenz .........................................................................11 Äquivalenz von Namen .........................................................................12 Baumrepräsentation von Typen .............................................................12 Typkonvertierung ..................................................................................13 Überladen von Operatoren und Funktionen ..........................................14 Polymorphe Funktionen .........................................................................................16 5.1 Type Inference ................................................................................................... 16 5.2 Erweiterung des Übersetzungsschemas .............................................................17 5.3 Unifizierbarkeit ..................................................................................................18 5.4 Algorithmus für Unifikation ..............................................................................19 5.4.1 5.4.2 6 Konstruktion des Typgraphen ...............................................................19 Der Algorithmus ....................................................................................21 Zusammenfassung ..................................................................................................24 Literaturverzeichnis ........................................................................................................ 25 II 1 Einleitung Das Verhalten eines Programms, das Verhalten von Klassen, das Verhalten von Methoden, all dies ist maßgeblich durch den Fluss von Daten bestimmt. Um ein korrektes Verhalten dieses Flusses zu garantieren, muss es Möglichkeiten geben, diese Daten zu typisieren und zu vergleichen. Nur so kann zur Compilezeit, oder ggf. während der Ausführung eines Programms, festgestellt werden, ob dieses überhaupt syntaktisch korrekt ist (z.B. wird eine Funktion auf Argumente angewandt, für die sie überhaupt nicht definiert ist?). Natürlich gehört zur syntaktischen Analyse mehr als nur die Typüberprüfung. Dennoch ist sie essentieller Bestandteil eben dieser. Wenn man die Pässe, die ein Compiler bis zur fertigen Complierung eines Quellcodes grob in Lexer, Parser, Kontextanalyse und Synthese (Codererzeugung) einteilt, so ist die Typüberprüfung Teil der Kontextanalyse und wird also in den meisten Fällen auf den durch Lexer und Parser erzeugten attributiertem Baum angewandt. Abb. 1.1 Compiler Pässe Im Laufe dieser Arbeit wird zuerst ein allgemeiner Blick auf Datentypen aus der Designkonzeptsicht einer Programmiersprache geworfen (Kapitel 2), und ein kleiner Einblick in die Welt der Typsysteme gegeben (Kapitel 3). Um danach auf die grundlegende Problematik der Typüberprüfung an Hand einer simplen, aber für die Zwecke ausreichenden Sprache einzugehen (Kapitel 4). Zum Schluss wird noch ein spezielles Augenmerk auf Typvariablen, bzw. polymorphe Funktionen gelegt (Kapitel 5). 2 Datentypen Ein Datentyp ist mathematisch gesprochen nichts anderes als eine Menge von Werten. Im Wesentlichen unterstützen alle Programmiersprachen sowohl primitive als auch zusammengesetzte, die meisten Programmiersprachen zudem rekursive Datentypen. Sei T ein Datentyp, dann nennt man v Î T einen Wert vom Typ T. 2.1 Primitive Datentypen Als primitiven Datentyp versteht man solche Datentypen, deren Werte nicht in einfachere Werte zerlegbar sind. Jeder Programmiersprache liegen eingebaute Datentypen zur Grunde. Zu den gebräuchlichsten gehören dabei boolean, char, int, float, und enums. Gesondert zu betrachten sind Zeiger. Ist T ein Datentyp, so ist pointer(T) ebenfalls ein Datentyp. 2.2 Zusammengesetzte Datentypen Analog zu den primitiven Datentypen sind zusammengesetzte solche, deren Werte aus einfacheren Werten zusammengesetzt werden. Man kann unterscheiden zwischen: Kartesisches Produkt (Structs, Records) Sind T1....Tn Datentypen (nicht notwendigerweise gleich), dann ist das Kartesische Produkt dieser wieder ein Datentyp: T1 × ... × Tn = {(t1 ,.., t n ) | t1 ∈ T1 ,..., t n ∈Tn } Abbildungen (Arrays, Funktionen) Sowohl Arrays als auch Funktionen gehören zu den Hauptanwendungen für Abbildungen. Analog zur Mathematik bildet eine Abbildung f Werte eines Typs S auf Werte eines Typs T ab, kurz f:S®T Bei Funktionen (oder allgemein Abbildungen), die auf mehr als einem Parameter arbeiten, identifiziert man S als Tupel der Parametertypen S1 × ... × Sn . Um Arrays und Funktionen im Folgenden besser auseinander halten zu können, sei folgende Notation für Arrays eingeführt: array(I, T) I sei dabei eine Indexmenge (oft beschränkt auf den Bereich Integer) und T der Typ des Arrays. Die Datentypen werden also durch den jeweiligen Operator zusammengesetzt. Im Folgenden werden array, × und à als Typkonstruktoren bezeichnet. 2.3 Rekursive Datentypen Rekursive Datentypen sind Typen, die sich durch sich selbst definieren. In imperativen oder objektorientierten Sprachen wie Pascal oder C++ werden rekursive Datentypen über Zeiger realisiert, während sie in anderen Sprachen teilweise zu den eingebauten Datentypen gehören (z.B. Listen in Lisp / Scheme) oder können aufgrund der Referenzsemantik direkt definiert werden (Java, C#). Eine allgemeine Definition für rekursive Datentypen lässt sich wie folgt angeben: R = nil ∨ T1 × ... × Tn × R 1 × ... × R m mit n ≥ 0, m > 0, R i = R Ein rekursiver Datentyp ist also entweder nil (leer) oder ein Tupel von Datentypen, in dem der Datentyp selbst wieder ein oder mehrere Male vorkommt. 3 Typsysteme Ein Typsystem beschreibt Verfahrung zur Überprüfung der Typsicherheit eines Programms. Vor der Anwendung eines Operators müssen die Operanden auf ihren Typ überprüft werden. Erst dann kann überhaupt entschieden werden, ob ein Operator existiert, der auf den angegebenen Typen anwendbar ist. Existiert ein solcher nicht, ist das Programm aufgrund eines Typfehlers syntaktisch inkorrekt. 3.1 Statische und Dynamische Typsysteme Man unterscheidet generell zwischen dynamischen und statischen Typsystemen. Der Unterschied bezieht sich in erste Linie auf den Zeitpunkt, zu dem ein Typcheck durchgeführt wird. Bei einem dynamischen Typsystem wird zur Ausführungszeit überprüft, bei einem statischen schon während der Kompilierung. In einem statischen Typsystem hat jede Variable und jeder Ausdruck einen festgelegten Typ, wohingegen in dynamisch getypten Sprachen Variablen und Ausdrücke erst zur Laufzeit getypt werden. Lediglich Werte sind von einem fixen Typ. Somit haben statisch getypte Sprachen einen Effizienz- sowie Speicherplatzvorteil, da keine Überprüfungen zur Laufzeit durchgeführt werden müssen. Bei dynamischen Typsystemen muss für jeden aufkommenden Wert zudem auch sein Typ mitgespeichert werden. Des Weiteren sind statische Typsysteme von Natur aus sicherer, wohingegen dynamische eine höhere Flexibilität bereitstellen. 3.2 Parametrischer Polymorphismus Im Allgemeinen kann man zwischen drei Arten von parametrischem Polymorphismus unterscheiden: • Parametrische Funktionen • Parametrische Klassen • Parametrische Typen. Alle drei zeichnen sich dadurch aus, dass sie durch Typvariablen spezifiziert werden, d.h. der Typ, auf dem eine solche Funktion, Klasse oder ein (zusammengesetzter) Typ basiert, wird bei der Spezifikation nicht festgelegt, sondern bleibt variabel. Das hat einen Gewinn an Universalität zur Folge. Im Folgenden werden Typvariablen durch kleine griechischen Buchstaben dargestellt: σ,τ,υ… Parametrische Typen werden von fast allen Programmiersprachen unterstützt, allerdings mit der Einschränkung, dass es sich dabei meistens um eingebaute Datentypen handelt. Beispiel: Weiter oben wurde der primitive Datentyp pointer eingeführt. Es gilt: Ist υ ∈ ∪ T , so ist pointer (υ) ein Datentyp T Datentyp Ein solcher Datentyp wird Polytyp genannt. Polytypen enthalten immer mindestens eine Typvariable. Beispiel (Haskell): second(x: υ, y: τ) = y Die Programmiersprache Haskell unterstützt parametrische Funktion. Die Funktion „second“ bekommt als Eingabe ein Paar von Werten und liefert das zweite Element zurück. Es wäre unsinnig, eine solche Funktion auf ein bestimmtes Paar von Werten (z.B. Integer × Boolean) zu beschränken. Dies wird hier durch das Einführen der Typvariablen υ und τ umgangen. Die Funktion ist also vom Typ: υ ×τ à τ Eine mögliche Instanz des Typs wäre: Boolean × Integer à Integer second(True, 27) = 27 Das Beispiel stammt aus [Wa04]. (siehe Kapitel 5) 4 Anwendung: Einfache Typüberprüfung Anhand einer simplen Beispielsprache sollen im Folgenden die grundlegenden Problematiken der Typüberprüfung dargelegt werden. Die Beispielsprache orientiert sich dabei an [ASU86 Kapitel 6]. Dabei werden Kenntnisse über die EBNF- Notation für Grammatiken vorausgesetzt. Für jede Regel (bzw. für den Großteil der Regeln) werden dann im Folgenden Übersetzungsregeln zur Typbestimmung der einzelnen Ausdrücke angegeben. Es wird angenommen, dass im Laufe des Compilierprozesses ein attributierter Baum als Repräsentant für den Quellcode erzeugt wurde. Jeder Knoten in einem solchem Baum hat diverse Attribute, wobei hier in erster Linie die Bestimmung des Typattributs im Vordergrund steht. 4.1 Beispielsprache P D T E S O I := := := := := := := D ; E D ; D | I : T char | integer | boolean | array[num] of T | ^T | T -> T literal | num | I | E O E | E[E] | E^ | S | I(E) | E ; E I := E | if (E) then S | while (E) do S | S ; S + | - | * | / | > … … Abb. 4.1 Grammatik der Sprache Die obige Grammatik definiert eine Sprache, in der zuerst eine Reihe von Deklarationen (D) definiert werden, gefolgt von sequenziellen Ausdrücken (E). Dick gedruckt sind die Terminalsymbole. (O) und (I) sind nur der Vollständigkeit halber angegeben. Zu den angebotenen Datentypen gehören char, integer, boolean, Arrays, Zeiger (^T) und Funktionen (T -> T). Implizit existiert noch ein weiterer Typ type_error. Beispiel: number : integer; // Deklaration func : integer -> integer; // Deklaration if (func(20) > 10) number := 10; 4.2 Übersetzungsschema Für jede Produktion in der Grammatik können jetzt einfache Übersetzungsregeln definiert werden, anhand derer der Typ eines Ausdruckes abgeleitet werden kann. Ein solches Übersetzungsschema könnte schon beim Parsen eines Quellcodes angewandt werden. 4.2.1 Überprüfung von eingebaute Datentypen D T T T T T := := := := := := I : T { addtype(I.entry, T.type) } char { T.type := char } integer { T.type := integer } ^T1 { T.type := pointer(T1.type) } array[num] of T1 { T.type := array(1..num.val, T1.type } T1 -> T2 { T.type := T1.type à T2.type } Abb. 4.2 Übersetzungsschema für eingebaute Datentypen [ASU86] Die erste Regel fügt der Variablentabelle eine neue Variable vom Typ T.type hinzu, deren Name durch I.entry gegeben sei. Alle weiteren Regeln setzen das Typ- Attribute. 4.2.2 Überprüfung von Operatoren O à > { O.type := boolean O.left := integer O.right := integer } (usw.) Abb. 4.3 Übersetzungsschema für Operatoren Operatoren sind in der Regel zweistellig, also von der Form (L-Operand) Operator (ROperand) (L = links, R = rechts). Dabei muss nicht unbedingt L-Operand.type == Operator.type gelten. Demnach müssen nicht nur der Operatortyp, sondern auch die Typen seiner Operanden vordefiniert sein. Dies geschieht über die Attribute left und right. 4.2.3 Überprüfung von Ausdrücken E E E E à à à à literal num I E1 O E2 { { { { E.type E.type E.type E.type := := := := char } integer } lookup(I.entry) } if E1.type = O.left and E2.type = 0.right then O.type else type_error } E à E1[E2] { E.type := if E2.type = integer and E1.type = array(s, t) then t else type_error E à E1^ { E.type := if E1.type = pointer(t) then t else type_error } E à E1 (E2) { E.type := if E2.type = s and E1.type = s à t then t else type_error } Abb. 4.4 Übersetzungsschema für Ausdrücke [ASU86] Bei der Überprüfung von Ausdrücken muss sichergestellt werden, dass diese syntaktisch richtig geformt sind. So gibt z.B. jeder Operator vor, von welchem Typ seine Operanden sein müssen, damit er anwendbar ist. Die Beispielsprache lässt Arrayzugriffe nur über Integerwerte zu, bzw. über Integerkompatible Werte, da die Indexmenge I, auf der ein Array gültig ist, meistens nur eine Teilmenge von Integer ist. Für die weitere Überprüfung eines Arrayzugriffs spielt die Menge I allerdings keine Rolle. Bei Funktionsaufrufen muss kontrolliert werden, ob die Funktionsparameter kompatibel sind 4.2.4 Überprüfung von Anweisungen S à I := E { S.type := if I.type = E.type then void else type_error } S à if E then S1 { S.type := if E.type = boolean then S1.type else type_error } S à while E do S1 { S.type := if E.type = boolean then S1.type else type_error } S à S1 ; S2 { S.type := if S1.type = void and S2.type = void then void else type_error } Abb. 4.5 Übersetzungsschema für Anweisungen [ASU86] Ausdrücke wie while E do S werten in den meisten Programmiersprachen zu dem speziellen Datentyp void aus. 4.3 Äquivalenz von Typen In den nicht trivialen Fällen (zusammengesetzte Datentypen) des oben angeführten Schemas basiert die Typüberprüfung auf dem Vergleich von zwei Typen, bzw. der Überprüfung der Äquivalenz der beiden Typen Man unterscheidet generell zwischen zwei Varianten der Typäquivalenz: • Strukturelle Äquivalenz • Äquivalenz von Namen Äquivalenz wird im Folgenden durch T1 ≡ T2 dargestellt. T1 /≡ T2 bedeutet „nicht äquivalent“. 4.3.1 Strukturelle Äquivalenz Zwei Datentypen T1 und T2 sind strukturell äquivalent, falls sie aus denselben (primitiveren) Datentypen zusammengesetzt sind. Für nicht rekursive Datentypen lässt sich das einfach spezifizieren [Wa04]: 1. Sind T1 und T2 primitiv (boolean, integer, char, usw.), dann gilt T1 ≡ T2 genau dann, wenn T1 und T2 identisch sind. 2. Ist T1 = A1 × B1 und T2 = A2 × B2, dann gilt T1 ≡ T2 genau dann, wenn A1 ≡ A2 und B1 ≡ B2. 3. Ist T1 = A1 à B1 und T2 = A2 à B2, dann gilt T1 ≡ T2 genau dann, wenn A1 ≡ A2 und B1 ≡ B1. 4. Ist T1 = A1 + B1 und T2 = A2 + B2, dann gilt T1 ≡ T2 genau dann, wenn entweder A1 ≡ A2 und B1 ≡ B2 oder A1 ≡ B2 und B1 ≡ A2. 5. A /≡ B andernfalls. Für die oben eingeführte Beispielsprache lässt sich also relativ leicht eine rekursive Funktion schreiben, die (strukturelle) Äquivalenz von zwei Datentypen überprüft. In Pseudocode: bool StructEquiv(s, t) { // primitive types if (s is bool && t is bool) return true; if (s is integer && t is integer) return true; if (s is char && t is char) return true; // composite types if (s is array(s1, s2) && t is array(t1, t2) return structEquiv(s2, t2); if (s is pointer(s1) && t is pointer(t1) return structEquiv(s1, t1); if (s is s1 × s2 && t is t1 × t2) return structEquiv(s1, t1) && structEquiv(s1, t2); if (s is s1 -> s2 && t is t1 -> t2) return structEquiv(s1, t1) && structEquiv(s2, t2); } Abb. 4.6 Algorithmus für strukturelle Äquivalenz [ASU86] Dieser Algorithmus wird bei rekursiven Datentypen allerdings nicht anhalten. 4.3.2 Äquivalenz von Namen Wenn zwei Typen T1 und T2 aufgrund desselben Namens als äquivalent angesehen werden, spricht man von Äquivalenz von Namen. Das spielt vor allem dann eine Rolle, wenn die Programmiersprache das Umbenennen von Datentypen zulässt: type var link = ^cell; next : link; last : link; p : ^cell; q, r : ^cell; Unter Äquivalenz von Namen gilt next ≡ last und p ≡ q ≡ r, allerdings nicht next ≡ r, während unter strukturelle Äquivalenz next ≡ last ≡ p ≡ q ≡ r gelten würde. In Sprachen, die dem Programmierer keine Möglichkeiten geben neue Typen zu definieren (z.B. Java) oder eingebaute Typen umzubenennen, reicht die Überprüfung der Namen zur Bestimmung der Äquivalenz. In objektorientierten Sprachen könnte man die Definition einer Klasse als Deklaration eines neuen Typs verstehen, doch werden auch diese immer auf Äquivalenz der Namen geprüft. Indem ggf. der Ableitungsbaum durchlaufen wird, werden zudem zwei Typen auf Kompatibilität überprüft. 4.3.3 Baumrepräsentation von Typen Datentypen werden meistens durch einen Baum (nicht notwendig zyklenfrei) dargestellt. In den Knoten stehen Typkonstruktoren und in den Blättern entweder primitive oder Verweise auf zusammengesetzte Datentypen oder Variablenbezeichner. Das Problem von rekursiven Datentypen spiegelt sich bei einer solchen Baumdarstellung in Zyklen wieder, die per Definition in einem Baum eigentlich nicht vorkommen dürfen. Beispiel (Listen): // Pascal (a) type link = ^cell; cell = record info: integer; next: link; end; // C (b) struct cell { int info; struct cell *next; }; Abb. 4.7 Rekursiv definierter Datentyp "cell" [ASU86] In C wird das Problem der zyklischen Bäume (Abbildung 4.7 (b)) dadurch umgangen, dass Strukturen immer auf Äquivalenz der Namen überprüft werden (alle anderen Datentypen werden strukturell verglichen). 4.3.4 Typkonvertierung Ein Compiler nimmt unter Umständen automatisch eine Typkonvertierung vor, wenn klar ist, dass es dabei zu keinem Datenverlust kommt. Besteht die Gefahr des Datenverlustes, muss eine Typkonvertierung immer explizit vom Programmierer veranlasst werden. Beispiel (C#) public void Test() { int summand1 = 10; float summand2 = 10.0f; float testf = (summand1 + summand2); int testi = (summand1 + summand2); } Die Zuweisung testf = (summand1 + summand2) wird der Compiler noch anstandslos zulassen, während testi = (summand1 + summand2) einen Compilerfehler provoziert. Es müsste eine explizite Konvertierung (Cast) erfolgen. Man spricht also von zwei Arten der Konvertierung: • Coercion (implizite Konvertierung) • Cast (explizite Konvertierung). In den meisten Programmiersprachen sind Operatoren überladen (siehe Abschnitt 4.3.5). Angenommen, in der zu Anfang des Kapitels eingeführten Beispielsprache gäbe es den Operator +, der auf integer sowie auf einen weiteren (neuen) Datentyp float definiert sei. Ein Übersetzungsschema (mit Coercion) könnte dann wie folgt aussehen: E à E1 + E2 { E.type = if E1.type = integer and E2.type = integer then integer; else if E1.type = integer and E2.type = float then float; else if E1.type = float and E2.type = integer then float; else if E.type = float and E2.type = float then float; else type_error; } Abb. 4.8 Beispiel Übersetzungsschema für den überladenen Operatoren „+“[ASU86] 4.3.5 Überladen von Operatoren und Funktionen Operatoren sind in den meisten Programmiersprachen überladen und auch das Überladen von Funktionen ist ein probates Mittel (im Prinzip lässt sich jeder Operator auch als Funktion betrachten). Um Doppeldeutigkeiten zu vermeiden, muss ein Ausdruck immer genug Informationen liefern, um genau einen Typ für ihn zu bestimmen. Wie das folgende Beispiel zeigt, gilt das allerdings nicht für Teilausdrücke: Beispiel (Überladen von „*“ in Ada) function “*“ (i, j : integer) return integer; function “*“ (i, j : integer) return complex; function “*“ (i, j : complex) return complex; Damit existieren für die Funktion “*“ gleich mehrere mögliche Typen: integer integer complex × integer à integer × integer à complex × complex à complex Bisher wurde davon ausgegangen, dass ein Ausdruck genau zu einem Typen auswertet. Wie das obige Bespiel zeigt, ist das allerdings nicht immer richtig. Das Verfahren zur Typbestimmung kann folgendermaßen generalisiert werden: // bisher E à E1 (E2) { E.type := if E2.type = s and E1.type = s à t then t else type_error } // generalisiert E à E1 (E2) { E.types := { t | ∃ s ∈ E2.types such that s à t ∈ E1.types } Abb. 4.9 Generalisiertes Funktionsschema [ASU86] } Man betrachte den Ausdruck (27 * 37) * x angewandt auf dem überladenen AdaOperator * aus dem obigen Beispiel. Der Teilausdruck kann in Abhängigkeit von x (x wird als primitiv und unzerlegbar vorausgesetzt) jetzt entweder vom Typ complex oder integer sein. Wäre x seinerseits wiederum zerlegbar, also auch ein Ausdruck der Form (y * z), so könnte der Typ von (27 * 37) * x nicht mehr eindeutig bestimmt werden. Abb. 4.10 Ableitungsbaum für den Ausdruck (27*37) [ASU86] Zur Typbestimmung wird der Baum von unten nach oben („bottom up“) traversiert, und die Typen der Teilausdrücke nach oben propagiert. Resultiert dies nicht in einem eindeutigen Typ, so wird aufgrund der Doppeldeutigkeit ein Typfehler adressiert. Der bestimmte Typ wird explizit gespeichert (Attribute „unique“) und kann in einer weiteren Traversierung von oben nach unten („top down“) weitergeleitet werden. E’ à E { E’.types := E.types E.unique := if E’.types = {t} then t else type_error } E à E1 (E2) { E.types := { s’ | ∃ s ∈ E2.types such that s à s’ ∈ E1.types } t := E.unique S := {s | s ∈ E2.types and s à t ∈ E1.types } E2.unique := if S = {s} then s else type_error E1.unique := if S = {s} then s à t else type_error } Abb. 4.11 Übersetzungsschema für überladene Funktionen [ASU86] 5 Polymorphe Funktionen Polymorphe Funktionen wurden schon kurz in Kapitel 3 angesprochen und deren Vorzüge in einem Beispiel dargelegt. Typvariablen (σ,τ,υ,…) stehen für einen unbekannten Typ und sind damit vor allem in Programmiersprachen wichtig, die es nicht verlangen, dass Variablen deklariert werden, bevor sie benutzt werden können. 5.1 Type Inference Wenn eine Variable nicht deklariert werden muss, muss es trotzdem eine Möglichkeit bzw. ein Verfahren geben, um den Typ dieser Variablen, oder generell den Typ eines Ausdrucks, zu bestimmen. Dieses Verfahren nennt man „Type Inference“ (oder Typdeduktion) Beispiel (Haskell) even n = (n ’mod’ 2 = 0) In Haskell ist es nicht nötig, den Typ eines Funktionsparameters zu deklarieren. Viel mehr wird der Typ durch Deduktion ermittelt. Der (eingebaute) Operator mod hat den Typ Integer × Integer à Integer, der (eingebaute) Operator = den Typ τ × υ à Boolean. Also muss n vom Typ Integer sein und die Funktion even vom Typ Integer à Boolean. In diesem Fall ergibt die Deduktion (in beiden Fällen) einen Monotyp, also einen Typ, in dem keine weiteren Typvariablen vorkommen. Beispiel: (Haskell) f . g = \x -> f(g(x)) Dieses Codefragment definiert einen neuen Operator “.“ (Komposition von Funktionen). Der Haskell Compiler (oder Interpreter) wird zuerst feststellen, dass f und g Funktionen sein müssen, wobei der Definitionsbereich der einen Funktion dem Bild der anderen entsprechen muss, da f auf g angewandt wird. Sei demnach der Typ von f β à γ und der von g α à β. Also ist der Typ von f(g(x)) α à γ und somit der Typ von “.“ (β à γ) × (α à β) à (α à γ). Kürzer ausgedrückt: Für alle Typen α, β, γ ist der Typ von . (β à γ) × (α à β) à (α à γ). (Beispiel entnommen aus [Wa04]) Das Resultat der Inference ist demzufolge ein Polytyp (ein Typ mit mindestens einer Typvariablen). 5.2 Erweiterung des Übersetzungsschemas Um im Weiteren über polymorphe Funktionen argumentieren zu können, wird die Beispielsprache aus Kapitel 4 um einige Produktionsregeln erweitert: P D Q T E S O I V := := := := := := := := := … … | I : Q ∀ V . Q | T … | T × T | V | (T) … … … … α | β | γ | … Abb. 5.1 Erweiterte Grammatik [ASU86] Mit der Produktionsregel Q := ∀ V . Q | T werden polymorphe Funktionen spezifiziert. Die Typvariable V ist in Q | T also an den „für alle“-Quantor gebunden. Beispiel: deref : ∀ α . pointer(α)à α q : pointer(pointer(integer)); deref(deref(q)) Die Funktion deref bewirkt eine Dereferenzierung und erwartet demnach einen Zeiger auf einen undefinierten Typ α. Die Typüberprüfung von polymorphen und normalen Funktionen unterscheidet sich in drei Punkten: 1. Verschiedene Vorkommen von polymorphen Funktionen in ein und demselben Ausdruck müssen nicht Argumente vom selben Typ haben. Jedem Vorkommen wird eine neue ungebundene Typvariable zugewiesen. Im Laufe dieses Prozesses kann der ∀ Quantor entfernt werden. 2. Angenommen, eine Funktion vom Typ α à β wird auf einen Ausdruck vom Typ γ angewandt. Dann reicht es nicht mehr zu überprüfen, ob α und γ äquivalent sind (α und γ sind Typvariablen!), sondern die beiden Typvariablen müssen angeglichen (unifiziert) werden (siehe nächster Abschnitt). 3. Über Vereinigungen von Typen muss während des Prozesses der Typüberprüfung Buch geführt werden. 5.3 Unifizierbarkeit Um zwei (polymorphe) Ausdrücke T1 und T2 miteinander zu vergleichen, müssen sie unifiziert werden, d.h. es muss eine Belegung der Typvariablen gefunden werden, so dass T1 und T2 gleich sind. Eine solche Belegung wird Substitution genannt. Das Resultat einer Substitution wird im Folgenden mit S(T) bzw. S < T bezeichnet (gelesen: S ist Instanz von T). Beispiele: (1) (2) (3) (4) T T T T := := := := α α α α à β ∧ × α à à β ∧ à β ∧ S α S S := integer à float ⇒ S < T ∧ S := integer × integer à boolean ⇒ ¬(S < T) := boolean à boolean ⇒ S < T := boolean à β ⇒ ¬(S < T) Die Substitutionen α à integer und β à float in (1) sind zulässig und damit ist S eine Instanz von T. Bei (2) hingegen wurde einmal die Substitution α à integer und gleichzeitig α à float angewandt, was unzulässig ist, da eine Substitution einer Variablen auf jedes Vorkommen der gleichen Variablen angewendet werden muss. Wie (3) zeigt, ist es aber durchaus möglich, auf verschiedene Variablen die gleiche Substitution anzuwenden. Das vierte Beispiel macht deutlich, dass in einer Instanz jede Variable substituiert werden muss. Generell ist man daran interessiert, den allgemeingültigsten Unifikator zu finden. Sind T1 und T2 Ausdrücke mit Polytypen, dann ist der allgemeingültigste Unifikator S gegeben durch: • S(T1) = S(T2) • Falls ∃ S’ mit S’(T1) = S’(T2) ⇒ S’ < S 5.4 Algorithmus für Unifikation 5.4.1 Konstruktion des Typgraphen Das Problem der Unifizierbarkeit, bzw. des Findens des allgemeingültigsten Unifikators lässt sich zurückführen auf das Problem der Gruppierung von Graphknoten, die unter einer Substitution S äquivalent sein müssen. Zwei Ausdrücke sind äquivalent, wenn ihre Wurzelknoten äquivalent sind, d.h. sie repräsentieren denselben Operator (Konstruktor) und alle Kindknoten sind äquivalent. Zu Konstruktion werden mehrere Operationen benötigt: 1. fresh(t) ersetzt alle durch einen ∀-Quantor gebundenen Variablen durch neue. Dadurch wird der ∀-Quantor überflüssig. 2. mkleaf(typevar) erzeugt einen neuen Blattknoten für die übergebene Typvariable und liefert eine Referenz auf den Knoten zurück. 3. mknode(label, left, right) erzeugt einen neuen durch label markierten Knoten mit den beiden Kinderknoten left und right. 4. unify(node1, node2) bekommt zwei Knoten übergeben, die respektiv für einen Typausdruck stehen und versucht diese beiden Ausdrücke zu unifizieren. Bespiel zu 1.: ∀α . (∀ α . pointer(α)à α) à α Das innere α ist nicht dasselbe wie das äußere gebundene α. Einfaches Weglassen des ∀-Quantors ist also nicht möglich. Führt man für jedes ∀ neue, bisher ungebundene Variablen ein, kann der Quantor weggelassen werden. Damit lässt sich analog zu Kapitel 4 ein Übersetzungsschema für polymorphe Funktionen erstellen: E à E1 (E2) { p := mkleaf(newtypevar); unify(E1.type, mknode(’à’, E2.type, p)); E.type := p; } E à E1, E2 { E.type := mknode(’×’, E1.type, E2.type); } E -> id { E.type := fresh(id.type); } Abb. 5.2 Übersetzungsschema für polymorphe Funktionen [ASU86] E.type ist hierbei eine Referenz auf den entsprechenden Wurzelknoten des Typausdrucks. In E à E1 (E2) ist E1 eine Funktion vom Typ α = β à γ. Dabei ist β der Typ von E2 und γ unbekannt, weshalb für γ ein neuer Blattknoten erzeugt wird. Die Funktion muss jetzt mit den Funktionstypen unifiziert werden, der durch E2 entsteht. Das Ergebnis der Unifizierung ist der Rückgabetyp der Funktion und somit folglich der Typ von E. Beispiel: (ML) fun length(lptr) = if (null(ltpr) then 0 else length(lt(lptr)) + 1; Die Funktion length(α) definiert eine Funktion zur Bestimmung der Länge einer Liste. Zum einfacherem Verständnis wird im Folgenden die Spezialform „if“ als polymorphe Funktion angenommen (if : ∀ α . boolean × α × α à α). Dann kann der Rückgabetyp von length(α) folgendermaßen abgeleitet werden: (01) (02) (03) (04) (05) (06) (07) (08) (09) (10) (11) (12) (13) (14) (15) (16) lptr : γ length : β length(lptr) : δ null : list(α_n) à boolean null(lptr) : boolean 0 : integer lptr : list(α_n) tl : list(α_t) à list(α_t) tl(lptr) : list(α_n) length : list(α_n) à δ length(tl(lptr)) : δ 1 : integer + : integer × integer à integer length(tl(lptr))+1 : integer if : boolean α_i × α_i à α_i if (…) : integer Abb. 5.3 Ableitungsreihenfolge für "length(α)" Dabei wurden folgende Substitutionen vorgenommen: (03) β = γ à δ length(lptr) ist die Anwendung der Funktion β auf den Ausdruck γ. Damit ist der Typ von length(lptr) der Rückgabetyp der Funktion β. (05) γ = list(α_n) null(list(α_n)) erwartet eine Liste beliebigen Typs, also muss auch der Typ von lptr eine Liste beliebigen Typs sein. (09) α_t = α_n Auch tl(list(α_t)) erwartet eine Liste beliebigen Typs. Da tl(list(α_t)) auf lptr angewandt wird, kann α_t mit α_n substituiert werden. (14) δ = integer Der Operator + erwartet zwei und liefert einen integer Wert. Da der linke Operand eine 1 ist, kann integer für δ abgeleitet werden. Am Ende wurde die Typvariable α_n nicht substituiert, d.h. sie bleibt frei wählbar. Der Typ von length ist also ein Polytyp und seine Typvariable muss an den ∀-Quantor gebunden werden: ∀ α_n . list(α_n) à integer (Beispiel entnommen aus [ASU86]) 5.4.2 Der Algorithmus Eingabe: Der komplette Algorithmus arbeitet auf einem binären Graphen, d.h. jeder Knoten im Graphen hat höchstens zwei Kinder. Angewandt wird der Algorithmus auf ein Paar von Knoten des Graphen. Ausgabe: Sollten die beiden Eingabeknoten unifizierbar sein, so liefert der Algorithmus true, ansonsten false. Methode: Jeder Knoten wird repräsentiert durch ein Quadrupel (value, left, right, set). In value wird entweder der Typkonstruktor gespeichert (innerer Knoten), oder eine Typvariable bzw. ein primitiver Datentyp (Blattknoten). Die beiden Referenzen left und right zeigen auf die jeweiligen Kinder des Knotens (oder sind im Falle eines Blattknotens nil). Zudem wird jedem Knoten eine Äquivalenzklasse zugeordnet, repräsentiert durch set. Diese werden dann im Laufe der Unifizierung verschmolzen. Zu Beginn ist jeder Knoten für sich eine Äquivalenzklasse und set ist nil. Wenn zwei Knoten zusammengeführt werden und sich beide noch in keiner anderen Äquivalenzklasse befinden, so wird einer der beiden Knoten als Repräsentant für die Äquivalenzklasse ausgewählt (sein set Attribut bleibt auf nil) und das set Attribut des anderen Knoten zeigt auf den Repräsentanten. Ist einer der beiden Knoten bereits in einer Äquivalenzklasse, so zeigt das set Attribute des anderen ebenfalls auf diesen Knoten. Eine Äquivalenzklasse ist also eine linear verkettete Liste, deren Kopf den Repräsentanten der Klasse darstellt. Der Algorithmus basiert dabei auf den folgenden beiden Operationen: 1. find(n) sucht den Repräsentanten der zu Knoten n gehörenden Äquivalenzklasse (das bedeutet eine lineare Suche entlang des set-Pfads, bis derjenige Knoten gefunden ist, dessen set-Attribut nil ist). 2. union(m,n) vollzieht genau nach der oben beschriebenen Methodik das Zusammenfügen der beiden jeweils m und n enthaltenen Äquivalenzklassen. Dabei wird als Repräsentant immer derjenige Knoten gewählt, der keine Typvariable enthält (falls dies überhaupt auf einen Knoten zutrifft, ansonsten wird ein Knoten beliebig gewählt). Algorithmus: boolean unify(m, n : node) { // find class- representive node s, t; s = find(m); t = find(n); // s and t point to the same node if (s == t) return true; // s and t are nodes representing the same basic type if (s.value == t.value && isBasic(s.value) && isBasic(t.value)) { return true; } // both s and t represent a type constructor/ operator if (isOp(s.value) and isOp(t.value) { union(s, t); return unify(s.left, t.left) && unify(s.right, t.right) } // one of both represents a Typvariable if (isTypeVar(s.value) || isTypeVar(t.value)) { union(s, t); return true; } // unification failed return false } Abb. 5.4 Algorithmus zur Typbestimmung von polymorphen Funktionen [ASU86] Bespiel: (( α1 à α2 ) × list( α3 )) à list( α2 ) (( α3 à α4 ) × list( α3 )) à α5 Abb. 5.5 Initialisierungsgraph [ASU86] Der Algorithmus wird gestartet mit dem Aufruf unifiy(1, 9). Die Knoten, die die Äquivalenzklassen 1 und 9 repräsentieren, repräsentieren denselben Operator. Die Äquivalenzklassen können also zusammengeführt werden. Nun wird unify(2, 10) und unify(8, 14) ausgeführt. Wieder repräsentieren die Äquivalenzklassen 2 und 10 denselben Operator, genauso wie 3 und 11, als auch 6 und 13. Diese Klassen können also wieder zusammengeführt werden. Letztendlich ergeben sich also die Substitutionen: α1 à α3 , α4 à α2 , α5 à list( α2 ). Abb. 5.6 Graph nach Unifizierung [ASU86] 6 Zusammenfassung Wie in den vorangegangen Kapitel aufgezeigt, gibt es bei der Typüberprüfung zwei große Themengebiete: 1. Äquivalenz von Datentypen 2. Unifizierung von polymorphen Datentypen Es wurden die unterschiedlichen Formen der Datentypäquivalenz dargelegt. Für den Fall der strukturellen Äquivalenz sogar ein Algorithmus angegeben, der nicht rekursive Datentypen miteinander vergleicht und Ansätze zum Lösen des Problems der rekursiven Datentypenäquivalenz gegeben. An Hand kurzer aber prägnanter Beispiele wurden die Problematik und der Unterschied zwischen struktureller Äquivalenz und Äquivalenz von Namen gezeigt. Beide Verfahren spielen unmittelbar eine Rolle, wenn es darum geht, den Typ eines Ausdrucks festzulegen. Dazu wurden eine Reihe von Übersetzungsregeln angegeben, an Hand derer der Typ von Ausdrücken, Anweisung und Funktionen abgeleitet werden kann. Diese Ableitungen entscheiden über die syntaktische Korrektheit eines Quellcodes. Das andere große Thema, waren polymorphe Datentypen. Um diese Vergleichen zu können, müssen sie unifiziert werden. Der Abschluss dieser Arbeit wurde durch einen Algorithmus zur Unifizierung von Ausdrücken markiert. Literaturverzeichnis [ASU86] Compilers, Alfred V. Aho, Ravi Sethi, Jeffrey D. Ullman, Addison-Wesley, 1986 [Wa04] Programming Language Design Conceps, David A. Watt, John Wiley & Sons Ltd, 2004 [Wa93] Programming Language Processors, David A. Watt, Prentice Hall, 1993