98 3 3 PROGRAMMIERPARADIGMEN Programmierparadigmen 3.1 Einführung in die funktionale Programmierung (ML) Das Prinzip funktionaler Programmierung könnte man beschreiben als Programmieren durch ” Anwenden von Funktionen“. Im Vordergrund steht also nicht, wie bei den üblichen imperativen Sprachen wie etwa Pascal, der durch die Variablen gegebene Zustand einer (fiktiven) Maschine, die durch jede Anweisung der Programmiersprache in einen neuen Zustand übergeht, sondern die meist tief geschachtelte Anwendung von Funktionen auf elementare Objekte. Der Vorteil dieser mathematischen“ Betrachtung ist dabei, dass der Wert eines derartigen Ausdrucks bei ” jeder Auswertung gleich ist, d.h. es gibt keine Nebeneffekte. In einer rein“ funktionalen Programmiersprache gibt es keine Variablen und daher auch keine ” Wertzuweisungen. Variablen werden durch Parameter von Funktionen ersetzt. Man wendet nur Funktionen auf konstante Daten an. Die meisten funktionalen Programmiersprachen besitzen jedoch eingeschränkte Möglichkeiten, Variablen und Wertzuweisungen zu benutzen. Charakteristisch ist weiterhin, dass Benutzer funktionaler Programmiersprachen sich üblicherweise nicht um den Speicherplatz für Daten kümmern müssen, da dynamisches Zuweisen und Rückgeben von Speicherplatz im Laufzeitsystem automatisch durchgeführt wird. Die Rolle von Iteration, also Schleifenkonstruktionen, wird in funktionalen Programmiersprachen von der Rekursion übernommen. (Bemerkung: Zur Iteration gehört üblicherweise eine Steuervariable, deren Wert bei jedem Durchlauf durch die Schleife geändert wird.) Funktionen sind in einer funktionalen Programmiersprache sogenannte First Class Objects“ , ” d. h. sie besitzen den gleichen Status wie andere Werte. Eine Funktion kann der Wert eines Ausdrucks, ein Parameter oder Teil einer Datenstruktur sein. Z. B. kann man eine Funktion definieren, die zwei Funktionen f , g als Parameter hat und als Wert die Komposition beider Funktionen, f ◦ g, zurückgibt. function comp(f,g : func) : func begin return f◦g end Als Beispielsprache für diesen Abschnitt soll ML (1986 Milner) verwendet werden. ML ist eine Nachfolgersprache von LISP (1958), der ersten funktionalen Programmiersprache. ML entstand als Zwischensprache ( Meta Language“) zu dem ambitionierten Projekt LCF (Logic for ” Computable Functions) und vereint die Flexibilität einer funktionalen Sprache mit einem sehr ausgereiften Typ-System, das eine starke Typ-Prüfung erlaubt. Charakteristisch für ML ist, dass es keine Deklaration eines Typs einer Variablen gibt. Statt dessen schließt das System aus dem Gebrauch der Variablen auf mögliche Typen und prüft, ob alle Auftreten dieser Variablen konsistent sind. Ein ML-Interpreter arbeitet immer die folgende Schleife ab: • Einlesen eines ML-Ausdrucks • Auswerten des eingelesenen Ausdrucks • Ausgeben des Werts des Ausdrucks. 3.1 Einführung in die funktionale Programmierung (ML) 99 Beispiel 3.1: 3.14159; (* Der Wert einer Zahl ist die Zahl selbst *) > val it = 3.14159 : real Die Eingabe des Benutzers ist in der ersten Zeile, die Antwort des ML-Systems ist in der zweiten Zeile in einer anderen Schrift und mit dem zusätzlichen Zeichen >“ zu sehen. Der Rückgabewert ” des Ausdrucks selbst (it) hat den Wert 3.14159 und dieser ist vom Typ real. 15 + 22; > val it = 37 : int 15 + 22.0; ! Toplevel input: ! 15 + 22.0; ! ˆˆˆˆ ! Type clash: expression of type ! real ! cannot have type ! int Das zweite Beispiel zeigt, dass ML bei den elementaren Zahlentypen keine automatischen Typanpassungen durchführt, sondern einen Typ-Fehler ausgibt. Das Binden von Namen an Werten geschieht über eine spezielle Funktion val“ . ” Beispiel 3.2: val pi = 3.14159; > val pi = 3.14158 : real pi; > val it = 3.14158 : real val f = (op +); > val f = fn : int * int -> int f (2,3); > val it = 5 : int Die eingeführten Namen werden in der momentanen Umgebung (Environment) mit den zugeordneten Werten abgelegt. Diese Umgebung enthält alle zur Zeit gültigen Namen-Wert-Paare (Bindungen). Funktionen können in ML durch einen Ausdruck der folgenden Form definiert werden: fun <Name> (<formale-Parameter>) = <Ausdruck> ; Beispiel 3.3: fun umfang(x) = 2.0*pi*x; > val umfang = fn : real -> real umfang(1.0); > val it = 6.28316 : real val pi = 20; > val pi = 20 : int umfang(1.0); > val it = 6.28316 : real 100 3 PROGRAMMIERPARADIGMEN An diesem Beispiel merkt man sehr deutlich den Unterschied zu einer üblichen“ Programmier” sprache. Durch eine Neudefinition der Variablen pi wird eine neue Bindung des Variablennamens an einen Wert in das Environment eingefügt ohne jedoch die frühere Bindung zu ändern! Die alte Bindung ist weiterhin existent, aber nicht mehr (einfach) zugreifbar. Es treten bei der Neudefinition also keine erwünschten (oder häufig auch nicht erwünschten) Nebemeffekte auf. Die obige Form der Definition von Funktionen ist nur eine syntaktische Umschreibung der eigentlichen Definition. Eine Funktion (ohne Namen!) wird nämlich eigentlich durch einen Ausdruck der Form: fn (<formale-Parameter>) => <Ausdruck> definiert. Man könnte also eine namenlose Quadratfunktion wie folgt definieren: fn x => x*x; > val it = fn : int -> int und damit könnte man z. B. (fn x => x*x) (5); > val it = 25 : int auswerten oder aber auch val square = fn x => x*x > val square = fn : int -> int square(5); > val it = 25 : int definieren. Will man die Quadratfunktion für Zahlen vom Typ real definieren, so muss man dem ML-System ein wenig helfen. Es reicht aus, dem Parameter x den Typ real mitzugeben: fun square(x:real) = x*x; > val square = fn : real -> real Die beiden booleschen Werte true und false werden in ML mit true und false bezeichnet. Die booleschen Operationen und“ und oder“ heißen in ML andalso bzw. orelse. Die Operanden ” ” werden immer von links nach rechts abgearbeitet und die Abarbeitung sofort beendet, wenn der Wert des Gesamtausdrucks feststeht. Beispiel 3.4: 5 < 7; > val it = true : bool 5.0 < 7.0; > val it = true : bool 5.0 > 7.0; > val it = true : bool 1 < 2 orelse 3 > 4; > val it = true : bool 1 < 2 orelse 1.0/0.0 < 5.0; (*Division wird nicht ausgeführt!*) > val it = true : bool 1 < 2 andalso 3 < 4; > val it = true : bool 3.1 Einführung in die funktionale Programmierung (ML) 101 Für bedingte Audrücke gibt es in ML die Anweisung if <Prädikat> then <Ausdruck1 > else <Ausdruck2 >) Sie entspricht der üblichen if-then-else Anweisung mit der zusätzlichen Forderung, dass die Typen der beiden Ausdrücke <Ausdruck1 > und <Ausdruck2 > übereinstimmen müssen!. Beispiel 3.5: Definition einer Funktion, die den mittleren von drei Werten bestimmt: fun mittel wert(x,y,z) = if x <= y then if y <= z then y else if x <= z then z else x else if z <= y then y else if z <= x then z else x; > val mittel wert = fn : int * int * int -> int Als Beispiel soll jetzt ein Programm zur Bestimmung der Quadratwurzel einer Zahl mit dem Newton-Verfahren angegeben werden. √ Die Funktion good enough prüft, ob eine Näherung guess für x gut genug ist. Die Funktion √ improve berechnet eine neue Näherung für x nach der Newton-Methode und sqrt iter iteriert diesen Prozess, bis eine ausreichend gute Näherung gefunden wurde. fun good enough (guess, x) = abs(x - square(guess)) < 0.001; > val good enough = fn : real * real -> bool fun improve(guess,x) = (x + square(guess))/(2.0 * guess); > val improve = fn : real * real -> real fun sqrt iter(guess,x) = if good enough (guess, x) then guess else sqrt iter(improve (guess, x), x); > val sqrt iter = fn : real * real -> real fun sqrt(x) = sqrt iter(1.0, x); > val sqrt = fn : real -> real square(sqrt(1000.0)); > val it = 1000.00036992 : real sqrt (2.0) > val it = 1.41421568627 : real Das allgemeine Newton-Verfahren zur Bestimmung einer Nullstelle einer Funktion f lautet: xi+1 = xi − f (xi ) . f 0 (xi ) 102 3 PROGRAMMIERPARADIGMEN Als Näherung für den Ableitungswert an der Stelle x wollen wir den Differenzenquotienten f (x + ∆x) − f (x) ∆x verwenden. Eine ML-Funktion, die als Parameter eine beliebige Funktion f übergeben bekommt und die Näherungsfunktion der ersten Ableitung als Wert zurückgibt, wäre z. B. fun derive(f, dx) = fn (x) => (f(x+dx) - f(x))/dx; > val derive = fn : (real -> real) * real -> real -> real derive(square, 0.001) (5.0); > val it = 10.001 : real Das allgemeine Newton-Verfahren lässt sich dann wie folgt formulieren: fun good enough (guess, f) = abs(f(guess)) < 0.001; > val good enough = fn : ’a * (’a -> real) -> bool Der hier auftretende Ausdruck ’a ist die ML-Form einer Typ-Variablen. fun improve(guess,f) = guess - f(guess)/(derive(f,0.01)(guess)); > val improve = fn : real * (real -> real) -> real fun newton(f, guess) = if good enough(guess, f) then guess else newton(f, improve(guess,f)); > val newton = fn : (real -> real) * real -> real Die Anwendung auf die Funktion f (x) = x − cos(x) wäre newton(fn x => x-cos(x), 1.0); > val it = 0.739139805143 : real Es gibt in ML verschiedene Möglichkeiten, mehrere Datenobjekte zu größeren Datenobjekten zusammenzufassen. Will man Objekte beliebigen Typs zusammenfassen, so benutzt man in ML das Tupel. Die allgemeine Form ist (objekt1 , ..., objektr ). Der Zugriff auf die i-te Komponenten eines Tupels t geschieht in der Form #i(t). Beispiel 3.6: val t = (4, 5.0, "six"); > val t = (4, 5.0, " six") : int * real * string #3(t); > val it = " six" : string 3.1 Einführung in die funktionale Programmierung (ML) 103 In Listen können nur Objekte eines einzigen Typs zusammengefasst werden. nil oder [] bezeichnet die leere Liste val l > val val s > val = l = s [1,2,3,4]; (* eine Liste mit den Zahlen 1, 2, 3 und 4 *) = [1, 2, 3, 4] : int list ["one", "two", "three"] ; = [" one", " two", " three"] : string list Die wichtigsten Operationen auf Listen sind hd (x) tl (x) a::x x@y liefert das erste Element einer Liste x (head) liefert den Rest der Liste x ohne das erste Element (tail) liefert, falls x eine Liste ist, die um a verlängerte Liste verbindet die beiden Listen x und y Beispiel 3.7: val l = [1,2,3,4]; > val l = [1, 2, 3, 4] : int list hd(l); > val it = 1 : int tl(l); > val it = [2, 3, 4] : int list tl(tl(tl(tl(l)))); > val it = [] : int list 6::[1,2,3,4]; > val it = [6, 1, 2, 3, 4] : int list 1.0::2.0::3.0::nil; > val it = [1.0, 2.0, 3.0] : real list [1,2,3]@[4,5]; > val it = [1, 2, 3, 4, 5] : int list Bei Listenoperationen zeigen sich besonders gut die Vorteile einer rekursiven Definition. Will man z. B. die Länge einer Liste bestimmen, so kann man definieren: fun my length(list) = if list = nil then 0 else 1 + my length(tl(list)); > val ’a my length = fn : ’a list -> int my length([1,2,3]); > val it = 3 : int my length(["abc","defgh","defgh","a"]); > val it = 4 : int Man beachte, dass der Typ-Ausdruck für die Funktion my length eine Typ-Variable ’a enthält! Diese Funktion bestimmt also die Länge einer Liste unabhängig vom Typ der Elemente, die sich in der Liste befinden. Eine alternative Möglichkeit der Definition dieser Funktion besteht darin, dass man das PatternKonzept in ML ausnutzt. Ohne auf die genaue Definiton eines Patterns einzugehen, kann man 104 3 PROGRAMMIERPARADIGMEN sagen, dass man für unterschiedliche Muster der Eingabe unterschiedliche Prozedur-Rümpfe definieren kann: fun my length(nil) = 0 | my length(x::xs) = 1+my length(xs); > val ’a my length = fn : ’a list -> int Interessant ist auch die Funktion map, die ihr erstes Argument (eine Funktion) auf alle Elemente des zweiten Arguments (einer Liste) anwendet und eine Ergebnisliste liefert. fun map(f,liste) = if liste = nil then nil else f(hd(liste))::map(f,tl(liste)); > val (’a, ’b) map = fn : (’a -> ’b) * ’a list -> ’b list map(square, [1.0,2.0,3.0]); > val it = [1.0, 4.0, 9.0] : real list Auch hier kann man das Pattern-Konzept benutzen und erhält: fun map(f,nil) = nil | map(f, x::lrest) = f(x)::map(f,lrest); > val (’a, ’b) map = fn : (’a -> ’b) * ’a list -> ’b list map(floor, map(square, map(real, [1,2,3]))); > val it = [1, 4, 9] : int list Als weiteres Beispiel soll eine Funktion definiert werden, die ein Prädikat als erstes Argument und eine Liste als zweites Argument bekommt. Diese Funktion soll eine neue Liste liefern, in der alle Elemente der alten Liste nicht auftreten, für die das Prädikat den Wert true ergibt. fun remove if(pred, nil) = nil | remove if(pred, x::lrest) = if pred(x) then remove if(pred,lrest) else x::remove if(pred,lrest); > val ’a remove if = fn : (’a -> bool) * ’a list -> ’a list fun even(x) = (x div 2)*2 = x; > val even = fn : int -> bool remove if(even, [1,2,3,4,5,6,7,8,9,10]); > val it = [1, 3, 5, 7, 9] : int list Beispiel 3.8: Als weiteres Beispiel soll nun ein Programm zum Sortieren durch Verschmelzen“ (merge-sort) ” in ML vorgestellt werden. Dieses Beispiel zeigt noch einmal die Vorzüge des Pattern-Konzepts in ML. Zunächst betrachten wir die merge-Funktion, die zwei geordnete Listen verschmilzt: 3.1 Einführung in die funktionale Programmierung (ML) 105 fun merge(nil, M) = M | merge(L, nil) = L | merge(L as x::xs, M as y::ys) = if x<y then x::merge(xs,M) else y:: merge(L,ys); > val merge = fn : int list * int list -> int list merge([], [2,4,6,8]); > val it = [2, 4, 6, 8] : int list merge([1,3,5], []); > val it = [1, 3, 5] : int list merge([1,3,5],[2,4,6,8]); > val it = [1, 2, 3, 4, 5, 6, 8] : int list Man beachte, dass in der dritten Zeile das Pattern-Matching quasi zum zweiten Mal durchgeführt wird, indem die Listen L und M wiederum in die ersten Elemente und den Listenrest zerlegt“ ” werden. Man benötigt weiterhin eine Funktion split, die eine Liste in zwei etwa gleich große Listen zerlegt. Die split-Funktion liefert ein Tupel von zwei Listen zurück. fun split (nil) = (nil, nil) | split ([a]) = ([a], nil) | split (a::b::rest) = let val (M,N) = split (rest) in (a::M,b::N) end; > val split = fn : ’a list -> ’a list * ’a list split([1,2,3,4,5,6,7]); > val it = ([1, 3, 5, 7], [2, 4, 6]) : int list * int list Nun kann man die eigentliche Sortierfunktion definieren: fun merge sort (nil) = nil | merge sort ([a]) = [a] | merge sort (L) = let val (M,N) = split(L) in merge(merge sort(M), merge sort(N)) end; val merge sort = fn : int list -> int list merge sort([4,8,2,3,6,1,7,5]); > val it = [1, 2, 3, 4, 5, 6, 7, 8] : int list Durch den Vergleichsoperator < in der merge-Funktion wird durch das Typ-Inferenzsystem gefolgert, dass es sich bei den Listen um Listen von Integer-Zahlen handelt. Ein Auftreten von üblichen mathematischen Operationen verhindert in ML, dass eine Prozedur als polymorphe Prozedur identifiziert wird. 106 3 PROGRAMMIERPARADIGMEN Will man die merge-Prozedur auf Listen von z. B. Zeichen anwenden, so reicht es aus, x oder y als vom Typ char zu deklarieren. Man erhält: Beispiel 3.9: fun merge(nil, M) = M | merge(L, nil) = L | merge(L as x::xs, M as y::ys) = if x<(y:char) then x::merge(xs,M) else y:: merge(L,ys); > val merge = fn : char list * char list -> char list explode("acfkry"); > val it = [#" a", #" c", #" f", #" k", #" r", #" y"] : char list merge(explode("acfkry"), explode("bdglmpsux")); > val it = [#" a", #" b", #" c", #" d", #" f", #" g", #" k", #" l", #" m", #" p", #" r", #" s", #" u", #" x", #" y"] : char list Beispiel 3.10: Abschließend soll das in der Einführung behandelte Problem der Komposition zweier Funktionen in ML formuliert werden. Eine naheliegende Lösung wäre etwa: fun F(x) = x + 3; > val F = fn : int -> int fun G(y) = y*y + 2*y; > val G = fn : int -> int fun comp(X,Y) = fn (z) => X(Y(z)); > val (’a, ’b, ’c) comp = fn : (’a -> ’b) * (’c -> ’a) -> ’c -> ’b val H = comp(G,F); > val H = fn : int -> int H(10); > val it = 195 : int Man muss die Prozedur comp nicht selbst schreiben, denn in ML gibt es für derartige Zwecke den Kompositionsoperator o“, der das Entsprechende leistet: ” fun comp(X,Y) = (X o Y); > val (’a, ’b, ’c) comp = fn : (’a -> ’b) * (’c -> ’a) -> ’c -> ’b comp(G,F)(10); > val it = 195 : int