3. Funktionales Programmieren • Grundkonzepte funktionaler Programmierung • Algorithmen auf Listen und Bäumen • Abstraktion mittels Polymorphie und Funktionen höherer Ordnung • Semantik, Testen und Verifikation 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 1 Inhalt von Kapitel 3: 1. Einführung 2. Grundkonzepte von Softwaresystemen 3. Funktionales Modellieren und Programmieren 3.1 Grundkonzepte funktionaler Programmierung 3.1.1 Zentrale Begriffe und Einführung 3.1.2 Rekursive Funktionen 3.1.3 Listen und Tupel 3.1.4 Benutzerdefinierte Datentypen 3.1.5 Signaturen und Strukturen 3.1.6 Ein- und Ausgabe 3.2 Algorithmen auf Listen und Bäumen 3.2.1 Sortieren 3.2.2 Suchen 3.3 Abstraktion mittels Polymorphie und Funktionen höherer Ordnung 3.3.1 Typisierung 3.3.2 Funktionen höherer Ordnung 3.4 Semantik, Testen und Verifikation 3.4.1 Zur Semantik funktionaler Programme 3.4.2 Testen und Verifikation 4. Prozedurales Programmieren 5. Objektorientiertes Programmieren 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 2 3.1 Grundkonzepte funktionaler Programmierung Vorgehen: • Zentrale Begriffe und Einführung: Funktion, Wert, Typ, Datenstruktur, Auswertung, ... • Rekursive Funktionen • Listen und Tupel • Benutzerdefinierte Datentypen 3.1.1 Zentrale Begriffe und Einführung Funktionale Programmierung im Überblick: • Funktionales Programm: - partielle Funktionen von Eingabe- auf Ausgabedaten - besteht aus Deklarationen von (Daten-)Typen, Funktionen und (Daten-)Strukturen - Rekursion ist eines der zentralen Sprachkonzepte - in Reinform: kein Zustandskonzept, keine veränderlichen Variablen, keine Schleifen, keine Zeiger • Ausführung eines funktionalen Programms: Anwendung der Funktion auf Eingabedaten 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 3 Definition: (partielle Funktion) Ein Funktion heißt partiell, wenn sie nur auf einer Untermenge ihres Argumentbereichs definiert ist. Andernfalls heißt sie total. Beispiel: (partielle Funktion) 1. Bezeichne nat die Menge der positiven ganzen Zahlen und sei fact : nat nat wie folgt definiert: 1 , für n = 0 fact (n-1) * n , für n > 0 fact (n) = Dann ist fact wohldefiniert und total. 2. Bezeichne real die Menge der auf dem Rechner darstellbaren reellen Zahlen. Dann ist die Funktion dividiere : real * real real dividiere (dvd, dvs) = dvd / dvs partiell (Division durch Null ist nicht definiert). 3. Bezeichne string die Menge der Zeichenreihen. Dann ist die Funktion abschneide2, die die ersten beiden Zeichen einer Zeichenreihe abschneidet partiell (warum?) 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 4 Definition: (Funktionsanwendung, -auswertung, Terminierung, Nichtterminierung) Bezeichne f eine Funktion und a ein zulässiges Argument von f. Die Anwendung von f auf a nennen wir eine Funktionsanwendung (engl. function application); üblicherweise schreibt man dafür f(a) oder f a . Den Prozess der Berechnung des Funktionswerts nennen wir Auswertung (engl. evaluation). Die Auswertung kann: - nach endlich vielen Schritten terminieren und ein Ergebnis liefern (normale Terminierung, engl. normal termination), - nach endlich vielen Schritten terminieren und einen Fehler melden (abrupte Terminierung, engl. abrupt termination), - nicht terminieren, d.h. der Prozess der Auswertung kommt (von alleine) nicht zu Ende. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 5 Bemerkungen: • Entsprechendes gilt in anderen Programmierparadigmen. • Da Terminierung nicht entscheidbar ist, benutzt man in der Informatik häufig partielle Funktionen. Beispiel: (Zur Entscheidbarkeit der Terminierung) McCarthy‘s Funktion: Sei m : nat nat n -10 m( n ) = wie folgt definiert: , für n >100 m( m( n+11) ) , für n ≤ 100 Ist m für alle Argumente wohldefiniert? • In der Theorie kann man durch Einführen eines Elements „undefiniert“ jede partielle Funktion total machen. Üblicherweise bezeichnet man das Element für „undefiniert“ mit ⊥ (engl. „bottom“). 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 6 Begriffsklärung: (Wert, Value) Werte (engl. values) in der (reinen) funktionalen Programmierung sind - Elementare Daten (Zahlen, Wahrheitswerte, Zeichen, ...), - zusammengesetzte Daten (Listen von Werten, Wertepaare,...), - (partielle) Funktionen mit Werten als Argumenten und Ergebnissen. Bemerkungen: • In anderen Sprachparadigmen gibt es auch Werte, allerdings werden Funktionen nicht immer als Werte betrachtet. (z.B. in der objektorientierten Programmierung: „immutable objects“) • Im Mittelpunkt der funktionalen Programmierung steht die Definition von Wertemengen (Datentypen) und Funktionen. Funktionale Programmiersprachen stellen dafür Sprachmittel zur Verfügung. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 7 • Wie für abstrakte Objekte oder Begriffe typisch, besitzen Werte - keinen Ort, - keine Lebensdauer, - keinen veränderbaren Zustand, - kein Verhalten. Begriffsklärung: (Typ, type) Typisierte Sprachen besitzen ein Typsystem. Ein Typ (engl. type) fasst Werte zusammen, auf denen die gleichen Funktionsanwendungen zulässig sind. In typisierten Sprachen besitzt jeder Wert einen Typ. In funktionalen Programmiersprachen gibt es drei Arten von Werten bzw. Typen, mit denen man rechnen kann: • Basisdatentypen ( int, bool, string, ... ) • benutzerdef., insbesondere rekursive Datentypen • Funktionstypen ( z.B. int bool oder ( int int ) ( int int ) 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern ) 8 Datenstrukturen Eine Struktur fasst Typen und Werte zusammen, insbesondere also auch Funktionen. Datenstrukturen sind Strukturen, die mindestens einen „neuen“ Datentyp und alle seine wesentlichen Funktionen bereitstellen. Eine Datenstruktur besteht aus einer oder mehrerer disjunkter Wertemengen zusammen mit den darauf definierten Funktionen. In der Mathematik nennt man so ein Gebilde eine Algebra oder einfach nur Struktur. In der Informatik spricht man auch von einer Rechenstruktur. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 9 Definition: (Datenstruktur mit Signatur) Eine Signatur einer Datenstruktur (T,F) besteht aus - einer endlichen Menge T von Typbezeichnern und - einer endlichen Menge F von Funktionsbezeichnern, wobei für jedes f ∈ F ein Funktionstyp f : T1 * ... * Tn T0 , Ti ∈ T, 0 ≤ i ≤ n, 0 ≤ n, definiert ist. n gibt die Stelligkeit von f an. Eine (partielle) Datenstruktur mit Signatur (T,F) ordnet - jedem Typbezeichner T ∈ T eine Wertemenge, - jedem Funktionsbezeichner f ∈ F eine partielle Funktion zu, so dass Argument- und Wertebereich von f den Wertemengen entsprechen, die zu f‘s Funktionstyp gehören. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 10 Bemerkungen: • Wir betrachten zunächst die Basisdatenstrukturen, wie man sie in jeder Programmier-, Spezifikationsund Modellierungssprache findet. • Die Basisdatenstrukturen (engl. basic / primitive data structures) bilden die Grundlage zur Definition weiterer Typen, Funktionen und Datenstrukturen. • Als Beispiel dienen uns die Basisdatenstrukturen der funktionalen Sprache ML. Später lernen wir auch die Basisdatenstrukturen von Java kennen. • Wir benutzen Funktionsbezeichner auch mit Infix-Schreibweise. • Funktionen ohne Argumente nennen wir Konstanten. • Allgemeinere Formen von Strukturen betrachten wir erst am Ende des Kapitels. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 11 Die Datenstruktur Boolean: Typ: bool Funktionen: = : bool * bool bool <> : bool * bool bool not: bool bool Konstanten: true: bool false: bool Dem Typbezeichner bool ist die Wertemenge { true, false } zugeordnet. = bezeichnet die Gleichheit auf Wahrheitswerten <> bezeichnet die Ungleichheit auf Wahrheitsw. not bezeichnet die logische Negation. true bezeichnet den Wert true. false bezeichnet den Wert false. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 12 Bemerkung: Im Folgenden unterscheiden wir nur noch dann zwischen Funktionsbezeichner und bezeichneter Funktion, wenn dies aus Gründen der Klarheit nötig ist. Die Datenstruktur Int: Die Datenstruktur Int erweitert die Datenstruktur Boolean, d.h. sie umfasst den Typ bool und die darauf definierten Funktionen. Zusätzlich enthält sie: Typ: int Funktionen: = : int * int bool (* Gleichheit *) <>: int * int bool (* Ungleichheit *) ~ : int int (* Negation *) + : int * int int (* Addition *) - : int * int int (* Subtraktion *) * : int * int int (* Multiplikation *) abs: int int 14.10.08 (* Absolutbetrag *) © A. Poetzsch-Heffter, TU Kaiserslautern 13 div: int * int int (* ganzzahlige Division *) mod: int * int int (* Divisionsrest *) < : int * int bool (* kleiner *) > : int * int bool (* größer *) <=: int * int bool (* kleiner gleich *) >=: int * int bool (* größer gleich *) Konstanten: - in Dezimaldarstellung: 0, 128, ~245 - in Hexadezimaldarstellung: 0x0, 0x80, ~0xF5 Dem Typbezeichner int ist in ML eine rechnerabhängige Wertemenge zugeordnet, typischerweise die Menge der ganzen Zahlen von -2^30 bis 2^30-1. Innerhalb der Wertemenge sind die Funktionen der Datenstruktur Int verlaufsgleich mit den üblichen Funktionen auf den ganzen Zahlen. Außerhalb der Wertemenge sind sie nicht definiert. Insbesondere bezeichnen +, *, abs, ~ partielle Funktionen. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 14 Begriffsklärung: Wenn unterschiedliche Funktionen oder andere Programmelemente den gleichen Bezeichner haben, spricht man vom Überladen des Bezeichners (engl. Overloading). Beispiel: (Überladung von Bezeichnern) Funktionsbezeichner können überladen werden, d.h. in Abhängigkeit vom Typ ihrer Argumente bezeichnen sie unterschiedliche Funktionen. Die Datenstruktur Real: Die Datenstruktur Real erweitert die Datenstruktur Int, d.h. sie umfasst deren Typen und Funktionen sowie: Typ: real Funktionen: ~ : real real (* Negation *) + : real * real real (* Addition *) - : real * real real (* Subtraktion *) * : real * real real (* Multiplikation *) / : real * real real (* Division *) abs: real real 14.10.08 (* Absolutbetrag *) © A. Poetzsch-Heffter, TU Kaiserslautern 15 < : real * real bool (* kleiner *) > : real * real bool (* größer *) <=: real * real bool (* kleiner gleich *) >=: real * real bool (* größer gleich *) real : int real (* nächste reelle Zahl *) round: real int (* nächste ganze Zahl *) floor: real int (* größte ganze Zahl <= *) ceil : real int (* kleinste ganze Zahl >= *) trunc: real int (* ganzzahliger Anteil *) Konstanten: - mit Dezimalpunkt: 0.0, 1000.0, 128.9, ~2.897 - mit Exponenten: 0e0, 1E3, 1289E~1, ~2897e~3 Dem Typbezeichner real ist in ML eine rechnerabhängige Wertemenge zugeordnet. Entsprechendes gilt für die präzise Semantik der Funktionsbezeichner. Bemerkung: • Die ganzen Zahlen sind in der Programmierung keine Teilmenge der reellen Zahlen! • Keine Gleichheitsoperationen auf reellen Zahlen. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 16 Die Datenstruktur Char: Die Datenstruktur Char erweitert die Datenstruktur Int, also auch Bool. Zusätzlich enthält sie: Typ: char Funktionen: = : char * char bool <>: char * char bool < : char * char bool (* kleiner *) > : char * char bool (* größer *) <=: char * char bool >=: char * char bool chr: int char ord: char int (* char zu ASCII Code *) (* ASCII Code zu char *) Konstanten: Konstantenbezeichner haben die Form #“α“, wobei α - ein druckbares Zeichen ist, - die Form \t, \n, \\, \“, ... hat, - die Form \zzz hat, wobei z eine Ziffer ist und die dargestellte Dezimalzahl ein legaler Zeichencode ist. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 17 Die Datenstruktur String: Die Datenstruktur String erweitert die Datenstruktur Char, d.h. sie umfasst deren Typen und Funktionen. Zusätzlich enthält sie: Typ: string Funktionen: = : string * string bool <>: string * string bool < : string * string bool (* kleiner *) > : string * string bool (* größer *) <=: string * string bool >=: string * string bool ^ : string * string string (* Zeichenreihenkonkatenation *) size: string int (* Länge *) substring: string * int * int string (* Teilzeichenreihe *) str: char string (* entsprechende Zeichenreihe der Länge 1 *) 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 18 Konstanten: Zeichenreihen eingeschlossen in doppelte Hochkommas: “Ich bin ein String!!“ “Ich \098\105\110 ein String!!“ “Mein Lehrer sagt: \“Nehme die Dinge genau!\““ “String vor Zeilenumbruch\n Nach Zeilenumbruch“ Dem Typbezeichner string ist die Menge der Zeichenreihen über der Menge der Zeichen zugeordnet, die von der vorliegenden ML-Implementierung unterstützt werden. Den Vergleichsoperationen liegt die lexikographische Ordnung zugrunde, wobei die Ordnung auf den Zeichen auf deren Code basiert (siehe Datenstruktur Char). 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 19 Bemerkung: • Es wird unterschieden zwischen Zeichen und Zeichenreihen der Länge 1. • Aufbauend auf der Datenstruktur string und der Listendatenstruktur stellt ML auch die Funktionen explode : string char list implode : char list string zur Verfügung. • Jede Programmier-, Modellierungs- und Spezifikationssprache besitzt Basisdatenstrukturen. Die Details variieren aber teilweise deutlich. • Wenn Basisdatenstrukturen implementierungsoder rechnerabhängig sind, entstehen Portabilitätsprobleme. • Der Trend bei den Basisdatenstrukturen geht zur Standardisierung. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 20 Aufbau funktionaler Programme Im Kern, d.h. wenn man die Modularisierungskonstrukte nicht betrachtet, bestehen funktionale Programme aus: • der Beschreibung von Werten: - z.B. (7+23), 30 • Vereinbarung von Bezeichnern für Werte (einschließlich Funktionen): - val x = 7; • der Definitionen von Typen: - type t = ... ; - datatype dt = ... ; Im Folgenden betrachten wir die Sprache ML. ML bietet ein interaktives Laufzeitsystem, das Eingaben obiger Form akzeptiert. Selbstverständlich kann man Eingaben auch aus einer Datei einlesen. Darüber hinaus gibt es Übersetzer für ML. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 21 Beschreibung von Werten: - mittels Konstanten oder Bezeichnern für Werte: 23 “Ich bin eine Zeichenreihe“ true x - durch direkte Anwendung von Funktionen: abs(~28382) “Urin“ ^ “stinkt“ not(true) - durch geschachtelte Anwendung von Funktionen: floor (~3.4) = trunc (~3.4) substring (“Urin“ ^ “stinkt“, 0,3) - durch Verwendung der nicht-strikten Operationen: if <boolAusdruck> then <Ausdruck> else <Ausdruck> <boolAusdruck> andalso <boolAusdruck> <boolAusdruck> orelse <boolAusdruck> 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 22 Begriffsklärung: (Ausdruck, expression) Ausdrücke sind das Sprachmittel zur Beschreibung von Werten. Ein Ausdruck (engl. expression) in ML ist - eine Konstante, - ein Bezeichner (Variable, Name), - die Anwendung einer Funktion auf einen Ausdruck, - ein nicht-strikter Ausdruck gebildet mit den Operationen if-then-else, andalso oder orelse, - oder ist mit Sprachmitteln aufgebaut, die erst später behandelt werden. Jeder Ausdruck hat einen Typ: - Der Typ einer Konstanten ergibt sich aus der Signatur. - Der Typ eines Bezeichners ergibt sich aus dem Wert, den er bezeichnet. - Der Typ einer Funktionsanwendung ist der Ergebnistyp der Funktion. - Der Typ eines if-then-else-Ausdrucks ist gleich dem Typ des Ausdruck im then- bzw. else-Zweig. Der Typ der anderen nicht-strikten Ausdrücke ist bool. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 23 Präzedenzregeln: Wenn Ausdrücke nicht vollständig geklammert sind, ist im Allg. nicht klar, wie ihr Syntaxbaum aussieht. Beispiele: 3 = 5 = true false = true orelse true false andalso true orelse true Präzedenzregeln legen fest, wie Ausdrücke zu strukturieren sind: - Am stärksten binden Funktionsanwendungen in Präfixform. - Regeln für Infix-Operationen: infix 7 *, /, div, mod; infix 6 +, -; infix 4 = <> < > <= >=; Je höher die Präzedenzzahl, desto stärker binden die Operationen. - andalso bindet stärker als orelse. - Binäre Operationen sind linksassoziativ (d.h. sie werden von links her geklammert). - Vergleichsoperationen binden stärker als nichtstrikte Operationen. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 24 Deklaration und Bezeichnerbindung: Bisher haben wir Ausdrücke formuliert, die sich auf die vordefinierten Funktions- und Konstantenbezeichner von ML gestützt haben. Syntaktisch gesehen, heißt Programmierung: - neue Typen, Werte und Funktionen zu definieren, - die neu definierten Elemente unter Bezeichnern zugänglich zu machen. Begriffsklärung: (Vereinbarung, Deklaration) In Programmiersprachen dienen Vereinbarungen oder Deklarationen (engl. declaration) dazu, den in einem Programm verwendeten Elementen Bezeichner/Namen zu geben. Dadurch entsteht eine Bindung (n,e) zwischen dem Bezeichner n und dem bezeichneten Programmelement e. An allen Programmstellen, an denen die Bindung sichtbar ist, kann der Bezeichner benutzt werden, um sich auf das Programmelement zu beziehen. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 25 Bemerkung: • Die Art der Programmelemente, die in Deklarationen vorkommen können, hängt von der Programmiersprache ab. In ML sind es im Wesentlichen Typen und Werte. • Die Regeln, die die Sichtbarkeit von Bindungen bzw. Bezeichern festlegen, sind ebenfalls sprachabhängig und können sehr komplex sein. Wir führen die Sichtbarkeitsregeln schrittweise ein. In Folgenden betrachten wir drei Arten von ML-Vereinbarungen: 1. Wertvereinbarungen 2. Vereinbarungen (rekursiver) Funktionen 3. Vereinbarungen benutzerdeklarierter Typen 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 26 Wertvereinbarungen: • haben (u.a.) die Form: val <Bezeichner> = <Ausdruck> ; val <Bezeichner> : <Typ> = <Ausdruck> ; In der ersten Form wird der Typ des Ausdrucks zum Typ des Bezeichners, in der zweiten Form muss der Typ des Ausdrucks gleich dem vereinbarten Typ sein. • Der rechtsseitige Ausdruck darf nur Bezeichner enthalten, die „vorher“ oder im Ausdruck selbst gebunden wurden. Beispiele: (Wertvereinbarungen) val sieben : real = 7.0 ; val dkv = 3.4 ; val flag = floor ( ~dkv ) = trunc (~dkv) ; val dkv = “Deutscher Komiker Verein e.v.“ ; 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 27 Funktionsvereinbarungen: Zwei Probleme: 1. bisher keine Ausdrücke, die eine Funktion als Ergebnis liefern (Ausnahme: Funktionsbezeichner) 2. Funktionen können rekursiv sein, d.h. der Funktionsbezeichner kommt im definierenden Ausdruck vor. Lösungen: Zu 1.: Erweitere die Menge der Ausdrücke, so dass Ausdrücke Funktionen beschreiben können. Dann kann die obige Wertvereinbarung genutzt werden. Zu 2.: Benutze spezielle Syntax für rekursive Funktionsdeklarationen. Genaueres dazu in Unterabschnitt 3.1.2. Beispiele: (Funktionsvereinbarungen) val string2charl:string->char list = explode; val charl2string = implode ; fun fac(n:int):int = if n=0 then 1 else n*fac(n-1) ; fun fac(n)= if n=0 then 1 else n*fac(n-1) ; 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 28 Typvereinbarungen: Zwei Probleme: 1. bisher keine Ausdrücke, die Typen als Ergebnis liefern 2. Typen können rekursiv sein, d.h. der vereinbarte Typbezeichner kommt im definierenden Typausdruck vor. Lösungen: Zu 1.: Führe Ausdrücke für Typen ein. Zu 2.: Benutze spezielle Syntax für rekursive Typdeklarationen. Genaueres dazu in Unterabschnitt 3.1.4 . Beispiele: (Typvereinbarungen) type intpaar = int * int ; type charlist = char list ; type telefonbuch = (( string * string * string * int ) * string list ) list ; type intNachInt = int -> int ; val fakultaet : intNachInt = fac ; 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 29 Begriffsklärung: (Bezeichnerumgebung) Eine Bezeichnerumgebung ist eine Abbildung von Bezeichnern auf Werte (einschl. Funktionen) und Typen, ggf. auch auf andersartige Programmelemente. Oft spricht man auch von Namensumgebung oder einfach von Umgebung (engl. Environment). Beispiel: (Bezeichnerumgebung) Die elementaren Datenstrukturen von ML bilden die Standardumgebung für ML-Programme. Bemerkung: • Eine Bezeichnerumgebung wird häufig als Liste von Bindungen modelliert. • Jede Datenstruktur definiert eine Bezeichnerumgebung. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 30 Begriffsklärung: (funktionales Programm) Ein funktionales Programm definiert eine Bezeichnerumgebung für die Anwender des Programms. Interaktive Laufzeitumgebungen für funktionale Programme erlauben das schrittweise Anreichern und Nutzen von Bezeichnerumgebungen. Beispiel: (funktionales Programm) val val fun val val val a b f a b d = 7; = 8; (x: int):int = x+a; = 20; = true; = f(a); Frage: Welchen Wert bezeichnet d? Bemerkung: Am obigen Beispiel kann man den Unterschied zwischen Wertvereinbarung und Zuweisung an Programmvariable erkennen. Im Folgenden wird er noch deutlicher werden. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 31 3.1.2 Rekursive Funktionen Eine Funktion kann man durch einen Ausdruck beschreiben, in dem die Argumente der Funktion durch Bezeichner vertreten sind. Um deutlich zu machen, welche Bezeichner für Argumente stehen, werden diese deklariert. Alle anderen Bezeichner des Ausdrucks müssen anderweitig gebunden werden. Diesen Schritt von einem Ausdruck zu der Beschreibung einer Funktion nennt man Funktionsabstraktion oder λ-Abstraktion. Beispiele: (Funktionsabstraktion) 1. Quadratfunktion: Ausdruck: x*x Abstraktion: λ x. x * x ML-Notation: fn x => x * x fn x : real => x * x Vereinbarung eines Bezeichners für die Funktion: val sq = 14.10.08 fn x :real => x * x ; © A. Poetzsch-Heffter, TU Kaiserslautern 32 2. Volumenberechnung eines Kegelstumpfes: Formel: Sei h die Höhe, r,R die Radien; dann ergibt sich das Volumen V zu V= π*h 3 2 2 *(r +r*R+R ) ML-Ausdruck: (Math.pi *h) / 3.0 * ( sq(r) + r * R + sq( R ) ) Abstraktion: λ h, r, R . (Math.pi * h)/3 * ( sq(r) + r * R + sq( R ) ) ML-Notation: fn ( h:real, r:real, R:real ) => (Math.pi * h)/3.0 * (sq(r) + r*R + sq(R)) Vereinbarung eines Bezeichners für die Funktion: val volkegstu = fn ( h, r, R ) => (Math.pi * h)/3.0 * (sq(r) + r*R + sq(R)) ; 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 33 3. Abstraktion über Funktionsbezeichner: Ausdruck: f ( f (x) ) Abstraktion: λ f, x . f ( f (x) ) ML-Notation: fn (f: real -> real, x) => f(f(x)) Vereinbarung eines Bezeichners für die Funktion: val twice = fn (f:real->real,x) => f(f(x)) val erg = twice ( sq, 3.0 ) ; 4. Abstraktion mit Funktion als Ergebnis: Ausdruck: f ( f (x) ) 1. Abstraktion: λ x . f ( f (x) ) 2. Abstraktion: λ f. ( λ x . f ( f (x) ) ) ML-Notation: fn f =>(fn x:real => f(f(x))) Vereinbarung eines Bezeichners für die Funktion: val appl2 = fn f =>(fn x:real => f(f(x))) ; val pow4 = appl2 ( sq ) ; val erg = pow4 ( 3.0 ) ; 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 34 Funktionsdeklaration In ML können Funktionen entweder mittels einer normalen Wertvereinbarung (siehe oben) oder mit einer speziellen Syntax deklariert werden. Erster Schritt zur Einführung der speziellen Syntax: fun <Bezeichner> <Signatur> = <Ausdruck> and <Bezeichner> <Signatur> = <Ausdruck> ... and <Bezeichner> <Signatur> = <Ausdruck> ; wobei die Liste der mit dem Schlüsselwort “and“ begonnenen Zeilen leer sein kann. Zunächst betrachten wir Signaturen der Form: <Signatur> ( <Params> ) <TypOpt> <Params> ε | <ParamListe> <ParamListe> <ParamDekl> | <ParamDekl> , <ParamListe> <ParamDekl> <Bezeichner> <TypOpt> <TypOpt> ε | 14.10.08 : <TypAusdruck> © A. Poetzsch-Heffter, TU Kaiserslautern 35 Beispiel: (rekursive Funktionsdekl.) 1. Einzelne rekursive Funktionsdeklaration: fun rpow if ( r: real, n: int ): real = n = 0 then 1.0 else r * rpow (r,n-1) ; 2. Verschränkt rekursive Funktionsdeklaraion: fun gerade ( n: int ) : bool = n = 0 orelse ungerade (n-1) and ungerade ( n: int ) : bool = if n=0 then false else gerade (n-1) Deklaration rekursiver Funktionen Begriffsklärung: (rekursive Definition) Eine Definition oder Deklaration nennt man rekursiv, wenn der definierte Begriff bzw. das deklarierte Programmelement im definierenden Teil verwendet wird. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 36 Bemerkung: • Rekursive Definitionen finden sich in vielen Bereichen der Informatik und Mathematik, aber auch in anderen Wissenschaften und der nichtwissenschaftlichen Sprachwelt. • Wir werden hauptsächlich rekursive Funktionsund Datentypdeklarationen betrachten. Definition: (rekursive Funktionsdekl.) Eine Funktionsdeklaration heißt direkt rekursiv, wenn der definierende Ausdruck eine Anwendung der definierten Funktion enthält. Eine Menge von Funktionsdeklarationen heißt verschränkt rekursiv oder indirekt rekursiv (engl. mutually recursive), wenn die Deklarationen gegenseitig voneinander abhängen. Eine Funktionsdeklaration heißt rekursiv, wenn sie direkt rekursiv ist oder Element einer Menge verschränkt rekursiver Funktionsdeklarationen ist. Begriffsklärung: (rekursive Funktion) Eine Funktion heißt rekursiv, wenn es rekursive Funktionsdeklarationen gibt, mit denen sie definiert werden kann. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 37 Bemerkung: • Die Menge der rekursiven Funktionen ist berechnungsvollständig. • Rekursive Funktionsdeklarationen können als eine Gleichung mit einer Variablen verstanden werden, wobei die Variable von einem Funktionstyp ist. Beispiel: (Definition der Fakultätsfunktion) Gesucht ist die Funktion f, die folgende Gleichung für alle n ∈ nat erfüllt: f(n) = if n=0 then 1 else n * f(n-1) Zur Auswertung von Funktionsanwendungen: Sei fun f(x) = A[x] In ML werden Funktionsanwendungen f(e) nach der Strategie call-by-value ausgewertet: • Werte Ausdruck e aus; Ergebnis nennt man den aktuellen Parameter z . • Ersetze x in A[x] durch z . • Werte den resultierenden Ausdruck A[z] aus. Beispiele: (Rekursion) siehe Vorlesung 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 38 Formen rekursiver Funktionsdeklarationen: Vereinfachend betrachten wir nur Funktionsdeklarationen, bei denen die Fallunterscheidung „außen“ und die rekursiven Aufrufe in den Zweigen der Fallunterscheidung stehen. • Eine rekursive Funktionsdeklaration heißt linear rekursiv, wenn in jedem Zweig der Fallunterscheidung höchstens eine rekursive Anwendung erfolgt (Beispiel: Definition von fac). • Eine rekursive Funktionsdeklaration heißt repetitiv, wenn sie linear rekursiv ist und die rekursiven Anwendungen in den Zweigen der Fallunterscheidung an äußerster Stelle stehen. Beispiele: 1. Die übliche Definition von fac ist nicht repetitiv, da im Zweig der rekursiven Anwendung die Multiplikation an äußerster Stelle steht. 2. Die folgende Funktion facrep ist repetitiv: fun facrep ( n: int, res: int ): int = if n=0 then res else facrep( n-1, res*n ) 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 39 • Eine rekursive Funktionsdeklaration für f heißt geschachtelt rekursiv, wenn sie Teilausdrücke der Form f ( ... f ( ... ) ... ) enthält. • Eine rekursive Funktionsdeklaration für f heißt kaskadenartig rekursiv, wenn sie Teilausdrücke der Form h ( ... f ( ... ) ... f ( ... ) ... ) enthält. Beispiel: (kaskadenartige Rekursion) Berechne: Wie viele Kaninchen-Pärchen gibt es nach n Jahren, wenn man • am Anfang mit einem Pärchen beginnt, • jedes Pärchen nach zwei Jahren und dann jedes folgende Jahr ein weiteres Pärchen Nachwuchs erzeugt und • die Kaninchen nie sterben. Die Anzahl der Pärchen stellen wir als Funktion fib von n dar: • vor dem 1. Jahr: fib(0) = 1 • nach dem 1. Jahr: fib(1) = 1 • nach dem 2. Jahr: fib(2) = 2 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 40 nach dem n. Jahr: die im Jahr vorher existierenden Pärchen plus die Anzahl der neu geborenen und die ist gleich der Anzahl von vor zwei Jahren, also gilt: fib( n ) = fib( n-1 ) + fib( n-2 ) für n > 1. Insgesamt ergibt sich folgende kaskadenartige Funktionsdeklaration: fun fib ( n: int ):int = if n <= 1 then 1 else fib (n-1) + fib (n-2) Bemerkung: • Aus Beschreibungssicht spielt die Form der Rekursion keine Rolle; wichtig ist eine möglichst am Problem orientierte Beschreibung. • Aus Programmierungssicht spielt Auswertungseffizienz eine wichtige Rolle, und diese hängt von der Form der Rekursion ab. Beispiel: Kaskadenartige Rekursion führt im Allg. zu einer exponentiellen Anzahl von Funktionsanwendungen. ( z.B. bei fib(30) bereits 1.664.079 Anwendungen ) 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 41 3.1.3 Listen und Tupel Die Datenstruktur der Listen Eine Liste über einem Typ T ist eine total geordnete Multimenge mit endlich vielen Elementen aus T (bzw. eine endliche Folge, d.h. eine Abb. {1,...,n } T ). ML stellt standardmäßig eine Datenstruktur für Listen bereit, die bzgl. des Elementtyps parametrisiert ist: Typen: Ist t ein ML-Typ, dann bezeichnet t list den Typ der Listen mit Elementen aus t. Funktionen: hd : t list t tl : t list t list _::_ : t * t list t list null : t list bool (* cons *) (* Test, ob Liste leer *) length: t list int _@_ : t list * t list t list rev : t list t list (* Umkehren einer Liste / engl. revert *) Konstanten: nil 14.10.08 : t list © A. Poetzsch-Heffter, TU Kaiserslautern 42 Dem Typ t list ist als Wertemenge die Menge aller Listen über Elementen vom Typ t zugeordnet. Bemerkungen: • In ML gibt es eine spezielle Notation für Listen: statt kann man a :: a k [ a ,a k k-1 k-1 :: ... :: a :: nil 1 , ... ,a ] 1 schreiben; insbesondere [ ] für nil. • Selbstverständlich kann man mit Listen von Listen arbeiten. • Wie wir in 3.3 sehen werden, unterstützt ML Typparameter. Z. B. ist nil vom Typ ‘a list , wobei ‘a eine Typvariable ist. Beispiele: (Funktionen auf Listen) • Addiere alle Zahlen einer Liste von Typ int list: - fun mapplus ( xl: int list ) = if null(xl) then 0 else hd(xl) + mapplus(tl(xl)) ; - mapplus ( [ 1,2,3,4,5,6 ] ) ; 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 43 2. Prüfen einer Liste von Zahlen auf Sortiertheit: - fun ist_sortiert ( xl ) = if null (xl) orelse null ( tl(xl) ) then true else if hd (xl) <= hd(tl(xl)) then ist_sortiert( tl (xl)) else false ; 3. Zusammenhängen zweier Listen (engl. append): - fun append ( l1, l2 ) = if l1 = [ ] then l2 else hd (l1) :: append ( tl(l1), l2 ) ; 4. Umkehren einer Liste: - fun rev ( xl ) = if xl = nil then [ ] else append ( rev (tl (xl)), [ hd(xl) ] ) ; 5. Zusammenhängen der Elemente einer Liste von Listen: - fun concat ( xl ) = if null(xl) then [ ] else append ( hd(xl) , concat( tl( xl ) ) ) ; 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 44 6. Wende eine Liste von Funktionen vom Typ int int nacheinander auf eine ganze Zahl an: - fun seqappl ( xl : (intint) list, i: int ) : if null( xl ) then i else seqappl ( tl(xl), (hd(xl) i)) ; Bemerkungen: • Rekursive Funktionsdeklaration sind bei Listen angemessen, weil Listen rekursive Datenstrukturen sind. • Mit Pattern Matching lassen sich die obigen Deklaration noch eleganter fassen (s. unten). Die Datenstrukturen der Tupel Wir betrachten zunächst Paare und verallgemeinern dann auf n-Tupel: Paare oder 2-Tupel sind die Elemente des kartesischen Produktes zweier ggf. verschiedener Mengen oder Typen. Paare gehören zu Produkttypen. Als Typkonstruktor wird * in Infix-Schreibweise benutzt: 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 45 Typen: Sind t1, t2 ML-Typen, dann bezeichnet t1 * t2 den Typ der geordneten Paare aus Elementen von t1 und t2. Funktionen: (* Paarbildung *) (_,_): t1 * t2 t1 * t2 #1 : t1 * t2 t1 #2 : t1 * t2 t2 (* 1. Komponente *) (* 2. Komponente *) Konstanten: keine Dem Typ t1*t2 ist die Menge der Paare bestehend aus Elementen von t1 und t2 zugeordnet. Beispiel: (Funktionen auf Paaren) Transformiere eine Liste von Paaren in ein Paar von Listen: fun unzip ( xl:(int*char) list ): (int list)*(char list) = if null( xl ) then ([],[]) else ( (#1(hd(xl))) :: (#1(unzip(tl(xl)))), (#2(hd(xl))) :: (#2(unzip(tl(xl)))) ); ( auch das geht erheblich schöner mit Pattern Matching ) 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 46 ML unterstützt n-Tupel für alle n ≥ 0, n≠ 1: Für n=0 ist es die triviale Datenstruktur Unit: Typ: unit Funktionen: keine Konstanten: (): unit Die Wertemenge zum Typ unit enthält genau ein Element, genannt Einheit (engl. unity). Bemerkung: unit wird oft als Ergebnistyp verwendet, wenn es keine relevanten Ergebniswerte gibt. Für n=1 gilt: 1-Tupel vom Typ t werden wie Elemente vom Typ t betrachtet. Für n>1 gilt: n-Tupel sind eine Verallgemeinerung von Paaren. Klammern werden zur Tupel-Konstruktion verwendet. #i mit 0<i≤ n selektiert die i-te Komponente. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 47 Beispiel: (Geschachtelte Tupel) Mit der Tupelbildung lassen sich „baumstrukturierte“ Werte, sogenannte Tupelterme, aufbauen. So entspricht der Tupelterm: ((8,true), (("Tupel","sind","toll"),"aha")); dem Baum: 8 true "Tupel" "sind" "aha" "toll" Beispiel: (Funktionen auf n-Tupeln) 1. Flache ein Paar von Tripeln in ein 6-Tupel aus: - fun ausflachen ( p: (‘a * ‘b * ‘c) * (‘d * ‘e * ‘f) ) = ( #1(#1(p)), #2(#1(p)), #3(#1(p)), #1(#2(p)), #2(#2(p)), #3(#2(p)) ) ; 2. Funktion zur Paarbildung: 14.10.08 fun paarung (lk,rk) = (lk,rk) ; © A. Poetzsch-Heffter, TU Kaiserslautern 48 Stelligkeit von Funktionen: Formal gesehen, sind alle Funktionen in ML einstellig, haben also nur ein Argument. Die Klammern um das Argument kann man weglassen, also f a statt f(a) . Statt mehrerer Parameter übergibt man ein Tupel von Parametern. Die Tupelklammern kann man natürlich nicht weglassen. Beispiel: Die Additionsoperation in ML op + nimmt nicht zwei Argumente sondern ein Tupel von Argumenten. Statt mehrere Parameter als Tupel zu übergeben, kann man sie auch „nacheinander“ übergeben. Z. B. hat die Funktion fun curriedplus m n = m + n ; den Typ fn : int (int int) Die Auswertung des Ausdruck curriedplus 7 liefert also eine Funktion vom Typ (int int) . 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 49 Zusammenfassung: • In ML haben alle Funktionen nur eine Parameter! • Funktionen mit n-Parametern werden in ML durch einstellige Funktionen ausgedrückt, die ein n-Tupel als Parameter nehmen! • Die bisher verwendeten Klammern bei einstelligen Funktionen sind also überflüssig. Syntax der Funktionsanwendung in ML: <Ausdruck> <Ausdruck> Funktionstyp zum Funktionstyp passender Parametertyp Pattern Matching Pattern Matching meint in diesem Zusammenhang die Verwendung von Mustern zur Vereinfachung von Vereinbarungen/Bezeichnerbindungen. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 50 Beispiel: (Funktionssyntax) - fun ist_sortiert x = if null x orelse null (tl x) then true else if hd x <= hd (tl x) then ist_sortiert( tl x ) else false ; - fun append (l1,l2) = if l1 = [ ] then l2 else hd l1 :: append ( tl l1, l2 ) ; - fun concat x = if null x then [ ] else append ( hd x, concat ( tl x ) ) ; - fun unzip ( x : (‘a * ‘b) list ): (‘a list) * (‘b list) = if null x then ([],[]) else ( #1 (hd x) :: (#1 (unzip (tl x))), #2 (hd x) :: (#2 (unzip (tl x))) ) ; 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 51 Begriffsklärung: (Konstruktoren) Konstruktoren in ML sind bestimmte Funktionen: - die Funktionen zur Tupelbildung - die Listenfunktion _::_ (daher der Name cons) - benutzerdefinierte Konstruktoren zu rekursiven Datentypen (siehe 3.1.4). Begriffsklärung: (ML-Muster, ML-Pattern) Muster (engl. Pattern) in ML sind Ausdrücke gebildet über Bezeichnern, Konstanten und Konstruktoren. Alle Bezeichner in einem Muster müssen verschieden sein. Ein Muster M mit Bezeichnern b , ..., b passt auf 1 k einen Wert w (engl. a pattern matches a value w), wenn es eine Substitution der Bezeichner bi in M durch Werte v i gibt, in Zeichen M[v1 /b 1, ... , vk /bk ], so dass M[v /b , ... , v /b ] = w 1 14.10.08 1 k k © A. Poetzsch-Heffter, TU Kaiserslautern 52 Beispiel: (ML-Muster, Passen) 1. (a,b) passt auf (4,5) mit Substitution a=4, b=5. 3. (a,b) passt auf (~47, (true, “dada“)) mit a = ~47, b = (true, “dada“) . 6. x::xs passt auf 7::8::9::nil mit x = 7 und xs = 8::9::nil , d.h. xs = [ 8, 9 ] . 9. x1::x2::xs passt auf 7::8::9::nil mit x1 = 7, x2 = 8, x3 = [ 9 ] . 12. first :: rest passt auf [ “computo“, “ergo“, “sum“ ] mit first = “computo“, rest = [ “ergo“, “sum“ ] 15. ( (8,x) , (y,"aha") ) pass auf ((8,true), ( ("Tupel","sind","toll"), "aha") ) mit x = true und y = ("Tupel","sind","toll") 8 14.10.08 x y "aha" © A. Poetzsch-Heffter, TU Kaiserslautern 53 Muster können in ML-Wertvereinbarungen verwendet werden: val <Muster> = <Ausdruck> ; Wenn das Muster auf den Wert des Ausdrucks passt und σ die zugehörige Substitution ist, werden die Bezeichner im Muster gemäß σ an die zugehörige Werte gebunden. Beispiel: (Wertvereinbarung mit Mustern) - val ( a, b ) = ( 4, 5 ); val a = 4 : int val b = 5 : int - a+b ; val it = 9: int Muster können in ML-Funktionsdeklarationen verwendet werden: fun <Bezeichner> <Muster> = <Ausdruck> ... | <Bezeichner> <Muster> = <Ausdruck> ; 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 54 Beispiel: (Funktionsdeklaration mit Mustern) 1. Deklaration von mapplus ohne und mit Mustern: - fun mapplus (xl: int list) = if null xl then 0 else (hd xl)+ mapplus (tl xl) ; - fun mapplus [] = 0 | mapplus (x::xl) = x + mapplus xl ; 2. Einige der obigen Funktionsdeklaration mit Verwendung von Mustern: - fun ist_sortiert [ ] = true | ist_sortiert (x::nil) = true | ist_sortiert (x1::x2::xs) = if x1 > x2 then false else ist_sortiert (x2::xs ) ; - fun append ([],l2) | = l2 append (x::xs,l2) = x::append (xs,l2) ; 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 55 3. Verwendung geschachtelter Muster: - fun unzip (xl:(int*char)list): (int list)*(char list) = if null xl then ([],[]) else ((#1 (hd xl))::(#1 (unzip (tl xl))), (#2 (hd xl))::(#2 (unzip (tl xl)))); - fun unzip [] = ([],[]) | unzip ((x,y)::ps) = (x::(#1 (unzip ps)), y::(#2 (unzip ps)) ); let- und case-Ausdrücke: Der Mustermechanismus kann auch innerhalb von Ausdrücken eingesetzt werden. 1. Syntax des let-Ausdrucks: let <Liste von Deklarationen> in <Ausdruck> end Die aus den Deklarationen resultierenden Bindungen sind nur im let-Ausdruck gültig. D.h. sie sind sichtbar im let-Ausdruck, an den Stellen, an denen sie nicht verdeckt sind. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 56 2. Syntax des case-Ausdrucks: case <Ausdruck0> of <Muster1> => <Ausdruck1> ... | <MusterN> => <AusdruckN> Bedeutung: Werte Ausdruck0 aus und prüfe der Reihe nach, ob der resultierende Wert auf eines der Muster passt. Passt er auf ein Muster, nehme die entsprechenden Bindungen vor und werte den zugehörigen Ausdruck aus (die Bindungen sind nur in dem zugehörigen Ausdruck gültig). Beispiele: (let-Ausdruck) - val a = let val b = 7*8 in b*b end; - val a = let val a = 7*8 in let val (a,b) = (a,a+1) in a*b end end; - fun unzip [] = ([],[]) | unzip ((x,y)::ps) = let (xs,ys) = unzip ps in (x::xs,y::ys) end 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern ; 57 Beispiel: (case-Ausdruck) - fun ist_sortiert xl case xl of [] | (x::nil) = => true => true | (x1::x2::xs) => if x1 > x2 then false else ist_sortiert (x2::xs) ; Bemerkungen: • Das Verbot von gleichen Bezeichnern in Mustern hat im Wesentlichen den Grund, dass nicht für alle Werte/Typen die Gleichheitsoperation definiert ist. - val mal2 = fn x => 2 * x ; - val twotimes = fn x => x+x ; - val (a,a) ( mal2 , twotimes ) = • Offene Frage: Was passiert, wenn keines der angegebenen Muster passt? Abrupte Terminierung. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 58 3.1.4 Benutzerdefinierte Datentypen Übersicht: • Vereinbarung von Typbezeichnern • Deklaration neuer Typen • Summentypen • Rekursive Datentypen Fast alle modernen Spezifikations- und Programmiersprachen gestatten es dem Benutzer, „neue“ Typen zu definieren. Vereinbarung von Typbezeichnern ML erlaubt es, Bezeichner für Typen zu deklarieren (vgl. Folie 105). Dabei wird kein neuer Typ definiert, sondern nur ein Typ an einen Bezeichner gebunden. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 59 Beispiele: (Typvereinbarungen) type intpaar = int * int type charlist = char list type adresse = string * string* string * int type telefonbuch = ( adresse * string list ) list type intNachInt val = int int fakultaet : intNachInt = ; fac ; Typvereinbarungen sind nur Abkürzungen. Zwei unterschiedliche Bezeichner können den gleichen Typ bezeichnen; z.B.: - type inttripel = int * int * int; type inttripel = int * int * int - type date = int * int * int; type date = int * int * int - fun kalenderwoche (d:date): int = ... ; val kalenderwoche = fn : date -> int - kalenderwoche (192,45,111111); 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 60 Deklaration neuer Typen Neue Typen werden in ML mit dem datatype-Konstrukt definiert, das im Folgenden schrittweise erläutert wird. Definition eines neuen Typs und Konstruktors: datatype <Typbezeichner> = <Konstruktorbezeichner> of <TypAusdruck> Die obige Datatypdeklaration definiert: - einen neuen Typ und bindet ihn an <Typbezeichner> - eine Konstruktorfunktion, die Werte des durch den Typausdruck beschriebenen Typs in den neuen Typ einbettet. Die Konstruktorfunktion ist injektiv. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 61 Beispiel: (Definition von Typ und Konstruktor) datatype person = Student of string * string * int * int definiert den neuen Typ person und den Konstruktor Student: string*string*int*int -> person Wir definieren die Selektorfunktionen: - fun vorname (Student(v,n,g,m)) val vorname = fn : person -> string = v ; - fun name (Student(v,n,g,m)) val name = fn : person -> string = n ; - fun geburtsdatum (Student(v,n,g,m)) = g ; val geburtsdatum = fn : person -> int - fun matriknr (Student(v,n,g,m)) val matriknr = fn : person -> int 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern = m ; 62 Jede Datentypdeklaration definiert einen neuen Typ, d.h. insbesondere: - Werte vom Argumentbereich des Konstruktors sind inkompatibel mit dem neuen Typ; - Werte strukturgleicher benutzerdefinierter Typen sind inkompatibel. Beispiele: (Typkompatibilität) 1. Der Typ person ist inkompatibel mit dem Typ string*string*int*int , insbesondere ist vorname (“Niels“,“Bohr“,18851007, 221) nicht typkorrekt. 2. Der Typ person ist inkompatibel mit dem strukturgleichen Typ adresse datatype adresse = WohnAdresse of string * string * int * int Insbesondere ist name (WohnAdresse(“Pfaffenbergstraße“, “Kaiserslautern“, 27, 67663 ) ) nicht typkorrekt. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 63 Bemerkung: Den Konstruktor kann man sich als eine Markierung der Werte seines Argumentbereichs vorstellen. Dabei werden Werte mit unterschiedlicher Markierung als verschieden betrachtet. Summentypen Ein Summentyp stellt die disjunkte Vereinigung der Elemente anderen Typen zu einem neuen Typ dar. Die meisten modernen Programmiersprachen unterstützen die Deklaration von Summentypen. Summentypen in ML: Bei einer Datentypdeklaration kann man verschiedene Alternativen angeben: datatype <Typbezeichner> = <Konstruktorbezeichner1> of <TypAusdruck1> ... | <KonstruktorbezeichnerN> of <TypAusdruckN> 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 64 Beispiele: (Summentypen) 1. Ein anderer Datentyp zur Behandlung von Personen: datatype person2 = Student of string*string*int*int | Mitarbeiter of string*string*int*int | Professor of string*string*int*int*string 2. Eine benutzerdefinierte Datenstruktur für Zahlen: - datatype number = Intn of int | Realn of real - fun isInt (Intn m) = true | isInt (Realn r) = false ; val isInt = fn : number -> bool - fun isReal (Intn m) = false | isReal (Realn r) = true ; val isReal = fn : number -> bool - fun neg | neg (Intn m) = Intn (~m) (Realn r) = Realn (~r); val neg = fn : number -> number 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 65 - fun plus (Intn m,Intn n) = Intn (m+n) | plus (Intn m,Realn r) = Realn((real m)+r) | plus (Realn r,Intn m) = Realn(r+(real m)) | plus (Realn r,Realn q)= Realn(r+q); val plus = fn : number * number -> number Begriffsklärung: Konstruktorfunktionen oder Konstruktoren liefern Werte des neu definierten Datentyps. Sie können in Mustern verwendet werden (z.B.: Student, Intn). Diskriminatorfunktionen oder Diskriminatoren prüfen, ob der Wert eines benutzerdefinierten Datentyps zu einer bestimmten Alternative gehört (Beispiel: isInt). Selektorfunktionen oder Selektoren liefern Komponenten von Werten des definierten Datentyps (z.B.: vorname, name, ...). Bemerkung: In funktionalen Sprachen kann man meist auf Selektorfunktionen verzichten. Man wendet stattdessen Pattern. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 66 Weitere Formen des datatype-Konstrukts: Das datatype-Konstrukt kann auch verwendet werden, um Aufzählungstypen zu definieren. Dabei wird in den Alternativen der Teil beginnend mit dem Schlüsselwort of weggelassen. Die Wertemenge eines Aufzählungstyps ist eine endliche Menge (von Namen). Beispiele: (Aufzählungstypen) - datatype Montag wochentag = | Dienstag | Mittwoch | Donnerstag | Freitag | Samstag | Sonntag - fun istMittwoch Mittwoch = | istMittwoch w = ; true false ; Oder knapper: - fun istMittwoch w 14.10.08 = (w=Mittwoch) ; © A. Poetzsch-Heffter, TU Kaiserslautern 67 Beide Formen der Datentypdeklaration können kombiniert werden; z.B.: - datatype intBottom Bottom | Some of int = Bemerkung (vgl. 3.3): Datentypdeklarationen lassen sich parametrisieren. Die Typparameter werden dabei vor den vereinbarten Typbezeichner gestellt: - datatype ‘a bottom = Bottom | Some of ‘a ML sieht dafür standardmäßig den folgenden Typ vor: - datatype ‘a option = NONE | SOME of ‘a Zur Erweiterung von Summentypen Im Gegensatz zur objektorientierten Programmierung (vgl. Kapitel 5) lassen sich die Summentypen in der funktionalen Programmierung meist nicht erweitern, d.h. einem definierten Datentyp können im Nachhinein keine Alternativen hinzugefügt werden. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 68 Beispiel: (Erweiterung von Summentypen) Den Datentyp person2: datatype person2 = Student of string*string*int*int | Mitarbeiter of string*string*int*int | Professor of string*string*int*int*string hätte man aus person durch Hinzufügen der Alternativen für Mitarbeiter und Professoren gewinnen können. Bemerkung: Die Möglichkeit, einen Datentypen T nachträglich erweitern zu können, bringt zwei Vorteile für die Wiederverwendung mit sich: 1. Der Programmieraufwand wird reduziert. 2. Programme, die für T entwickelt wurden, können teilweise weiter benutzt werden. In ML müssen Erweiterungen im Allg. durch neue Summentypen realisiert werden. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 69 Beispiel: Ein Datentyp dt : datatype dt = Constr1 of t1 | Constr2 of t2 | Constr3 of t3 | Constr4 of t4 soll eine fünfte Alternative bekommen. Statt alle existierenden Programme auf fünf Alternativen umzustellen, kann man mit dtneu arbeiten: datatype dtneu = | Constr0 of dt Constr5 of t5 Nachteil: Die Programme müssen auf dtneu angepasst werden. Bemerkung: Der Datentyp exn ist in ML erweiterbar. Jede Ausnahme-Vereinbarung fügt eine Alternative hinzu. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 70 Rekursive Datentypen Von großer Bedeutung in der Programmierung sind rekursive Datentypen. Definition: (rekursive Datentypdeklaration) Eine Datentypdeklaration heißt direkt rekursiv, wenn der definierte Typ in einer der Alternativen der Datentypdeklaration vorkommt. Wie bei Funktionen gibt es auch verschränkt rekursive Datentypdeklarationen. Eine Datentypdeklaration heißt rekursiv, wenn sie direkt rekursiv ist oder Element einer Menge verschränkt rekursiver Datentypdeklarationen ist. Begriffsklärung: (rekursiver Datentyp) Ein Datentyp heißt rekursiv, wenn er mit einer rekursiven Datentypdeklaration definiert wurde. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 71 Beispiele: (Listendatentypen) 1. Ein Datentyp für Integer-Listen: - datatype intlist = nil | Cons of int * intlist ; 2. Ein Datentyp für homogene Listen mit Elementen von beliebigem Typ: - datatype ‘a list = nil | Cons of ‘a * ‘a list Vorsicht: Diese Deklaration verdeckt den vordefinierten Datentyp ‘a list. 3. Der vordefinierte Datentyp für homogene Listen mit Elementen von beliebigem Typ: - datatype ‘a list = of * nil | 14.10.08 :: ‘a ‘a list © A. Poetzsch-Heffter, TU Kaiserslautern 72 Baumartige Datenstrukturen: Ottmann, Widmayer: „Bäume gehören zu den wichtigsten in der Informatik auftretenden Datenstrukturen“. A B C D E F G H Begriffsklärung: (zu Bäumen) • In einem endlich verzweigten Baum hat jeder Knoten endlich viele Kinder. • Üblicherweise sagt man, die Kinder sind von links nach rechts geordnet. •. Einen Knoten ohne Kinder nennt man Blatt, einen Knoten mit Kindern einen inneren Knoten oder Zweig. • Den Knoten ohne Elter nennt man Wurzel. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 73 • Ein Baum heißt markiert, wenn jeder Knoten k eine Markierung m(k) besitzt. • In einem Binärbaum ist jeder Knoten entweder ein Blatt, oder er hat genau zwei Kinder. • Zu jedem Knoten gehört ein Unterbaum. Datentyp für markierte Binärbäume: - datatype intbbaum Blatt | of Zweig of = int - val einbaum = int * intbbaum * intbbaum ; Zweig (7, Zweig(3,(Blatt 2),(Blatt 4)), (Blatt 5)); - fun | m (Blatt n) = n m (Zweig (n,lk,rk)) = n ; Ein mit ganzen Zahlen markierter Binärbaum heißt sortiert, wenn für alle Knoten k gilt: - Die Markierungen der linken Nachkommen von k sind kleiner als m(k). - Die Markierungen der rechten Nachkommen von k sind größer als m(k). 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 74 Aufgabe: Prüfe, ob ein Baum vom Typ intbbaum sortiert ist. Idee: Berechne zu jedem Unterbaum die minimale und maximale Markierung und prüfe rekursiv die Sortiertheitseigenschaft für alle Knoten/Unterbäume. fun maxmark (Blatt n) = n | maxmark (Zweig (n,l,r)) = Int.max(n,Int.max(maxmark l, maxmark r)) fun minmark (Blatt n) = n | minmark (Zweig (n,l,r)) = Int.min(n,Int.min(minmark l, minmark r)) fun istsortiert (Blatt n) = true | istsortiert (Zweig (n,l,r)) = istsortiert l andalso istsortiert r andalso (maxmark l)<n andalso n<(minmark r) istsortiert einbaum 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 75 Wenig effiziente Lösung!! Besser ist es, die Berechnung von Minima und Maxima mit der Sortiertheitsprüfung zu verschränken. Idee: Entwickle eine Einbettung, die für sortierte Bäume auch Minimum und Maximum liefert. fun istsorteinbett (Blatt n) = (true,n,n) | istsorteinbett (Zweig (n,l,r)) = let val (lerg,lmn,lmx) = istsorteinbett l val (rerg,rmn,rmx) = istsorteinbett r in ( lerg andalso rerg andalso lmx<n andalso n<rmn, lmn, rmx ) end ; fun istsortiert b = #1 (istsorteinbett b) 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 76 Weitere Eigenschaften von Bäumen: Begriffsklärung: (zu Bäumen) Der leere Baum ist ein Baum ohne Knoten. Die Höhe eines Baumes ist der maximale Abstand eines Blattes von der Wurzel. Ein Baum, der nur aus einem Blatt besteht, hat die Höhe 1. Der leere Baum hat die Höhe 0. Die Tiefe eines Knotens ist sein Abstand zur Wurzel. Die Knoten gleicher Tiefe t nennt man das Niveau t. Die Größe eines Baums ist die Anzahl seiner Knoten. Datentyp möglicherweise leere Binärbäume: - datatype imlbbaum = Leer | 14.10.08 Knoten of int * imlbbaum * imlbbaum © A. Poetzsch-Heffter, TU Kaiserslautern ; 77 Bäume mit variabler Kinderzahl: Bäume mit variabler Kinderzahl lassen sich realisieren: - durch mehrere Alternativen für Zweige (begrenzte Anzahl von Kindern) - durch Listen von Unterbäumen: datatype vibaum = Kn of int * (vibaum list) Beachte: Der Rekursionsanfang ergibt sich durch Knoten mit leerer Unterbaumliste. fun zaehleKnViBaum (Kn (_,xs)) = 1 + (zaehleKnViBaumLst xs) and zaehleKnViBaumList [] = 0 | zaehleKnViBaumLst (x::xs) = (zaehleKnViBaum x) + (zaehleKnViBaumLst xs) Bäume mit variabler Kinderzahl lassen sich z.B. zur Repräsentation von Syntaxbäumen verwenden, indem das Terminal- bzw. Nichtterminalsymbol als Markierung verwendet wird. Besser ist es allerdings, die Information über die Symbole mittels Konstruktoren auszudrücken. --> Gewinn von Typsicherheit (vgl. nächstes Beispiel). 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 78 Verschränkte Datentypdeklarationen: Genau wie Funktionsdeklarationen werden verschränkt rekursive Datentypdeklarationen mit dem Schlüsselwort „and“ verbunden: datatype <Typbezeichner1> = <Konstruktorbezeichner1_1> of <TypAusdruck1_1> ... | <Konstruktorbezeichner1_N> of <TypAusdruck1_N> and ... and <TypbezeichnerM> = <KonstruktorbezeichnerM_1> of <TypAusdruckM_1> ... | <KonstruktorbezeichnerM_K> of <TypAusdruckM_K> 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 79 Beispiel: (abstrakte Syntaxbäume) Der abstrakte Syntaxbaum eines Programms repräsentiert die Programmstruktur unter Verzicht auf Schlüsselworte und Trennzeichen. Rekursive Datentypen eignen sich sehr gut zur Beschreibung von abstrakter Syntax. Als Beispiel betrachten wir die abstrakte Syntax von Femto: datatype programm = Prog and wertdekl = Dekl and ausdruck = Bzn | Zahl | Add | Mul of wertdekl list * ausdruck of string * ausdruck of of of of string int ausdruck * ausdruck ausdruck * ausdruck Das Programm val a = 73; ( a + 12 ) ; Wird durch folgenden Baum repräsentiert: Prog ( [Dekl (“a“,Zahl 73)], Add (Bzn “a“,Zahl 12) ) Die Baumrepräsentation eignet sich besser als die Zeichenreihenrepräsentation zur weiteren Verarbeitung von Programmen. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 80 Beispiel: (verschränkte Datentypen) Bei abstrakten Syntaxbäumen wird häufig verschränkte Rekursion der Datentypen benötigt. Als Beispiel betrachten wir die abstrakte Syntax einer Erweiterung von Femto um let-Ausdrücke: datatype programm = Prog and wertdekl = Dekl and ausdruck = Bzn | Zahl | Add | Mul | Let of wertdekl list * ausdruck of string * ausdruck of of of of of string int ausdruck * ausdruck ausdruck * ausdruck wertdekl * ausdruck Es ergibt sich einen Verschränkung zwischen der Definition von wertdekl , die den Typ ausdruck benutzt, und der Definition von ausdruck , die den Typ wertdekl benutzt. 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 81 Unendliche Datenobjekte: Zu einer nicht-leeren Liste kann man sich das erste Element und eine Liste als Rest geben lassen kann. Hat die endliche Liste xl die Länge n>0, dann hat (tl xl) die Länge n-1. Eine unendliche Liste besitzt keine natürlichzahlige Länge und wird durch Anwendung von tl nicht kürzer. Begriffklärung: (Strom) Potenziell unendliche Listen werde meist als Ströme bezeichnet. Typische Operationen: - lesen des ersten Elements (head) - entfernen des ersten Elements (tail) - prüfen, ob noch Elemente im Strom vorhanden sind. Beispiel: (Stromtyp) In ML lassen sich beispielsweise unendliche Listen von Strings mit folgender Datenstruktur realisieren: datatype stringstream = Nil | Cons of string * (unit -> stringstream) fun hd (Cons ( x, xf )) | hd Nil fun x raise Empty tl (Cons ( x, xf )) | 14.10.08 = = tl Nil = = xf () raise Empty © A. Poetzsch-Heffter, TU Kaiserslautern 82 Beispiel: (unendliche Liste) Liste aller (positiven) Dualzahlen dargestellt als Zeichenreichen: - fun incr | incr | incr val incr = (#"O"::xs) = (#"L"::xs) = [ ] = fn : char list #"L"::xs #"O":: incr xs [ #"L" ] ; -> char list - fun incrstr s = implode (rev (incr (rev (explode s)))); val incrstr = fn : string -> string - fun dualz_ab s = Cons (s, fn x=> dualz_ab (incrstr s)); val dualz_ab = fn: string -> stringstream - val dl = dualz_ab "O" ; val dl = Cons ("O",fn) : stringstream - hd dl; val it = "O" : string - val dl = tl dl; val dl = Cons ("L",fn) : stringstream - val dl = tl dl; val dl = Cons ("LO",fn) : stringstream 14.10.08 © A. Poetzsch-Heffter, TU Kaiserslautern 83