Generische Programmierung

Werbung
Generische Programmierung
Der Begriff Generische Programmierung wird für verschiedene Konzepte verwendet. Zum
Beispiel nennt man das, was in Java Generics heißt, in Haskell Polymorphie, aber auch das
Konzept der Überladung, zum Beispiel arithmetischer Funktionen, wird gelegentlich als
generisch bezeichnet. In diesem Kapitel behandeln wir sogenannte Datentyp-generische
Programmierung, mit deren Hilfe man gleichartige Funktionen auf unterschiedlichen
Datentypen mit Hilfe einer einzigen Implementierung definieren kann.
Im vorigen Kapitel haben wir Tries für unterschiedliche Datenstrukturen kennengelernt
und dabei gesehen, dass Implementierungen sowohl der Map-Typen als auch der zugehörigen Funktionen einem festen Schema folgen. Bereits früher sind wir solchen Funktionen
begegnet, die zwar für unterschiedliche Datentypen gleichartig, aber nicht identisch,
implementiert werden.
Zum Beispiel folgt der Gleichheits-Test in der Regel einem festen Muster. Trotzdem kann
man keine allgemeine Implementierung für
(==) :: a -> a -> Bool
angeben, da sich die Implementierungen für unterschiedliche Typen unterscheiden. In
Haskell wurde deshalb die Typklasse Eq eingeführt, die es erlaubt, für unterschiedliche
Datentypen unterschiedliche Implementierungen für == anzugeben. Da solche Implementierungen sich in der Regel ähneln, gibt es außerdem die Möglichkeit, Eq-Instanzen
automatisch vom Compiler nach einem festen Muster generieren zu lassen (Schlüsselwort
deriving). Eine Alternative zu solch speziellem Compiler-Support ist die im Folgenden
vorgestellte Datentyp-generische Programmierung.
Statt ein festes Muster zur Implementierung von == für unterschiedliche Datentypen immer
wieder anzuwenden, kann man es ein einziges Mal für einen bestimmten universellen
Datentyp definieren und alle anderen Typen in diesen Typ konvertieren. Dieses Vorgehen
erscheint zunächst umständlicher, da man nun zwar keine Gleichheits-Funktion für jeden
Typ mehr angeben muss, dafür aber eine Konvertierungsfunktion. Bei genauerem Hinsehen
zeigt sich jedoch ein Vorteil: Die Konvertierungsfunktionen kann man verwenden, um
eine unbegrenzte Zahl generischer Funktionen anzuwenden. Statt viele Funktionen für
viele Typen zu implementieren braucht man also nur noch eine Konvertierungsfunktion
für jeden Typ und eine Implementierung für jede generische Funktion.
Um drei generische Funktionen für vier Datentypen zu implementieren, braucht man
unter Verwendung eines universellen Datentyps nur 4 + 3 statt 4 ∗ 3 Funktionen zu
implementieren.
Es stellt sich die Frage, wie der universelle Datentyp beschaffen sein muss, um die
Definition möglichst vieler generischer Funktionen zu unterstützen. Zunächst muss es
möglich sein, jeden Datentyp1 injektiv in den universellen Datentyp abzubilden, da
1
Wir vernachlässigen hierbei primitive Datentypen wie Int oder Char und beschränken uns auf selbst
definierte, algebraische Datentypen (ohne Funktionen).
1
Bool
==
[Bool]
compare
(Bool,[()])
universal data type
serialize
Either [()] Bool
...
...
Abbildung 1: Avoiding quadratic number of function definitions with universal datatype
wir ansonsten keine sinnvolle Gleichheits-Funktion implementieren könnten. Darüber
hinaus muss die Struktur eines Wertes erhalten bleiben, damit die Implementierung
einer generischen Funktion auf dem universellen Datentyp das Muster, dem man für den
Original-Datentyp folgen würde, anwenden kann.
Wir verwenden deshalb den folgenden universellen Datentyp.
data Universal
= Unit
| Pair Universal Universal
| This Universal
| That Universal
Diesen Datentyp können wir verwenden, um das Muster, dem die Gleichheits-Funktion
folgt, zu formalisieren. Dazu geben wir einfach eine ==-Funktion für den Typ Universal
an.
instance Eq Universal where
Unit
== Unit
=
Pair u1 v1 == Pair u2 v2 =
This u1
== This u2
=
That u1
== That u2
=
_
== _
=
True
u1 == u2 && v1 == v2
u1 == u2
u1 == u2
False
2
Wir können nun Werte beliebiger Typen, die sich in den Universal-Typ konvertieren lassen, mit dieser Funktion vergleichen. Zur Konvertierung in den Universal-Typ definieren
wir eine Typklasse Generic
class Generic a where
universal :: a -> Universal
mit deren Hilfe wir einen generischen Gleichheits-Test implementieren können.
genericEq :: Generic a => a -> a -> Bool
genericEq x y = universal x == universal y
Die Funktion universal ist selbst eine Datentyp-generische Funktion und zwar die
einzige, die man für jeden Typ gesondert programmieren muss. Sie folgt einem festen
Muster, welches wir im Folgenden untersuchen.
Um mehrere Konstruktoren eines Datentyps auseinander zu halten, verwendet man die
Konstruktoren This und That. Zum Beispiel konvertiert man Werte vom Typ Bool wie
folgt:
instance Generic Bool where
universal False = This Unit
universal True = That Unit
Hierbei verwenden wir Unit als Argument von This und That, da die Konstruktoren
von Bool keine Argumente haben (Konstruktoren mit Argumenten widmen wir uns
später). Bei Datentypen mit mehr als zwei Konstruktoren, können wir This und That
geschachtelt verwenden. Beispielhaft betrachten wir die Konvertierung eines Datentyps
für vier Farben.
data Colour = Red | Green | Blue | Yellow
In der Generic-Instanz für Colour schachteln wir die This und That Konstruktoren so,
dass man an der Anzahl der That-Konstruktoren erkennen kann, um welche Farbe es
sich handelt.
instance Generic Colour where
universal Red
= This Unit
universal Green = That (This Unit)
universal Blue
= That (That (This Unit))
universal Yellow = That (That (That (This Unit)))
Alternativ zu so einer linearen Kodierung der Farben, können wir auch eine Art Binärkodierung verwenden.
instance Generic Colour where
universal Red
= This (This Unit)
universal Green = This (That Unit)
3
universal Blue
= That (This Unit)
universal Yellow = That (That Unit)
Mit dieser Kodierung ist die Anzahl der verwendeten Konstruktoren pro Regel logarithmisch in der Anzahl der Regeln statt linear.
Zur Definition der generischen universal-Funktion verwenden wir also zur Unterscheidung von n Konstruktoren eine Schachtelung von log(n) This und That Konstruktoren
entsprechend der Binärdarstellung der Nummer des Konstruktors.
Wir können nun die generische Gleichheits-Funktion auf Boole’sche Werte und auf Farben
anwenden, aber nicht auf einen Boole’schen Wert und eine Farbe:
ghci> genericEq False False
True
ghci> genericEq Red Blue
False
ghci> genericEq False Yellow
Couldn't match expected type `Bool'
against inferred type `Colour'
Wir kommen nun zu Datentypen, deren Konstruktoren Argumente haben und definieren
dazu eine Generic-Instanz für Listen.
instance Generic a => Generic [a] where
universal []
= This Unit
universal (x:xs) = That (Pair (universal x) (universal xs))
Wieder unterscheiden wir die Konstruktoren mit This und That, verwenden aber zusätzlich den Pair-Konstruktor, um die Argumente von (:) zu speichern. Durch diese
Definition können wir nun zum Beispiel Listen von Farben konvertieren:
ghci> universal [Red]
That (Pair (This (This Unit)) (This Unit))
Im Allgemeinen verwenden wir Unit bei Konstruktoren ohne Argumente und schachteln
n − 1 Pair-Konstruktoren bei Konstruktoren mit n Argumenten. Auch bei der Schachtelung von Pair-Konstruktoren haben wir unterschiedliche Möglichkeiten. Zum Beispiel
können wir die Elemente linear oder als balancierten Baum schachteln. Die Art der
Schachtelung hat aber anders als bei This und That keinen Einfluss auf die Anzahl der
benötigten Pair Konstruktoren, da ein Binärbaum mit n Blättern unabhängig von seiner
Struktur immer genau n − 1 innere Knoten hat.
Die Konstruktoren des Universal-Datentyps entsprechen genau den Konstruktoren der
()-, (,)-, und Either-Typen:
4
instance Generic () where
universal () = Unit
instance (Generic a, Generic b) => Generic (a,b) where
universal (x, y) = Pair (universal x) (universal y)
instance (Generic a, Generic b) => Generic (Either a b) where
universal (Left x) = This (universal x)
universal (Right y) = That (universal y)
Diese Typen reichen aus, um die Strukturinformation beliebiger algebraischer Datentypen zu kodieren, denn nach dem oben erklärten Muster lassen sich alle algebraischen
Datentypen in den Universal-Typ konvertieren.
Bei der Definition von Konvertierungs-Funktionen ist man nicht an das beschriebene
Muster gebunden, es stellt nur eine mögliche Art dar, beliebige Datentypen zu konvertieren.
Zum Beispiel können wir Listen auch konvertieren, ohne This und That zu verwenden:
instance Generic a => Generic [a] where
universal []
= Unit
universal (x:xs) = Pair (universal x) (universal xs)
Diese Definition führt zu einer kompakteren Darstellung von Listen:
ghci> universal [Red]
Pair (This (This Unit)) Unit
Bei eigenen Konvertierungs-Funktionen müssen wir sicherstellen, dass diese injektiv sind,
das heißt, dass keine unterschiedlichen Werte des Original-Typs auf den selben Wert
des Universal-Typs abgebildet werden. Weiterhin sollte die Strukturinformation bei der
Konvertierung vollständig erhalten bleiben. Beides ist mit Konvertierungs-Funktionen,
die nach dem generischen Muster erstellt werden, der Fall.
Ein weiteres Beispiel für eine generische Haskell-Funktion ist die show-Funktion zum
Umwandeln eines Wertes in einen String. Auch diese Funktion kann man generisch über
die Struktur des Arguments definieren. Die im Universal-Typ gespeicherte StrukturInformation reicht zur Definition von show aber nicht aus. Es fehlt die Information über
die Konstruktor-Namen, die im erzeugten String vorkommen.
Es ist möglich, den Universal-Typ um weitere Informationen zu erweitern, auch um solche,
mit deren Hilfe wir show implementieren könnten. Wir beschränken uns aber auf den
gezeigten Universal-Datentyp und definieren anstelle von show eine generische Funktion
serialize, die einen beliebigen Datentyp in eine Bitfolge übersetzt:
serialize :: Generic a => a -> [Bool]
serialize = binary . universal
5
binary ist dabei eine Funktion, die einen Universal-Wert in eine Liste Boole’scher Werte
übersetzt.
binary
binary
binary
binary
binary
:: Universal
Unit
=
(Pair u v) =
(This
u) =
(That
u) =
-> [Bool]
[False,False]
[False,True ] ++ binary u ++ binary v
[True ,False] ++ binary u
[True ,True ] ++ binary u
Da der Universal-Typ vier Konstruktoren hat, verwenden wir zwei Bits für jeden und
serialisieren sie von links nach rechts. Hier ein Beispielaufruf:
ghci> binary (Pair (That Unit) Unit)
[False,True,True,True,False,False,False,False]
Dadurch, dass wir die binary-Funktionion für den Universal-Typ definiert haben,
können wir beliebige Daten, deren Typ eine Instanz der Klasse Generic ist, in Bitfolgen
transformieren.
ghci> serialize False
[True,False,False,False]
ghci> serialize [()]
[False,True,False,False,False,False]
Wie man sieht, verwendet diese Implementierung mehr Bits als man erwarten könnte. Zum
Beispiel kann man Boole’sche Werte mit einem einzigen Bit kodieren satt wie hier mit
vieren. Gelegentlich ist eine generische Implementierung mittels des universellen Datentyps
weniger effizient als eine auf einen bestimmten Datentyp spezialisierte Implementierung.
Die erzeugten Bitfolgen lassen sich auf eindeutige Weise in den Universal-Datentyp
zurück übersetzen. Zusammen mit einer Funktion, die Universal-Werte in beliebige
Datentypen zurück konvertiert, kann man also auch eine Funktion deserialize schreiben,
die Daten aus einer Bitfolge einliest (Übung).
Zum Abschluss dieses Kapitels implementieren wir eine generische Trie-Struktur, die
man mit Schlüsseln beliebigen (nach Universal konvertierbaren) Typs verwenden kann.
Dazu definieren wir zunächst einen Trie für den Universal-Typ nach dem im vorigen
Kapitel diskutierten Muster.
data UniMap a = UniMap (Maybe a)
(UniMap (UniMap a))
(UniMap a)
(UniMap a)
Die Definitionen der empty-, lookup- und update-Funktionen sind im bereitgestellten
Generic-Modul verfügbar. Aufbauend auf dieser Implementierung definieren wir Zugriffsfunktionen für beliebige Datentypen, hier am Beispiel der lookup-Funktion:
6
lookupG :: Generic k => k -> UniMap a -> Maybe a
lookupG = lookupUni . universal
Mit solchen Zugriffsfunktionen können wir in eine emptyUniMap Werte zu beliebigen
Schlüsseln eintragen.
ghci> let m =
ghci> lookupG
Just 42
ghci> lookupG
Nothing
ghci> lookupG
Nothing
insertG [True,False] 42 emptyUniMap
[True,False] m
[False] m
True m
Der letzte Aufruf ist verdächtig. Obwohl wir m mit Schlüsseln vom Typ [Bool] verwendet
haben, können wir sie auch mit anderen Schlüssel-Typen, die eine Generic-Instanz sind,
verwenden. Das ist eine potentielle Fehlerquelle, denn obwohl es in diesem Beispiel richtig
ist, dass kein Wert unter dem Schlüssel True abgelegt wurde, ist nicht sichergestellt, dass
unterschiedliche Werte unterschiedlicher Typen verschiedene Universal-Darstellungen
haben. Die Konvertierungsfunktionen sind nur injektiv bezüglich eines bestimmten Typs,
nicht über Typgrenzen hinweg.
Wir definieren deshalb einen Trie für generische Werte, bei dem jeder einzelne Trie mit
nur einem Schlüsseltyp (verschiedene Tries aber mit unterschiedlichen Schlüsseltypen)
verwendet werden können.
newtype GenMap k a = GenMap (UniMap a)
GenMap ist im Wesentlichen nur ein neuer Name für UniMap mit einer wichtigen Besonderheit: Der GenMap Typkonstruktor hat einen zusätzlichen Parameter k für den Schlüsseltyp.
Dieser Parameter ist ein sogenannter Phantom-Typ, da er auf der rechten Seite der Definition nicht vorkommt. Wir verwenden ihn in den Typsignaturen der Zugriffsfunktionen
für GenMaps, um sicher zu stellen, dass mit einer gegebenen GenMap immer Schlüssel
desselben Typs verwendet werden.
Die Implementierung der Zugriffsfunktionen für GenMaps greift auf die für UniMaps zurück,
verwendet aber restriktivere Typsignaturen (hier am Beispiel von lookupGen):
lookupGen :: Generic k => k -> GenMap k a -> Maybe a
lookupGen k (GenMap m) = lookupUni (universal k) m
Dadurch, dass der Typparameter k im ersten und zweiten Argument von lookupGen
identisch ist, können wir nur mit Schlüsseln eines einzigen Typs auf eine bestimmte
GenMap zugreifen.
Wir können GenMaps ähnlich verwenden wie im obigen Beispiel. Sobald wir aber eine
Zugriffsfunktion auf einer GenMap mit einem konkreten Schlüssel ausgeführt haben, ist
der Typ der GenMap auf diesen Schlüsseltyp festgelegt.
7
ghci> let m = insertGen [True,False] 42 emptyGenMap
ghci> lookupGen [True,False] m
Just 42
ghci> lookupGen [False] m
Nothing
ghci> lookupGen True m
Couldn't match expected type `Bool'
against inferred type `[Bool]'
ghci> :t m
m :: GenMap [Bool] Int
Der lookupGen Aufruf mit einem Bool-Schlüssel führt zu einem Typfehler, da vorher mit
einem Schlüssel vom Typ [Bool] auf m zugegriffen wurde. Auf eine neue GenMap können
wir mit Bool-Schlüsseln zugreifen:
ghci> let m = insertGen True 42 emptyGenMap
ghci> :t m
m :: GenMap Bool Int
ghci> lookupGen True m
Just 42
Wir haben durch Phatom-Typen erreicht, dass auf eine GenMap nur mit Schlüsseln eines
Typs zugegriffen werden kann. Die erste Zugriffsfunktion legt dabei den Schlüsseltyp fest
und stellt dadurch sicher, dass sich gleich dargestellte Schlüssel unterschiedlicher Typen
nicht in die Quere kommen.
8
Herunterladen