222 101 14MB
German Pages 192 Year 1999
Grundlagen funktionaler Programmierung von Dr. Martin Erwig, FernUniversität Hagen
R. Oldenbourg Verlag München Wien 1999
Dr. rer. nat. Martin Erwig: Geboren 1963 in Marl. Studium der Informatik mit Nebenfach Betriebswirtschaftslehre an der Universität Dortmund (1984 - 1989). Wiss. Mitarbeiter an der FernUniversität Hagen (1989 - 1994). Promotion 1994 bei R. H. Güting. Seit 1994 Hochschulassistent an der FernUniversität Hagen.
Die Deutsche Bibliothek - CIP-Einheitsaufnahme Erwig, Martin: Grundlagen funktionaler Programmierung / von Erwig Martin. München ; Wien : Oldenbourg, 1999 ISBN 3-486-25100-7
© 1999 R. Oldenbourg Verlag Rosenheimer Straße 145, D-81671 München Telefon: (089) 45051-0, Internet: http://www.oldenbourg.de Das Werk einschließlich aller Abbildungen ist urheberrechtlich geschützt. Jede Verwertung außerhalb der Grenzen des Urheberrechtsgesetzes ist ohne Zustimmung des Verlages unzulässig und strafbar. Das gilt insbesondere für Vervielfältigungen, Übersetzungen, Mikroverfilmungen und die Einspeicherung und Bearbeitung in elektronischen Systemen. Lektorat: Margarete Metzger Herstellung: Rainer Hartl Umschlagkonzeption: Kraxenberger Kommunikationshaus, München Gedruckt auf säure- und chlorfreiem Papier Gesamtherstellung: Druckhaus „Thomas Müntzer" GmbH, Bad Langensalza
FiirAnja und Alexander
7
Vorwort Dieses Buch vermittelt •
grundlegende Konzepte,
•
fortgeschrittene Programmiertechniken,
•
formale Grundlagen sowie
•
Implementierungsaspekte
moderner funktionaler Programmiersprachen. Die Bezeichnung funktionale Programmierung weist auf die intensive Verwendung von Funktionen als Programmierkonstrukte hin. Warum überhaupt bedient man sich Funktionen beim Programmieren?
Funktionen als Grundlage der Programmierung Funktionen werden schon seit langem in der Mathematik sehr erfolgreich eingesetzt; sie sind relativ einfach zu begreifende Objekte und finden gleichzeitig in sehr vielen Bereichen Anwendung. Insbesondere bieten Funktionen ein anschauliches Konzept von Berechnungen und damit auch von Programmen. Die funktionale Programmierung bezieht einen Großteil ihrer Attraktivität aus der Tatsache, daß der zugrundeliegende Funktionenbegriff sehr eng an den aus der Mathematik angelehnt ist. Eine Funktion / : A —> B ist durch ihren Definitionsbereich A, ihren Wertebereich B sowie eine Abbildungsvorschrift gegeben. / ist das Funktionssymbol (oder der Name) der Funktion. Die Abbildungsvorschrift kann durch Aufzählung von Wertepaaren aus A x B oder aber durch eine Funktionsgleichung gegeben sein. Eine solche Funktionsgleichung führt auf der linken Seite eine oder mehrere Variablen ein, die auf der rechten Seite benutzt werden und für Werte aus dem Definitionsbereich stehen. Die rechte Seite der Funktionsgleichung ist durch einen Ausdruck gegeben, in dem Konstanten und Funktionen verwandt werden.
Funktionen Mathematik
in der
8
Vorwort
Als Beispiel betrachten wir die durch eine Gleichung definierte Funktion zur Berechnung des Kreisinhalts bei gegebenem Radius. K{r)=n*r2 Definitions- und Wertebereich von K sind die reellen Zahlen, das heißt K : 1R —>• IR. Der definierende Ausdruck der Funktionsgleichung enthält die Konstante n und verwendet die Multiplikations- und Quadratfunktion auf den reellen Zahlen. Die Funktion K läßt sich in einer funktionalen Sprache genauso definieren und benutzen, wie man es aus der Mathematik gewohnt ist. Im Unterschied zu LISP oder Scheme zeichnen sich neuere funktionale Sprachen insbesondere durch sogenannt starke, polymorphe Typsysteme sowie (benutzerdefinierbare) Datentypen aus. Ein Typsystem verlangt, vereinfacht gesagt, daß jede Funktion einen genau festgelegten Definitions- und Wertebereich besitzen muß. Ein ganz wichtiger Aspekt von Datentypen ist die Möglichkeit, komplexe Wertebereiche zu formulieren und dafür Funktionsdefinitionen über pattern matching vorzunehmen. Referentielle Transparenz
Wertzuweisung
Eine ganz fundamentale Eigenschaft jeder mathematischen Notation ist, daß ein und dieselbe Variable an verschiedenen Stellen innerhalb ihres Geltungsbereiches immer den gleichen Wert bezeichnet. Dieses Prinzip der referentiellen Transparenz gilt in imperativen Programmiersprachen nicht, da ihre Semantik von einem impliziten Zustand abhängt. So erlaubt die Wertzuweisungsoperationen beispielsweise die folgende Programmzeile: i = i + 1
Seiteneffekte
Mathematisch betrachtet stellt diese Gleichung eine nicht erfüllbare Aussage dar, das heißt, für die Gleichung gibt es keine Lösung. Imperative Sprachen halten dagegen sehr wohl eine Lösung bereit: Auf der linken Seite steht i z. B. für den Wert 5 und auf der rechten Seite für den Wert 4. Noch gravierendere Effekte ergeben sich aus der Verwendung von Seiteneffekten. Ist beispielsweise f eine Prozedur, die bei jedem Aufruf eine globale Variable um 1 erhöht und den Wert der Variable als Ergebnis liefert, so gilt f ^ f In funktionalen Sprachen bestehen Programme grundsätzlich aus einer Reihe von Werte- und Funktionsdefinitionen; Zuweisungen an Variablen gibt es im allgemeinen nicht. Dadurch wird ein hohes Maß an referentieller Transparenz erreicht, wodurch funktionale Programme mathematischen Methoden
Vorwort
9
zugänglich werden: Man kann beispielsweise eine Variable in einem Ausdruck durch ihre Definition ersetzen, ohne die Bedeutung des Ausdrucks zu verändern. Dieses Prinzip nennt man auch Substituierbarkeit. Dies kann einerseits dazu dienen, Eigenschaften bestimmter Funktionen nachzuweisen, oder andererseits auch dazu, aus sehr einfachen Spezifikationen durch Transformationen effiziente Implementierungen zu gewinnen.
Inhalt des Buches Im ersten Kapitel werden die Konzepte moderner funktionaler Sprachen (Funktionen, Typisierung, Polymorphismus, Datentypen und pattern matching, Funktionen höherer Ordnung) am Beispiel der Sprache ML vorgestellt. Eine Reihe von Autoren vertritt die Meinung, daß darüber hinaus auch verzögerte Auswertung ein grundlegendes Element funktionaler Programmierung ist und somit Bestandteil einer funktionalen Sprache sein sollte. Dementsprechend wird in einigen Sprachen (z.B. Miranda oder Haskeil) jeder (Teil-) Ausdruck grundsätzlich nur dann ausgewertet, wenn er zur Berechnung des Gesamtergebnisses auch wirklich benötigt wird. Es gibt in der Tat Situationen, in denen verzögerte Auswertung sehr hilfreich ist und Vorteile gegenüber der call-by-value Parameterübergabe bietet, wie man sie von C oder Pascal kennt. Wir diskutieren diesen Aspekt im zweiten Kapitel und stellen dort eine Technik vor, mit der man verzögerte Auswertung von Ausdrücken in call-by-value Sprachen wie ML realisieren kann. Das dritte Kapitel ist formalen Methoden gewidmet, mit denen man Eigenschaften von Programmen beweisen kann oder aber auch Programme transformieren kann (um z. B. deren Effizienz zu steigern). In Kapitel 4 betrachten wir den Lambda-Kalkül, der ein formales Funktionenmodell zur Beschreibung aller rekursiven Funktionen darstellt. Mit der Formalisierung als Kalkül wird die automatische Verarbeitung von Funktionen ermöglicht. Den Lambda-Kalkül kann man sicherlich als das formale Modell für alle funktionalen Programmiersprachen bezeichnen. Danach beschreiben wir im fünften Kapitel die Formalisierung von Typsystemen für funktionale Sprachen. Insbesondere geben wir einen Algorithmus an, der für ein Programm den jeweils allgemeinsten Typ automatisch zu ermitteln vermag. Das letzte Kapitel schließlich vertieft das Verständnis funktionaler Sprachen durch die Untersuchung verschiedener Implementierungsaspekte. Dies umfaßt die Beschreibung eines Interpreters, einer speziellen Maschinenarchitektur und eines Compilers. Für Interpreter, Compiler und Maschine beschreiben wir auch die Realisierung in ML selbst.
Substituierbarkeit
Vorwort
10
Adressaten und Lernziele Dieses Buch wendet sich primär an Informatik-Studenten und Programmierer, die bereits etwas Erfahrung im Programmieren haben, und die sich einen raschen, aber dennoch tiefergehenden Einblick in die funktionale Programmierung verschaffen wollen. Ein Lernziel ist der Erwerb spezieller Techniken der funktionalen Programmierung (z.B. pattern matching, polymorphe Funktionen, Funktionen höherer Ordnung, verzögerte Auswertung). Diese werden an kleineren (in den Kapiteln 1 und 2) und auch größeren Beispielen (in Kapitel 6) erprobt, so daß der Leser nach Durcharbeiten des Buches in der Lage sein sollte, selbst komplizierte Programmieraufgaben durch konsequente Definition geeigneter Datentypen und Funktionen zu bewältigen. Ein weiteres Lernziel bildet das Verständnis der formalen Grundlagen funktionaler Sprachen. Dies beinhaltet zum einen natürlich den Stoff der Kapitel 4 und 5, zum anderen aber vermittelt gerade die Implementierung einer Sprache Einsichten in innere Strukturen, die durch rein formale Definitionen zwar genau erfaßt werden können, oft aber nur schwer zu verstehen sind. Schließlich soll demonstriert werden, daß wegen ihrer starken formalen Basis funktionale Sprachen einen einfachen Zugang mathematischer Methoden erlauben (z.B. zum Zwecke des Korrektheitsbeweises oder der optimierenden Transformationen). Damit soll dokumentiert werden, daß es in funktionalen Sprachen sehr viel einfacher ist, korrekte Software zu realisieren, als in imperativen Sprachen, ohne daß dies zu Effizienzeinbußen führen muß.
Weitere Informationen Einige zusätzliche Informationen zu diesem Buch sind über das World-Wide Web zugänglich: http://www.fernuni-hagen.de/inf/pi4/erwig/buch/fp/ Dort sind insbesondere online-Versionen aller im Buch abgebildeten Programme verfügbar. Einige Links bezüglich der im Kurs verwandten Programmiersprache ML sind unter anderem unter http://www.des.ed.ac.uk/home/cdw/archive.html http://cm.bell-labs.com/cm/cs/what/smlnj/links.html zusammengetragen. Neben allgemeiner Information und Dokumentation sind dort auch Verweise auf verschiedene Implementierungen zu finden.
Vorwort
11
Alle abgebildeten Programme genügen dem aktuellen ML-Standard von 1997. In den Beispielen werden zuweilen die Antworten des ML-Interpreters auf die Eingaben gezeigt. Daran kann man insbesondere Details des Typsystems erklären und auch mögliche Programmierfehler besprechen. Wir verwenden hier das frei erhältliche System „Standard ML of New Jersey", in der Version 110. Dies ist unter anderem für Unix und Windows 95/Windows NT verfügbar: http://cm.bell-labs.com/cm/cs/what/smlnj/
Danksagung Grundlage für das vorliegende Buch sind Kursmaterialien zu einer Vorlesung, die wiederholt an der FernUniversität Hagen gehalten worden ist. Dabei haben eine Reihe von Studenten durch Hinweise auf Fehler zu Verbesserungen beigetragen. Außerordentliches Engagement haben dabei André Piotrowski und Thomas Salvador gezeigt, denen ich an dieser Stelle besonders dafür danken möchte. Ungeachtet der vielen Überarbeitungen sind gewiß noch einige Fehler in diesem Buch verblieben. Für Hinweise auf solche Fehler wäre ich sehr dankbar.
13
Inhaltsverzeichnis Vorwort
7
1 1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.8
Elemente funktionaler Programmierung Werte und Ausdrücke Tupel, Records und Listen Variablen und Definitionen Funktionen Typen und Polymorphismus Datentypen und Pattern Matching Funktionen höherer Ordnung Literaturhinweise
15 15 21 25 31 37 42 47 52
2 2.1 2.2 2.3
Verzögerte Auswertung Parameterübergabe und Auswertungsstrategien Unendliche Datenstrukturen Literaturhinweise
53 53 57 59
3 3.1 3.1.1 3.1.2 3.1.3 3.2 3.3 3.3.1 3.3.2 3.3.3 3.3.4 3.4
Verifikation und Programm-Transformation Induktion Korrektheit rekursiv definierter Funktionen Strukturelle Induktion auf Datentypen Wohlfundierte Relationen und wohlfundierte Induktion . . . . Optimierung mit dem Unfold/Fold-Verfahren Der Bird/Meertens-Formalismus Beispiel: Das Auswahlproblem Eigenschaften von map und f o l d l Berechnung von Listensegmenten mittels s c a n l Optimale Berechnung der maximalen Teilsumme Literaturhinweise
61 61 62 66 71 75 83 83 86 88 91 93
4 4.1 4.2 4.3
Der Lambda-Kalkül Die Syntax des Lambda-Kalküls Reduktion und Normalformen Repräsentation von Datentypen
95 95 99 107
14
INHALTSVERZEICHNIS
4.4 4.5 4.6
Rekursion Ausdrucksstärke des Lambda-Kalküls Literaturhinweise
109 112 115
5 5.1 5.2 5.3 5.4
Typisierung und Typinferenz Einführung in Typsysteme Ein polymorphes Typinferenzsystem Automatische Typinferenz Literaturhinweise
117 117 120 128 133
6 6.1 6.2 6.2.1 6.2.2 6.3 6.3.1 6.3.2 6.3.3 6.3.4
Implementierungstechniken Implementierung von Programmiersprachen Ein Interpreter für Mini-ML Interne Repräsentation von Programmen Die Interpreterfunktion eval Ein Compiler füir Mini-ML Die SECD-Maschine Übersetzung von Mini-ML in SECD-Code Behandlung von Rekursion Implementierung der SECD-Maschine in ML
135 135 137 138 140 147 148 153 155 159
6.4
Literaturhinweise
163
Lösungen zu den Aufgaben
165
Literatur
179
Index
185
15
1 Elemente funktionaler Programmierung In diesem Kapitel beschreiben wir einige wesentliche Bestandteile moderner funktionaler Programmiersprachen am Beispiel der Sprache ML. Alle angegebenen Beispiele genügen dem ML-Standard von 1997 und sollten somit in jedem System, das diesen implementiert, nachvollziehbar sein. Ein funktionales Programm besteht im wesentlichen aus einer Menge von Definitionen D, die dann in einem (u. U. sehr komplexen) Ausdruck exp verwendet werden. Der Ausdruck exp entspricht in etwa dem Hauptprogramm in einer imperativen Sprache, und die Definitionen D entsprechen einzelnen Prozedur- und Variablen-Deklarationen. Der „Abarbeitung" eines Programms oder einer Prozedur entspricht in der funktionalen Welt das Auswerten eines Ausdrucks zu einem Wert. Wir werden zunächst in Abschnitt 1.1 verschiedene Arten von einfachen Werten vorstellen. Wir zeigen, wie man Ausdrücke bildet und wie diese dann ausgewertet werden. Komplexe Werte kann man auf einfache Weise mittels Tupel- oder Listen-Konstruktoren erzeugen, die wir in Abschnitt 1.2 ansprechen. In Abschnitt 1.3 zeigen wir, wie man Definitionen vornimmt. Dabei gehen wir auch kurz auf den Begriff des statischen Bindens ein. Funktionen besprechen wir dann in Abschnitt 1.4, wobei einige Aspekte erst in den folgenden Abschnitten behandelt werden können. Das Typsystem von ML behandeln wir in Abschnitt 1.5; insbesondere werden wir dort die Idee des Typ-Polymorphismus erläutern. Datentypen und pattern matching folgen dann in Abschnitt 1.6, bevor wir schließlich in Abschnitt 1.7 Funktionen höherer Ordnung untersuchen.
1.1 Werte und Ausdrücke Werte sind Ausdrücke, die nicht weiter ausgewertet werden können. Es gibt einfache Werte wie Zahlen oder Zeichenketten und konstruierte Werte wie Records oder Listen. Darüber hinaus stellen Funktionen ebenfalls Werte dar, allerdings mit der Besonderheit, daß sie auf andere Werte angewandt
Korrespondenz imperatives funktionales Programm
1 Elemente funktionaler
16
Programmierung
(appliziert) werden können. Alle Werte in ML, also auch Funktionen, haben einen eindeutig bestimmten Typ. Werte, die keine Funktionen sind oder enthalten, nennt man auch Konstanten. Aus Werten und Funktionen können Ausdrücke geformt werden, die vom ML-Interpreter auf Werte reduziert werden. Diesen Vorgang nennt man das Auswerten von Ausdrücken.
Notation ganzer Zahlen
Ganze Zahlen. Ganze Zahlen (integers) werden durch Folgen von Ziffern dargestellt, wobei negativen Zahlen eine Tilde " (und nicht das Minuszeichen!) vorangestellt wird. Zahlenkonstanten werden wie alle übrigen Werte vom ML-Interpreter zu sich selbst ausgewertet, das heißt, sie werden unverändert als Ergebnis zurückgeliefert, z. B.: 5; > val i t = 5 : int Die Antwort des ML-Systems enthält jeweils folgende Informationen: (1) Art des Ergebnisses (hier: v a l , das heißt ein Wert) (2) Variable, an die das Ergebnis gebunden ist. Dies ist beim Auswerten von Ausdrücken immer die Variable i t . (3) Gleichheitszeichen gefolgt von dem Ergebnis selbst (hier: die Zahl 5) (4) Falls das Ergebnis ein Wert ist, ein Doppelpunkt gefolgt vom Typ des Wertes (hier: i n t )
Ausdrücke
Ein Ausdruck wird durch Applikation einer Funktion auf Werte oder Ausdrücke gebildet. Als Funktionen auf ganzen Zahlen stehen zunächst die binären Operationen +, - , *, d i v und mod zur Verfügung, wobei die ersten beiden eine niedrigere Präzedenz als die übrigen haben. Durch Verwendung von Klammern kann man die Auswertungsreihenfolge allerdings verändern. Alle Operationen sind vom Typ i n t * i n t -> i n t
Funktionstypen
Das heißt, sie nehmen integer-Paare als Argumente und liefern integer-Werte als Ergebnis. Dabei bezeichnet der Pfeil (->) den Typ von Funktionen: Der Typ links vom Pfeil gibt den Typ der Argumente und der Typ rechts vom Pfeil gibt den der Ergebnisse an. -> ist ein Beispiel für einen Typkonstruktor, das heißt, er kann eine ganze Reihe verschiedener Typen bezeichnen. Durch Anwendung auf zwei Typen (hier ein Argumenttyp i n t * i n t sowie ein Ergebnistyp i n t ) wird dann ein konkreter Typ konstruiert (hier der Typ der Funktionen, die integer-Paare auf einzelne integer-Werte abbilden). In
1.1 Werte und Ausdrücke
17
diesem Beispiel ist nun der Argumenttyp wieder ein konstruierter Typ, nämlich der Typ aller integer-Paare. Der entsprechende Typkonstruktor * erinnert an die Bildung des kartesischen Produkts. Der Grund dafür, daß der obige Typausdruck nicht etwa als Typ von Paaren jeweils bestehend aus einem integer-Wert und einer unären integer-Funktion interpretiert wird, liegt wiederum an der höheren Präzedenz des *-Konstruktors gegenüber des ->-Konstruktors. Da die verwandten Funktionen alle in Inßx-Notation verwandt werden, brauchen wir an dieser Stelle noch nicht auf die Frage einzugehen, wie man Paare von Werten erzeugt. Dies geschieht im Abschnitt 1.2.
Infix-Notation
Unäre Operationen auf ganzen Zahlen sind die Negationsfunktion die das Vorzeichen einer Zahl umdreht, sowie die Funktion a b s , die den Absolutbetrag einer ganzen Zahl liefert. Beide Funktionen sind vom Typ i n t - > i n t . Im Gegensatz zu den zuvor erwähnten binären Infix-Operationen werden ~ und a b s in Präfix-Notation verwandt. ~3
+
abs
Präfix-Notation
(4-5)
Dabei steht die anzuwendende Funktion direkt vor ihrem Argument, möglicherweise durch Leerzeichen getrennt (das heißt, man könnte auch ~ u 3 + l oder aber auch a b s ( 4 - 5 ) schreiben). Die Funktionsapplikation hat die höchste syntaktische Präzedenz. Dies bedeutet, daß im ersten Beispiel die Negation vor der Addition ausgewertet wird und daß beispielsweise der Ausdruck a b s 4 - 5 den Wert " 1 ergibt. Diese Tatsache führt oft zu Fehlern. Ebenso wichtig ist die Tatsache, daß die Funktionsapplikation eine linksassoziative Operation ist, das heißt, ein Ausdruck der Form
fgx wird implizit als
(fg)* geklammert. Auch dies führt sehr oft zu Verwirrungen. Man darf nämlich nicht schreiben ~ abs (4-5); > std_in:l.l Error: overloaded variable not defined at type > symbol: > type: int - > int
Präzedenz der Applikation Assoziativst der Applikation
18
1 Elemente funktionaler
Programmierung
denn ML interpretiert dies als Applikation der Negationsfunktion auf die Funktion abs, was zu einem Typfehler führt. Die Fehlernachricht ist in diesem Fall wie folgt zu interpretieren: Im allgemeinen kann ein Funktionssymbol wie ~ (hier „variable" genannt) durchaus verschiedene Funktionen bezeichnen (hier: die Negation für ganze Zahlen und für reelle Zahlen). Dann sagt man, das Symbol ist überladen. In dem obigen Ausdruck jedoch wird versucht, ~ auf abs zu applizieren, eine Funktion vom Typ und i n t -> i n t , und die Fehlermeldung besagt nun, daß ~ nicht für diesen Typ überladen ist. Man sollte sich also der Assoziativität von Funktionsapplikationen und der beabsichtigten Klammerung von Argumenten stets bewußt sein.
Notation
reeller Zahlen
2 0 . 0 ^ 20
Reelle Zahlen. Reelle Zahlen (reais) werden ebenso wie ganze Zahlen durch eine Folge von Ziffern mit optional vorangestellter Tilde dargestellt, wobei sie zur Unterscheidung immer entweder (i) einen Dezimalpunkt und eine oder mehrere Nachkommaziffern oder (ii) ein E gefolgt von einer ganzen Zahl, oder beides enthalten. Dabei wird durch die Exponentenschreibweise nEm die reelle Zahl n x 10m bezeichnet. Der Typ der reellen Zahlen heißt wenig überraschend r e a l . Beispiele für reelle Zahlen sind: 2 . 3 , 1. 5E"2 oder 2E1. Man beachte, daß der letzte Wert nicht identisch ist mit der entsprechenden ganzen Zahl 20. Dies liegt ganz einfach daran, daß sie unterschiedliche Typen besitzen. Werte sind jedoch nur dann vergleichbar, wenn sie den gleichen Typ besitzen. Die integer-Operationen +, *, ~ und abs sind auch für reelle Zahlen definiert; die Division auf reellen Zahlen wird mit / bezeichnet. Die unären Funktionen haben erwartungsgemäß den Typ r e a l -> r e a l , und die binären Operationen sind analog zu denen für integer-Werte vom Typ r e a l * r e a l -> r e a l .
Überladen
von
Symbolen
Hierbei fällt nun auf, daß Operationen wie + oder * zwei verschiedene Typen haben. Ist dies nicht ein Widerspruch zu der eingangs erwähnten Eigenschaft, daß jeder Wert einen eindeutig bestimmten Typ hat? Des Rätsels Lösung liegt in der Unterscheidung von Werten und deren Notation. So sind beispielsweise die Addition auf reellen und ganzen Zahlen in der Tat verschiedene Funktionswerte, die aber beide durch das Symbol + bezeichnet werden. Man sagt auch, das Symbol + ist überladen (siehe auch Abschnitt 1.4). ML versucht nun, bei der Interpretation von Ausdrücken aus dem Kontext heraus den richtigen Wert zu einem überladenen Symbol zu ermitteln. In den bisherigen Beispielen war dies stets möglich, da die Argumente der Funktionen jeweils immer vom Typ i n t waren. Zur Konvertierung zwischen reellen und ganzen Zahlen gibt es die beiden Funktionen r e a l : i n t -> r e a l und f l o o r : r e a l - > i n t . Während r e a l den eigentlichen Wert des Argumentes unverändert läßt und lediglich
1.1 Werte und Ausdrücke
19
eine Typkonversion vornimmt, rundet f l o o r auf eine ganze Zahl, und zwar auf die größte Zahl, die kleiner oder gleich dem Argument ist. Zeichen und Zeichenketten. Der Typ s t r i n g umfaßt Zeichenketten (strings). Diese werden als Folgen von (ASCII-) Zeichen dargestellt, die durch Hochkommata eingeklammert sind. Der Typ char enthält dagegen einzelne Zeichen. Die Notation unterscheidet sich durch vorangestelltes #. Den Code eines einzelnen Zeichens liefert die Funktion o r d : char -> i n t in Form eines integer-Wertes, und invers dazu bildet die Funktion e h r : i n t -> char ganze Zahlen im Bereich von 0 bis 255 auf die durch die Zahl kodierten Zeichen ab. ord # " a " ; > v a l i t = 97 : i n t Einzelne Zeichen können mit der Funktion s t r : char -> s t r i n g in einelementige Zeichenketten umgewandelt werden. Im folgenden betrachten wir nur noch Zeichenketten und behandeln einzelne Zeichen stets als einelementige Zeichenketten. Als Operationen auf Zeichenketten steht zunächst die Konkatenation ~ : s t r i n g * s t r i n g -> s t r i n g zur Verfügung: "A" ~ "BC" " s t r #"D"; > v a l i t = "ABCD" : s t r i n g Die Länge einer Zeichenkette kann man mit dem Befehl s i z e : s t r i n g -> i n t ermitteln. Als Operation zur Bearbeitung von Teilstrings gibt es die Funktion s u b s t r i n g : s t r i n g * i n t * i n t -> s t r i n g , die einen Teil einer Zeichenkette liefert. Die Positionen im string-Wert werden dazu bei 0 beginnend durchnumeriert. Beispiel: substring ("01234",1,3); > v a l i t = "123" : s t r i n g Wahrheitswerte. Die beiden Konstanten t r u e und f a l s e bilden den Typ bool. Rein logische Operationen sind zunächst einmal die Negation (not), die Konjunktion (andalso) und die Disjunktion (orelse). Die Namensgebung der beiden letzteren Funktionen deutet auf eine Besonderheit bei deren Ausweitung hin: Das zweite Argument von a n d a l s o (bzw. o r e l s e ) wird nur dann ausgewertet, wenn das erste Argument t r u e (bzw. f a l s e ) ist, da ansonsten der Wert des Ausdrucks nämlich schon allein durch das erste Argument bestimmt ist. Beispiele folgen gleich.
Operationen Substrings
auf
20
1 Elemente funktionaler
Programmierung
Richtig nützlich werden logische Operationen und Wahrheitswerte erst, wenn man Vergleichsoperationen auf Werten zur Verfügung hat. Alle bisher eingeführten Werte können auf Gleichheit (mit =) bzw. Ungleichheit (mit ) getestet werden. Ganze und reelle Zahlen können darüber hinaus auch noch auf größer/kleiner (oder gleich) verglichen werden, und zwar mit den binären Operationen >, >=, 1 o r e l s e (1 d i v 0) > 0; > val i t = t r u e : bool
Striktheit
von
Funktionen
Fallunterscheidung
Eine Funktion, die immer dann Undefiniert ist, wenn irgendeines ihrer Argumente Undefiniert ist, nennt man auch strikt. In diesem Sinne ist die Funktion o r e l s e , genauso wie a n d a l s o , in ihrem zweiten Argument nicht strikt (siehe auch Abschnitt 2.1). Ebenfalls nicht strikt (im zweiten und dritten Argument) ist die Fallunterscheidung. Sie hat die Form i f cond t h e n exp\ e i s e exp2 Die Bedingung cond muß ein Ausdruck vom Typ b o o l sein, und die beiden Alternativen exp{ und exp2 müssen den gleichen Typ haben. Bei der
1.2 Tupel, Records und Listen
21
Auswertung wird zunächst die Bedingung cond berechnet, und falls diese true ergibt, wird exp{ berechnet und als Ergebnis geliefert; exp2 wird dann nicht ausgewertet. Falls dagegen cond den Wert f a l s e liefert, berechnet ML lediglich exp2 und liefert den resultierenden Wert als Ergebnis. i f 4>3 then 0 e l s e 1 div 0; > val i t = 0 : int Da die Fallunterscheidung nicht strikt ist, wird lediglich der then-Zweig ausgewertet, was wiederum die erfolgreiche Auswertung des Gesamtausdrucks überhaupt erst ermöglicht. Aufgabe 1.1 Welche der folgenden Ausdrücke sind nicht korrekt? Wie können sie korrigiert werden? a) b) c) d) e) f) g) h) i) j)
3.5 * 2 s i z e "abc"~"def" 7.0 / 0.0 not 34 o r e l s e 3=4 i f 02); > val i t = ( 0 , t r u e , 0 . 0 , f a l s e )
: i n t * bool * r e a l * bool
22
1 Elemente funktionaler
Die einzelnen Teile eines Tupels werden auch Komponenten nach besteht das obige Tupel aus vier Komponenten.
*-Typkonstruktor
Nulltupel
Programmierung genannt. Dem-
Wenn man einen Typ als die Mengen seiner Werte auffaßt, kann man sich den Typ eines Tupels als kartesisches Produkt der jeweiligen Typen seiner Komponenten vorstellen. Dies wird durch die Notation mit dem Typkonstruktor * nahegelegt. Ein Typkonstruktor ist ein Operator auf Typen, das heißt, er nimmt Typen als Argumente und konstruiert einen Typ als Ergebnis. Man beachte, daß an dieser Stelle das *-Symbol nichts mit der Multiplikation auf ganzen oder reellen Zahlen zu tun hat. Einstellige Tupel gibt es in ML als eigene Typen nicht. In Ausdrücken wie z.B. ( 2 . 0 ) werden die Klammern lediglich als syntaktische Gruppierungssymbole aufgefaßt und nicht als Tupelkonstruktor. Einstellige Tupel werden auch eigentlich nicht benötigt, da die entsprechenden Typen identisch mit den Argumenttypen sind. Dagegen existiert ein spezieller Wert ( ) , den man als Nulltupel auffassen kann:
();
> v a l i t = ()
: unit
Der zugehörige Typ u n i t ist in Anlehnung an die Vorstellung des leeren kartesischen Produkts ein Typ mit lediglich dem einen Wert ( ) . Selektion
von
Komponenten
Die einzelnen Komponenten können anhand ihrer Position aus einem Tupel selektiert werden. Die Positionen werden bei 1 beginnend fortlaufend numeriert. #1 ( 1 - 1 , t r u e , 0 . 0 , 1 > 2 ) ; > val i t = 0 : int #1 (#2 ( 1 . 1 , ( 2 , 3 . 3 ) ) ) ; > val i t = 2 : int Ansonsten kann man zwei Tupel (mit gleicher Anzahl von Komponenten) auf Gleichheit oder Ungleichheit testen. Dies erfolgt komponentenweise; nur wenn alle Komponenten an der jeweils entsprechenden Stelle gleich sind, gelten die beiden Tupel als gleich. ((1,false)=(2-l,false),("A",0.1)("A",0.1)); > val i t = ( t r u e . f a l s e ) : bool * bool
Records
In der Tat ist ein Tupel eigentlich nichts anderes als ein spezieller Record. Nur sind die Komponenten von Records nicht geordnet, sondern werden
1.2 Tupel, Records und Listen
23
anstelle von Positionen über Namen identifiziert. Ein Beispiel für einen Personen-Record ist: {Name="Joe",Age=35}; > v a l i t = {Age=35,Name="Joe"} : { A g e : i n t ,
Name:string}
Man sieht schon an der Antwort, daß die Reihenfolge der Komponenten keine Bedeutung hat. Einen weitereren Unterschied zu Tupeln kann man erahnen, wenn man den angegebenen Typ des Records betrachtet: Zwei Records haben nur dann den gleichen Typ - und sind somit auch nur dann vergleichbar wenn sie in den Komponentennamen und den entsprechenden Typen übereinstimmen. Die Komponenten eines Records werden über ihre Namen selektiert: #Pos { O r t = " H a g e n " , P o s = ( 1 2 , 4 ) > ; > val i t = (12,4) : int * i n t Ein ¿-Tupel ist nun einfach ein Record, in dem die Namen der Komponenten die Zahlen 1,..., k sind. Damit können wir noch kurz demonstrieren, daß Tupel wirklich nur eine abkürzende Schreibweise für Records bieten: {1=9,3=3.0,4=0,2=true}=(9,true,3.0,{>); > v a l i t = t r u e : bool Listen nehmen in der funktionalen Programmierung eine herausragende Stellung ein. Sie sind als elementare Datenstruktur wohl deshalb so wichtig, da sie auf sehr einfache Weise das Umgehen mit Kollektionen von Werten erlauben; Mengen bilden die vergleichbare grundlegende Struktur in der Mathematik. Vom Standpunkt des Programmierens aus betrachtet bieten Listen gegenüber Mengen gewisse Vorteile, so kann man z. B. direkt auf die Reihenfolge der Elemente Einfluß nehmen und auch Duplikate verwalten. Listen sind homogen, das heißt, alle Elemente einer Liste haben denselben Typ. Die Liste der drei Zahlen 1, 2 und 3 notiert man in ML als [1,2,3] ; > val i t = [1,2,3]
: int
list
Die Typangabe vom ML-Interpreter besagt, daß die Liste integer-Werte enthält. Ebenso können Listen von strings, Listen von integer-Listen oder aber auch Listen von Funktionen gebildet werden. Die leere Liste wird konsequenterweise mit [] bezeichnet. Alternativ kann man aber auch n i l
Listen
24
1 Elemente funktionaler Programmierung
verwenden. Für jede nicht-leere Liste berechnen die Funktionen hd und t l das erste Element sowie die Restliste: hd [ 1 , 2 , 3 ] ; > val i t = 1 : i n t t l [[1,2] ,[3] , [ ] ] ; > val i t = [ [ 3 ] , [ ] ] : i n t l i s t
list
Man beachte, daß die Anwendung der Funktion t l stets eine Liste liefert. Wenn man beispielsweise die Länge des zweiten Elements der Liste [ " e i n s " , "zwei"] ermitteln will, so ergibt der Ausdruck s i z e ( t l [ " e i n s " , "zwei"]) einen Typfehler, da t l lediglich eine einelementige Liste und keinen string-Wert liefert. Um also die Zeichenkette "zwei" zu extrahieren, muß man auf die einelementige Liste noch die hdFunktion anwenden: s i z e (hd ( t l [ " e i n s " , " z w e i " ] ) ) ; > val i t = 4 : i n t Während hd und t l Listen zerlegen, erlauben die Funktionen : : (cons) und 0 (append) das Anfügen eines Elementes am Anfang einer Liste bzw. das Konkatenieren zweier Listen. Dabei ist zu beachten, daß der : : -Konstruktor in Infix-Notation verwandt wird: "L"::["i","s"]; > val i t = [ " L " , " i " , " s " ]
: string l i s t
[ " L " , " i " , " s " ] val i t = [ " L " , " i " , " s " , " t " , " e " , " n " ] : s t r i n g l i s t Die Funktion rev kehrt die Reihenfolge der Elemente einer Liste um: rev [ [ 3 , 1 ] , [ 4 ] , n i l , [ 2 , 0 ] ] ; > val i t = [ [ 2 , 0 ] , [ ] , [ 4 ] , [ 3 , 1 ] ] : i n t l i s t
list
Man kann also das letzte Element einer Liste 1 einfach mit hd (rev 1) ermitteln. Schließlich gibt es noch die Funktion explode vom Typ s t r i n g -> char l i s t , die eine Zeichenkette in eine Liste einzelner Zeichen zerlegt, und analog dazu die Funktion implode vom Typ char l i s t -> s t r i n g , die eine Liste von Zeichenketten zu einer einzigen Zeichenkette zusammenfügt:
1.3 Variablen und Definitionen
25
explode "Bombe"; > val i t = [#"B",#"o",#"m",#"b",#"e"]
: char l i s t
Listen sind eine äußerst mächtige Datenstruktur, deren volle Bedeutung wohl erst in späteren Abschnitten deutlich wird, denn zum einen sind Listen polymorphe Typen (siehe Abschnitt 1.5), man kann daher sehr allgemeine Funktionen auf Listen definieren. Zum anderen sind Listen Datentypen (siehe Abschnitt 1.6), das heißt, in Funktionsdefinitionen kann man von pattem matching Gebrauch machen, was in vielen Fällen zu sehr klaren und leicht verständlichen Definitionen führt. Schließlich sind die wichtigsten Listenfunktionen von höherer Ordnung (siehe Abschnitt 1.7), das heißt, sie haben Funktionsparameter oder auch Funktionen als Ergebnisse. Aufgabe 1.2 Welche der folgenden Ausdrücke sind korrekt? Ermitteln Sie deren Typen, und werten Sie die Ausdrücke aus. a) b) c) d) e) f) g) h) i) j) k) 1) m) n)
{l="a",3=false} {"a"=l> {false=true> ({» {()> #2 ( ( ( 1 , 2 ) , 3 ) , 4 ) 2:: ["3","4"] [3,4]::nil nil®[3,4] [hd [4]] val Name = "Goethe" : string Wir beobachten, daß ML hier zusätzlich zu Wert und Typ des ausgewerteten Ausdrucks auch den Variablennamen der neuen Bindung angibt.
pattem matching
Mit einer einzigen Definition kann man auch mehrere Variablen auf einmal binden. Dazu kann man die Möglichkeiten des pattern matching ausnutzen. Im allgemeinen nämlich können linke und rechte Seite einer Deklaration „komplexe" Werte sein, die die gleiche Struktur haben. Die linke Seite darf nur Variablen und Konstruktoren (siehe Abschnitt 1.6) enthalten. Dann werden Variablen der linken Seite an die Werte der jeweils entsprechenden Position der rechten Seite gebunden. Als komplexe Werte haben wir bisher Tupel, Records und Listen kennengelernt. Einige Beispiele sind: val (Vorname,Spitzname) = ("Heinrich","Heini"); > val Vorname = "Heinrich" : string > val Spitzname = "Heini" : string val first::second::rest = [1,2,3,4,5]; > std_in:9.1-9.37 Warning: binding not exhaustive > first :: second :: rest = ... > val first = 1 : int > val second = 2 : int > val rest = [3,4,5] : int list Man erhält somit zwei bzw. drei Bindungen durch jeweils eine Definition. Die Warnung im zweiten Beispiel ignorieren wir an dieser Stelle, wir kommen darauf in Abschnitt 1.6 zurück, wenn wir pattern matching genauer betrachten. Nach einer Deklaration sind über die Variablenbezeichner die entsprechenden Werte in Ausdrücken verfügbar.
1.3 Variablen und Definitionen
27
Vorname ~ " " " Name; > v a l i t = " H e i n r i c h Goethe" : s t r i n g Da wir hier einen Ausdruck eingegeben haben, beginnt die Antwort von ML wieder mit dem altbekannten „val i t = ...". Wir hatten bereits erwähnt, daß ML das Ergebnis der jeweils letzten Auswertung (eines Ausdrucks) an die Variable i t bindet, und in der Tat ist i t eine ganz normale Variable. Die einzige Besonderheit liegt darin, daß ML immer dann, wenn ein Ausdruck exp eingegeben wird, diesen automatisch zu einer entsprechenden Definition v a l i t = exp erweitert. Man kann also i t in Ausdrücken verwenden oder aber auch selbst neue Bindungen für i t erzeugen: 3; > val i t = 3 : int i t >v9a l; i t = f a l s e : bool > v a l i t = 10 + ( i f i t t h e n 0 e i s e 2 ) ; > v a l i t = 12 : i n t Man sieht an diesem Beispiel, daß es für eine Variable möglicherweise mehrere Bindungen geben kann, von denen aber immer nur die letzte aktuell (oder sichtbar) ist. Außerdem zeigt das Beispiel, daß man an eine Variable einen beliebigen neuen Wert binden kann, das heißt unabhängig vom Typ des aktuellen Wertes. Eine Umgebung kann man sich als Liste von Bindungen vorstellen. In der Umgebung U = ((x 0),(y t r u e ) ) beispielsweise ist der Wert 0 an die Variable x gebunden. Nun werden Ausdrücke, Definitionen usw. stets in einer Umgebung verarbeitet. Dies bedeutet einerseits, daß immer dann, wenn ein Bezeichner ausgewertet werden muß, in der entsprechenden Umgebung der Wert für diesen Bezeichner „nachgesehen" wird; und zwar erfolgt die Suche von vorne nach hinten, wenn man das Listenmodell einer Umgebung zugrunde legt. Wenn also der Bezeichner x auszuwerten ist, dann wird dies in der Umgebung U zu dem Wert 0 führen. Anders als Ausdrücke, die Umgebungen nur lesend benutzen, bewirken Definitionen Änderungen an der aktuellen Umgebung. Wird z. B. die Definition v a l y = "abc"
Umgebung
1 Elemente funktionaler
28
Programmierung
in der Umgebung U ausgeführt, so wird eine neue Umgebung £/' = { ( y H "abc"), (x t-> 0), (y 1-4 t r u e ) ) generiert, in der y an zwei verschiedene Werte gebunden ist; U selbst bleibt unverändert. Indem man festlegt, daß sowohl die Suche nach Bezeichnem als auch das Einbringen neuer Bindungen stets am Anfang der Umgebungsliste erfolgt, erreicht man, daß bei mehreren Definitionen für ein und denselben Bezeichner stets die letzte Definition Gültigkeit hat. freie
Variablen in
Funktionsdefinitionen
Ein ganz wichtiger Aspekt im Zusammenhang mit Umgebungen ist die Tatsache, daß freie Variablen in Funktionen sich stets auf den Wert beziehen, der zum Zeitpunkt der Definition der Funktion gültig ist (und nicht zum Zeitpunkt der Funktionsanwendung). Dazu betrachten wir noch einmal die Umgebung U = ((x H 0 ) , ( y H t r u e ) ) und definieren eine Funktion f (siehe Abschnitt 1.4). f u n f x = i f y t h e n x e i s e x + 1; > v a l f = f n : i n t -> i n t Dabei bezieht sich die Funktion f nun auf den aktuellen Wert von y und liefert gemäß ihrer Definition nun stets das Argument unverändert als Ergebnis zurück, und zwar auch dann, wenn y umdefiniert wird. Wenn wir also beispielsweise eingeben: val y = false; > val y = f a l s e : bool f 4; > val i t = 4 : int
statisches Binden
lokale Definitionen
let-Ausdruck
So sehen wir, daß die neu eingefügte Bindung auf die Definition von f keinen Einfluß hat. Wollten wir nun aber eine neue Funktion g mit der gleichen Definition wie f deklarieren, so würde diese sich auf die letzte Definition von y beziehen und stets den Nachfolger ihres Arguments als Ergebnis liefern. Die Eigenschaft, daß sich Definitionen stets auf die zum Zeitpunkt der Definition gültige Umgebung beziehen, nennt man statisches Binden (static binding). Statisches Binden findet in fast allen funktionalen Sprachen (bis auf einige LISP-Dialekte) Anwendung. Manchmal möchte man eine Definition nur lokal vornehmen und nicht permanent in die aktuelle Umgebung eintragen. Dies ist z. B. dann sinnvoll, wenn man Abkürzungen in Ausdrücken verwenden will. Dazu gibt es die sogenannten 1 et-Ausdrücke: l e t dec i n exp end
1.3 Variablen und Definitionen
29
wobei exp ein Ausdruck und dec eine Folge von Definitionen ist. Eine Definition bezeichnet man manchmal auch als Deklaration. Auf Definitionen einzelner Variablen beschränkt, können wir die 1 et-Konstruktion zunächst auch wie folgt notieren: l e t v a l var\ = exp\ v a l var2 = exp2 v a l varn = expn in exp end Der Sinn eines solchen Ausdrucks ist, die Bezeichner vari,...,varn anstelle der entsprechenden Ausdrücke exp ],..., expn in exp zu verwenden. Der gesamte Ausdruck wird dann wie folgt ausgewertet: Zunächst werden die Definitionen (sequentiell) ausgeführt, das heißt, expl wird zu valt ausgeweitet, und die aktuelle Umgebung wird um die Bindungen (va/'i val]),..., (varn i—> valn) erweitert. Anschließend wird der Ausdruck exp in dieser erweiterten Umgebung ausgewertet. Dadurch wird erreicht, daß für jeden Bezeichner var; der korrekte Wert val, verwandt wird.1 Das Besondere an Definitionen innerhalb von let-Ausdrücken ist, daß zum einen ältere, möglicherweise anderslautende Definitionen für die Variablen var, im Ausdruck exp nicht sichtbar sind und daß zum anderen diese Definitionen nach dem let-Block wieder gültig sind, das heißt, ein l e t Block verdeckt Variablendefinitionen lediglich temporär; Definitionen, die außerhalb von ihm liegen, bleiben prinzipiell unberührt: v a l x = "unberuehrt"; > v a l x = "unberuehrt" : s t r i n g l e t val x=size x in x*9 end; > v a l i t = 90 : i n t "x b l i e b " ~ x ; > v a l i t = "x b l i e b unberuehrt" : s t r i n g 1
Dies trifft natürlich nur auf die Variablen zu, die nicht innerhalb von exp eine erneute Definition erfahren.
30
1 Elemente funktionaler
Programmierung
An diesem Beispiel sieht man ebenfalls, daß innerhalb des let-Ausdrucks im Teilausdruck size x der bis dahin gültige string-Wert für x verwandt wird, da ja die neue Bindung erst noch aufgebaut werden muß. Erfolgen in einem let-Ausdruck mehrere Definitionen, so werden diese nacheinander ausgeführt, das heißt, daß sich jede Definition auf die jeweils ihr vorangehenden abstützen kann. Wir haben also mit l e t eine Möglichkeit zur Formulierung lokaler Definitionen, die nur für einen Ausdruck Gültigkeit haben. Insbesondere können Definitionen auch in anderen Definitionen benutzt werden. Speziell für diesen Anwendungsfall bietet ML ein weiteres Sprachmittel an, die l o c a l l o c ai -Deklaration Deklaration. l o c a l dec' i n dec end Hier ist dec' eine Folge von Definitionen, die nur lokal in den Definitionen dec Gültigkeit haben. Beispiel: local val val in val val end:i > val y = > val x =
x=15 y=12 y=x div 3 x=2*y 5 : int 10 : int
In diesem Beispiel hat die Definition v a l y=12 keine Auswirkung, da vor der Benutzung im Ausdruck 2*x die Variable y bereits wieder neu mit dem Wert 5 definiert wurde. Man beachte, daß die local-Deklaration etwas völlig anderes ist als die let-Konstruktion: Während l e t dec i n exp end ein Ausdruck ist, der zu einem Wert ausgewertet wird, stellt l o c a l dec' i n dec end eine Definition dar, die eine oder mehrere Bindungen erzeugt. Aufgabe 1.3 Wie antwortet ML auf die folgenden Eingaben?
1.4
Funktionen
31
a) let val x=3 local val x=4 in val y=x end in x end b) local x=l in end c) local val x=2 val y=let local val x=x*x in val x=x*x end in x*x end in val x=y div x div x end
1.4 Funktionen In Abschnitt 1.1 haben wir mit Hilfe vordefinierter Funktionen und Konstanten Ausdrücke gebildet. Damit diente uns ML bisher mehr oder weniger lediglich als komfortabler „Taschenrechner". Um aber programmieren zu können, benötigen wir so etwas wie Kontrollstrukturen und funktionale/prozedurale Abstraktion. Beides wird über die Definition von Funktionen ermöglicht. Die Funktion (aus dem Vorwort) zur Berechnung des Flächeninhaltes eines Kreises bei gegebenem Radius notiert man in ML beispielsweise als: fn r => 3.1415 * r * r; > val it = fn : real -> real Auf das Schlüsselwort f n folgt der Parameter r , und das Symbol => leitet den definierenden Ausdruck ein. Im Unterschied zu anderen Werten, wie z. B. ganzen Zahlen, haben Funktionen keine eindeutige Repräsentation. Deshalb wird auch nur das Kürzel f n und keine Funktionsdefinition als „Wert" angezeigt. Man könnte zwar argumentieren, daß immer die vom Benutzer angegebene Definition der Funktion
32
1 Elemente funktionaler
Programmierung
angezeigt werden solle, doch dann hätte ein und dieselbe Funktion u . U . verschiedene Darstellungen. So könnte man in unserem Beispiel die Reihenfolge der Faktoren beliebig vertauschen, ohne die dadurch beschriebene Funktion als solche zu verändern. Funktionsdefinition
Da eine Funktion ein Wert ist, können wir für sie auch wie in Abschnitt 1.3 beschrieben einen Namen definieren. val circleArea = fn r => 3.1415 * r * r; > val circleArea = fn : real -> real An den Bezeichner c i r c l e A r e a ist nun die angegebene Funktion gebunden. Da Funktionsdefinitionen sehr häufig benötigt werden (im allgemeinen sogar wesentlich häufiger als andere Wertedefinitionen), gibt es auch die etwas abkürzende und intuitive Form: fun circleArea r = 3.1415 * r * r; > val circleArea = fn : real -> real In allgemeiner Form lautet also die Syntax einer Funktion f n var => exp und die Kurzform zur Definition einer benannten Funktion 1 f u n var var' = exp
anonyme Funktionen
Funktionen mit mehreren Argumenten
Da Funktionen, die gemäß der ersten Form definiert worden sind, im Unterschied zu der zweiten Form keinen Namen haben, werden diese auch anonyme Funktionen genannt. Es fällt auf, daß man in der beschriebenen Weise lediglich Funktionen mit einem Parameter definieren kann. Und in der Tat haben streng genommen alle Funktionen genau ein Argument und liefern genau ein Resultat. Um nun eine Funktion mit zwei oder mehr Argumenten zu definieren, gibt es zwei Möglichkeiten: (1) Man gruppiert alle Argumente zu einem Tupel und definiert die Funktion auf Tupeln. (2) Man definiert die Funktion als Abstraktion des ersten Arguments, und zwar so, daß das Ergebnis eine Funktion ist, die auf die restlichen Argumente anwendbar ist. 1
Die erste Variable var ist der Bezeichner der Funktion; die zweite Variable var' repräsentiert den Funktionsparameter.
1.4 Funktionen
33
Das zweite Verfahren nennt man auch „currying". Wir kommen darauf in currying Abschnitt 1.7 zurück. Eine Funktion, die den Flächeninhalt eines Kreisausschnitts berechnet, benötigt neben dem Radius r auch den Winkel w des Ausschnitts als zweiten Parameter. Wir gruppieren beide Argumente zu einem Tupel ( r ,w) und können dann die Funktion p i e definieren.1 fun pie (r,w) = 3.1415 * r * r * w / 360.0; > v a l p i e = f n : r e a l * r e a l -> r e a l Die Definition von Funktionen mit mehreren Ergebnissen erfolgt gleichfalls über die Konstruktion von Tupeln. Wenn man bei der Definition einer Funktion den Typ der Argumente bzw. des Ergebnisses nicht angibt, dann versucht der ML-Interpreter, den Typ automatisch zu ermitteln. In der Definition der Funktion p i e beispielsweise Typinferenz benötigt ML zur korrekten Anwendung der Multiplikation entweder zwei integer- oder zwei real-Werte als Argumente, da die Multiplikation für entweder reelle oder ganze Zahlen definiert ist. Da die erste Konstante eine reelle Zahl ist, wird für die Multiplikation die reelle Funktion angenommen. Aus diesem Grund aber erwartet ML, daß r ebenfalls eine reelle Zahl bezeichnet. Induktiv kann man diese Argumentation fortführen und ermittelt so den Typ r e a l auch für die Variable w.2 Der Ergebnistyp ergibt sich aus dem Ergebnistyp der verwandten Operationen, hier die reellwertige Multiplikation bzw. Division. Welcher Typ wird dann wohl mit für die folgenden Definition einer Quadratfunktion ermittelt? fun sqr x = x * x Das Problem besteht darin, daß der symbolische Bezeichner * überladen Überladene ist, das heißt, er bezeichnet zwei verschiedene Funktionen, nämlich einmal Funktionssymbole die Multiplikation von ganzen Zahlen und des weiteren die Multiplikation von reellen Werten. Da der ML-Interpreter nun nicht wissen kann, ob die integer- oder die real-Multiplikation gemeint ist, könnte er einen Typfehler melden. Dies wurde in früheren ML-Versionen auch tatsächlich so gehandhabt. Man mußte daher durch eine Typrestriktion der benutzten Variablen Typrestriktion den erwünschten Typ „erzwingen". Dabei ist es egal, welches Auftreten der 1 Wir gehen bei dieser Definition davon aus, daß der Winkel in Grad und nicht in Bogenmaß angegeben wird. 2
Alternativ kann man auch über die Funktion / argumentieren, die ausschließlich auf reellen Zahlen definiert ist.
34
1 Elemente funktionaler
Programmierung
Variablen verwandt wird, da eine Variable an sämtlichen Stellen ihres Auftretens immer denselben Typ haben muß. Ein korrekte Definition wäre also beispielsweise: f u n s q r ( x : i n t ) = x * x; > v a l s q r = f n : i n t -> i n t Tatsächlich aber kann man die Definition wie oben gezeigt ohne Typrestriktion vornehmen, da für überladene Bezeichner im 1997er Standard von ML im Zweifelsfall default-Typen angenommen werden; in vorliegendem Fall die integer-Multiplikation. Aber auch in den Fällen, in denen M L den Typ von Bezeichnern zweifelsfrei ermitteln kann, ist es manchmal durchaus sinnvoll, die Typen explizit anzugeben. Einerseits dient dies in längeren Programmtexten zur Dokumentation, andererseits kann man dann den Typinferenzmechanismus von ML zur Kontrolle verwenden. Außer den arithmetischen Operationen sind die Vergleichsoperationen und die Selektionen auf Tupeln überladen: sind ebenso wie die arithmetischen Operationen auf ganzen und reellen Zahlen definiert, und die Selektion #n ist für alle Tupel definiert, die n oder mehr Komponenten haben. Aufgabe 1.4 Definieren Sie eine Funktion max zur Berechnung des Maximums zweier integer-Werte.
Applikation
Eine funktionale Abstraktion kann man nun genauso wie eine vordefinierte Funktion auf Werte anwenden. Dies geschieht im allgemeinen durch Präfixnotation, das heißt, für die Applikation gilt die folgende Syntax: exp exp' Dabei muß sich der Ausdruck exp zu einer Funktion auswerten lassen. Um also z. B. den Flächeninhalt eines Halbkreises mit Radius 3 . 0 zu bestimmen, schreiben wir einfach: c i r c l e A r e a 3 . 0 / 2 . 0 . Da die Funktionsapplikation die höchste Präzedenz hat, wird zunächst die Funktion c i r c l e A r e a auf den Wert 3 . 0 angewandt, und erst danach wird durch 2 . 0 geteilt. Bei der Applikation von Funktionen kann man anstelle des Funktionsbezeichners c i r c l e A r e a natürlich auch direkt eine anonyme Funktion verwenden.
1.4
Funktionen
35
( f n r => 3 . 1 4 1 5 * r * r ) 3 . 0 / > v a l i t = 14.13675 : r e a l
2.0;
Wir wollen die Auswertung von Funktionsapplikationen noch etwas genauer betrachten. Bei der Berechnung eines Ausdrucks ( f n (var\,..,,varn)
=> exp)
(expi,...,expn)
geschieht folgendes: (1) Jeder Ausdruck expt wird in der aktuellen Umgebung zu einem Wert v«a/, ausgewertet. (2) Für jeden Parameter vart wird eine Bindung {var,
va/,) erzeugt.
(3) Der Ausdruck exp wird in der um die Bindungen (var\ val\), (varn i—'t valn) erweiterten aktuellen Umgebung ausgewertet.
...,
Man kann sich also die Applikation durchaus auch als let-Ausdruck vorstellen (siehe Seite 29).1 Die Parameter einer Funktion werden also vor dem Auswerten des Funktionsrumpfes ausgewertet. Diese Art der Parameterübergabe nennt man call-by-value. Die Auswertungsstrategie bezeichnet man auch als eager evaluation (wobei unter diese Bezeichnung aber auch noch andere Auswertungsstrategien fallen). Im Gegensatz dazu werden in einigen anderen funktionalen Sprachen (wie z.B. Haskell oder Miranda) Parameter gemäß call-by-name übergeben, das heißt, die Parameterausdrücke werden erst dann ausgewertet, wenn sie in einem Ausdruck benötigt werden. Auf Parameterübergabemechanismen werden wir in Kapitel 2 noch genauer zu sprechen kommen. Als Beispiel für eine rekursive Funktionsdefinition zeigen wir die wohlbekannte Fakultätsfunktion sowie eine Funktion zum Aufsummieren einer Liste von ganzen Zahlen: f u n f a k n = i f n=0 t h e n 1 e i s e n * f a k > v a l f a k = f n : i n t -> i n t
(n-1);
f u n sum 1 = i f 1=[] t h e n 0 e i s e hd 1 + sura ( t l > v a l sum = f n : i n t l i s t -> i n t
1);
Man sollte sich klarmachen, daß die Formulierung rekursiver Funktionen durch die Nicht-Striktheit der Fallunterscheidung überhaupt erst möglich 1
Funktionsparameter werden allerdings parallel gebunden, während die Definitionen eines let-Ausdrucks sequentiell verarbeitet werden.
Auswertungsstrategien call-by-value
call-by-name
rekursive Funktionsdefinitionen
36
1 Elemente funktionaler
Programmierung
wird: Wäre die Fallunterscheidung strikt, so würde z.B. der else-Zweig der f ak-Definition immer ausgeführt, auch wenn n Null ist. Damit könnte die Funktion für kein Argument terminieren. Aufgabe 1.5 Definieren Sie eine rekursive Funktion maxi zur Berechnung des Maximums einer Liste von positiven integer-Werten. (Das Maximum einer leeren Liste sei mit 0 definiert.) wechselseitige Rekursion
Es gibt manche Probleme, die sich durch zwei (oder auch mehrere) Funktionen beschreiben lassen, die sich gegenseitig aufrufen. Die Eigenschaften „gerade" und „ungerade" beispielsweise lassen sich (für natürliche Zahlen) durch folgende Aussagen beschreiben: (1) Die Zahl 0 ist gerade, ansonsten ist eine Zahl gerade, wenn sie größer als 1 ist und ihr Vorgänger ungerade ist. (2) Die Zahl 1 ist ungerade, ansonsten ist eine Zahl ungerade, wenn ihr Vorgänger gerade ist. Diese Charakterisierung läßt sich nun direkt in zwei Funktionsdefinitionen umsetzen. Aber Vorsicht, wenn wir einfach die folgende Eingabe versuchen: f u n even n = n=0 o r e l s e n>l andalso odd (n-1) f u n odd n = n=l o r e l s e even ( n - 1 ) ; > s t d _ i n : 2 . 2 7 - 2 . 3 4 E r r o r : unbound v a r i a b l e or > c o n s t r u c t o r : odd So meldet ML als Fehler in der Definition von even, daß die Funktion odd noch nicht definiert sei, was ja auch der Fall ist. Man kann aber nun genauso wenig zuerst odd und danach even definieren, da ja dann in der Definition von odd die Funktion even noch nicht bekannt ist. Zur Lösung dieses Dilemmas bietet ML die Möglichkeit an, mehrere Definitionen sozusagen parallel durchzuführen. Dazu muß man bei allen außer der ersten Deklaration, die als Block definiert werden sollen, das Schlüsselwort f u n durch and ersetzen. Wir können also definieren: f u n even n = n=0 o r e l s e n>l andalso odd (n-1) and odd n = n=l o r e l s e even ( n - 1 ) ; > v a l even = f n : i n t -> bool > v a l odd = f n : i n t -> bool
1.5 Typen und Polymorphismus
37
Diese Methode funktioniert für beliebige Definitionen. Man kann also auch z. B. Variablen parallel definieren. Man beachte aber, daß man sich nicht auf eine bestimmte Auswertungsreihenfolge verlassen darf. Eine Konsequenz ist, daß folgende Definition zu einem Fehler führt: v a l x=l and y=(x=0); > s t d _ i n : 3 . 8 E r r o r : unbound v a r i a b l e o r c o n s t r u c t o r : x Außer für Funktionsdefinitionen ist das Verfahren der parallelen Deklaration eigentlich nur für Datentypdefinitionen interessant.
1.5 Typen und Polymorphismus Typen haben in verschiedenen Programmiersprachen eine durchaus sehr unterschiedliche Bedeutung. Viele Sprachen bieten zwar ein Typkonzept an, erlauben oft aber einen eher „nachlässigen" Umgang mit Werten was deren Typen anbelangt; so kann man beispielsweise byte arrays in Modula-2 als beliebige Typen uminterpretieren oder in C ein Zeichen einfach wie einen integer-Wert behandeln. Solche Kuriositäten werden zuweilen durchaus als „features" angepriesen, beispielsweise dienen die byte arrays in Modula-2 dazu, generische Prozeduren zu realisieren. Dahinter steckt aber eine gewisse Gleichgültigkeit gegenüber dem Typsystem: Man definiert ein (ausdrucksschwaches) Typsystem, und an den Stellen, an denen dessen Unzulänglichkeiten zutage treten, wird es einfach aufgeweicht bzw. außer Kraft gesetzt und damit weiter abgeschwächt. Auf der anderen Seite werden vom Programmierer oftmals Überspezifikationen von Typen verlangt, die zum Teil als äußerst lästig empfunden werden.1
Bedeutung von Typen in Programmiersprachen
In der funktionalen Programmierung dagegen besitzt das Typsystem einen extrem hohen Stellenwert. Programmierkonzepte wie z. B. generische Funktionen werden nicht durch Umgehung des Typsystems realisiert, sondern sind vielmehr voll in das Typsystem integriert, und zwar durch Typpolymorphismus.
Typen in funktionalen Sprachen
Wir hatten an den bisherigen Beispielen immer gesehen, daß jeder Wert einen eindeutig bestimmten Typ hat. Diese Eigenschaft nennt man auch strenge Typisierung. Das Typsystem von ML ist bewußt so entworfen worden, daß man den Typ eines jeden Ausdrucks prinzipiell vor dessen Auswertung ermitteln kann. Das heißt, man kann allein durch Betrachten der syntaktischen Struktur eines 1
So kann beispielsweise in den meisten Fällen die Typisierung von Variablen aus deren Verwendung automatisch bestimmt werden, so daß man sich Deklarationen ersparen kann.
strenge
Typisierung
38 statische Typprüfung
Polymorphismus
Typausdrücke
1 Elemente funktionaler
Programmierung
Ausdrucks dessen Typ bestimmen. Man sagt auch, der Typ eines Ausdrucks i s t statisch überprüfbar, da man zur Ermittlung des Typs den Ausdruck nicht auswerten muß, sein dynamisches Verhalten also ignorieren kann. Die Bedeutung dieser Eigenschaft liegt darin, daß sämtliche Typfehler bereits zur Übersetzungszeit von Ausdrücken und Programmen erkannt werden. Somit werden Typüberprüfungen zur Laufzeit überflüssig, da beim Auswerten von Ausdrücken Typfehler nicht mehr auftreten können. Das heißt, der vom Compiler erzeugte Code ist kürzer und damit auch schneller ausführbar. Ein anderer äußerst wichtiger Nutzen des statischen Typsystems ergibt sich aus der Beobachtung, daß ein sehr großer Teil von Programmierfehlern in der Tat Typfehler sind, die man mit einem statischen Typsystem schon sehr früh, nämlich vor dem eigentlichen Programmlauf, erkennen kann. Damit erspart man sich in vielen Fällen aufwendiges Testen und langwierige Fehlersuche. Eine weitere wesentliche Eigenschaft des ML-Typsystems ist die Existenz von polymorphen Typen bzw. Werten. Das heißt, Funktionen können nicht nur für einen bestimmten Wertebereich definiert werden, sondern auch für eine ganze Klasse von Typen. Damit verringert sich der Programmieraufwand, und es erhöht sich die Wiederverwendbarkeit von Software: Viele Funktionen (z. B. zum Sortieren) werden nur einmal definiert und können für Werte beliebigen Typs eingesetzt werden. Wir hatten in Abschnitt 1.1 die Welt der Werte und Ausdrücke kennengelernt. Mit Hilfe von Operationen konnte man aus Konstanten Ausdrücke konstruieren, und durch die Einführung von Variablen erhielt man mittels Abstraktion aus Ausdrücken Funktionen. Mit der Welt der Typen verhält es sich sehr ähnlich, obwohl die entsprechenden Konzepte nicht ganz so umfangreich sind. Wir wollen also im folgenden das Typsystem zunächst als eine eigene Sprache auffassen und die einzelnen Strukturelemente in Analogie zum Bereich der Werte vorstellen. Beginnen wir mit den Konstanten. Konstante Typen in ML sind unter anderem: 1 unit
bool
int
real
char
string
Operationen auf Typen nennt man Typkonstruktoren. An Typkonstruktoren und entsprechenden Typausdrücken sind uns bisher Tupel, Records, Listen sowie Funktionstypen begegnet. ben u tzerdeünierte
Typen
Genauso wie man Werte an Bezeichner binden kann, gibt es in ML die Möglichkeit, Typen zu definieren. Dies geschieht über die Deklaration: type var = X 1
Es gibt auch noch den Typ e x n für exceptions, den wir hier aber nicht besprechen werden.
1.5 Typen und
Polymorphismus
39
Dabei muß t ein Typausdruck sein, der keine Typvariablen enthält. Man kann also auf diese Art lediglich monomorphe Typen definieren, z. B.: type point = real * real Benutzerdefinierte Typen können natürlich ihrerseits in weiteren Typdefinitionen verwandt werden: type polygon = point list Man beachte, daß mittels t y p e deklarierte Typen keine abstrakten Typen darstellen, in dem Sinne, daß die definierenden Typausdrücke in irgendeiner Weise verborgen wären. Die definierten Typen stellen lediglich eine abkürzende (und eventuell erläuternde) alternative Notation des Typausdrucks dar. Neben Konstanten und vordefinierten Operationen gibt es wie auf der Ebene der Werte auch für Typausdrücke einen Abstraktionsmechanismus: Durch Verwendung von Variablen in Typausdrücken kann man Mengen von Typen beschreiben, die alle die gleiche (Typkonstruktor-) Struktur haben. Dabei sind zwei Typen genau dann von gleicher Struktur, wenn die sie beschreibenden Typausdrücke bis auf die konstanten Typen wie i n t , b o o l usw. gleich sind. So haben die Typen i n t * s t r i n g und b o o l * r e a l die gleiche Struktur: Es sind nämlich beides Typen, die Paare als Werte haben. Ebenso sind in diesem Sinne i n t und s t r i n g gleich, da sie beide konstante Typen sind (das heißt, der Typausdruck enthält keine Typkonstruktoren). Betrachten wir nun beispielhaft einige polymorphe Typen. Der Ausdruck, der alle konstanten Typen beschreibt, ist einfach durch eine Typvariable wie z. B. ' a oder ' b gegeben 1 , und die Menge aller Paar-Typen ist durch einen Ausdruck ' a * ' b 2 beschrieben. Ein weiteres Beispiel ist der Typ ' a l i s t - > ' a aller Funktionen, die zu einer Liste von Elementen eines beliebigen Typs ' a einen Wert vom Typ ' a liefern. Ein Wert dieses Typs ist z. B. die Funktion hd, mit anderen Worten, hd hat den Typ ' a l i s t - > ' a. Das Binden von Variablen in Typausdrücken erfolgt durch Auflisten der entsprechenden Typvariablen (als Tupel) vor dem zu definierenden Typ. Das heißt, um einen polymorphen Typ mit Hilfe der type-Deklaration zu definieren, muß man die im Typausdruck verwendeten Typvariablen dem definierten Typ voranstellen: 1
Typvariablen werden von Wertevariablen rein syntaktisch durch einen vorangestellten Apostroph unterschieden. 2 Der Ausdruck ' a * ' a bezeichnet eine weiter eingeschränkte Menge von Typen, nämlich lediglich die Paare, deren Komponenten den gleichen Typ haben.
parametrischer Polymorphismus
Bindung von Typvariablen
40
1 Elemente funktionaler
Programmierung
t y p e ' a monoid = ' a * ( ' a * ' a -> ' a)
Instanzierung
Ein so definierter polymorpher Typ ist nichts anderes als ein benutzerdefinierter Typkonstruktor, den man auf Typen applizieren kann. Eine solche Typ-Applikation beschreibt dann die Konkretisierung eines polymorphen Typs, das heißt das Einsetzen eines Typs für eine Typvariable. Diesen Vorgang nennt man Instanzierung, den resultierenden Typ nennt man Instanz des polymorphen Typs. So ist der Typ i n t monoid beispielsweise eine Instanz des obigen Typs.
Aufgabe 1.6 Ein Dictionary kann man als Liste von Paaren implementieren. Geben Sie eine entsprechende Typdefinition an.
impliziter Polymorphismus
flacher Polymorphismus
Die in Typausdrücken verwandten Typvariablen sind implizit gebunden, und zwar durch eine dem Typausdruck vorangestellte Allquantifizierung über die Menge % aller Typen. Das heißt, der Typausdruck für Paare ist als V ' a , ' b € t . ' a * ' b zu lesen. (Einen quantifizierten Typausdruck nennt man auch ein Typschema, siehe Abschnitt 5.1.) Da aber die Quantifizierung der Typvariablen erstens immer über alle Typen und zweitens immer außerhalb des Typausdrucks erfolgt, kann man die Quantifizierung auch fortlassen.1 Die Tatsache, daß die Quantifizierung zu einem Typausdruck nicht erwähnt wird (bzw. nicht erwähnt werden muß), nennt man auch impliziten Polymorphismus. Aufgrund der Einschränkung, daß Quantifizierung nur auf der äußersten Ebene erlaubt ist, das heißt nicht in einen Typausdruck verschachtelt, bezeichnet man den Polymorphismus von ML auch als flachen Polymorphismus (shallow polymorphism). Der Grund für die Beschränkung auf flachen Polymorphismus ist, daß dadurch die statische Typprüfung garantiert wird, siehe Kapitel 5. Die enorme Bedeutung und Ausdruckskraft des parametrischen Polymorphismus liegt in der Tatsache, daß man generische Funktionen und Datentypen definieren kann.
polymorphe Funktionen
Betrachten wir zunächst als ein einfaches Beispiel die Identitätsfunktion, die ihr Argument unverändert als Ergebnis liefert. 1 Dies ist eine etwas vereinfachte Darstellung, denn es gibt spezielle Typvariablen - diese beginnen mit zwei Apostrophs die nur über einen Teil der Typen quantifizieren, nämlich lediglich über alle Typen, auf denen die —Funktion definiert ist. Dies sind alle Typen, die keinen ->-Typkonstruktor enthalten, da die Gleichheit für Funktionen unentscheidbar ist. Ein Beispiel folgt gegen Ende dieses Abschnitts. Da die Menge der Typen für eine Typvariable aber aus deren Notation hervorgeht, kann man dennoch auf Quantoren verzichten.
1.5 Typen und
Polymorphismus
41
fun id x = x; > val id = fn : 'a -> 'a Der Typausdruck ' a - > ' a umfaßt Funktionstypen wie i n t - > i n t oder auch b o o l * r e a l - > b o o l * r e a l . Dies bedeutet, daß die Funktion i d auf jeden Wert eines beliebigen Typs anwendbar ist. Weitere polymorphe Funktionen und die durch ML inferierten zugehörigen Typen sind: fun fst (x,y) = x; > val fst = fn : 'a * 'b -> 'a fun ignore x = "Argument ist futsch"; > val ignore = fn : 'a -> string fun length 1 = if 1=[] then 0 eise 1 + length (tl 1); > val length = fn : ''a list -> int Die polymorphen Typen der ersten beiden Funktionen ergeben sich jeweils aus der Tatsache, daß keine Operationen auf den Argumenten ausgeführt werden. Dagegen fällt beim Typ der Funktion l e n g t h der doppelte Apostroph in der Typ variablen auf. Solche Variablen sind nicht über alle Typen quantifiziert, sondern lediglich über Typen, auf denen Gleichheit definiert ist, sogenannte equality types, also z.B. keine Funktionstypen. Dieser eingeschränkte Typ ergibt sich aus der Verwendung der Bedingung i f 1= [ ] , da man eine Liste nur auf Gleichheit prüfen kann, wenn Gleichheit auch auf ihrem Typ definiert ist. Da nun aber die Gleichheit zweier Listen über die Gleichheit ihrer Elemente definiert ist, muß demzufolge Gleichheit auch auf dem Argumenttyp der Liste definiert sein. Also kann man mit der angegebenen Definition nicht die Länge der Liste [f a k , s q r ] bestimmen. Eine alternative Definition für l e n g t h , die für alle Typen definiert ist, kann man sehr leicht über pattern matching definieren (siehe Abschnitt 1.6, Seite 46). Aufgabe 1.7 Welche Typen inferiert ML für die folgenden Funktionsdefinitionen? a) fun f (x,y) = (x,x) b) fun g (x,y) = if true then (x,y) else (y,x) c) local fun g x = x=l in fun f x = (x,g x) end
equality
types
42
1 Elemente funktionaler Programmierung
d) local fun f x = if true then g x else g x and g x = if true then f x else f x in fun h x = (f x,g x) end
1.6 Datentypen und Pattern Matching
Variantentypen
Mit Datentypen kann man Aufzählungstypen, Variantentypen und rekursive Datenstrukturen realisieren, wie man sie z.B. aus Pascal oder C kennt. Ein Datentyp besteht aus einer Menge von typisierten Konstruktoren, von denen jeder eine Variante des Datentyps beschreibt. Betrachten wir einmal als einfaches Beispiel die Definition eines Typs für geometrische Objekte, der Punkte, Kreise und Rechtecke umfaßt:1 datatype geo = POINT of point I CIRCLE of point * real I RECT of {lowLeft:point, upRight:point}; > datatype geo > con CIRCLE : point * real -> geo > con POINT : point -> geo > con RECT : {lowLeft:point, upRight:point} -> geo Die Datentypdefinition besteht aus drei Varianten, und für jede Variante wird ein Konstruktor definiert, der den Typ der jeweiligen Variante als Argumenttyp hat. Zu beachten ist, daß die beiden Bezeichner POINT und p o i n t in der ersten Zeile zwei völlig verschiedene Dinge meinen, nämlich einen Konstruktor bzw. einen Typnamen. In ML herrscht die Konvention, bei Konstruktoren (zumindest bei benutzerdefinierten) Großbuchstaben zu verwenden.2 In der dritten Variante haben wir anstelle eines Tupeltyps p o i n t • p o i n t einen Recordtyp verwandt, um durch die Namen der Komponenten die Bedeutung der beiden Punkte besser unterscheiden zu können. Die allgemeine Syntax für die Definition von Datentypen lautet: datatype (oci.. .a*) type = con\ of Ti I ... I conn of ln 1 2
Für p o i n t gelte die Definition von Seite 39.
Dies hilft auch bei der Vermeidung eines häufigen Programmierfehlers: Wenn man irrtümlich einen Konstruktornamen als Parameter in einer Funktionsdefinition verwendet, meldet ML einen Typfehler.
43
1.6 Datentypen und Pattern Matching
Werte des Datentyps geo kann man durch Anwendung der Konstruktoren erzeugen, z. B.: v a l c = CIRCLE ( ( 2 . 0 , 3 . 1 ) , 5 . 0 ) ; > v a l c = CIRCLE ( ( 2 . 0 , 3 . 1 ) , 5 . 0 )
: geo
Werte, die durch Applikation eines Datentyp-Konstruktors erzeugt worden sind, nennen wir manchmal auch Konstruktorterme oder auch nur kurz Terme. Ein Aufzählungstyp läßt sich als Spezialfall eines Datentyps darstellen, in dem alle Konstruktoren nullstellig sind, z. B.:
Aufzählungstypen
d a t a t y p e c o l o r = RED | GREEN | BLUE; > datatype color > con BLUE : c o l o r > con GREEN : c o l o r > con RED : c o l o r Variantentypen kann man sehr gut dazu benutzen, heterogene Listen zu realisieren, indem man die verschiedenen Typen, die in der Liste gespeichert werden sollen, als Varianten eines Datentyps definiert und in der Liste dann entsprechende Varianten verwendet. Beispiel: Eine Liste mit verschiedenfarbigen geometrischen Objekten (vom Typ (geo * c o l o r ) l i s t ) .
heterogene
local val p = (4.0,3.0):point in v a l p i c t u r e = [(CIRCLE ( p , 1 . 0 ) , R E D ) , (CIRCLE (p,2.0).GREEN), (POINT p.BLUE)] end Schließlich können Datentypdefinitionen auch rekursiv sein, das heißt, der zu definierende Typ selbst kann in den Varianten verwandt werden, wie z. B. in der Definition eines Typs für binäre Bäume: d a t a t y p e t r e e = NODE of i n t * t r e e * t r e e I EMPTY; > datatype t r e e > con EMPTY : t r e e > con NODE : i n t * t r e e * t r e e -> t r e e
Rekursive Datentypen
Listen
1 Elemente funktionaler
44
Programmierung
Der binäre Baum 3
•
/
1
\• •
/
4
/
8
\•
\•
wird dann durch den Ausdruck NODE (3,N0DE (1,EMPTY,EMPTY), NODE (8,NODE (4,EMPTY,EMPTY),EMPTY)) dargestellt.
pattern
matching
Erzeugen von Bindungen
Wenn man nun beispielsweise den linken Teilbaum eines Binärbaums oder den Mittelpunkt eines Kreises vom Typ geo selektieren möchte, stellt sich die Frage, wie man auf die einzelnen Varianten eines Datentyp-Wertes zugreift. Dies geschieht im allgemeinen über pattem matching: Ein Muster (pattern) ist ein Ausdruck, der ausschließlich Variablen und Konstruktoren enthält. Ein Muster pat kann mit einem Ausdruck exp (der keine ungebundenen Variablen enthält) verglichen werden. Wenn das Muster pat auf den Ausdruck exp „paßt", das heißt, wenn pat und exp die gleiche Struktur haben, so werden die Variablen in pat an die Werte gebunden, die sich aus der Auswertung der in exp an den jeweils entsprechenden Stellen stehenden Ausdrücke ergeben. Man kann pattern matching zunächst also zum Erzeugen von Bindungen verwenden, z. B. können wir den Mittelpunkt des Kreises c wie folgt extrahieren: v a l CIRCLE ( p , r ) = c ; > s t d _ i n : 2 4 . 1 - 2 4 . 2 0 Warning: b i n d i n g n o t CIRCLE ( p , r ) = > val p = (2.0,3.1) : point > val r = 5.0 : r e a l
exhaustive
Hierzu sind zwei Anmerkungen zu machen: Zunächst besagt die Warnung von ML, daß das Muster CIRCLE ( p , r ) nicht „erschöpfend" ist. Dies erklärt sich aus der Tatsache, daß man im allgemeinen von der Variablen c nur den Typ, nämlich geo, kennt und ihr nicht ansehen kann, welche Variante des Typs sie beinhaltet. Andererseits kann man das angegebene Muster nur mit Kreisen vergleichen, das heißt, wäre c eine andere Variante des geoTyps, so würde der Mustervergleich scheitern und es könnte keine Bindung
1.6 Datentypen und Pattern Matching
45
erzeugt werden. In diesem Fall würde die Auswertung von ML abgebrochen. Um solche Laufzeitfehler zu vermeiden, generiert ML Warnungen, die insbesondere bei Funktionsdefinitionen verhindern sollen, daß Varianten beim Programmieren vergessen werden. Die zweite Beobachtung ist, daß wir nicht nur für den Mittelpunkt, sondern auch für den Radius eine Bindung erzeugt haben. Wenn dies nicht erwünscht ist, so kann man anstelle von Variablen auch den Unterstrich _ als anonyme Variable (auch wildcard genannt) verwenden. Insbesondere werden Muster in Funktionsdefinitionen eingesetzt. Zur Berechnung des Flächeninhalts eines beliebigen geometrischen Objekts definieren wir beispielsweise:
anonyme
Variablen
Funktionsdefinitionen auf Datentypen
f u n a r e a (POINT _) =0.0 I a r e a (CIRCLE ( _ , r ) ) = c i r c l e A r e a r I a r e a (RECT { l o w L e f t = ( x l , y l ) , u p R i g h t = ( x 2 , y 2 ) } ) = abs ( ( x l - x 2 ) * ( y l - y 2 ) ) ; > v a i a r e a = f n : geo -> r e a l Man sieht, wie die Funktionsdefinition mit Hilfe der Muster „entlang" der Datentypdefinition verläuft. Dies führt im allgemeinen zu sehr übersichtlichen und leicht verständlichen Programmen. Dieser Programmierstil wird zusätzlich noch durch die automatische Überprüfung der Vollständigkeit von Mustern unterstützt. Die rekursive (oder auch induktive) Definition des tree-Datentyps führt in natürlicher Weise zu rekursiven Funktionsdefinitionen auf diesem Typ. So ergibt sich beispielsweise als Definition für die binäre Suche: f u n f i n d (y,EMPTY) = f a l s e I f i n d (y,N0DE ( x , l e f t , r i g h t ) ) = i f y=x t h e n t r u e e l s e i f y v a l f i n d = f n : i n t * t r e e -> b o o l Man kann den Datentyp für binäre Bäume nicht nur für ganze Zahlen, sondern für Werte eines beliebigen Typs definieren. Dazu macht man die obige Definition einfach polymorph, indem man anstelle des konkreten Typs i n t eine Typvariable verwendet. Die polymorphe Datentypdefinition lautet dann:
polymorphe Datentypen
46
1 Elemente funktionaler
Programmierung
d a t a t y p e ' a t r e e = NODE of ' a * ' a t r e e * ' a t r e e I EMPTY; > datatype 'a tree > con EMPTY : ' a t r e e > con NODE : ' a * ' a t r e e * ' a t r e e -> ' a t r e e Damit ist t r e e nun kein einfacher Typ mehr, sondern vielmehr ein Typkonstruktor, der für einen beliebigen Typ ' a den Typ der Binärbäume über ' a bezeichnet. Man beachte aber, daß man die Definition der Funktion f i n d nicht ohne weiteres auf polymorphe Bäume erweitern kann, da die Vergleichsoperation < nicht auf allen Typen definiert ist.1 Der Datentyp Liste
Wir haben Listen bisher immer als vordefinierte Datenstruktur betrachtet. In der Tat aber sind Listen nichts anderes als ein polymorpher Datentyp gemäß folgender Definition: i n f i x r 5 :: d a t a t y p e 'a l i s t = n i l I : : of ' a * ' a l i s t Dabei besagt die infixr-Deklaration in der ersten Zeile, daß der : : Konstruktor in Infix-Notation verwandt wird und daß er rechtsassoziativ ist, das heißt, ein Ausdruck x: : y: : 1 wird implizit als x: : (y: : 1) geklammert. Die Zahl 5 gibt die Präzedenz des Konstruktors an, das heißt, : : hat eine höhere Präzedenz als z. B. die Vergleichsoperationen und eine niedrigere Präzedenz als die arithmetischen Operationen.2 Nun können wir also auch Funktionen auf Listen mittels pattern matching definieren. Beispielsweise erhalten wir folgende Version der Funktion length: fun length n i l = 0 I l e n g t h ( x : : 1 ) = 1 + l e n g t h 1; > v a l l e n g t h = f n : ' a l i s t -> i n t Verglichen mit der Definition auf Seite 41 ist diese Version etwas übersichtlicher. Der eigentliche Vorteil aber liegt darin, daß nunmehr die length-Funktion auf allen Listentypen definiert ist, also auch für Listen von Funktionen. Der Grund dafür ist, daß wir die auf die —Funktion zugunsten eines Musters verzichtet haben. 1 Man benötigt ein Konzept, mit dem man bei polymorphen Datentypen nicht nur Typparameter sondern auch Werteparameter, in diesem Fall ein Funktionswert, spezifizieren und auch instanzieren kann. Dies wird in ML durch des Modulsystem ermöglicht, und zwar durch Signaturen, Strukturen und Funktoren; dies werden wir hier aber nicht besprechen. 2 Deshalb brauchen z. B. im Ausdruck 2+1: : n i l = 4 - 1 : : n i l keine Klammern gesetzt zu werden.
47
1.7 Funktionen höherer Ordnung
Aufgabe 1.8 Definieren Sie eine Funktion assoc, die in einem Dictionary (gemäß der Definition aus Aufgabe 1.6) zu einem Schlüssel den dazugehörenden Eintrag liefert. Für Schlüssel, die nicht im Dictionary vorhanden sind, soll ein default-Wert als Ergebnis geliefert werden. Dieser default-Wert wird als Parameter der Funktion a s s o c mitgegeben.
1.7 Funktionen höherer Ordnung Bisher haben wir Funktionen immer nur auf einfache Werte sowie Tupel und Listen appliziert. Ebenso bekamen wir solche Werte als Ergebnis. Es gibt aber auch eine ganze Reihe von Operationen, die sich sehr elegant und allgemein formulieren lassen, wenn Funktionen als Parameter oder Ergebnis fungieren. Betrachten wir als Beispiel die Selektion von Elementen aus einer Liste gemäß einer bestimmten Bedingung, z. B. alle geraden Zahlen aus einer integer-Liste oder alle blauen Objekte aus einer geo-Liste. Anstatt jede Selektionsaufgabe erneut zu implementieren, definiert man eine einzige generische Funktion, die das entsprechende Selektionskriterium als Parameter hat, und gibt bei jeder Selektion lediglich die jeweilige Bedingung an.
Funktionen
als
Argumente
f u n f i l t e r (p, [ ] ) = [] I f i l t e r ( p , x : : l ) = if p(x) t h e n x : : f i l t e r ( p , l ) eise f i l t e r (p,l); > v a l f i l t e r = f n : ( ' a -> bool) * ' a l i s t -> ' a l i s t f i l t e r (even,[1,2,3,4,5]); > v a l i t = [2,4] : i n t l i s t f i l t e r ( f n (g,c)=>c=BLUE,picture); > v a l i t = [(POINT (4.0,3.0),BLUE)]
: (geo*color)
list
Wie wir an der zweiten Anwendung sehen, muß man Argumentfunktionen nicht vorher definieren, man kann auch Bedingungen direkt über anonyme Funktionen formulieren. Eine häufig benötigte Operation ist das Applizieren einer Funktion auf alle Elemente einer Liste. Wir geben hier zunächst eine vereinfachte Definition:
map, 1.
Version
48
1 Elemente funktionaler
Programmierung
f u n map ( f , [] ) = [] I map ( f , x : : l ) = f x::map ( f , l ) ; > v a l map = f n : ( ' a -> ' b ) * ' a l i s t -> ' b
list
Die Funktion map nimmt also als Argumente eine Funktion f , die auf einem beliebigen Typ ' a definiert ist, sowie eine Liste von ' a-Werten und konstruiert eine Liste von ' b-Werten, wobei ' b der Ergebnistyp von f ist. Anwendungen sind beispielsweise map ( s i z e , [ " a " , " " , " v i e r " ] ) map ( f n x = > x + l , [ 1 , 2 , 3 ] ) Da die Funktion map eine Form von Iteration realisiert, wird man sie sehr häufig in funktionalen Programmen antreffen. Betrachten wir einmal die Aufgabe, die Länge aller Worte in einem Text zu ermitteln, wobei ein Text als Liste von Sätzen gegeben ist und jeder Satz eine Liste von string-Werten ist, z. B. val text =
[["A","B","Bu"],["Und","aus","bist","Du"]]
Für eine Liste von string-Werten läßt sich die Aufgabe wie gezeigt bequem mittels map und s i z e lösen, dagegen ist die Lösung für eine Liste von string-Listen nicht ganz so offensichtlich: Eine Möglichkeit besteht darin, eine Funktion l i s t s i z e zu definieren, die die Längen für eine s t r i n g Liste liefert, und diese Funktion dann mit map auf den gesamten Text zu applizieren: f u n l i s t s i z e 1 = map ( s i z e . l ) ; > v a l l i s t s i z e = f n : s t r i n g l i s t -> i n t map ( l i s t s i z e , t e x t ) ; > val i t = [[1,1,2], [3,3,4,2]]
: int l i s t
list
list
Allerdings lohnt sich im allgemeinen die Definition einer Funktion kaum für nur eine Anwendung. Deshalb könnte man die Funktionsdefinition durch Verwenden einer anonymen Funktion einsparen. Man erhielte dann einen Ausdruck wie map ( f n l=>map
(size,1),text)
Solche Ausdrücke sind nicht ganz einfach zu lesen, störend wirkt insbesondere die Abstraktion mittels f n . Diese ließe sich einsparen, wenn map
49
1.7 Funktionen höherer Ordnung
anstelle der Ergebnisliste eine Funktion liefern würde, die man auf Listen applizieren kann. Dies kann man beispielsweise dadurch erreichen, daß man Funktionen als den Listenparameter fortläßt und die obige Definition um eine entsprechende Ergebnisse Abstraktion erweitert: l o c a l fun I in fun end; > v a l map
mapl ( f , [ ] ) = [] mapl ( f , x : : 1 ) = f x::mapl map f = fn l=>mapl
(f,l)
(f,1)
= fn : C a -> ' b ) -> ' a l i s t -> 'b l i s t
Am Typ erkennt man, daß mit dieser Definition map als Argument lediglich eine Funktion (vom Typ ' a -> 'b) nimmt und als Ergebnis ebenfalls eine Funktion liefert, die ' a-Listen auf ' b-Listen abbildet.1 Es wird nun map nicht mehr auf ein Paar bestehend aus Funktion und Liste appliziert, sondern lediglich auf die Funktion. Das heißt, das erste der obigen Beispiele notiert man nun als map s i z e [ " a " , " " , " v i e r " ] . Wegen der Linksassoziativität der Funktionsapplikation ist der Ausdruck identisch zu (map s i z e ) [ " a " , " " , " v i e r " ] , das heißt, map s i z e bezeichnet die auf Listen fortgesetzte size-Funktion, und diese wird auf eine Liste appliziert. Damit kann man nun die Anwendung auf die geschachtelte Liste ganz einfach notieren, und zwar als map (map s i z e )
text
Das heißt, die (map size)-Funktion (vom Typ s t r i n g l i s t -> s t r i n g l i s t ) wird selbst auf Listen mittels map fortgesetzt. Nun stört vielleicht noch die kompliziert aussehende Definition der Funktion map. Erinnern wir uns, daß die f un-Deklaration eine Abkürzung für Abstraktionen bereithält; es gilt ja: fun f x=e ist eine Abkürzung für val f = f n x=>e. Dies funktioniert auch für geschachtelte Abstraktionen, das heißt, fun var pat\ ... patn = exp ist die Kurzform für2 val var = f n pat\ => ... => fn patn => exp Da der ->-Typkonstruktor rechtsassoziativ ist, werden die Klammern um den Ergebnisfunktionstyp weggelassen, das heißt, der Typ von map ist als C a - > ' b ) - > ( ' a l i s t - > 'b l i s t ) zu lesen. 1
2
Dies gilt nur, sofern keine zwei Muster eine gemeinsame Variable haben.
1 Elemente funktionaler
50 map, 2. Version
Programmierung
wobei pat¡ ein Muster ist. Damit erhalten wir eine Definition für map, die sehr viel einfacher zu lesen ist: f u n map f [] = [] I map f ( x : : l ) = f x::map f 1
currying
Diese Form der Definition bezeichnet man nach dem Logiker Haskell B. Curry auch als currying.1 Funktionsdefinitionen mittels currying sind immer dann besonders nützlich, wenn man durch teilweise Fixierung der Argumente neue Funktionen erhalten möchte. Currying erlaubt die partielle Applikation von Funktionen. Aufgabe 1.9 a) Geben Sie eine geschönfinkelte Funktionsdefinition für die Funktion mult (Multiplikation von integer-Werten). Welche Funktion wird durch die partielle Applikation mult 2 beschrieben? b) Definieren Sie die Funktion a s s o c aus Aufgabe 1.8 mittels currying, und zwar so, daß man mit einem möglichst einfachen Ausdruck zu einem Dictionary und einem default-Wert eine Funktion konstruieren kann, die zu einem Schlüssel den dazugehörenden Eintrag liefert. Mit der folgenden Funktion c u r r y kann man zu jeder Funktion auf Paaren eine entsprechend geschönfinkelte Version bilden: fun curry f x y = f (x,y); > v a l c u r r y = f n : (»a * ' b -> 'c)
f oldr
-> ' a -> ' b -> ' c
Eine weitere äußerst wichtige Operation auf Listen ist das Reduzieren einer Liste anhand einer binären Operation. Dies wird durch die f oldr-Funktion 2 ermöglicht: f u n f o l d r f u [] = u I f o l d r f u (x::1) = f ( x , f o l d r f u 1); > v a l f o l d r = f n : (>a * ' b -> ' b ) -> ' b -> ' a l i s t -> ' b 1
Eigentlich hat der deutsche Mathematiker Schönfinkel die Notation zuerst erfunden, der Begriff „schönfinkeln" hat sich aber leider nicht durchsetzten können. 2 Das r als letzter Buchstabe zeigt an, daß die Liste von rechts reduziert wird bzw., daß die binäre Operation f rechtsassoziativ angewandt wird. Eine korrespondierende Funktion f o l d l werden wir in Abschnitt 3.3 noch kennenlernen.
1.7 Funktionen höherer Ordnung
51
Die Funktionsweise der f oldr-Funktion macht man sich am besten durch ein Beispiel klar. Für eine binäre Operation , eine Konstante u und eine Liste von Werten [*!,... ,x„] berechnet f oldr: f o l d r u
,...,*„] = xi ® (*2 ® (• • • (xn u)...))
Viele Listenfunktionen lassen sich mittels f o l d r definieren:1 val val fun fun fun fun fun
sum implode 11 ® 12 length 1 rev 1 map f maxi 1
= = = = = = =
foldr foldr foldr foldr foldr foldr foldr
op+ 0 op" "" op:: 12 11 (fn (x,y)=>y+l) 0 1 ( f n (x,y)=>y®[x]) [] 1 ( f n (x,y)=>f x : : y ) [] max (hd 1) ( t l 1)
Versuchen Sie, die Funktionsweise aller Definitionen nachzuvollziehen. f o l d r beschreibt ein ganz bestimmtes Rekursionsschema auf Listen. Jede Funktion, die in dieses Schema fällt, kann man mittels f o l d r ausdrücken. Eine wichtige Bedeutung haben allgemeine Funktionen wie f o l d r unter anderem im Bereich der Programmverifikation bzw. -transformation, wir werden darauf in Abschnitt 3.3 zurückkommen. Aufgabe 1.10 Definieren Sie mit Hilfe der f oldr-Funktion die Funktion l i s t C o n c a t , die eine Liste von Listen zu einer einzigen Liste konkateniert. Als letzte Funktion höherer Ordnung stellen wir die Komposition o vor, die zu zwei gegebenen Funktionen f : 'b -> ' c und g: 'a -> 'b die Funktion f o g: ' a -> ' c bildet, die zu jedem Wert x den Wert f (g x) berechnet.2 Damit lassen sich sehr effektiv neue Funktionen konstruieren, z.B.: v a l l a s t = hd o rev v a l odd = not o even v a l nestmap = map o map 1
op hebt den Infix-Status einer Funktion temporär auf, das heißt, eine Funktion mit InfixNotation kann dadurch an Stellen verwandt werden, an denen Präfix-Notation verlangt ist, z. B . o p + ( 2 , 3 ) . 2 o ist eine vordefinierte Funktion in ML, für die Infix-Notation und eine geringere Präzedenz als die Vergleichsoperationen vereinbart ist.
52
1 Elemente funktionaler
Programmierung
Mit der letzten Funktion lassen sich beispielsweise die Wortlängen in t e x t einfach durch n e s t m a p s i z e t e x t ermitteln. Eine ganze Reihe weiterer Funktionen höherer Ordnung werden wir im Abschnitt 3.3 noch kennenlernen.
1.8 Literaturhinweise Reade (1989) stellt in seinem Buch funktionale Programmierkonzepte ebenfalls auf der Basis von ML dar. Eine sehr empfehlenswerte umfassende Einführung in M L gibt Ullman (1998). Besonders hilfreich sind für Anfänger die stets ausführlich erklärten Beispiele sowie die Erläuterungen zu häufig gemachten Fehlern. Das Buch von Paulson (1996) bietet einige größere Programmbeispiele, die insbesondere die Verwendung des hier nicht besprochenen ML-Modulsystems illustrieren. Der ML-Standard ist in (Milner, Tofte, Harper, & MacQueen 1997) festgelegt. Die in diesem Buch verwendete Implementierung „Standard ML of New Jersey" ist in (Appel & MacQueen 1991) näher beschrieben. Eine sehr gute Einführung in die funktionale Programmierung, basierend auf der lazy Sprache Haskell, ist das Buch von Bird (1998). (Dies ist eine Neuauflage des Buches von Bird & Wadler (1988).) Einen guten Überblick über funktionale Programmiersprachen verschafft der Übersichtsartikel von Hudak (1989). Plädoyers für funktionale Programmierung und Vergleiche mit z. B. imperativen Sprachen finden sich in vielen Büchern und Artikeln. Hervorzuheben sind die Artikel von Hughes (1989) und Backus (1978). Fortgeschrittene Beispiele für ML-Programmierung aus dem Bereich Datenstrukturen bietet das Buch von Okasaki (1998). Dort wird insbesondere auf den Unterschied zu Datenstrukturen in imperativen Sprachen eingegangen. An deutschsprachiger Literatur zu funktionaler Programmierung ist zunächst die Übersetzung des Buches von Bird und Wadler zu nennen (Bird & Wadler 1992). Thiemann (1994) behandelt eine Vielzahl von Themen in z.T. sehr gestraffter Form.
53
2 Verzögerte Auswertung Die Art der Parameterübergabe, das heißt die Reihenfolge und die Zeitpunkte der Auswertung der Parameter, hat in einer Sprache Einfluß auf die Effizienz der Berechnungen, auf das Terminierungsverhalten und auch auf die Ausdruckskraft der Sprache. Wir werden in diesem Kapitel einige damit verbundene Aspekte untersuchen. In Abschnitt 2.1 betrachten wir zunächst die Striktheit van Funktionen und die entsprechenden Mechanismen zur Parameterübergabe. Wir zeigen, daß eager évaluation in manchen Fällen unnötige Berechnungen durchführt, ebenso wie normal order évaluation manche Berechnungen mehrfach vornimmt. Wir demonstrieren dann, wie man in einer call-by-value Sprache wie ML call-by-name und call-by-need Parameterübergabe simulieren kann. Danach beschreiben wir in Abschnitt 2.2 die Darstellung unendlicher Datenstrukturen durch verzögerte Auswertung von Termkonstruktoren.
2.1 Parameterübergabe und Auswertungsstrategien Wir sind bisher immer davon ausgegangen, daß Funktionsapplikationen Werte liefern. Nicht terminierende bzw. nicht definierte Ausdrücke haben wir als Undefiniert bezeichnet. Dafür verwenden wir im folgenden das Symbol 1 , das heißt also z. B. 1 . 0 / 0 . 0 = X oder hd [] = _L. Nun können wir definieren: Eine «-stellige Funktion / ist strikt im ¿-ten Argument (1 < k < n), wenn gilt: f{x\,...,xk-\,±.,xk+\,...,xn)
= J_
Wir hatten in Abschnitt 1.4 kurz die call-by-value Parameterübergabe von ML erklärt. Demnach wurden alle Argumente einer Funktion ausgewertet, bevor der definierende Funktionsausdruck berechnet wird. Dies hat zur Folge, daß irgendein Undefiniertes Argument die gesamte Funktionsanwendung Undefiniert macht. Das heißt also, daß call-by-value die strikte Semantik von Funktionen realisiert.
strikte Funktion
54
2 Verzögerte Auswertung
Auswertungsstrategien
Die entsprechende Auswertungs- bzw. Reduktionsstrategie von Ausdrücken wird als applicative order bezeichnet. Dies ist eigentlich nicht ganz passend, da der Begriff aus dem Lambda-Kalkül stammt und die Reduktion auf die sogenannte $-Normalform bezeichnet. In funktionalen Sprachen aber wird ein Ausdruck zumeist nur zu einer weak normal form oder weak head normal form ausgewertet. Bezogen aber auf diese Normalformen sind applicative order Reduktion und call-by-value Parameterübergabe semantisch gleich. Anstelle von applicative order wird oftmals auch der Begriff eager evaluation verwendet. Genau genommen ist applicative order nur ein Beispiel für eager evaluation, da wir aber keine anderen Formen von eager evaluation besprechen, verwenden wir den Begriff synonym zu applicative order evaluation. Auf Normalformen und Reduktionsstrategien werden wir noch in Abschnitt 4.2 zu sprechen kommen.
Auswertungsreihenfolge
Man sollte sich an dieser Stelle noch merken, daß Auswertungsstrategien in irgendeiner Form die Reihenfolge der Auswertung der Argumente festlegen. Insofern sind Auswertungsstrategien speziellere Beschreibungen als Parameterübergabemechanismen. Sowohl applicative order als auch normal order und lazy evaluation werten Funktionsargumente von links nach rechts aus.
Vorteile von call-by-name
Verzögerte Auswertung von unktionsrümpfen
Wir haben bereits einige Ausnahmen von der call-by-value Parameterübergabe in ML kennengelernt: Sowohl a n d a l s o als auch o r e l s e waren nicht strikt in ihrem zweiten Argument, und die Fallunterscheidung war nicht strikt im zweiten und dritten Argument. Das heißt, die entsprechenden Argumente werden nur dann ausgewertet, wenn es zur Berechnung des Gesamtausdrucks wirklich notwendig ist. Diese Art der Parameterübergabe bezeichnet man als call-by-name. Die Vorteile waren zum einen mögliche Effizienzgewinne durch Nichtauswerten nicht benötigter, komplizierter Ausdrücke. Zum anderen werden durch die Nicht-Striktheit bei der Fallunterscheidung terminierende rekursive Funktionsdefinitionen überhaupt erst möglich. Es gibt nun eine Reihe moderner funktionaler Programmiersprachen, deren Parameterübergabe ausschließlich call-by-name erfolgt (z.B. Miranda, Gofer und Haskeil).1 Da stellt sich die Frage, ob es in ML noch mehr Situationen gibt, in denen Ausdrücke nicht sofort ausgewertet werden, und ob man die verzögerte Auswertung möglicherweise wegen ihrer Vorteile eventuell explizit einsetzen kann. Dies ist in der Tat der Fall: Definierende Ausdrücke von Funktionen werden nicht bei der Funktionsdefinition, sondern erst bei deren Applikation ausgewertet. Somit kann man die Auswertung eines beliebigen Ausdrucks verzögern, indem man daraus eine Abstraktion macht. Zum Zeitpunkt der gewünschten Auswertung braucht man die Abstraktion dann nur noch zu applizieren. 1
Genauer gesagt erfolgt die Parameterübergabe call-by-need, siehe unten.
2.1 Parameterübergabe und Auswertungsstrategien
55
Betrachten wir als Beispiel die folgende Funktionsdefinition: f u n f ( x , y ) = i f x=0 t h e n 0 e i s e x*y*y In einem Aufruf f ( 0 , y ) wird gemäß eager evaluation das Argument y stets ausgewertet, obwohl der Wert zum Ergebnis nichts beiträgt. Die möglicherweise zeitintensive Berechnung kann man einsparen, wenn man y nicht direkt, sondern in eine Abstraktion „verpackt" übergibt. Dazu verwenden wir die Abstraktion über dem Nulltupel (), das heißt, der Funktionsaufruf lautet nunmehr f (0, f n () =>y). Nun muß man aber natürlich auch in der Definition von f alle Auftreten von y durch eine Applikation auf () ersetzen. Wir erhalten die folgende Definition. f u n f ( x , y ) = i f x=0 then 0 e i s e x*y ()*y ( ) ; > v a l f = f n : i n t * ( u n i t -> i n t ) -> i n t Zur besseren Lesbarkeit von Funktionsdefinitionen und Typen verwenden wir die folgenden Abkürzungen: t y p e ' a l a z y = u n i t -> ' a f u n f o r c e ( f : ' a l a z y ) = f () Damit können wir die obige Definition wie folgt notieren: f u n f ( x , y ) = if x=0 t h e n 0 e i s e x * f o r c e y * f o r c e y; > v a l f = f n : i n t * i n t l a z y -> i n t Aufgabe 2.1 Warum verwenden wir nicht auch zur Verzögerung eine Funktion wie z. B.: f u n d e l a y x = ( f n () => x) : ' a l a z y Damit könnte man doch eigentlich Ausdrücke wie f ( 0 , f n ()=>y) übersichtlicher notieren, nämlich als f ( 0 , d e l a y y). Wenn wir konsequent alle Argumentausdrücke durch Abstraktionen und alle Vorkommen von Parametern in Funktionen durch Applikationen ersetzen, so realisieren wir damit die call-by-name Parameterübergabe. Die entsprechende Auswertungsstrategie heißt normal order (hier gilt die gleiche Anmerkung bezüglich Normalformen wie bei call-by-value/applicative order).
Simulation von call-by-name
56
2 Verzögerte
Auswertung
An der Definition von f fällt auf, daß der verzögerte Ausdruck y mehrmals berechnet wird. Zur Beseitigung dieses Mankos fällt einem schnell die folgende, verbesserte Definition ein: fun f (x,y) = if x=0 then 0 eise let val z = force y in x*z*z end
caU-by-need Parameterübergabe
Effizienz von lazy vs. eager evaluation
Striktheitsanalyse
Nicht-Striktheit und lazy evaluation
Hier wird y höchstens einmal ausgewertet (und auch nur dann, wenn es benötigt wird). Diese optimierte Form der Parameterübergabe nennt man call-by-need. Die Auswertungsstrategie bezeichnet man als lazy evaluation. Dies sieht eigentlich nach einer optimalen Auswertungstrategie aus: Ausdrücke werden maximal einmal berechnet, und auch nur dann, wenn sie wirklich benötigt werden. Allerdings ist die Übergabe benötigter (und damit auszuwertender) Parameter mit lazy evaluation deutlich ineffizienter als mit eager evaluation (und dies gilt nicht nur für die Simulation in eager Sprachen, sondern auch für lazy Sprachen wie Miranda oder Haskeil). Das heißt, lazy evaluation bringt nur dann Vorteile, wenn Parameter nicht (oder nur teilweise, siehe Abschnitt 2.2) ausgewertet werden müssen. Deshalb gibt es auch für lazy Sprachen Optimierungsverfahren zur Striktheitsanalyse, mit denen während der Übersetzung einer Funktionsdefinition festzustellen versucht wird, welche Parameter auf jeden Fall benötigt werden. Es wird dann Code erzeugt, der die entsprechenden Parameter call-by-value übergibt. Alle übrigen Parameter werden verzögert ausgewertet. Selbst unter Einsatz der Striktheitsanalyse sind lazy Sprachen dennoch im allgemeinen ineffizienter als eager Sprachen. Gründe dafür sind z.B., daß die Striktheitsanalyse prinzipiell nur einen Teil der strikten Parameter erkennen kann, daß es eine Reihe von Optimierungsverfahren für eager Sprachen gibt, die auf lazy Sprachen nicht anwendbar sind, und daß unbenötigte Argumente in der Praxis nicht so häufig vorkommen, das heißt, daß die Vorteile von lazy evaluation den Aufwand häufig nicht rechtfertigen. Wir hatten gesagt, daß eager evaluation strikte Funktionen realisiert. Das Verhältnis von nicht-strikten Funktionen zu lazy evaluation ist dagegen nicht so klar, da es nicht-strikte Funktionen gibt, die durch keine feste Auswertungsreihenfolge implementiert werden können. Die folgenden Gleichungen definieren eine nicht-strikte Multiplikation ( x ) auf der Basis der Multiplikation für Zahlen (•):
2.2 Unendliche Datenstrukturen
57
0xy= 0 xxO=O xxy
= x-y
Das heißt, wenn ein Argument Undefiniert ist, liefert x ein Ergebnis, falls das andere Argument O ist. Implementieren kann man die Funktion nur durch eine Art von paralleler Auswertung: Beide Argumente werden parallel ausgewertet, und sobald eine Auswertung mit 0 terminiert, kann man die andere Auswertung beenden und 0 als Ergebnis liefern. Man kann also nur sagen, daß unter normal order und lazy evaluation nichtstrikte Funktionen möglich sind. Darüber hinaus sind normal order und lazy evaluation semantisch identisch, der Unterschied besteht allein in der Effizienz der Auswertung von Ausdrücken.
2.2 Unendliche Datenstrukturen Datentyp-Konstruktoren sind im Prinzip nichts anderes als Funktionen. Also kann man die Argumente eines Konstruktors genauso wie die Argumente in einem Funktionsaufruf verzögern. Man muß dann natürlich in der Definition des Konstruktors, das heißt in der Definition des Datentyps, den Argumenttyp geeignet wählen. Ein klassisches Beispiel sind die sogenannten Ströme (streams), die Listen sehr ähnlich sind. Da mit „streams" in ML Typen zur Ein-/Ausgabe bezeichnet werden, verwenden wir für einen Strom im folgenden die Begriffe lazy list oder Sequenz. Wir arbeiten mit der folgenden Definition für Sequenzen: d a t a t y p e ' a sequ = NIL | CONS of ' a * ' a sequ l a z y Eine Sequenz ist entweder leer (Konstruktor NIL) oder besteht aus einem Kopfelement und einer Restsequenz. Die Definition ist der einer Liste sehr ähnlich, der einzige Unterschied besteht darin, daß das zweite Argument des CONS-Konstruktors, die Restsequenz, kein Element vom Typ ' a sequ ist, sondern eine Funktion, die ein solches Element liefert. Der Liste [ 1 , 2 ] entspricht die Sequenz: CONS ( 1 , f n ()=>C0NS ( 2 , f n ()=>NIL)) In solchen Ausdrücken nutzt die Verzögerungstechnik nicht viel, da alle Elemente der Liste sowieso berechnet werden. Die verzögerte Auswertung der Restsequenz gibt uns aber die Möglichkeit, potentiell unendliche Listen
Ströme
2 Verzögerte
58
Auswertung
zu notieren. Beispielsweise läßt sich die Liste aller natürlichen Zahlen über die Funktion f r o m definieren: fun from n = CONS (n,fn ()=>from (n+1)); > vai from = fn : int -> int sequ vai nats = from 1; > vai nats = CONS (l,fn) : int sequ Wir können zwar niemals einen Ausdruck wie f r o m 1 ganz auswerten, wir haben aber eine Repräsentation einer Liste, die beliebig lang sein kann. Ein Anfangsstück einer Sequenz in Form einer Liste kann man mit der Funktion f i r s t berechnen: fun first 0 s I first n NIL I
f i r s t n (CONS ( x , r ) )
= [] = [] = x: . - f i r s t (n-1)
(force r)
Damit berechnet z. B. der Ausdruck f i r s t 4 n a t s die Liste [ 1 , 2 , 3 , 4 ] , Aufgabe 2.2 Definieren Sie den ML-Wert ones, der eine Liste von potentiell unendlich vielen Einsen repräsentiert. (Hinweis: Wahrscheinlich benötigen Sie wie bei der Definition von n a t s eine Hilfsfunktion.) Wir können auf Sequenzen ganz ähnliche Funktionen definieren wie auf Listen. fun maps f NIL = NIL I maps f (CONS (x,r)) = CONS (f x,fn ()=>maps f (force r)) fun filters p NIL = NIL I filters p (CONS (x,r)) = if p x then CONS (x,fn ()=>filters p (force r)) else filters p (force r) Da wir mit möglicherweise unendlichen Objekten umgehen, sollte man sich bei der Definition von Funktionen auf Sequenzen immer über deren Terminierung Gedanken machen. So terminiert die maps Funktion (bei überall definiertem f ) stets nach einem Schritt. Dagegen wird in der f i l t e r s Funktion die Restsequenz solange expandiert, bis sie leer ist oder ein Element
2.3
Literaturhinweise
59
gefunden wird, für das p gilt. Das heißt, falls es in einer unendlichen Liste kein solches Element gibt, so terminiert f i l t e r s nicht. Mit den beiden Funktionen können wir beispielsweise die Liste aller Quadratzahlen oder aller ungeraden Zahlen ausdrücken: v a l squares = maps sqr n a t s v a l odds = f i l t e r s odd n a t s Aufgabe 2.3 Warum terminiert der folgende Ausdruck nicht? f i r s t 2 ( f i l t e r s ( f n x=>x i mod x0) Also läßt sich das Sieb des Eratosthenes s i e v e und damit die Sequenz der Primzahlen p r i m e s wie folgt definieren: f u n s i e v e (CONS ( x , r ) ) = CONS ( x , f n ( ) = > s i e v e ( f i l t e r s ( f n i = > i mod x0) ( f o r c e r ) ) ) v a l primes = s i e v e ( f r o m 2)
2.3
Literaturhinweise
Die Simulation verzögerter Auswertung in call-by-value Sprachen wird in den Büchern von Reade (1989), Paulson (1996) oder auch Henderson (1980) sehr ausführlich behandelt. Die Frage, ob lazy evaluation grundsätzlich die Auswertungstrategie für funktionale Sprachen sein soll oder nicht, wird z. T. sehr heftig diskutiert. Pro-Argumente findet man unter anderem in (Hughes 1989) oder (Hudak
60
2 Verzögerte Auswertung
1989). Auf die Nachteile wird beispielsweise in den Büchern von Reade (1989) und Paulson (1996) hingewiesen. Normalformen und Auswertungsstrategien werden in (Peyton Jones 1987) unter dem Aspekt der Implementierung funktionaler Sprachen betrachtet. Es gibt eine Vielzahl von Verfahren zur Striktheitsanalyse. Den Ursprang bildet die Arbeit von Cousot und Cousot (1977), die von Mycroft (1980) erweitert wurde. Eine ausführliche Einführung und Übersicht findet man unter anderem in (Burn, Hankin, & Abramsky 1986). Der bewußte Einsatz von lazy evaluation zur Realisierung von effizienten Datenstrukturen ist von Chris Okasaki eingehend untersucht worden (Okasaki 1996; Okasaki 1998).
61
3 Verifikation und Programm-Transformation Die mathematische Orientierung funktionaler Programmiersprachen erlaubt den Zugang zu Programmen mit wohlbekannten mathematischen Methoden. Dies kann der Verifikation von Programmeigenschaften oder auch der Berechnung von optimierten Funktionsdefinitionen dienen. Den ersten Aspekt behandeln wir in Abschnitt 3.1, wo wir Induktionsprinzipien für rekursive Funktionen und Datentypen vorstellen. Die Herleitung optimierter rekursiver Funktionsdefinitionen ist Thema des Abschnitts 3.2. Das dort vorgestellte System von nur sechs Regeln ist sehr allgemein und verlangt daher bei der Anwendung auch einiges an Geschick und Kreativität. Dagegen baut die eher algebraische Methode aus Abschnitt 3.3 auf einen großen Satz von Programmidentitäten, mit denen man ausschließlich Gleichungstransformationen durchführt und so in direkter Weise von einer einfachen Programmspezifikation zu effizienten Implementierungen gelangen kann.
3.1 Induktion Für die Menge der natürlichen Zahlen IN gelten die Beweisprinzipien der mathematischen bzw. vollständigen Induktion, mit denen sich Eigenschaften für alle natürlichen Zahlen zeigen lassen. Mit dem Induktionsprinzip kann man insbesondere die Korrektheit rekursiver Funktionsdefinitionen zeigen. Dies wird zunächst in Abschnitt 3.1.1 demonstriert. Das Induktionsprinzip basiert essentiell auf der induktiven Definition der natürlichen Zahlen. Dies läßt vermuten, daß man Induktionsbeweise auch für andere induktiv definierte Strukturen führen kann. Dies ist in der Tat der Fall, und in Abschnitt 3.1.2 werden wir ein Induktionsprinzip für Datentypen vorstellen, die strukturelle Induktion. Eine weitere Verallgemeinerung bietet die wohlfundierte Induktion, mit der man Beweise über beliebige Strukturen führen kann, auf denen eine Rela-
62
3 Verifikation und
Programm-Transformation
tion mit bestimmter Eigenschaft definiert ist. Die wohlfundierte Induktion werden wir in Abschnitt 3.1.3 besprechen.
3.1.1 Korrektheit rekursiv definierter Funktionen Bereits in der Schule lernt man, Eigenschaften der natürlichen Zahlen mittels Induktion zu beweisen. Diesen Beweisen liegt zumeist das folgende Beweisprinzip zugrunde: Prinzip der mathematischen Induktion
Aus der Gültigkeit der beiden Aussagen (1)
P(0)
INDUKTIONSANFANG
(2)
V n G I N : P(n)
=> P(n + 1)
INDUKTIONSSCHRITT
folgt die Gültigkeit der Aussage P(n) fiir alle natürlichen Zahlen. Eine implizite Voraussetzung ist dabei, daß die Aussage P fiir alle natürlichen Zahlen definiert ist. Die Aussage P(n) bezeichnet man in Beweisen üblicherweise als Induktionsannahme. Ein allseits bekannter Induktionsbeweis ist der fiir die Formel: X"=i' = n ( n + l ) / 2 . Wir notieren im folgenden Induktionsregeln in einer Form, wie sie in Deduktionssystemen verwandt wird, das heißt als Folge von Prämissen über der Schlußfolgerung, getrennt durch eine horizontale Linie. Die mathematische Induktion schreiben wir beispielsweise als: P( 0)
/>(«) =•/»(« + 1) Pin)
Dabei sind, sofern nichts anderes gesagt wird, sämtliche Variablen jeweils in jeder Formel über IN allquantifiziert, das heißt, die obige Regel ist eine Kurzform für: P{0)
Vn £ IN : (P(n) => P(n+ 1)) Vn e IN : P(n)
Man kann das Induktionsprinzip nun sehr gut einsetzen, um die Korrektheit rekursiver Funktionsdefinitionen nachzuweisen.
Einschränkungen
Wichtige Anmerkung! Mit der Anwendung des Induktionsprinzips auf ML-Funktionen fassen wir diese als mathematische Objekte auf, genauer gesagt als Funktionen auf Mengen. Diese Sicht ist aber nicht für alle in ML definierbaren Funktionen gerechtfertigt. Wir müssen deshalb einige Dinge beachten: Zum einen fordern wir von ML-Funktionen, daß sie wohldefiniert sind, das heißt, sie müssen korrekt typisiert sein und immer terminieren.
63
3.1 Induktion
Denn ansonsten könnte man möglicherweise falsche Aussagen aus Definitionsgleichungen für solche Funktionen ableiten. Des weiteren interpretieren wir Typen als Mengen. Damit ergeben sich gewisse Einschränkungen für die zu betrachtenden Datentypen: Wir schließen beispielsweise Funktionstypen in Datentypen aus, da diese keine mengentheoretische Interpretation besitzen. Betrachten wir nun aber als einfaches Beispiel die Definition einer Potenzfunktion für reelle Zahlen (eingeschränkt auf positive Exponenten). f u n power ( x , 0 ) = 1 . 0 I power ( x , n ) = x * power
(x,n-l)
Wir wollen nun die Korrektheit dieser Definition beweisen, das heißt, wir wollen zeigen: Satz 3.1 (Korrektheit der Funktion power) V« G IN : Vx G IR #0 : power (x,n)
= x"
Beweis. 1. Beweise P(0), das heißt, zeige power (x,0) = x°. Die erste Funktionsgleichung besagt power (x,0) = 1.0. Andererseits gilt Vx G IR/° : x° = 1.0. Also ist der Induktionsanfang bewiesen. 2. Zeige den Induktionsschritt: P(n) =*• P(n + 1). power (x,n+ 1) = x * power ( x , n ) = x * jc" = x"+l Damit ist P(n) für alle n G IN bewiesen.
{power 2 } {Ind. Ann.} {xm-*n=xm+n} •
Dabei verweisen die in Klammern gesetzten Rechtfertigungen der Gleichungsumformungen wie z.B. {power 2 } immer auf die entsprechenden Gleichungen der Funktionsdefinitionen oder wie {x"1 • x" = xm+n} auf Definitionen oder Gesetzmäßigkeiten aus der Mathematik; {Ind.Ann.} steht für die Induktionsannahme. Dies war sicherlich ein relativ einfaches Beispiel, das indes verdeutlicht, wie sehr die Orientierung funktionaler Sprachen an mathematischen Konzepten den formalen Umgang mit Programmen erleichtert. Aber: Dem aufmerksamen Leser wird vielleicht schon aufgefallen sein, daß der obige „Beweis" nicht ganz einwandfrei ist, da wir implizite, unbewiesene
64
3 Verifikation und
Programm-Transformation
Annahmen über die Korrektheit von ML-Funktionen machen. So setzt beispielsweise der letzte Beweisschritt voraus, daß der ML-Ausdruck x * x" äquivalent ist zu dem mathematischen Ausdruck x x f 1 , was wiederum die RechenunKorrektheit der *-Funktion erfordert. Dies ist insofern problematisch, als daß genauigkeit m a n in einem Computer nur endliche Zahlen darstellen kann und auch nur mit endlicher Rechengenauigkeit Operationen auf beispielsweise den reellen Zahlen durchführen kann, was im allgemeinen zu Rundungsfehlern führt. Dies ist aber ein generelles Problem, das im Prinzip jede Programmiersprache und jedes Programm betrifft. Wir können also Aussagen nur über die darstellbaren Zahlen machen und dies auch nur „bis auf Rechenungenauigkeiten". Die obige Definition der Funktion power benötigt O(n) Rekursionsschritte. Wenn wir nun eine optimierte Definition vornehmen wollen, die die Anzahl der Rekursionschritte von 0(n) auf O(logn) reduziert, so stellt sich die Frage, ob diese Definition korrekt ist, und ob wir dies auch formal beweisen können. fun power' (x,0) = 1.0 I power' (x,n) = if n mod 2=0 then power' (x * x,n div 2) eise x * power' (x * x,n div 2) Die Korrektheit ist zwar einleuchtend, aber beweisen können wir dies mit dem obigen Induktionsprinzip leider nicht, da die rekursiven Aufrufe von p o w e r ' mit n d i v 2 und nicht mit n - 1 erfolgen. Es gibt aber ein Induktionsprinzip, das es erlaubt, „beliebig weit zurückliegende" Aussagen im Induktionsschritt zu benutzen. Prinzip der
vollständigen Induktion
Falls aus der Gültigkeit von P(k) für alle k € IN < n die Gültigkeit von p ( n ) f0igt) so folgt daraus die Gültigkeit der Aussage P(n) für alle natürlichen
Zahlen.
Als Deduktionsregel lautet die vollständige Induktion: (VA: € IN : k P(k)) =» P(n) Pin) Hierbei ist die Induktionsannahme die Aussage P(k) für alle k < n. Auf den ersten Blick erscheint die Induktionsregel möglicherweise verwirrend, da sie gar keinen Induktionsanfang zu enthalten scheint. In der Tat aber ist sie vollkommen äquivalent zum mathematischen Induktionsprinzip. Die Induktion verläuft so, daß man zunächst P(0) und anschließend P(n) unter
65
3.1 Induktion
den Annahmen P(0),... ,P(n — 1) zeigt. Da man den zweiten Schritt für ein beliebiges n durchfährt, hat man damit die Gültigkeit von P für alle natürlichen Zahlen bewiesen. Nun können wir die Korrektheit der Definition von power' wie folgt zeigen: Wir nehmen zunächst an, daß VRC G IN : k < n => (VJC G 1R / 0 : power»
(x,k)=xk)
gilt. Dann zeigen wir, daß daraus die Gültigkeit von \/x G
: power'
(x,n)=xT
folgt, und können damit die Gültigkeit für alle n schließen. Satz 3.2 (Korrektheit der Funktion power') V« G IN : Vx G IR # 0 : p o w e r ' (x,n) = x? Beweis. Wir unterscheiden drei Fälle: n= 0 Für x 6 gilt einerseits aufgrund der Funktionsdefinition power' Ct.O) = 1.0, andererseits gilt V*G I R / 0 : = 1.0. n ^ O A n mod 2 = 0 Hier ist die erste Alternative der zweiten Funktionsgleichung relevant. Es sei y G Dann gilt: power'
Oy,n) = power'
(y * y,n
d i v 2)
(3.1)
Da n gerade ist (und größer als Null), ist n d i v 2 = n/2 eine natürliche Zahl kleiner als n, und wir können somit die Induktionsannahme anwenden. Diese lautet mit n d i v 2 für k eingesetzt: V x e l R ^ 0 : power'
( x , n d i v 2) = xn/2
Daraus folgt dann insbesondere: power'
(y * y,n
d i v 2 ) = (y • y)n/2 = y"
Hieraus und aus der Gleichung (3.1) folgt dann der Satz.
66 n^O
3 Verifikation und
Programm-Transformation
An mod 2 ^ 0 In p o w e r ' wird jetzt die zweite Alternative der zweiten Funktionsgleichung verwandt. Es sei y £ Dann gilt: power'
(y,n) = y * p o w e r '
(y * y,n
d i v 2)
(3.2)
Da n ungerade ist, ist n d i v 2 = (n — l ) / 2 eine natürliche Zahl kleiner als n, und wir können erneut die Induktionsannahme anwenden. Diese lautet mit n d i v 2 für k eingesetzt: V x e l R ^ 0 : power'
(x,n
d i v 2) = j c ( n " 1 ) / 2
Daraus folgt dann insbesondere: power'
Oy * y,n
d i v 2) =
=
Setzt man dieses Ergebnis in die Gleichung (3.2) ein, so erhält man power'
(y,n)
=y
* y"~l
=yn
womit der Satz bewiesen ist.
•
3.1.2 Strukturelle Induktion auf Datentypen Die Gültigkeit der beiden Induktionsverfahren aus dem vorigen Abschnitt ergibt sich aus der induktiven Definition der natürlichen Zahlen: 1. 0 € IN 2. n G IN =>- (succ(n) G INAiwcc(n) ^ 0) 3. IN enthält keine weiteren Elemente. Das heißt, die natürlichen Zahlen kann man auch als freie 1 Termalgebra über der Konstanten 0 : IN und der Operation succ : IN —>• IN auffassen. Ebenso sind ML-Datentypen nichts anderes als freie Termalgebren - zumindest gilt dies für solche Datentypen, die keine Funktionstypen enthalten. Ein MLDatentyp gemäß der folgenden Form (alle x,j seien konstante Typen) d a t a t y p e ( o c i , . . . , a * ) type = con\ of Tu * ... * Xim, I ... I conn of T„i * ... * Tnm„ 1
Das bedeutet, daß alle Terme verschieden sind.
3.1
67
Induktion
definiert eine freie Algebra von Termen des Typs ( a i , . . . , a*) type, die über die Operationen com:
Tu * ... * Ti«,, -> (ai,...,0Cjfc) type
con„: T„i * ... * 1nm„ ~> ( a i , . . . , a ¿ )
type
konstruiert werden. Für jede solche Termalgebra stellt die strukturelle Induktion ein eigenes Induktionsprinzip zur Verfügung. Im folgenden sei x¡j vom Typ Tij für 1 < i < n, 1 < j < m¡. Dann lautet das Prinzip der strukturellen Induktion in allgemeiner Form: Falls aus der Gültigkeit von P für alle Argumente vom Typ t eines Konstruktors die Gültigkeit von P für den entsprechenden Konstruktorterm folgt, und dies für alle Konstruktoren des Typs T, so folgt daraus die Gültigkeit der Aussage Pfür alle Elemente des Typs T.
Prinzip der strukturellen Induktion
Als Deduktionsregel notieren wir dies: V/ < n : (Vj < m¡ : ( x = ( a i , . . . ,a*) type => P(x¡j))) VxS (ai ,...,ak)
P{conl (xn type : P(x)
,...,xm))
Für Listen ergibt sich daraus das folgende Induktionsprinzip: P([])
V / e ' a l i s t : (P(/)=»P(jc::Q) V/G ' a l i s t :/>(/)
Im folgenden nehmen wir an, daß freie Variablen in Induktionsregeln immer typkorrekt allquantifiziert sind, das heißt, wir können die obige Regel auch kürzer notieren: P(ü)
(P(l)=>P(x::l)) P{1)
(Die Quantifizierung der Schlußfolgerung ergibt sich aus Art der Induktion, hier: Listen). Mit der Listeninduktion kann man sehr viele Eigenschaften von Listenfunktionen verifizieren.
Strukturelle Induktion für Listen
68
3 Verifikation
und
Programm-Transformation
Als Beispiel wollen wir zeigen, wie sich das Spiegeln einer Liste (mit rev) bezüglich der Konkatenation von Listen ( xE A
(2) M,N 6 A => (MN) € A
(Applikation)
\-Terme
96
4 Der
(3) Me
AAxeV
(XxM)
e A
Lambda-Kalkül
(Abstraktion)
(4) Keine weiteren Terme sind in A enthalten
•
Wir werden im folgenden À-Terme auch nur kurz Terme oder Ausdrücke nennen. Wir verwenden Großbuchstaben wie M,N,P zur Bezeichnung von Tennen und Kleinbuchstaben x,y,z zur Bezeichnung von Variablen. Mit dem Symbol = drücken wir die syntaktische Gleichheit zwischen Termen aus. Als abkürzende Schreibweise für À-Terme haben sich die folgenden Konventionen bewährt: AJCI .,.xn.M
=
MNiN2...Nn
=
(Xxi(...(U,(Af))...)) (...{(MNi)N2)...Nn)
Das heißt, die Abstraktion ist rechtsassoziativ, und die Applikation ist linksassoziativ und hat eine höhere Präzedenz als die Abstraktion. X-Terme in ab- und ungekürzter Schreibweise sind: xx
=
yhc.y
=
Xxy.yx
=
7ucy.y(kx.x)x
=
(xx) (y(hcy)) (hc(Ky(yx))) (Xx(ky((yÇkxx))x)))
Aufgabe 4.1 Geben Sie eine Definition für die Subterm-Relation N C M an (das heißt, M enthält den Term N). Bindungssymbol
Das X nennt man auch Bindungssymbol. In einem Ausdruck Xx.M bindet es alle freien Vorkommen von x in M. Dies wird in der folgenden Definition formalisiert. Definition 4.2 (Freie Variablen)
freie
Variablen
Die Menge der freien Variablen eines Terms M wird mit FV(M) und ist induktiv wie folgt definiert: FV(x)
=
bezeichnet
{*}
FV(MN)
= FV(M)
U
FV(kx.N)
= FV(N)
- {x}
FV(N) •
Die Definition besagt, daß eine Variable in einem Term frei auftritt, sofern sie nicht durch ein X gebunden ist.
4.1 Die Syntax des
Lambda-Kalküls
97
Aufgabe 4.2 Geben Sie analog zur Definition 4.2 eine Definition für die Menge GV(M) der gebundenen Variablen eines Terms M an. Falls FV(M) = 0, so nennt man M einen geschlossenen Term. Ein geschlossener Term heißt auch Kombinator.
Kombinator
Beispiele: Im A,-Term Xx.xy ist x gebunden und y frei, im Term y(Xxy.z)x sind x,y und z frei, und im Term (Xx.x)x tritt x sowohl gebunden als auch frei auf. Xxy.x(yx) ist ein Beispiel für einen geschlossenen Term. Einige besonders wichtige Kombinatoren haben einen Namen, so z.B. die Identitätsfunktion und das Konstanten-Funktional: 1 = Xx.x K = Xxy.x Der Zusammenhang zwischen X-Termen und funktionalen Programmen ist nicht schwer zu erkennen: Applikationen entsprechen Funktionsanwendungen, und Abstraktionen entsprechen Funktionen, wobei die durch ein X gebundenen Variablen Parameter von Funktionen repräsentieren. (Konstanten gibt es in der hier vorgestellten Version des Lambda-Kalküls nicht, für sie kann man geeignete Darstellungen als X-Terme finden, siehe Abschnitt 4.3.)
Bedeutung von Abstraktion und Applikation
Die Auswertung einer Funktionsanwendung entspricht nun im Lambda- Auswertung Kalkül der Transformation einer Applikation (Xx.M)N in einen Term M', der sich aus der Substitution aller freien Vorkommen von xinM durch N ergibt. Substitution Wir notieren eine solche Substitution als [N/x]M und die Transformation des Terms M in den Term M' als M —>• M'. Es ergeben sich beispielsweise folgende Transformationen: (:Xx.xxy)N -> [N/x]xxy = NNy (Xx.y(Xx.x)xXy.x)N
->
= y(Xx.x)NXy.N
Die Substitutionsoperation werden wir weiter unten noch präzise definieren. Die Definition ist nicht ganz einfach, da sie verhindern muß, daß freie Variablen unzulässigerweise gebunden werden. Was dies bedeutet und warum dies zu Problemen bei der Auswertung von Applikationen fuhren kann, erläutern wir an einem kleinen Beispiel. Betrachten wir einmal den X,-Term Xxy.yx. Intuitiv beschreibt dieser eine Funktion, die ihre Argumente „vertauscht", das heißt, von der Transfor-
Bindung freier Variablen
4 Der Lambda-Kalkül
98
mation des Ausdrucks (Xxy.yx)MN erwartet man das Ergebnis NM. Was geschieht aber, wenn wir für den Term M die Variable y verwenden? Wir erinnern uns, daß (Xxy.yx)yN eine Abkürzung für ((Xx.(Xy.(yx)))y)N ist, und erhalten durch Substitution von >> für die äußere Variable x die folgende Transformation: ((Xx.{Xy.{yx)))y)N
->
= (Xy.yy)N
Eine erneute Transformation ergibt den Term NN. Dies ist aber leider nicht der Term, den wir erwartet haben (denn im allgemeinen ist N ^ y). Das Problem liegt darin, daß die freie Variable y nach der Ersetzung auf einmal gebunden ist. Um dies zu vermeiden, muß man gebundene Variablen im Substituenden umbenennen, also z. B. Xy in Xz. Dann erhalten wir alternativ: (Quc.(lz.(zx)))y)N
(\y/x](kz.(zpc)))N = (Xz.zy)N
Daraus resultiert dann das erwartete Ergebnis Ny. Das Beispiel verdeutlicht zweierlei: (i) Die allgemeine Definition der Substitution benötigt Variablenumbennenung, und (ii) falls in einem Term keine Variable sowohl gebunden als auch frei vorkommt, tritt das Problem der Bindung freier Variablen während der Substitution nicht auf. Wenn man das Beispiel verallgemeinert, so kommt man zu der Strategie, bei Auswertung einer Applikation (Xx.M)N sämtliche Abstraktionen innerhalb von M vor der Substitution von N für x geeignet umzubenennen. Das heißt, daß eine Abstraktion Xy.L zu Xz.Ll wird, wobei z eine neue Variable ist, die in N nicht frei vorkommt, und L' aus L mittels Ersetzung von y durch z entsteht. Dadurch wird gerade die Bindung freier Variablen aus N während der Substitution verhindert. Nun können wir die Substitutionsoperation exakt definieren. Definition 4.3 (Substitution) (1) [N/x]x = N (2) [N/x]y = y, f a l l s x ^ y (3) [N/x]{MxM2) = (4) [N/x](Xy.M) =
([N/x\Ml)([N/x]M2) Xz.([N/x}([z/y]M)), für z g {x} U FV{Xy.M) U FV(N)
•
4.2 Reduktion und Normalformen
99
In der vierten Regel erfolgt die notwendige Umbenennung gebundener Umbenennung Variablen. Dabei muß die neue Variable z drei Bedingungen erfüllen: (1) z ^ FV(N). Dies schließt gerade den im obigen Beispiel geschilderten Konfliktfall aus. (2) z ^ x. Anderenfalls würden freie Vorkommen der Variablen y in M zunächst durch jc und in einem zweiten Schritt dann fälschlicherweise durch N ersetzt. Beispiel: Man erwartet natürlich von der Substitution [7V/;t]A.y.;y das Ergebnis Xy.y, da ja die Substitution nur freie Variablen ersetzen soll. Wählt man aber z = x, so wird N auch für die gebundene Variable y eingesetzt, was natürlich nicht passieren darf: Ajc.([A7*]([jc/y]y)) = Ajc.([W/jc]jc) = Xx.N (3) z ^ FV(Xy.M). Träte z frei in M auf, so würden diese Vorkommen unzulässigerweise durch das äußere X gebunden (zumindest, wenn z ^ y). Beispiel: Die Substitution [N/x]Xy.a sollte Xy.a ergeben (a ist frei). Wählt man aber z = a, so ergibt sich Xa.([W/*]([a/y]a))
= ha.a
In diesem ^.-Term aber ist a gebunden. Man beachte, daß z durchaus y sein darf (sofern x^y und y 0 FV(N)). Die vierte Regel umfaßt die folgenden beiden Spezialfälle. [N/x](kx.M) = Xx.M [N/x](ky.M) = Xy.([N/x]M)
falls x^yAyg
FV{N)
Die erste Gleichung ergibt sich dabei aus der Tatsache, daß x in Xx.M nicht frei vorkommen kann (da ja Xx alle freien Vorkommen in M bindet).
4.2 Reduktion und Normalformen Mit Hilfe der Substitutionsoperation können wir die Transformationsregeln des Lambda-Kalküls definieren. Wir führen zunächst einige allgemeine Begriffe ein, mit denen wir dann die konkreten Transformationen definieren. Definition 4.4 (A-kompatible Hülle, p-Reduktion, p-Aquivalenz) Sei p eine binäre Relation auf A, das heißt, p C A x A. (i) p induziert die A-kompatible Hülle —>p, die wie folgt definiert ist
A-kompatible Hülle
4 Der Lambda-Kalkül
100 (1) (M,N) e p (2) M —»p N (3) M —N
M —>p N MP —>p NP
=> PM —>p PN
(4) M —>p N => Xx.M ->p Xx.N p-Reduktion (ii) Die p-Reduktion —»p ist definiert als die reflexive und transitive Hülle von — d a s heißt, (1) M ->p N =• M -»p N (2) M —»p M (3) M -»p N,N —»p P => M
—P
p-Äquivalenz (iii) Die p-Äquivalenz = p ist die von —»p erzeugte Äquivalenz-Relation, das heißt, (1)
M -»p
N => M =p
(2)
M =p
(3)
M = p N , N =
N
N =p p
N M
•
P ^ M = p P
Für die drei Relationen gilt die folgende Sprechweise: M->pN M-^pN M=pN
M wird in einem Schritt zu N reduziert M wird zu N reduziert M und N sind p-konvertibel
Insbesondere nennt man — e i n e Reduktionsrelation auf A. Mit diesen Begriffen können wir nun Reduktions- und Äquivalenzrelationen auf A einfach durch Angabe einer binären Relation auf A erklären, das heißt, durch Angabe einer Definition für die Relation a oder ß werden automatisch die Relationen = a , —>ß, —»ß usw. induziert. Die a-Reduktion beschreibt die Umbenennung gebundener Variablen. Definition 4.5 (a-Reduktion) a-Reduktion
a-Konvertibilität
Es sei a = {(Xx.M, Xy.\y/x]M) \ M,N £ A,y £ FV(M)}. — u n d = a ergeben sich aus der Definition 4.4.
Die Relationen
r
0l> •
Anschaulich besagt die a-Konvertibilität, daß die Wahl des Namens eines Funktionsparameters nicht relevant ist. In der Tat verhalten sich z.B. die
4.2 Reduktion und
Normalformen
101
beiden Terme Xx.x und Xy.y identisch bezüglich der Auswertung im LambdaKalkül. Weitere Beispiele sind: Xx.xy =a Xz.zy ±a
ty.yy
Xx.x(Xx.x) =a
=a Xy.y(Xz.z)
Da also die konkreten Namen gebundener Variablen für die Struktur und das Verhalten von X-Termen unerheblich sind, betrachten wir im folgenden A modulo = a , das heißt, wir sehen a-konvertible Terme als syntaktisch identisch an. Wir erlauben also, Xx.x = Xy.y usw. zu schreiben. Die ß-Reduktion stellt die eigentliche „Rechenregel" des Lambda-Kalküls dar, sie formalisiert das Auswerten von Applikationen. Definition 4.6 (ß-Reduktion) Es sei ß = {(Xx.M)N,[N/x]M) \ M,N G A}. Die Relationen ->ß, - » ß und ß-Reduktion =ß ergeben sich aus der Definition 4.4. • Aus Teil (i) der Definition 4.4 wird deutlich, daß die Reduktion eines Terms M durchaus auch in seinem Inneren erfolgen kann. Dies bedeutet insbesondere, daß es für einen Term u. U. mehrere Möglichkeiten zur Reduktion gibt. Man kann z. B. im Term (Xx.(ky.x)N)M sowohl für die äußere als auch für die innere Abstraktion eine ß-Reduktion durchführen: (Xx.(Xy.x)N)M
—(Xy.M)N
P (:Xx.x)M Führt man bei den resultierenden Termen eine weitere Reduktion durch, so erhält man in beiden Fällen den Term M. {.Xx.{Xy.x)N)M P (Xx.x)M
—(Xy.M)N P »M
Ist dies ein Zufall? Nein, eine ganz wichtige Eigenschaft des LambdaKalküls ist, daß die Wahl der Reihenfolge von Reduktionsschritten keinen
Verschiedene Möglichkeiten zui Reduktion
102
4 Der Lambda-Kalkül
Einfluß auf das Ergebnis hat, sofern - und dies ist eine wesentliche Voraussetzung - jede Reduktionsreihenfolge zu einem Ergebnis führt. Dabei betrachten wir als Ergebnisse oder Werte die sogenannten Normalformen von Termen. Definition 4.7 (ß-Redex, ß-Normalform) Redex, Ein ß-Redex 1 ist ein /\.-Term der Form (Xx.M)N, und [N/x]M ist sein Normal form Kontraktion. Ein X-Term M ist in fi-Normalform, wenn er keinen ß-Redex als Subterm besitzt. Ein A.-Term M hat eine ß-Normalform, wenn für ein beliebiges N gilt: M = ß N und N ist in ß-Normalform. • Im folgenden meinen wir, sofern nichts anderes gesagt wird, mit „Normalform" stets die ß-Normalform. Beispiele: DerTerm M = (Ajt.(A,;y.x)a)z hat zwei ß-Redexe, nämlich M selbst sowie den Subterm (Xy.x)a. M ist somit nicht in Normalform, hat jedoch eine Normalform, und zwar z. Eine elementare Eigenschaft von Termen in Normalform ist: Lemma 4.1 Falls M in ß-Normalform ist, so gilt: M —»ß N => M = N Beweis. Da M in ß-Normalform ist, gibt es kein N mit M ->ß N. Daher kann M —»ß N nur aufgrund der Reflexivität und Transitivität gelten. • Termeohne Nicht alle Terme haben eine Normalform, wie z.B. der Term AA mit A = Normalform Xx.xx, denn AA — A A . Das heißt, AA hat einen Redex, ist also nicht in Normalform, die ß-Reduktion aber kann den Redex nicht eliminieren, so daß man keine Normalform „erreichen" kann. Mit anderen Worten, Terme wie AA, die keine Normalform besitzen, entsprechen nicht-terminierenden Programmen. Ein fundamentale Eigenschaft des Lambda-Kalküls ist im folgenden Satz festgehalten: Satz 4.1 (Church-Rosser Theorem) Church-Rosser Falls M —»ß N und M - » ß N', so gibt es einen Term P mit: N —»ß P und Theorem N' f>. • Wenn man also einen Term M auf zwei verschiedene2 Terme N und N' reduzieren kann, so existiert immer auch ein Term P, auf den die beiden 1 2
Die Bezeichnung ist eine Kurzform für reducible expression. Für N = N' gilt der Satz trivialerweise aufgrund der Reflexivität von - » p .
4.2 Reduktion und Normalformen
103
Terme mittels ß-Reduktion zurückgeführt werden können. Diese Situation ist in Abbildung 4.1 dargestellt.
N
N'
Abbildung 4.1: Church-Rosser Eigenschaft
Als direkte Folgerung aus Satz 4.1 ergibt sich: Folgerung 4.1 Falls M =ß N, so gibt es einen Term P mit: M -»-ß P und N —»ß P. Beweis. Wir führen eine Induktion durch über die Ableitung von M =ß N. Falls M =ß JV aufgrund von M —»ß N, so erfüllt P = N die Folgerung. Falls M N aufgrund von N =ß M, so gibt es nach Induktionsannahme ein P' mit N —»ß P' und M - ^ ß P'. Dann erfüllt P = P' die Folgerung. Falls nun M =ßN aufgrund von Transitivität gilt, das heißt M =ß N' und N' =ß N, so gibt es nach Induktionsannahme zwei Terme P' und P" mit M -^ß P' und N' -»ß P' sowie N' -»ß P" und N P". Das Church-Rosser Theorem angewandt auf N', P' und P" ergibt einen Term P, der die Folgerung erfüllt, siehe Abbildung 4.2. •
P Abbildung 4.2: ß-Konvertibilität impliziert gemeinsame Normalform
Die Folgerung besagt, daß ß-konvertible Terme eine gemeinsame Normalform besitzen. Da wir als einzige Transformationsregel die ß-Reduktion betrachten, schreiben wir manchmal auch kurz M = N anstelle von M =ß N. Als weitere Folgerung erhält man schließlich:
104
4 Der Lambda-Kalkül
Folgerung 4.2 (1) Falls M eine ß-Normalform N hat, gilt M —»ß N. (2) Ein Ä.-Term hat höchstens eine ß-Normalform. Beweis. (1) Wir nehmen an, daß M =ß N, wobei N in ß-Normalform ist. Aufgrund der Folgerung 4.1 gibt es ein P mit M —»ß P und N —»ß P. Da N in Normalform ist, wissen wir aber von Lemma 4.1, daß N = P und somit, daß M -»ß N. (2) Angenommen, M hat zwei Normalformen N und N'. Dann gilt M =ß N und M =ß N'. Aufgrund der Symmetrie und Transitivität von =ß gilt damit ebenfalls: N =ß N'. Gemäß Folgerung 4.1 gibt es ein P mit N —»ß P und N' ~»ß P. Dies aber bedeutet aufgrund von Lemma 4.1, daß N = P = N'. • Anschaulich besagt das letzte Ergebnis, daß die ß-Reduktion die Auswertung von Funktionen korrekt modelliert, das heißt, wenn eine Berechnung terminiert und ein Ergebnis liefert, so ist dieses Ergebnis eindeutig. Wir hatten bereits im Beispiel auf Seite 101 gesehen, daß ein Term durchaus mehrere Redexe enthalten kann und daß man ß-Reduktionen an entsprechend verschiedenen Stellen durchführen kann. Für das Auswerten von Termen stellt sich nun mit jedem Reduktionsschritt die Frage, welchen Redex man reduzieren soll. Die Folgerung 4.2 besagt zwar, daß man mittels ß-Reduktion immer die Normalform eines Terms erreichen kann (sofern sie existiert), es ist aber keineswegs so, daß jede beliebige Folge von Reduktionsschritten zur Normalform führt. Mit anderen Worten, es gibt Terme, für die eine bestimmte Folge von Reduktionsschritten zu einer Normalform führt, während eine andere Reduktionsreihenfolge nicht terminiert. Beispiel: Der Term M = (Xjt/y) (AA) enthält zwei Redexe, nämlich M selbst und den Term AA. Reduziert man M, so erhält man unmittelbar den Term y als Ergebnis, eine Normalform von M. Eine wiederholte Reduktion innerhalb des Terms AA führt, wie wir bereits gesehen haben, nicht zu einer Normalform; die Reduktion terminiert nicht. ReduktionsStrategie
Eine Reduktionsstrategie wählt für einen Term aus verschiedenen möglichen Redexen einen bestimmten 1 aus und bestimmt somit die Reihenfolge der Reduktionsschritte. Dazu müssen wir über die Positionen von Redexen in Termen sprechen können: Definition 4.8 (Redexpositionen)
Redexpositionen
Der linke Redex eines Terms M ist der Redex, dessen X (textuell) links von allen anderen Redexen in M steht. Ein äußerer Redex eines Terms ist ein 1
Es gibt auch Reduktionsstrategien, die mehrere Redexe zur gleichzeitigen, parallelen Reduktion auswählen. Diese lassen wir hier allerdings außer acht.
4.2 Reduktion und Normalformen
105
Redex, der nicht Subterm eines anderen Redexes ist. Ein innerer Redex eines Terms ist ein Redex, der keinen anderen Redex enthält. • Aufgabe 4.3 Begründen oder widerlegen Sie die folgende Aussage: N ist linker Redex von M =>• N ist ein äußerer Redex von M. Die beiden wichtigsten Reduktionsstrategien sind normal order, mit der jeweils immer der linke Redex reduziert wird (diese Strategie nennt man deshalb oft auch leftmost outermost) und applicative order, mit der jeweils immer der am weitesten links stehende aller innersten Redexe reduziert wird (diese Strategie nennt man deshalb oft auch leftmost innermost).
normal order applicative order
Wie wir im letzten Beispiel gesehen haben, kann die Reduktion gemäß der applicative order Strategie zu Nichtterminierung führen, selbst dann, wenn der zu reduzierende Term eine Normalform besitzt. In dem gleichen Beispiel führt dagegen die normal order Strategie zur Normalform. Es gilt der folgende Satz. Satz 4.2 Falls ein A,-Term eine Normalform hat, so wird diese durch die normal order Strategie berechnet. • Diese Aussage gilt nicht für applicative order. Dies bedeutet, daß normal order häufiger zu einem Ergebnis kommt als applicative order. Dennoch basiert der Auswertungsmechanismus vieler funktionaler Sprachen, wie z. B. auch ML oder Lisp, auf der applicative order Strategie. Der Grund liegt darin, daß Reduktion gemäß applicative order in vielen Fällen effizienter abläuft als normal order. Beispiel: Es sei M ein komplexer Term mit Normalform N, das heißt, die Reduktion M —»ß N benötigt sehr viele Schritte (z. B. n). Wenn man nun den Term (Xx.xxx)M in applicative order auswertet, so benötigt man n + 1 Reduktionen um die Normalform NNN zu erhalten, denn zunächst wird ja M zu N ausgewertet, bevor anschließend der Redex {"kx.xxx)N eliminiert wird. Berechnet man dagegen gemäß normal order, so wird zunächst (Xx.xxx)M zu MMM reduziert, und anschließend muß M dreimal zu N reduziert werden. Der Aufwand beträgt also 3n + 1 Reduktionen. Es gibt aber andererseits auch Situationen, in denen man mit normal order schneller zum Ergebnis kommt als mit applicative order. Wenn man z. B. den Term (Xx.y)M gemäß applicative order auswertet, so benötigt man wie im vorigen Fall n + 1 Reduktionen, hingegen kommt man mit normal order
Effizienz vergleich normal order vs. applicative order
106
4 Der Lambda-Kalkül
in nur einem Schritt auf die Normalform y. Im allgemeinen ist das Effizienzverhalten bei applicative order besser vorhersagbar als bei normal order. lazy evaluation
eager evaluation
Mit der sogenannten lazy evaluation (siehe auch Abschnitt 2.1) versucht man nun, die Vorteile von normal order zu nutzen, nämlich nicht benötigte Terme auch nicht zu berechnen, und gleichzeitig deren Nachteile zu vermeiden, das heißt die wiederholte Berechnung ein- und desselben Terms. Man beachte, daß sowohl lazy evaluation als auch die in ML verwandte eager evaluation sich von normal order bzw. applicative order dadurch unterscheiden, daß sie Terme nicht auf ß-Normalform reduzieren, sondern lediglich auf die sogenannte weak head normal form bzw. weak normal form. Definition 4.9 (Weak (head) normal form)
weak head normal form
Ein Term ist in weak head normal form genau dann, wenn er die folgende Form hat: Xy.M
weak normal form
oder
xM\M2...Mk
(k > 0)
Man sagt, ein Term M in weak head normal form hat keinen top-level Redex. Ein Term ist in weak normal form genau dann, wenn er in weak head normal form ist, und wenn zusätzlich für alle 1 < i < k gilt: Ml ist in weak normal form. • Ein Term in weak normal form enthält also keinen Redex außerhalb einer Abstraktion, während für weak head normal form lediglich gefordert wird, daß keine Reduktionen auf der äußersten Termebene möglich sind. Die Bedeutung der weak normal form wird deutlich, wenn man sich klarmacht, daß das Reduzieren von Redexen innerhalb von Abstraktionen dem Ausweiten von nicht-applizierten Funktionsrümpfen entspricht. (Solche Auswertungen kommen Optimierungen von Funktionsdefinitionen gleich, die in vielen Fällen nur einmal vor der eigentlichen Auswertung von Ausdrücken passieren.) Da eine Funktion in den meisten Sprachen keine für den Benutzer lesbare Darstellung besitzt, benötigt man deren Rumpf aber erst dann, wenn sie auf ein Argument appliziert wird, was bedeutet, daß man den Rumpf auch erst dann auswerten muß. Darüber hinaus werden in lazy Sprachen Terme nur bis zur weak head normal form ausgeweitet, also nur soweit, daß das Ergebnis auf der äußersten Termebene feststeht. Das heißt, Argumente von Term-Konstruktoren werden nur dann ausgewertet, wenn sie zur weiteren Berechnung (oder zur Ausgabe) benötigt werden. Jeder Term in weak normal form ist also auch in weak head normal form, aber nicht umgekehrt.
4.3 Repräsentation von Datentypen
107
4.3 Repräsentation von Datentypen Elemente von Datentypen wie den natürlichen Zahlen oder den Wahrheitswerten werden durch geschlossene ?i-Terme in Normalform dargestellt. Die Operationen der Datentypen sind dann durch À-Terme gegeben, die diese Normalformen manipulieren. Wir beginnen mit der Darstellung von Wahrheitswerten, da wir diese in der Definition von Funktionen auf natürlichen Zahlen benötigen. Wir brauchen zwei Kombinatoren T und F zur Darstellung von t r u e und f a l s e sowie eine Fallunterscheidungsoperation If. Die Repräsentation als À-Terme ist vollkommen willkürlich, sofern die beiden Gleichungen gelten: If T MN
=
\fFMN
= N
M
Eine mögliche Codierung ist die folgende. Definition 4.10 (Codierung von Wahrheitswerten) T = Xxy.x
X-Terme für
F = Xxy y
Wahrheitswerte
If = Xpxy.pxy
•
Man kann leicht nachprüfen, daß die Definitionen die obigen Gleichungen erfüllen. Für die erste Gleichung erhalten wir: If T A / N
E E (Xpxy.pxy)!
MN
(kxy.Txy)MN ->ß
(ky.JMy)N TMN
= ->ß
(kxy.x)MN (ky.M)N
->p M Damit gilt If T M N —»ß M und somit auch If T M N =ß M oder kurz, If T M N = M. Die zweite Gleichung wird analog nachgeprüft. Aufgabe 4.4 Zeigen Sie, daß mit der Definition If = Xx.x ebenfalls eine gültige Codierung der Wahrheitswerte erreicht wird.
108 X-Terme für natürliche Zahlen
4 Der
Lambda-Kalkül
Nun widmen wir uns der Darstellung natürlicher Zahlen. Auch hierzu gibt e s durchaus verschiedene Möglichkeiten; wir verwenden im folgenden die Repräsentation von Church, in der die Zahl n durch n-malige Applikation einer Funktion auf ein Argument dargestellt wird. Zur Definition benötigen wir eine Exponentenschreibweise für X-Terme (es sei n eine natürliche Zahl oder Null): M°{N) n+X
M
{N)
= =
N M(Mn{N))
Nun bezeichnen wir mit [n] die Repräsentation der Zahl n als X-Term. Damit können wir dann die folgende Definition geben. Definition 4.11 (Church-Numerale) Church-Numerale
\ri\ = Xfx.f"
•
(x)
Die Zahl 0 wird also durch den Term Xfx.x dargestellt, und die Zahl 2 wird durch den Term X f x . f ( f x ) repräsentiert. Auf der Basis dieser Repräsentation definieren wir nun die Funktionen Nachfolger, Vorgänger1 und Nulltest. Definition 4.12 (Operationen auf natürlichen Zahlen) Operationen auf Church-Numeralen
Suc = X n f x . f ( n f x )
Pred
= Xnfx.n{'kyz.z(yf)){
Zero = Ä.«.n(Ajt.F)T
Kx)\
•
Im folgenden betrachten wir vorzugsweise die Terme Suc und Zero. Anschaulich funktioniert z. B. Suc wie folgt. Wird Suc auf die Repräsentation einer Zahl n angewandt, so wird der Term \n\ im Rumpf von Suc auf die Argumente / und x appliziert. Das Ergebnis ist der Term \n] selbst. Auf diesen wird nun noch ein zusätzliches / angewandt, so daß zusammen mit der Eliminierung der äußeren ^-Abstraktion die Repräsentation des Nachfolgers von n entsteht. Um ein Gespür für die Wirkungsweise dieser Definitionen zu bekommen, sollte man zunächst die folgende Aufgabe lösen: Aufgabe 4.5 Zeigen Sie: 1
Es gilt: Pred |"01 = [0].
4.4 Rekursion
109
Sue [n] = \n + 1] Zero [Ol = T Zero (Sue f/i]) = F Die Church-Numerale erlauben sehr kompakte Definitionen weiterer arithmetischer Funktionen wie z. B. Addition, Multiplikation, Potenzierung und Subtraktion. (Man beachte, daß die Subtraktion nur für nicht-negative Differenzen funktioniert; negative Differenzen werden auf 0 „aufgerundet".) Add Mult Exp Sub
= = = =
Xnmfx.nf(m fx) Xnmf,n(mf) Xnm fx.mnfx Xnm.mPredn
Die Funktionsweise der Terme wird deutlich, wenn man bedenkt, daß das jeweilige Church-Numeral \ri\ ein Funktional ist, das eine Funktion «-mal appliziert. Dann sieht man leicht, daß \m~\fx = fm(x) und \n]f(\m\fx) = fn(fm{x)) ist. Damit wird dann klar, daß Add \n] \m}fx = f(fm(x))=r+m(x)
= \n + m\fx
gilt. Die anderen Funktionen kann man sich analog verdeutlichen.
4.4
Rekursion
Im vorangegangenen Abschnitt sind wir ausschließlich nicht-rekursiven Funktionsdefinitionen begegnet. Viele Funktionen aber kann man nur unter Zuhilfenahme von Rekursion (oder Iteration) beschreiben. In einer Programmiersprache werden rekursive Funktionsdefinitionen durch die Verwendung von Namen für Funktionen möglich, wenn man auf den Namen einer zu definierenden Funktion aus deren Rumpf Bezug nimmt. Im Lambda-Kalkül dagegen gibt es keine Namen für Funktionen.1 Man benötigt daher eine andere Methode, um Rekursion zu formulieren. Dazu stellt man eine rekursiv definierte Funktion / über eine Funktion / ' dar, die einen zusätzlichen Parameter hat, an den man dann / ' selbst bindet. Warum? Damit die Definition von f in ihrem eigenen Rumpf verfügbar ist. Wir wollen dies am Beispiel 1
Man beachte, daß Namen für beispielsweise Kombinatoren wie I oder Sue extern gewählte Abkürzungen sind, die die Notation mancher Terme erleichtern; im Lambda-Kalkül selbst gibt es keinen Namensmechanismus.
4 Der Lambda-Kalkül
110
der Fakultätsfunktion verdeutlichen. In ML lautet die Funktionsdefinition (für / ) beispielsweise: f u n fak n = i f n=0 then 1 e i s e n*fak (n-1) Im Lambda-Kalkül erhält man für die um einen Parameter g erweiterte Funktion / ' dann den folgenden Term: /' =
Y-Kombir>ator
(Zero n) [1] (Multn(g(Pred n)))
Nun muß man noch die Variable g an den gesamten Funktionsausdruck / ' binden. Dies kann man durch Applikation des sogenannten Y-Kombinators auf / ' erreichen. Der Y-Kombinator, den wir weiter unten noch definieren werden, hat nämlich die folgende Eigenschaft: YM = M(YM) Das heißt, wenn / = Y / ' die gesuchte Fakultätsfunktion bezeichnet, dann wird aufgrund der obigen Gleichung / an das erste Argument von f gebunden, denn / = Y / ' = /'(Y
f') =
f'f
Der Effekt ist, daß die Definition von / über den Parameter g im Term der Definition selbst zur Verfügung steht. Wir erhalten also den folgenden >.-Term für die Fakultätsfunktion: Fak = Y (Xgn.lf (Zeron) [1] (Multn(g(Pred n)))) Um zu veranschaulichen, daß dieser Term tatsächlich die Fakultätsfunktion realisiert, führen wir in Abbildung 4.3 die Reduktion zur Berechnung der Fakultät von 1 durch. Dabei geben wir für die Berechnung von Kombinatoren wie If, Zero oder Mult jeweils immer in nur eine Zeile an. Zur einfacheren Identifikation unterstreichen wir den jeweils in der folgenden Zeile ersetzten Redex (mit Ausnahme von top-level Redexen). Man sieht also, wie der Y-Kombinator die Entfaltung rekursiver Definitionen ermöglicht. Was nun noch fehlt, ist eine Definition des Y-Kombinators. Die Existenz eines Y-Kombinators geht aus dem äußerst wichtigen Fixpunktsatz hervor. Satz 4.3 (Fixpunktsatz) Fixpunktsatz
Zu jedem ^-Term M gibt es einen Term X, so daß gilt: MX = X, das heißt, X ist ein Fixpunkt von M. Es gilt: X = YM mit Y = X f . { X x . f ( x x ) ) ( X x . f ( x x ) ) .
4.4
Rekursion
111
Fakfl]
=
Y/Til
=y f ( Y f m =
(Xgn.If ( Z e r o « ) [ 11 ( M u l t n ( g ( P r e d n ) ) ) ) ( Y / ' ) | " 1]
->ß (Xn.\f (Zeron) |"ll ( M u l t n ( ( Y / ' ) ( P r e d n ) ) ) ) [1] - + p If ( Z e r o |"1~|) |"l~|(Mult [1"| ( ( Y / ' ) ( P r e d f l ] ) ) )
^ p lfFril(Multril((Y/')(Predm))) - » 3 Mult |"1] ( ( Y / ' ) ( P r e d |"1])) = Y Mult \ 1] ( / ' ( Y / ' ) ( P r e d =
1]))
Mult [1] ((Xgft.lf (Zerow) | " l ] ( M u l t n ( g ( P r e d n ) ) ) ) ( Y / ' ) ( P r e d |"1])) Mult [l]((X,n.lf ( Z e r o n ) | " l ] ( M u l t n ( ( Y / ' ) ( P r e d n ) ) ) ) ( P r e d f 1~|))
- > ß Mult
(If ( Z e r o ( P r e d | " l ] ) ) [ l l ( M u l t ( P r e d | " l ] ) ( ( Y / ' ) ( P r e d ( P r e d |~1])))))
- » p Mult p i ( I f ( Z e r o [ 0 j ) [ 1 ] ( M u l t ( P r e d | " l ] ) ( ( Y / ' ) ( P r e d (Pred m ) ) ) ) ) - » p Mult |"l~| (lf T |"1~| (Mult ( P r e d r i ] ) ( ( Y / ' ) ( P r e d (Pred 1"!])))))
- » ß Muit m m -*p m Abbildung 4.3: Berechnung der Fakultätsfunktion
Beweis. Einerseits gilt für YM: YM=
Xf.{ljc.f{xx)){"kx.f{xx))M (kc.M{xx))(Xx.M{xx))
->ß
M((Xx.M(xx))(Xx.M(xx)))
Andererseits kann man A/(YM) wie folgt reduzieren: M(YM)
=
M(Xf.(Xx.f(xx))(Xx.f(xx))M)
->ß
M((Xx.M(xx))(Xx.M(xx)))
Es gilt also für einen beliebigen Term M: X = YM =ß M(Y M) = MX.
•
Der Y-Kombinator stellt nicht die einzig mögliche Definition für einen Fixpunkt-Kombinator dar. Von Alan Turing stammt der 0-Kombinator: 0 = (ta^.;y(xxy))(tay.;y(xry)) Für diesen gilt insbesondere: QM -»ß
M(QM)
4 Der Lambda-Kalkül
112
Man beachte, daß dies nicht für den Y-Kombinator gilt; Y M und M(YM) sind lediglich ß-äquivalent.
4.5 Ausdrucksstärke des Lambda-Kalküls In diesem Abschnitt zeigen wir kurz, daß der Lambda-Kalkül gerade die rekursiven Funktionen beschreibt. Eine detaillierte Beweisführung fällt eher in den Rahmen der Berechenbarkeitstheorie. numerische Funktion X-definierbar
Zunächst benötigen wir einige Begriffe: Eine numerische Funktion ist eine Abbildung / : lNfc —• IN für beliebiges k 6 IN.1 Eine numerische Funktion / mit k Argumenten ist X-deñnierbar, wenn es einen Kombinator M gibt, so daß für alle ti\,..., 6 IN gilt: M\n{\...
\tik \ =
Man sagt dann auch, daß / durch M X-definiert ist. Anfangsfunktionen
Die numerischen Anfangsfunktionen Projektion U f , Nachfolger S und Null Z sind definiert als2 Ul{ñ¡¿) = n¡ für 1 < i < k S(n) = n+ 1 Z(n) = 0 Schließlich bezeichnet für eine Relation P(m) der Ausdruck fim[P(m)] die kleinste Zahl m, so daß P{m) gilt. Im folgenden sei C eine Klasse von numerischen Funktionen, und es gelte g,h,h\,...,hm G C.
Komposition
C ist abgeschlossen unter Komposition, wenn für jede Funktion / , die über /(«¡O
=g(hi(ñk),.--,hm(ñ¡;))
definiert ist, gilt: / € C. primitive C ist abgeschlossen unter primitiver Rekursion, wenn für jede Funktion / , die über
Rekursion
/(0,ñt)=*(ñt) f(j+
=
Kf{íM)J-,ñk)
1
Wir nehmen an, daß 0 6 IN.
2
Im folgenden notieren wir ti\,...,rik auch kurz als n^.
4.5 Ausdrucksstärke des Lambda-Kalküls
113
definiert ist, gilt: / € C. C ist abgeschlossen unter unbeschränkter Minimaüsierung, wenn für jede Funktion / , die über
unbeschränkte Minimalisierung
f{njl) = fjm[g(nj^, m) = 0] definiert ist (wobei es für alle n i , . . . e i n m mit /ec.
= 0 gibt), gilt:
Nun ist die Klasse % der rekursiven Funktionen definiert als die kleinste Klasse numerischer Funktionen, die alle Anfangsfunktionen enthält und abgeschlossen ist unter Komposition, primitiver Rekursion und unbeschränkter Minimalisierung. Der Beweis, daß alle rekursiven Funktionen ^.-definierbar sind, folgt der induktiven Definition der Klasse Lemma 4.2 Die Anfangsfunktionen sind X-definierbar. Beweis. Man nehme als Definitionen die Terme Uf == Ajc] ... Xfc.Xi S = Suc
•
Z = Xfx.x Lemma 4.3
Die ?i-definierbaren Funktionen sind abgeschlossen unter primitiver Rekursion. Beweis. / sei definiert über / ( 0 , / ü ) =g(nk) /(;'+ 1,«*) = h(f(j,r%),j,n*), wobei g und h durch G bzw. H ^-definierte Funktionen sind. Intuitiv kann man f ( j , n k ) berechnen, indem man überprüft, ob j = 0 ist, und wenn ja, ansonsten h(f(j— 1, n*), j — 1,«*) berechnet. Ein Term für eine solche Funktion existiert aufgrund von Satz 4.3, und durch Anwendung des Fixpunkt-Kombinators erhält man dafür: M = Y ( X / ^ . l f (Zero*)(^)(H(/(Pred*)yk)(Pred*)yjt))
•
Klasse der rekursiven Funktionen
114
4 Der Lambda-Kalkül
Lemma 4.4 Die Ä,-definierbaren Funktionen sind abgeschlossen unter unbeschränkterer Minimalisierung. Beweis. / sei definiert als /(«£) = frni[g(n^,m) = 0] wobei g eine durch G X-definierte Funktion ist. Intuitiv kann man berechnen, indem man bei 0 beginnend für m überprüft, ob g(tik,m) = 0 ist, und wenn ja, m ausgibt, oder ansonsten die Überprüfung mit m + 1 fortsetzt. Ein Term für eine solche Funktion existiert aufgrund von Satz 4.3, und durch Anwendung des Fixpunkt-Kombinators erhält man zunächst: N = Y (XfTky. If (Zero
(Grky))y(fTk(Socy)))
Durch den Term M = Xxj^.Nx^lO] wird nun die Funktion / ^-definiert, denn es gilt:1
A/[S*1 =
Mint1
roi
rol falls G\nj^1|"0] =
M\rTk1[0]
sonst
m
M\nÜ\\2\
falls G\ni\ [ 1 ] = sonst
PI
falls G\ni] \2] =
M
[3i
roi roi roi
sonst
•
Aus den Lemmata 4.2 bis 4.4 folgt nun der Satz: Satz 4.4 Alle rekursiven Funktionen sind X-definierbar.
•
Die umgekehrte Richtung des Satzes gilt ebenfalls, so daß wir nun wissen: Eine Funktion ist genau dann rekursiv, wenn sie X-definierbar ist. Der Begriff der X-Definierbarkeit läßt sich auch auf partiell-rekursive Funktionen übertragen. Es gilt dann analog: Eine Funktion ist genau dann partiell rekursiv, wenn sie X-definierbar ist. 1
\nk] steht für \n\],..., [n*].
4.6
Literaturhinweise
115
Der Lambda-Kalkül bietet also ein universelles Berechnungsmodell an, das in seiner Ausdrucksstärke den rekursiven Funktionen und auch den TuringMaschinen gleichkommt. Die Tatsache, daß die Begriffe A,-Definierbarkeit, rekursive Funktion und Turing-Berechenbarkeit allesamt äquivalent sind, kann man als ein starkes Indiz für die Gültigkeit der These Churchs werten, daß die intuitiv berechenbaren Funktionen gerade die rekursiven Funktionen sind. Mit anderen Worten, rekursive Funktionen oder auch der Lambda-Kalkül sind angemessene Formalismen, um die berechenbaren Funktionen zu beschreiben.
4.6
Literaturhinweise
Der Lambda-Kalkül wurde von Alonzo Church entwickelt, um die Eigenschaften berechenbarer Funktionen zu studieren (Church 1936; Church 1941). Die wohl ausführlichste Abhandlung über den Lambda-Kalkül gibt Barendregt (1981). Dort sind auch viele weitere Referenzen auf Originalliteratur zusammengetragen. Eine zusammengefaßte Darstellung, in der auch der Bezug zur funktionalen Programmierung herausgestellt wird, findet man in (Barendregt 1990); an dieser orientiert sich auch der Abschnitt 4.5. In diesem Zusammenhang sei erwähnt, daß die Äquivalenz von Lambda-Kalkül und Turing-Maschinen bereits 1937 von Alan Turing gezeigt wurde (Turing 1937). Neben der hier vorgestellten Version des Lambda-Kalküls gibt es eine ganze Reihe von anderen Definitionen. Dabei ist eine sehr häufig anzutreffende Variante der typisierte Lambda-Kalkül. Eine elementare Einführung bietet beispielsweise das Buch (Hindley & Seidin 1986). Ebenfalls ausführliche Beschreibungen sowie Anwendungen findet man in (Gunter 1992) oder auch in (Mitchell 1996). Im übrigen sind mehr oder weniger ausführliche Beschreibungen des Lambda-Kalküls oft auch in Lehrbüchern über funktionale Programmierung enthalten (Paulson 1996; Reade 1989; Field & Harrison 1988; Thiemann 1994). Dabei bietet das Buch von Paulson (1996) zusätzlich eine Implementierung des Lambda-Kalküls in ML.
117
5 Typisierung und Typinferenz Wir haben bereits mehrfach auf den Sinn strenger Typisierung hingewiesen: Zum einen stellt die Typkorrektheit eines Programms einen partiellen Korrektheitsbeweis dar: Die Typen der einzelnen Elemente eines Programms beschreiben das Verhalten dieses Programms auf sehr abstrakter Ebene. Wenn nun all diese Elemente bezüglich ihrer Typen zusammenpassen, so ist das Verhalten des Programms auf dieser abstrakten Ebene korrekt. Darüber hinaus wird durch frühzeitiges 1 Abfangen von Programmen mit Typfehlern ein möglicherweise sehr aufwendiges Testen und Fehlersuchen vermieden. Zum anderen kann man sich in Funktionen, die keine Typfehler enthalten, Typprüfungen zur Laufzeit ersparen. Das heißt, der vom Compiler erzeugte Code ist kürzer und damit auch schneller ausführbar. Ein Typsystem ist ein logisches System, das Aussagen der Form „ A u s d r u c k exp hat den Typ t' (notiert als exp-.x) formalisiert. Derartige Aussagen können über die Axiome und Regeln des logischen Systems bewiesen werden. Ein solches Typsystem werden wir für eine Sprache wie ML in Abschnitt 5.2 entwickeln. In Abschnitt 5.3 betrachten wir dann einen Algorithmus, der mit Hilfe der Regeln eines Typsystems zu einem Ausdruck einen Typ ermittelt. Diesen Vorgang nennt man Typinferenz. Der vorgestellte Algorithmus hat zudem die Eigenschaft, daß er jeweils den (im Rahmen des Typsystems) allgemeinsten Typ eines Ausdrucks findet.
5.1 Einführung in Typsysteme Wir wollen den Typ für das folgende Programmstück ermitteln. l e t f u n f x=x in f 3 end Es ist unschwer zu erkennen, daß der gesamte Ausdruck den Typ i n t hat, aber wie kann man dies formal herleiten? Man kann sich Typinferenz ver1
Das heißt schon zur Übersetzungszeit.
Korrektheit
Effizienter
Typsystem
Code
118
Typ-Gleichungen
5 Typisierung und Typinferenz
einfacht als das Lösen einer Menge von Gleichungen vorstellen: Zunächst ermittelt man für alle Elemente in einem Ausdruck (das heißt Konstanten, Funktionen und Variablen) den allgemeinsten möglichen Typ. Für Konstanten ist dies ihr definierter Typ, für eine Variable eine Typvariable und für Funktionen ein Typ ' a -> 'b. Diese Informationen notiert man in Form einer Menge von Gleichungen. Für das obige Beispiel erhalten wir also die Gleichungen type(3) = i n t , type(x) = ' a und type(f) = ' b -> ' c. Dabei muß man zunächst für verschiedene Variablen und Funktionen verschiedene Typvariablen nehmen, da die Verwendung von gleichen Typvariablen die Gleichheit der entsprechenden Typen ausdrückt. Darüber kann man aber zu diesem Zeitpunkt noch gar keine Aussagen machen. Nun fügt man Gleichungen hinzu, die sich aus der Struktur des Ausdrucks ergeben. Diese Gleichungen ergeben sich aus allgemeinen Regeln wie z. B.: ,,Der Typ des Parameters einer Funktionsdefinition muß gleich dem Argumenttyp der Funktion sein" oder „In einer Applikation muß der Typ des Arguments mit dem Argumenttyp der Funktion übereinstimmen". Im obigen Beispiel ergeben sich daraus die zusätzlichen Gleichungen type(x) = ' b bzw. type('b) = i n t . Eine ähnliche Regel besagt, daß der Typ des definierenden Ausdrucks einer Funktion gleich dem Ergebnistyp der Funktion sein muß. Daraus folgt für unser Beispiel dann noch die Gleichung type{x) = ' c. Damit kann man direkt folgern, daß ' a = ' b = ' c = i n t gelten muß. Da schließlich noch der Typ einer Applikation gleich dem Ergebnistyp der applizierten Funktion ist, ergibt sich als Typ des Gesamtausdrucks der Typ i n t . Ein anderes Beispiel. Die Funktion f n f => (f 3 , f
true)
ist nicht typisierbar. Dies sollte intuitiv klar sein, denn einerseits verlangt der Ausdruck f 3 , daß f den Typ i n t -> ' a hat, während andererseits die zweite Applikation für f den Typ b o o l -> ' a fordert. Diese beiden Typen aber sind nicht miteinander vereinbar, das heißt, es gibt keinen Typ für f , der den beiden Anforderungen genügt. Damit ist die gesamte Abstraktion nicht typisierbar. Insbesondere ist nun auch jede Applikation der Funktion, wie z.B. ( f n f => (f 3 , f t r u e ) ) ( f n x=>x) nicht typisierbar. Ein ähnliches Beispiel ist der Ausdruck:
5.1 Einführung in Typsysteme
119
l e t v a l f = f n x=>x in (f 3 , f
true)
end
In diesem Fall kann man f sehr wohl einen Typ zuweisen, und zwar ' a -> 1 a, den man in beiden Applikationen zu einem jeweils korrekten Typ instanzieren kann, nämlich i n t -> i n t für den Ausdruck f 3 und bool -> b o o l für f t r u e . Dies ist etwas überraschend, hatten wir doch ausdrücklich erwähnt, daß eine Applikation
Applikation vs. let-Ausdruck
( f n x=>e) e ' dem let-Ausdruck let val x=e' in e
end
entspricht (siehe Abschnitt 1.4, Seite 29). Warum also kann der l e t Ausdruck typisiert werden, während man für die entsprechende Applikation keinen Typ finden kann? Dies liegt daran, daß innerhalb des let-Ausdrucks die Struktur der Funktion f bereits bekannt ist, während die Typisierung der Abstraktion unabhängig von der bei der Applikation für f einzusetzenden Funktion erfolgen muß. Das heißt also, daß die beschriebene Korrespondenz zwischen Applikation und let-Ausdruck lediglich für die Auswertung von Ausdrücken gilt. Bei der Typisierung unterscheiden wir dagegen lokal gebundene Variablen von Variablen, die Funktionsparameter sind. Falls also var eine Variable einer Abstraktion ist (z. B. f n var=>exp), so müssen alle zugehörigen Vorkommen in exp den gleichen Typ haben.1 Die in dem für var ermittelten Typ enthaltenen Typvariablen können nicht zu verschiedenen Typen instanziert werden; deshalb nennt man sie nicht-generisch. Ist dagegen var durch genetische einen let-Ausdruck gebunden, so können die Typvariablen aus dem Typ für Typvariablen var durchaus instanziert werden, und zwar auch an verschiedenen Stellen zu verschiedenen Typen. Daher nennt man solche Typvariablen genetisch. Generische Typvariablen werden auf der äußersten Ebene eines Typausdrucks durch einen Allquantor gebunden, um sie in Typausdrücken von nicht-generischen Typvariablen unterscheiden zu können. So notieren wir beispielsweise den Typ von f aus dem let-Ausdruck als Typschema Typschema 1 Das heißt alle Vorkommen, die durch diese Abstraktion gebunden sind und nicht durch eine andere Abstraktion oder einen let-Ausdruck innerhalb von exp.
120
5 Typisierung und Typinferenz V'a.'a -> 'a
Die Instanzierung eines Typschemas erfolgt durch das Ersetzen quantifizierter Variablen durch Typen. So erhält man z.B. den Typ i n t -> i n t , indem man die Variable ' a durch den Typ i n t ersetzt. Einen Typausdruck, der durch das Ersetzen von gebundenen Variablen entstanden ist, nennt man genetische Instanz (dementsprechend bezeichnet man das Ergebnis einer Instanzierung über freie Typvariablen auch als nicht-generische Instanz). Aufgabe 5.1 Geben Sie Funktionsdefinitionen an, für die die folgenden Typen inferiert werden. (Hinweis: Überlegen Sie, mit welchen Operationen man die Gleichheit von Typvariablen/Typen erzwingen kann.) a) ' a -> i n t b) ' a * ' a * ' b -> ' b * ' a c) ' a -> ' b d) i n t -> ' a e) ( ' a -> >a) -> i n t
5.2 Ein polymorphes Typinferenzsystem Ein Typsystem ordnet einem Ausdruck in einer Programmiersprache L einen Typ zu, das heißt einen Ausdruck in einer Sprache von Typen T. Um ein Typsystem genau beschreiben zu können, definieren wir zunächst die Sprachen L und T. Damit die weitere Darstellung übersichtlich bleibt, wählen wir für L nicht etwa ML, sondern einen Kern von Sprachelementen, mit denen man jedes ML-Programm ausdrücken kann. Gegeben sei eine Menge von Bezeichnern für Variablen V, mit var € V, sowie eine Menge von Konstanten C, mit con S C. Dann ist die Sprache Mini-ML Mini-ML über die folgende Grammatik definiert:
5.2 Ein polymorphes exp
Typinferenzsystem
con var i f exp then exp e i s e exp exp exp f n var=>exp l e t v a l var=exp in exp end l e t val rec defs i n exp end
defs
::= var=exp | var-exp and defs
121 Konstante Variable Fallunterscheidung Applikation Abstraktion let-Ausdruck rekursives l e t Definitionen
Die Beschränkung auf eine Definition in let-Ausdrücken ist nicht wesentlich, sondern dient lediglich der Vereinfachung, denn man erkennt leicht, daß ein Ausdruck l e t val var\ = exp\ v a l var2 - exp2 v a l varn = expn in exp end äquivalent ist zu: l e t val var\ = exp\ in l e t v a l var2 = exp2 in l e t v a l varn = expn in exp end end end Mit dem rekursiven let-Block lassen sich wechselseitig rekursive Definitionen beschreiben. Das Schlüsselwort r e c erlaubt dabei die Verwendung der definierten Variablen in den definierenden Ausdrücken. Beispielsweise
5 Typisierung und Typinferenz
122
lassen sich die beiden Funktionen odd und even mittels einer rekursiven let-Konstruktion wie folgt definieren: l e t v a l r e c odd = f n x => x=l o r e l s e even (x-1) and even = f n x => x=0 o r e l s e (x>l a n d a l s o odd ( x - 1 ) ) in (odd,even) end Im folgenden nehmen wir an, daß C alle integer-, real- und string-Konstanten enthält sowie die Konstanten t r u e und f a l s e . Typ-Grammatik
Mit einer gegebenen Menge von Typvariablen TV, a 6 TV, ist die Sprache der Typen (t) und die der Typschemata (a) wie folgt definiert. T
::= | |
i n t | r e a l | s t r i n g | bool a t -> x
einfacher Typ Typvariable Funktionstyp
G
::= |
x Va.a
Typ Typbindung
Mit dieser Definition ist ein Typ immer gleichzeitig auch ein Typschema. Man beachte, daß Quantoren nicht innerhalb von Typausdrücken verwandt werden können (shallow polymorphism, siehe auch Abschnitt 1.5). Daher kann man jedes Typschema auch mit nur einem Quantor gefolgt von allen gebundenen Variablen notieren: V a i . . . a n .x. Bevor wir die Regeln des Typsystems vorstellen, müssen wir den Begriff der generischen Instanz noch präzise definieren. Dazu benötigen wir eine Substitutionsoperation auf Typen sowie den Begriff der freien und gebundenen Typvariablen. Eine ähnliche Thematik haben wir schon im Kapitel 4 ausführlich besprochen. Die Definitionen für freie bzw. gebundene Variablen lauten hier: Definition 5.1 (Freie und gebundene Typ variablen) FV( oc) FV{x -> x') FV(Vai...a„.T) FV(x)
= {«} = FV{x)\JFV{x') = FV(x) - {(Xi,... ,a„} — 0 für x £ { i n t , r e a l , s t r i n g , b o o l }
5.2 Ein polymorphes Typinferenzsystem
123
GV(Vai... a„.x) = {a,,..., a„} n FV(x) GV{x) =0
•
Es gilt z.B.: FV(V'a.'b -> ' a -> i n t ) = { ' b } GV(V>a.'b -> ' a -> i n t ) = { ' a } Da wir Substitutionen lediglich auf Typen (und nicht auf Typschemata) durchführen, brauchen wir uns um Variablenumbenennungen nicht zu kümmern. Dies vereinfacht die folgende Definition: Definition 5.2 (Substitution von Typvariablen) [t/OC]OC = X [x/a]a' = al falls a ^ a ' [x/a]x' = x' falls x' € { i n t , r e a l , s t r i n g , b o o l } [x/a](xj -> x2) = [x/a]xi -> [x/a]x2
•
Damit können wir nun definieren: Definition 5.3 (Generische Instanz) Ein Typschema c' = Va'j ...alm.x' ist generische Instanz des Typschemas c = V a j . . . a„.x (notiert alsCT'^ a), falls x' = [x,/a,]x für beliebige Typen und falls kein a- frei in a vorkommt. • Es ist also beispielsweise V ' c . ' b -> C b -> ' c ) ^ V ' a . ' b -> 'a, denn x' = 'b -> ( ' b -> ' c ) = ['b -> ' c / ' a ] ' b -> ' a und ' c ^ FV(V' a . ' b -> ' a) = {'b}, aber es gilt nicht: V ' b V ' c . ' b -> C b -> ' c ) X V ' a . ' b -> 'a, da ' b e F V ( V ' a . ' b -> 'a). Die Regeln des Typinferenzsystems betreffen Aussagen der Form r h exp: x, die ausdrücken, daß unter den Typannahmen T der Ausdruck exp den Typ x hat. Eine Typannahme ist eine partielle Funktion, die Variablen auf Typschemata abbildet, wobei r { v a n h-> X],..., varn h-> x„} die Funktion bezeichnet, die für var, den Typ x, liefert und ansonsten wie T definiert ist.
Regeln des Typsystems
124
5 Typisierung und Typinferenz
Die freien Variablen einer Typannahme sind definiert als (dabei liefert dorn den Definitionsbereich einer Funktion):
FV(r) =
U
FV(r(wzr))
varedom(r) Die Regeln des Typsystems haben die Form: Ai
A2
...
An
B Die Ai heißen Voraussetzungen der Regel, C ist eine logische Formel, und B ist die Folgerung der Regel. Eine Regel besagt anschaulich: Wenn alle Voraussetzungen hergeleitet werden können und zusätzlich noch die Bedingung C erfüllt ist, so ist auch die Folgerung herzuleiten. Eine Regel, in der n = 0 ist, nennt man auch ein Axiom. Typsystem
für
Mini-ML
Das Typsystem für Mini-ML ist in Abbildung 5.1 dargestellt. Die Inferenzregeln sind wie folgt zu lesen: Zunächst besagen die Axiome VAR und CON, daß sich der Typ einer Variablen bzw. eines Konstruktors aus der Typannahme ergeben muß. Die COND-Regel setzt voraus, daß die Bedingung exp den Typ bool und die beiden Alternativen expl und exp2 den gleichen Typ x haben. Dann kann man folgern, daß der Ausdruck i f exp t h e n expx e i s e exp2 den Typ T hat. Regel APP beschreibt, daß man einen Ausdruck exp nur dann auf einen anderen Ausdruck exp' applizieren darf, wenn exp eine Funktion ist, das heißt einen Typ der Form x' -> x hat, und wenn der Typ des Arguments gleich x' ist. Dann hat der Ausdruck exp exp' den Typ x. In Regel ABS sieht man, daß unter den Annahmen in T für eine Abstraktion der Typ x' -> x hergeleitet werden kann, wenn unter der erweiterten Typannahme T{var x'}, das heißt unter den Annahmen in T und der Annahme, daß var den Typ x' hat, für den Rumpf der Abstraktion exp der Typ x hergeleitet werden kann. Zu beachten ist hier, daß x und x' Typen sein müssen und keine Typschemata. Die Regel LET besagt: Wenn für exp' das TypschemaCTherleitbar ist und wenn man für exp unter der Annahme des Typschemas a für var den Typ x zeigen kann, so hat l e t v a l var=exp' i n exp end den Typ x. Die LETREC-Regel verlangt nach einigen Erklärungen. Zunächst sollte klar sein, daß in der Typannahme F" für die Herleitung des Typs von exp die Annahmen für sämtliche n Variablen enthalten sein müssen. Da die Ausdrücke expi wechselseitig rekursiv sein können, erwartet man für deren Herleitung
125
5.2 Ein polymorphes Typinferenzsystem
VAR
CON
COND
APP
ABS
LET
LETREC
r 1- var\Y{var)
r h
con:T(con)
r h exp:bool r I- i f
T b expj:%
T 1- exp2:t
exp then expx e i s e
r I- exp: x' - > X
exp2'.l
r h exp': x'
r I- exp exp': X
r{var h-> x'} h exp: X r I- f n var=>exp :x' -> X r (- exp':
CT
T{var i-> ct} h exp: X
r h l e t v a l var-exp'
r h expi'.ii
i n exp end:x
r" I-
exp:x
r h l e t v a l r e c de/s i n exp end:x wobei defs = var\=expx and ... and varn=expn T = T{vari r" = r{vari
Xi,..., varn (->• x„} h->CTi ,..., varn
und X; -x den Typ V ' a . ' a - > ' a hat, das heißt, wir müssen innerhalb des Typsystems beweisen, daß { } h f n x=>x:V"a.'a -> 'a gilt. Mit R = {x ' a } folgt zunächst aus Regel VAR: F h x i ' a . Die Typannahme in T wird in der Regel ABS verwandt (sie wird sozusagen „aufgebraucht"), und es ergibt sich { } I- f n x=>x: ' a -> 'a. Jetzt kann man ' a generalisieren, da ' a nicht in der Typannahme vorkommt, das heißt, mit der Regel GEN erhält man die gewünschte Aussage. Nun wollen wir die Applikation dieser Funktion auf einen integer-Wert typisieren. Dazu kann man den generischen Typ der Funktion spezialisieren. Gemäß Definition 5.3 gilt z. B.: i n t -> i n t -^V'a.'a -> ' a. Daher kann man mit der Regel SPEC folgern: { } h f n x = > x : i n t -> i n t . Die vorangegangenen Typherleitungen können genauso unter erweiterten Typannahmen vorgenommen werden, sofern diese für x keinen anderen Typ angeben und ' a nicht enthalten. Also können wir insbesondere auch {3 1
i n t } h f n x=>x:int -> i n t
Diese Einschränkung resultiert aus der Tatsache, daß allgemeine Typinferenz für polymorphe Rekursion unentscheidbar ist.
5.2 Ein polymorphes
Typinferenzsystem
127
zeigen. Unter der gleichen Annahme können wir mit der Regel CON auch {3 i y i n t } b 3: i n t zeigen. Diese beiden Aussagen kann man nun als Voraussetzungen für die Regel APP verwenden und damit dann {3 I—> i n t } h ( f n x=>x) 3: i n t herleiten. Abschließend wollen wir noch die Typisierung des let-Ausdrucks l e t v a l f = f n x=>x in (f 3 , f t r u e ) end innerhalb des Typsystems nachvollziehen. Dazu erweitern wir Mini-ML und das Typsystem um Tupel. Für unser konkretes Beispiel reichen Paare aus. Das heißt, daß ( e x p , exp) ebenfalls ein Ausdruck von Mini-ML ist und daß x * x ein Element der Sprache x ist. Für das Typsystem führen wir die folgende Regel ein: TUP
r b exp< :Xi r h expi :x? — — T b (exp x ,exp2) :Xi * 1z
Es sei nun r = {3 int,true Zunächst zeigen wir
bool} und T ' = T{f
V ' a . ' a -> 'a}.
r b f n x = > x : V ' a . ' a -> ' a
(5.1)
Dies geschieht wie oben, nur in einer erweiterten Typannahme (die ' a nicht enthält). Mit der Regel VAR kann man den Typ für f aus T' entnehmen, das heißt, es gilt: T b f : V ' a . ' a -> ' a Daraus erhält man durch die Regel n b f : i n t -> i n t
SPEC
und
die beiden Aussagen: V b f :bool -> bool
Für diese beiden Instanzen kann man nun mit den Regeln CON und APP getrennt die Aussagen r b f 3: i n t
und
Hbf
true:bool
128
5 Typisierung und Typinferenz
nachweisen. Diese dienen als Voraussetzungen für die Regel TUP und man erhält r h (f 3 , f t r u e ) : i n t * b o o l
(5.2)
Schließlich kann man mit 5.1 und 5.2 als Voraussetzungen die Regel LET anwenden und zeigt damit: r h l e t v a l f = f n x=>x i n (f 3 , f t r u e ) e n d : i n t * b o o l
5.3 Automatische Typinferenz
Auswahl von Regeln
Mit dem Inferenzsystem des vorigen Abschnitts können wir nun Typen sozusagen „von Hand" herleiten. Offen bleibt aber die Frage nach einem Verfahren, das diese Arbeit automatisch erledigt. Einen solchen Algorithmus kann man sich durchaus als eine Methode zum Suchen von Beweisen in dem vorgestellten logischen System vorstellen. Insbesondere muß der Algorithmus Entscheidungen treffen, welche Regeln wann anzuwenden sind. Das Inferenzsystem erschwert dies insofern, als in machen Situationen mehr als nur eine Regel anwendbar ist. So kann man beispielsweise die GEN-Regel fast immer anwenden. Bei genauem Betrachten des Typsystems fällt jedoch auf, daß die GEN-Regel eigentlich nur zum Generalisieren von Typvariablen in let-Ausdrücken benötigt wird und daß man die SPEC-Regel immer nur direkt nach einer CON-Regel oder nach einer VAR-Regel anzuwenden braucht. Daher kann man die beiden Regeln GEN und SPEC auch wegfallen lassen und deren Aufgaben in die übrigen Regeln integrieren. Dies geschieht wie folgt: (1) Genetische Instanzen werden direkt in den neuen Regeln VAR' und CON' durch eine entsprechende Bedingung gebildet. (2) Typschemata werden durch eine Funktion gen generiert, die relativ zu einer Typannahme T für einen Typ t das allgemeinste Typschema konstruiert. Die Funktion gen ist wie folgt definiert: gen{T,i)
= Vai...a„.T wobei { « ! , . . . , « „ } = FV{%) - FV(r)
Man erhält dann das modifizierte Typsystem aus Abbildung 5.2 Damit existiert nun für jede syntaktische Variante von exp genau eine Regel, mit der man den entsprechenden Typ ermitteln kann. Das bedeutet, daß
5.3 Automatische Typinferenz VAR'
CON'
COND
APP
ABS
LET'
LETREC'
r h var\x
r h con:t
129
x -< r(var)
X R(con)
r h exp:bool T h exp\ :x T h exp2:i r h i f exp then expi e i s e exp2:t r i- exp-.x' -> x R H
r h exp': %'
exp exp': X
r{varH->x'} h exp:x r H f n var=>exp:%' -> x r I- exp' :x'
V{var
gen(T,%')} h exp:i
r h l e t v a l var=exp' i n exp end:x r' h expi'.Xi V" h exp\T r h l e t v a l r e c defs i n exp end:x wobei defs = var\=expl and ... and varn=expn r = r{vari Xi,..., varn i— r " = r{vari
H->gen(r,Xi),...,var„
gen(T,xn)}
Abbildung 5.2: Modifiziertes Typinferenzsystem
man die Regeln durch die syntaktische Struktur eines Ausdrucks gesteuert von unten nach oben (und von links nach rechts) anwenden kann. Dabei erzeugt man zunächst für jeden Ausdruck, auf den man trifft und dessen Typ noch nicht bestimmt ist, eine neue Typvariable. Diese Typvariablen unterliegen nun Einschränkungen, die sich aus den Regeln des Typsystems ergeben. Solche Einschränkungen sind insbesondere die Gleichheit der Typen zweier Teilausdrücke (beispielsweise in der Regel APP). Diese Gleichheit (bei gleichzeitiger maximaler Allgemeinheit) kann man durch Unifikation der entsprechenden Typausdrücke erreichen. Bevor wir also den Typinferenzalgorithmus angeben, werden wir zunächst kurz die TermUnifikation besprechen. Ein Unifikator zweier Terme ist nichts anderes als eine Substitution, die die beiden Terme „gleichmacht" (insbesondere ist ein Typ-Unifikator eine
Unifikation
130
allgemeinster Unifikator
5 Typisierung und Typinferenz
Substitution von Typen für Typvariablen). Das heißt, U ist ein Unifikator für die beiden Terme x und V, wenn gilt: Ux = Ui'. Ein Unifikator U ist allgemeinster Unifikator (most general unifier) der Terme x und x', wenn sich jeder andere Unifikator R durch Komposition aus U und einer weiteren Substitution darstellen läßt. Das bedeutet, daß ein allgemeinster Unifikator nur für solche Variablen Definitionen enthält, die es unbedingt erfordern. Beispiel: Ein Unifikator für die beiden Typen ' a -> ( b o o l * ' c ) und i n t -> ' b ist die Substitution [ i n t / ' a , r e a l / ' c , ( b o o l * r e a l ) / ' b ] , diese ist jedoch kein allgemeinster Unifikator, da sie durch Komposition der Substitution [ r e a l / ' c] mit der Substitution [ i n t / ' a, ( b o o l * ' c ) / ' b ] entsteht. Letztere ist ein allgemeinster Unifikator für die beiden Terme.
UnifikationsAlgorithmus
Wir geben nun einen Unifikations-Algorithmus in Form einer Funktion ZI an, die zu zwei Typ-Ausdrücken den allgemeinsten Unifikator ermittelt. Falls die Terme nicht unifizierbar sind, wird eine Fehlermeldung ausgegeben. Die folgenden Gleichungen sind sequentiell von oben nach unten anzuwenden. Definition 5.4 (Unifikations-Algorithmus) Zl{ a,a) U( CC,T)1 UM
J
{
U{X] -> x 2 ,x 3 -> x4)
[x/ot]
falls a ^ x
Fehler!
sonst
il{Ux2,Ux4)U wobei U = £/(ti,t 3 )
11(1,T?) =
occurs check
i 0
falls x = x'
1 Fehler!
sonst
•
Die erste Zeile liefert eine leere Substitution, da zwei gleiche Typvariablen bereits unifiziert sind. Der zweite Fall beschreibt die Unifikation einer Typvariablen mit einem beliebigen Typ. Diese resultiert in der angegebenen Substitution, falls die Typvariable im Typ nicht vorkommt. Diese Überprüfung nennt man auch occurs check; damit wird ausgeschlossen, daß eine Variable mit einem Typ unifiziert wird, in dem sie selbst enthalten ist, denn es gibt keine endlichen Lösungen für solche Gleichungen. Der dritte Fall beschreibt die Unifikation von Funktionstypen: Zunächst einmal kann ein Funktionstyp nur mit einem anderen Funktionstyp unifiziert werden (Typvariablen sind ja schon abgehandelt). Dann werden zunächst beide Argumenttypen unifiziert, was zu einer Substitution U führt. Danach werden die mit U instanzierten Ergebnistypen unifiziert. Dies stellt sicher, daß die bei den Argumenttypen berechneten Einschränkungen auch in der Unifikation der Ergebnistypen berücksichtigt werden. Die resultierende Substitution wird dann mit U komponiert. Schließlich sind zwei konstante Typen (wie
5.3 Automatische
Typinferenz
131
i n t ) nur zu unifizieren, wenn sie gleich sind (alle anderen Möglichkeiten sind bereits betrachtet worden). In dem Fall wird keine Substitution generiert. Als Beispiel betrachten wir die Unifikation der beiden Typterme ' a - > ' c und ' b -> i n t -> ' a. Zl('a = = = =
-> ' c , ' b -> i n t - > J
11(U ' c , £ / ( i n t -> a ) ) U Zl(' c, i n t -> ' b ) [ ' b / ' a] [ i n t -> ' b / ' c ] [ ' b / ' a ] [ i n t -> ' b / ' c , ' b / ' a ]
'a) {mit U = Zl{ ' a , 'b) = [ ' b / 'a]}
Die resultierende Substitution ergibt auf beide Terme angewandt den Typ ' b -> i n t -> 'b. Nun können wir den Algorithmus T zur Typinferenz angeben. T nimmt als Parameter eine Typannahme Y und einen Ausdruck exp und liefert als Ergebnis eine Substitution U sowie den allgemeinsten Typ x zu exp, siehe Abbildung 5.3 In der Beschreibung von T in bezeichnen überstrichene Typvariablen (wie ä ) stets neue Typvariablen, die an keiner Stelle in einem Typausdruck oder in der Typannahme vorkommen. Wird eine Substitution auf eine Typannahme angewandt, so ist damit die durch Anwendung der Substitution auf jeden einzelnen Typ entstehende Typannahme gemeint. Der Algorithmus T liefert zwei Ergebnisse: (1) eine Substitution und (2) einen inferierten Typ. Die Substitution wird lediglich im Algorithmus zur rekursiven Anwendung benötigt, eine Funktion zur Typprüfung wird üblicherweise nur den inferierten Typ als Ergebnis liefern. Zur Funktionsweise des Algorithmus T sei angemerkt, daß für quantifizierte Variablen aus Typschemata jeweils neue Variablen eingesetzt werden (dies betrifft die ersten beiden Zeilen). Bei der Fallunterscheidung wird zunächst der Typ des Prädikats (t) bestimmt. Dieser muß natürlich gleich b o o l sein, daher wird X mit b o o l unifiziert. Man beachte, daß man nicht einfach verlangen kann, daß x = b o o l sein muß, da exp ja z. B. auch eine Variable sein kann, die in einem umschließenden Ausdruck definiert ist und dort (zunächst) korrekterweise einen Typ a haben kann. Deshalb muß man unifizieren und die resultierende Substitution in der weiteren Typprüfung mitführen. Danach ermittelt man den Typ von exp\, wobei man die Typannahme r mit den bisher ermittelten Substitutionen R und S einschränkt. Analog verfährt man mit exp2, und schließlich muß man noch die beiden für expx und exp2 ermittelten Typen Xi und X2 unifizieren, da beide Alternativen ja den gleichen Typ haben müssen. Das Ergebnis der Typprüfung ist dann die Gesamtheit aller Substitutionen sowie der Typ Ux2. (Da die bei der letzten
TypinferenzAlgorithmus
132
5 Typisierung und Typinferenz
Unifikation berechneten Einschränkungen in x2 noch nicht berücksichtigt sind, muß man U noch auf x2 applizieren.) Die übrigen Fälle kann man sich analog klarmachen. Zur Inferenz wechselseitig rekursiver Definitionen sei noch angemerkt, daß r 7 für jede zu inferierende Funktion var, bereits eine T(r,w)=
(0,[ä 1 /a 1 ,...,ä„/a n ]'c) wobei T(var) = Voci... a„.x T(r,con) = (D, [ ä j / a i , . . . ,ä„/cxn]T) wobei T(con) = V a i . . . a n . x T(r, i f exp t h e n expl e i s e exp2) = (UT2T\SR,Ux2) wobei (R,x) = T(r,exp) S= Zl(x, bool) (TU t O = T(SRr,exPi) (T2,x2) = T(TiSRr,exp2)
U = U(T2xuX2) T{r,exp
exp') =
(UTS,Uä) wobei (5, t) = T(r,exp) (T, x') = T(Sr,exp')
u = u{tx,x' -> a)
T ( r , f n var=>exp)= (U,(Ua) -> x) wobei {U,x) = 1(r{var 1-4 ä},exp) T ( r , l e t v a l var=exp' i n exp end) = (UT,x') wobei (T,t) = T(r,exp') (U,x') = T(Tr{var ^ gen(Tr,x)},exp) T ( r , l e t v a l r e c defs in exp end) = (UTnTn-\... T2T\ ,x) wobei defs = var\=expi and ... and varn=expl T = T{varl' y ä, -> öc|} {Tuxi) = (T2,x2) =
t
r(T',expl) T(Tir>,exp2)
(Tn,xn) = T{Tn-\... T2TxV,expn) r" = TnTn^...T2Tlrl (U,t) = T(P'{van h* gen(r,Xi)},exp) Abbildung 5.3: Typinferenz-Algorithmus
5.4 Literaturhinweise
133
T({3 (->• i n t } , ( f n x=>x) 3) = (UTS,U> a) mit (S,t) = T({3 >->• i n t } , f n x=>x) = {U,(U' a) -> x) mit (C/,T) = T ( { x - > «a},x)
=
(D,'a)
= ( 0 , ' a -> 'a) (r,x') = T(5{3 i ^ i n t } , 3 ) = "T({3 H-> i n t } , 3 ) = (0,int) U = Í/(7t,T' -> >a) = 1 i ( ' a -> ' a, i n t -> 'a) = [int/'a] = ([int/'a], int) Abbildung 5.4: Beispiel für eine Typinferenz
(möglichst allgemeine) Typannahme enthält, um diesen Typ bei der Inferenz eines Ausdrucks expj zur Verfügung zu haben. Man beachte auch, daß diese Typen nicht-generisch sind und erst für die Inferenz von exp generalisiert werden. Als Beispiel inferieren wir den Typ des Ausdrucks ( f n x=>x) 3 unter der Typannahme {3 i-» i n t } , siehe Abbildung 5.4. Man sieht an diesem Beispiel, daß Typen für Konstanten und vordefinierte Funktionen dem Algorithmus T als anfängliche Typannahmen mitgegeben werden müssen. Aufgabe 5.2 Erweitern Sie den Typinferenzalgorithmus T zur Inferenz von Paaren.
5.4
Literaturhinweise
Die polymorphen Typsysteme der allermeisten funktionalen Programmiersprachen basieren auf den Arbeiten von Milner und Damas (Milner 1978; Damas & Milner 1982). Der für die Typinferenz benötigte Unifikationsalgorithmus stammt von Robinson (Robinson 1965). Eine gute Einführung in Typsysteme und Typinferenz gibt der Artikel von Cardelli (1987). Ausführliche Beispiele für die Anwendung des Typinferenzalgorithmus findet man
134
5 Typisierung und Typinferenz
in (Field & Harrison 1988). Allerdings basiert die Darstellung (wie auch die in (Cardelli 1987)) auf einer etwas anderen Kernsprache, die anstelle eines rekursiven l e t - B l o c k s einen Fixpunktoperator verwendet. Reade (1989) beschreibt die Typinferenz über eine Implementierung in ML. Erweiterungen des Typsystems um kontrolliertes Overloading wie es von der Sprache Haskell angeboten wird, sind in (Wadler & Blott 1989; Jones 1995b; Jones 1995a) beschrieben. Für entsprechende Erweiterungen des Typinferenzalgorithmus gibt es verschiedene Ansätze (Jones 1992; Nipkow & Snelting 1991; Nipkow & Prehofer 1993; Hall, Hammond, Peyton Jones, & Wadler 1996). Mehr über Typsysteme und Polymorphismus kann man im Übersichtsartikel von Mitchell (1990) lesen. Dort wird insbesondere auch auf die Semantik von Typen eingegangen. Siehe dazu auch (Mitchell 1996) und (Gunter 1992).
135
6
Implementierungstechniken
Die Implementierung von funktionalen Sprachen zu untersuchen hat zwei Aspekte: Zunächst wird man natürlich in die Lage versetzt, funktionale Sprachen zu realisieren. Darüber hinaus ergibt sich zusätzlich der Lerneffekt, bestimmte Sprachdetails besser und genauer zu verstehen: Kann man beim Programmieren manche Sprachmittel nur begrenzt einsetzen oder gar ignorieren, so ist man bei der Implementierung einer Sprache gezwungen, sämtliche Sprachkonstrukte exakt zu beschreiben; und dies setzt natürlich deren Verständnis voraus. Wir betrachten zunächst in Abschnitt 6.1 einige allgemeine Aspekte der Implementierung (funktionaler) Sprachen, bevor wir in Abschnitt 6.2 einen Interpreter und in Abschnitt 6.3 einen Compiler für einen funktionalen Sprachkern untersuchen werden.
6.1 Implementierung von Programmiersprachen Bei der Implementierung einer Programmiersprache bieten sich prinzipiell zwei Möglichkeiten an: Zum einen kann man einen Interpreter für die Sprache schreiben, oder man entwickelt einen Übersetzer, der die Sprache in eine Maschinensprache abbildet. Dabei muß die Maschinensprache nicht unbedingt die eines realen Prozessors sein; in der Tat bildet man meistens in eine abstrakte Maschine ab, um eine gewisse Unabhängigkeit von konkreten Hardwareplattformen zu wahren. Die beiden Ansätze werden sehr oft kombiniert eingesetzt. Wie schon erwähnt, wird man häufig einen Compiler für eine abstrakte Maschine schreiben; diese abstrakte Maschine wird nun typischerweise durch einen Interpreter auf einer realen Maschine simuliert. Ebenso kann man die Implementierung eines Interpreters vereinfachen, indem man diesen lediglich für einen Sprachkern (wie z. B. in Mini-ML) realisiert und die übrigen Sprachelemente auf Konstrukte des Sprachkerns (durch einen Übersetzer) abbildet.
Interpretation und Übersetzung
Wenn man über die Implementierung einer Programmiersprache spricht, so muß man zwei Ebenen unterscheiden: Zum einen kann man die (abstrakte)
Ebenen der Sprachübersetzung
136
SprachRepräsentationen
Kern-Sprache
6
Implementierungstechniken
Sprachebene betrachten. Hier sind Sprachen (S,T,...) als Mengen von Worten definiert; ein Compiler ist eine Funktion, z. B. C : S —• T, ein Interpreter (einer funktionalen Sprache) ist typischerweise eine Funktion / : S S1, wobei S' C S die Menge der Werte aus S enthält. Auf der anderen Seite gibt es für Sprachen verschiedene Arten von Repräsentationen, die jeweils gan Z unterschiedlichen Zwecken dienen. So ist die Darstellung eines Programms als Folge von Zeichen für den Programmierer sicherlich einfacher zu handhaben, als die entsprechende Repräsentation über einen Syntaxbaum, der andererseits für einen Compiler durchaus zweckmäßig ist. Da wir hier weder auf die lexikalische Analyse noch auf die Syntaxanalyse eingehen wollen, können wir uns auf die interne Repräsentation von Programmen als Syntaxbäume (bzw. Liste von Maschinenbefehlen) beschränken. Dies ist insofern hilfreich, als sich solche abstrakten syntaktischen Strukturen ausgezeichnet mit ML-Datentypen modellieren lassen. Wenn wir also davon ausgehen, daß wir ein syntaktisch korrektes funktionales Programm als Syntaxbaum vorliegen haben, so müssen wir als nächstes dessen Typkorrektheit überprüfen. Dies kann im wesentlichen mit dem Algorithmus aus Abschnitt 5.3 erfolgen. Ein typkorrektes Programm wird nun aber nicht direkt von einem Interpreter verarbeitet oder in eine Maschinenspräche übersetzt, sondern zunächst in eine Kern-Sprache übersetzt. Dies beinhaltet z.B. die Übersetzung von pattern matching. Eine solche Aufteilung der Übersetzung vereinfacht zum einen die Implementierung von Compilern selbst und hat zudem den Vorteil, daß verschiedene funktionale Sprachen u. U. die Implementierung eines einzigen Sprachkerns nutzen können. Ebenso können Erweiterungen oder Modifikationen einer Sprache sehr viel leichter vorgenommen werden. Wir wollen uns hier auf die Interpretation und Übersetzung einer Kern-Sprache beschränken. Eine grobe Übersicht über die Elemente einer Sprach-Implementierung gibt die Abbildung 6.1. Dabei bezeichnet „SECD" eine spezielle abstrakte Maschinenarchitektur. Die gepunkteten Elemente werden wir hier nicht behandeln.
Implementierung über den Lambda-Kalkül
Wir wollen noch auf eine weitere, eigentlich sehr naheliegende Möglichkeit zur Implementierung hinweisen: In Abschnitt 4.5 hatten wir gesehen, daß man im Lambda-Kalkül alle (partiell) rekursiven Funktionen ausdrücken kann. Wenn man also eine funktionale Sprache in den Lambda-Kalkül abbildet, so benötigt man nur noch einen Interpreter für den Lambda-Kalkül. Bei näherer Betrachtung fällt jedoch auf, daß die Substitution mit der erforderlichen Umbenennung von Variablen eine sehr aufwendige Operation ist. Durch die Verwendung von Umgebungen kann man diese vermeiden. Insbesondere erlaubt das Speichern von Variablen-Bindungen das sogenannte sharing von Werten. Dies bedeutet, daß ein Ausdruck nicht mehrfach kopiert
6.2 Ein Interpreter für Mini-ML Sprache
137 Implementierungsfunktion
Abbildung 6.1: Implementierungs-Struktur.
werden muß, sondern in der Umgebung bei Bedarf immer wieder nachgesehen werden kann. Es gibt aber auch einen Implementierungsansatz, der sharing auf der Basis des Lambda-Kalküls realisiert. Dies ist die sogenannte Graph-Reduktion. Dort werden, wie der Name schon andeutet, X-Terme als Graphen repräsentiert, und für jedes Auftreten eines Wertes oder Ausdrucks existiert eine Kante von der entsprechenden Stelle zu dem repräsentierenden Knoten. Graph-Reduktion wird vornehmlich für lazy Sprachen eingesetzt. Im nächsten Abschnitt stellen wir einen Interpreter für Mini-ML vor, einen Compiler-Ansatz beschreiben wir in Abschnitt 6.3.
6.2 Ein Interpreter für Mini-ML In Abschnitt 5.2 hatten wir uns bei der Darstellung eines Typsystems auf einen Kern von ML-Sprachkonstrukten beschränkt. Bei der Beschreibung eines Interpreters wollen wir ebenso verfahren. Wir werden also einen Interpreter für Mini-ML entwickeln (siehe Grammatik in Abschnitt 5.2 auf Seite 120), und zwar ganz konkret in Form einer ML-Funktion. Dazu müssen wir zu interpretierende Programme als ML-Objekte repräsentieren.
Graph-Reduktion
138
6
Implementierungstechniken
6.2.1 Interne Repräsentation von Programmen
Repräsentation
von
Datentypen
Wir führen einen Datentyp e x p r für Ausdrücke ein. Darin werden Konstanten nach vordefinierten Typen unterschieden, das heißt, für Konstanten eines jeden vordefinierten Typs gibt es einen eigenen Konstruktor. Alle Datentyp-Konstruktoren werden dagegen über einen einzigen Konstruktor Con dargestellt. Dessen Parameter sind der Name des Konstruktors (z.B. " c o n s " ) sowie die Liste seiner Argumente (z.B. [ I n t 2,Con ( " n i l " , [ ] ) ] ) . Beispielsweise wird dann die einelementige Liste [2] (das heißt der Konstruktor-Term 2 : : n i l ) durch den Ausdruck Con ( " c o n s " , [ I n t 2,Con
("nil",[])])
repräsentiert. Da wir pattern matching aber hier nicht implementieren werden, können Teile eines Konstruktor-Terms nur über (vordefinierte) Selektionsfunktionen wie hd oder t l extrahiert werden. An vordefinierten Typen betrachten wir nur integers und booleans, und wir beschränken uns bei vordefinierten Datentypen auf Listen; Erweiterungen sind jederzeit sehr einfach möglich. Vordefinierte Funktionen werden über die Konstruktoren UnOp und BinOp repräsentiert, je nachdem, ob es sich um unäre oder binäre Funktionen handelt. Variablen werden als strings dargestellt. Die Konstruktoren für Fallunterscheidung, Applikation und Abstraktion sowie für die beiden let-Konstrukte sind entsprechend der Mini-ML Grammatik definiert. Zusätzlich gibt es noch den C l o s Konstruktor zur internen Repräsentation von Funktionen als sogenannte „closures". Closures werden wir später noch genau erklären (ebenso die Typen b i n d i n g und env). type var = string datatype expr of Int of 1 Bool of 1 Con 1 Var of 1 UnOp of 1 BinOp of 1 Cond of of 1 Abs 1 App of 1 Let of 1 LetRec of 1 Clos of
=
int bool var * expr list var var * expr var * expr * expr expr * expr * expr var * expr expr * expr var * expr * expr (var * expr) list * expr expr * env lazy
6.2 Ein Interpreter für Mini-ML
139
withtype binding = var * expr and env = binding list
Das Schlüsselwort w i t h t y p e hat eine ähnliche Funktion wie das and: Es erlaubt die simultane Definition eines Datentyps zusammen mit einem Typ. Dies ist in der obigen Definition erforderlich, da einerseits expr in der Typdefinition von b i n d i n g verwandt wird, andererseits aber auch b i n d i n g (über die Verwendung in der Definition von env) in der Definition von expr benötigt wird (im Konstruktor Clos). Der l a z y Typkonstruktor sollte noch aus Kapitel 2 bekannt sein. Die Funktionen p r e d und f a k beispielsweise werden entsprechend der obigen Datentyp-Definition wie folgt als ML-Objekte dargestellt: v a l pred = Abs ("x",BinOp ( " - " , V a r " x " , I n t
1))
LetRec ( [("f",Abs ("x", Cond (BinOp ("eval x E) 1) Variablen werden zu den in der aktuellen Umgebung für sie definierten Werten ausgewertet.
Variablen
eval (Var x ) E = lookup ( x , E ) Man beachte, daß ein mittels lookup ermittelter Ausdruck nicht weiter ausgewertet werden muß, da aufgrund der call-by-value Semantik des Interpreters Ausdrücke nur ausgewertet als Parameter übergeben werden und dementsprechend (siehe die Gleichungen für Abs und L e t ) nur Ausdrücke in weak normal form in die Umgebung eingebracht werden. An vordefinierten Funktionen betrachten wir unäre Operationen (wie z. B. not) und binäre Operationen (z. B. + oder =), die über die Konstruktoren UnOp bzw. BinOp dargestellt werden. Bei der Auswertung werden zunächst die Argumente berechnet, und dann wird die primitive Funktion appliziert. Die Applikation dieser primitiven Funktionen erfolgt über die Funktionen applyl und apply2, die für jede vordefinierte Funktion eine geeignete Definition enthalten. fun applyl "not" (Bool x) = Bool (not x ) I applyl "hd" (Con ( " c o n s " , [ x , y ] ) ) = x I applyl " t l " (Con ( " c o n s " , [ x , y ] ) ) = y fun apply2 I apply2 apply2 apply2 apply2 apply2 apply2 apply2
i l __ M
M ^
H
Ii ^ I I
Int (x+y) (Int x.Int y) Int ( x - y ) (Int x,Int y) Int ( x * y ) (Int x,Int y) Bool (x>y) (Int x,Int y) Bool (xb) (apply2 "=" ( x , y ) ) andalso a l l e q ( 1 , 1 ' )
Vordefinierte Funktionen
142
6
Implementierungstechniken
Die etwas komplizierte Implementierung der Gleichheit von KonstruktorTermen bedarf einiger Erklärungen. Nach Definition sind zwei Terme genau dann gleich, wenn ihre Konstruktoren und ihre Argumente gleich sind. Zunächst mag man sich fragen, warum die Gleichheit der Listen 1 und 1 ' nicht einfach mit der —Funktion überprüft wird. Der Grund ist, daß = nur für Gleichheitstypen definiert ist, expr aber kein solcher Typ ist, da der l a z y Typkonstruktor Funktionstypen bezeichnet. Somit kann man = nicht auf expr-Werte (und auch nicht auf Listen von solchen) anwenden. Also muß man mit a p p l y 2 die Gleichheit aller Elemente der beiden Listen überprüfen. Dies wird durch die Funktion a l l e q realisiert. Dabei fällt auf, daß auf jeden Gleichheitstest für zwei Listenelemente noch der Ausdruck (f n Bool b=>b) appliziert wird. Dies hat den folgenden Grund: a p p l y 2 liefert stets einen Wert vom Typ expr, in diesem Fall also Bool t r u e oder Bool f a l s e . Für solche Werte aber ist a n d a l s o nicht definiert. Die Applikation der Funktion ( f n Bool b=>b) auf diesen Wert macht nun nichts anderes, als den Bool-Konstruktor vor dem Wahrheitswert zu entfernen, wodurch die Verwendung in andalso-Ausdrücken ermöglicht wird. Nun ist die Definition von e v a l für vordefinierte Funktionen relativ einfach: e v a l (BinOp ( f , e l , e 2 ) ) E = a p p l y 2 f ( e v a l e l E . e v a l e2 E) e v a l (UnOp ( f , e ) ) E = a p p l y l f ( e v a l e E) Fallunterscheidung
Die Fallunterscheidung nimmt in Sprachen mit eager evaluation eine besondere Rolle ein: Es wird nämlich zunächst nur die Bedingung ausgewertet und danach dann abhängig von dem Ergebnis wahlweise eine der beiden Alternativen. Wir hatten schon erwähnt, daß die Fallunterscheidung, als Funktion betrachtet, lediglich im ersten Argument strikt ist. Dies ermöglicht unter anderem erst rekursive Funktionsdefinitionen: Denn wertete die Fallunterscheidung alle Argumente sofort aus, dann würde ein rekursiver Aufruf (siehe z.B. die Definition von f a k ) immer ausgeführt, selbst dann, wenn die Bedingung letztendlich die andere Alternative als Ergebnis definiert. Der Effekt wäre, daß rekursive Definitionen niemals terminieren könnten! Diese Ausnahme von der call-by-value Parameterübergabe für Fallunterscheidungen erzwingt eine gesonderte Behandlung über einen eigenen Konstruktor Cond und verhindert die Repräsentation über eine vordefinierte Funktion (für die der Interpreter ja stets alle Argumente auswertet). Die Definition von e v a l ist eigentlich sehr einfach: Falls die Bedingung e in der aktuellen Umgebung erfüllt ist, wird e l , ansonsten e2 in der aktuellen Umgebung ausgewertet. e v a l (Cond ( e , e l , e 2 ) ) E = e v a l ( i f ( f n Bool b=>b) ( e v a l e E) t h e n e l e i s e e2) E
6.2 Ein Interpreter für Mini-ML
143
Bei der Auswertung der Bedingung haben wir erneut den Bool-Konstruktor vor dem Wahrheitswert entfernt, um den gesamten Ausdruck als Argument für i f verwenden zu können. (Der Ausdruck e v a l e E = Bool t r u e als Bedingung ist nicht zulässig, da, wie schon erwähnt, das Gleichheitsprädikat nur für Gleichheitstypen definiert ist, der Typ e x p r aber einen Funktionstyp enthält.) Eine Abstraktion ist immer in weak normal form, also könnte e v a l sie eigentlich unverändert als Ergebnis liefern. Nun kann es aber sein, daß im Rumpf der Abstraktion freie Variablen enthalten sind. Für diese würden dann beim späteren Auswerten einer Applikation mittels lookup Werte aus der gerade aktuellen Umgebung ermittelt. Dies aber entspräche dem dynamischen Binden von Variablen. Bisher sind wir jedoch immer von statischem Binden ausgegangen und wollen dieses Verhalten auch in unserem Interpreter realisieren. Das bedeutet aber, daß wir uns in irgendeiner Form die zur Auswertungszeit der Applikation gültigen Bindungen der freien Variablen merken müssen und auf diese Bindungen bei der Auswertung einer Applikation dann zurückgreifen. Dies erreichen wir, indem wir einfach als Ergebnis einer Abstraktion ein Paar bestehend aus der Abstraktion selbst und der gerade aktuellen Umgebung liefern. Ein solches Paar nennt man closure (Abschluß). Im expr-Datentyp werden closures durch den Konstruktor Clos repräsentiert. In der nachfolgenden Funktionsgleichung für e v a l wird nun nicht die Umgebung Teil der closure, sondern eine Funktion, die die Umgebung liefert. Den Grund für die zusätzliche Abstraktion erklären wir bei der Besprechung des rekursiven let-Blocks.
Abstraktion
closure
e v a l (Abs ( x , e ) ) E = Clos (Abs ( x , e ) , f n ()=>E) Da closures Funktionswerte darstellen, sind sie immer auch in weak normal form und brauchen somit von e v a l nicht weiter vereinfacht zu werden. Bei der Auswertung einer Applikation werden zunächst Funktionsausdruck e und Argument e ' mittels e v a l zu ihrer weak normal form ausgewertet. Dabei erwartet man für nicht vordefinierte Funktionen (für die e v a l ja separat definiert ist) eine closure. Mittels pattern matching kann man so zunächst die Variable x und den definierenden Ausdruck e ' ' der Abstraktion sowie die Funktion zur Erzeugung einer Umgebung E' binden. Dann wertet man e ' ' in der durch den Ausdruck 1 f o r c e E ' gegebenen Umgebung aus, die um die Bindung des ausgewerteten Arguments an x erweitert ist. 1
Zu beachten ist, daß E' keine Umgebung, sondern eine verzögerte Umgebung, das heißt
eine Funktion, ist. Die entsprechende Umgebung erhält man durch die mittels f o r c e erzeugte Applikation E'
().
Applikation
144
6
Implementierungstechniken
e v a l (App ( e , e ' ) ) E = l e t v a l Clos (Abs ( x , e " ) , E ' ) = e v a l e E in e v a l e " ( i n s [ ( x . e v a l e ' E)] ( f o r c e E ' ) ) end Durch die Auswertung des Arguments vor der Bindung an x wird die callby-value Parameterübergabe realisiert. let-AusdrucJc
Die Interpretation für einen let-Ausdruck l e t v a l x=e i n e ' end erweitert die aktuelle Umgebung um die Bindung (x ^ v), wobei v den ausgewerteten Ausdruck e bezeichnet. In dieser erweiterten Umgebung wird dann der Ausdruck e ' ausgewertet. e v a l (Let ( x , e , e ' ) ) E = e v a l e '
rekursiver let-Block
( i n s [ ( x . e v a l e E)] E)
Ein rekursiver l e t -Block wird im Prinzip ganz ähnlich interpretiert wie nicht-rekursiver let-Ausdruck. Es gibt jedoch zwei Unterschiede: Zum einen müssen wir anstelle einer einzigen Definition eine ganze Liste von Definitionen in die aktuelle Umgebung einfügen - dies bereitet wohl kaum Probleme. Zum anderen aber, und dies ist schon wesentlich komplizierter zu bewerkstelligen, können die definierenden Ausdrücke die definierten Variablen enthalten. Betrachten wir als Beispiel den Ausdruck
ejn
l e t val rec f = el and g = e2 in e end wobei wir annehmen wollen, daß g und f in den Ausdrücken e l und e2 vorkommen. Angenommen der let-Block ist von e v a l in der Umgebung E auszuwerten (die Repräsentation als ML-Objekt ist LetRec ( [ ( f , e l ) , ( g , e 2 ) ] , e ) ) , so muß die Auswertung von e in der Umgebung E' = i n s [ ( f , v l ) , ( g , v 2 ) ] E erfolgen, wobei v i und v2 die Ergebnisse der Auswertung von e l bzw. e2 sind. Deren Auswertung muß jedoch ebenfalls in E' erfolgen, da in ihnen g und f verwendet werden. Wie kann man diese zirkuläre, scheinbar widersprüchliche Situation auflösen? Wir bemerken zunächst, daß Rekursion nur in der Definition von Funktionen möglich ist. Das heißt, nur falls z.B. e l eine Abstraktion ist, muß man e l in der Umgebung E' auswerten, ansonsten kann man die Auswertung wie im Falle von l e t in der Umgebung E vornehmen. Ist e l nun ein Ausdruck der Form
6.2 Ein Interpreter für Mini-ML
145
Abs ( x , e ' ) , so muß v i folglich eine closure sein, das heißt ein Paar (Abstraktion, Umgebung). Die Umgebung wird aber erst dann benötigt, wenn die closure appliziert wird, das heißt, die in ihr enthaltenen Bindungen brauchen beim Erzeugen der Umgebung noch nicht voll ausgewertet zu sein, es reicht aus, wenn die Werte erst bei der Applikation zur Verfügung stehen. Dies können wir ausnutzen und die Berechnung der Umgebung E' mit der Methode aus Abschnitt 2.2 verzögern. Wir setzen also anstelle der Umgebung E' eine Funktion Efun in die closure ein, die bei der späteren Applikation der closure eine Umgebung liefert. Zum Erzeugen von Definitionspaaren verwenden wir eine Hilfsfunktion d e f , deren erste zwei Parameter eine Umgebung E sowie eine Funktion Efun sind (Efun repräsentiert die verzögerte Aktualisierung von E). Nun wertet def ihren dritten Parameter, ein ( v a r * expr)-Paar aus der Liste der Definitionen des let-Blocks, wie beschrieben aus: Eine Abstraktion liefert eine closure mit Efun als verzögerter Umgebung, während alle anderen Ausdrücke mittels e v a l in der Umgebung E ausgewertet werden. f u n def E Efun (x,Abs ( y , e ) ) = ( x . C l o s (Abs ( y , e ) , E f u n ) ) I def E Efun ( x , e ) = ( x . e v a l e E) Nun müssen wir noch erklären, wie Efun zu definieren ist. Dann kann man def auf alle Definitionen des let-Blocks anwenden und die aktuelle Umgebung um die resultierende Liste von Bindungen erweitern. An dieser Stelle kommt die Zirkularität ins Spiel: Wir definieren Efun als Funktion, die def auf alle Definitionen des let-Blocks anwendet und das Ergebnis in E einbringt. Der zweite Parameter von def ist dabei Efun selbst. e v a l (LetRec ( d , e ) ) E = l e t f u n E f u n () = i n s (map (def E Efun) d) E in e v a l e ( f o r c e Efun) end Alternativ zur verzögerten Auswertung kann man die Zirkularität in rekursiven Definitionen durch Verwendung von Seiteneffekten realisieren: Man generiert zunächst nur einen Platzhalter für die Umgebung und überschreibt diesen später, wenn alle Definitionen erfolgt sind. Diese Technik werden wir in Abschnitt 6.3.3 verwenden. Der komplette Interpreter ist noch einmal in Abbildung 6.2 dargestellt. Aufgabe 6.2 Schreiben Sie einen Interpreter für aussagenlogische Formeln:
verzögerte Auswertung
von E'
6
146
Implementierungstechniken
type var = string datatype expr = Int of int Bool of bool Con of var * expr list Var of var UnOp of var * expr BinOp of var * expr * expr Cond of expr * expr * expr App of expr * expr Abs of var * expr Let of var * expr * expr LetRec of (var * expr) list * expr Clos of expr * env lazy withtype binding = var * expr and env = binding list fun ins 1 E = 1SE fun lookup (x,(y,e)::E)
if x=y then e else lookup (x,E)
fun apply1 "not" (Bool x) = Bool (not x) I apply1 "hd" (Con ("cons",[x,y])) = x I apply1 "tl" (Con ("cons",[x.y])) = y (Int x,Int y) = apply2 apply2 (Int x,Int y) = apply2 II £ II (Int x,Int y) = apply2 II y II (Int x.Int y) = apply2 (Int x,Int y) = apply2 (Int x,Int y) = apply2 "=" (Bool x.Bool y) apply2 "=" (Con (c,l), Con Bool (c=c' andalso alleq and alleq ([],[]) = true I alleq (x::l,y::l') = (fn Bool b=>b) (apply2 "=" andalso alleq (1,1')
Int (x+y) Int (x-y) Int (x*y) Bool (x>y) Bool (x ),E') = eval e E in eval e'' (ins [(x,eval e' E)] (force E')) end eval (Abs (x,e)) E = Clos (Abs (x,e),fn ()=>E) eval (Let (x,e,e')) E = eval e' (ins [(x.eval e E)] E) eval (LetRec (d,e)) E = let fun NewE () = ins (map (def E NewE) d) E in eval e (force NewE) end A b b i l d u n g 6.2: Interpreter für Mini-ML
6.3 Ein Compiler für Mini-ML
147
a) Definieren Sie zunächst einen Datentyp term, der logische Konstanten, Variablen sowie Ausdrücke mittels Und, Oder und Negation repräsentiert. b) Definieren Sie eine Funktion prop, die eine Aussage (das heißt einen Ausdruck vom Typ term) auswertet. Als zweiten Parameter soll p r o p eine Liste von Variablen erhalten, die den Wahrheitswert von Variablen im auszuwertenden Ausdruck festlegt: Eine Variable ist genau dann wahr, wenn sie in der Argumentliste enthalten ist. c) Schreiben Sie eine Funktion t a u t , die überprüft, ob eine Aussage eine Tautologie ist. (Hinweis: Eine einfache Möglichkeit besteht darin, die Aussage für alle möglichen Variablenbelegungen mittels p r o p auszurechnen. Dazu müssen die Variablen der zu prüfenden Aussage extrahiert werden und daraus alle möglichen Teilmengen gebildet werden. Jede dieser Teilmengen stellt eine bestimmte Variablenbelegung dar. Für jede solche Teilmenge wird p r o p dann ausgewertet.)
6.3 Ein Compiler für Mini-ML Die im vorigen Abschnitt dargestellte Implementierung war in gewissem Sinne eine „Mogelpackung", da der Interpreter selbst in ML geschrieben ist und somit eine Implementierung der zu implementierenden Sprache voraussetzt.1 Ein entscheidendes Element funktionaler Sprachen ist zweifelsohne die Rekursion. In der Interpreter-Implementierung haben wir davon intensiv Gebrauch gemacht. Es gibt allerdings nur wenige Prozessor-Architekturen, die Rekursion elementar zur Verfügung stellen. Unter diesem Gesichtspunkt sind wir von einer realistischen Implementierung doch noch ein gutes Stück entfernt. Deshalb betrachten wir im folgenden die Übersetzung der funktionalen Kern-Sprache in eine Maschinensprache. Allerdings beziehen wir uns nicht auf einen konkreten Prozessor, sondern auf eine abstrakte Maschine, die SECD-Maschine. Wir beschreiben in Abschnitt 6.3.1 die Architektur der SECD-Maschine sowie die Maschinenbefehle und deren Wirkung. In Abschnitt 6.3.2 zeigen wir dann, wie man Mini-ML in SECDCode übersetzen kann. Den Compiler geben wir wie den Interpreter direkt als ML-Funktion an. Die SECD-Maschine beschreiben wir zunächst auf einer etwas abstrakteren Ebene und zeigen eine ML-Implementierung in Abschnitt 1
Auch wenn es in der Compiler-Entwicklung durchaus üblich ist, mit einer existierenden Implementierung einer Sprache S einen neuen, möglicherweise optimierten Compiler für S (oder eine Obermenge von 5) in S zu realisieren, so haben wir hier lediglich mit einer Sprache S eine Untermenge von S implementiert.
6 Implementierungstechniken
148
6.3.4, nachdem wir in Abschnitt 6.3.3 einige Anforderungen erarbeitet haben, die durch die Behandlung von rekursiven Definitionen entstehen.
6.3.1 Die SECD-Maschine Die SECD-Maschine arbeitet mit vier Registern. Die Bezeichnungen der Register geben der SECD-Maschine ihren Namen:
Transitionsregeln
Laden von Konstanten
S
Stack. Der Stack dient zur Ablage von Zwischenergebnissen bei der Berechnung von Ausdrücken.
E
Environment. Im Environment werden Variablen-Bindungen verwaltet, die zur Auswertung von Ausdrücken benötigt werden.
C
Control. Dies ist der eigentliche Programmspeicher. Etwas ungewöhnlich erscheint vielleicht, daß das Programm in einem Register gehalten wird. Der Grund dafür ist, daß Befehlsfolgen zur Laufzeit dynamisch in den Programmspeicher geladen werden müssen, siehe Dump.
D
Dump. Dieses Register dient zum Zwischenspeichern kompletter Maschinenkonfigurationen. Dies ist immer bei einer Funktionsapplikation erforderlich: In der SECD-Maschine werden Funktionen als closures repräsentiert, wobei eine closure aus Maschinencode und einer Umgebung besteht, in dem dieser Code ausgeführt werden soll. Um die closure applizieren zu können, muß man den Code in den Programmspeicher bringen und das Environment installieren. Die alten Zustände dieser Register werden dann auf dem Dump gesichert und nach Abarbeitung der Applikation reinstalliert.
Der Vorrat an Maschinenbefehlen ist in Abbildung 6.3 dargestellt. Die SECD-Maschine kann man sich als Automaten vorstellen, dessen aktueller Zustand jeweils durch ein Viertupel (S,E,C,D) gegeben ist. Die Funktionsweise der SECD-Maschine ist dann durch Transitionsregeln der Form (S,E,C,D) h (S',E',C',D') gegeben, die wir für jeden Befehl anschließend beschreiben werden. In der Tat sind alle Register Stacks, die in den Transitionsregeln unter Zuhilfenahme der ML-Listennotation dargestellt werden, das heißt, einen Stack mit oberstem Element x und Rest-Stack s schreiben wir als x: :s. Die Ladebefehle (LD, LDV und LDC) bringen allesamt ihr jeweiliges Argum e n t auf den Stack. Die einfachste Lade-Operation ist LD: (S,£,LDx: :C,D) b
Laden von Variablen
(x::S,E,C,D)
Beim Laden einer Variablen muß deren Wert im Environment nachgesehen werden. Dies geschieht in etwas anderer Weise als in der Interpreter-
6.3 Ein Compiler für Mini-ML
149
LD
Laden einer Konstanten
LDV
Laden einer Variablen
LDC
Laden einer closure
LDT
Laden eines Konstruktor-Terms
APP
Applizieren einer closure
RAP
Rekursives Applizieren
DUM
Erzeugen von „Dummy"-Einträgen im Environment
COND
Bedingter Sprung zu einer Befehlsfolge
RET
Rückkehr nach bedingtem Sprung
ADD
eingebaute Befehle
HD EQ
Abbildung 6.3: Befehle der SECD-Maschinen
Implementierung: Eine Umgebung wird in der SECD-Maschine nicht als Liste von Bindungen dargestellt, sondern einfach als Liste von Werten. Die Position des zu einer Variablen gehörenden Wertes wird bereits bei der Übersetzung des funktionalen Programms berechnet und erscheint als Argument des LD-Befehls. (S, x\ ::...:
:xn:
LDV n\:C,D)
h (xn: :S,X\:
:
... : :xn:
:E,C,D)
Der LDC-Befehl hat als Parameter eine Liste von Maschinenbefehlen (C), die der Übersetzung einer Funktion entsprechen. Aus dieser Liste und der aktuellen Umgebung wird bei Abarbeitung des LDC-Befehls eine closure (CL ( C ' , £ ) ) erzeugt, die auf den Stack geladen wird. (Wir kennzeichnen closures der SECD-Maschine mit dem Konstruktor CL.) (S,E, (LDC C"): :C,D)
b (CL ( C , E ) :
Laden von Funktionen
:S,E,C,D)
Beim Laden eines Konstruktor-Terms werden zunächst die Argumente auf den Stack geladen. Dies geschieht über entsprechende Ladebefehle für die Argumente, die sich dann in umgekehrter Reihenfolge auf dem Stack befinden. Anschließend bewirkt die Ausführung des Befehls LDT ( c , n ) , daß die obersten n Argumente vom Stack genommen werden und der daraus mittels c erstellte Konstruktor-Term auf dem Stack erscheint.
Laden von KonstruktorTennen
150 (xn Applizieren
von
Funktionen
6
: : . . . : : x i : :S,£,LDT (c ,n) : :C,D) h ( c ( * i , . . .
Rückkehr aus
(C,E')::x::S,E,APP::C,D)
h
von
primitiven Operationen
:S,E,C,D)
{{],x::E',C,(S,E,C)::D)
Das Laden des closure-Codes in den Progammspeicher entspricht einem Sprung in konventionellen Maschinen. Wir müssen noch erklären, wie nach Abarbeitung des Codes der entsprechende „Rücksprung" erfolgt. Das Ende des Funktionscodes erkennt man ganz einfach an einem leeren Programmspeicher. Dann steht das Resultat der entsprechenden Applikation (je) oben auf dem Stack. Nun wird der auf dem Dump gesicherte Maschinenzustand wiederhergestellt, wobei x nicht verworfen, sondern oben auf den gesicherten Stack gepackt wird. (x::S,E,ü,(S',E',C')::D)
Applizieren
,xn):
Bei der Bearbeitung des APP-Befehls wird auf dem Stack eine closure CL (C' ,E') erwartet sowie das Argument x, auf das die closure appliziert wird. Im weiteren soll dann der Code C" im um x erweiterten Environment E' ausgeführt werden, das heißt, die Übersetzung der durch C' repräsentierten Funktion erfolgt so, daß Referenzen auf das Funktionsargument als Zugriffe auf die erste Variable des Environments (mit LDV 1) dargestellt werden und die Numerierung der globalen Variablen bei 2 beginnt. Die Ausführung der Applikation erreicht man nun durch Installation von C' im Programmspeicher und von x::E' als neuer Umgebung. Ist C' vollständig abgearbeitet, wird der in C auf APP folgende Befehl ausgeführt, und zwar in der alten Umgebung. Dies bedeutet, daß sowohl der restliche Programmspeicher als auch die aktuelle Umgebung vor Auswertung der closure gesichert werden müssen. Dazu wird der Dump verwendet: Dort wird der gesamte Maschinenzustand gespeichert, wie er nach Beendigung der closure-Bearbeitung wiederherzustellen ist. (CL
Funktionsaufrufen
Implementierungstechniken
h
(x:
:S',E',C',D)
Bei der Applikation von eingebauten Befehlen (zumindest bei den zweistelligen) ist wie schon beim Laden von Konstruktor-Termen zu beachten, daß ¿jg Argumente in umgekehrter Reihenfolge auf dem Stack liegen. Dies ergibt sich aus der Übersetzung von Programmen in Maschinencode (siehe Abschnitt 6.3.2). Ansonsten sind die Transitionen offensichtlich: (y:
:S,£,ADD: :C,D) h
{y::x::S,E,EQ::C,D)
(cons(x,y)
:
:S,E,HD:
h
:C,D)
h
{x + y:
:S,E,C,D)
{x = y:
:S,E,C,D)
(x::S,E,C,D)
6.3 Ein Compiler für Mini-ML
151
Der COND-Befehl realisiert einen bedingten Sprung. Auf dem Stack wird ein boolescher Wert erwartet, der die Auswahl einer von zwei Codesequenzen bestimmt. Nach Ausführung einer der beiden Alternativen wird mit dem nächsten Befehl im Programmspeicher fortgefahren. Deshalb muß der Code auf dem Dump gesichert werden. Stack und Environment bleiben prinzipiell erhalten und brauchen daher nicht gesichert zu werden. Wir übergeben daher zwei leere Listen in den Dump. ( t r u e : :S,is,C0ND (Ci ,C 2 ) : :C,D)
bedingter
Sprung
b (S,E,C\ , ( • , • ,C): :D)
( f a l s e : :S,£,C0ND (C\ ,C 2 ) : :C,D) h (S,E,C2, ( • , • ,C): :D) Der Rücksprung aus einer COND-Verzweigung erfolgt durch den Befehl RET (durch die Übersetzung wird sichergestellt, daß jede der beiden Alternativen C\ und als letzten Befehl RET enthält). Daher kann man die Fallunterscheidung von der Applikation unterscheiden und beim Rücksprung lediglich den Code reinstallieren und die leeren Listen für Stack und Environment ignorieren. (S,E, [RET], ( [ ] , [ ] , C): :D) b
(S,E,C,D)
Bei der Ausführung eines RAP-Befehls ergibt sich ein ähnliches Zirkularitätsproblem wie schon bei der Interpretation des rekursiven let-Blocks in Abschnitt 6.2. Um die Arbeitsweise der SECD-Maschine bei diesem Befehl (und auch beim Befehl DUM) genau beschreiben zu können, müssen wir den Zusammenhang zwischen einem rekursiven let-Ausdruck und dem RAP-Befehl verstehen, und dies erfordert Kenntnisse über die Übersetzung in Maschinensprache. Deshalb schieben wir die Behandlung von Rekursion auf, bis wir im nächsten Abschnitt die Übersetzung näher betrachtet haben.
Applikation rekursiver Funktionen
Ein Maschinenprogramm C wird nun von der SECD-Maschine wie folgt bearbeitet: Beginnend mit dem Zustand ( [ ] , [ ] , C, []) werden die Transitionsregeln so lange angewandt, bis Programmspeicher und Dump leer sind. Das Ergebnis befindet sich dann auf dem Stack. Im folgenden wollen wir noch im Hinblick auf eine ML-Implementierung der SECD-Maschine deren Strukturen durch Angabe von ML-Datentypen noch etwas konkretisieren. Es gibt im Prinzip zwei Arten von Daten: Werte, die auf dem Stack und im Environment vorkommen, sowie Befehle, die hauptsächlich im Programmspeicher stehen. Mit dem Datentyp v a l u e können Werte vordefinierter Typen, Konstruktor-Terme sowie closures repräsentiert werden:
Datentypen SECD-
für
Implementierung
152
6
Implementierungstechniken
datatype value = I of int I B of bool I T of string * value list I CL of code list * value list Anstatt zwischen integer- und boolean-Konstanten zu unterscheiden, könnte man auch die Wahrheitswerte durch 0 und 1 repräsentieren. Dies ist sicherlich Geschmackssache. Die gewählte Definition macht aber die Struktur des Übersetzers ein wenig klarer, außerdem benötigen wir in jedem Fall eine Datentypdefinition, da wir sowohl closures als auch Konstruktor-Terme nicht über integers darstellen können. (Man beachte auch, daß wegen der wechselseitigen Rekursion die Datentypdefinition zusammen, das heißt durch and verbunden, mit der Definition von code erfolgen muß.) Tatsächlich ist die Definition von v a l u e noch ein wenig komplizierter. Wir werden dies später im Zusammenhang mit der Behandlung der Rekursion in der SECDMaschine erläutern. Die Definition des Datentyps code ergibt sich fast automatisch aus der obigen Beschreibung der SECD-Befehle: datatype code = LD of value LDV of int LDC of code list LDT of string * int APP RAP of int DUM of int COND of code list * RET ADD | SUB | MULT | NOT | EQ | LT | GT
| HD | TL
Aufgabe 6.3 Das folgende Maschinenprogramm Successor-Funktion auf die Zahl 3
beschreibt
die
Anwendung
der
[LD (I 3),LDC [LDV 1,LD (I 1),ADD],APP] Vollziehen Sie die Bearbeitung des Programms durch die SECD-Maschine nach, das heißt, geben Sie die entsprechende Folge von Maschinenzuständen an.
6.3 Ein Compiler für Mini-ML
153
6.3.2 Übersetzung von Mini-ML in SECD-Code Wir hatten in der Erklärung zum LDV-Befehl bereits erwähnt, daß im Environment lediglich Werte (ohne Variablennamen) stehen und daß der Zugriff über die Positionen der Werte in der Environment-Liste erfolgt. Diese Positionen werden vom Compiler berechnet. Daher müssen die Übersetzungsregeln relativ zu einer Liste von Variablennamen formuliert werden, die der Liste von Werten im Environment entspricht. Dagegen benötigen wir das Environment selbst (das heißt die Werte) bei der Übersetzung nicht. Die Namensliste wird nun während der Übersetzung z. B. durch Abstraktion oder l e t - A u s d r u c k erweitert, ganz so wie es mit dem Environment zur Laufzeit geschieht. Bei der Übersetzung eines Variablennamens wird dann die Position des Namens in der Liste ermittelt, und dies ergibt den Parameter des LDV-Befehls. Da die Namensliste während der Übersetzungsphase genauso auf- und abgebaut wird wie das Environment zur Laufzeit der SECD-Maschine, ist gewährleistet, daß die für Variablen ermittelten Positionswerte stets auf den richtigen Wert im Environment verweisen. Im folgenden entwickeln wir die Funktion c o m p i l e , mit der ein Ausdruck des Typs e x p r (abhängig von einer Liste von Variablen) in eine Liste von SECD-Maschinenbefehlen übersetzt werden kann. Konstanten
werden direkt in Ladebefehle übersetzt:
Konstanten
compile (Int i) N = [LD (I i)] compile (Bool b) N = [LD (B b)] Ebenso werden für Variablen Ladebefehle generiert. Dabei ermittelt die Variablen Funktion p o s i t i o n die Stelle der Variablen v in der Namensliste N. compile (Var x) N = [LDV (position x N)] fun position x (y::1) = if x=y then 1 eise 1+position x 1 Die Übersetzung von Variablen ist im übrigen die einzige Stelle, an der in der Namensliste N gesucht wird. Zunächst wird Code für die Argumente eines Konstruktor-Terms erzeugt Konstruktor-Tenne (die resultierenden Codesequenzen werden mittels l i s t C o n c a t aneinandergehängt). Anschließend wird der LDT-Befehl generiert, der neben dem Namen des Konstruktors auch noch die Anzahl der Argumente als Parameter hat. Nur so kann die entsprechende Transition der SECD-Maschine die korrekte Anzahl von Werten auf dem Stack finden.
154
6
Implementierungstechniken
compile (Con (c,l)) N = listConcat (map (fn e=>compile e N) 1)0[LDT (c.length 1)] vordefinierte Funktionen
Die Übersetzung von vordefinierten Funktionen erzeugt zunächst Code für die Argumente und hängt dann an die Befehlsliste einen Maschinenbefehl an, der die vordefinierte Funktion realisiert. Die Entsprechung zwischen vordefinierten Befehlen und Maschinenbefehlen ist in der Funktion cmd festgehalten. fun cmd "+" = ADD I cmd "hd" = HD I cmd "=" = EQ
Damit lautet die Übersetzung für unäre und binäre Funktionen: compile (UnOp (f,e)) N = compile e NO[cmd f] compile (BinOp (f,el,e2)) N = compile el NOcompile e2 NO[cmd f] An der Übersetzung für binäre Funktionen kann man nun auch erkennen, warum binäre Maschinenbefehle ihre Argumente in umgekehrter Reihenfolge auf dem Stack vorfinden. Denn die Übersetzung des Ausdrucks BinOp ("-",Int 4,Int 1) ergibt offensichtlich die Befehlsfolge [LD (I 4) ,LD (I 1),SUB] Die Abarbeitung dieser Sequenz durch die SECD-Maschine bringt zunächst die 4 und danach die 1 auf den Stack, das heißt, der Befehl SUB findet sein zweites Argument über dem ersten auf dem Stack. (Entsprechendes gilt auch für die zuvor beschriebene Übersetzung von Konstruktor-Termen.) Fallunterscheidung
Die Fallunterscheidung wird in einen COND-Befehl übersetzt. Da dieser den booleschen Wert der Bedingung auf dem Stack erwartet, wird die Übersetzung der Bedingung dem COND-Befehl vorangestellt. Das Argument des COND-Befehls ist ein Paar bestehend aus den Übersetzungen der beiden Alternativen, jeweils gefolgt von einem RET-Befehl. compile (Cond (e,el,e2)) N = compile e NO [COND (compile el NO[RET], compile e2 NO[RET])]
155
6.3 Ein Compiler für Mini-ML
Der APP-Befehl erwartet eine closure und darunter das Argument der Applikation auf dem Stack. Daher wird zunächst das Argument übersetzt und danach der zu applizierende Ausdruck.
Applikation
compile (App ( e , e ' ) ) N = compile e ' NScompile e N@[APP] Eine Abstraktion Abs ( x , e ) wird in einen LDC-Befehl übersetzt, dessen Argument die aus der Übersetzung des Funktionsrumpfes e resultierende Befehlsfolge ist. Zu beachten ist, daß e relativ zu der um x erweiterten Namensliste übersetzt wird.
Abstraktion
compile (Abs ( x , e ) ) N = [LDC (compile e ( x : : N ) ) ] Die Übersetzung eines let-Ausdrucks ergibt sich direkt aus der Korrespon- let-Ausdruck denz des Ausdrucks Let (x ,e , e ' ) zum Ausdruck App (Abs ( x , e ' ) , e ) . compile (Let ( x , e , e ' ) ) N = compile e N@[LDC (compile e '
(x::N)),APP]
Die Übersetzung eines rekursiven let-Blocks betrachten wir zusammen mit den noch ausstehenden Transitionsregeln im nächsten Abschnitt. Aufgabe 6.4 Übersetzen Sie den folgenden Ausdruck (die Repräsentation der Funktion twice) in SECD-Code. Abs ( " f " , A b s ("x",App (Var " f " , App (Var " f " , V a r
"x"))))
6.3.3 Behandlung von Rekursion Wir untersuchen zunächst die Übersetzung eines rekursiven let-Blocks und beschreiben daran anschließend die dazugehörigen Transitionsregeln der SECD-Maschine. Der Unterschied zwischen einem rekursiven let-Block und einem l e t Ausdruck ist zum einen, daß mehrere Definitionen vorgenommen werden, und zum anderen, daß die definierenden Ausdrücke die definierten Variablen enthalten können. Entsprechend wollen wir die Übersetzung von LetRec-Ausdrücken in zwei Schritten erklären: Zunächst betrachten wir die
156
6
Implementierungstechniken
Erweiterung eines let-Ausdrucks auf mehrere definierte Variablen. Danach behandeln wir die Rekursion. Die Übersetzung eines Ausdrucks Let (x, e , e ' ) erzeugt zunächst Code für e (z.B. C), gefolgt vom Code für e ' (z.B. LDC C') und einem APPBefehl. Zur Laufzeit bewirkt der Code C, daß der Wert von e (z.B. v) oben auf dem Stack erscheint, LDC C' lädt eine closure darüber, und APP veranlaßt die Ausführung von C' im um v erweiterten Environment. Betrachten wir nun die erweiterte Form L e t ' ( d , e ) , wobei d = [ ( x l , e 1 ) , . . . , (xn, en) ] ist. Wir müssen sowohl die Übersetzung anpassen als auch die SECD-Transitionsregel für APP. Die Übersetzung muß zunächst Code für alle Definitionen erzeugen, das heißt die konkatenierte Folge der Befehle C i . . . Cn, wobei C, der Code für den Ausdruck e i ist. Anschließend wird der LDC-Befehl mit dem Code für e generiert. Dabei ist zu beachten, daß die Übersetzung von e relativ zu einer um [ x l , . . . , xn] erweiterten Namensliste erfolgt. Darüber hinaus muß der abschließende APP-Befehl Kenntnis über die Anzahl n der auf dem Stack liegenden Definitionen haben, um genau diese auf das Environment laden zu können. Daher wird der APP-Befehl mit einem entsprechenden Parameter ausgestattet. Die Übersetzung lautet nun: compile ( L e t ' ( d , e ) ) N = l e t val n = length d v a l x i = map #1 d v a l e i = map #2 d v a l c i = l i s t C o n c a t (map ( f n e=>compile e N) e i ) in ci®[LDC (compile e (xi@N)),APP n] end Die Listen x i bzw. e i enthalten die definierten Variablen bzw. die definierenden Ausdrücke. Die Befehlsfolge c i erhält man durch Übersetzung eines jeden Ausdrucks in e i und anschließender Konkatenation der einzelnen Codesequenzen. Den kompletten Code erhält man durch Anhängen der Übersetzung von e (in der um x i erweiterten Namensliste N) und des Befehls APP n, wobei n die Anzahl der Definitionen ist. Die Transitionsregel für APP muß nun n Werte auf das Environment der zu applizierenden closure laden. Zu beachten ist dabei wiederum, daß die Werte in gespiegelter Reihenfolge auf dem Stack erscheinen. (CL ( C ' , E ' ) : :xn::...: :x\: :S,E,kPP n: :C,D) h(ü,xr.:...-.:xn::E',C',(S,E,C)::D)
6.3 Ein Compiler für Mini-ML
157
Wenden wir uns der Rekursion in den Ausdrücken e i zu. Um Verweise auf beliebige x j korrekt zu behandeln,muß die Übersetzung eines jeden Ausdrucks e i in der um [ x l , . . . , xn] erweiterten Namensliste erfolgen. An die Übersetzung wird ein RAP-Befehl angefügt, dessen Transitionsregel sich von der des APP-Befehl deutlich unterscheidet. Da die e i in einem rekursiven let-Block Funktionen sein müssen, 1 werden diese in LDC-Befehle übersetzt. Zur Laufzeit wird dann für jeden LDC-Befehl eine closure CL,=CL (C, ,E') gebildet, die auf den Stack geladen wird. Das Environment E' einer jeden solchen closure muß aber das Environment sein, das bei Abarbeitung des RAP-Befehls durch Laden der closures in das aktuelle Environment erst entsteht, denn in den Codesequenzen C, können ja Ladebefehle für beliebige Werte (das heißt also für beliebige CL,) vorkommen. Hier ergibt sich eine ähnlich zirkuläre Situation wie im Interpreter aus Abschnitt 6.2.2. Über Gleichungen läßt sich der Zustand (S',E',C,D) der SECD-Maschine nach Laden aller closures auf den Stack wie folgt beschreiben: 5" = CL ( C „ , £ ' ) : : . . . : :CL ( C i , E ' ) : : S E'= CL (Ci , £ ' ) : : . . . : :CL
(Cn,E')::E
Wie kann man dieses Verhalten nun in einer der SECD-Maschine angemessenen Weise2 realisieren? Wir bemerken, daß die closures CL, zunächst (das heißt bis zur Ausführung des RAP-Befehls) lediglich auf den Stack geladen werden und nicht appliziert werden. Daher wird auf das jeweils enthaltene Environment E' auch noch nicht zugegriffen. Es reicht somit aus, in E' für die Werte e i zunächst nur Platzhalter vorzusehen, in die dann nach Bearbeitung der letzten Definition alle Werte eingetragen werden können. Dies bedeutet in der Tat ein nachträgliches, imperatives Überschreiben im Environment E'. Eine Liste von n Platzhaltern wird durch den Befehl DUM n vorne an das aktuelle Environment angefügt. Da dieses erweiterte Environment von allen closures CL, benötigt wird, erscheint der DUM-Befehl vor der Übersetzung der Ausdrücke e i . Damit wird ein rekursives l e t also wie folgt übersetzt: 1
Im Interpreter haben wir dies nicht verlangt; dort konnten auch andere Werte verarbeitet werden. 2
Das heißt ohne Verwendung rekursiver Funktionen, denn die SECD-Maschine soll ja ein einfaches, an reale Prozessor-Architekturen angelehntes Maschinenmodell sein.
158
6
Implementierungstechniken
compile (LetRec (d,e)) N = let val n = length d val xi = map #1 d val ei = map #2 d val ci = listConcat (map (fn e=>compile e (xi®N)) ei) in DUM n::ci0[LDC (compile e (xi®N)),RAP n] end Betrachten wir nun die Verarbeitung durch die SECD-Maschine. Zunächst erzeugt der DUM-Befehl ein Environment mit n Platzhaltern an der Spitze. Diese Platzhalter können wir uns als Zeiger auf einen Undefinierten Wert vorstellen. (S,£,DUMn: :C,D) mit E':=
1 V
h
::...:: v /i-mal
(S,E',C,D) _L : :E '
Der Bezeichnet E' wird hier im Sinne einer imperativen Variablen verwandt: Der durch das imperative Überschreiben der Platzhalter _L bewirkte Seiteneffekt wird dann an allen Stellen sichtbar, an denen auf E' verwiesen wird. Die folgenden LDC-Befehle laden nun die closures CL, auf den Stack, die jeweils das unfertige Environment E' enthalten. Danach wird die für e übersetzte closure CL = (C',E') (ebenfalls mit Zeiger auf Environment E') auf den Stack geladen. Der RAP-Befehl appliziert nun die closure CL auf die im Stack darunter liegenden closures CL,. Das erforderliche Laden dieser closures in das Environment E' erfolgt durch Überschreiben der darin enthaltenen Platzhalter. Der Zustand vor Ausführung des RAP-Befehls ist also: (CL ( C " , £ ' ) : : C L (Cn,E')::...:
:CL (C\ ,£') : :S,E',MP
n:
:C,D)
Nun werden die folgenden Zuweisungen an die Platzhalter in E' ausgeführt: £"[1] := CL (Cj ,E'y,...-E'[n] := CL ( C n , E ' ) . Mit diesen Seiteneffekten ist dann der Zustand nach Ausführung des RAP-Befehls: (Ü,E',C',(S,E,C)::D) mit £ ' = CL ( C i , £ " ) : : . . . : :CL ( C n , E ' ) : : E
6.3 Ein Compiler für Mini-ML
159
Aufgabe 6.5 Übersetzen Sie den folgenden Ausdruck (die Repräsentation der Fakultätsfunktion) in SECD-Code. LetRec ( [ ( " f " , A b s ( " x " , Cond (BinOp (" N E Sub(M).
Zu Aufgabe 4.2 (Seite 97) Wir definieren zunächst die Menge V aller Variablen, die in einem X-Term vorkommen: V(x) = {x} V(MN) = V(M)UV(A0 V(kx.M) = V(M) Damit ergibt sich für die Menge GV der gebundenen Variablen: GV(M) = V(M) — FV(M). Zu Aufgabe 4.3 (Seite 105) Die Aussage ist wahr. Den Beweis führen wir durch Widerspruch. Wir nehmen an, der linke Redex, bezeichnet mit L, sei kein äußerer Redex. Dies bedeutet, daß L in einem anderen Redex, z.B. A, enthalten ist. Nun hat ein Redex wie A die Form (Xx.M)N, und L kann gemäß der Lösung zu Aufgabe 4.1 nur in A enthalten sein, wenn L C M, LC N oder L = A gilt. Gefordert ist, daß L Subterm eines anderen Terms (als L selbst) ist, so daß L C M oder LCN folgt. Offensichtlich aber steht das X des Terms A links von dem des Terms L, was ein Widerspruch zur Annahme ist, L sei linker Redex.
Zu Aufgabe 4.4 (Seite 107) Wir haben bereits gesehen, daß \fTMN —»ß TMN gilt. Da die Reduktion völlig unabhängig von den konkreten Argumenten abläuft, das heißt, insbesondere unabhängig davon ist, ob T oder F als erstes Argument auftritt, gilt ebenso: If FA/iV —»ß F MN Nun gilt offensichtlich:
173
Lösungen (!Xx.x)TMN
->p
TMN
( k x ) F M i V - > p FMN Damit ergibt sich für If = Xx.x dasselbe Reduktionsverhalten wie für die Version aus Definition 4.10. Wenn nun die Codierung aus Definition 4.10 korrekt ist, so ist es folglich auch die Codierung mit If = Xx.x.
Zu Aufgabe 4.5 (Seite 108) Gemäß Definition 4.11 ist [n] = Xfx.f" (x). Damit ergibt die Applikation eines Church-Numerals auf die Argumente / und x: \n\fx
->p
fn(x)
Es gilt nun: Suc \rî\ ->ß
Xfx.f{\n]fx)
= Xfx.f^ix))
[n+11
=
Für den Zero-Kombinator gilt zum einen: Zero [0] =
(Xn.«(XxF)T)|"0] [0](Xx.F)T
=
(kfx.x)Ckx.
F)T
^ßT Andererseits erhält man für beliebige Nachfolger: Zero (Suc \ri\ ) =
(À/î.«(AxF)T)(Suc [«])
->p (Suc |"n])(Xjc.F)T =
(l/x/(MA))(kF)T (Xx.F)([/il(X*.F)T)
174
Lösungen
Zu Aufgabe 5.1 (Seite 120) a) f u n f x = 0 b) f u n f ( x , y , z ) = i f t r u e t h e n ( z , x ) e i s e
(z,y)
c) Bei der Definition einer neuen Funktion wird zunächst der allgemeinste Funktionstyp ' a -> ' b angenommen. Die Idee besteht nun darin, im definierenden Ausdruck keine Operationen anzuwenden, die den Typ einschränken könnten. Man darf also lediglich Operationen des Typs ' a -> ' b anwenden. Mittels Rekursion kann man dazu die zu definierende Funktion selbst nehmen. fun f x = f x Aber Achtung, die Funktion f terminiert natürlich nicht! d) Hier können wir die Funktion f aus dem vorigen Aufgabenteil benutzen und diese einfach auf eine Zahl anwenden: local fun f x = f x in fun g 0 = f 0 end Alternativ hätte man auch anstelle der 0 eine Variable mit i n t Typrestriktion verwenden können. e) Möglicherweise erscheint diese Aufgabe besonders schwierig. Dennoch gibt es durchaus verschiedene Lösungen: f u n f g = ( f n x=>l) ( f n x=>g (g x ) ) f u n f ( g : ' a -> >a) = 0 Dabei „mogelt" die zweite Definition durch Verwendung von Typrestriktionen ein wenig.
Zu Aufgabe 5.2 (Seite 133) T ( r , (exPl,
exp2))=
{UT,Uti
* x2)
wobei (T,Xi) = T ( r , e x p x ) 0U,x2) =