Haskell, Typen, und Objektorientierung

Werbung
Kapitel 2
Haskell, Typen, und
Objektorientierung
2.1
Typisierung in Haskell
In diesem Kapitel sind die Typisierungsmethoden in Haskell beschrieben, wobei
auch kurz auf die Typisierung in anderen Programmiersprachen eingegangen
wird.
In Haskell gilt, dass jeder gültige Ausdruck einen Typ haben muss. Der
Typchecker sorgt dafür, dass es keine Ausnahmen gibt. Diese Art des Typchecks
nennt man starke statische Typisierung. Die Theorie sagt dann, dass nach
erfolgreichem Typcheck keine Typfehler zur Laufzeit mehr auftreten können.
Das Gegenteil ist dynamische Typisierung, die eine Typüberprüfung zur
Laufzeit macht. D.h. es gibt ein Typsystem, alle Datenobjekte haben einen
Typ, meist durch die Implementierung der Daten gegeben. Bei falschem Aufruf
wird ein Laufzeit-Typfehler gemeldet.
Die schwache, statische Typisierung hat einen Typcheck zur Kompilierzeit,
aber es sind Löcher gelassen für den Programmierer, so dass in bestimmten
Fällen entweder ein dynamischer Typcheck notwendig ist und eventuell doch ein
Typfehler zur Laufzeit auftreten kann. Normalerweise haben alle Konstanten,
Variablen (Bezeichner), Prozeduren und Funktionen einen im Programm
festgelegten Typ. Typisch für diese Sprachen ist, dass man zur Laufzeit den
Typ per Programmbefehl abfragen kann.
Monomorphe Typisierung erzwingt, dass alle Objekte und insbesondere
Funktionen einen eindeutigen Typ haben, d.h. Typvariablen sind verboten
bzw. es gibt keine Allquantoren in den Typen. In Haskell würde das
bedeuten, dass man für Listen von Zahlen und Listen von Strings verschiedene
Längenfunktionen bräuchte.
Polymorphe Typisierung erlaubt es, Funktionen zu schreiben, die auf
mehreren Typen arbeiten können, wobei man schematische Typen meint. In
Haskell sind die Typvariablen verantwortlich für die Schematisierung. Man
1
2
Praktische Informatik 2, SS 2002, Kapitel 2
nennt dies auch parametrischen Polymorphismus. In dieser Typisierung ist es
möglich, eine einzige Längenfunktion für alle Listen zu verwenden.
Oft interpretiert man die polymorphe Typisierung auch so: Ein Ausdruck
kann eine Menge von Typen haben (vielgestaltig, polymorph). Die (teilweise
unendliche) Menge der Typen kann man dann oft in einem einzigen
schematischen Typausdruck notieren.
Die weitere Strukturierung der Typen in Typklassen ignorieren wir zunächst
mal. Wir geben die Syntax von Typen in Haskell an:
Definition 2.1.1
hTypi
Syntax der Haskell-Typen (ohne Typklassen-Information)
::=
hBasistypi ::=
hBasistypi | (hTypi) | hTypvariablei
| hTypkonstruktorn i{hTypi}n
(n ist dessen Stelligkeit)
Int | Integer | Float | Rational | Char
Typkonstruktoren können benutzerdefiniert sein (z.B. Baum .). Eingebaute
Typkonstruktoren sind: Liste [·], Tupel (.,...,.) für Stelligkeiten ≥ 2 und
Funktion → (Stelligkeit 2).
Typen mit → sind sogenannte Funktionstypen. Die Konvention ist, dass man
normalerweise a → b → c → d schreiben darf, und der gemeinte Typ dann
durch Rechtsklammerung entsteht, d.h. a → (b → (c → d)). Es gibt spezielle
syntaktische Konventionen für Tupel und Listen. Zum Beispiel ist (Int, Char)
der Typ eines Paars von Zahlen und Zeichen; [Int] ist der Typ einer Liste von
Zahlen des Typs Int.
Man verwendet den doppelten Doppelpunkt zur Typangabe. t :: τ soll
bedeuten, dass der Ausdruck t den Typ τ hat. Dies ist syntaktisch auch in
Haskell-Programmen erlaubt.
• Basistypen nennt man auch elementare Typen.
• Einen Typ nennt man auch Grundtyp, wenn er keine Typvariablen enthält.
Einen solchen Typ nennt man manchmal auch monomorph.
• Einen Typ nennt man polymorph, wenn er Typvariablen enthält.
Beispiel 2.1.2
• length :: [α] → Int
D.h. die Funktion length hat als Argument ein Objekt vom Typ [α], wobei
α noch frei ist. Das Ergebnis muss vom Typ Int sein.
Will man den Typ als Formel ausdrücken, so könnte man schreiben:
∀α.length :: [α] → Int
Das kann man interpretieren als: Für alle Typen α ist length eine
Funktion, die vom Typ [α] → Int ist.
Da das eine Aussage über Argumente und Resultate ist, sollte folgendes
gelten: ∀x.x :: [α] ⇒ (length x) :: Int.
3
Praktische Informatik 2, SS 2002, Kapitel 2
• (:) :: α → [α] → [α].
Der Listenkonstruktor hat zwei Argumente. Das erste hat irgendeinen Typ
α. Das zweite Argument ist eine Liste, deren Elemente vom Typ α, d.h.
von gleichem Typ wie das erste Argument sein müssen. Das Ergebnis ist
eine Liste vom Typ [α].
• map :: (α → β) → [α] → [β] beschreibt den Typ der Funktion map.
2.1.1
Typregeln
Die wichtigste Typregel in Haskell, die auch in anderen Programmiersprachen
mit monomorphem Typsystem gilt, betrifft die Anwendung von Funktionen auf
Argumente: Wenn σ, τ Typen sind, dann gilt die Regel:
s :: σ → τ , t :: σ
(s t) :: τ
Zum Beispiel ist die Anwendung der Funktion quadrat :: Int → Int auf
eine Zahl 2 : Int. D.h. quadrat 1 :: Int.
Wenn man z.B. die Funktion + anwendet, mit dem Typ + :: Int → Int → Int,
dann erhält man für (1 + 2) die voll geklammerte Version ((+ 1) 2). (+ 1)
hat dann den Typ (Int → Int), und ((+ 1) 2) den Typ Int. Man kann auch
die Regel im Fall mehrerer Argumente etwas einfacher handhaben, indem man
sofort alle Argumenttypen einsetzt. Die zugehörige Regel ist dann
s :: σ1 → σ2 . . . → σn → τ , t1 :: σ1 , . . . , tn :: σn
(s t1 . . . tn ) :: τ
Zum Beispiel ergibt das für (+ 1 2):
+ :: Int → Int → Int, 1 :: Int , 2 :: Int
(+ 1 1) :: Int
Dies lässt sich noch nicht so richtig auf die Funktion length oder
map anwenden. Da die Haskelltypen aber Typvariablen enthalten können,
formulieren wir die erste Regel etwas allgemeiner, benötigen dazu aber den
Begriff der Instanz eines Typs.
Definition 2.1.3 Wenn γ eine (mathematische) Funktion auf Typen ist, die
Typen für Typvariablen einsetzt, dann ist γ eine Typsubstitution.
Beispiel 2.1.4 Man kann mit γ = {α 7→ Char, β 7→ Float} die Typinstanz
γ([α] → Int) = [Char] → Int bilden.
Die Regel für die Anwendung lautet dann:
s : σ → τ, t : ρ und γ(σ) = γ(ρ)
(s t) :: γ(τ )
4
Praktische Informatik 2, SS 2002, Kapitel 2
Beispiel 2.1.5 Die Anwendung für den Ausdruck map quadrat ergibt
folgendes. Zunächst ist
map :: (α → β) → [α] → [β]
Instanziiert man das mit der Typsubstitution {α 7→ Int, β 7→ Int}, dann erhält
man, dass map u.a. den Typ (Int → Int) → [Int] → [Int] hat. Damit kann
man dann die Regel verwenden:
map : (Int → Int) → [Int] → [Int], quadrat :: (Int → Int)
(map quadrat) :: [Int] → [Int]
Die Erweiterung auf eine Funktion mit n Argumenten, wenn γ eine
Typsubstitution ist, sieht so aus:
s :: σ1 → σ2 . . . → σn → τ, t1 : ρ1 , . . . , tn : ρn und ∀i : γ(σi ) = γ(ρi )
(s t1 . . . tn ) :: γ(τ )
Bei diesen Regeln ist zu beachten, dass man das allgemeinste γ nehmen
muss, um den Typ des Ergebnisses zu ermitteln. Nimmt man irgendeine andere,
passende Typsubstitution, dann erhält man i.a. einen zu speziellen Typ.
Beispiel 2.1.6 Betrachte die Funktion id mit der Definition id x = x und
dem Typ α → α. Der Typ von (map id) kann berechnet werden, wenn man obige
Regel mit der richtigen Typsubstitution benutzt. Der Typ des ersten Arguments
von map muss α → β sein, und id erzwingt, dass α = β. Die passende
Typsubstitution ist γ = {α → β}. Das ergibt unter Anwendung der Regel
map : (α → β) → ([α] → [β]), id :: α → α
(map id) :: γ([α] → [β])
d.h. (map id) :: ([α] → [α]).
Definition 2.1.7 Sei V eine Menge von Typvariablen. und γ, γ 0 zwei
Typsubstitutionen. Dann ist γ allgemeiner als γ 0 , wenn es eine weitere
Typsubstitution δ gibt, so dass für alle Variablen x ∈ V : δ(γ(x)) = γ 0 (x)
Beispiel 2.1.8 Nimmt man V = {α1 }, dann ist γ = {α1 7→ (α2 , β2 )}
allgemeiner als γ 0 = {α1 7→ (Int, Int)}, denn man kann γ 0 durch weitere
Instanziierung von γ erhalten: Nehme δ = {α2 7→ Int, β2 7→ Int}. Dann ist
δ(γ(α1 )) = (Int, Int) = γ 0 (α1 ).
Beispiel 2.1.9 Der Typ der Liste [1] kann folgendermaßen ermittelt werden:
• [1] = 1 : []
• 1 :: Int und [] :: [β] folgt aus den Typen der Konstanten.
Praktische Informatik 2, SS 2002, Kapitel 2
5
• (:) :: α → [α] → [α]
• Anwendung der Regel mit γ = {α 7→ Int} ergibt:
(1 :) :: [Int] → [Int]
• Nochmalige Anwendung der Regel mit γ = {β 7→ Int} ergibt:
(1 : []) :: [Int]
Beispiel 2.1.10 Wir weisen nach, dass es keinen Typ von [1, 0 a0 ] gibt: Der voll
geklammerte Ausdruck ist 1 : (0 a0 : []).
1 :: Int, [] :: [β] und 0 a0 :: Char folgen aus den Typen der Konstanten.
Wie oben ermittelt man: (1 :) :: [Int] → [Int] und
’a’:[] :: [Char].
Wenn wir jetzt die Typregel anwenden wollen, dann stellen wir fest: es gibt
kein γ, das [Int] und [Char] gleichmacht. D.h. die Regel ist nicht anwendbar.
Da das auch der allgemeinste Versuch war, einen Typ für diese Liste zu
finden, haben wir nachgewiesen, dass die Liste [1,0 a0 ] keinen Typ hat; d.h. der
Typchecker wird sie zurückweisen.
> [1,’a’]
ERROR - Illegal Haskell 98 class constraint in inferred type
*** Expression : [1,’a’]
*** Type
: Num Char => [Char]
Beispiel 2.1.11 Wir zeigen, wie der Typ von (map quadrat [1,2,3,4])
ermittelt wird.
• map hat den Typ (α → β) → [α] → [β]
quadrat den Typ Int → Int, und [1, 2, 3, 4] :: [Int].
• Wir nehmen γ = {α 7→ Int, β 7→ Int}.
• Das ergibt γ(α) = Int, γ([α]) = [Int], γ([β]) = [Int].
• Damit ergibt sich mit obiger Regel, dass das Resultat vom Typ γ([β]) =
[Int] ist.
Etwas komplexer ist die Typisierung von definierten Funktionen,
insbesondere von rekursiv definierten. Man benötigt auch weitere Regeln
für
Lambda-Ausdrücke,
let-Ausdrücke
und
List-Komprehensionen.
In
Haskell
und
ML
wird
im
wesentlichen
der
sogenannte
Typcheckalgorithmus von Robin Milner verwendet. Dieser ist i.a. schnell,
aber hat eine sehr schlechte worst-case Komplexität: in seltenen Fällen hat er
exponentiellen Platzbedarf. Dieser Algorithmus liefert allgemeinste Typen im
Sinne des Milnerschen Typsystems. Allerdings nicht immer die allgemeinsten
möglichen polymorphen Typen.
Folgender Satz gilt im Milner-Typsystem. Wir formulieren ihn für Haskell.
Praktische Informatik 2, SS 2002, Kapitel 2
6
Satz 2.1.12 Sei t ein getypter Haskell-Ausdruck, der keine freien Variablen
enthält (d.h. der geschlossen ist). Dann wird die Auswertung des Ausdrucks t
nicht mit einem Typfehler abbrechen.
Dieser Satz sollte auch in allen streng typisierten Programmiersprachen
gelten, sonst ist die Typisierung nicht viel wert.
Wir untermauern den obigen Satz, indem wir uns die Wirkung der BetaReduktion auf die Typen der beteiligten Ausdrücke anschauen.
((λx.t) s)
Erinnerung Beta-Reduktion:
.
t[s/x]
Der Typ von λx.t sei τ1 → τ2 . Der Typ von s sei σ. Damit der Ausdruck
((λx.t) s) einen Typ hat, muss es eine (allgemeinste) Typsubstitution γ geben,
so dass γ(τ1 ) = γ(σ) ist. Der Ausdruck hat dann den Typ γ(τ2 ).
Jetzt verwenden wir γ um etwas über den Typ des Resultatterms t[s/x]
herauszufinden. Dazu muss man wissen, dass der Milner-Typcheck nur den
Typ der Unterterme beachtet, nicht aber deren genaue Form. Verwendet man
γ, dann hat unter γ der Ausdruck s und die Variable x den gleichen Typ;
jeweils als Unterterme von t betrachtet. D.h. Der Typ von t[s/x] ist unter der
Typsubstitution γ gerade γ(τ2 ).
Da man das für jede Typsubstitution machen kann, kann man daraus
schließen, dass die Beta-Reduktion den Typ erhält.
Leider ist die Argumentation etwas komplizierter, wenn die Beta-Reduktion
innerhalb eines Terms gemacht wird, d.h. wenn t Unterausdruck eines anderen
Ausdrucks ist, aber auch in diesem Fall gilt die Behauptung, dass die BetaReduktion den Typ nicht ändert.
Analog kann man die obige Argumentation für andere Auswertungsregeln
verwenden.
2.1.2
Bemerkungen zu anderen Programmiersprachen
In den funktionalen Programmiersprachen Lisp und Scheme, die beide höherer
Ordnung sind, gibt es in den Standardvarianten kein statisches Typsystem. Der
Grund ist, dass schon in den ersten Konzeptionen dieser Sprachen keine statische
Typdisziplin eingehalten wurde: Z.B. ist es erlaubt und wird auch genutzt,
dass man alle Werte als Boolesche Werte verwenden darf: Die Konvention
ist: Nil = False, alles andere gilt als True. Z.B. ist der Wert von 1 = 0 in
Lisp die Konstante Nil, während 1 = 1 die Konstante 1 ergibt. Damit hat
man Ausdrücke, die sowohl Boolesche Ausdrücke sind als auch Listen; und
Ausdrücke, die Boolesche Ausdrücke sind und Zahlen.
Als weitere Schwierigkeit kommen die Typeffekte von Zuweisungen
hinzu. D.h. um einen vernünftigen Typisierungsalgorithmus für diese
Programmiersprachen zu konzipieren, müsste man zu viel ändern.
Programmiersprachen, die keine Funktionen oder Prozeduren höherer
Ordnung haben, begnügen sich meist mit einem monomorphen Typcheck.
Bei Verwendung der arithmetischen Operatoren, z.B. des Additionszeichens
(+) will man oft erreichen, dass dies für alle Zahlentypen wie Int, Rational
Praktische Informatik 2, SS 2002, Kapitel 2
7
usw. funktioniert. In manchen Programmiersprachen wird vom Compiler
automatisch eine Typkonversion eingefügt, wenn unverträgliche Zahltypen
benutzt werden.
In Haskell sind eingegebene Zahlkonstanten normalerweise überladen, indem
z.B. bei Eingabe der Zahl 1 vom Compiler fromInteger 1 eingefügt
wird. Wenn unverträgliche Operanden benutzt werden, muss man die
Typkonversion von Hand einfügen. Z.B. pi + 2%3 ergibt einen Typfehler,
während pi + (fromRational (2%3)) korrekt 3.80825932::Double ergibt.
Die Programmiersprache Python ist ungetypt. Man könnte vermutlich ein
monomorphes Typsystem zu Python hinzufügen, allerdings hat Python bisher
keine syntaktischen Elemente, um den Typ einer Variablen festzulegen. Als
weitere Schwierigkeit kommt hinzu, dass Python Lambda-Ausdrücke zulässt.
Diese Kombination erlaubt mit Sicherheit kein strenges Typsystem, es sei
denn, man nimmt hin, dass viele in normalem Python erlaubten Programme
in getypten Python verboten wären.
Die Programmiersprache Java ist streng getypt. Das Typsystem ist
monomorph, allerdings entsprechen die Java-Klassen den elementaren Typen.
Zudem gibt es einen Untertyp-Begriff für Klassen, d.h. für die elementaren
Typen. Die Typfehler, die jetzt noch auftreten, sind meist von der Art, dass
ein syntaktisch geforderter Typ nicht mit dem aktuellen Objekt übereinstimmt,
d.h. Fehler beim (cast).
2.2
Typklassen und Überladung in Haskell
Typklassen wurden entwickelt, um Überladung von Funktionen im polymorphen
Typsystem systematisch zu benutzen und auch benutzerdefinierbar zu machen.
(Überladung: Es gibt mehrere Funktionen mit gleichem Namen). Dazu muss
spätestens zum Zeitpunkt der Ausführung bekannt sein, welche Funktion denn
tatsächlich ausgeführt werden soll. Dieses Problem ist praxisrelevant, denn es ist
nicht einzusehen, dass man z.B. 2+5 schreiben darf, aber bei komplexen Zahlen
statt (1 + i) + (3 + 2i) in etwa schreiben müsste: (1 + i) complexadd (3 + 2i).
Als weiteren Nachteil eines Überladungsverbots würden sich die Namen der
allgemeinen Funktionen vermehren, z.B. Summe einer Liste müsste für reelle
Zahlen und komplexe Zahlen extra definiert werden.
Ein spezifisches Problem der Typklassen in Milner-getypten Sprachen wie
Haskell ist die korrekte Kombination der normalen Typisierung mit Typklassen.
• +, ∗, −, / will man für verschiedene Arten von Zahlen verwenden: Integer,
Float, Rational, ...
• Es gibt Funktionalitäten, die nicht auf allen Typen definierbar sind,
sondern nur auf einer Menge von vorgegebenen Typen: z.B. Gleichheit (==)
oder die Funktion show, die Objekte in eine druckbare Form überführt.
8
Praktische Informatik 2, SS 2002, Kapitel 2
2.2.1
Typklassen
Eine Typklasse kann man sich vorstellen als eine Menge von Typen zusammen
mit zugehörigen Funktionen bzw. Methoden. Die Typklassen werden als eigene
syntaktische Einheiten (d.h. sie haben einen Namen) eingeführt.
Typklassendefinition
Eine neue Typklasse in Haskell kann definiert werden durch:
class
<Vorbedingung>
=>
NEWCLASS(a)
hVorbedingungi: Klassenbedingungen an die Variable a
NEWCLASS:
neuer Klassenname. Muss mit Großbuchstabe beginnen
Im allgemeinen gibt es nur eine Variable a. Als <Vorbedingung> ist in Haskell
nur eine Unterklassenbeziehung von Typklassen erlaubt.
Als Beispiel zeigen wir den Anfang der Definition der Typklasse Num in Haskell:
class (Eq a, Show a) => Num a
Hier bedeutet Eq a, dass der Typ a zur Typklasse Eq gehört. Die
Vorbedingung (Eq a, Show a) => Num a bedeutet: Nur für Typen a, die
bereits in den Klassen Eq, Show sind, wird a auch in Num definiert: d.h. Num
ist Unterklasse von Eq, Show. Leider ist dies von der logischen Schreibweise
her falsch geraten, da bei der Betrachtung von Mengen und Aussagenlogik
normalerweise A ⇒ B zu A ⊆ B passt, während Eq a => Num a in Haskell
die Bedeutung Num ⊆ Eq hat.
Die Deklaration von Typklassen und die Deklaration von Typen von
Funktionen erfordert eine erweiterte Syntax:
Die Syntax für Typen kann eine Vorbedingung an die Zugehörigkeit von
Typvariablen zu Typklassen enthalten.
class
<Vorbedingung>
=>
<Typ>
Beispiel 2.2.1 Der Typ von == ist
Eq a
=>
a -> a -> Bool
Das bedeutet, dass für alle Typen a, die in Eq sind, die Gleichheit == den Typ
a -> a -> Bool hat.
Diese Schreibweise kann man auch folgendermaßen interpretieren:
Ein Ausdruck hat alle Grundtypen, die Instanz des Typs sind, wobei
man bei der Einsetzung die Typklassenbedingung beachten muss.
Bei der aktuellen Verwendung von == in einem Ausdruck wird der
Typchecker dann prüfen, ob die auf Gleichheit getesteten Objekte tatsächlich
in der Klasse Eq sind; und natürlich, ob sie den gleichen Typ haben. Wenn nein,
wird der Ausdruck als ungetypt zurückgewiesen. Wenn ja, wird der berechnete
Typ verwendet, um evtl. den zu benutzenden Gleichheitstest direkt einzusetzen.
In Eq sind alle Typen, für die ein Gleichheitstest implementiert worden ist.
Nicht sinnvoll ist der Vergleich von Funktionen mit ==, obwohl man das auch
definieren kann, beispielsweise für Funktionen mit endlichen Definitionsbereich.
9
Praktische Informatik 2, SS 2002, Kapitel 2
Beispiel 2.2.2 Definition der Typklasse Eq (im Haskell-Prelude):
class Eq a where
(==), (/=)
x /= y
=
:: a -> a -> Bool
(not (x == y))
Vergleicht man das mit der üblichen objektorientierten Programmierung
(z.B. Java), so entspricht Eq einer Klasse, die Methoden sind == und /=. Für
die Methode == wird nur die Schnittstelle zur Verfügung gestellt, während /=
standardmäßig bereits aufbauend auf == definiert wird. Die Typen der Methoden
werden ebenfalls festgelegt und können nicht überschrieben werden.
Umgekehrt ist es nicht sinnvoll, alle Klassen einer objektorientierten
Sprache auch als Typklassen in Haskell zu implementieren. Oft ist die richtige
Übersetzung ein Typ: z.B. Listen.
Danach kann man noch selbst-definierte Typen zu Instanzen der
Typklasse Eq erklären. Oder auch rekursive Regeln zur Erzeugung von
Instanztypen angeben. Dies bedeutet: == (ist gleich) muss bei jeder solchen
Instanzdefinition mit definiert werden, /= (ungleich) ist dann automatisch
ebenfalls definiert. Allerdings gibt es auch einen Default (.. deriving Eq) ,
der die Strukturgleichheit implementiert, wobei rekursiv wieder == verwendet
wird.
Beispiel 2.2.3
instance Eq Char
x == y
=
where
ord c == ord d
Dies erklärt Char zu einem Typ der Typklasse Eq, auf den == angewendet
werden kann. Gleichheit auf Zahlen wird im Haskell-Prelude mittels eingebauter
Funktionen implementiert.
Beispiel 2.2.4
instance
instance
instance
instance
Eq
Eq
Ord
Ord
Int
Integer
Int
Integer
where
where
where
where
(==)
(==)
compare
compare
=
=
=
=
primEqInt
primEqInteger
primCmpInt
primCmpInteger
Bei (selbstdefinierten) algebraischen Datentypen, die auch noch rekursiv
definiert sind (hier Beispiel Listen) ist das etwas komplizierter. Hier wird die
komplette Gleichheitsdefinition für Listen implementiert.
instance Eq a => Eq [a]
where
[] == []
= True
[] == (x:xs)
= False
(x:xs) == []
= False
(x:xs) == (y:ys) = x == y && xs == ys
10
Praktische Informatik 2, SS 2002, Kapitel 2
• Dies ist eine rekursive Definition einer unendlichen Menge von Eq-Typen
• Die Gleichheit ist explizit für alle Konstruktormöglichkeiten ([] und :)
definiert.
• In Haskell kann statt der expliziten Implementierung eine DefaultDefinition benutzt werden, bei der direkt an der Typdeklaration
steht: deriving (Eq,Ord). Hierbei werden offenbar die Daten auf
Strukturgleichheit getestet.
2.2.2
Verwendung der Definition der Gleichheit
Will man nicht die Standarddefinition der Gleichheit, sondern hat eine andere
Gleichheit intendiert, dann ist die entsprechende Implementierung möglich. Hier
am Beispiel von Mengen, die als Listen implementiert sind.
Beispiel: Mengen
data Menge a = Set [a]
Instance Eq a => Eq (Menge a) where
Set xs == Set ys = subset xs ys && subset ys xs
subset: Eq a => a -> a -> a
subset xs ys = all (’elem’ ys)
xs
instance (Ord a) => Ord (Menge a) where
Set xs <= Set ys = subset xs ys
Damit kann man erreichen, dass verschiedene algebraische Datentypen auf
ihre eigene und korrekte Weise auf Gleichheit verglichen werden, auch wenn
diese verschachtelt sind, z.B. Mengen von Mengen, Mengen von Listen, oder
Listen von Mengen.
Man kann das Drucken von Mengen analog zum Drucken von Listen
implementieren:
instance Show a => Show (Menge a) where
showsPrec p = showMenge
showMenge (Set [])
= showString "{}"
showMenge (Set (x:xs)) = showChar ’{’ . shows x . showm xs
where showm []
= showChar ’}’
showm (x:xs) = showChar ’,’ . shows x . showm xs
Testen der Mengendarstellung ergibt:
Main>> Set [[1],[2,3]]
{[1],[2,3]} :: Menge [Integer]
11
Praktische Informatik 2, SS 2002, Kapitel 2
Main> Set [Set [1], Set [1,2]]
{{1},{1,2}} :: Menge (Menge Integer)
Übungsaufgabe 2.2.5 Implementiere Multimengen analog zu Mengen.
2.2.3
Die Typklasse Ord
Das ist die Klasse der Typen mit einer totalen Ordnungsrelation <.
Beispiel 2.2.6 Es folgt die Definition der Typklasse Ord aus dem Prelude von
Haskell.
data Ordering = LT | EQ | GT
deriving (Eq, Ord, Ix, Enum, Read, Show, Bounded)
class (Eq a) => Ord a where
compare
:: a -> a -> Ordering
(<), (<=), (>=), (>)
:: a -> a -> Bool
max, min
:: a -> a -> a
-- Minimal complete definition: (<=) or compare
-- using compare can be more efficient for complex types
compare x y
| x==y
= EQ
| x<=y
= LT
| otherwise = GT
x
x
x
x
<=
<
>=
>
y
y
y
y
max x y
|
|
min x y
|
|
=
=
=
=
x >= y
otherwise
= x
= y
x <= y
otherwise
= x
= y
compare
compare
compare
compare
x
x
x
x
y
y
y
y
/=
==
/=
==
GT
LT
LT
GT
Die primitive Operation ist offensichtlich <=, die anderen sind darauf
aufgebaut. Damit kann man nicht nur Zahlen und Character vergleichen,
sondern auch Tupel und Listen. Zum Beispiel gilt [1,2] < [1,2,3],
[1,2] < [1,3], und (1,’c’) < (2,’b’). D.h. , es wird die lexikographische
Ordnung auf Tupeln verwendet. Unglücklicherweise sind in Haskell98 Tupel nur
Praktische Informatik 2, SS 2002, Kapitel 2
12
bis zu 5 -Tupeln vordefiniert. Will man größere Tupel verwenden, so muss man
die Definitionen selbst machen.
Verwendet man in einer Datentypdefinition deriving (Eq,Ord), werden
offenbar die Datenkonstruktoren ihrem Index nach geordnet, und Komponenten
lexikographisch.
2.2.4
Typklassen Read und Show
Die Klasse Show enthält alle Typen, deren Objekte eine Repräsentation als
Text haben. Die Klasse Read enthält die Typen, deren Objekte aus einer
Textrepräsentation zu rekonstruieren sind. Wenn ein Typ sowohl zu Show
als auch Read gehört, dann sollte die Textrepräsentation wieder eindeutig zu
parsen sein und das Objekt ergeben, d.h. für alle sinnvollen x muss gelten:
read(show(x)) = x.
Funktionen (Methoden) für die Klassen Read, Show:
shows :: Show a => a -> ShowS
reads :: Read a => ReadS a
show :: Show a => a -> String
read :: Read a => String -> a
type ReadS a = String -> [(a,String)]
type ShowS
= String -> String
Diese sind für einige eingebaute Typen bereits vordefiniert und müssen für
andere entsprechend definiert werden.
Der technische Grund, zunächst die Funktionen shows und reads zu
definieren, hat ihren Grund in der besseren Ressourcennutzung. Analog
zum fold auf Bäumen ist es besser, hier mittels Funktionskomposition zu
programmieren, auch wenn dies etwas umständlicher und gewöhnungsbedürftig
ist.
2.2.5
Mehrfache Vererbung
Es gibt bei der Definition von Typklassen Möglichkeiten analog zur multiplen
Vererbung:
class (Eq a, Show a) => Num a where
(+), (-), (*) :: a -> a -> a
negate
:: a -> a
abs, signum
:: a -> a
fromInteger
:: Integer -> a
fromInt
:: Int -> a
-- Minimal complete definition: All, except negate or (-)
x - y
= x + negate y
fromInt
= fromIntegral
13
Praktische Informatik 2, SS 2002, Kapitel 2
negate x
= 0 - x
instance Num Int where
(+)
= primPlusInt
(-)
= primMinusInt
negate
= primNegInt
(*)
= primMulInt
abs
= absReal
signum
= signumReal
fromInteger
= primIntegerToInt
fromInt x
= x
Die numerischen Typklassen müssen somit die Methoden von Eq und Show
implementiert haben. Interessanterweise ist dies analog zum Java-Mechanismus
der multiplen Vererbung, die nur Interface-Vererbung erlaubt.
2.2.6
Auflösung der Überladung
Wird an einer Stelle in einer Funktion eine überladene Funktion (Methode)
verwendet, dann muss das Typsystem während der Compilezeit dafür sorgen,
dass die Überladung aufgelöst wird, bzw. zur Laufzeit aufgelöst werden kann. Es
wird durch den Typechecker in Kombination mit dem Kompiler sichergestellt,
dass eine Anwendung von Methoden auf primitive Objekte mit der richtigen
primitiven Methode erfolgt.
!!! Im Haskell-Laufzeitsystem gibt es keine Typinformationen mehr. D.h.
man kann zur Laufzeit Listen-Objekte von Bäumen nicht unterscheiden (mittels
Tags). Insbesondere ist eine Java-Methode wie instanceof in Haskell nicht
möglich.
Zum Zeitpunkt des Typchecks hat das manchmal merkwürdige
Konsequenzen: z.B. lässt sich [] = [] nicht typen, da ein [] von Typ [a],
das andere von Typ [b] ist, und über a,b nichts weiter bekannt ist.
Im Vergleich dazu existieren im Java Laufzeitsystem noch die
Typinformationen und können auch in Abfragen verwendet werden.
Die interne Überladungsauflösung in Haskell funktioniert so, dass überladene
Funktionen, die zum Zeitpunkt des Typchecks keinen eindeutigen Typ haben,
intern einen zusätzlichen Parameter, den sogenannten Dictionary-Parameter
bekommen. Zum Aufrufzeitpunkt einer Funktion muss dieser Parameter dann
soweit bekannt sein, dass die zugehörige Implementierung im Dictionary
gefunden werden kann. Außerdem wird intern vom Kompiler ein“Dictionary“
aufgebaut, das zur Laufzeit als normale Haskell-Datenstruktur vorhanden
ist, und die Funktionen mit den richtigen“Methoden“ versorgt. Wenn der
Dictionary-Parameter einem primitiven Typ wie Int entspricht, dann wird die
zugehörige Gleichheit benutzt. D.h. die rekursive Aufrufhierarchie bzgl. der
Typen als Aufrufhierarchie des Dictionary von == abgebildet.
Eine Konsequenz dieses Verfahrens ist, dass in Haskell zur Compilezeit
genügend viel Typinformation (auch für Unterausdrücke) zur Verfügung stehen
14
Praktische Informatik 2, SS 2002, Kapitel 2
muss, um überladene Funktionen mit den richtigen Dictionary-Parametern zu
versehen.
Da z.B. die Funktion == überladen ist, und nur für Argumente der Typklasse
Eq zur Verfügung steht, muss für eine Gleichung s == t für die Ausdrücke s und t
entweder ein Grundtyp berechnet werden, oder, wenn Typvariablen vorkommen,
muss aus dem übergeordneten Ausdruck hervorgehen, welches Dictionary zu
benutzen ist. Dieser Dictionary-Parameter ist i.a. ein (implizites) Argument
einer übergeordneten Funktion.
Ein Dictionary kann auch rekursiv aufgebaut sein, wobei jedoch das
Dictionary endlich sein muss.
2.3
Aufzählungsklasse Enum
Mit dieser Typklasse kann man die Funktionalität von Datentypen erfassen,
deren Elemente sich vorwärts und rückwärts aufzählen lassen, wie z.B.
nichtnegative ganze Zahlen; die Zeichen eines Alphabets, oder Farben, falls
man diese in eine Ordnung bringt. Für Elemente dieser Typen kann man
das Aufzählungsverfahren mit der Syntax analog zu Zahlen verwenden: [2..],
[’a’..’z’], [’1’,’0’..], . . . .
Als Beispiel als ein Auszug aus dem Hugs-Prelude die Definition der Klasse.
class Enum a where
toEnum
fromEnum
enumFrom
enumFromThen
enumFromTo
enumFromThenTo
enumFromTo x y
enumFromThenTo x y z
::
::
::
::
::
::
Int -> a
a -> Int
a -> [a]
a -> a -> [a]
a -> a -> [a]
a -> a -> a -> [a]
-----
[n..]
[n,m..]
[n..m]
[n,n’..m]
= map toEnum [fromEnum x .. fromEnum y ]
= map toEnum [fromEnum x,
fromEnum y .. fromEnum z ]
succ, pred :: Enum a => a -> a
succ
= toEnum . (1+)
. fromEnum
pred
= toEnum . subtract 1 . fromEnum
instance Enum Char where
toEnum
= primIntToChar
fromEnum
= primCharToInt
enumFrom c
= map toEnum
[fromEnum c .. fromEnum (maxBound::Char)]
enumFromThen c d =
map toEnum [fromEnum c, fromEnum d .. fromEnum (lastChar::Char)]
where lastChar = if d < c then minBound else maxBound
15
Praktische Informatik 2, SS 2002, Kapitel 2
Typklassenhierarchie (Ausschnitt aus den vordefinierten Typklassen)
Eq
Ord
Show
Num
Enum
Real
Integral
Beispiel 2.3.1 Definition einer überladenen Konstanten.
Die Klasse Finite hat als einzige Methode die Funktion members, die alle
Elemente des Typs als Liste enthält.
class Finite a
where members :: [a]
instance Finite Bool where members = [True, False]
instance (Finite a, Finite b) => Finite (a, b) where
members = [(x,y) | x <- members, y <- members]
Dies definiert eine Konstante in einer Typklasse, die abhängig von ihrem
Typkontext ihren Wert ändert. Weitere Typen können mittels Bool und
Paarbildung zusammengesetzt werden.
Damit erhält man z.B.
members::Bool -> [ True, False]
length (members :: [((Bool, Bool), (Bool, Bool)]])
--->
16
Beispiel 2.3.2 Programmiere die allgemeine Summe über alle Listen von
Typen, die zur Typklasse Num gehören.
Der folgende Versuch scheiterte in einer vorherigen Version von Haskell:
summe [] = 0
summe (x:xs) = x+summe xs
Praktische Informatik 2, SS 2002, Kapitel 2
16
Der Grund war, dass die Zahl 0 beim Einlesen den festen Typ Int hatte.
Dies wurde in der aktuellen Haskell-Version so abgeändert, dass ganzzahlige
Konstanten als Integererkannt werden und dann mit der Konversionsfunktion
fromInteger eingepackt werden.
Die allgemeine Summe von Zahlen (Objekten von einem Typ der Typklasse
Num) über eine Liste erhält man folgendermaßen (noch effizienter mit
Akkumulator):
allgemeine_summe :: Num a => [a] -> a
allgemeine_summe [] = 0
allgemeine_summe (x:xs) = x + allgemeine_summe xs
2.3.1
Klasse Functor
Diese Klasse kann man verwenden, um die Funktion map für eine Menge von
Typen zu verwenden, die eine vergleichbare map-Funktion wie Listen haben. In
der aktuellen Haskell-Version wird der Namen fmap statt map verwendet.
class Functor f where
fmap :: (a -> b) -> (f a -> f b)
data Maybe a = Just a | Nothing
deriving (Eq, Ord, Read, Show)
instance Functor Maybe where
fmap f Nothing = Nothing
fmap f (Just x) = Just (f x)
instance Functor [] where
-- [] bedeutet hier List als Typkonstruktor, nicht nil
fmap = map
Sinnvoll ist es, auch andere Datenstrukturen, für die man ein map definieren
kann, zu Instanzen von Functor zu machen.
data Baum a = Blatt a | Knoten (Baum a) (Baum a)
deriving (Eq, Show)
instance Functor Baum where
fmap = baummap
baummap f (Blatt a) = Blatt (f a)
baummap f (Knoten x y) = Knoten (baummap f x) (baummap f y)
Praktische Informatik 2, SS 2002, Kapitel 2
17
test = fmap (\x -> 2*x) (Knoten (Blatt 1) (Blatt 2))
Der Nutzen ist hier nicht so groß wie bei Eq und Ord, da diese Funktion nicht
rekursiv über verschiedene Typen operiert.
2.3.2
Verallgemeinerungen von Typklassen
Eine Verallgemeinerung von Typklassen ist die Möglichkeit, mehr als eine
Typvariable in der Definition einer Typklasse zu erlauben, bzw. komplexer
strukturierte Typen.
Ein Problem dieser Verallgemeinerungen ist die Frage, ob es einen
entscheidbaren Typcheckalgorithmus für Typen und Typklassen gibt.
Pragmatischer muss man fragen, ob es einen brauchbaren Typcheck gibt, ob man
den Compiler mit Überladungsauflösung noch sinnvoll implementieren kann,
und welche Vorteile und Änderungen dies für die Programmierung in Haskell
bedeutet.
Möglicherweise ist ein Typsystem einfacher zu handhaben, das abhängige
Typen (dependent types) verwendet. Dies würde bedeutet: man hat ein sehr
ausdrucksstarkes, allerdings unentscheidbares Typsystem. Damit kann man den
Typ von Objekten vom Werten von Variablen abhängig machen. Ein einfaches
Beispiel sind Matrizen, bei denen man in der Typbeschreibung die Anzahl der
Zeilen und Spalten angeben muss. Diese sind i.a. nur dynamisch bekannt und
sind Zahlenwerte im Programm. Der einfachste Fall in Haskell sind Tupel,
die man in einem dependent-type System in vollem Umfang implementieren
könnte. Zur Erinnerung: In Haskell sind manche Tupelfunktionalitäten nicht
vordefiniert; man bräuchte unendlich viele Funktionen, um alles in Haskell
vorzudefinieren, wie Z.b. extrahiere i-tes Element eines n-Tupels. Es gibt
experimentelle (auch funktionale) Programmiersprachen, die das Konzept der
dependent-types implementiert haben.
Herunterladen