Kapitel 3.1

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