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.