3.1.5 Signaturen und Strukturen
Begriffsklärung: (Modulsystem)
Ein Programmmodul fasst mehrere Deklarationen zusammen und stellt sie unter einem Namen zur Verfügung.
ML-Module heißen Strukturen und fassen Datentyp- und Funktionsdeklarationen zusammen.
Jede Struktur besitzt eine Signatur, die angibt, - welche Typen und
- welche Funktionen eine Struktur deklariert.
Bemerkung: (Signaturen/Strukturen)
Die Sprachkonstruktre „Signatur“ und „Struktur“
implementieren und verallgemeinern die in 3.1.1 eingeführten Konzepte von Datenstrukturen mit Signaturen:
- ML-Strukturen können auch Ausnahmen (engl.
Exception) und andere Strukturen enthalten
- ML-Signaturen können unabhängig von Strukturen deklariert werden.
Definition von Strukturen:
structure Environment = struct
type env = (string,int) list;
val emptyEnv = nil;
fun lookup (n:string, nil:env) = NONE | lookup (n, (n1,i)::ev) =
if n = n1 then SOME i
else lookup (n,ev)
fun insert ((n,i), ev) = ((n,i)::ev):env fun delete (n, nil:env) = nil:env
| delete (n, (n1,i)::env) =
if n = n1 then delete (n,env) else (n1,i)::(delete (n,env)) end;
ML inferiert aus der Strukturdefinition eine Signatur:
structure Environment : sig
type env = (string * int) list val emptyEnv : env
val lookup : string * env -> int option val insert : (string*int) * env -> env val delete : string * env -> env end
Definition und Anwendung von Signaturen:
signature SSTRINTENV = sig
type env = (string * int) list val emptyEnv : env
val lookup : string * env -> int option val insert : (string*int) * env -> env end;
Signaturen sind „Typen für Strukturen“. Sie können benutzt werden, um Strukturen einzuschränken:
- auf weniger Funktionen, Werte, … - auf eingeschränktere Typen
structure StrIntEnv : SSTRINTENV =
Environment;
Beispiel: (Anwendung von Signaturen)
Struktur StrIntEnv besitzt keine Funktion delete.
Anwendung von Strukturen in ML:
- Ist S eine Struktur in ML, dann kann man ihre Deklarationen d mit S.d verwenden.
- Das Kommando „open“ macht die Deklarationen eines Moduls direkt sichtbar:
open S;
(* d kann nun direkt verwendet werden *)
Beispiel: (Öffnen einer Struktur)
open StrIntEnv;
macht die Name der drei Funtionen von StrIntEnv direkt verwendbar.
3.1.6 Ein- und Ausgabe
Die ML Basis-Bibliothek stellt u.a. Strukturen zur Verfügung, um
- Werte auf der Standardausgabe auszugeben und - aus Dateien zu lesen und in Dateien zu schreiben.
Ausgabe auf dem Standardausgabestrom
Ein- und Ausgabe von Programmen wird in den meisten Sprachen/Betriebssytemen über Ströme realisiert:
- Eingabestrom (standard input) - Ausgabestrom (standard output)
- Fehlerausgabestrom (standard error)
Für die Ausgabe einer Zeichenreihe in den Standard- ausgabestrom bietet ML die Funktion:
print : string -> unit
Beispiele: (Anwendung print)
Ausgabe aller Elemente einer string-Liste:
3.1.6 Ein- und Ausgabe
fun printStringList [] = () | printStringList (s::xs) =
(print s; printStringList xs) ;
Bisher haben wir Ausdrücke zur Beschreibung von Werten verwendet (siehe Folie 3.23).
Ausdrücke, die die print-Funktion enthalten, liefern nicht nur einen Wert, sondern haben außerdem einen Seiteneffekt, nämlich die Veränderung der Standard- ausgabe.
Bedeutung von Ausdrücken und Ausdruckssequenzen
Begriffsklärung: (Seiteneffekt, Anweisung, Anweisungssequenz)
Ausdrücke haben einen Wert als Ergebnis und können Seiteneffekte verursachen:
- Verändern von Ein-/Ausgabeströmen, - Verändern von Dateien,
- Verändern des Zustands von Variablen.
Ein Ausdruck A, bei dem nur die Seiteneffekte eine Rolle spielen, nennen wir eine Anweisung.
Listen von Anweisungen A0, ... , An, die nacheinander ausgeführt werden sollen, nennen wir
Anweisungssequenzen. Notation in ML:
( A0 ; ... ; An )
1. Aufeinander folgende Ausgaben:
fun printSum (m,n) =
( print (“Die Summe von “);
print (Int.toString m);
print (“ und “);
print (Int.toString n);
print (“ ist “);
print (Int.toString (m+n));
print (“\n“) ) ;
Beispiele: (Ausdruckssequenz)
2. Ausgabe aller Quadratzahlen kleiner n :
fun printQZ (n:int):unit =
if n<0 then () else printQZemb (0,n) and printQZemb (i:int,n:int):unit =
let val isq = i*i in if isq >= n then ()
else ( print (Int.toString isq);
print "\n";
printQZemb (i+1,n) )
end ;
©
Programmieren mit Ein-/Ausgabeströmen:
type elem = char
type vector = string val stdIn = instream val stdOut = outstream val stdErr = outstream
val output = fn: outstream * string -> unit val flushOut = fn: outstream -> unit
val inputLine = fn: instream -> string option val inputN = fn: instream * int -> string
val input1 = fn: instream -> char option val inputAll = fn: instream -> string val endOfStream = fn: instream -> bool
Die Struktur TextIO stellt u.a. die folgenden Ströme und Funktionen zur Verfügung:
Erklärung siehe Vorlesung und unter
http://www.smlnj.org/doc/basis/
Beispiel: (Programmieren mit Strömen)
2. Sukzessives Einlesen und Ausgeben :
open TextIO;
(* cumulateInput setzt Eingabezeilen solange*) (* hintereinander, bis das Ende des Stroms *) (* erreicht ist; dann wird alles Eingelesene*) (* ausgegeben. *) fun cumulateInput () = successiveRead ""
and successiveRead str = if endOfStream stdIn
then output (stdOut,"\n"^str) else case inputLine stdIn of
SOME s => successiveRead (str^s) | NONE => output (stdErr,"Error")
;
1. Lesen und Ausgeben eines Zeichens:
open TextIO;
(* charReadPrint liest zeichenweise ein *) (* und gibt die gelesenen Zeichen aus *) fun charReadPrint () =
case input1 stdIn of
SOME c => ( print(str c); charReadPrint()) | NONE => print "Error: no character"
;
©
Lesen und Schreiben von Dateien:
val openIn = fn : string -> instream val closeIn = fn : instream -> unit val openOut = fn : string -> outstream val closeOut = fn : outstream -> unit
Dateien können nur bearbeitet werden, nachdem sie geöffnet wurden.
Nach der Bearbeitung sollte man die Datei schließen, um sie anderen Nutzern wieder zugänglich zu machen.
Für das Öffnen und Schließen enthält die Struktur TextIO die folgenden Funktionen:
Der string-Parameter von openIn und openOut liefert den Namen der Datei.
Beispiel: (Lesen & Schreiben von Dateien)
open TextIO;
(* readWriteFile liest Eingabezeilen aus *) (* der Datei inFile und schreibt sie in *) (* die Datei outFile *) fun readWriteFile (inFile,outFile) =
let val inStream = openIn inFile ;
val content = inputAll inStream ; val _ = closeIn inStream ; val outStream = openOut outFile in ( output (outStream,content);
closeOut outStream )
end;
3.1.7 Zusammenfassung von 3.1
- Begriffe und Sprachmittel wie Ausdruck, Bezeichner, Vereinbarung, Wert, Typ, Muster, ...
- rekursive Funktionen
- rekursive Datentypen (insbesondere Listen) - Strukturen und Signaturen
- Ströme und Dateibehandlung
3.2 Algorithmen auf Listen und Bäumen
Sortieren und Suchen sind elementare Aufgaben, die in den meisten Programmen anfallen.
Verfahren zum Suchen und Sortieren spielen eine zentrale Rolle in der Algorithmik.
Bemerkung:
Lernziele:
- Intuitiver Algorithmusbegriff
- Kenntnis wichtiger/klassischer Algorithmen und Algorithmenklassen
- Zusammenhang Algorithmus und Datenstruktur - Wege vom Problem zum Algorithmus
- Implementierungstechniken für Datenstrukturen und Algorithmen (vom Algorithmus zum Programm)
Wir führen in den Bereich Algorithmen und Datenstrukturen ausgehend vom Problem ein.
Andere Möglichkeit wäre gemäß der benutzten Datenstrukturen (Listen, Bäume, etc.).
3.2.1 Sortieren
Übersicht über 3.2:
• Sortieren
• Suchen
Sortieren ist eine Standardaufgabe, die Teil vieler speziellerer, umfassenderer Aufgaben ist.
Untersuchungen zeigen, dass „mehr als ein Viertel der kommerziell verbrauchten Rechenzeit auf
Sortiervorgänge entfällt“ [Ottmann, Widmayer:
Algorithmen und Datenstrukturen, Kap. 2].
Begriffsklärung: (Sortierproblem)
Gegeben ist eine Folge s , ..., s von sogenannten Datensätzen. Jeder Satz s hat einen Schlüssel k . Wir gehen davon aus, dass die Schlüssel ganzzahlig sind. Aufgabe des Sortierproblems ist es, eine
Permutation π zu finden, so dass die Umordnung der Sätze gemäß π folgende Reihenfolge auf den
Schlüsseln ergibt:
k ≤ k ≤ ... ≤ k
1 N
i i
π (1) π (2) π (N)
Bemerkung:
Offene Aspekte der Formulierung des Sortierproblem:
- Was heißt, eine Folge ist „gegeben“?
- Ist der Bereich der Schlüssel bekannt?
- Welche Operationen stehen zur Verfügung, um π zu bestimmen?
- Was genau heißt „Umordnung“?
Wir benutzen Datensätze folgenden Typs
type dataset = int * string
mit Vergleichsoperator:
infix 4 leq
fun op leq((kx:int,dx),(ky:int,dy))= (kx<=ky)
Entwickle eine Funktion
sort: dataset list dataset list
so dass sort(xl) für alle Eingaben xl aufsteigend sortiert ist. Insbesondere sind mehrere Einträge mit gleichem Schlüssel nicht ausgeschlossen.
Aufgabenstellung:
Sortieren durch Auswahl (selection sort)
Algorithmische Idee:
• Entferne einen minimalen Eintrag min aus der Liste.
• Sortiere die Liste, aus der min entfernt wurde.
• Füge min als ersten Element an die sortierte Liste an.
Wir betrachten:
• Sortieren durch Auswahl (engl. selection sort)
• Sortieren durch Einfügen (engl. insertion sort)
• Bubblesort
• Sortieren durch rekursives Teilen (quick sort)
• Sortieren durch Mischen (merge sort)
• Heapsort
(* select: Hilfsfunktion
liefert einen minimalen Eintrag der Liste bzw. x, falls x minimal. *) fun select x nil = x
| select x (y::yl) = if x leq y
then select x yl else select y yl;
(* delete: Hilfsfunktion
löscht ein Vorkommen von x aus der Liste.*) fun delete x nil = nil
| delete x (y::yl) = if (x = y)
then yl
else y :: delete x yl;
(* selectionsort: sortiert Liste
min: ein minimaler Eintrag in Liste xl.
rest: die Liste xl ohne min *) fun selectionsort nil = nil
| selectionsort (x::xl) =
let val min = select x xl;
val rest = delete min (x::xl);
in
min :: (selectionsort rest) end;
Sortieren durch Einfügen (insertion sort)
Algorithmische Idee:
• Sortiere zunächst den Rest der Liste.
• Füge dann den ersten Eintrag in die sortierte Liste ein.
(* insert: Hilfsfunktion
fügt Argument in sortierte Liste ein *) fun insert a nil = [a]
| insert a (y::yl) = if (a leq y)
then a :: (y :: yl)
else y :: (insert a yl)
(* insertionsort: sortiert Liste *) fun insertionsort nil = nil
| insertionsort (x::xl) =
insert x (insertionsort xl)
Bubblesort
(* bubble: Hilfsfunktion
liefert einen maximalen Eintrag der Liste und die Liste ohne den maximalen Eintrag *) fun bubble rl e nil = (rl,e)
| bubble rl e (y::yl) = if (e leq y)
then bubble (rl@[e]) y yl else bubble (rl@[y]) e yl
(* bubblesort: sortiert Liste *) fun bubblesort nil = nil
| bubblesort (x::xl) =
let val (rl,max) = bubble nil x xl in (bubblesort rl)@[max]
end
Algorithmische Idee:
• Schiebe einen Eintrag nach rechts heraus:
- Beginne dazu mit dem ersten Eintrag e.
- Wenn schieben von e auf einen gleichen oder größeren Eintrag y stößt, schiebe y weiter.
- Ergebnis: maximaler Eintrag max und Liste ohne max
• Sortiere die Liste ohne max und hänge max an.
Quicksort: Sortieren durch Teilen
fun split p nil = (nil,nil) | split p (x::xr) =
let val (below, above) = split p xr in if p leq x then (below,x::above)
else (x::below,above) end
fun qsort nil = nil | qsort (p::rest) =
let val (below,above) = split p rest in (qsort below) @ [p] @ (qsort above) end
Algorithmische Idee:
• Wähle einen beliebigen Datensatz mit Schlüssel k aus, das sogenannte Pivotelement.
• Teile die Liste in zwei Teile:
- 1. Teil enthält alle Datensätze mit Schlüsseln < k - 2. Teil enthält die Datensätze mit Schlüsseln ≥ k
• Wende quicksort rekursiv auf die Teillisten an.
• Hänge die resultierenden Listen und das Pivotelement zusammen.
Bemerkung:
Quicksort ist ein typischer Algorithmus gemäß der Divide-and-Conquer-Strategie:
- Zerlege das Problem in Teilprobleme.
- Wende den Algorithmus auf die Teilprobleme an.
- Füge die Ergebnisse zusammen.
Sortieren durch Mischen:
Algorithmische Idee:
• Hat die Liste mehr als ein Element, berechne die Länge der Liste div 2 (halfsize).
• Teile die Liste in zwei Teile der Länge halfsize (+1).
• Sortiere die Teile.
• Mische die Teile zusammen.
Bemerkung:
Mergesort ist auch effizient für das Sortieren von Datensätzen, die auf externen Speichermedien liegen und nicht vollständig in den Hauptspeicher geladen werden können.
open List;
fun merge nil nil = nil | merge nil yl = yl | merge xl nil = xl
| merge (x::xl) (y::yl) = if (x leq y)
then x :: (merge xl (y::yl)) else y :: (merge (x::xl) yl);
(* mergesort: sortiert gegebene Liste halfsize: Hälfte der Listenlänge.
front: Vordere Hälfte der Liste.
back : Hintere Hälfte der Liste. *) fun mergesort nil = nil
| mergesort (x::nil) = [x]
| mergesort xl = let
val halfsize = (length xl) div 2;
val front = take (xl,halfsize);
val back = drop (xl,halfsize);
in (* Merge 2 sortierte Listen! *)
merge (mergesort front) (mergesort back) end;
Heapsort
Heapsort verfeinert die Idee des Sortierens durch Auswahl:
Minimum bzw. Maximum wird nicht durch lineare Suche gefunden, sondern mit logarithmischem Aufwand durch Verwendung einer besonderen Datenstruktur, dem sogenannten Heap.
Algorithmische Idee:
• 1. Schritt: Erstelle den Heap zur Eingabeliste.
• 2. Schritt:
- Entferne Maximumelement aus Heap (konstanter Aufwand) und hänge es an die Ausgabeliste.
- Stelle Heap-Bedingung wieder her (logarithmischer Aufwand).
- Fahre mit Schritt 2 fort bis der Heap leer.
Bemerkung:
• Ziele des Vorgehens:
- Beispiel für komplexe, abstrakte Datenstruktur - Zusammenhang der algorithmischen Idee und der Datenstruktur.
• Der Begriff „Heap“ ist in der Informatik überladen.
Auch der Speicher für zur Laufzeit angelegte Variablen wird im Englischen „heap“ genannt.
Begriffsklärung: (zu Bäumen)
Wir betrachten im Folgenden Binärbäume, die - entweder leer sind oder
- aus einem markierten Knoten mit zwei Unterbäumen bestehen.
Ein Blatt ist ein Knoten mit zwei leeren Unterbäumen.
Ein Binärbaum heißt strikt, wenn jeder Knoten
ein Blatt ist oder zwei nicht-leere Unterbäume besitzt.
Ein Binärbaum der Höhe h heißt vollständig, wenn er strikt ist und alle Blätter die Tiefe h-1 haben.
Ein Binärbaum der Höhe h heißt fast vollständig, wenn
- jedes Blatt die Tiefe h-1 oder h-2 hat,
- jeder Knoten mit einer Tiefe kleiner h-2 zwei nicht- leere Unterbäume hat,
- für die Knoten K des Niveaus h-2 gilt:
1. Hat K 2 nicht-leere Unterbäume, dann auch alle linken Nachbarn von K.
2. Ist K ein Blatt, dann sind auch alle rechten Nachbarn von K Blätter.
3. Es gibt maximal ein K mit genau einem nicht-leeren Unterbaum und der ist links.
Beispiel: (Fast vollständiger indizierter und markierter Binärbaum)
88
57 2
151 42
31
0:
1: 2:
3: 4: 5:
Ein Baum der Größe n heißt indiziert, wenn man seine Knoten mittels der Indizes 0,...,n-1 ansprechen kann.
Bemerkung:
Im Folgenden gehen wir bei indizierten Bäumen immer davon aus, dass die Reihenfolge der Indices einem Breitendurchlauf folgt (siehe Beispiel unten).
signature FVBINTREE = sig
(* Typ fast vollständiger Binärbäume, ggf. leer *)
type fvbintree;
(* Erzeugt fast vollständigen Binärbaum, wobei die Listenelemente zu Markierungen werden *)
val create : dataset list -> fvbintree;
(* Anzahl der Knoten des Baums *)
val size : fvbintree -> int ;
(* Markierung am Knoten mit Index i *)
val get : fvbintree * int -> dataset ;
(* Vertausche Markierungen der Knoten mit Ind. i, ,j *)
val swap : fvbintree * int * int -> fvbintree;
(* Entferne letzten Knoten *)
val removeLast: fvbintree -> fvbintree ; (* Knoten mit Index i hat linkes Kind *)
val hasLeft : fvbintree * int -> bool ; (* Knoten mit Index i hat rechtes Kind *)
val hasRight : fvbintree * int -> bool
(* Index des linken Kinds von Knoten mit Index i *)
val left : fvbintree * int -> int ;
(* Index des rechten Kinds von Knoten mit Index i *)
val right : fvbintree * int -> int end;
Signatur für fast vollständige, markierte,
indizierte Binärbäume:
Vorgehen:
• Wir gehen im Folgenden davon aus, dass wir eine Datenstruktur zur Signatur FVBINTREE haben (steht zum Testen bereit) und
realisieren heapsort damit; d.h. ohne die Struktur/Implementierung zu kennen.
• Im Zusammenhang mit der objektorientierten Programmierung werden wir dann eine sehr effiziente Implementierung für die Signatur kennen lernen.
Bemerkung:
• Wir benutzen die Datenstruktur ohne die Implemen- tierung zu kennen; man sagt die Datenstruktur ist für den Nutzer abstrakt und spricht von
abstrakter Datenstruktur.
• Die Signatur beschreibt die Schnittstelle der abstrakten Datenstruktur. Die Benutzung von Schnittstellen abstrakter Datenstrukturen ist ein zentraler Bestandteil der SW-Entwicklung.
• Eine abstrakte Datenstruktur kann unterschiedliche Implementierungen haben. Implementierungen
können ausgetauscht werden, ohne dass der Nutzer seine Programme ändern muss!
Begriffsklärung: (Heap)
Ein markierter, fast vollständiger, indizierter Binärbaum mit n Knoten heißt ein Heap der Größe n, wenn die folgende Heap-Eigenschaft erfüllt ist:
Ist M ein Knoten und N ein Kind von M mit Markierungen k und k , dann gilt:
k ≥ k .N
M M
N
Beispiel: (Heap)
88
57 62
25 51
42 31
10
Heap der Größe 8:
Bei einem Heap sind die Knoten entsprechend einem Breitendurchlauf indiziert (siehe Beispiel unten).
0:
1: 2:
3: 4: 5: 6:
7:
Herstellen der Heap-Eigenschaft:
Bemerkung:
Die Heap-Eigenschaft garantiert, dass der Schlüssel eines Knotens M größer gleich aller Schlüssel in den Unterbäumen von M ist. Insbesondere steht in der Wurzel ein Element mit einem maximalen Schlüssel.
Sei ein markierter, fast vollständiger Binärbaum gegeben, der die Heap-Eigenschaft nur an der Wurzel verletzt.
Die Heap-Eigenschaft kann hergestellt werden, indem man den Wurzelknoten M rekursiv in dem Unterbaum mit dem größeren Schlüssel
versickern lässt:
- Gibt es kein Kind, ist nichts zu tun.
- Gibt es genau ein Kind N, dann ist dies links und kinderlos: Ist k < k , vertausche die
Markierungen.
- Gibt es zwei Kinder und ist N das Kind mit dem größeren Schlüssel: Ist k < k , vertausche die Markierungen und fahre rekursiv mit dem
Unterbaum zu N fort.
N M
N M
Beispiel: (Versickern lassen)
62
57 18
51 42
0:
1: 2:
3: 10 4: 5:
62
57 42
51 18
0:
1: 2:
3: 10 4: 5:
18
57 62
51 42
0:
1: 2:
3: 10 4: 5:
(* Annahme: Die Kinder von ix in b erfüllen die Heap-Eigenschaft *)
fun heapify b ix =
let val ds = get(b,ix) in
if hasLeft(b,ix)
andalso not (hasRight(b,ix)) then
let val lx = left(b,ix) in if get(b,lx) leq ds then b
else swap(b,ix,lx) end
else
if hasRight(b,ix) then
let val lx = left(b,ix) ; val rx = right(b,ix);
(* zu betrachtendes Kind *) val cx =
if get(b,lx) leq get(b,rx) then rx
else lx
in if get(b,cx) leq ds then b
else heapify (swap(b,ix,cx)) cx end
else b end
Funktion heapify formuliert auf Basis von FVBINREE:
Konkretisierung des Heapsort-Algorithmus:
1. Schritt:
- Erzeuge Binärbaum-Repräsentation aus Eingabefolge.
- Stelle Heap-Eigenschaft her, indem heapify ausgehend von den Blättern für jeden Knoten aufgerufen wird. Es reicht, nur Knoten mit Kindern zu berücksichtigen.
2. Schritt::
- Schreibe den Wurzel-Datensatz in die Ausgabe.
- Schreibe den Datensatz des letzten Elementes in den Wurzelknoten.
- Entferne das letzte Element.
- Stelle die Heap-Eigenschaft wieder her.
- Fahre mit Schritt 2 fort, solange die Größe > 0.
Lemma
:In einem fast vollständigen Binärbaum der Größe n sind die Knoten mit den Indizes (n div 2) bis n-1 Blätter.
Heapsort: Abstrakte Version
Wir betrachten zunächst Heapsort auf Basis des abstrakten Datentyps mit Signatur FVBINTREE:
Heapsort profitiert davon, dass sich fast vollständige, markierte, indizierte Binärbäume sehr effizient mit Feldern realisieren lassen:
(* Hilfsfunktion für Schritt 1 *)
fun heapifyAll b = hpfyEmb b ((size b) div 2) and hpfyEmb b 0 = if size b = 0 then b
else heapify b 0
| hpfyEmb b ix = hpfyEmb (heapify b ix) (ix-1) (* heapsort: sortiert gegebene Liste *)
fun heapsort xl =
rev (sortheap (heapifyAll (create xl))) and sortheap hp =
if size hp = 0 then []
else
let val maxds = get(hp,0) ; in if size hp = 1 then [maxds]
else
let val hp1 = swap(hp,0,(size hp)-1);
val hp2 = removeLast hp1 ; val hp3 = heapify hp2 0 in maxds::(sortheap hp3) end
end;
Es hätte klar werden müssen:
• Zu einem algorithmischen Problem (hier Sortieren) gibt es im Allg. viele Lösungen.
• Lösungen unterscheiden sich in:
- der Laufzeiteffizienz (messbar) - der Speichereffizienz (messbar)
- der „Komplexität“ der Verfahrensidee (im Allg.
nicht messbar).
In den folgenden Kapiteln werden wir demonstrieren,
• wie einige der obigen Algorithmen in anderen
Programmierparadigmen formuliert werden können;
• wie der Effizienz/Komplexitätsbegriff präzisiert werden kann.
3.2.2 Suchen
Abschließende Bemerkungen zu 3.2.1
Die Verwaltung von Datensätzen basiert auf drei grundlegenden Operationen:
- Einfügen eines Datensatzes in eine Menge von Datensätzen;
- Suchen eines Datensatzes mit Schlüssel k;
- Löschen eines Datensatzes mit Schlüssel k.
Weitere oft gewünschte Operationen sind: sortierte Ausgabe, Suchen aller Datensätze mit bestimmten Eigenschaften, bearbeiten von Daten ohne eindeutige Schlüssel, etc.
Entsprechend unseren Datensätzen betrachten wir die folgende Signatur / Schnittstelle:
Ziel ist es, Datenstrukturen zu finden, bei denen der Aufwand für obige Operationen gering ist. Wir betrach- ten hier die folgenden Dictionary-Realisierungen:
• lineare Datenstrukturen (Übung)
• (natürliche) binäre Suchbäume (Vorlesung)
signature DICTIONARY = sig type dict;
val Empty: dict;
val get : dict * int -> bool * string ; val put : dict * int * string -> dict ; val remove: dict * int -> dict;
end;
Bemerkung:
In der Literatur zur funktionalen Programmierung wir „get“ oft „lookup“ oder „search“, „put“ oft „insert“
und „remove“ oft „delete“ genannt.
Um den Zusammenhang zu OO-Schnittstellen augenfälliger zu machen, benutzen wir die dort üblichen Namen.
Binäre Suchbäume
Begriffsklärung: (binärer Suchbaum)
Ein markierter Binärbaum B ist ein natürlicher binärer Suchbaum (kurz: binärer Suchbaum), wenn die Suchbaum-Eigenschaft gilt, d.h.
wenn für jeden Knoten K in B gilt:
- Alle Schlüssel im linken Unterbaum von K sind echt kleiner als der Schlüssel von K.
- Alle Schlüssel im rechten Unterbaum von K sind echt größer als der Schlüssel von K.
• „Natürlich“ bezieht sich auf das Entstehen der Bäume in Abhängigkeit von der Reihenfolge der Einfüge-Operationen (Abgrenzung zu balancierten Bäumen).
• In einem binären Suchbaum gibt es zu einem Schlüssel maximal einen Knoten mit
entsprechender Markierung.
Bemerkung:
Datenstruktur:
Wir stellen Dictionaries als Binärbäume mit Markierungen vom Typ dataset dar:
Die Konstante Empty repräsentiert das leere Dictionary.
Binärbäume, die als Dictionary verwendet werden, müssen die Suchbaum-Eigenschaft erfüllen.
Alle Funktionen, die Dictionaries als Parameter bekommen, gehen davon aus, dass die
Suchbaum-Eigenschaft für die Parameter gilt.
Die Funktionen müssen garantieren, dass die Eigenschaft auch für Ergebnisse gilt.
Man sagt:
Die Suchbaum-Eigenschaft ist eine
Datenstrukturinvariante von Dictionaries.
Wir guarantieren die Datenstrukturinvariante u.a.
dadurch, dass wir Nutzern der Datenstruktur keinen Zugriff auf den Konstruktor Node geben.
datatype btree =
Node of dataset * btree * btree | Empty ;
structure Dictionary : DICTIONARY = struct datatype btree =
Node of dataset * btree * btree | Empty ;
type dict = btree ;
fun get ... (* siehe unten *) fun put ... (* siehe unten *) fun remove ... (* siehe unten *) end;
Die Struktur Dictionary stellt nur die in der Signatur DICTIONARY vereinbarten Typen, Werte und
Funktionen den Nutzern zur Verfügung.
Insbesondere sind Node und btree nur in Dictionary verfügbar und nicht für Nutzer von Dictionary.
Suchen eines Eintrags:
Wenn kein Eintrag zum Schlüssel existiert, liefere (false,““) ; sonst liefere (true,s), wobei s der String zum Schlüssel ist:
fun get (Empty, k) = (false,"") | get (Node ((km,s),l,r),k) =
if k < km then get (l,k)
else if k > km then get (r,k) else (* k = km *) (true,s)
Einfügen:
Algorithmisches Vorgehen:
- Neue Knoten werden immer als Blätter eingefügt.
- Die Position des Blattes wird durch den Schlüssel des neuen Eintrags festgelegt.
- Beim Aufbau eines Baumes ergibt der erste Eintrag die Wurzel.
- Ein Knoten wird in den linken Unterbaum der Wurzel eingefügt, wenn sein Schlüssel kleiner ist als der Schlüssel der Wurzel; in den rechten, wenn er größer ist. Dieses Verfahren wird rekursiv fortgesetzt, bis die Einfügeposition bestimmt ist.
45
57 22
65 52
42 17
49
Beispiel:
Einfügen von 33:
33
fun put (Empty,k,s) = Node((k,s),Empty,Empty) | put (Node ((km,sm),l,r),k,s) =
if k < km
then Node ((km,sm),put(l,k,s),r) else if k > km
then Node ((km,sm),l,put(r,k,s) else (* k = km *)
Node ((k,s),l,r)
Die algorithmische Idee lässt sich direkt umsetzen.
Beachte aber, dass das Dictionary nicht verändert wird, sondern ein neues erzeugt und abgeliefert wird:
• Die Reihenfolge des Einfügens bestimmt das Aussehen des binären Suchbaums:
Reihenfolgen:
2 ; 3 ; 1 1 ; 3 ; 2 1 ; 2 ; 3
Bemerkungen:
1 3
2 1
3 2
2
1
3
• Es gibt sehr viele Möglichkeiten, aus einer vorgegebenen Schlüsselmenge einen binären Suchbaum zu erzeugen.
• Bei sortierter Einfügereihenfolge entartet der binäre Suchbaum zur linearen Liste.
• Der Algorithmus zum Einfügen ist schnell, insbesondere weil keine Ausgleichs- oder Reorganisationsoperationen vorgenommen werden müssen.
Algorithmisches Vorgehen:
- Die Position eines zu löschenden Knotens K mit Schlüssel X wird nach dem gleichen Verfahren wie beim Suchen eines Knotens bestimmt.
- Dann sind drei Fälle zu unterscheiden:
Löschen:
Löschen ist die schwierigste Operation, da
- ggf. innere Knoten entfernt werden und dabei
- die Suchbaum-Eigenschaft erhalten werden muss.
1. Fall: K ist ein Blatt.
Lösche K:
X
Y
Z
Y
Z
2. Fall: K hat genau einen Unterbaum.
K wird im Eltern-Knoten durch sein Kind ersetzt und gelöscht:
X
Y
Z
Y
Z
Entsprechend, wenn X in rechtem Unterbaum.
Die anderen links-rechts-Varianten entsprechend.
3. Fall: K hat genau zwei Unterbäume.
Problem: Wo werden die beiden Unterbäume nach dem Löschen eingehängt?
Hier gibt es 2 symmetrische Lösungsvarianten:
- Ermittle den Knoten KR mit dem kleinsten Schlüssel im rechten Unterbaum, Schlüssel von KR sei XR.
- Speichere XR und die Daten von KR in K.
- Lösche KR gemäß Vorgehen zu Fall 1 bzw. 2.
(andere Variante: größten Schlüssel im rechten UB)
X
Y Z
XR
XR
Y Z
fun removemin (Node (d,Empty,r)) = (d,r) | removemin (Node (d,l,r)) =
let val (min,ll) = removemin l;
in (min, Node(d,ll,r)) end;
fun remove (Empty,k) = Empty | remove (Node((km,s),l,r),k)=
if k < km
then Node ((km,s), remove(l,k), r) else if k > km
then Node ((km,s), l, remove(r,k))
else (* k = km *) case (l,r) of
(Empty,rt) => rt | (lt,Empty) => lt | (lt,rt) =>
let val (min,rr) = removemin r in Node (min,lt,rr)
Umsetzung in ML:
• Die Fälle 1 und 2 lassen sich direkt behandeln.
• Für Fall 3 realisiere Hilfsfunktion removemin, die - nichtleeren binären Suchbaum b als Parameter nimmt;
- ein Paar (min,br) als Ergebnis liefert, wobei -- min der kleinste Datensatz in b ist und
-- br der Baum ist, der sich durch Löschen von min aus b ergibt.
Diskussion:
Der Aufwand für die Grundoperationen Einfügen, Suchen und Löschen eines Knotens ist proportional zur Tiefe des Knotens, bei dem die Operation aus- geführt wird.
Ist h die Höhe des Suchbaumes, ist der Aufwand der Grundoperationen im ungünstigsten Fall also O(h), wobei log (N+1)-1 ≤ h ≤ N-1 für Knotenanzahl N.
Folgerung:
Bei degenerierten natürlichen Suchbäumen kann
linearer Aufwand für alle Grundoperationen entstehen.
Im ungünstigsten Fall ist das Laufzeitverhalten also schlechter als bei der binären Suche auf Feldern.
Im Mittel verhalten sich Suchbäume aber wesentlich besser (O(log N)). Zusätzlich versucht man durch gezielte Reorganisation eine gute Balancierung zu erreichen (siehe Kapitel 5).
Bemerkung:
Mit modifizierenden Operationen kann man das Auf- und Abbauen der Suchbäume vermeiden und damit die Effizienz steigern.
3.3 Abstraktion mittels Polymorphie und Funktionen höherer Ordnung
Überblick:
• Grundbegriffe der Typisierung
• Polymorphie als Abstraktionsmittel
• Typsysteme und Typinferenz
• Einführung in Funktionen höherer Ordnung
• Wichtige Funktionen höherer Ordnung
• Abstraktionen über Datenstrukturen
3.3.1 Typisierung
Inhalte:
• Was ist ein Typ?
• Ziele der Typisierung
• Polymorphie und parametrische Typen
• Typsystem von ML und Typinferenz Fast alle modernen Spezifikations- und
Programmiersprachen besitzen ein Typsystem.
Ein Typ beschreibt Eigenschaften von Elementen der Modellierung oder Programmierung:
• Elementare Typen stehen häufig für die
Eigenschaft, zu einer bestimmten Wertemenge zu gehören ( bool, int, char, string, ... ).
• Zusammengesetzte Typen beschreiben die
genaue Struktur ihrer Elemente ( Tupel-, Listen- und Funktionstypen).
Was ist ein Typ?
• Parametrische Typen beschreiben bestimmte Eigenschaften und lassen andere offen; z.B.:
- Elemente vom Typ ‘a list sind homogene Listen; man kann also null, hd, tl anwenden.
Offen bleibt z.B. der Ergebnistyp von hd.
- Elemente vom Typ ‘a * ‘a list sind Paare, so dass die Funktionen für Paare angewendet werden können. Außerdem besitzen sie die Eigenschaft, dass die zweite Komponente immer eine Liste ist, deren Elemente vom selben Typ sind wie die erste Komponente:
fun f ( p:('a * 'a list ) ): 'a list = let val (fst,scd) = p
in fst::scd end;
Die Typisierung verfolgt drei Ziele:
• Automatische Erkennung von Programmier- fehlern (durch Übersetzer, Interpreter);
• Verbessern der Lesbarkeit von Programmen;
• Ermöglichen effizienterer Implementierungen.
Wir konzentrieren uns hier auf das erste Ziel.
Zentrale Idee:
- Für jeden Ausdruck und jede Funktion wird ein Typ festgelegt.
- Prüfe, ob die Typen der aktuellen Parameter- ausdrücke mit der Signatur der angewendeten Funktion oder Operation übereinstimmen.
Ziele der Typisierung
f: int int, dann sind:
f 7 , f (hd [ 1,2,3 ]) , f ( f 78 ) + 9 typkorrekt;
f true , [ f, 5.6 ] , f hd nicht typkorrekt.
Beispiele: (Typprüfung von Ausdrücken)
Bemerkung:
Typisierung war lange Zeit nicht unumstritten.
Hauptgegenargumente sind:
• zusätzlicher Schreibaufwand
• Einschränkung der Freiheit:
- inhomogene Listen
- Nutzen der Repräsentation von Daten im Rechner
Es gibt viele Programme, die nicht typkorrekt sind,
sich aber trotzdem zur Laufzeit gutartig verhalten; z.B:
Aufgabe 1:
Schreibe eine Funktion frp:
- Eingabe: Liste von Paaren entweder vom Typ bool * int oder bool * real
- Zulässige Listen: Wenn 1. Komponente true, dann 2. Komponente vom Typ int, sonst vom Typ real - Summiere die Listenelemente und liefere ein Paar mit beschriebener Eigenschaft.
Beispiel:
Realisierung in ML-Notation
(kein ML-Programm, da nicht typkorrekt!):
fun frp [ ] = (true, 0) | frp ((true,n)::xs ) =
case frp xs of
(true,k) => (true, k+n )
| (false,q) => (false, q + (real n)) | frp ((false,r)::xs ) =
case frp xs of
(true,k) => (false, (real k) + r) | (false,q) => (false, q + r)
Bemerkung:
Wegen fehlender Typisierung ist es schwierig, die Überladung des Pluszeichens aufzulösen.
Aufgabe 2:
Schreibe eine Funktion,
- die ein n-Tupel (n>0) nimmt und
- die erste Komponente des Tupels liefert.
Das Typsystem einer Sprache S beschreibt,
- welche Typen es in S gibt bzw. wie neue Typen deklariert werden;
- wie den Ausdrücken von S ein Typ zugeordnet wird;
- welche Regeln typisierte Ausdrücke erfüllen müssen.
Ein Typsystem heißt polymorph, wenn es Werte bzw.
Objekte gibt, die zu mehreren Typen gehören.
Begriffsklärung: (polymorphes Typsystem) Polymorphie und parametrische Typen
Im Allg. bedeutet Polymorphie Vielgestaltigkeit.
In der Programmierung bezieht sich Polymorphie auf die Typisierung bzw. das Typsystem.
Realisierung in ML-Notation
(kein ML-Programm, da nicht typkorrekt!) :
- fun f n = #1 n;
stdIn:14.1-14.15 Error: unresolved flex record (can't tell what fields there are besides #1)
• In polymorphen Typsystemen gibt es meist eine
Relation „ist_spezieller_als“ zwischen Typen T1, T2 : T1 heißt spezieller als T2, wenn die Eigenschaften, die T1 garantiert, die Eigenschaften von T2
implizieren (umgekehrt sagt man: T2 ist allgemeiner als T1).
Beispiel:
Der Typ int list ist spezieller als der parametrische Typ ‘a list . Insbesondere gilt:
Jeder Wert vom Typ int list kann überall dort benutzt werden, wo ein Wert vom Typ ‘a list erwartet wird.
Bemerkung:
• Man unterscheidet:
- Parametrische Polymorphie
- Subtyp-Polymorphie (vgl. Typisierung in Java)
• Oft spricht man im Zusammenhang mit der
Überladung von Funktions- oder Operatorsymbolen von Ad-hoc-Polymorphie.
Beispiel:
Dem +-Operator könnte man in ML den Typ
„int * int int oder real * real real “ geben.
Typsystem von ML und Typinferenz
Typen werden in ML durch Typausdrücke beschrieben:
- Typkonstanten sind die elementaren
Datentypen: bool, int, char, string, real, ...
- Typvariablen: ‘a, ‘meineTypvar, ‘‘a, ‘‘gTyp - Ausdrücke gebildet mit Typkonstruktoren:
Sind TA, TA1, TA2, TA3, ... Typausdrücke, dann sind die folgenden Ausdrücke auch Typausdrücke:
TA1 * TA2 Typ der Paare TA1 * TA2 * TA3 Typ der Triple ...
TA list Listentyp
TA1 -> TA2 Funktionstyp ...
Beispiele: (Typausdrücke)
- int, bool - ‘a, ‘b
- int * bool , int * ‘a , int * ‘b * ‘a - int list, ‘a list , (int * ‘b * ‘a ) list - int -> int , ‘a list -> (int * ‘b * ‘a ) list - (int -> int) -> (real -> real)
Präzedenzregeln für Typkonstruktoren:
list bindet am stärksten
* bindet am zweitstärksten
-> bindet am schwächsten und ist rechtsassoziativ
Beispiele: (Präzedenzen)
int * bool list steht für int * ( bool list )
int * real -> int list steht für (int * real) -> (int list )
‘a -> char -> bool steht für ‘a -> ( char -> bool )
Begriffserklärung: (Vergleich von Typen)
Seien TA und TB Typausdrücke und var(TA) die Menge der Typvariablen, die in TA vorkommen.
Eine Variablensubstitution ist eine Abbildung β von var(TA) auf die Menge der Typausdrücke.
Bezeichne TA den Typausdruck, den man aus TA erhält, wenn man alle Variablen v in TA konsistent durch β (v) ersetzt.
TB ist spezieller als TA, wenn es eine
Variablensubstitution β gibt, so dass TA = TB.
TA und TB bezeichnen den gleichen Typ, wenn TA spezieller als TB ist und TB spezieller als TA.
β β
Beispiele:
int list ist spezieller als ‘a list
‘a list ist spezieller als ‘b list und umgekehrt
‘a -> ‘a ist spezieller als ‘a -> ‘b
int list * ‘b und ‘c list * bool sind nicht vergleichbar, d.h. der erste Ausdruck ist nicht spezieller als der
zweite und der zweite nicht spezieller als der erste.
Bemerkung:
• Bezeichne TE die Menge der Typausdrücke in ML.
- Die „ist_spezieller_als“ Relation auf TE x TE ist reflexiv und transitiv, aber nicht antisymmetrisch.
- Identifiziert man alle Typausdrücke, die den gleichen Typ bezeichnen, erhält man die Menge T der Typen.
( T, ist _spezieller_als ) ist eine partielle Ordnung.
• Das Typsystem von ML ist feiner als hier dargestellt.
Insbesondere wird zwischen allen Typen und der Teilmenge von Typen unterschieden, auf denen die Gleichheit definiert ist, den sogenannten
Gleichheitstypen.
Beispiel:
- fun listcompare [] [] = true
| listcompare [] (x::xs) = false | listcompare (x::xs) [] = false | listcompare (x::xs) (y::ys) =
if x=y then listcompare xs ys else false;
val listcompare = fn : ''a list -> ''a list -> bool
• Typvariablen für Gleichheitstypen beginnen mit zwei Hochkommas. Damit verstehen wir nun auch den Typ der Gleichheitsfunktion in ML:
- val eq = fn (x,y) => x = y ; val eq = fn : ''a * ''a -> bool
Damit ist gesagt, was Typen in ML sind.
Wir nennen eine Ausdruck A typannotiert, wenn A und allen Teilausdrücken von A ein Typ zugeordnet ist.
Beispiel:
Die Typannotationen von abs ~2
ist ( (abs:int->int)(~2:int) ):int Die Typregeln legen fest, wann ein typannotierter Ausdruck typkorrekt ist. In ML muss gelten:
- die Typen der formalen Parameter müssen gleich den Typen der aktuellen Parameter sein.
- if-then-else, andalso, orelse müssen korrekt typisierte Parameter haben.
Typregeln in ML:
Typinferenz:
Typinferenz bedeutet das Ableiten der Typ- annotation für Ausdrücke aus den gegebenen Deklarationsinformationen.
Sie ist in gängigen Programmiersprachen oft recht einfach, in modernen Programmiersprachen teilweise recht komplex.
Beispiele:
1. Leere Liste:
- val a = [ ];
val a = [ ] : 'a list
2. Einsortieren in geordnete Liste von Zahlen:
- fun einsortieren (p1,p2) = case p2 of
x::xs => if p1 <= x then p1::p2
else x::(einsortieren (p1,xs)) | [ ] => [ p1 ] ;
val einsortieren = fn : int * int list -> int list
3. Einsortieren in geordnete Liste mit Vergleichs- funktion:
- fun einsortieren2 (v,p1,p2) = case p2 of
x::xs => if v (p1,x) then p1::p2
else x::(einsortieren2 (v,p1,xs)) | [ ] => [ p1 ] ;
val einsortieren2 = fn: ('a * 'a-> bool) * ‘a * 'a list -> 'a list
Bemerkung:
Bei der Typinferenz versucht man immer den allgemeinsten Typ herauszufinden.
Parametrische markierte Binärbaumtypen:
1. Alle Markierungen sind vom gleichen Typ:
datatype ‘a bbaum = Blatt of ‘a
| Zweig of ‘a * ‘a bbaum * ‘a bbaum
2. Blatt- und Zweigmarkierungen sind möglicherweise von unterschiedlichen Typen:
datatype (‘a,‘b) bbaum = Blatt of ‘a
| Zweig of ‘b * (‘a,‘b) bbaum * (‘a,‘b) bbaum
Funktionen höherer Ordnung sind Funktionen, die - Funktionen als Argumente nehmen und/oder
- Funktionen als Ergebnis haben.
Selbstverständlich sind auch Listen oder Tupel von Funktionen als Argumente oder Ergebnisse möglich.
Eine Funktion F, die Funktionen als Argumente
nimmt und als Ergebnis liefert, nennt man häufig auch ein Funktional.
Einführung
3.3.2 Funktionen höherer Ordnung
Funktionale, die aus der Schule bekannt sind, sind - Differenzial
- unbestimmtes Integral Überblick:
• Einführung in Funktionen höherer Ordnung
• Wichtige Funktionen höherer Ordnung
• Abstraktionen über Datenstrukturen
Zwei konzeptionelle Aspekte liegen der Anwendung von Funktionen höherer Ordnung in der Software- Entwicklung zugrunde:
1. Abstraktion und Wiederverwendung
2. Metaprogrammierung, d.h. das Entwickeln von Programmen, die Programme als Argumente und Ergebnisse haben.
Wir betrachten im Folgenden den ersten Aspekt.
Sprachliche Aspekte:
Alle wesentlichen Sprachmittel zum Arbeiten mit Funktionen höherer Ordnung sind bereits bekannt:
- Funktionsabstraktion - Funktionsdeklaration - Funktionsanwendung - Funktionstypen
Konzeptionelle Aspekte:
Aufgabe: Sortiere eine Liste xl von Zahlen Rekursionsidee:
- Sortiere zunächst den Rest der Liste.
- Das ergibt eine sortierte Liste xs.
- Sortiere das erste Element von xl in xs ein.
Umsetzung in ML:
- fun einsortieren e [ ] = [ e ] | einsortieren e (x::xr) =
if e <= x then e::x::xr
else x::(einsortieren e xr) ; val einsortieren =
fn : int -> int list -> int list - fun sort [] = []
| sort (x::xr) = einsortieren x (sort xr);
val it = fn : int list -> int list
Frage:
- Warum kann man mit sort nicht auch Werte der Typen char, string, real, etc. sortieren?
Antwort: Auflösung der Überladung von <=
Wiederverwendung zur Sortierung von real-Listen:
- fun einsortieren (e:real) [] = [e]
| einsortieren e (x::xr) = if e <= x then e::x::xr
else x::(einsortieren e xr) ; val einsortieren =
fn : real -> real list -> real list Unbefriedigend:
Verändern einer Funktion, die für den Anwender von sort ggf. nicht bekannt.
Abstraktion durch Parametrisierung:
Wiederverwendung von sort wird möglich, wenn wir die Vergleichsoperation als zusätzlichen Parameter einführen:
- fun einsortieren vop e [ ] = [ e ] | einsortieren vop e (x::xr) =
if vop (e,x) then e::x::xr
else x::(einsortieren vop e xr) ; - fun sort vop [ ] = [ ]
| sort vop (x::xr) =
einsortieren vop x (sort vop xr) ;
Anwendung:
- sort (op <=) [ 2,3, 968,~98,34,0 ] ; val it = [~98,0,2,3,34,968] : int list - sort (op >=) [ 2,3, 968,~98,34,0 ] ; val it = [968,34,3,2,0,~98] : int list
- sort ((op >=):real*real->bool) [1.0,1e~4];
val it = [1.0,0.0001] : real list
- val strcmp = ((op <=):string*string->bool);
- sort strcmp ["Abbay", "Abba", "Ara", "ab"];
val it = ["Abba","Abbay","Ara","ab"]
: string list
Polymorphe Funktionen können häufig auch Funktionen als Parameter nehmen.
Beispiel:
1. Funktion cons: abs::fac::nil
2. Identitätsfunktion: (fn x => x) (fn x => x)
(Ausdruck ist von der Typisierung her problematisch)
Bemerkung:
Wichtige Funktionen höherer Ordnung
Dieser Abschnitt betrachtet einige Beispiele für Funktionen höherer Ordnung und diskutiert das Arbeiten mit solchen Funktionen.
Funktionskomposition:
- fun fcomp f g = (fn x => f(g x));
val fcomp = fn : ('a->'b)->('c->'a)->'c->'b
In ML gibt es die vordefinierte Infix-Operation o für die Funktionskomposition.
Map:
Anwendung einer Funktion auf die Elemente einer Liste map f [ x1, ..., xn ] = [ (f x1), ... , (f xn) ] - fun map f [] = []
| map f (x::xs) = (f x):: map f xs;
Anwendungsbeispiel 1:
- map size [“Schwerter“,“zu“,“Pflugscharen“];
val it = [9,2,12] : int list
Currying und Schönfinkeln:
Anwendungsbeispiel 2:
Aufgabe:
- Eingabe: Liste von Listen von ganzen Zahlen
- Ausgabe: gleiche Listenstruktur, Zahlen verdoppelt
- fun double n = 2*n;
- map (map double) [ [1,2], [34829] ];
val it = [ [2,4], [69658] ] : int list list
Funktionen mit einem Argumenttupel kann man die Argumente auch sukzessive geben. Dabei entstehen Funktionen höherer Ordnung.
Beispiele:
- fun times m n = m * n;
val times = fn : int -> int -> int - val double = times 2;
val double = fn : int -> int - double 5;
val it = 10 : int
Zwei Varianten von map im Vergleich:
1. Die beiden Argumente als Paar:
- fun map (f , [ ]) = [ ]
| map (f,(x::xs)) = (f x) :: map (f,xs);
val map = fn: ('a->'b) * 'a list -> 'b list
2. Die Argumente nacheinander („gecurryt“):
- fun map f [] = []
| map f (x::xs) = (f x) :: map f xs;
val map = fn: ('a->'b) -> 'a list -> 'b list
Bemerkung:
Die gecurryte Fassung ist flexibler: Sie kann nicht nur auf ein vollständiges Argumententupel angewendet werden, sondern auch zur Definition neuer Funktionen mittels partieller Anwendung benutzt werden.
Beispiele:
val double = times 2 ;
val ilsort = sort (op <=) ; val doublelist = map double ;
Curryen von Funktionen:
Die Funktion curry liefert zu einer Funktion auf Paaren die zugehörige gecurryte Funktion:
- fun curry f x y = f (x,y);
val curry = fn: ('a*'b->'c) -> 'a -> 'b -> 'c
Prüfen von Prädikaten für Listenelemente:
Die folgenden Funktionen exists und all prüfen, ob - es Elemente in einer Liste gibt, die ein gegebenes Prädikat erfüllen bzw.
- alle Elemente einer Liste ein gegebenes Prädikat erfüllen.
- fun exists pred [ ] = false | exists pred (x::xs) =
pred x orelse exists pred xs;
val exists = fn: ('a->bool) -> 'a list -> bool - fun all pred [ ] = true
| all pred (x::xs) =
pred x andalso all pred xs;
val all = fn : ('a -> bool) -> 'a list -> bool
Anwendungsbeispiel:
Prüfen, ob ein Element in einer Liste enthalten ist:
- fun ismember x xs = exists (fn y=> x=y) xs;
val ismember = fn : ''a -> ''a list -> bool
Falten von Listen:
Eine sehr verbreitete Funktion ist das Falten einer Liste mittels einer binären Funktion und einem neutralen Element:
foldr ⊗ n [e1, e2,...,en] = e1 ⊗ (e2 ⊗ (...(en ⊗ n)...)) Deklaration von foldr:
- fun foldr f n [ ] = n
| foldr f n (x::xs) = f (x,foldr f n xs);
val foldr = fn: ('a*'b->'b)->'b->'a list->'b
Punktweise Veränderung von Funktionen:
Die „Veränderung“ einer Funktion an einem Punkt des Argumentbereichs:
- fun update f x v y =
if x = y then v else f y ; val update = fn:(''a->'b)->''a->'b-> ''a -> 'b
- fun implode1 cl = foldr op^ "" (map str cl);
val implode = fn : char list -> string
- val implode2 = foldr (fn(x,y)=>(str x)^y) ““;
val implode2 = fn : char list -> string
Auf Basis von foldr lassen sich viele Listenfunktionen direkt, d.h. ohne Rekursion definieren:
- val sum = foldr op+ 0 ;
val sum = fn : int list -> int - fun l1 @ l2 = foldr op:: l2 l1;
val @ = fn : 'a list * 'a list -> 'a list
Bäume mit variabler Kinderzahl lassen sich allgemeiner und kompakter behandeln (vgl. Folie 3.78):
datatype 'a vbaum = Kn of 'a*('a vbaum list) fun zaehlekn (Kn (_,xs)) =
foldr op+ 1 (map zaehlekn xs)
Bemerkung:
Die Programmentwicklung mittels Funktionen höherer Ordnung (funktionale Programmierung) ist ein erstes Beispiel für:
• das Zusammensetzen komplexerer Bausteine,
• programmiertechnische Variationsmöglichkeiten,
• die Problematik der Wiederverwendung:
- die Bausteine müssen bekannt sein,
- die Bausteine müssen ausreichend generisch sein.
Abstraktion über Datenstrukturen
Datenstrukturen lassen sich bzgl. einiger in ihnen verwendeten Typen und Funktionen parametrisieren.
Der Parameter ist dann kein Wert sondern eine Struktur.
Der „Typ“ des Parameters, d.h. dessen Eigenschaften, werden durch eine Signatur angegeben.
Eine derart abstrahierte/parametrisierte Datenstruktur nennt man in ML einen Funktor. Ein Funktor nimmt eine Datenstruktur als Parameter und liefert eine Datenstruktur als Ergebnis.
Exemplarisch betrachten wir hier Dictionaries, die bzgl.
- des Schlüsseltyps key
- des Typs der Daten data
- eines Dummy-Elements dummydata und - der Vergleichsoperation auf Schlüsseln parametrisiert sind.
signature ENTRY = sig eqtype key;
type data;
val dummydata : data;
val leq: key * key -> bool end;
Der Funktor nimmt also eine Datenstruktur mit Signatur ENTRY als Parameter und liefert eine Datenstruktur für Dictionaries:
functor MakeBST ( Et: ENTRY ):
sig
type dict;
val Empty: dict;
val get: dict * Et.key -> bool * Et.data ; val put: dict * Et.key * Et.data -> dict ; val remove: dict * Et.key -> dict
end
=
struct open Et;
type dataset = key * data ;
datatype btree = Node of (dataset*btree*btree) | Empty ;
type dict = btree ;
fun get (Empty, k) = (false,dummydata) | get (Node ((km,s),l,r),k) =
if k = km then (true,s) else if leq(k,km) then get (l,k) else get (r,k)
... (* Fortsetzung auf kommender Folie *)
(* vgl. Folie 120, 122 und 126 *) fun put (Empty,k,s) =
Node((k,s),Empty,Empty)
| put (Node ((km,sm),l,r),k,s) = if k = km
then Node ((k,s),l,r) else if leq(k,km)
then Node ((km,sm),put(l,k,s),r) else (* leq(km,k) *)
Node ((km,sm),l,put(r,k,s)) fun removemin (Node(d,Empty,r)) = (d,r) | removemin (Node(d,l,r)) =
let val (min,ll) = removemin l;
in (min, Node(d,ll,r)) end;
fun remove (Empty,k) = Empty
| remove (Node((km,s),l,r),k) = if leq(k,km)
then Node ((km,s), remove(l,k), r) else if leq(km,k)
then Node ((km,s), l, remove(r,k))
else (* k = km *) case (l,r) of (Empty,rt) => r | (lt,Empty) => l | (lt,rt) =>
let val (min,rr) = removemin r in Node (min,l,rr)
structure IntStringDataset = struct type key = int;
type data = string;
val dummydata = "";
val leq = op <
end;
structure Dictionary =
MakeBST(IntStringDataset);
Zur Anwendung übergeben wir dem Funktor eine Datenstruktur mit Signatur ENTRY:
Bemerkung:
• Bei der Abstraktion über Datenstrukturen übernimmt die Signatur die Rolle des Parameter- und Ergebnis- typs.
• Wie bei der Funktionsabstraktion geht es auch bei der Abstraktion über Datenstrukturen darum, den einmal geschriebenen Rumpf für mehrere unterschiedliche Eingaben wiederzuverwenden.
• Bzgl. der präzisen Abstraktion von Programmen hinsichtlich unterschiedlicher Aspekte sind
funktionale Programmiersprachen am weitesten.
Bemerkungen: (zur ML-Einführung)
• Ziel des Kapitels war es nicht, eine umfassende
ML-Einführung zu geben. ML dient hier vor allem als Hilfsmittel, wichtige Konzepte zu erläutern.
• Die meisten zentralen Konstrukte wurden behandelt.
• Es fehlt Genaueres zu:
- Fehlerbehandlung
- Modularisierungskonstrukte (Signaturen, Strukturen, abstrakte Typen, Funktoren)
- Konstrukte zur imperativen Programmierung
• Viele Aspekte der Programmierumgebung wurden nicht näher erläutert.
3.4 Semantik, Testen & Verifikation
Übersicht:
• Einführung in Semantik von Programmiersprachen
• Testen und Verifikation
3.4.1 Zur Semantik funktionaler Programme
Lernziele in diesem Unterabschnitt:
- Was bedeutet Auswertungssemantik?
- Wie sieht sie im Falle von ML aus?
- Welche Bedeutung haben Bezeichner- umgebungen dabei?
In erster Näherung definiert eine Funktionsdeklaration eine partielle Funktion. Gründe für Partialität:
1. Der Ausdruck, der die Funktion definiert, ist bereits partiell:
- fun division (dd,dr) = dd / dr ; - fun hd x::xs = x ;
2. Behandlung rekursiver Deklarationen:
a. Insgesamt unbestimmt:
- fun f x = f x ;
b. Teilweise unbestimmt (hier für negative Zahlen):
- fun fac (n:int):int = if n=0 then 1 else n*fac (n-1)
Ziel:
Ordne jeder syntaktisch korrekten Funktionsdeklaration eine partielle Funktion zu. Die Semantik beschreibt
diese Zuordnung.
Wir unterscheiden hier denotationelle und operationelle Semantik. Statt operationeller Semantik spricht man
häufig von Auswertungssemantik.