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