PD Stefan Bosse
Universität Koblenz-Landau, Fakultät Informatik
SS 2019
Revision 2019-07-09 sbosse@uni-koblenz.de |
Grundlagen funktionaler Programmiersprachen
Konzepte funktionaler Programmiersprachen
Praktische Relevanz und Anwendung der funktionalen Programmierung
Funktionale Programmiersprache Haskell
Begleitet von Übungen um obige Techniken konkret anzuwenden
2 SWS mit Grundlagen und Live Programming mit einfachen Programmierübungen zum mitmachen (Notebook zur Vorlesung mitbringen!)
2 SWS mit Programmierung und angewandter Vertiefung
Grundlegende Kenntnisse der Mathematik, Grundlegende Programmierfähigkeiten (in einer beliebigen Sprache)
|
|
|
|
Einfacher WEB basierter Haskell Compiler und Interpreter (REPL)
Benötigt wird nur ein WEB Browser und eine einzige Datei (hslab.html) → Kann auch offline mit lokaler Kopie ausgeführt werden!
Enthält Editor, Interpreter Shell, und Online Hilfe
Compiler übersetzt (Untermenge) von Haskell in JavaScript → Übersetztes Programm kann auch außerhalb des Labs ausgeführt werden!
Integriertes WEB basiertes Clipboard zum Austausch von Code in der Vorlesung (Lehrer-Student-Student)!
Glasgow Haskell Compilerhttp://haskell.org/ghc/download.html Wird von der Kommandozeile aus benutzt:
| LeksahGrafische Entwicklungsumgebung für Haskell
|
Die Studierenden verstehen das Paradigma der funktionalen Programmierung und beherrschen eine entsprechende Programmiersprache wie etwa
Die Studierenden können einfache algorithmische Probleme und Datenstrukturen funktional modellieren und können dabei Funktionen höherer Ordnung und Typkonstruktoren wie etwa
Die Studierenden kennen typische Szenarien der funktionalen Programmierung etwa im
Funktionen mit der Mathematik als Ausgangspunkt → Mathematische Funktionen als “Programmiersprache”
Einführung in die Programmierung mit Haskell
Mehr über Funktionen: Funktionen als Werte, Funktionen höherer Ordnung, Anonyme Funktionen, usw.
Typisierung
Wie kann man Daten strukturieren und strukturiert verwenden
Wie kann man Typen benutzen um Randbedingungen zu beschreiben, z.B., welche Art von Daten können mit einer Funktion verarbeitet werden
Wie kann man Daten unterscheidbar machen (Im Programm für den Übersetzer und zur Laufzeit)
Wie kann man Typen konstruieren - alles nur eine Funktion?
Anwendung der funktionalen Programmierung
Mo. 16:00 s.t. in M 201
Umfang: 2SWS
Marcel Heinz
Ablauf der Übung:
Besprechung der Lösungen zu den letzten Assignments
Vorbereitung auf die neuen Assignments mit Live-Programmierung
Formalitäten:
Es gibt 9-12 Assignments-
Abgaben werden in SVN hochgeladen.
Vorschlag bzgl. der Zulassung: siehe “Exam admission rules” hier: http://www.softlang.org/course:fp17
Alles, was in der Übung programmiert wird, wird hoch geladen.
Imperative Sprachen
| Deklarative Sprachen
|
Funktionale Programmierung kann schwieriger zu Erlernen sein als prozedurale und objektorientierte Programmierung
Aber: Mit funktionaler Programmierung lassen sich Programmierfehler reduzieren
Eigene Ideen bei der Software-Entwicklung können besser und effizienter ausgedrückt werden
Nötig, um in konkreten Projekten geeignete Sprache auszuwählen
Erleichtert das Erlernen weiterer Programmiersprachen
Grundlage für den Entwurf neuer Programmiersprachen
Haskell: Reine funktionale Programmiersprache
OCaML: Funktionale Programmiersprache kombiniert mit prozeduraler und objektorientierter Programmierung
JavaScript: Prozedurale Programmiersprache kombiniert mit objektorientierter und funktionaler Programmierung
Lua: Prozedurale Programmiersprache kombiniert mit objektorientierter und funktionaler Programmierung
Primär wird in diesem Kurs Haskell gelehrt und in den Übungen eingesetzt. Es werden aber immer wieder Analogien zu den drei weiter aufgelisteten Sprachen gezeigt.
Alonzo Church entwickelte den Lambda Calculus, eine einfache und ausdrucksstarke Theory und Notation von Funktionen
John McCarthy entwickelte Lisp, die erste Funktionale Sprache, mit etwas Einfluß vom Lambda Calclus, aber noch mit Speichervariablen und Zuweisungen
Robin Milner und andere entwicklten ML, die erste moderne Funktionale Sprache mit Typinferenz und polymorphen Typen
Ein internationales Kommitee von Forschern starteten die Entwicklung von Haskell, eine standardisierte Funktionale Sprache mit Bedarfsauswertung (lazy evaluation)
ghci
sum [15, 36, 70]
121
Die Datei hslab.html enthält das Haskell Labor bestehend aus den folgenden Komponenten:
JSHC unterstützt nur eine Untermenge von Haskell und ersetzt daher GHC nicht!
Es gibt leichte syntaktische Abweichungen zu Glasgow Haskell (wird darauf hingewiesen)
Die Typsignaturen von JSHC haben etwas andere Notation als bei Glasgow Haskell
Das Haskell WEB Labor ist experimentell! Daher unterliegt es fortlaufender Aktualisierung
Mathematik ist Funktionale Programmierung ;-)
Mathematische “Methoden”: Funktionen, Ausdrücke (Terme), symbolische Variable, Werte!
Mathematische Funktionen bestehen aus Termen und berechnen ein Ergebnis
Mathematische Funktionen sind zeit- und zustandslos
Die Ergebnisse einer Berechnung hängen nur von den aktuellen Werten und nicht von Berechnungen aus der Vergangenheit ab → Kein Speicher!
Eine symbolische Variable ist daher nur eine Ausdruckssubstituierung
Mathematische Funktionen beschreiben Zusammenhänge und nicht im Detail die Berechnung:
Was und nicht Wie!
1 1.0 3.14 (-1,1) ..
a b c ..
+ - * / mod ..
Bindung von Werten des gleichen Typs (z.B. aus ℕ)
Einzelne Elemente können durch einen ganzzahligen Index ausgewählt werden
Ein Tupel ist eine Bindung von verschiedenen Werten u.U. unterschiedlichen Typs
Notation eines n-stelligen Tupels: <v1,v2,v3,..,vn>
Beispiel komplexe Zahlen: <real,complex> : <1,-1> <2,2> ..
Beispiel mehrsortiges Tupel: <True,1> ∈ 𝔹 × ℕ
Anders als bei einem Vektor kann man einzelne Werte nicht direkt indizieren
Man benötigt Musteranpassung um einzelne Elemente eines Tupels lesen zu können (wird später eingeführt, oder vordefinierte Funktionen aus der Prelude Bibliothek)
False
, True
}Def. Funktion: Eine Funktion f ist ein Tripel <𝔻f,𝕎f,ℝf> aus drei Mengen: Definitionsmenge 𝔻f, Wertemenge 𝕎f, und einer Relation ℝf
Die Menge aller Eingabewerte. Z.B. die Additionsfunktion (+) kann die Definitionsmenge 𝔻f = ℕ × ℕ = {<0,0>, <0,1>, ..} besitzen.
Die Menge aller Ausgabewerte (alle möglichen Berechnungsergebnisse). Z.B. die Additionsfunktion (+) könnte die Wertemenge 𝕎f = ℕ = { 0, 1, 2, .. } besitzen.
Die Relation verknüpft Eingabewerte mit Ausgabewerten. Im Beispiel der Addition wäre dies ℝf = { <<0,0>,0>, .. , <<2,3>,5> .. }
Links: Funktionsdefinition, Rechts: Funktionsanwendung mit konkreten Werten
Das Blockdiagramm ist hierarchisch. Die Implementierung einer Funktion wird durch Unterdiagramme spezifiziert (Funktionale Komposition).
Die Auswertung von Ausdrücken kann man sich leicht als Datenfluss durch ein Netz von Funktionskästen (Blöcken) vorstellen.
Zum Beispiel kann die obige Formel zur Berechnung der Wurfweite folgendermaßen visualisiert werden (wobei hier der Fluss nicht von oben nach unten, sondern von links nach rechts läuft)
Rekursion ist der Selbstaufruf einer Funktion
Beispiele: Fakultät und Fibonacci
Eine Rekursion benötigt ein Terminierungskriterium!
Gefahr: Endlosrekursion
Zwei ähnliche Beispiele:
Man unterscheidet zwei Arten der Rekursion, die erhebliche Auswirkungen zur Laufzeit und auf die Programmausführung haben können:
sum(3) = 3 + sum(2) Stack [n1=3]
sum(2) = 2 + sum(1) Stack [n1=3,n2=2]
sum(1) = 1 + sum(0) Stack [n1=3,n2=2,n3=1]
sum(0) = 0 Stack [n1=3,n2=2,n3=1,n4=0]
sum(1) = 1 + 0 = 1
sum(2) = 2 + 1 = 3
sum(3) = 3 + 3 = 6
sum(3) = add_sum(0, 3) Stack [m=0,n=3]
= add_sum(3, 2) Stack [m=3,n=2]
= add_sum(5, 1) Stack [m=5,n=1]
= add_sum(6, 0) Stack [m=6,n=0]
= 6
Kopfrekursion hat progressiven Speicherbedarf
Endrekursion hat konstanten Speicherbedarf
Jede (End)rekursion kann in eine bedingte Schleife (while do) mit konstanten Speicherbedarf transformiert werden
sum=0;
while (n>0) do
sum := sum + n;
n := n -1;
done
Haskell
JavaScript
OCAML
Die Zuordnung eines Funktionsnamens und benannten Funktionsparametern zu einer Berechnung mit einem Ausdruck: f(p1,p2,..) = ε(p1,p2,..). Die Funktionsparameter sind nur im Funktionskörper nutzbar und sichtbar.
Die Anwendung einer Funktion i.A. in Ausdrücken, d.h. die Berechnung mit konkreten Werten: f(ε1,ε2,..), wobei ε ein beliebiger Wert oder Ausdruck sein kann.
Die Zusammensetzung neuer Funktionen aus bereits definierten Funktionen:
Definition, Applikation, Komposition, und Rekursion sind elementare Eigenschaften der Programmierung und von Programmiermodellen!
Die Funktionsdefinition und Applikation erlaubt die Zerlegung von Programmen in kleine wiederverwendbare Einheiten!
Die (Funktionale) Komposition ist hierarchisch und kann durch Blockdiagramme graphisch dargestellt werden:
Die funktionale Programmierung ist ein Programmierparadigma welches die Berechnung als Evaluierung von mathematischen Funktionen behandelt und Zustand mit veränderlichen Speicher vermeidet!
Spezifiziere was berechnet werden soll, nicht wie es berechnet werden soll.
Die charakteristische Eigenschaft deklarativer Sprachen ist die Church-Rosser Eigenschaft:
Intuitiv besagt sie, dass die Reihenfolge der Auswertung unerheblich für das Resultat ist.
Es gibt keine Möglichkeit den Ressourcenverbrauch zu steuern und zur Programmierzeit abzuschätzen
Es gibt weniger Werkzeuge für den Programmentwurf
Ein- und Ausgabe (auch Netzwerkkommunikation) ist (evtl. strikt) sequenziell!
Haskell ist eine rein-funktionale, nicht-strikte Programmiersprache.
Die wichtigsten Konzepte in Haskell sind:
Neben Haskell sollen in diesem Kurs aber auch einige andere funktionale Sprachen beleuchtet werden: ML, JavaScript, Lua (teils,!)
Da es auch in Haskell nicht alle Konzepte der funktionalen Programmierung gibt wird eine allgemein verständliche Pseudonotation verwendet (siehe [A])
Boolesche (logische) Werte: True
und False
Zeichenkette String ⇔ Zeichenfolgen sind Listen von Zeichen, d.h [Char]
Ganze Zahlen (“fixed precision integers”)
Ganze Zahlen beliebiger Länge (“arbitrary precision integers”)
Fließkommazahlen (“single-precision floating point numbers”)
Ausdrücke werden in Pseudonotation allgemein mit ε bezeichnet
Ausdrücke bestehen aus (symbolischen) Variablen, Werten, Funktionsapplikationen, und Operatoren
|
|
x = ε
let x = ε1 in ε2
let in
wird innerhalb von Ausdrücken verwendet und ist nur innerhalb des Ausdrucks und nach der Definition sichtbar
Definition 1.
f x = if cond-ε1x then ε1 else
if cond-ε2x then ε2 else
...
else ε0
Definition 2.
f x | cond-ε1x = ε1
| cond-ε2x = ε2
...
| otherwise = ε0
Entscheidungsbäume werden im Maschinellen Lernen (ML) eingesetzt. Zweck des ML ist u.A. das Lernen von Zusammenhängen (Modellfunktionen) mit Trainingsdaten.
Das Ergebnis vom überwachten Lernen ist eine Funktion die einen Satz von Eingabevariablen x1,x2,.., xn auf Ausgabevariablen y1,y2,..,ym abbildet:
Eingabevariablen: x1:Geschlecht, x2:Alter, x3:Anzahl Verwandte
Ausgabevariable: y:Überleben?
Ein mögliches ML Modell der die Funktion f() approximiert ist der Entscheidungsbaum:
Definition 3.
[ v1, v2, ε3 , .. ]
Definition 4.
( v1, v2, ε3 , .. )
FUN head: [α] → α
FUN last: [α] → α
FUN tail: [α] → [α]
FUN drop: Int → [α] → [α]
FUN take: Int → [α] → [α]
FUN (!!): [α] → Int → α
FUN (++): [α] → [α] → [α]
FUN reverse: [α] → [α]
[x^2 | x <- [1..5]]
Auf einzelne Tupelelemente oder Bereiche kann nicht direkt zugegriffen werden.
Stattdessen muss Musteranpassung oder Zerlegung verwendet werden, d.h., im einfachsten Fall wird eine Ausdruckszuweisung verwendet mit einem Variablentupel auf der linken Seite und dem zu zerlegenden Tupel:
Definition 5.
tuple = ( v1, v2, .. , vn)
( x1, x2, .. , xn ) = tuple
let ( x1, x2, .. , xn ) = tuple
f ( x1, x2, .. , xn ) = ..
fst (x,_) = x
snd (_,y) = y
Wenn ein Element eines Tupels nicht verwendet wird, kann ein Platzhalter _
eingefügt werden (wildcard)
Tupelzerlegung kann auch bei der Definition von Funktionsparametern angewendet werden
Wie in allen gängigen Programmiersprachen gibt es auch in Haskell eine Mehrfachauswahl.
Die Mehrfachauswahl ist aber deutlich ausdrucksstärker als z.B. switch-case in C
Die Mehfrachauswahl ist ein Ausdruck der einen Wert berechnet!
Definition 6.
case ε of {
v1 -> ε1 ;
v2 -> ε2 ;
..
vn -> εn ;
OTHERWISE
x -> ε0 ; ODER
_ -> ε0
}
Ein Case-Ausdruck muss mindestens eine Alternative haben und jede Alternative muss mindestens einen Körper haben. Jeder Körper muss denselben Typ haben, und der Typ des gesamten Ausdrucks ist dieser Typ.
farbe = "rot"
ist_rot col = case col of {
"rot" -> True ;
_ -> False
}
ist_mischung col = case col of {
"rot" -> False ; "blau" -> False; "gruen" -> False;
_ -> True
}
Definition 7.
case tuple-ε of {
(v1,v2,..) -> ε1 ;
(v1,x2,..) -> ε1 ;
(x1,x2,..) -> ε1 ;
}
DEF f (x,y,z,..) = ε(x,y,z)
⇔ DEF f x y z = ε(x,y,z)
(x,y,z,..)
ein Parameter (ein Tupel)! Geht auch!f x y z .. = εx,y,z,..
f (x,y,z,..) = εx,y,z,..
FUN f : typ1 → typ2 .. → typr
⇔ FUN f : typ1 × typ2 × .. → typr
Die Typsignatur einer Funktion (oder einer Variable) gibt die Datentypen der Funktionsparameter und des Rückgabewertes an. Kann mittels der :type
Funktion angezeigt werden.
Wenn eine Funktion, z.B. Addition, für Werte von verschiedenen Typen anwendbar ist, wird eine Typklasse angegeben. Beispiel: :type (+)
→ (+) :: Num a => a -> a -> a
mit Num
als Typklasse aller numerischen Typen (ℕ ℝ ..)
Gehören zu den eingebauten Funktionen, häufig überladen/polymorph definiert.
Viele Funktionen wie die Summationsfunktion sum
des Basismoduls Prelude
arbeiten mit Listen, d.h., einsortige Listen von Elementen:
[v1,v2,v3,..]
FUN f1, FUN f2, .., DEF fN x y z .. = f1 ∘ f2 ∘ ..
Klassisch ist eine Funktionsdefinition monolitisch, d.h. es existiert eine Definition mit:
In der funktionalen Programmierung können Funktionen aber durch mehrere partielle Funktionsdefinitonen bestehen (nicht zu verwechseln mit Funktionen höherer Ordnung), die durch verschiedene Muster unterscheidbar sind.
Definition 8.
f pat1 = ε1
f pat2 = ε2
..
f patn = εn
count [] = 0
count (x:xs) = 1+(count xs)
Implementiere im HSLAB eine Zählfunktion, die alle Nullwerte einer Liste zählt, mit funktionaler Musterzerlegung:
div (x,0) = None
count [] = 0
Spielt die Reihenfolge der musterbasierten Definitionen von Funktionen eine Rolle?
In einer rein funktionalen Sprache wie Haskell haben Funktionen keine Seiteneffekte. Deshalb spielt auch die Reihenfolge des Aufrufs eine weniger wichtige Rolle.
Ein- und Ausgabe erzeugen einen Seiteneffekt auf die Programmumgebung. Sie müssen in der richtigen Reihenfolge ausgeführt werden.
Definition 9.
do {
Anweisung 1;
Anweisung 2;
..
Anweisung n
}
|
|
Ausgabe einer Zeichenkette auf der Konsole
Ausgabe einer Zeichenkette mit nachfolgenden Zeilenumbruch auf der Konsole
Einlesen einer Zeichenkette von der Konsole (Tastatur
Definition 10.
FUN putStr: String -> IO ()
FUN putStrLn: String -> IO ()
FUN getLine: IO String
Definition 11.
x <- getLine;
main = do {
hSetBuffering stdout NoBuffering ; -- import System.IO
putStr "Gib eine Zeichenreihe ein: ";
s <- getLine ;
putStr " Revertierte Zeichenreihe : ";
putStrLn ( reverse s)
}
Definition 12.
do {
if cond then
Anweisung 1
else
Anweisung 2
..
Anweisung n
}
Definition (Implementierung)
| Deklaration (Typisierung)
|
Applikation (Berechnung)
| Komposition
|
Konzept: Jeder Ausdruck und jeder Wert wird als auswertbare Funktion betrachtet, so dass die ganze Programmierung auf der Übergabe von Funktionen als Parameter an Funktionen beruht.
Definition 13.
v/g sin(2*phi) ⇔
λ v,g,phi → (v2/g sin(2*phi))
λ v,g,phi . (v2/g sin(2*phi))
Die Bindungsregeln in einer Abstraktion λ x → ε sind so geregelt:
Alle Vorkommen der Variablen x im Ausdruck ε gehören zu diesem Ausdruck (sind an dieses x und die Abstraktion gebunden).
Die gebundenen Variablen sind nur in dem Ausdruck sichtbar und verwendbar!
Zum Beispiel ist im Ausdruck (λ x → (y x)) die Variable x eine gebundene Variable (durch λ gebunden), und y eine freie Variable
Variablen (bzw. Vorkommen von Variablen), die in keinem Gültigkeitsbereich einer Bindung stehen, sind frei.
Da es auch Konflikte geben kann durch Verwendung des gleichen Variablennamens, gilt dann die Regel: die innerste Bindung ist sichtbar/anwendbar.
Ein Ausdruck ohne freie Variablen heißt geschlossener Ausdruck, anderenfalls offener Ausdruck.
Notation: (s t) sind Anwendungen, d.h. t wird auf s angewendet, z.B. ( sq 4 ) = 16
Warum gilt: ( ( x y ) z ) = ( x y z )
Aber nicht: ( x ( y z )) ≠ ( x y z ) ?
Auflösung folgt gleich …
g = 9.81
sq1 x = x*x
sq2 = λ x -> x*x
wurfweite1 v0 phi =
let sq x = x*x in
(sq v0)/g*(sin phi) ⇔
wurfweite2 = λ v0 phi -> ((λ x -> x*x) v0)/g*(sin phi)
wurfweite3 = λ v0 phi -> (sq2 v0)/g*(sin phi)
Funktionen sind Werte erster Ordnung, d.h. f = λp . ε ⇔ f p = ε
Eine Funktion kann symbolischen Variablen und Funktionsparametern zugeordnet werden
Ein Lambda Ausdruck ist eine Funktion als Wert und kann von einer Funktion als Ergebnis geliefert werden!
Haskell
| JavaScript
|
Eine anonyme Funktion beschreibt die Abbildung von Variablen auf Werte ohne der Funktion einen konkreten Namen zu geben!
Da Funktionen auch Werte sind und Funktionsparametern übergeben werden können gibt es zwei Anwendungsfälle:
Eine anonyme Funktion wird “identifizierbar”, d.h. anwendbar, durch Zuweisung (Bindung) an eine gebundene oder freie Variable
Eine anonyme Funktion wird “identifizierbar” durch Bindung an einen Funktionsparameter (eigentlich das gleiche)
Ein Lambda Ausdruck ist eine anonyme Form (wie gehabt)!
Definition 14.
\ x y z -> ε(x,y,z,..) ⇔
\ x y z . ε(x,y,z,..) ⇔
λ x y z → ε
Sehr nützlich bei wiederholter Anwendung einer Funktion auf Listenelemente
Wichtige Methode der Datenverarbeitung: Map & Reduce
Haskell
| JavaScript
|
Ein kleiner Ausflug in Big Data ..
Großskalige Datenverarbeitung
Map-Reduce bietet:
function (f: ’a->’b, ’a list) -> ’b list
[a]
function (in_key,in_value) -> (out_key,intermediate_value) list
[a]
function (f:'a*'b->'b, x0:'b, lst;'a list) ->'b
[a]
function (out_key, intermediate_value list) -> out_value list
[a]
Daten werden verteilt im HDFS (Hadoop Distributed File System) abgelegt
Datenblöcke werden mehreren Mappern zugewiesen, die Schlüssel-Wert-Paare ausgeben, die parallel gemischt und sortiert werden.
Der Reduzierungsschritt gibt ein oder mehrere Paare (mit Daten die den gleichen Schlüssel haben) aus, wobei die Ergebnisse im HDFS gespeichert werden
Bei der Curryfizierung findet eine Umwandlung einer einzelnen Funktion mit n Argumenten in n Funktionen mit je einem einzelnen Argument statt.
Traditionell kennen wir nur Funktionen die eine Menge von Argumenten als “ganzes” evaluieren, d.h., Eingabewerte führen immer zu Ausgabewerten (Zahlen usw.)
Da Funktionen aber auch Funktionen zurück geben können kann man (wie bisher schon geschehen) die Funktion zerlegen und die Funktionsargumente einzeln evaluieren → Curry Schreibweise (vom Mathematiker Haskell Curry)
Definition 15.
FUN f x y z : typx → typy → typz → typr ⇔
FUN f x y : typx → typy → ( typz → typr ) ⇔
FUN f x : typx → ( typy → typz → typr )
incr = (+) 1
mapsq = map \x -> x*x
Haskell
Lambdaλ (x,y,z) . x+y+z | JavaScript
|
uncurry f (x,y) = f x y
curry f x y = f(x,y)
f1 (x,y) = x*2+y*3
f2 (y,x) = x*2+y*3
f3 x y = x*2+y*3
f3 y x = x*2+3*y
Funktionen höherer Ordnung (“higher-order functions”) nehmen Funktionen als Argumente oder liefern Funktionen als Resultate.
Bei der funktionalen Komposition werden Funktionen statisch (also zur Programmierzeit) zu neuen Funktionen zusammengesetzt
Mit Funktionen höherer Ordnung kann man dynamisch zur Laufzeit neue Funktionen erzeugen!
Funktionen höherer Ordnung stellen eine Abstraktion und Generalisierung dar.
sum sq [1,2,3]
int f delta = \ a b ->
(sum (map f [ x*delta | x <- [a/delta..b/delta] ]))*delta
int f delta
Funktion ist eine partielle Applikation von f und liefert eine neue Funktion als Wert zurück!
Es gibt Funktionen bei denen der Definitionsbereich nicht vollständig auf den Wertebereich abgebildet werden kann.
Die bekannteste partielle Funktion ist die Ganzzahldivision :
Teilmenge des Definitionsbereiches von
Wertebereich der Divisionsfunktion
Die Relationsmenge enthält ‘Löcher’ wo der Nenner 0 ist
Die Totalisierung fügt ein symbolisches “Not a Number” für ein undefiniertes Ergebnis einer Funktion ein um die Relationsmenge vollständig zu machen
Dazu wird das Symbol “NaN” oder mathematisch ⊥ dem Wertebereich hinzugefügt:
data Num x = NaN | Value x
div a b = if b == 0 then NaN else Value (a/b)
add (Value a) (Value b) = Value (a+b)
Polymorphie bedeutet die Möglichkeit dass Variablen und Funktionsparameter Werte mit verschiedenen Datentypen annehmen können.
Dabei gibt es zwei (+1) Arten von Polymorphie:
Statisch: Bei der Definition und Applikation im Programm: Der konkrete Datentyp wird zur Kompilierungszeit durch die Applikation festgelegt → Typinferenz (Haskell)
Dynamisch: Zur Laufzeit können Variablen und Funktionsparameter noch Werte mit unterschiedlichen Datentypen aufnehmen und verarbeiten (JavaScript)
Überladung: Es wird nicht eine polymorphe Funktion definiert und mit unterschiedlichen Typsignaturen verwendet, sondern eine Menge gleichnamiger Funktionen mit unterschiedlichen Typsignaturen die dann bei der Applikation zur Kompilierungszeit zugeordnet werden.
Polymorphe Funktionen können polymorphe Funktionen verwenden/aufrufen. Aber:
Eine polymorphe Funktion zur Addition von: Ganzen Zahlen (Int), reellen Zahlen (Real), und Zeichenketten ([char]):
FUN add: α → α → α
FUN addInt: Int → Int → Int
FUN addReal: Real → Real → Real
FUN addString: [char] → [char] → [char]
fst :: (a,b) → a snd: (a,b) → b
fst(x,_) = x snd(_,y) = y
map :: (t → u) → [t] → [u]
map f [] = []
map f (a : tail) = f a : map f tail
Definition 16.
qsort :: [Int] → [Int]
qsort [] = []
qsort (x:xs) =
qsort smaller ++ [x] ++ qsort larger
where
smaller = [a | a <- xs, a <= x]
larger = [b | b <- xs, b > x ]
Bei der Sortierung können die Werte einer Liste aufsteigend [1,2,3,..] oder absteigend [10,9,8,..] sortiert werden.
Frage: Werden im obigen Algorithmus die Werte einer Liste aufsteigend oder absteigend sortiert?
Im vorherigen Beispiel wurden die Elemente mittels der eingebauten relationen Funktionen <=
und <
sortiert, anwendbar nur auf numerische Werte.
Erweiterung der Sortierung auf beliebige Werttypen mittels nutzerdefinierten Vergleichsfunktionen cmp(a,b)
die 0 liefert wenn a==b
, -1 wenn a<b
, sonst 1:
qsort :: (Int → Int → Bool) → [Int] → [Int]
qsort cmp [] = []
qsort cmp (x:xs) =
qsort cmp smaller ++ [x] ++ qsort cmp larger
where
smaller = [a | a <- xs, (cmp a x) != 1 ]
larger = [b | b <- xs, (cmp b x) == 1 ]
Funktoren sind Parametrisierte Strukturen
Strukturen werden im nächsten Kapitel eingeführt und dienen der namentlichen Bindung von Werten.
Neben der klassischen Polymorphie mit ihrer Einschränkung auf Typvariablen stellt eine Parametrisierung von Strukturen eine Möglichkeit bereit, in der neben Typvariablen auch Funktionsvariablen vorkommen können.
Dieses Programmierkonstrukt basiert auf Modularisierungstechniken.
Später mehr …
Bisher wurden z.B. Funktionen oder auch Ausdrücke ohne explizite Typdeklaration definiert oder berechnet. Untypisch in traditionellen Programmiersprachen wie C, C++, JAVA, usw.
Die Datentypen werden dabei in der funktionalen Programmierung durch Ableitung (Typinferenz) ermittelt. Wenn nicht möglich gibt wird der Typ eines Wertes bzw. Funktionsparameters durch einen Platzhalter vorläufig bestimmt → α, β, ..
Man kann aber auch explizit Typen und Typsignaturen angeben, in folgender Form:
Definition 17.
sum a b = a+b
sumInt a b = (a::Int)+(b::Int)
mapInt f l = map f (l::Int)
Typen sind auch nur Terme!
Daher kann man neue Typen mit Typausdrücken konstruieren
Einfachster Typausdruck: Synonym
Definition 18.
type T =
type Euro = Double
type Dollar = Double
type Exchange = (Euro , Dollar)
type ExchangeBack = (Dollar , Euro)
let exchange euro = ((euro::Euro)*1.2)::Dollar
::t exchange
>> exchange :: Euro -> Dollar
Bei der Ausführung des Programms wird bei jedem Aufruf f(e) einer Funktion f: A → B überprüft, ob der Argumentwert e dem geforderten Typ A genügt und ob das Resultat dem Typ B genügt.
Der Compiler analysiert das Programm (mit Tpydeklarationen und Typinferenz) um sicherzustellen, dass jeder Funktionsaufruf die Typisierung erfüllt. Man kann das als den einfachsten Grenzfall einer Programmverifikation auffassen.
Die Typeinferenz ist die Fähigkeit eines Übersetzers oder einer Laufzeitumgebung den Datentype von Variablen, d.h. Ausdrücken, Funktionsparametern und Rückgabewerten aus eine Kette (Baumstruktur) von Applikationen und Ausdrücken abzuleiten
Bei der Typinferenz wird nach konkreten Datentypen gesucht und diese entlang des Evaluierungsbaums von Ausdrücken propagiert und aufgelöste Typen substituiert.
Beispiel:
⇒
⇒ ⇒
da ⇒ da und ⇒
⇔
id x = x
incr x = x+1
incr2 x = x+1.0
map :: (t → u) → [t] → [u]
map f [] = []
map f (a : tail) = f a : map f tail
mapDecr = map (\ x -> x-1)
Eine der häufigsten Gründe für die Einführung neuer Datenstrukturen ist die Beobachtung, dass eine Gruppe von Daten logisch zusammengehört und gemeinsam etwas Neues, Eigenständiges darstellt.
Interessanterweise zeigt sich bei Datenstrukturen das gleiche Phänomen wie bei Funktionen: Die reiche Fülle von Möglichkeiten erwächst aus einer kleinen Zahl von elementaren Konstruktionsprinzipien. Dies sind bei Funktionen:
Bei Datenstrukturen:
Unser Ziel ist also: Wir wollen aus vorhandenen Datenstrukturen neue Datenstrukturen aufbauen.
Dafür gibt es im Wesentlichen drei Konstrukte:
Dass diese Konstrukte die elementaren Bausteine eines ganzen Universums von Datenstrukturen sind , ist keine Entdeckung der Informatik , sondern - wieder einmal - der Mathematik, genauer: der elementaren Mengenlehre.
Auch eine zweite Beobachtung verdanken wir der Mathematik, und zwar diesmal der Algebra: Die Konstruktion von Datenstrukturen liefert nicht nur Mengen neuer Werte, sondern gleichzeitig auch kanonische Operationen fur diese Werte: Datenstrukturen und ihre kanonischen Operationen bilden eine logisch zusammenqehöriqe Einheit - das Eine macht ohne das Andere keinen Sinn.
Listen und Arrays sind Produkttypen, d.h. die Elemente eines Produkttyps werden prinzipiell alle zusammen in Berechnungen verwendet (e1 und e2 und ..)
I.A. ist der Datentyp der Elemente gleich, d.h. es handelt sich um einen einsortige Komposition
Die Elemente von Produkttypen sind i.A. durch einen Index (ganze Zahl) identifizierbar und referenzierbar.
Tupel sind mehrsortig, d.h., die Werte der einzelnen Tupelelemente können zu verschiedenen Datentypen gehören.
Aber die Elemente sind anonym (wie anonyme Funktionen) und nicht selektierbar
Tupel werden i.A. ohne vorherige Typdefinition erzeugt (anonym und dynamisch zur Laufzeit)
Es können aber auch namentliche Tupeltypen definiert werden:
Definition 19.
Hier sind die Elemente des Produkttyps über einen eindeutigen Namen (i.A. auf den speziellen Produkttyp begrenzt) identifizierbar und referenzierbar (selektierbar).
Die gesamte Typsignatur eines mehrsortigen Produkttyps ist genau wie beim Tupel ein Produkt der Typen der Einzelelemente:
Definition 20.
Die Definition eines Strukturtyps (Gruppenbildung) erfordert die Aufzählung der Typelemente in Form von Tupeln <Namei,Typi>,
Die Definition eines Strukturtyps kann wieder rein funktional durch eine Typkonstruktionsfunktion erfolgen, die i.A. gleichnamig wie der Produkttyp ist:
Definition 21.
DATA point == point(x : real, y : real)
DATA line == line(p1: point, p2: point)
DATA circle == circle(center:point, radius: real)
Definition 22.
Definition 23.
D.h. um auf ein Element einer Datenstruktur des Typs (lesend) zugreifen zu können wird die Funktion verwendet → Die Selektorfunktion liefert den Wert des Elements.
Häufige wird eine Kurzschreibweise in der Form verwendet, wobei eine vom Typ abgeleitete Datenstruktur ist.
In vielen Programmiersprachen sind die Strukturelemente (Variablen) ebenso (über die Selektorfunktion) veränderlich. Nicht so in der (strikten) funktionalen Programmierung!
data
und den Strukturelementen als Parameter des Summentypelements T:
Definition 24.
data Eintrag = E
String -- Vorname
String -- Nachname
String -- Straße
String -- Stadt
String -- Land
getVorname :: Eintrag -> String
getVorname (E n _ _ _ _) = n
setVorname :: String -> Eintrag -> Eintrag
setVorname n (E _ a b c d) = E n a b c d
createVorname :: String -> Eintrag
createVorname n = E n undefined undefined undefined undefined
-- analog für die weiteren Felder
-- ...
data T = T typ1 typ2 ..
ei (T _ .. xi _ ..) = xi
s = T ε1 ε2 ..
Da bei der Tupeltypisierung keine Elementnamen enthalten sind wird die Strukturtypdefinition und Verwendung schnell unhandlich und verwirrend.
Erweiterte Tupeltypdefinition und Elementselektion mit Feldnamen:
Definition 25.
data Eintrag = E {
vorname :: String,
nachname :: String,
strasse :: String,
stadt :: String,
land :: String }
Sehr häufig hat man das Problem, dass ein Typ Elemente zusammenfassen soll, die inhaltlich etwas Gemeinsames darstellen, aber strukturell unterschiedlich aufgebaut sind.
In diesen Situationen lassen sich die Elemente des Typs in verschiedene Varianten klassifizieren. Im Sinne der Mengenlehre haben wir es dann mit einer so genannten direkten Summe zu tun, d.h. im Wesentlichen mit einer disjunkten Vereinigung.
DATA figure == line(pl : point , p2 : point)
triangle (pl : point, p2 : point, p3 : po int)
circle( center: point, radius : real)
DATA address == st (plz : nat , ort: denotation, str : denotation)
pf (plz : nat , ort : denotation, postfach : nat )
DATA result == ok(value : real)
error(message : denotation)
DATA infNat == normal(value : nat)
infinity
Konstruktor-Varianten. Diese Beispiele illustrieren, dass ein Summentyp aus mehreren Varianten besteht, die ihrerseits Produkte sind.
Als Grenzfall sind auch Konstanten als Varianten möglich → Aufzählungstyp. Da die einzelnen Varianten Produkte sind, können wir ihre Konstruktorfunktionen wie üblich benutzen, um die Elemente des Summentyps zu erhalten.
Definition 26.
var line = {
tag:'line',
p1:{tag:'point',x:0,y:20} , .. };
var circle = {
tag:'circle',
center:{tag:'point',x:0,y:20} , .. };
function Line (p1,p2) {
return {tag:'line',p1:p1,p2:p2}
}
function Circle (center,radius) {
return {tag:'circle',center:center,radius:radius}
}
function height (o) {
switch (o.tag) {
case 'line': return Math.abs(o.p1.y-o.p2.y);
case 'circle': return o.radius*2;
} }
In vielen Funktionen können Fehler auftreten, die den Benutzer nicht weiter interessieren. Er möchte lediglich wissen, ob die Berechnung erfolgreich war.
Z.B. die Suche in einem Telefonbuch ergibt als Ergebnis keinen Eintrag. Häufig wird dann ein “leeres” Ergebnis (0, [], ..) zurückgegeben.
Besser ist ein Summentyp für das Ergebnis der entweder einen konkreten gebundenen Wert Some ϕ zurück gibt oder ein Symbol None:
data Maybe v = Some v | None
teilen :: Int -> Int -> Maybe Int
teilen n 0 = Nothing -- man kann nicht durch 0 teilen
teilen n m = Some (div n m)
teilenMoeglich :: Int -> Int -> String
teilenMoeglich a b = case (teilen a b) of
Some _ -> " Der Divisor war nicht 0"
Nothing -> " Die Division ist nicht möglich"
Algebraische Datentypen können wie im Fall Maybe
polymorph definiert werden!
An die Daten von Summentypen und deren Elementen kann man wieder nur über eine Musterzerlegung gelangen, d.h., ein Ausdruck mit der Subtypkonstruktion (der Variante) auf der linken Seite mit Parametern und dem zu zerlegenden Wert:
Definition 27.
Der Aufzählungstyp ist ein Spezialfall des Summentyps mit rein symbolischen Elementen (d.h. Produkttypen nullter Ordnung):
Die folgenden Typen illustrieren Situationen, in denen eine kleine Gruppe von Werten einen neuen Typ darstellt.
Definition 28.
data Day = Monday | Tuesday | Wednesday | Thursday | Friday | Saturday | Sunday
data Color = Blue | Green | Red | Yellow
data Switch = On | Off
data TrafficLight = Green | Amber | Red
today = Tuesday
Typkonstruktoren können wie bereits eingeführt wurde auf gewisse Weise als Funktionen über Typen interpretiert werden.
Rekursion lässt sich nicht nur als Entwurfstechnik für Funktionen verwenden, sondern auch für die Definition von Datentypen.
Oft enthält eine Datenstruktur als Bestandteile wieder Elemente derselben Sorte.
Ausdrücken lassen sich solche Typen mit Hilfe von Produkten und Summen, wobei - analog zur Funktionsdefinition - jetzt der deklarierte Typ selbst auf der rechten Seite vorkommt.
Die Liste haben wir bereits als rekursiven Datentypen kennen gelernt. Eine Liste ist entweder leer oder sie besteht aus einem Kopfelement und einer Restliste. Als algebraischen Datentypen können wir das beispielsweise wie folgt definieren:
data List a = Nil | Cons a (List a)
Aus Gründen der Wiederverwendbarkeit, Wartung und Fehlerlokalisierung ist es ratsam, Programme in Teile zu zerlegen und durch diese Modularisierung das Abstraktionsniveau zu erhöhen:
Gerade bei Projekten mit mehreren Programmierern ist es ratsam, Aufgaben in mehrere Bereiche aufzuteilen, die dann parallel bewältigt werden.
Ein wichtiges Konzept für große Softwareprojekte stellt die Definition von Schnittstellen (Interfaces) dar → Typsignaturen
Ein Modul besteht aus Deklarationen (Schnittstelle) und Definitionen (Implementierung)
Haskell bietet in diesem Zusammenhang die Definition von Modulen an.
Ein Modul wird in Haskell mit dem Schlüsselwort module
definiert.
Höchstens ein Modul pro Datei ist dabei erlaubt.
Als Konvention gilt, dass der Name eines Moduls identisch mit dem Dateinamen ist.
Definition 29.
module M (
e1,
e2,
e3,
..
ei
) where
ei :: typi
ei = εi
..
Die in einem Modul definierten Elemente (Funktionen, Typen, Terme) sind nur innerhalb des Moduls nach where sichtbar. Daher:
Module bzw. Ausschnitte (einzelne Funktionen usw.) können in andere Module über das Schlüsselwort import
importiert werden (Ein Skript ist ebenso ein Modul):
Definition 30.
import M (e1, e2, .. )
M.ei
selektieren.
Typklassen bieten die Typisierung von Typen!
Man erhält ein abgestuftes System der Typisierung (horizontal), verbunden mit einem abgestuften System der Modularisierung (vertikal).
Der innerste Bereich betrifft die Werte. Dies sind atomare Werte wie Zahlen, Buchstaben etc., zusammengesetzte Werte wie Tupel, Listen, Arrays etc. und auch Funktionen.
Die Werte werden durch Typen klassifiziert. Es gibt atomare Typen, Tupeltypen, Funktionstypen etc.
Die Typen wiederum werden durch Typklassen klassifiziert. Dazu gehören Konzepte wie Eq, Ord, Numeric usw.
Auf der ersten Modularisierungsstufe erlauben Strukturen die Zusammenfassung von Werten und Typen zu neuen Einheiten.
Diese Strukturen werden durch Spezifikationen typisiert.
Definition 31.
Eine Typklasse C charakterisiert eine Menge von Typen (genauso wie ein Typ eine Menge von Werten charakterisiert). Eine Typklasse ist weiterhin eine Menge von Typen, die bestimmte Eigenschaften erfüllen.
Alle Typen in Eq unterstützen die Funktionen (==) und (/=).
Alle Typen in Ord haben eine (<=) Relation auf ihren Typen und unterstützen alle weiteren Vergleichsoperatoren
Alle Typen in Show verfügen über eine textuelle Formatierungsfunktion für die Ausgabe von Werten auf dem Bildschirm.
Um beispielsweise einen neuen Datentypen zu einer Instanz der Klasse Show zu machen, müssen wir eine Funktion schreiben, die die Werte dieses Typen in Strings umwandelt.
deriving
:
data Saison = Fruehling | Sommer | Herbst | Winter
deriving(Eq, Ord, Enum, Show, Read, Bounded)
Alternativ zur automatischen Instantiierung von Typen zu Klassen lassen sich diese auch manuell vornehmen.
Definition 32.
instance Klasse Datentyp
where ...
data Boolean = T | F
instance Show Boolean
where
show T = "Wahr"
show F = "Falsch"
instance Eq Boolean instance Ord Boolean
where where
T == T = True F <= T = True
F == F = True x <= y = x == y
_ == _ = False
einzuführen (Vergleiche Haskell Intensivkurs [C]) .
Der abstrakte Datentyp Wörterbuch findet in vielen Anwendungen seinen Einsatz und trägt den Namen aufgrund der Ähnlichkeit zum physikalischen Wörterbuch.
Elemente können mit einem Schlüssel darin abgelegt und anschließend effizient wieder gefunden werden.
Ein Wörterbuch ist eine Liste von (Schlüssel,Wert)-Tupeln.
Die Frage ist wie können Einträge Schnell anhand des Schlüssels gefunden werden?
Ist der Schlüssel eine ganze Zahl oder eine Zeichenkette? Au falle Fälle sollte er abzählbar sein (Typ Ordinal).
Der Schlüssel kann eindeutig oder mehrdeutig sein (relevant und kritisch)
Ein Wert (der Datensatz) kann einzigartig sein oder nicht (Mehrdeutigkeit nicht relevant und unkritisch)
module Woerterbuch (
WBuch,
leeresWBuch ,
einfuegen , finde, loesche)
where
newtype WBuch k v = D [(k,v)] deriving Show
leeresWBuch :: Eq k => WBuch k v
leeresWBuch = D []
einfuegen :: (k, v) -> WBuch k v -> WBuch k v
einfuegen x (D l) = D (x:l)
finde :: Ord k => k -> WBuch k v -> Maybe v
finde k (D l) = lookup k l -- lookup aus Prelude
loesche :: Eq k => k -> WBuch k v -> WBuch k v
loesche key (D l) = D [(k,v) | (k,v) <- l, k/=key]
Eine einfache Möglichkeit, den ADT Wörterbuch zu implementieren, besteht darin, nur eine Liste mit dem Tupel (Schlüssel k, Wert v) anzulegen.
Das Einfügen ist trivial (Oeinfuegen(N) = 1), aber das Finden und das Löschen der Elemente lässt sich nur in linearer Zeit realisieren. → Olookup(N) = N.
Das Suchen kann erfolgreich sein (Typ Maybe: Just v) oder ergebnislos (Nothing) und wird durch Iteration über die Wörterbuchliste durchgeführt:
lookup k ((x,v):tail) = if (x==k) Just v else lookup k tail
lookup k [] = Nothing
Listen erlauben eine Menge von Datensätzen so zu speichern, dass die Operationen Suchen, Einfügen und Entfernen unterstützt werden.
Jeder Datensatz ist gekennzeichnet durch einen eindeutigen Schlüssel, z.B. eine Integer-Zahl oder eine Zeichenkette.
Zu jedem Zeitpunkt ist lediglich eine (kleine) Teilmenge σ aller möglichen Schlüssel Σ gespeichert.
Das bedeutet bei einer linearen Speicherung der Datensätze mit einer Tabelle, wo der Index der Tabelle/des Arrays gleich (bzw. die Speicheradresse proportional) dem Schlüssel ist, eine große Tabelle mit geringer Zeilenausnutzung.
Lineare Speicherung | Verteilte Speicherung |
liegt in der Suchzeit O(1)!
Die Größe der Hash-Tabelle ist zweckmäßigerweise m < n=nkmax
Die Hash-Funktion ordnet jedem Schlüssel k einen Index der Tabelle (oder eine Speicheradresse) h(k) mit 1 ≤ h(k) ≤ m im Falle eines Indexes zu.
I.A. ist σ eine sehr kleine Teilmenge von Σ. Z.B. gibt es 26*3679 mögliche Schlüssel für eine Zeichenkette mit 80 Zeichen, die mit einem Buchstaben beginnt, und mit Buchstaben oder Ziffern fortgesetzt wird.
k1 und k2 heissen dann Synonyme. Ergibt sich direkt aus der Tatsache m < n und wird als Mehrdeutigkeit bzw. Adresskollision der Hash-Funktion bezeichnet.
Eine gute Hash-Funktion sollte möglichst schnell berechenbar sein, und die zu speichernden Datensätze möglichst gleichmäßig, aber ohne erkennbare Struktur oder Ordnung auf den Speicherbereich verteilen, um Kollisionen zu minimieren.
Problem: Die Schlüsselmenge σ={ki} ist meistens nicht gleichmäßig verteilt.
Z.B. bestehen Variablenamen in Programmen häufig aus 1-2 Zeichen mit den Buchstaben {x,y,z} und den Zahlen {1,2,..}.
Ein Maß für Wahrscheinlichkeit der Kollision in einer Tabelle mit m Zellen und nach k Einfügungen ist:
In JavaScript sind Arrays immer Hash-Tabellen → Und Objekte (Strukturen) sind ebenfalls Hash-Tabellen → Der Schlüssel ist immer eine Zeichenkette (auch bei numerischen Index)!
Definition 33.
var h; h[key]=data; x=h[y];
Beispiele
var a = [1,2,3,4]
var e1 = a[2]; -- == 3
var e2 = a["2"]; -- == 3!
a.counter = 4;
a["counter"]= 4;
var o = {x:1,y:2};
print(o.y-o.x) -- 1
print(o["y"]-o["x"]) -- 1
function WBuch() {
this.data={};
this.n=0;
}
WBuch.prototype.finde = function (k) {
return this.data[k];
}
WBuch.prototype.einfuegen = function (k,v) {
this.data[k]=v; this.n++;
}
WBuch.prototype.loesche = function (k) {
this.data[k]=undefined; this.n--;
}
function leeresWBUch() {
return new WBuch();
}
Ein Eintrag in einer Hashtabelle ist eine lineare Liste. Die Überläufer werden in dieser Liste angeordnet.
Die Überläufer werden in freien Zellen der Hashtabelle untergebracht:
Anders als Listen sind Arrays feste Blöcke im Speicher eines Computers.
Analog zu den Listen lassen sich viele Elemente desselben Datentyps in einem Array speichern.
Arrays lassen sich nicht vergrößern oder verkleinern, ohne sie neu anzulegen.
Dieser Nachteil führt dazu, dass Arrays in Haskell seltener zum Einsatz kommen als Listen.
Vorteil gegenüber Listen: Die Zugriffszeit auf ein Array-Element ist immer konstant. Um auf das k-te Element zuzugreifen, müssen nicht die ersten k−1 Elemente entlanggelaufen werden, wie es bei einer verketteten Liste der Fall ist!
Für Hashtabellen werden veränderliche Arrays benötigt.
Traditionell sind Arrays vor allem ein Zugeständnis an die Architektur der von Neumann-Rechner: Sie repräsentieren einen konsekutiven Speicherbereich, dessen Elemente sehr effizient und kompakt über Adressrechnung verarbeitbar sind.
Ein Array ist ein i.A. monosortiger Produkttyp und besteht aus einer Anzahl N von Elementen die über einen abzählbaren Index selektiert werden können.
Es gibt Schreib- und Leseoperationen auf Arrays bzw. deren selektierte Elemente:
a = ARRAY (ia .. ib) typelem ([v0,..])
write a index value
x = read a index
Die Array-Selektion v(i) wird auf die Blockselektion abgebildet, wobei ein Indexshift in das Intervall 0..n −1 vorgenommen wird.
Der Array-Update wird entsprechend auf set abgebildet.
Die Operation step kodiert die Richtung und Schrittweite, in der das Array verarbeitet werden soll
Die Erzeugung eines Arrays über einen Ausdruck, also v = λ i • f (v, i), wird sequenziell über einen Iterator gelöst, der mit Hilfe der Operation step die Arrayelemente nacheinander mit den entsprechendenWerten von f (v, i) besetzt.
Was wäre bei einem ‘rohen’ Speichermodell noch zu beachten?
Funktional gibt es keine veränderlichen Daten!
Daher würde strikt funktional die Änderung eines Arrayelements die (virtuelle) Erzeugung eines neuen Arrays bedeuten:
FUN read: α Array → β → α
FUN write: α Array → β → α → α Array
Auf semantischer und methodischer Ebene sind Arrays nichts anderes als spezielle Funktionen:
Definition 34.
import Data.Array.IO
.. do ..
a <- newArray (ia,ib) v0
:: IO (IOArray typindex typelem )
writeArray a index value
readArray a index
Die Grenzen eines Arrays sind nicht wie in vielen anderen Sprachen auf den Datentyp Int beschränkt.
Alle Typen aus der Typklasse Ix, insbesondere auch Tupel, können als Arraygrenzen verwendet werden.
So können problemlos mehrdimensionale Arrays verwaltet werden, beispielsweise um Digitale Verarbeitung von Bildern zu ermöglichen.
Bei statischen Arrays können die Elemente nicht verändert werden.
Arrays müssen in funktionalen Sprachen wie andere (symbolische) Variablen mit einem Wert initialisiert werden.
Durch den Liste-Array Dualismus können statische Arrays auch aus Listen erzeugt werden:
import Data.Array.IArray
.. do ..
a = (listArray (ia,ib) [..]) :: Array typindex typelem
a!index
!
index Operators zugegriffen werden (nur lesend!)
Eine Liste ist eine lineare Verkettung von Listenelementen (Daten).
Ein Listenelement besteht dabei aus einem Verkettungs-, Schlüssel und Datenteil:
TYPE (α,β) LISTENELEMENT = {
connect:[(α,β) LISTENELEMENT],
key:β,
data:α
}
Der Schlüssel ist optional assoziiert einen Datensatz (eindeutige oder mehrdeutige Assoziation)
Listen unterstützen folgende wesentliche Operationen:
Jedes Element besitzt eine Referenz (Link) zu dem nächsten rechten (oder linken) Nachbarn
Jedes Element besitzt zwei Referenzen zum jeweiligen linken und rechten Nachbarn
data DList a b =
Leaf |
Node { prev::(DList a b), key:: a, elt::b, next::(DList a b) }
create = go Leaf
where go _ [] = Leaf
go prev ((key,val):xs) = current
where current = Node prev key val next
next = go current xs
findRight _ Leaf = Nothing
findRight key (Node l k v r) =
if k == key then Just v
else findRight key r
findLeft _ Leaf = Nothing
findLeft key (Node l k v r) =
if k == key then Just v
else findLeft key l
Bei einer einfach verketteten Liste muss immer das Kopfelement gespeichert werden, bei einer doppelt verketteten Listen können sowohl Kopf- als auch Endelement gespeichert werden
Kopf- und Endelemente (Wurzelknoten) sind der Zugang zur Liste, z.B. beim Suchen
Laufzeit als Laufzeitklasse → Θ(x) → Größenordnung der groben Berechnungsschritte in Abhängigkeit von der Größe x der Datenstruktur
Das Einfügen oder Löschen eines Elements am Kopf der Liste (und am Ende bei dopp. verk.) benötigt nur einen Schritt → Θ(1) → konstante Laufzeit unabhängig von n (Anzahl der Knoten)
Das Suchen eines Elements benötigt bei beiden Listen maximal n Schritte → Θ(n)
Das Einfügen oder Löschen eines Elements anhand eines Schlüssel innerhalb der Liste benötigt maximal (n-1) Schritte (Suche) → Θ(n)
Data.List
zwei weitere wichtige Operationen auf Listen an:
Findet das kleinste Element einer Liste und gibt dieses als Ergebnis zurück (Wert, nicht Position!)
Löscht ein Element einer Liste. Der Wert des zu löschenden Elements muss angegeben werden, nicht die Position! Die modifizierte Liste wird zurück gegeben.
Der vermutlich einfachste Ansatz, eine Liste mit Elementen zu sortieren, ist
Dieser Algorithmus wird als Selection Sort oder Sortieren durch Auswahl bezeichnet. Iterativ können wir den Algorithmus in etwa so angeben:
FOR i = 0 to n-1 DO
FOR j=n-1 to i+1 DO
IF x[j-1] > x[j] THEN vertausche x[j-1] und x[j]
minimum
und delete
die Sortierung rekursiv formulieren:
import Data.List
sSort :: (Ord a) => [a]->[a]
sSort [] = []
sSort xs = m : sSort (delete m xs)
where m = minimum xs
Laufzeitanalyse mittels Rekursion:
Das Finden des Minimums einer Liste ist in linearer Zeit zu schaffen.
Das Finden und damit auch das Löschen eines beliebigen Elements benötigt ebenfalls lineare Zeit.
T(0) = 1
T(n) = n + T(n-1)
⇒ ΘsSort(n2)
Ein weiterer Sortieralgorithmus ist das „Sortieren durch Einfügen“ oder kurz InsertionSort.
Angenommen, eine Liste liegt bereits aufsteigend sortiert vor, dann ist das Einordnen eines neuen Elements einfach.
Es wird solange von links beginnend überprüft, ob das aktuelle Element kleiner oder gleich dem Einzufügenden ist, bis die korrekte Position ermittelt ist.
An diese Stelle wird das Element eingefügt und die Liste bleibt sortiert
einfuegen :: Ord a => a -> [a]-> [a]
einfuegen x [] = [x]
einfuegen x (y:ys)
| x<=y = x:y:ys
| otherwise = y:(einfuegen x ys)
iSort :: Ord a => [a]-> [a]
iSort [] = []
iSort (x:xs)= einfuegen x (iSort xs)
Laufzeitanalyse:
Im schlechtesten Fall benötigt dieses Verfahren ebenfalls eine quadratische Laufzeit, da das Auffinden der richtigen Stelle zum Einfügen im schlechtesten Fall lineare Zeit benötigt und die Liste für die Rekursion nur um eins verkürzt wird.
Im besten Fall ist InsertionSort, anders als bei SelectionSort, allerdings sehr viel schneller.
Der best-case tritt ein, wenn die Startliste umgekehrt sortiert vorliegt. Dann ist die richtige Stelle zum Einfügen immer ganz vorn zu finden und das benötigt konstante Zeit. Es wird also n-mal ein konstanter Aufwand betrieben, so dass Θ(n) Operationen gebraucht werden.
Der QuickSort-Algorithmus ist ein typisches Beispiel für Algorithmen die auf dem Teile-und-Herrsche-Prinzip basieren → Top-down Ansatz
In jedem Schritt, beginnend mit der gesamten Liste, wird ein Element ausgewählt → Pivotelement
Die Liste wird dann dort zweigeteilt.
Das Pivotelement verbleibt dabei in der Mitte und hat seinen endgültigen Platz in der sortierten Liste bereits eingenommen.
Die Auswahl des Pivotelements ist zentral in diesem Algorithmus und kann auf verschiedene Weisen erfolgen. Z.B.: Immer Auswahl des ersten Elements einer Teilliste
qSort :: Ord a => [a]-> [a]
qSort [] = []
qSort (x:xs)=
qSort [y | y<-xs, y<=x]++[x]++
qSort [y | y<-xs, y> x]
Die Laufzeit hängt dabei stark davon ab, wie das Pivotelement gewählt wird. Im besten Fall wird die Liste in jedem Rekursionsschritt halbiert.
T(0)= 1
T(n)= n+2 T(n/2)
Im besten Fall erreicht man Θ(n log(n))
QuickSort ist ein effizientes Sortierverfahren, hat jedoch im schlechtesten Fall quadratische Laufzeit.
MergeSort ist ein weiteres Sortierverfahren, das dem Teile-und-Herrsche-Prinzip folgt → aber Bottom-up Ansatz.
Annahme: Es ist kostengünstig, aus zwei sortierten Listen eine neue sortierte Liste zu erzeugen.
Beim MergeSort-Algorithmus werden zunächst einelementige Listen rekursiv erzeugt und diese sortierten Listen anschließend mit zusammengefasst, bis nur noch eine Liste vorhanden ist
merge [] ys = ys
merge xs [] = xs
merge (x:xs)(y:ys)
| x <= y = x:(merge xs (y:ys))
| otherwise = y:(merge (x:xs) ys)
mSort :: Ord a => [a]-> [a]
mSort [] = []
mSort [x]=[x]
mSort xs = merge (mSort erste)(mSort zweite)
where
(erste, zweite) = splitAt haelfte xs
haelfte = div (length xs) 2
Laufzeitanalyse:
Die Laufzeit der merge Funktion ist linear zu der Länge der beiden Listen.
Wenn die Listen die Längen m und n haben, ist die Laufzeit Θ(m+n).
Da bei diesem Verfahren die Listen garantiert halbiert werden und in einem Rekursionsschritt nur linearer Aufwand betrieben wird, ist die Laufzeit auf jeden Fall Θ(n log(n))
(Stapelspeicher / Kellerspeicher)
Ein Element el wird auf dem Stapelspeicher als oberstes Element abgelegt (top)
Das oberste Element wird vom Stapelspeicher entfernt
Stapelspeicher finden bei Funktionen und Funktionsaufrufen Anwendung
Ein Stapelspeicher unterstützt keine Konkatenation!
Stapelspeicher kann direkt mit Haskell Listen implementiert werden
Laufzeit für Einfügen und Entfernen ist immer O(1)
data Stack a = S [a]
create () = []
push (S s) el = el:s
pop S (el:tail) = (el,tail)
Ein Element am Ende der Liste einfügen
Ein Element vom Anfang der Liste (Kopf) entfernen
Warteschlangen sind für viele bekannte Algorithmen wichtig.
Als erste Idee für die Implementierung einer Warteschlange könnte der Datentyp Liste angedacht werden.
|
|
data Queue a = Q [a][a]
empty :: Queue a -> Bool
empty (Q [] []) = True
empty _ = False
create () = Q [] []
enqueue :: a -> Queue a -> Queue a
enqueue a (Q x y)= Q x (a:y)
dequeue :: Queue a -> (a, Queue a)
dequeue (Q [] y) = (y1, Q ys [])
where (y1:ys) = reverse y
dequeue (Q [x] y) = (x, Q (reverse y) [])
dequeue (Q (x:xs) y) = (x, Qxs y)
Die Laufzeit der Zweilistenimplementirung ist nicht generell konstant, denn im schlechtesten Fall hat dequeue einen linearen Aufwand O(n), um die zweite Liste umzudrehen.
Anschließend kann dequeue allerdings n-mal mit konstantem Aufwand aufgerufen werden.
Da die Operationen enqueue und dequeue aber im Mittel gleich häufig verwendet werden ist eine armortisierte Laufzeitanalyse sinnvoll, d.h. die mittleren Kosten bei Verwendung der beiden Operationen
Man kann die Laufzeit einer Sequenz der Länge n von enqueue und dequeue Funktionsaufrufen betrachten.
Eine Möglichkeit die amortisierten Laufzeiten zu bestimmen, ist die so genannte Bankiermethode (banker’s method).
Dabei werden den Operationen nicht nur die tatsächlichen Kosten zugewiesen, wie es bei der normalen Laufzeitanalyse gemacht wird.
Es werden zusätzlich noch Kredite hinterlassen oder vorhandene Kredite konsumiert.
Jeder enqueue Aufruf benötigt Laufzeit O(1) und hinterlässt einen Kredit in Höhe 1, was die armotisierten Kosten auf 2 erhöht!
Ein dequeue Aufruf mit nicht leerer erster Liste benötigt nur O(1)
Ein dequeue Aufruf mit leerer erster Liste erfordert reverse und benötigt O(1) um ein Element zu entfernen und O(m) um die Liste umzudrehen
Die teure Operation verbraucht m Kredite!
Die armortisierte Laufzeit der Warteschlange wäre dann O'(1)!!! Aber Datenstrukturen sind in Haskell persistent, d.h. unveränderlich, und in der Realität kann die armortisierte Laufzeit größer sein!
Lazy Evaluation: Ausdrücke werden nur bei Bedarf ausgewertet bzw. Operationen ausgeführt
Hier: Kritisch, dass es bereits zu spät ist, die zweite Liste erst dann umzudrehen, wenn die erste Liste bereits leer ist
Stattdessen muss dafür gesorgt werden, dass immer genügend Elemente vorhanden sind.
data Queue a = Q Int [a] Int [a]
empty :: Queue a -> Bool
empty (Q 0 _ _ _)= True
empty _ = False
queue n x m y
| n > m = Q n x m y
| otherwise = Q (n+m) (x++reverse y) 0 []
enqueue :: a -> Queue a -> Queue a
enqueue a (Q n x m y) = queue n x (m+1) (a:y)
dequeue :: Queue a -> (a, Queue a)
dequeue (Q n (x:xs) my)=(x, queue (n-1) xs m y)
Die Datenstruktur Baum liegt vielen Algorithmen zu Grunde.
Ein Baum ist ein spezieller Graph (zyklenfrei)
Bäume bestehen aus
Der Startknoten wird als Wurzel bezeichnet.
Daten können nur in den Endknoten (Blättern) enthalten sein → die Zwischenknoten dienen nur zum Auffinden der Daten
In den Knoten und an den Kanten können bei Bedarf zusätzliche Informationen gespeichert werden.
Alle kreisfreien Datenstrukturen, wie beispielsweise Listen, lassen sich durch Bäume repräsentieren.
Interessant sind schon wie bei den vorhergehenden Datenstrukturen das Einfügen und das Löschen von Elementen und deren Laufzeitkosten.
Beide Operationen lassen sich auf das Zusammenfügen von Bäumen zurückführen.
Wenn man ein Element in einen existierenden Baum einfügt, wird ein Baum mit einem Element zu einem neuen Baum zusammengeführt.
Wenn ein Element gelöscht wird, dann erhält man zunächst viele Bäume die wieder miteinander sinnvoll zu einem neuen Baum verbunden werden müssen.
data Baum a =
Nil
| Knoten { key :: a, inhalt :: b, lT, rT :: (Baum a)}
height :: Baum -> Int
height Nil =0
height x = max (height (lT x)) (height (rT x)) + 1
Nur ein balanzierter Baum hat die geringste Höhe
Die größte Höhe entsteht beim Grenzfall lineare Liste!
Bei vielen Anwendungen ist es wichtig, dass die Höhe des Baumes möglichst gering bleibt, dieser also bestmöglich balanciert ist.
Suche eines Elements kostet beim balanzierten Binärbaum Θ(log n)!
search :: Baum -> a -> Maybe b
search Nil _ = Nothing
search x k
| (key x) == k = x
| (key x) < k = (lT x)
| (key x) > k = (rT x)
data Baum a =
Nil
| Knoten { anzahl :: Int, key :: a, inhalt :: b,
lT, rT :: (Baum a)}
merge :: Baum a -> Baum a -> Baum a
merge Nil x = x
merge x Nil = x
merge x y |
size x >= size y = x {
elems = size x + size y,
-- das kleine Kind rekursiv mergen
lT=(merge s y),
-- das größere Kind nicht anfassen
rT = b}
| otherwise = merge y x where
(s,b)=
if (size (lT x) <= size (rT x)) then (lT x, rT x)
else (rT x, lT x)
Performanz von Algorithmen kann auf zwei Arten bestimmt werden:
Was genau als primitive Operation festgelegt wird, ist im Allgemeinen nicht wichtig.
Stattdessen geht es um die Abschätzung der Laufzeit in Abängigkeit von der Größe der Eingabedaten N → Problemgröße.
Die Laufzeitanalyse ist wichtig, um möglichst effiziente Lösungen zu finden. Das ist trotz der immer schneller werdenden Computer sehr wichtig. Vergleichen
Eine Funktion f ist eine asymptotische obere Schranke einer Funktion g, wenn es eine Stelle n0 und eine Konstante c > 0 gibt, so dass für alle natürlichen Zahlen n>n0 stets g(n) ≤ c · f(n) gilt.
Mit Quantorennotation kann man schreiben: ∃n0, ∃c, ∀n>n0 : g(n) ≤ c · f(n)
Das bedeutet, dass ab dem Wert n0 für alle folgenden n gilt: g(n) ist immer kleiner/ gleich c · f(n).
Es gibt für ein g(n) eine sehr große Menge von Funktionen f(n), die diese Bedingung erfüllen.
Im umgekehrten Fall kann man eine asymptotische unteren Schranke definieren. Man schreibt g ∈ Ω(f), wenn es eine Stelle n0 und eine Konstante c > 0 gibt, so dass gilt:
g ∈ Ω(f) ⇔ ∃c, ∃n0, ∀n ≥ n0 : g(n) ≥ c · f(n)
Die Definition ist ganz ähnlich, lediglich die Konstante c kann nun nicht mehr frei gewählt werden. Die Ungleichung muss für alle c gelten:
g ∈ o(f) ⇔ ∀c, ∃n0, ∀n>n0>0: g(n) ≤ c · f(n)
Es gibt auch eine Notation, um auszudrücken, dass zwei Funktionen asymptotisch gleich schnellwachsen. Das ist genau dann der Fall, wenn sie sowohl obere als auch untere Schranke voneinander sind:
g ∈ O(f) ∧ g ∈ Ω(f) ⇔ g ∈ Θ(f)
Multiplikation mit Konstanten: Für alle k >0 gilt: k · f(n) ∈ Θ(f(n))
Summen und Produkte. Es gilt:
f1(n) ∈ O(g1(n)) ∧ f2(n) ∈ O(g2(n)) ⇒ f1(n) + f2(n) ∈ O(g1(n)+g2(n))
Potenzen. Größere Potenzen sind starke obere Schranken von kleineren Potenzen. Es gilt:
a < b ⇒ na ∈ o(nb)
Polynome. Es gilt:
(n+a)b ∈ Θ(nb)
Logarithmen. Alle Logarithmen (mit einer Basis > 1) unterscheiden sich nur um eine Konstante, daher gilt:
loga n ∈ Θ(logb n)
Logarithmen gegen Potenzen: Potenzen von Logarithmen wachsen langsamer als normale Potenzen. Es gilt:
(log n)a ∈ o(nb)
Bei der Analyse von Algorithmen wird zwischen verschiedenen Fällen unterschieden. So dauert z.B. die Suche in Listen unterschiedlich lange in Abhängikeit von der Position.
Weiterhin kann Optimeirung wie Lazy Evaluation oder Memoization das tatsächliche Laufzeitverhalten (Klasse) nach unten beeinflussen
head
(f n), wird sie eventuell mit Θ(1) optimiert abgearbeitet, da nur das erste Element benötigt wird und die restliche Liste gar nicht erst produziert wird (Lazy Evaluation).
Man unterscheidet drei Fälle:
Um eine Komplexitätsklasse zu bezeichnen, gibt man immer die einfachste Funktion an, die geeignet ist, die jeweilige Komplexitätsklasse zu repräsentieren. Tatsächlich handelt es sich bei O(2n2 + 7n – 10) und O(n2) um dieselben Mengen, man wählt aber O(n2) als Bezeichnung.
Analyse von Funktionen wird unter der Annahme einer strikten Auswertung erfolgen (keine Lazy Evaluation)
fakul 0 = 1
fakul n = n * fakul (n-1)
Der Rekursionsanker (Terminierungsausdruck Zeile 1) benötigt nur konstante Zeit, also Θ(1).
Annahme: Multiplikationen und Subtraktionen sind primitive Operationen und benötigen konstante Zeit, also Θ(1)
Die Laufzeit T(n) der Fakultätsfunktion als Rekursionsgleichung aufgeschreiben:
suche :: Eq a => a -> [a]-> Maybe a
suche _ [] = Nothing
suche a (x:xs)
| a == x = Just a
| otherwise = suche a xs
Ist es gar nicht vorhanden oder das letzte Element in der Liste, müssen wir uns alle anderen Elemente einmal ansehen. Im worst-case ist das recht offensichtlich eine lineare Laufzeit Θ(n).
D.h. es werden Θ(k) Operationen benötigt wenn sich das Element an Position k befindet
reverse :: [a]-> [a]
reverse []=[]
reverse (x:xs) = reverse xs ++ [x]
Also quadratische Laufzeitklasse!
Alternative durch Faltung benötigt nur Θ(n · f)+Θ(s)=Θ(n)!!!
foldl :: (a -> b -> a)-> a -> [b]-> a
foldl _ s [] = s
foldl f s (x:xs)= foldl f (f s x) xs
Lazy Evaluation (verzögerte Auswertung) ist die bedarfsabhängige Auswertung von Ausdrücken und Funktionsapplikationen
Die Lazy Evaluation wertet nur Ausdrücke aus, die tatsächlich benötigt werden. So kann Rechenzeit eingespart werden, und sogar mit problematischen Werten gerechnet wer- den.
Ein Nachteil der verzögerten Auswertung ist, dass man nicht immer wissen kann, wann ein Ausdruck ausgewertet ist. Falls die Auswertung Nebenwirkungen hat, ist nicht absehbar, wann diese eintreten.
Nur wenige Laufzeitsysteme unterstützen eingebaute Bedarfsauswertung (OCaML z.B. nur explizit bei Mengen und Listen über Module) → Tiefe Codeanalyse u.U. erforderlich! → Lambda Calculus bietet Grundlage dafür
Die Lazy Evaluation wird in JavaScript anders als in manchen funktionalen Sprachen nicht von der Sprache selbst unterstützt. Es gibt allerdings Bibliotheken, die die Lazy Evaluation in JavaScript ermöglichen (wu.js).
Bei ihr wird von außen nach innen ausgewertet. Hier wird der Funktionsrumpf daher vor den Argumenten ausgewertet.
Bei ihr wird zuerst der Funktionsrumpf ausgewertet und dann die Argumente. Bei call-by-name werden dadurch bei einer Funktionsanwendung die noch nicht ausgewerteten Argumente im Funktions- rumpf ersetzt und müssen möglicherweise mehrfach ausgewertet werden. Im Gegensatz zur normal order wird bei call-by-name allerdings ein Funktionsrumpf, der nicht ange- wendet wird, nicht ausgewertet.
Im Gegensatz zu call-by-name wird ein Argument nur einmal ausgewertet und dieser ausgewertete Ausdruck wird für folgende Verwendungen zwischengespeichert.
Haskell Beispiele
f n = [1..n]
g n = head (f n)
h order l1 l2 = if order == 'left' then reverse l1 else l2
let l3 = h "right" [1..100] [1..200]
JavaScript Beispiele
if (f(x) || g(y) || h(z)) I;
⇔
if (f(x)) I else if (g(y)) I else if (h(z)) I;
Memoization ist keine Programmiermethode sondern ebenfalss wie Lazy Evulation eine Laufzeitfunktion um die Programmausführung zu beschleunugen.
Annahme: Eine Funktion mit dem Parametertupel (p1, p2, .. , pm) liefert immer das gleiche Berechnunsgergebnis für gleiche Eingabetupel (v1,v2,..,vm) → (y1,y2,..,yn).
Dann muss für jedes Eingabetupel (v1,v2,..,vm) ein Ergebniswert (y1,y2,..,yn) nur einmalig bnerechnet werden.
Dieser Wert wird gespeichert (an die Funktion gebunden).
Alle weiteren Funktionsapplikationen mit den gleichen Eingabewert benötigen nur noch das Lesen des Wertspeichers und keine Neuberechnung!!!!
function alwaysCompute (x) {
if (x<2) return 1;
else return alwaysComoute(x-2)+alwaysCompute(x-1);
}
var memo=[];
function maybeCompute (x) {
if (memo[x] != undefined) return memo[x];
if (x<2) return 1;
else return memo[x]=maybeComoute(x-2)+maybeCompute(x-1);
}
Im Wesentlichen ist jede Typklasse eine Kategorie.
In der Mathematik sind Funktoren F generell Abbildungen von Kategorien in Kategorien.
*
Operator der die Funktionen der einen Kategorie (d.h. z.B. Identität) auf Funktionen der anderern Kategorie abildet → map
Operator!!!
In der Mathematik wird ein Funktor üblicherweise in Form von zwei Abbildungen dargestellt, einer Objektabbildung und einer Pfeilabbildung.
Der Zusammenhang wird im folgenden Diagramm dargestellt (wobei der Pfeiloperator nicht als f ∗
sondern als F f geschrieben wird)
class Functor F where
fmap :: (α → β) → (F α → F β)
Die Applikation wird dann als entsprechende Instanz definiert, z. B.
instance Functor Seq where
fmap = ∗
Kategorien und Funktoren sind – im Kontext funktionaler Sprachen - eigentlich sehr einfache Konzepte (wenn man von den etwas merkwürdigen Namen absieht).
Demgegenüber sind die Monaden sperriger im Formalismus und Notation.
In der Mathematik sind Monaden spezielle Funktoren, für die gewisse natürliche Transformationen existieren.
Mathematisches Konzept aus der Kategorientheorie und werden in Haskell unter anderem auch für die Ein- und Ausgabe verwendet.
Diese Transformationen sind im Wesentlichen die Funktionen
Definition 35.
Generell lassen sich Monaden einfach charakterisieren: Eine Monade ist ein polymorpher Typ M α zusammen mit vier speziellen Funktionen ∗ , lift , flatten und &
Bei der Lösung komplexer Probleme, kann schnell der Punkt erreicht werden, an dem sich ein geschriebenes Programm anders verhält als es erwartet wird. In solchen Situationen wäre es hilfreich, wenn neben den eigentlichen Berechnungen auch noch eine Mitschrift des Programmablaufs erstellt werden könnte.
Angenommen es gibt zwei Funktionen von denen eine Mitschrift bei der Ausführung erforderlich ist:
f :: a -> b
g :: b -> c
f :: a -> (b , string)
g :: b -> (c , string)
h x = let
(fErg , fMit) = f x
(gErg , gMit) = g fErg in (gErg , gMit ++ fMit)
Alternativ kann man eine Funktion verbinde definieren, die den Eingabetypen anpasst und sich um die nötige Verknüpfung der Mitschriften kümmert.
Da die Erstellung der Mitschriften ein sequentieller Ablauf ist, wird das Argument vom Typ (a, string) an die erste Stelle gestellt
Jetzt kann man eine Verkettung von Funktionen ermöglichen, wo die Argumente von links nach rechts gelesen werden (Sequenz)
verbinde :: (a,String) -> (a -> (b, String)) -> (b, String)
verbinde (x,s) f = let (fErg , fMit) = f x in (fErg ,s++fMit)
h x = f x ‘verbinde‘ g
f x = (x, "f aufgerufen. ")
g x = (x, "g aufgerufen. ")
h x = f x ‘verbinde ‘ g ‘verbinde‘(\x -> (x, "fertig. "))
Ebenfalls problematisch ist der Einsatz von Zufallszahlen. Ihre Verwendung scheint geradezu ein Widerspruch zum Konzept der mathematischen Funktionen zu sein, die nur von ihren Eingaben abhängig sind.
Zufallszahlengeneratoren sind zustansbehaftet! Daher in Haskell: Wenn eine Zufallszahl über einer Generator erzeugt wird, dann führt in der funktionalen Programmierung zu einem “neuen” Generator.
random :: StdGen -> (a, StdGen)
f :: a -> StdGen -> (b, StdGen)
Der veränderte Zufallszahlengenerator soll mit dem Ergebnis zurückgebenwerden, damit die nächste Funktion diesen wieder verwenden kann, um neue Zufallszahlen zu generieren.
Jetzt wird es wieder mit der Komposition komplex …
Daher die Definition der “Monade” random:
verbinde :: (StdGen -> (a, StdGen)) ->
(a -> StdGen -> (b, StdGen)) -> StdGen -> (b, StdGen)
verbinde g f gen = let (a, gen ’) = g gen in f a gen’
einheit x gen = (x, gen)
lift f = einheit.f
Typklassen erklären die Klassendefinition und welchen Typ die geforderten Funktionen haben müssen
Die Semantik bleibt aber verborgen und ist nur dem Programmierer überlassen.
Damit ein Typ sich aber rechtmäßig Monade nennen darf, müssen die Funktionen die drei folgenden Gesetze erfüllen:
m >>= return = m
return x >>= f=f x
(m>>= f) >>= g=m >>= (\x.f x >>= g)
Ein wichtiges Charakteristikum funktionaler Programmiersprachen ist, dass sie von Zeit und Zustand unabhängig sind.
Aber Interaktion mit der Umwelt - insbesondere dem Benutzer - ist abhängig von der “Zeit” der realen Welt (und Reihenfolge).
Interaktive Systeme arbeiten sequenziell und können durch einen endlichen Zustandsautomaten abegbildet werden (Finit State Machine, FSM).
Automaten-Monaden oder Maschinen-Monaden können solche FSM umsetzen
Die Arbeit von Maschinen besteht in der Ausführung von Instruktionen.
Diese Instruktionen haben üblicherweise zwei Effekte:
Diese Situation wird in dem Typ Com (für Command) repräsentiert.
Definition 36.
Ein (monadisches)Kommando ist ein Paar von Funktionen. Die erste ist eine externe Beobachtungsfunktion observe, die aus dem inneren Zustand einen extern sichtbaren Wert extrahiert. Die zweite ist eine interne Transformation evolve*, die den internen Zustand in einen neuen Zustand überführt.