Daten Abstraktion

Werbung
Kapitel 3
Daten Abstraktion
3.1
Datenstrukturen und Typen in Haskell
Bisher haben wir nur die eingebauten Basisdatentypen wie Zahlen und
Wahrheitswerte benutzt.
3.1.1
Basisdatentypen
• Ganze Zahlen (Int) umfassen oft nur in einem bestimmten Zahlbereich.
Meist wird der Zahlbereich durch die Binärzahl b begrenzt, die in ein
Halbwort (2 Byte), Wort (4 Byte), Doppelwort (8 Byte) passen, so dass
der Bereich −b, b darstellbar ist. Die Darstellung ist somit mathematisch
außerhalb dieses Bereichs nicht korrekt. Das bedeutet, man muss damit
rechnen, dass ein Überlauf stattfindet.
• Unbeschränkte ganze Zahlen (Integer). Kann man in Haskell verwenden,
gibt es aber nicht in allen Programmiersprachen. Hier ist die Division
problematisch, da die Ergebnisse natürlich nicht immer ganze Zahlen sind.
Verwendet wird die ganzzahlige Division. Die Division durch 0 ergibt einen
Laufzeitfehler.
• Rationale Zahlen. Werden in einigen Programmiersprachen unterstützt;
meist als Paar von Int, in Haskell auch exakt als Paar von Integer.
• Komplexe Zahlen sind in Python direkt verfügbar. Es ist nicht schwer,
diese in Haskell selbst zu definieren. In Haskell gibt es einen Module
Complex, der einige Funktionen implementiert, u.a. auch exp und die
Winkelfunktionen für Komplexe Eingaben.
• Gleitkommazahlen
(Gleitpunktzahlen)
(Float).
Das
sind
Approximationen für reelle Zahlen in der Form Mantisse, Exponent, z.B.
1.234e − 40 Die Bedeutung ist 1.234 ∗ 10−40 . Die interne Darstellung ist
getrennt in Mantisse und Exponent. Die arithmetischen Operationen sind
1
Praktische Informatik 1, WS 2001/02, Kapitel 3
2
alle definiert, aber man muß immer damit rechnen, dass Fehler durch die
Approximation auftreten (Rundungsfehler, Fehler durch Abschneiden),
dass ein Überlauf eintritt, wenn der Bereich des Exponenten überschritten
wurde, oder dass Division durch 0 einen Laufzeitfehler ergibt. Beachte,
dass es genaue IEEE-Standards gibt, wie Rundung, Abschneiden,
die Operationen usw. für normale Genauigkeit (4 Byte) oder für
doppelte Genauigkeit (8 Byte) funktionieren, damit die Ergebnisse von
Berechnungen (insbesondere finanzielle) auf allen Rechnern den gleichen
Wert ergeben.
Meist sind diese Operationen schon auf der Prozessorebene implementiert,
so dass man diese Operationen i.a. über die Programmiersprache aufruft.
• Zeichen, Character. Sind meist ASCII-Zeichen (1 Byte); andere Standards
zu 1 Byte werden verdrängt. In Haskell ist dies Typ Char.
Ein weiterer Standard, der 4 Bytes pro Zeichen verwendet und der
es erlaubt, viel mehr Zeichen zu kodieren, und der für alle Sprachen
Kodierungen bereithält, ist der Unicode-Standard (www.unicode.org). Es
gibt drei Varianten, die es erlauben, Kompressionen zu verwenden, so
dass häufige Zeichen in einem oder 2 Byte kodiert werden. In Unicode
gibt es u.a. zusammengesetzte Zeichen, z.B. wird ü bevorzugt mittels zwei
Kode-einheiten kodiert. In Python werden ASCII und Unicode Zeichen
unterstützt.
Funktionen in Haskell: ord liefert die Hex-Codierung eines Zeichens und
chr ist die Umkehrfunktion von ord.
Wir werden nun größere, zusammengesetzte Datenobjekte als Abstraktion
verwenden.
3.1.2
Einfache Typen
Beispiel 3.1.1 Rationale Zahlen Eine rationale Zahl kann man als
zusammengesetztes Objekt verstehen, das aus zwei Zahlen, dem Zähler und dem
x
Nenner besteht. Normalerweise schreibt man für die rationale Zahl mit Zähler
y
x und dem Nenner y.
Die einfachste Methode, dies in Haskell darzustellen, ist als Paar von Zahlen
(x, y). Beachte, daß in Haskell rationale Zahlen bereits vordefiniert sind, und die
entsprechenden Paare (x, y) als x%y dargestellt werden. Z.B.:
Prelude> (3%4)*(4%5)
3 % 5
Prelude> 1%2+2%3
7 % 6
Datenkonversionen macht man mit toRational bzw. truncate. Es gibt
rationale Zahlen mit kurzen und beliebig langen ganzen Zahlen.
3
Praktische Informatik 1, WS 2001/02, Kapitel 3
Paare von Objekten kann man verallgemeinern zu n-Tupel von Objekten:
(t1 , . . . , tn ) stellt ein n-Tupel der Objekte t1 , . . . , tn dar.
Beispiel 3.1.2
(1,2,3,True)
(1,(2,True),3)
("hallo",False)
(fak 100,\x-> x)
Um mit Datenobjekten operieren zu können, benötigt man:
Datenkonstruktoren: Hiermit wird ein Datenobjekt neu konstruiert, wobei
die Teile als Parameter übergeben werden.
Datenselektoren: Funktion, die gegeben ein Datenobjekt, bestimmte Teile
daraus extrahiert.
Zum Beispiel konstruiert man ein Paar (s, t) aus den beiden Ausdrücken s
und t. Da man die Ausdrücke wieder extrahieren können muß benötigt man die
Selektoren fst und snd, für die gelten muß: fst(s, t) = s und snd(s, t) = t.
Für ein n-Tupel benötigt man n Datenselektoren, auch wegen der
Typisierbarkeit, für jede Stelle des Tupels einen. Die Definition dieser Selektoren
wird in Haskell syntaktisch vereinfacht durch sogenannte Muster (pattern).
Einfache Beispiele einer solchen Definition sind:
fst (x,y) = x
snd (x,y) = y
selektiere_3_von_5 (x1,x2,x3,x4,x5) = x3
Diese Muster sind syntaktisch überall dort erlaubt, wo formale Parameter
(Variablen) neu eingeführt werden, d.h. in Funktionsdefinitionen, in LambdaAusdrücken und in let-Ausdrücken.
Nun können wir auch den Typ von Tupeln hinschreiben: n-Tupel haben
einen impliziten Konstruktor: (., . . . , .). Der Typ wird entsprechend notiert: Der
| {z }
n
Typ von (1, 1) ist z.B . (Integer, Integer), der Typ von selektiere_3_von_5
ist (α1 , . . . , α5 ) → α3 , und der Typ von (1, 2.0, selektiere_3_von_5) ist
(Integer, Float, (α1 , . . . , α5 ) → α3 ).
Bemerkung 3.1.3 In Haskell kann man Typen und Konstruktoren mittels der
data-Anweisung definieren. Zum Beispiel
data Punkt = Punktkonstruktor Double Double
data Strecke = Streckenkonstruktor Punkt Punkt
data Viertupel a b c d = Viertupelkons a b c d
4
Praktische Informatik 1, WS 2001/02, Kapitel 3
Definition 3.1.4 Ein Muster ist ein Ausdruck, der nach folgender Syntax
erzeugt ist:
hMusteri ::= hVariablei | (hMusteri)
| hKonstruktor(n) i hMusteri . . . hMusteri
|
{z
}
n
| (hMusteri, . . . , hMusteri)
Als Kontextbedingung hat man, dass in einem Muster keine Variable doppelt
vorkommen darf. Beachte, dass Zahlen und Character als Konstruktoren zählen.
Die erlaubten Transformationen bei Verwendung von Mustern kann man
grob umschreiben als: wenn das Datenobjekt wie das Muster aussieht
und die Konstruktoren übereinstimmen, dann werden die Variablen an die
entsprechenden Ausdrücke (Werte) gebunden. Diese Musteranpassung kann
man mit der Funktion anpassen Muster Ausdruck rekursiv beschreiben,
wobei das Ergebnis eine Menge von Bindungen ist:
• anpassen Kon Kon = ∅
(passt; aber keine Bindung notwendig.)
• anpassen x t = {x → t}:
(x wird an t gebunden.)
• anpassen (Kon s1 . . . sn ) (Kon t1 . . . tn ) =
anpassen s1 t1 ∪ . . . ∪ anpassen sn tn
• anpassen (Kon s1 . . . sn ) (Kon0 t1 . . . tn ) = Fail, wenn Kon 6= kon0
Dies bedeutet auch, dass die Musteranpassung erzwingt, dass die Datenobjekte,
die an das Muster angepaßt werden sollen, zunächst ausgewertet werden müssen.
Muster wirken wie ein let-Ausdruck mit Selektoren kombiniert.
Man kann Zwischenstrukturen in Mustern ebenfalls mit Variablen benennen:
Das Muster (x, y@(z1 , z2 )) wird bei der Musteranpassung auf (1, (2, 3)) folgende
Bindungen liefern: x = 1, y = (2, 3), z1 = 2, z2 = 3.
Typen
Jedes Datenobjekt in Haskell muß einen Typ haben. Ein Typ korrespondiert
somit zu einer Klasse von Datenobjekten.
Syntaktisch wird für jeden Typ ein Name eingeführt. Die eingebauten
arithmetischen Datenobjekte haben z.B. die Typen Int, Integer, Float,
Char, .... Komplexere Typen und zugehörige Datenobjekte werden durch ihre
obersten Konstruktoren charakterisiert.
Datenkonstruktoren können explizit definiert werden und haben den Status
eines Ausdrucks in Haskell. Es gibt eine eigene Anweisung, die Datentypen
definiert, wobei man auf die definierten Typnamen zurückgreift und auch
rekursive Definitionen machen darf. Es genügt, nur die Datenkonstrukoren zu
definieren, da die Selektoren durch Musteranpassung (Musterinstanziierung)
Praktische Informatik 1, WS 2001/02, Kapitel 3
5
definierbar sind. In der Typanweisung werden auch evtl. neue Typnamen
definiert. Diese Typnamen können mit einem anderen Typ parametrisiert sein
(z.B. [a]: Liste mit dem dem a).
Beispiel 3.1.5 Punkte und Strecken und ein Polygonzug werden in der
Zahlenebene dargestellt durch Koordinaten:
data
data
data
data
Punkt(a)
= Punkt a a
Strecke(a) = Strecke (Punkt a) (Punkt a)
Vektor(a) = Vektor a a
Polygon a = Polygon [Punkt a]
Hier ist Punkt ein neu definierter (parametrisierter) Typ, Punkt auf der
rechten Seite der neu definierte Konstruktor für Punkte der Ebene. Strecke
ist ein neuer Typ, der aus zwei Punkten besteht. Es ist unproblematisch, Typ
und Konstruktor gleich zu benennen, da keine Verwechslungsgefahr besteht. Der
Parameter a kann beliebig belegt werden: z.B. mit Float, Int, aber auch mit
[[(Int, Char)]].
Haskell sorgt mit der Typüberprüfung dafür, dass z.B. Funktionen, die
für Punkte definiert sind, nicht auf rationale Zahlen angewendet werden, die
ebenfalls aus zwei Zahlen bestehen.
Einige Funktionen, die man jetzt definieren kann, sind:
addiereVektoren::Num a => Vektor a -> Vektor a -> Vektor a
addiereVektoren (Vektor a1 a2) (Vektor b1 b2) =
Vektor (a1 + b1) (a2 + b2)
streckenLaenge (Strecke (Punkt a1 a2) (Punkt b1 b2)) =
sqrt (fromInteger ((quadrat (a1 - b1))
+ (quadrat (a2-b2))))
verschiebeStrecke s v =
let (Strecke (Punkt a1 a2) (Punkt b1 b2)) = s
(Vektor v1 v2) = v
in (Strecke (Punkt (a1+v1) (a2+v2)) (Punkt (b1+v1) (b2+v2)))
teststrecke = (Strecke (Punkt 0 0) (Punkt 3 4))
test_streckenlaenge = streckenLaenge
(verschiebeStrecke teststrecke
(Vektor 10 (-10)))
-------------------------------------------------------streckenLaenge teststrecke
<CR>
> 5.0
6
Praktische Informatik 1, WS 2001/02, Kapitel 3
test_streckenlaenge
<CR>
> 5.0
Wenn wir die Typen der Funktionen überprüfen, erhalten wir, wie erwartet:
addiereVektoren :: Num a => Vektor a -> Vektor a -> Vektor a
streckenlaenge :: Num a => Strecke a -> Float
test_streckenlaenge :: Float
verschiebeStrecke :: Num a => Strecke a -> Vektor a -> Strecke a
3.1.3
Summentypen und Fallunterscheidung
Bisher können wir noch keine Entsprechung des Booleschen Datentyps selbst
definieren. Wir können Klassen von Datenobjekten verschiedener Struktur in
einer Klasse vereinigen, indem wir einem Typ mehr als einen Konstruktor
zuordnen: Dies ist gleichzeitig die Deklaration (Erklärung) eines syntaktischen
Datentyps.
data Wahrheitswerte = Wahr | Falsch
Die zugehörigen Typen nennt man auch Summentypen.
Da Funktionen für Objekte unterschiedlicher Struktur eine Fallunterscheidung
machen müssen, gibt es eine einfache Möglichkeit dies in Haskell hinzuschreiben:
Man schreibt die Funktionsdefinition für jeden Fall der verschiedenen Muster
der Argumente hin.
und1
und1
und1
und1
Wahr Falsch
= Falsch
Wahr Wahr
= Wahr
Falsch Falsch = Falsch
Falsch Wahr
= Falsch
oder
und2
und2
Wahr x
= x
Falsch x = Falsch
oder
und3
und3
Wahr x
= x
Falsch _ = Falsch
Der Unterstrich ist eine anonyme Mustervariable (wildcard). Beachte, dass und2
und und3 gleiches Verhalten haben, während und1 anderes Verhalten hat bzgl.
Terminierung.
Definition 3.1.6 Es gibt ein weiteres syntaktisches Konstrukt, den caseAusdruck, der zur Fallunterscheidung verwendet werden kann. Die Syntax ist:
case hAusdrucki of{h Musteri -> hAusdrucki; . . . ;hMusteri -> hAusdrucki}
7
Praktische Informatik 1, WS 2001/02, Kapitel 3
Die Kontextbedingung ist, dass die Muster vom Typ her passen. Die
Bindungsbereiche der Variablen in den Mustern sind genau die zugehörigen
Ausdrücke hinter dem Pfeil (-> ).
Der Gültigkeitsbereich der Variablen in bezug auf das case-Konstrukt kann
an der Definition der freien Variablen abgelesen werden:
F V (case s of
(c1 x11 . . . x1n1 ) → t1 ); . . . ;
(ck xk1 . . . xknk → tk ))
=
F V (s) ∪ F V (t1 ) \ {x11 , . . . x1n1 } . . .
∪F V (tk ) \ {xk1 , . . . xknk }
GV (case s of
(c1 x11 . . . x1n1 → t1); . . . ;
(ck xk1 . . . xknk → tk ))
=
GV (s) ∪ GV (t1 ) ∪ {x11 , . . . x1n1 } ∪ . . .
∪F V (tk ) ∪ {xk1 , . . . xknk }
Beispiel 3.1.7 Folgende Definition ist äquivalent zum in Haskell definierten
logischen “und“ (und auch zu und2 und und3) : &&.
und4
x y
= case x of True -> y; False -> False
Beispiel 3.1.8 Folgende Definition ist äquivalent zum normalen if . then
. else. D.h. case-Ausdrücke sind eine Verallgemeinerung des if . then .
else.
mein_if
x y z
= case x of True -> y; False -> z
Die Reduktionsregel zum case ist
case
(case (c t1 . . . tn ) of . . . (c x1 . . . xn → s) . . .)
s[t1 /x1 , . . . , tn /xn ]
Fallunterscheidungen in Funktionsdefinitionen können als case-Ausdrücke
geschrieben werden, allerdings muss man vorher analysieren, über welches
Argument eine Fallunterscheidung notwendig ist. In Haskell wird bei
überlappenden Mustern in der Funktionsdefinition die Strategie “Muster von
oben nach unten“ verfolgt, die ebenfalls in geschachtelte case-Ausdrücke
übersetzt werden kann.
Praktische Informatik 1, WS 2001/02, Kapitel 3
3.2
8
Rekursive Datenobjekte: z.B. Listen
Listen sind eine Datenstruktur für Folgen von gleichartigen (gleichgetypten)
Objekten. Da wir beliebig lange Folgen verarbeiten und definieren wollen, nutzen
wir die Möglichkeit, rekursive Datentypen zu definieren. Der Typ der Objekte
in der Liste ist nicht festgelegt, sondern wird hier als (Typ-) Variable in der
Definition verwendet. D.h. aber, dass trotzdem in einer bestimmten Liste nur
Elemente eines Typs sein dürfen.
-- eine eigene Definition
data Liste a = Leereliste | ListenKons a (Liste a)
Dies ergibt Datenobjekte, die entweder leer sind: Leereliste, oder deren
Folgenelemente alle den gleichen Typ a haben, und die aufgebaut sind als
ListenKons b1 (ListenKons b2 . . . Leereliste)).
Listen sind in Haskell eingebaut und werden syntaktisch bevorzugt
behandelt. Aber: man könnte sie völlig funktionsgleich auch selbst definieren.
Im folgenden werden wir die Haskell-Notation verwenden. Die Definition würde
man so hinschreiben: (allerdings entspricht diese nicht der Syntax)
data [a] = []
|
a : [a]
Listenobjekte werden auch dargestellt als Folge in eckigen Klammern:
[1,2,3,4,5] ist die Liste der Zahlen von 1 bis 5; die leere Liste wird einfach
durch [] dargestellt. Listentypen werden mit eckigen Klammern notiert: [Int]
ist der Typ Listen von Int. Eine Liste von Zeichen hat den Typ [Char], der
auch mit String abgekürzt wird. Der Typ einer Liste von Listen von Zeichen
ist: [[Char]]. der Typ einer Liste von Paaren von langen ganzen Zahlen ist
[(Integer, Integer)],
Wir können jetzt rekursive Funktionen auf Listenobjekten definieren:
-Laenge einer Liste
length []
= 0
length (_:xs) = 1 + length xs
-map wendet eine Funktion f auf alle Elemente einer Liste an.
map f []
= []
map f (x:xs)
= f x : map f xs
Die erste Funktion berechnet die Anzahl der Elemente einer Liste, d.h. deren
Länge, die zweite Funktion wendet eine Funktion auf jedes Listenelement an und
erzeugt die Liste der Resultate.
Strings oder Zeichenketten in Haskell sind Listen von Zeichen und können auch
genauso verarbeitet werden.
Die folgende Funktion hängt zwei Listen zusammen:
append [] ys
append (x:xs) ys
= ys
= x : (append xs ys)
Praktische Informatik 1, WS 2001/02, Kapitel 3
9
In Haskell wird diese Funktion als ++ geschrieben und Infix benutzt.
Beispiel 3.2.1
Main> 10:[7,8,9]
[10,7,8,9]
Main> length [3,4,5]
3
Main> length [1..1000]
1000
Main> let a = [1..] in (length a,a)
(
ERROR - Garbage collection fails to reclaim sufficient space
Main> map quadrat [3,4,5]
[9,16,25]
Main> [0,1,2] ++ [3,4,5]
[0,1,2,3,4,5]
Beispiel: einfache geometrische Algorithmen
Einige weitere geometrische Algorithmen, die man mit den bisherigen Mitteln
strukturieren kann:
Berechnung der Fläche eines von einem Polygonzug umschlossenen Areals,
falls dieses konvex ist. Der Test auf Konvexität ist ebenfalls angegeben. Typische
für geometrische Algorithmen sind Sonderfälle:
Die Sonderfälle, die die Implementierung beachtet, sind: i. Der Polygonzug muss
echt konvex sein, d.h. es darf keine Null-Strecke dabei sein, und ii. es dürfen keine
drei benachbarten Punkte auf einer Geraden liegen.
Als Beispiel ist die Fläche eines regelmäßigen n-Ecks und ein Vergleich mit
der Fläche des Kreises programmiert.
--
polgonflaeche
data Polygon a = Polygon [Punkt a] deriving Show
polyflaeche poly =
if ist_konvex_polygon poly then
polyflaeche_r poly
else error "Polygon ist nicht konvex"
polyflaeche_r (Polygon (v1:v2:v3:rest)) =
dreiecksflaeche v1 v2 v3 + polyflaeche_r (Polygon (v1:v3:rest))
polyflaeche_r _ = fromInt 0
dreiecksflaeche v1 v2 v3 =
let a = abstand v1 v2
Praktische Informatik 1, WS 2001/02, Kapitel 3
10
b = abstand v2 v3
c = abstand v3 v1
s = 0.5*(a+b+c)
in sqrt (s*(s-a)*(s-b)*(s-c))
abstand (Punkt a1 a2 ) (Punkt b1 b2 ) =
let d1 = a1-b1
d2 = a2-b2
in sqrt (d1^2 + d2^2)
-- testet konvexitaet: aber nur gegen den Uhrzeigersinn.
ist_konvex_polygon (Polygon []) = True
ist_konvex_polygon (Polygon (p:polygon)) =
ist_konvex_polygonr (polygon ++ [p])
testkonvex =
ist_konvex_polygon (Polygon [Punkt (fromInt 2) (fromInt 2),
Punkt (fromInt (-2)) (fromInt 2),
Punkt (fromInt (1)) (fromInt 1),
Punkt (fromInt 2) (fromInt (-2)) ])
ist_konvex_polygonr (p1:rest@(p2:p3:rest2)) =
ist_konvex_drehung_positiv p1 p2 p3 && ist_konvex_polygonr
rest
ist_konvex_polygonr _ = True
ist_konvex_drehung_positiv (Punkt a1 a2) (Punkt b1 b2) (Punkt c1 c2) =
let ab1 = a1-b1
ab2 = a2-b2
bc1 = b1-c1
bc2 = b2-c2
in ab1*bc2-ab2*bc1 > 0
testpoly = polyflaeche (Polygon [Punkt (fromInt 1) (fromInt 1),
Punkt (fromInt (-1)) (fromInt 1),
Punkt (fromInt (-1)) (fromInt (-1)),
Punkt (fromInt 1) (fromInt (-1)) ])
vieleck n = Polygon [Punkt (cos (2.0*pi*i/n)) (sin (2.0*pi*i/n))
| i <- [1.0..n]]
vieleckflaeche n = polyflaeche (vieleck n)
vieleck_zu_kreis n = let kreis
= pi
vieleck = vieleckflaeche n
ratio
= vieleck / kreis
in (n,kreis, vieleck,ratio)
-- Elimination von gleichen Punkten
--- und kollinearen Tripeln
poly_normeq_r [] = []
poly_normeq_r [x] = [x]
11
Praktische Informatik 1, WS 2001/02, Kapitel 3
poly_normeq_r (x:rest@(y:_)) =
if x == y
then poly_normeq_r rest
else x: poly_normeq_r rest
poly_norm_koll (x:rest1@(y:z:tail)) =
if poly_drei_koll x y z
then poly_norm_koll (x:z:tail)
else x:poly_norm_koll rest1
poly_norm_koll rest = rest
--testet x,y,z auf kollinearitaet:
poly_drei_koll (Punkt x1 x2) (Punkt y1 y2) (Punkt z1 z2)
(z1-y1)*(y2-x2) == (y1-x1)*(z2-y2)
--
=
(z1-y1)/(z2-y2) == (y1-x1)/(y2-x2)
Funktionen auf Listen
Zwei allgemeine Funktionen (Methoden), die Listen verarbeiten sind foldl
und foldr und z.B. “die Summe aller Elemente einer Liste“ verallgemeinern.
Die Argumente sind: eine zweistellige Operation, ein Anfangselement
(Einheitselement) und die Liste.
foldl
:: (a -> b -> a) -> a -> [b] -> a
foldl f z []
= z
foldl f z (x:xs) = foldl f (f z x) xs
foldr
:: (a -> b -> b) -> b -> [a] -> b
foldr f z []
= z
foldr f z (x:xs) = f x (foldr f z xs)
Für einen Operator ⊗ und ein Anfangselement (Einheitselement) e ist der
Ausdruck foldl ⊗ e [a1 , . . . , an ] äquivalent zu ((. . . ((e ⊗ a1 ) ⊗ a2 ) . . . ) ⊗ an ).
Analog entspricht foldr ⊗ e [a1 , . . . , an ] der umgekehrten Klammerung: a1 ⊗
(a2 ⊗(. . . (an ⊗e))). Für einen assoziativen Operatoren ⊗ und wenn e Rechts- und
Linkseins zu ⊗ ist, ergibt sich derselbe Wert. Die Operatoren foldl und foldr
unterscheiden sich bzgl. des Ressourcenbedarfs in Abhängigkeit vom Operator
und dem Typ des Arguments.
Beispiele für die Verwendung, wobei die bessere Variante definiert wurde.
sum xs = foldl (+) 0 xs
produkt xs = foldl (*) 1 xs
concat xs = foldr (++) [] xs
--
(foldl’ (*) 1 xs)
12
Praktische Informatik 1, WS 2001/02, Kapitel 3
Weitere wichtige Funktionen auf Listen sind:
filter f []
filter f (x:xs)
= []
= if (f x) then x : filter f xs
else filter f xs
-- umdrehen einer Liste
reverse xs = foldl (\x y -> y:x) [] xs
--- verbindet eine Liste von Listen zu einer einzigen Liste:
concat xs = foldr append [] xs
-take
take
take
nimmt die ersten n Elemente der Liste xs.
0 _ = []
n [] = []
n (x:xs) = x : (take (n-1) xs)
randomInts a b
-liefert eine Liste von (Pseudo-) Zufallszahlen
-wenn import Random im File steht.
Weitere Listen-Funktionen sind:
-- Restliste nach n-tem Element
drop 0 xs
= xs
drop _ []
= []
drop n (_:xs) | n>0 = drop (n-1) xs
drop _ _
= error "Prelude.drop: negative argument"
-- Bildet Liste der
zip
::
zip (a:as) (b:bs)
zip _ _
Paare
[a] -> [b] -> [(a,b)]
= (a,b) : zip as bs
= []
--- aus Liste von Paaren ein Paar von Listen
unzip :: [(a,b)] -> ([a],[b])
unzip
= foldr (\(a,b) (as,bs) -> (a:as, b:bs)) ([], [])
Beispielauswertungen sind:
drop 10 [1..100]
----->
zip "abcde" [1..] ----->
[11,12,...
[(’a’,1),(’b’,2),(’c’,3),(’d’,4),(’e’,5)]
13
Praktische Informatik 1, WS 2001/02, Kapitel 3
unzip (zip "abcdefg" [1..]) ----> ("abcdefg",[1,2,3,4,5,6,7])
Es gibt noch weitere brauchbare allgemeine Listenfunktionen, siehe Prelude
der Implementierung von Haskell.
3.2.1
Listenausdrücke, list comprehensions
Dies ist eine Spezialität von Haskell und erleichtert die Handhabung von Listen.
Die Syntax ist analog zu Mengenausdrücken, nur dass eine Reihenfolge der
Listenelemente festgelegt ist. Syntax:
[hAusdrucki | hGeneratori | hFilteri{, {hGeneratori | hFilteri}}∗ ].
Vor dem senkrechten Strich | kommt ein Ausdruck, danach eine mit Komma
getrennte Folge von Generatoren der Form v <- liste oder von Prädikaten.
Wirkungsweise: die Generatoren liefern nach und nach die Elemente der Listen.
Wenn alle Prädikate zutreffen, wird ein Element entsprechend dem Ausdruck
links von | in die Liste aufgenommen. Hierbei können auch neue lokale Variablen
eingeführt werden, deren Geltungsbereich rechts von der Einführung liegt, aber
noch in der Klammer [. . .].
Beispiel 3.2.2
[x
| x <- xs]
[f x | x <- xs]
[x
| x <- xs, p x]
ergibt die Liste selbst
ist dasselbe wie
map f xs
ist dasselbe wie
filter p xs
[(x,y) | x <- xs, y <-ys]
kartesisches Produkt
der endlichen Listen xs und ys
[y | x <- xs, y <-x]
entspricht der Funktion concat
Spezielle Listenausdrücke sind:
• [n..m] erzeugt eine Liste [n, n+1,..., m]
• [n,m..e] erzeugt eine Liste [n, m, n+2*(m-n), ..., e’] mit e’ ≤ e.
• [n..] erzeugt die (potentiell unendliche) Liste [n,n+1,n+2,...].
Beispiel 3.2.3 [(x,y) | x <- [1..10], even x, y <- [2..6], x < y]
Resultat: [(2,3),(2,4),(2,5),(2,6),(4,5),(4,6)]
Die Erzeugungsreihenfolge tabellarisch aufgelistet ergibt:
x 1 2 2 2 2 2 3 4 4 4 4 4 5 6 ...
y
2 3 4 5 6
2 3 4 5 6
2 ...
? N N Y Y Y Y N N N N Y Y N N ...
Ein weiteres Beispiel, das zeigt, dass die Elementvariable auch in der
Listenerzeugung weiter rechts verwendet werden kann:
Praktische Informatik 1, WS 2001/02, Kapitel 3
14
[(x,y) | x <- [1..10], y <- [1..x]]
[(1,1),
(2,1),(2,2),
(3,1),(3,2),(3,3),
(4,1),(4,2),(4,3),(4,4),
(5,1),(5,2),(5,3),(5,4),(5,5),
(6,1),(6,2),(6,3),(6,4),(6,5),(6,6),
(7,1),(7,2),(7,3),(7,4),(7,5),(7,6),(7,7),
(8,1),(8,2),(8,3),(8,4),(8,5),(8,6),(8,7),(8,8),
(9,1),(9,2),(9,3),(9,4),(9,5),(9,6),(9,7),(9,8),(9,9),
(10,1),(10,2),(10,3),(10,4),(10,5),(10,6),(10,7),(10,8),(10,9),(10,10)]
3.3
Listenbehandlung in Python
Viele Funktionen auf Listen haben in Python zwar dieselbe Hauptfunktionalität.
Der Rückgabewert (return ...) ist oft analog zu dem der Haskellfunktionen,
aber es scheint einen Unterschied bei Laufzeitfehlern zu geben: Oft wird der
Laufzeitfehler selbst als Wert zurückgegeben.
Bei Seiteneffekten haben diese Funktionen ein ganz anderes Verhalten
als in Haskell. Haskell ist referentiell transparent, was bewirkt, dass die
Listenargumente nach einer Funktionsanwendung unverändert sind, während
eine Funktionsanwendung in Python oft eine Veränderung der Listenstruktur,
und damit der Werte von Argumentvariablen nach sich zieht. Einige Funktionen
sind nur dazu gemacht, genau dies zu bewirken. Z.B. reverse dreht die PythonListe um. Dies kann soweit gehen, dass Variablen, die in einem Aufruf nicht
erwähnt werden, trotzdem nach dem Aufruf andere Werte haben (sogenanntes
aliasing: der gleiche Speicherbereich hat verschiedene Namen).
Wir geben zur Listenverarbeitung in Python Funktionen an und Beispiele:
>>> range(20)
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
>>> range(5,10)
[5, 6, 7, 8, 9]
>>> len(range(10))
10
>>> len(range(1000000))
Traceback (most recent call last):
File "<input>", line 1, in ?
MemoryError
>>> a = [’a’, ’b’,’c’]
>>> a
[’a’, ’b’, ’c’]
>>> b = [3,4,5]
15
Praktische Informatik 1, WS 2001/02, Kapitel 3
>>> a.extend(b)
>>> a
[’a’, ’b’, ’c’, 3, 4, 5]
>>> b
[3, 4, 5]
>>> a.append(b)
>>> a
[’a’, ’b’, ’c’, 3, 4, 5, [3, 4, 5]]
>>> a.reverse()
>>> a
[[3, 4, 5], 5, 4, 3, ’c’, ’b’, ’a’]
>>> b
[3, 4, 5]
Ein Beispiel für aliasing:
>>>
>>>
>>>
[3,
>>>
[3,
>>>
>>>
[1,
>>>
[1,
a = [3,2,1]
b = a
a
2, 1]
b
2, 1]
a.sort()
a
2, 3]
b
2, 3]
Um sich ein Bild von verketteten Listen zu machen, ist folgendes
Diagramm hilfreich, das die Situation nach der Zuweisung a = [1,2,3]; b = a
repräsentiert. Die letzte Box symbolisiert einen Zeiger auf Nil.
a
b
1
2
3
Es ist zu beachten, dass bei a.extend(b) eine Kopie der Liste b an a
angehängt wird. Bei append wird nur der Verweis genommen:
>>>
>>>
>>>
>>>
[1,
a = [1,2,3]
b = [4,5,6]
a.append(b)
a
2, 3, [4, 5, 6]]
16
Praktische Informatik 1, WS 2001/02, Kapitel 3
>>> b.reverse()
>>> a
[1, 2, 3, [6, 5, 4]]
>>>
Bild
des
Speichers
nach
den
obigen
Befehlen:
a
2
1
3
b
6
5
4
Es gibt in Python noch die Funktionen map, filter, und reduce, die
vordefiniert sind. Sie haben analoge Wirkung wie die Funktionen map, filter,
und foldl in Haskell.
Folgende Funktion erzeugt eine Kopie einer Liste:
def id(x): return x
def listcopy(x):
return map(id, x)
def geradeq(n):
return n%2 == 0
>>> filter(geradeq,range(20))
[0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
Die Summe aller Elemente einer Liste kann man ebenfalls analog zu den
Haskell-Methoden berechnen:
def add(x,y): return x+y
>>> reduce(add,range(100))
4950
Praktische Informatik 1, WS 2001/02, Kapitel 3
17
def mal(x,y): return x*y
def fak(n): return reduce(mal,range(1,n+1))
Schleifen in Python
Schleifen kann man in Python programmieren mittels while oder for:
>>> a=10
>>> while a > 0:
...
print a
...
a = a-1
...
10
9
8
7
6
5
4
3
2
1
>>> for i in range(1,11):
...
print(i)
...
1
2
3
4
5
6
7
8
9
10
>>>
Stacks und Queues
Ein Stack, Keller, Stapel ist eine Datenstruktur. Man kann sie benutzen wie
einen Stapel, auf den man viele Dinge drauflegen kann, und auch wieder
runternehmen kann, aber man darf nicht mitten im Stapel etwas hinzufügen
oder wegnehmen. Man sagt auch “last-in first-out“ (lifo). Die Operationen nennt
man push um etwas draufzulegen, und pop um das oberste Element zu entfernen.
18
Praktische Informatik 1, WS 2001/02, Kapitel 3
In Haskell kann man das sehr gut mit Listen implementieren. In Python
ebenfalls, man kann eine Liste von beiden Seiten als Stack verwenden: von rechts:
# von links:
a.insert(0,b)
a.pop(0)
# von rechts:
a.append(b)
a.pop()
Es kann ein Fehler auftreten, wenn man vom leeren Stapel etwas
herunternehmen will.
Beispiel 3.3.1 Zwei Beispiele, die sich eines Stacks bedienen, sind:
Umgebung zur Auswertung von (Python-)Programmen.
Umdrehen einer Liste in Haskell:
reverse xs = foldl (\x y -> y:x) [] xs
Eine Schlange, Queue ist eine Datenstruktur, die sich wie eine Warteschlange
verhält: Vorne wird bedient (abgearbeitet), und hinten stellen sich Leute an
(first-in first out, fifo). Dies kann man mit Haskell-Listen implementieren,
allerdings ist dann entweder das Hinzufügen oder das Entfernen nicht effizient.
In Python kann man das effizient implementieren mit einer der folgenden
Kombination von Operationen, allerdings ist nicht offengelegt, wie es intern
gemacht wird1 :
a.insert(0,b) , a.pop() oder a.append(b) , a.pop(0).
3.3.1
Auswertungsregeln für Listenfunktionen in Python
Dieser Paragraph soll verdeutlichen, dass man die operationelle Semantik
der Listenverarbeitung in einer imperativen Programmiersprache wie Python
formal darstellen kann, unabhängig von einem linearen Modell des Speichers
mit Adressen. Natürlich wird dies dann letztlich doch auf einen Speicher
abgebildet, aber man kann danach konzeptuell trennen zwischen den
minimalen Erfordernissen der Auswertung und den zufälligen Eigenschaften der
Implementierung. Z.B. ist die Größe der Adressen oder die Lokalität eher eine
Eigenschaft der Implementierung auf einem Rechner mit linearem Speicher.
Der Heap (Haufen) dient dazu, Daten mit Struktur, die vom Programm
modelliert werden, darzustellen, und deren Veränderung korrekt zu beschreiben.
Dazu werden Heap-Variablen verwendet, die man sich wie Zeiger (Pointer) in
einer Implementierung vorstellen kann.
Definition 3.3.2 Ein
Komponenten:
Haufen
(Heap,
• Einer Menge von Wurzelvariablen.
1 Vermutlich
mit Feldern und Indices
Halde)
besteht
aus
folgenden
19
Praktische Informatik 1, WS 2001/02, Kapitel 3
• Einer Menge von Heap-Variablen, wobei die Wurzelvariablen ebenfalls
Heap-Variablen sind.
• Einer Menge von Bindungen, wobei eine Bindung sein kann:
– (x, v): eine Bindung eines Wertes (Zahl oder Character oder Nil) v
an die Heap-Variable x.
– (x, (x1 , x2 )): eine Bindung eines Paares aus Heap-Variablen (x1 , x2 )
an die Heap-Variable x.
Es darf pro Heap-Variable nur eine Bindung im Heap sein; Außerdem muß
jede Heap-Variable, die in einer Paarbindung vorkommt, im Heap gebunden
sein. In diesem Fall heißt der Heap gültig.
Die beiden Formen der Bindung kann man folgendermaßen darstellen:
x
x
v
x1
x2
Definition 3.3.3 Ein Stack S zu einem Heap H ist eine Folge von Paaren aus
Programm-Variablen und Heap-Variablen.
Die im Stack vorkommenden Heap-Variablen müssen Wurzelvariablen des
Heaps sein.
Diese Modellierung erlaubt keine Werte direkt auf dem Stack. In realen
Implementierung werden allerdings oft Werte direkt auf dem Stack abgelegt
und verarbeitet, da dies effizienter ist.
Beispiel 3.3.4 Die Darstellung der Liste [1] mit Stack und Heap ist
folgendermaßen:
Stack (zelle1,zelle2)
Zeiger auf Liste
Heap {(zelle2, (zelle3, zelle4))
(zelle3,1)
(zelle4,Nil)}
zelle2
zelle3
1
zelle4
Nil
20
Praktische Informatik 1, WS 2001/02, Kapitel 3
Beispiel 3.3.5 Darstellung einer zyklischen Liste, die man gedruckt nur so
darstellen kann: [[[[[...]]]]]]
AA
AB
Der Heap dazu sieht so aus: {(AA, (AA, AB)), (AB, N il)}
Definition 3.3.6 Die Erreichbarkeitsrelation →er des Heaps H kann man
folgendermaßen definieren:
Ein Heap-Variable y ist von der Heap-Variablen x aus erreichbar, Notation
x →er,H y, wenn x = y, oder wenn es eine endliche Folge von Variablen
x1 , . . . , xn gibt, so dass x = x1 und y = xn und für all i = 1, . . . n − 1 entweder
(xi , (xi+1 , x0i+1 )) ∈ H oder (xi , (x0i+1 , xi+1 )) ∈ H wobei x0i+1 irgendwelche HeapVariablen sein dürfen.
Eine Heap-variable x ist erreichbar (aktiv), wenn es eine Wurzelvariable x0
gibt mit x0 →er x, andernfalls heißt sie unerreichbar (redundant).
Bemerkung 3.3.7 Die Erreichbarkeitsrelation →er,H des Heaps H kann man
auch als die reflexiv-transitive Hülle der folgenden Relation definieren:
Wenn (x, (x1 , x2 )) ∈ H, dann gilt x → x1 und x → x2 .
Die Bindung von unerreichbaren Variablen kann man aus dem Heap
entfernen. In einer Implementierung nennt man das garbage collection.
Wurzelvariablen sind i.a. die Heap-Variablen, auf die vom Stack aus referenziert
wird; aber auch die Heap-Variablen, auf die das Programm referenziert, muss
man dazu nehmen.
Jede Variable des Heaps stellt einen Wert oder eine komplexere
Datenstruktur dar. z.B.: Im Heap H = {(x, (x1 , x2 )), (x1 , 1), (x2 , N il)}
repräsentiert die Heap-Variable x die Liste [1]; x2 die leere Liste.
x
x1
x2
Nil
1
Der Aufwand ist etwas höher als bei der ersten Variante der operationellen
Semantik von Python, die nur mit Zahlen umgehen konnte, da auch zyklische
Praktische Informatik 1, WS 2001/02, Kapitel 3
21
Strukturen beschreibbar sein sollen.
Regeln der (erweiterten) operationellen Semantik
Der Zustand besteht aus zwei Komponenten: Stack und Heap. Die übliche
Umgebung (der Stack) wird so verallgemeinert, dass jeder Programmvariablen
eine Heapvariable zugeordnet wird, die ihrerseits auf einen (evtl. komplexeren)
Wert im Heap verweist.
Man kann sich vorstellen, dass auf dem Stack Zeiger auf Werte im Heap
stehen, und im Heap eine Zeigerstruktur einen Wert darstellt.
Definition 3.3.8 Der Zustand ist ein Paar (S, H), wobei
• S die Variablenumgebung (der Stack) ist, der eine Folge von Bindungen
(x, u) ist, wobei x ein Programmvariable und u eine Heapvariable ist.
• H ist ein Heap (s.o.).
Jetzt können wir die Auswertung von Listenausdrücken und
Listenfunktionen in Python mittels dieser Heap-Modellierung beschreiben:
Wir verwenden die Klammer [[.]], um syntaktische Konstrukte einzuklammern,
damit diese von den Operationen deutlich getrennt werden, und verwenden die
Notation (Z, e) → (Z 0 , x) um eine Auswertung des Ausdrucks e im Zustand
Z zu bezeichnen, wobei x die Heapvariable ist, die den zurückgegebenen Wert
repräsentiert und Z 0 der neue Zustand ist.
Definition 3.3.9 Eine Auswertung des Listenausdrucks [a1 , . . . , an ] in einem
gültigen Heap H folgt der Regel (n ≥ 1):
(Z; [[a1 ]]) → (Z 0 ; x1 ) (Z 0 ; [[[a2 , . . . , an ]]]) → (Z 00 ; x2 ) Z 00 = (S 00 ; H 00 )
(Z; [[[a1 , . . . , an ]]]) → ((S 00 , H 00 ∪ {(x, (x1 , x2 ))}); x)
Am Ende der Listen verwenden wir die spezielle Konstante Nil:
((S, H); [[[]]]) → ((S, H ∪ {(x, Nil)}); x)
Die Auswertung des Listenausdrucks [a1 , . . . , an ] kann man so umschreiben:
Zuerst wird a1 ausgewertet, danach a2 usw, bis an . Der Zustand am Ende ist der
letzt Zustand nach dieser Auswertung. Im Heap repräsentiert die Heapvariable
x die ausgewertete Liste.
Das Aliasing passiert jetzt durch Zuweisung einer Listenvariablen:
Sei die Folge der Befehle gegeben:
a = [1,2]
b = a
Dann entsteht folgende Struktur im Stack und Heap: Nach a = [1,2]:
22
Praktische Informatik 1, WS 2001/02, Kapitel 3
Stack: . . . , (a, u1 ), . . .
Heap: {(u1 , (u2 , u3 )), (u2 , 1), (u3 , (u4 , u5 )), (u4 , 2), (u5 , N il), . . .}
Nach der weiteren Zuweisung b = a entsteht der Zustand
Stack: . . . , (a, u1 ), . . . , (b, u1 ), . . .
Heap: {(u1 , (u2 , u3 )), (u2 , 1), (u3 , (u4 , u5 )), (u4 , 2), (u5 , N il), . . .}
Wenn jetzt innerhalb der Liste zu a etwas verändert wird, dann ändert sich
automatisch etwas in der Liste, auf die b zeigt.
a
u1
b
u2
u4
u3
u5
Nil
2
1
Jetzt können wir auch die Auswertung von Funktionen wie len, append,
insert, . . . angeben. Die Angabe () als Wert soll den leeren Wert bedeuten:
D.h., es wird kein Wert erzeugt. Für diese Regel nehmen wir der Einfachheit
halber an, dass a, b Programm-variablen sind.
((S, H); [[a.append(b)]]) → ((S, H 0 ); ())
Wobei H = H0 ∪ {(x, N il)} der Heap vor der Auswertung ist, x sei letzte
Heap-Variable der Liste zu a die auf N il zeigt, (b, x1 ) ist im Stack S, und
H 0 = H0 ∪ {(x, (x1 , x2 )), (x2 , N il) mit der neu erzeugten Heap-Variablen x2 .
Die Auswertung von a.append(b) kann man sich so veranschaulichen:
a
Vorher:
u1
b
a
b
x
x1
w
u1
x
x
Nil
w
x1 x2
1
Nachher:
Dadurch wird das Element b als letztes Element an die Liste angehängt. Es
kann dadurch auch eine Liste entstehen, die unendlich tiefe Verschachtelung
Nil
23
Praktische Informatik 1, WS 2001/02, Kapitel 3
hat, indem man a.append(a) ausführt. Der zugehörige Heap, für die Liste
a = [1, 2] sieht so aus:
{(u1 , (u2 , u3 )), (u2 , 1), (u3 , (u4 , u5 )), (u4 , 2), (u5 , (u1 , u7 )), (u7 , N il), . . .}
Dieser Heap ist zyklisch, aber zulässig.
a
u1
u2 u3
1
u4 u5
u1 u7
2
Als weiteres Beispiel die operationelle Semantik der Funktion insert. Für
diese Regel nehmen wir ebenfalls an, dass a, b Programm-variablen sind.
((S, H); [[a.insert(i, b)]]) → ((S, H 0 ); ())
wobei H = H0 ∪ {(xi , (y1 , y2 ))}, (b, z1 ) ∈ S, und H 0 = H0 ∪
{(xi , (z1 , z2 )), (z2 , (y1 , y2 ))}, wobei z2 neue Heap-Variable ist. Im Falle, dass die
Liste zu Ende ist, ergibt sich ein kleiner Unterschied: H = H0 ∪ {(xi , N il)},
(b, z1 ) ∈ S, und H 0 = H0 ∪ {(xi , (z1 , z2 )), (z2 , N il)}.
Die operationelle Semantik von map kann damit ebenfalls beschrieben
werden, allerdings ist diese Funktion aus kleineren Operationen
zusammengesetzt, so dass es einfacher wäre, für die Implementierung der
Funktion map die operationelle Semantik zu beschreiben.
Den Inhalt des Elements mit Index i der Liste a kann man mittels
a[i] = b
verändern.
Wir geben hierfür die operationelle Semantik an, wobei wir, annehmen, dass
b eine Programmvariable ist, und bei der Auswertung von Anweisungen rechts
nur den veränderten Zustand angeben.
((S, H); [[a[i] = b]]) → (S, H 0 )
wobei H = H0 ∪ {(xi , (y1 , y2 ))}, (b, z) ∈ S, und H 0 = H0 ∪ {(xi , (z, y2 ))}.
Nil
24
Praktische Informatik 1, WS 2001/02, Kapitel 3
Indirektionen
Diese könnte man auch im Heap erlauben. Es sind Bindungen von der Art
(x1 , x2 ), wobei x1 , x2 beides Heap-Variablen sind. Deren Bedeutung ist, dass der
Wert von x1 erst über die Referenz x2 und evtl. weitere Indirektionen gefunden
werden kann, wobei die Indirektionen für die Programmiersprache nicht sichtbar
sind.
Das formale Modell würde dadurch etwas komplizierter, da man den Wert
nicht direkt finden kann. Man muss auch Indirektionszyklen beachten, die eine
Nichtterminierung des Wertesuchens bewirken könnten.
In Implementierungen werden Indirektionen z.T. verwendet, aus
verschiedenen Gründen: manche Operationen lassen sich leichter formulieren,
und manchmal ist es effizienter, Indirektionen zu verwenden.
3.3.2
Implementierung
Speichermodell
der
Halde
in
einem
Zunächst das Speichermodell.
Definition 3.3.10 Einen RAM-Speicher S kann man definieren als endliche
Folge von Bytes (1 Byte = 8 Bit), (alternativ: von Zahlen zwischen 0 und 28 −1 =
255), nummeriert mit Indices 0, . . . , L − 1. L ist die Größe des Speichers.
Der gespeicherte Wert unter der Adresse i ist das Byte S(i), d.h. das i − te
Element der Folge, wenn die Zählung mit 0 beginnt.
Die benötigte Adresslänge in Bits ist dlog2 (L)e.
Die Zugriffsfunktion get(S, i, l) hat als Ergebnis die Subfolge von S der Länge l
ab i.
00101001
0
1
2
3
4
5
Eine Implementierung von Adressen eines Speichers mit Adresslänge 32 Bit
kann man durchführen, indem man eine Binärzahl, die durch eine Folge von 4
Bytes repräsentiert wird, als Adresse im gleichen Speicher interpretiert.
Heapvariablen kann man dann implementieren als Folge von 4 Bytes
(auch Wort genannt), die im Speicher direkt hintereinanderliegen, d.h.
L(i), L(i + 1), L(i + 2), L(i + 3). Ein Paar (x1 , x2 ) von zwei Heapvariablen
kann man als hintereinanderliegende Folge von 8 Bytes implementieren, d.h.
L(i), . . . , L(i + 7). Eine Zahl kann man implementieren wie eine Adresse, nur
wird der Wert als die binäre Zahl selbst interpretiert.
In dieser Implementierung geht Information verloren, welche Daten gemeint
sind. Das sieht man daran, dass man damit Indirektionen, Paare, Nil, und
Zahlen nicht unterscheiden kann. Je nach Implementierung benötigt man daher
Praktische Informatik 1, WS 2001/02, Kapitel 3
25
i.a. 2 noch eine Markierung (englisch: Tag), die diese verschiedenen Daten
voneinander unterscheidet. D.h. man könnte vereinbaren:
Adresse:
1 Byte Markierung (z.B. binär 1), und 4 Byte Adresse.
Paar:
1 Byte Markierung (z.B. binär 2), und 8 Byte Adressen.
Ganze Zahl: 1 Byte Markierung (z.B. binär 3), und 4 Byte Zahl.
Nil:
1 Byte Markierung (z.B. binär 4).
kein Wert:
1 Byte Markierung (z.B. binär 5).
Jetzt können wir auch unterscheiden, welche Eigenschaften der
Implementierung eher zufällig sind und nicht durch die operationelle Semantik
erzwungen werden:
Z.B. erzwingt die operationelle Semantik keinerlei lokale Zusammenhänge wie:
• die Adressen eines Adresspaares (x1 , x2 ) liegen direkt nebeneinander.
• Adressen sind als aufsteigende Folge von Bytes repräsentiert.
• Der nächste Eintrag im Array (Feld) hat Adresse des aktuellen Feldes +4.
Auch die Länge der Adressen ist nicht festgelegt.
3.4
Felder, Arrays
Felder (arrays, Vektoren) sind eine weitverbreitete Datenstruktur in
Programmiersprachen, die normalerweise im einfachsten Fall eine Folge ai , i =
0, . . . , n modellieren.
Als Semantik eines Feldes A mit den Grenzen (a, b) und Elementen des Typs
α kann man eine Funktion nehmen: fA : [a, b] → α. In Haskell sind Felder als
Zusatzmodul verfügbar. Die Elemente müssen gleichen Typ haben.
In Python sind die Listen gleichzeitig eindimensionale Felder, die
auch heterogen (d.h. mit Elementen unterschiedlichen Typs) sein dürfen.
Mehrdimensionale Arrays und Matrizen kann man mit einem Array von Arrays
modellieren.
Beispiel 3.4.1 Transposition einer 2-dimensionalen quadratischen Matrix in
Python
def transpose(a,n):
for i in range(n):
for j in range(i+1,n):
a[i][j], a[j][i] = a[j][i] , a[i][j]
return a
def testtranspose(n):
b = range(n)
for i in range(n):
2 Diese
soll.
Markierung wird nicht benötigt, wenn ein Programm vorher weiß, was dort stehen
26
Praktische Informatik 1, WS 2001/02, Kapitel 3
b[i] = range(n)
for i in range(n):
print b[i];
c = transpose(b,n)
print " "; print "Nach Transposition:"
for i in range(n):
print c[i]
Die interne Implementierung eines Feldes macht es möglich, eine (praktisch)
konstante Zugriffszeit zu realisieren, wenn der Index bekannt ist. Allerdings
ist die Handhabung eines Feldes im Programm etwas aufwendiger, da man bei
Zugriffen stets den Index berechnen muß und da man im allgemeinen ein Feld
nicht so ohne weiteres verkleinern oder erweitern kann.
3.4.1
Felder in Haskell
In Haskell gibt es als eingebaute Datenstruktur (als Modul) ebenfalls Arrays.
Dies sind Felder von Elementen des gleichen Typs. Die Implementierung eines
Feldes als Liste ist möglich, hätte aber als Nachteil, dass der Zugriff auf ein
bestimmtes Element, wenn der Index bekannt ist, die Größe O(länge(liste))
hat.
Als Spezialität kann man beim Erzeugen verschiedene Typen des Index
wählen, so dass nicht nur Zahlen, sondern auch Tupel (auch geschachtelte Tupel)
von ganzen Zahlen möglich sind, womit man (mehrdimensionale) Matrizen
modellieren kann. Einige Zugriffsfunktionen sind:
• array x y : erzeugt ein Feld (array) mit den Grenzen x = (start, ende),
initialisiert anhand der Liste y, die eine Liste von Index-Wert Paaren (eine
Assoziationsliste) sein soll
• listArray x y: erzeugt ein array mit den Grenzen x = (start, ende),
initialisiert sequentiell anhand der Liste y.
• (!): Infix funktion: ar!i ergibt das i-te Element des Feldes ar.
• (//): Infix-funktion a // xs ergibt ein neues Feld, bei dem die Elemente
entsprechend der Assoziationsliste xs abgeändert sind.
• bounds: Erlaubt es, die Indexgrenzen des Feldes zu ermitteln.
Beispiel 3.4.2 umdrehen_array ar =
let (n,m) = bounds ar
mplusn = m+n
in ar // [(i,ar!(mplusn -i))
| i <- [n..m] ]
Praktische Informatik 1, WS 2001/02, Kapitel 3
3.5
27
Kontrollstrukturen, Iteration in Haskell
Wir können auch Kontrollstrukturen wie while, until und for definieren,
wobei das Typsystem bestimmte Einschränkungen für den Rumpf macht.
Generell ist Rekursion allgemeiner als Iteration, allerdings sind vom
theoretischen Standpunkt aus beide gleichwertig: wenn man den Speicher
erweitern kann (d.h. beliebig große Datenobjekte verwenden und aufbauen
kann), dann kann Iteration die Rekursion simulieren.
Die Definition dieser Kontrollstrukturen benutzt einen Datentyp als
“Umgebung“, so dass jeder Iterationsschritt die alte Umgebung als Eingabe
hat, und die neue Umgebung als Ausgabe, die dann wieder als Eingabe für den
nächsten Iterationsschritt dient. Diesen Effekt kann man auch beschreiben als:
die Umgebung wird in jedem Iterationsschritt geändert. Der Rumpf ist eine
Funktion, die genau diesen Schritt beschreibt.
Beispiel 3.5.1
while:: (a -> Bool) -> (a -> a) -> a -> a
-while test f init
-Typ der Umgebung: a
-test: Test, ob While-bedingung erfuellt
-f:: a -> a
Rumpf, der die Umgebung abaendert
-init:
Anfangsumgebung
while test f init =
if test init
then while test f (f init)
else init
untill :: (a -> Bool) -> (a -> a) -> a -> a
untill test f init =
if test init
then init
else untill test f (f init)
for :: (Ord a, Num a) => a -> a -> a -> (b -> a -> b) -> b -> b
-For laeuft von start bis end in Schritten von schritt
-Umgebung:: b, Zaehler:: a
-f: Umgebung , aktueller Zaehler -> neue Umgebung
-f : init start -> init’
for start end schritt f init =
if start > end
then init
else let startneu = start + schritt
in for startneu end schritt f (f init start)
Die Funktion f, die als Argument mit übergeben wird, erzeugt ein neues
Datenobjekt.
28
Praktische Informatik 1, WS 2001/02, Kapitel 3
Verwendung im Beispiel:
dreinwhile n = while (> 1) dreinschritt n
dreinschritt x = if x == 1 then 1
else if geradeq x then x ‘div‘ 2
else 3*x+1
-berechnet 1 + 2+ ... + n:
summebis n = for 1 n 1 (+) 0
-berechnet fibonacci (n)
fib_for n = fst (for 1 n 1 fib_schritt
fib_schritt (a,b) _ = (a+b,a)
(1,1))
Ein Ausdruck mit foldl läßt sich als while-Ausdruck schreiben:
foldl f e xs
ist äquivalent zu:
fst (while (\(res,list) -> list /= [])
(\(res,list) -> (f res (head list), tail list))
(e,xs))
Ein Ausdruck mit foldr läßt sich nicht so ohne weiteres als while-Ausdruck
schreiben. Für endliche Listen ist das (unter Effizienzverlust) möglich:
foldr f e xs
ist äquivalent (für endliche Listen) zu:
fst (while (\(res,list) -> list /= [])
(\(res,list) -> (f (head list) res, tail list))
(e,reverse xs))
3.6
Church-Rosser Sätze in vollem Haskell
Nimmt man eine größere Menge der Konstrukte und Möglichkeiten von Haskell,
dann hat die Auswertung etwas kompliziertere Eigenschaften als im einfachen
Haskell.
Folgendes Bild veranschaulicht die Auswertung:
29
Praktische Informatik 1, WS 2001/02, Kapitel 3
Haskell- Programm
Entzuckerung
Programm in Kernsprache
Syntaxanalyse
Syntaxbaum des Programms
Auswertung
(operationelle Semantik)
transformierter Syntaxbaum
Der erste Schritt der Auswertung ist die Transformation einiger Konstrukte
in einfachere (Entzuckerung), so dass man sich bei der Definition der
operationellen Semantik-Regeln auf eine Untermenge der syntaktischen
Möglichkeiten von Haskell beschränken kann. Hierbei werden u.a. die Pattern
in Haskell und die Fallunterscheidung bei der Funktionsdefinition in caseAusdrücke übersetzt.
Beispiel 3.6.1 Wir zeigen, wie z.B. die Funktionsdefinition in einfacheres
Haskell transformiert wird.
map f []
map f (x:xs)
= []
= f x : map f xs
Das kann man transformieren zu:
map f l
= (case l of [] -> []; (x:xs) -> f x : map f xs)
Man beachte, dass diese Transformation vorher eine Analyse der Art der
Fallunterscheidung machen muss. Hier ist zu erkennen, dass Fallunterscheidung
über das zweite Argument gemacht werden muss.
Danach setzen wir die operationale Semantik auf einem Haskell-Fragment
auf, das Funktionsdefinitionen, Konstruktoren, Lambda-Ausdrücke und let
kennt.
Definition 3.6.2 Ein verallgemeinerter Wert (WHNF) ist entweder
1. eine Zahl, oder
Praktische Informatik 1, WS 2001/02, Kapitel 3
30
2. eine Abstraktion, oder
3. eine Applikation (f t1 . . . tn ), wobei f ein Konstruktor ist mit der
Stelligkeit ≤ n, oder
4. eine Applikation (f t1 . . . tn ), f ist eine global definierte Funktion, deren
Stelligkeit > n ist.
5. ein let-Ausdruck let . . . int, wobei t von einer der Formen 1—4 ist.
Atomare Werte sind Zahlen oder Konstruktorkonstanten (z.B. True.
D.h. neben den Zahlen und Funktionen sinf jetzt auch konstruierte Daten
als Werte zugelassen. Es ist zu beachten, dass auch Daten noch beliebige
(unausgewertete) Unterausdrücke haben können.
Die Auswertung in normaler Reihenfolge ist jetzt eine Erweiterung der
normalen Reihenfolge der Auswertung, wobei als zusätzlicher Fall nur der caseAusdruck mit der case-Reduktion auftritt. Hier hat man im Falle, dass man
ein case t ... hat, und t kein Wert ist, rekursiv die normale Reihenfolge auf t
anzuwenden. Man kann die Fälle ignorieren, in denen man nicht weiterkommt,
da t zu einem nicht brauchbaren Ausdruck reduziert (z.B. eine Abstraktion), da
Haskells Typcheck dafür sorgt, dass dies nicht vorkommt.
Zu beachten ist, dass die normale Reihenfolge die Argumente eines
Konstruktors nicht reduziert. Dies bewirkt, dass unendliche Listen in Haskell
verarbeitet werden können.
Die Sätze von Church-Rosser gelten immer noch, wenn es keine rekursiven
lets gibt, allerdings etwas verallgemeinert:
Satz 3.6.3 (Church-Rosser) Annahme: Kein letwird rekursiv verwendet.
Wenn s zu s0 und s00 reduziert, und sowohl s0 als auch s00 ist ein
verallgemeinerter Wert, dann gibt es Ausdrücke s000 , s0000 , die sich nur
um let-Reduktionen und um eine Umbenennung von gebundenen Variablen
unterscheiden, und s0 →∗ s000 , s00 →∗ s0000 .
Beachte: die Werte s0 , s00 können noch Unterausdrücke enthalten, die man
weiter auswerten kann.
Satz 3.6.4 (Church-Rosser) Annahme: Kein letwird rekursiv verwendet.
Wenn s zu s0 reduziert und s0 ist ein verallgemeinerter Wert, dann reduziert
die normale Reihenfolge s zu s00 , so dass s00 ein verallgemeinerter Wert ist, und
s00 reduziert zu einem s000 , so dass sich s000 und s0 nur um let-Reduktionen und
eine Umbenennung von gebundenen Variablen unterscheiden.
Auch in der Erweiterung gilt:
Satz 3.6.5 Wenn s zu s0 reduziert, und s0 ist ein atomarer Wert, dann
terminiert auch die normale Reihenfolge der Auswertung mit dem Wert s0 .
Praktische Informatik 1, WS 2001/02, Kapitel 3
31
Damit kann man salopp sagen: Die Normalordnung berechnet den Wert,
wenn es einen gibt, und dieser Wert ist (bis auf let-Reduktionen und
Umbenennung von gebundenen Variablen) eindeutig bestimmt.
Beachte: wenn rekursive Verwendung des let erlaubt ist, dann gelten die
Church-Rosser Sätze in dieser Form nicht mehr. Man benötigt dann das Konzept
der Verhaltensgleichheit.
Herunterladen