• Keine Ergebnisse gefunden

3.1.5 Signaturen und Strukturen

N/A
N/A
Protected

Academic year: 2022

Aktie "3.1.5 Signaturen und Strukturen"

Copied!
119
0
0

Wird geladen.... (Jetzt Volltext ansehen)

Volltext

(1)

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.

(2)

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

(3)

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.

(4)

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.

(5)

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) ;

(6)

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 )

(7)

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 ;

(8)

©

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/

(9)

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"

;

(10)

©

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.

(11)

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

(12)

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.).

(13)

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)

(14)

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:

(15)

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

(16)

(* 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;

(17)

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)

(18)

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.

(19)

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.

(20)

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.

(21)

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;

(22)

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.

(23)

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.

(24)

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).

(25)

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:

(26)

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!

(27)

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:

(28)

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

(29)

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:

(30)

(* 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:

(31)

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.

(32)

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;

(33)

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.

(34)

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.

(35)

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:

(36)

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 ;

(37)

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)

(38)

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

(39)

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

(40)

• 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.

(41)

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.

(42)

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

(43)

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.

(44)

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.

(45)

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.

(46)

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;

(47)

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)

(48)

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:

(49)

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.

(50)

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)

(51)

• 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.

(52)

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 ...

(53)

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 )

(54)

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.

(55)

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

(56)

• 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:

(57)

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

(58)

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.

(59)

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

(60)

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

(61)

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:

(62)

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 <=

(63)

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) ;

(64)

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:

(65)

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

(66)

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

(67)

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 ;

(68)

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

(69)

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

(70)

- 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)

(71)

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.

(72)

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;

(73)

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 *)

(74)

(* 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)

(75)

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.

(76)

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.

(77)

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?

(78)

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.

Referenzen

ÄHNLICHE DOKUMENTE

Und obwohl diese Begrifflichkeit inzwi- schen abgelöst wurde, kommt der Begriff Psychose immer noch bei einer Vielzahl unterschiedlicher Erkrankungen zur Anwendung, da er

Die E-Commerce Richtlinie definiere, dass es sich bei den koordinierten Bereichen um Anforderungen handle, die ein Diensteanbieter einhalten müsse, unter anderem

2 E-Commerce-Richtlinie, wie auch aus dem Schreiben der Europäischen Kommission an die Republik Österreich betreffend die Notifizierung des KoPl-G hervorgehe (vgl. Da

Aufgrund dieser Gegebenheiten und des Inkrafttretens des KoPl-G mit 01.01.2021 samt den sich daraus ergebenden weitreichenden Verpflichtungen für sowohl in- als

Laut DS-GVO besteht für den Kreis Steinfurt als Untere Immissionsschutzbehörde die Verpflichtung, den gesamten Umfang aller personenbezogenen Daten aufzulisten. Verantwortlicher

32918 Spezifizierung der Antikörper gegen HLA-Klasse I oder II Antigene mit- tels Single-Antigen-Festphasentest 32939 Nachweis von Antikörpern gegen. HLA-Klasse I oder II

Dennoch: Der Staat ist nicht über- schuldet, die Sozialausgaben sind nicht auf Pump finanziert, auch wer- den von der Frente weder Presse und Institutionen noch die freie Wirt-

Für das Vorhaben werden Ausnahmen/Befreiungen von folgenden Anforderungen der Thüringer Bauordnung oder von Satzungen aufgrund der Thüringer Bauordnung beantragt:. Umfang