Phantom Types

Werbung
Having Fun With Types
Hauptseminar im Sommersemester 2011
Phantom Types
Fabian Franzelin
Technische Universität München
10.06.2011
Zusammenfassung
In diesem Artikel werden Phantom Types vorgestellt als eine einfache und
sichere Methode zur Integration von dymamischer Typisierung und generischer Programmierung in eine Programmiersprache mit statischer Typisierung: Haskell. Beispielhaft wird die printf Funktion der Programmiersprache C mit Hilfe von Phantom Types implementiert und dabei
verschiedene Konzepte wie Typrepräsentanten und dynamische Werte eingeführt.
1
Einführung
In der Welt der Informatik gibt es viele verschiedene Programmiersprachen,
die verschiedenen Grundstrukturen folgen. Eine wichtige Grundstruktur ist die
Behandlung von Typen. Ein Typ kann allgemein als eine Zusammenfassung
von Werten gesehen werden, die eine gemeinsame Domäne abbilden. Ein Ausdruck, wie beispielsweise eine Funktion, evaluiert zu einem Wert eines bestimmten Typs. Dieser Zusammenhang ist in Abbildung 1 graphisch dargestellt. Je
nach Programmiersprache kann der Typ eines Ausdrucks dynamisch oder statisch festgelegt werden.
Bei dynamischer Typisierung wird die Typinformation zur Laufzeit mitgetragen
und zu einem bestimmten Teil erst dort überprüft. Dadurch werden moderne
Methoden der Programmierung, wie beispielsweise dynamische Casts in Java,
unterstützt. Ein weiterer Vorteil liegt darin, dass man flexibler im Schreiben von
Funktionen ist. In Programmiersprachen wie GNU R1 oder JavaScript ist die
Typinformation nicht an Variablen gebunden, was eine Typüberprüfung, wie sie
beispielweise Haskell zur Compilierzeit vornimmt, überflüssig macht. Der Typ
ist in diesem Kontext an die Ausprägung der Variable gebunden. Durch eine
erst zur Laufzeit erfolgende Typüberprüfung ergeben sich jedoch auch wesentliche Nachteile: Dadurch wird zwangsläufig die Ausführungsdauer erhöht und der
Speicherbedarf zur Ausführungszeit vergrößert. Zudem werden einige Typfehler
1 Siehe
http://www.r-project.org/
1
Wert
gehört zu
evaluiert zu
Typ
hat einen
Ausdruck
Abbildung 1: Zusammenhang zwischen Typ, Wert und Ausdruck (Adaptiert von
Kreiker [5, S. 3])
erst zur Laufzeit erkannt, obwohl sie bereits zur Übersetzungszeit erkennbar wären. Dieser Aspekt spielt gerade bei sicherheitskritischen Systemen eine wichtige
Rolle.
Als Gegenpart zur dynamischen Typisierung existiert eine statische Typisierung.
In diesem Kontext werden die verwendeten Typen während der Übersetzungszeit
vom Compiler kontrolliert und eine korrekte Verwendung sichergestellt. Darunter versteht man, dass jeder Ausdruck genau zu einem Typ evaluiert, dieser also
statisch im gegebenen Kontext ist. Haskell ist ein bekannter Vertreter dieser
Klasse. Dabei wird der Typ eines Ausdruck zur Compilierzeit abgeleitet2 und
erfüllt dabei die Korrektheits-Eigenschaft. Diese bedeutet, dass das Ergebnis der
Typableitung kohärent mit der abstrakten Semantik des Ausdrucks ist, sofern
der Ausdruck terminiert [4]. Die Konsequenz daraus ist, dass Typinformation
zur Laufzeit keine Rolle spielen und deshalb auch nicht mitgeführt werden. Es
ist deshalb, streng genommen, nicht möglich Programme zu schreiben, die in
Abhängigkeit der Ausführung unterschiedliche Ergebnisse erzeugen. In Haskell
existieren in diesem Zusammenhang Monads 3 die eine Interaktion mit der „Außenwelt“ ermöglichen ohne die Korrektheitseigenschaft zu korrumpieren. Ein
Vertreter dieser Klasse ist der Haskelltyp Maybe, der in späteren Beispielen verwendet wird.
Im Zusammenhang mit statischer Typisierung stellt sich folgende interessante
Frage: Ist es möglich dynamische Typisierung und generische Programmierung
im Rahmen einer Programmiersprache mit statischer Typisierung umzusetzen
ohne das Korrektheitsparadigma zu beinträchtigen? In [1, S. 1] wird genau diese
Frage gestellt und erklärt, dass dies bisher nur getrennt voneinander betrachtet
2 Diese
3 Siehe
Ableitung ist allgemein unter dem Begriff Type Inference bekannt.
http://www.haskell.org/tutorial/monads.html
2
und studiert wurde.
Im Folgenden wird eine Methode, im Rahmen der Programmiersprache Haskell,
vorgestellt, die es erlaubt, dynamische Typisierung mit generischer Programmierung zu verbinden, ohne Änderungen am Typsystem von Haskell vorzunehmen:
Phantom Types. Sie werden mit Hilfe einer beschränkten Ausdruckssprache eingeführt. Anschließend wird das Konzept der Typrepräsentierung mit Phantom
Types umgesetzt, um anschließend darüber generische Funktionen mit dynamischer Typisierung zu definieren, die dafür verwendet werden könnten die Funktion printf der Programmiersprache C in Haskell umzusetzen.
Die Publikationen [1], [2] von Cheney und Hinze dienen als Grundlage für diese
Arbeit.
2
Phantom Types
Einleitend wird auf die Konzepte eingegangen, die Haskell zur Erzeugung von
Typen und Daten zur Verfügung stellt, um daraus das Konzept der Phantom
Types herzuleiten.
Was ist nun ein Phantom Type? Was unterscheidet ihn von herkömmlichen
Typen in Haskell und wie kann er umgesetzt werden? In Haskell existiert das
data Konstrukt, welches es erlaubt, Typen zu deklarieren und gleichzeitig die
dazugehörigen Werte festzulegen. Ein Beispiel dafür ist nachfolgend angegeben:
data T a = K1
| K2 a
| K3 a a
T bezeichnet einen Typkonstruktor, K1, K2 und K3 jeweils einen zugehörigen
Datenkonstruktor. Bei a handelt es sich um eine Typvariable. Formal kann diese
Deklaration folgendermaßen ausgedrückt werden:
K1 :: ∀a.T a
K2 :: ∀a.a → T a
(1)
K3 :: ∀a.a → a → T a
Im Unterschied zum Haskell Konstrukt sei hier hervorgehoben, dass die Typvariablen universell quantifiziert sind. Bei Haskell gilt dies implizit und muss
für die folgenden Beispiele berücksichtigt werden. Zudem sei hier festgestellt,
dass der Typ eines Wertes explizit durch den Typkonstruktor gegeben ist und
vom Programmierer nicht verändert werden kann. In diesem Kontext seien nun
Phantom Types nach [6] definiert als:
Definition 2.1 Als Phantom Types werden parametrisierte Typen bezeichnet,
die ihre Typargumente nicht verwenden.
3
Für das Beispiel in (1) bedeutet dies konkret, dass die Typvariable a nicht für
die Deklaration von K1 , K2 oder K3 verwendet wird. Was das nun konkret
für Vorteile mit sich bringt, wenn man diesen Gedanken sinnvoll in Haskell
integriert, wird in den nächsten Abschnitten beschrieben.
2.1
Generische Typisierung
Als einführendes Beispiel, wird gezeigt, wie man eine eigene einfache Ausdruckssprache in Haskell integriert und diese dazu verwendet, eine dynamisch typisierte
Evaluierungsfunktion zu implementieren.
Gegeben seien folgende Datenkonstruktoren, welche die Peanozahlen, ein dazugehöriges Vergleichskonstrukt IsZero und eine If Abfrage definieren.
Zero
:: T erm Int
Succ
:: T erm Int → T erm Int
P red
:: T erm Int → T erm Int
(2)
IsZero :: ∀t.T erm t → T erm Bool
If
:: ∀t.T erm Bool → T erm t → T erm t → T erm t
Für diese möchte man nun eine einfache Evaluierungsfunktion definieren mit
folgender Typsignatur:
eval :: ∀t.T erm t → t
(3)
In Haskell könnte eval, nach diesen Vorgaben, intuitiv definiert werden durch
folgende Anweisungen:
eval
eval
eval
eval
eval
Zero = 0
( Succ a ) =
( Pred a ) =
( IsZero a)
( If a b c)
eval a + 1
eval a − 1
= e v a l a == 0
= i f e v a l a then e v a l b
else eval c
Listing 1: Die Funktion eval
Leider ist dieser einfache Weg in Standard-Haskell nicht umsetzbar. Weder Definition (3) noch die eval Funktion können übersetzt werden. Das in Abschnitt 2
eingeführte data Konstrukt legt fest, dass der Rückgabewert eines jeden Datenkonstruktors denjenigen Typ haben muss, in dessen Kontext er definiert wurde.
Konkret heißt das, dass IsZero den selben Ergebnistyp haben muss wie Zero.
Dies ist aber nicht der Fall, da dieser einmal Term Bool und einmal Term Int
ist. Selbst wenn eine entsprechende Definition zulässig wäre, könnte eval nicht
so definiert werden wie in Listing 1 angegeben, denn die Typsignatur wäre nicht
einheitlich. Bei einem Aufruf von eval mit einem IsZero Wert, wird ein Bool
zurückgegeben, während beim Aufruf mit Zero ein Int erwartet wird. Die Typsignatur kann demnach, in einem statischen Kontext, nicht abgeleitet abgeleitet
und die Funktion insgesamt nicht übersetzt werden.
4
Durch die Verwendung von Phantom Types können diese Probleme aber in einer eleganten Weise umgangen werden, indem der finale Typ eines Ausdrucks an
den Datenkonstruktor gebunden und damit versteckt wird. Um die Typsicherheit trotzdem aufrecht zu erhalten, wird die an den Typkonstruktor gebundene
Typvariable kontextabhängig durch eine Annotation am jeweiligen Datenkonstruktor eingeschränkt. Diese Einschränkung wird an den Compiler übertragen,
sodass erst zur Laufzeit überprüft wird, ob der Typ des vorliegenden Werts,
dem von der Typsignatur des Ausdrucks geforderten Typ entspricht. Zusätzlich dazu wird durch die Annotation eine typkohärente Erstellung eines Werts
sichergestellt. Insgesamt wird durch diese Erweiterungen die Korrektheit des
Typsystems von Haskell nicht beeinträchtigt [2].
Ein Phantom Type kann in Haskell mit den Spracherweiterungen für Generalized Algebraic Datatypes 4 (GADTs) und Existential Types 5 umgesetzt werden.
Listing 2 zeigt, wie Definition (3) in Haskell unter Verwendung der genannten
Erweiterungen implementiert werden kann:
data Term t
Zero
::
Succ
::
Pred
::
IsZero : :
If
::
where
( t ~ Int ) => Term Int
( t ~ Int ) => ( Term Int ) −> Term Int
( t ~ Int ) => ( Term Int ) −> Term Int
( t ~ Bool ) => ( Term Int ) −> Term Bool
f o r a l l a . ( t ~ a ) => ( Term Bool ) −>
( Term a ) −> ( Term a ) −> Term a
Listing 2: Definition der Peanozahlen als Phantom Type
Beim Typ Term t handelt es sich nun um einen Phantom Type, da der Typparameter t in der Definition der Datenkonstruktoren nicht verwendet wird. Jedoch
verfügt t über Einschränkungen, die abhängig vom jeweiligen Datenkonstruktor
festgelegt sind.6 .
Unter dieser Voraussetzung funktioniert auch das in Listing 1 angegebene Beispiel ohne Einschränkungen, denn die unterschiedlichen Typisierungen entstehen erst zur Laufzeit und nicht zur Übersetzungszeit.7 Die Annotationen bleiben
auch nach der Übersetzung vorhanden, sodass zur Laufzeit der Typ des Rückgabewertes dynamisch variieren kann, je nachdem welcher Wert vorliegt. Damit
wurde eine generische Funktion in Haskell integriert, deren Typ dynamisch zur
Laufzeit festgelegt wird.
4 Siehe
http://www.haskell.org/haskellwiki/Existential_type#Generalised_
algebraic_datatype
5 Siehe http://en.wikibooks.org/wiki/Haskell/GADT
6 Hervorzuheben an dieser Stelle ist, dass die Annotationen (t ∼ Int) hier nur zur Veranschaulichung angegeben sind. Sie sind nicht zwingend notwendig, da Haskell diesen Zusammenhang allein vom Rückgabewert des jeweiligen Werts erkennt. Zero z.B. liefert einen Term
Int zurück, was direkt auf die Einschränkung (t sim Int) in diesem Kontext schließen lässt.
7 Beispiele: eval (IsZero Zero) :: Bool und eval Zero :: Int
5
2.2
Typrepräsentanten und dynamische Werte am Beispiel printf
Die Mächtigkeit von Phantom Types wird in diesem Abschnitt durch die Umsetzung der printf Funktion der Programmiersprache C untermauert. Dafür
werden Typrepräsentanten eingeführt, mit denen es möglich ist, dynamische
Werte zu generieren die anschließend mit dynamischen Casting in statische Werte überführt werden können.
Wie funktioniert eigentlich die Funktion printf? Sie ist folgendermaßen definiert:
i n t p r i n t f ( const c h a r ∗ format ,
... );
Sie bekommt als Eingabe eine Zeichenkette, die bestimmte Tags beinhalten
kann, welche das Einfügen eines Wertes markieren. Findet sich im String das
%-Zeichen, so identifiziert das darauffolgende Zeichen den Typ des Wertes, der
an diese Stelle eingesetzt werden soll. Diese werden als zusätzliche Parameter
übergeben. Mögliche Identifikatoren sind beispielsweise i für Int oder c für Char.
Aus dieser Definition heraus ergibt sich intuitiv folgende Typsignatur für die
entsprechende printf Funktion in Haskell:
printf :: ∀t.String → [t] → M aybe String
(4)
Gegeben dieser Definition stellen sich zwei wesentliche Fragen:
1. Wie stellt man sicher, dass man an einer Position, an der man einen Wert
eines bestimmten Typs erwartet, auch nur einen solchen zulässt?
2. Wie kann eine Liste generiert werden, deren Inhalt unterschiedlichen Typs
sein kann, obwohl alle Einträge einer Liste per Definition vom selben Typ
sein müssen?
Die erste Frage birgt den von Haskell nicht unterstützten Kontext der dynamischen Castings. Zur Laufzeit muss entschieden werden ob der erwartete Typ
vorliegt, da dieser wesentlich für das Ergebnis der Funktion ist.8
Die zweite Frage kann interpretiert werden als die Suche nach dynamischen Werten, d.h. dass die Typinformation nicht an die Variable gebunden ist, sondern an
den Wert, der dieser Variable zugewiesen ist. Dies entspricht wiederum einer in
Abschnitt 1 erwähnten Eigenschaft von dynamische typisierten Programmiersprachen.
Der erste Schritt für die Umsetzung von printf besteht darin, eine Datenstruktur einzuführen, die es erlaubt, den Typ eines Wertes zu identifizieren. Diese sog.
8 Eigenheiten von C, die ein implizites Casting vorsehen bei nicht übereinstimmenden Typen, werden hier nicht berücksichtigt. Wenn printf ein Char erwartet und ein Int, dann wird
implizit der Int-Wert als Character interpretiert und nach diesen Vorgaben ausgegeben. Dies
kann gewollt sein, spiegelt in der Regel aber einen Programmierfehler wieder, der hier explizit
ausgeschlossen wird.
6
Typrepräsentanten werden als Phantom Types implementiert [2]. Das Beispiel
beschränkt sich auf Typrepräsentanten für Int, Char, Listen, Paaren und Dynamics, kann aber beliebig erweitert werden. Dynamics sind dynamische Werte,
die definiert sind als ein Paar, das aus einem Typrepräsentanten Type t und
einem Wert v besteht, dessen Typ t ist. Dynamische Werte kapseln also Werte
unterschiedlichen Typs. Die Implementierung eines solchen dynamischen Typs
ist in Listing 3 zusammen mit der Implementierung der dazugehörigen Typrepräsentanten dargestellt.9
data Type t where
RInt : : ( t ~ Int ) => Type Int
RChar : : ( t ~ Char) => Type Char
RList : : f o r a l l a . ( t ~ [ a ] ) => ( Type a ) −>
Type [ a ]
RPair : : f o r a l l a b . ( t ~ ( a , b ) ) =>
( Type a ) −> ( Type b ) −> Type ( a , b )
RDyn : : Type Dynamic
data Dynamic where Dyn : : Type t −> t −> Dynamic
Listing 3: Definition von Typreptäsentanten und eines dynamischen Typs
Ein besonderes Augenmerk soll in diesem Zusammenhang auf die zirkuläre Definition von Dynamic und Type t gelegt werden. Ein Wert vom Typ Dynmic kann
damit jeglichen Typ annehmen, der in Type t definiert ist. Gleichzeitig existiert
ein Typrepräsentant in Type t für einen solchen dynamischen Wert.
Der in Listing 3 eingeführte Typ Dynamic kann dazu verwendet werden, um
eine Liste zu generieren, die Werte verschiedenen Typs beinhaltet. Dadurch ist
ein Problem für printf bereits gelöst. Die Signatur aus Definition (4) kann nun
entsprechend angepasst werden:
printf :: String → [Dynamic] → M aybe String
(5)
Der nächste Schritt besteht darin, dynamisches Casting einzuführen. Der erwartete Typ, welcher durch die Eingabe festgelegt ist, muss mit dem Typen des
entsprechenden Listeneintrages verglichen werden. Dafür werden die Typrepräsentanten der Parameter mit den geforderten Typen auf syntaktische Gleichheit
hin überprüft. Die dazugehörige Haskellimplementierung findet sich in Listing 4
in Form der Funktion tequal.10 Die Signaturen der implizit verwendeten Funktionen sind in (7) angegeben.
tequal :: T ype t → T ype v → M aybe (t → v)
9 Die
(6)
Erstellung von dynamischen Werten in Haskell kann auch über sog. Smart Constructors erfolgen. Dabei wird eine Typklasse erstellt, die als Typcontainer fungiert, anschließend
ein Typ erstellt, dessen Datenkonstruktoren in Funktionen ausgelagert werden über die die
Typinformation nun, ähnlich zu Phantom Types, gekapselt ist.
10 In Haskell ist es möglich anhand eines Wertes den dazugehörigen Datenkonstruktor zu
ermitteln. Entsprechende Funktionen wie toConstr oder showConstr sind in den Typklassen Data und Typeable zusammengefasst. Über einen Vergleich Datenkonstruktor lassen sich
Typen dymanisch unterscheiden und damit ebenfalls ein Casting wie hier vorgestellt umsetzen. Für nähere Infos siehe http://www.haskell.org/ghc/docs/latest/html/libraries/
base/Data-Typeable.html
7
mapList
:: (a → b) → [a] → [b]
mapP air
:: (a → c) → (b → d) → (a, b) → (c, d)
lif tM
:: (M onad m) ⇒ (a → r) → m a → m r
lif tM 2
:: (M onad m) ⇒ (a → b → r) → m a → m b → m r
(7)
t e q u a l RInt RInt = return id
t e q u a l RDouble RDouble = return id
t e q u a l RChar RChar = return id
t e q u a l ( RList r a ) ( RList rb ) =
liftM mapList ( t e q u a l r a rb )
t e q u a l ( RPair r a rb ) ( RPair ra ’ rb ’ ) =
liftM2 mapPair ( t e q u a l r a ra ’ ) ( t e q u a l rb rb ’ )
t e q u a l _ _ = f a i l " t y p e s a r e not e q u a l "
−− B e i s p i e l a u f r u f
t e q u a l RInt RChar
// == False
t e q u a l ( RList RChar ) ( RList RChar ) // == True
Listing 4: Die Funktion tequal mit Beispielen
Doch mit dem Vergleich auf Gleichheit ist es noch nicht getan. Da der Listenparameter dynamische Werte enthält, müssen diese erst noch in statische
Werte umgewandelt werden. Die tequal Funktion liefert bei Typäquivalenz eine Funktion zurück, welche einen Wert eines Typs in einen Wert eines anderen
Typs castet. Diese Casting-Funktion muss lediglich noch angewandt werden auf
alle vorliegenden Parameter. Das übernimmt die Funktion cast. Ihre Signatur
ist in (8) definiert, der dazugehörige Haskellcode in Listing 5.
cast :: ∀t.Dynamic → T ype t → M aybe t
(8)
c a s t (Dyn r a a ) rb = fmap ( \ f −> f a )
( t e q u a l r a rb )
−− B e i s p i e l c o d e
c a s t (Dyn RInt 1 ) RInt // == Just 1
c a s t (Dyn ( RList RInt ) [ 1 ] ) ( RList RChar )
// == Nothing , C a s t i n g F e h l e r
Listing 5: Die Funktion cast mit Beispielen
Der letzte Schritt zur Programmierung von printf ist nun trivial. Der Eingabestring muss geparst werden, um die Tags herauszufiltern. Anschließend muss
die Casting Funktion angewendet werden und, bei erfolgreichem Typechecking,
das Stringäquivalent des Wertes an die Ausgabe angehängt werden.
3
Zusammenfassung und Ausblick
Es wurde gezeigt, dass sich Phantom Types dazu eignen dynamische Typisierung und generische Programmierung zusammen in das statische Typsystem
8
von Haskell zu integrieren, das lediglich um existentielle Typen erweitert werden musste. Nach [2] sind Phantom Types bisherigen Versuchen diese Konzepte
zusammenzubringen, wie z.B. equality types, in ihrer Einfachheit und Performance überlegen. Zudem sind sie leicht erweiterbar und vielseitig einsetzbar,
was das Beispiel printf anschaulich darlegen konnte.11 . In [3] und [7] wurden
weitere Einsatzmöglichkeiten von Phantom Types vorgestellt, die hier nicht aufgegriffen werden konnten.12 Möglichkeiten für Verbesserungen der vorgestellten
Phantom Types ergeben sich aus dem Vergleich mit Typklassen: Es ist nicht
möglich, eine Funktion, die über einen Phantom Type definiert ist, zu überladen, ohne dass dafür die Definition des Typs selbst geändert werden muss [1].
Dies ist bei Typklassen über das instance Konstrukt möglich.
Literatur
[1] James Cheney and Ralf Hinze. A lightweight implementation of generics and
dynamics. In Proceedings of the 2002 ACM SIGPLAN Haskell Workshop,
pages 90–104. ACM-Press, Oktober 2002.
[2] James Cheney and Ralf Hinze. Phantom types. Technical report, Cornell
University, May 2003.
[3] Ralf
Hinze.
Fun
with
phantom
http://www.comlab.ox.ac.uk/people/ralf.hinze/talks/FOP.pdf,
2003.
types.
März
[4] Richard B. Kieburtz. Automated soundness checking of a programming logic
for haskell. programatica.cs.pdx.edu/P/kieburtz.pdf, 2002.
[5] Jörg Kreiker. Vorlesung: Programming languages. Technische Unversität
München, Oktober 2010.
[6] Daan Leijen and Erik Meijer. Domain-specific embedded compilers. In Proceedings of the 2nd Conference on Domain-Specific-Languages, pages 109–
122, Oktober 1999.
[7] Mark Shields and Simon Peyton Jones. Object-oriented style overloading
for haskell, 2001.
11 Eine alternative Umsetzung von printf wurde unter http://www.comlab.ox.ac.uk/
people/ralf.hinze/talks/FOP.pdf veröffentlicht.
12 Es wurde beispielsweise eine universelle Traversierungsfunktion everywhere definiert, die
mit der Funktion fmap der Functor Klasse in Haskell vergleichbar ist.
9
Herunterladen