Ausarbeitung - Institut für Wirtschaftsinformatik

Werbung
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
Herunterladen