Der Name garantiert keine Sicherheit. Haskell und Typensicherheit

Haskell-Entwickler sprechen viel über Typensicherheit. Die Haskell-Entwicklergemeinschaft befürwortet die Idee, "eine Invariante auf der Ebene des Typsystems zu beschreiben" und "ungültige Zustände auszuschließen". Klingt nach einem inspirierenden Ziel! Es ist jedoch nicht ganz klar, wie dies erreicht werden soll. Vor fast einem Jahr veröffentlichte ich einen Artikel "Parsen, nicht validieren" - der erste Schritt, um diese Lücke zu schließen.



Dem Artikel folgten produktive Diskussionen, aber wir konnten nie einen Konsens über die korrekte Verwendung des Newtype-Konstrukts in Haskell erzielen. Die Idee ist einfach genug: Das Schlüsselwort newtype deklariert einen Wrapper-Typ, dessen Name sich unterscheidet, der jedoch repräsentativ dem Typ entspricht, den er umschließt. Auf den ersten Blick ist dies ein verständlicher Weg, um Typensicherheit zu erreichen. Überlegen Sie beispielsweise, wie Sie mithilfe einer Newtype-Deklaration den Typ einer E-Mail-Adresse definieren:



newtype EmailAddress = EmailAddress Text
      
      





Dieser Trick gibt uns eine gewisse Bedeutung und kann in Kombination mit einem intelligenten Konstruktor und einer Kapselungsgrenze sogar Sicherheit bieten. Dies ist jedoch eine völlig andere Art der Typensicherheit. Es ist viel schwächer und unterscheidet sich von dem, das ich vor einem Jahr identifiziert habe. Newtype ist für sich genommen nur ein Alias.



Namen sind keine Typensicherheit ©



Interne und externe Sicherheit



Schauen wir uns ein Beispiel an, um den Unterschied zwischen konstruktiver Datenmodellierung (mehr dazu im vorherigen Artikel ) und Newtype-Wrappern zu zeigen. Angenommen, wir möchten den Typ "Ganzzahl von 1 bis einschließlich 5". Ein natürlicher Ansatz zur konstruktiven Modellierung ist die Aufzählung mit fünf Fällen:



data OneToFive
  = One
  | Two
  | Three
  | Four
  | Five
      
      





Dann würden wir mehrere Funktionen schreiben, um zwischen Int und dem OneToFive-Typ zu konvertieren:



toOneToFive :: Int -> Maybe OneToFive
toOneToFive 1 = Just One
toOneToFive 2 = Just Two
toOneToFive 3 = Just Three
toOneToFive 4 = Just Four
toOneToFive 5 = Just Five
toOneToFive _ = Nothing

fromOneToFive :: OneToFive -> Int
fromOneToFive One   = 1
fromOneToFive Two   = 2
fromOneToFive Three = 3
fromOneToFive Four  = 4
fromOneToFive Five  = 5
      
      





Dies würde völlig ausreichen, um das erklärte Ziel zu erreichen, aber in Wirklichkeit ist es unpraktisch, mit einer solchen Technologie zu arbeiten. Da wir einen völlig neuen Typ erfunden haben, können wir die üblichen numerischen Funktionen von Haskell nicht wiederverwenden. Daher würden viele Entwickler lieber den Newtype-Wrapper verwenden:



newtype OneToFive = OneToFive Int
      
      





Wie im ersten Fall können wir Funktionen zu OneToFive und fromOneToFive mit identischen Typen deklarieren:



toOneToFive :: Int -> Maybe OneToFive
toOneToFive n
  | n >= 1 && n <= 5 = Just $ OneToFive n
  | otherwise        = Nothing

fromOneToFive :: OneToFive -> Int
fromOneToFive (OneToFive n) = n
      
      





Wenn wir diese Deklarationen in ein separates Modul einfügen und den OneToFive-Konstruktor nicht exportieren, sind die APIs vollständig austauschbar. Es scheint, dass die Option newtype einfacher und typsicherer ist. Dies ist jedoch nicht ganz richtig.



Stellen wir uns vor, wir schreiben eine Funktion, die den OneToFive-Wert als Argument verwendet. Bei der konstruktiven Modellierung erfordert eine solche Funktion einen Mustervergleich mit jedem der fünf Konstruktoren. Der GHC akzeptiert die Definition als ausreichend:



ordinal :: OneToFive -> Text
ordinal One   = "first"
ordinal Two   = "second"
ordinal Three = "third"
ordinal Four  = "fourth"
ordinal Five  = "fifth"
      
      





Die Newtype-Anzeige ist anders. Newtype ist undurchsichtig, daher besteht die einzige Möglichkeit, dies zu beobachten, darin, es wieder in Int zu konvertieren. Natürlich kann Int neben 1-5 noch viele andere Werte enthalten, daher müssen wir ein Muster für den Rest der möglichen Werte hinzufügen.



ordinal :: OneToFive -> Text
ordinal n = case fromOneToFive n of
  1 -> "first"
  2 -> "second"
  3 -> "third"
  4 -> "fourth"
  5 -> "fifth"
  _ -> error "impossible: bad OneToFive value"
      
      





In diesem fiktiven Beispiel wird das Problem möglicherweise nicht angezeigt. Es zeigt jedoch einen wesentlichen Unterschied in den Garantien, die die beiden beschriebenen Ansätze bieten:



  • Ein konstruktiver Datentyp fixiert seine Invarianten so, dass sie für die weitere Interaktion zur Verfügung stehen. Dies befreit die Ordnungsfunktion von der Behandlung ungültiger Werte, da diese nicht mehr ausgedrückt werden können.
  • Der Newtype-Wrapper bietet einen intelligenten Konstruktor, der den Wert validiert. Das boolesche Ergebnis dieser Validierung wird jedoch nur für den Kontrollfluss verwendet. es wird aufgrund der Funktion nicht gespeichert. Dementsprechend können wir das Ergebnis dieser Prüfung und die eingeführten Einschränkungen nicht weiter verwenden, da wir bei der nachfolgenden Ausführung mit dem Int-Typ interagieren.


Die Überprüfung auf Vollständigkeit scheint ein unnötiger Schritt zu sein, ist es jedoch nicht: Das Ausnutzen von Fehlern hat auf Schwachstellen in unserem Typsystem hingewiesen. Wenn wir dem OneToFive-Datentyp einen weiteren Konstruktor hinzufügen würden, wäre die Version der Ordnungszahl, die den konstruktiven Datentyp verwendet, zur Kompilierungszeit sofort nicht vollständig. In der Zwischenzeit würde eine andere Version, die den Newtype-Wrapper verwendet, weiter kompiliert, aber zur Laufzeit unterbrochen und zu einem unmöglichen Szenario übergehen.



Dies alles ist eine Folge der Tatsache, dass konstruktive Modellierung von Natur aus typsicher ist. Das heißt, die Sicherheitseigenschaften werden durch die Typdeklaration bereitgestellt. Ungültige Werte können in der Tat nicht dargestellt werden: Sie können 6 mit keinem der 5 Konstruktoren anzeigen.



Dies gilt nicht für die Newtype-Deklaration, da sie keinen intrinsischen semantischen Unterschied zu Int aufweist. Sein Wert wird extern durch den cleveren Konstruktor toOneToFive angegeben. Jeder durch den Newtype implizierte semantische Unterschied ist für das Typsystem unsichtbar. Der Entwickler denkt nur daran.



Nicht leere Listen erneut besuchen



Der OneToFive-Datentyp wurde erfunden, ähnliche Überlegungen gelten jedoch auch für andere realistischere Szenarien. Betrachten Sie das NonEmpty, über das ich zuvor geschrieben habe:



data NonEmpty a = a :| [a]
      
      





Stellen wir uns zur Verdeutlichung die Version von NonEmpty vor, die über knowtype deklariert wurde, im Vergleich zu regulären Listen. Wir können die übliche Strategie für intelligente Konstruktoren verwenden, um die gewünschte Nicht-Leereigenschaft bereitzustellen:



newtype NonEmpty a = NonEmpty [a]

nonEmpty :: [a] -> Maybe (NonEmpty a)
nonEmpty [] = Nothing
nonEmpty xs = Just $ NonEmpty xs

instance Foldable NonEmpty where
  toList (NonEmpty xs) = xs
      
      





Wie bei OneToFive werden wir schnell feststellen, welche Konsequenzen es hat, wenn diese Informationen nicht im Typsystem gespeichert werden können. Wir wollten NonEmpty verwenden, um eine sichere Version von head zu schreiben, aber die Newtype-Version erfordert eine andere Anweisung:



head :: NonEmpty a -> a
head xs = case toList xs of
  x:_ -> x
  []  -> error "impossible: empty NonEmpty value"
      
      





Es scheint keine Rolle zu spielen: Die Wahrscheinlichkeit, dass eine solche Situation eintreten könnte, ist so unwahrscheinlich. Ein solches Argument hängt jedoch ausschließlich vom Glauben an die Richtigkeit des Moduls ab, das das NonEmpty definiert, während die konstruktive Definition nur das Vertrauen in die GHC-Typprüfung erfordert. Da wir standardmäßig davon ausgehen, dass die Typprüfung ordnungsgemäß funktioniert, ist letzteres ein überzeugenderer Beweis.



Newtypes als Token



Wenn Sie neue Typen lieben, kann dieses Thema frustrierend sein. Ich meine nicht, dass Newtypes besser sind als Kommentare, obwohl letztere für die Typprüfung effektiv sind. Glücklicherweise ist die Situation nicht so schlimm: Neue Typen können eine schwächere Sicherheit bieten.



Abstraktionsgrenzen bieten neuen Typen einen enormen Sicherheitsvorteil. Wenn der Newtype-Konstruktor nicht exportiert wird, wird er für andere Module undurchsichtig. Ein Modul, das einen neuen Typ definiert (dh ein "Home-Modul"), kann dies nutzen, um eine Vertrauensgrenze zu erstellen, an der interne Invarianten erzwungen werden, indem Clients auf eine sichere API beschränkt werden.



Wir können das obige NonEmpty-Beispiel verwenden, um diese Technologie zu veranschaulichen. Lassen Sie uns zunächst den NonEmpty-Konstruktor nicht exportieren und die Head- und Tail-Operationen bereitstellen. Wir glauben, dass sie richtig funktionieren:



module Data.List.NonEmpty.Newtype
  ( NonEmpty
  , cons
  , nonEmpty
  , head
  , tail
  ) where

newtype NonEmpty a = NonEmpty [a]

cons :: a -> [a] -> NonEmpty a
cons x xs = NonEmpty (x:xs)

nonEmpty :: [a] -> Maybe (NonEmpty a)
nonEmpty [] = Nothing
nonEmpty xs = Just $ NonEmpty xs

head :: NonEmpty a -> a
head (NonEmpty (x:_)) = x
head (NonEmpty [])    = error "impossible: empty NonEmpty value"

tail :: NonEmpty a -> [a]
tail (NonEmpty (_:xs)) = xs
tail (NonEmpty [])     = error "impossible: empty NonEmpty value"
      
      





Da die einzige Möglichkeit zum Erstellen oder Verwenden von NonEmpty-Werten die Verwendung von Funktionen in der exportierten Data.List.NonEmpty-API ist, verhindert die obige Implementierung, dass Clients die Nicht-Leere-Invariante verletzen. Die Werte von undurchsichtigen neuen Typen sind wie Token: Das implementierende Modul gibt Token über seine Konstruktorfunktionen aus, und diese Token haben keine interne Bedeutung. Die einzige Möglichkeit, etwas Nützliches mit ihnen zu tun, besteht darin, sie Funktionen im Modul zur Verfügung zu stellen, die sie verwenden, und die darin enthaltenen Werte abzurufen. In diesem Fall sind diese Funktionen Kopf und Schwanz.



Dieser Ansatz ist weniger effizient als die Verwendung eines konstruktiven Datentyps, da er falsch sein und versehentlich die Möglichkeit bieten kann, einen ungültigen NonEmpty [] -Wert zu erstellen. Aus diesem Grund ist der Newtype-Ansatz zur Typensicherheit an sich kein Beweis dafür, dass die gewünschte Invariante gilt.



Dieser Ansatz begrenzt jedoch den Bereich, in dem die invariante Verletzung für das definierende Modul auftreten kann. Um sicherzustellen, dass die Invariante tatsächlich gültig ist, muss die Modul-API mithilfe von Fuzzing-Techniken oder anhand von Eigenschaften getestet werden.



Dieser Kompromiss kann äußerst nützlich sein. Es ist schwierig, Invarianten mithilfe einer konstruktiven Datenmodellierung zu garantieren, daher ist dies nicht immer praktisch. Wir müssen jedoch darauf achten, nicht versehentlich einen Mechanismus zum Brechen der Invariante bereitzustellen. Ein Entwickler kann beispielsweise die GHC-Convenience-Typklasse nutzen, die sich aus der generischen Typklasse für NonEmpty ableitet:



{-# LANGUAGE DeriveGeneric #-}

import GHC.Generics (Generic)

newtype NonEmpty a = NonEmpty [a]
  deriving (Generic)
      
      





Nur eine Zeile bietet einen einfachen Mechanismus zum Überqueren der Abstraktionsgrenze:



ghci> GHC.Generics.to @(NonEmpty ()) (M1 $ M1 $ M1 $ K1 [])
NonEmpty []
      
      





Dieses Beispiel ist in der Praxis nicht möglich, da abgeleitete generische Instanzen die Abstraktion grundlegend unterbrechen. Darüber hinaus kann ein solches Problem unter anderen, weniger offensichtlichen Bedingungen auftreten. Zum Beispiel mit einer abgeleiteten Read-Instanz:



ghci> read @(NonEmpty ()) "NonEmpty []"
NonEmpty []
      
      





Für einige Leser mögen diese Fallen alltäglich erscheinen, aber solche Schwachstellen sind sehr häufig. Insbesondere für Datentypen mit komplexeren Invarianten, da es manchmal schwierig ist festzustellen, ob sie von einer Modulimplementierung unterstützt werden. Die ordnungsgemäße Anwendung dieser Methode erfordert Sorgfalt und Aufmerksamkeit:



  • Alle Invarianten müssen den Betreuern des vertrauenswürdigen Moduls klar sein. Für einfache Typen wie NonEmpty ist die Invariante offensichtlich, für komplexere Typen sind jedoch Kommentare erforderlich.
  • Jede Änderung an einem vertrauenswürdigen Modul muss überprüft werden, da dies die gewünschten Invarianten schwächen kann.
  • Sie sollten keine unsicheren Lücken hinzufügen, die bei Missbrauch Invarianten gefährden könnten.
  • Möglicherweise ist ein regelmäßiges Refactoring erforderlich, um den vertrauenswürdigen Bereich klein zu halten. Andernfalls steigt mit der Zeit die Wahrscheinlichkeit einer Interaktion stark an, was zu einer Verletzung der Invariante führt.


Gleichzeitig haben Datentypen, die aufgrund ihrer Konstruktion korrekt sind, keine der oben genannten Probleme. Die Invariante kann nicht verletzt werden, ohne die Definition des Datentyps zu ändern. Dies wirkt sich auf den Rest des Programms aus. Es ist kein Entwickleraufwand erforderlich, da die Typprüfung automatisch Invarianten anwendet. Für diese Datentypen gibt es keinen "vertrauenswürdigen Code", da alle Teile des Programms gleichermaßen den durch den Datentyp auferlegten Einschränkungen unterliegen.



In Bibliotheken ist es sinnvoll, ein neues Sicherheitskonzept (dank des neuen Typs) durch Kapselung zu verwenden, da Bibliotheken häufig Bausteine ​​zur Erstellung komplexerer Datenstrukturen bereitstellen. Solche Bibliotheken erhalten normalerweise mehr Studien und Prüfungen als Anwendungscode, zumal sie sich viel seltener ändern.



Im Anwendungscode sind diese Techniken immer noch nützlich, aber Änderungen in der Produktionscodebasis im Laufe der Zeit schwächen die Grenzen der Kapselung, sodass das Design nach Möglichkeit bevorzugt werden sollte.



Andere Verwendungen von Newtype, Missbrauch und Missbrauch



Im vorherigen Abschnitt werden die Hauptverwendungen für Newtype beschrieben. In der Praxis werden neue Typen jedoch normalerweise anders verwendet als oben beschrieben. Einige dieser Anträge sind gerechtfertigt, zum Beispiel:



  • In Haskell beschränkt die Idee der Typklassenkonsistenz jeden Typ auf eine Instanz einer Klasse. Für Typen, die mehr als eine nützliche Instanz zulassen, ist newtypes die traditionelle Lösung und kann erfolgreich verwendet werden. Beispiel: Neue Typen Summe und Produkt aus Data.Monoid bieten nützliche Monoid-Instanzen für numerische Typen.
  • Ebenso können neue Typen verwendet werden, um Typparameter einzufügen oder zu ändern. Newtype Flip from Data.Bifunctor.Flip ist ein einfaches Beispiel, bei dem die Bifunctor-Argumente ausgetauscht werden, damit die Functor-Instanz mit der umgekehrten Reihenfolge der Argumente arbeiten kann:


newtype Flip p a b = Flip { runFlip :: p b a }
      
      





Für diese Art der Manipulation sind neue Typen erforderlich, da Haskell Lambda-Ausdrücke auf Typebene noch nicht unterstützt.



  • Transparente neue Typen können verwendet werden, um Missbrauch zu verhindern, wenn ein Wert zwischen entfernten Teilen eines Programms übergeben werden muss und es keinen Grund für Zwischencode gibt, den Wert zu validieren. Beispielsweise kann ein ByteString, der einen geheimen Schlüssel enthält, in einen neuen Typ eingeschlossen werden (wobei die Show-Instanz ausgeschlossen ist), um zu verhindern, dass Code versehentlich protokolliert oder auf andere Weise verfügbar gemacht wird.


Alle diese Praktiken sind gut, haben aber nichts mit der Typensicherheit zu tun. Der letzte Punkt wird oft mit Sicherheit verwechselt und verwendet ein Typsystem, um logische Fehler zu vermeiden. Es wäre jedoch falsch zu argumentieren, dass eine solche Verwendung Missbrauch verhindert; Jeder Teil des Programms kann den Wert jederzeit überprüfen.



Zu oft führt diese Illusion von Sicherheit zu einem offensichtlichen Missbrauch des Newtyps. Hier ist zum Beispiel eine Definition aus einer Codebasis, mit der ich persönlich arbeite:



newtype ArgumentName = ArgumentName { unArgumentName :: GraphQL.Name }
  deriving ( Show, Eq, FromJSON, ToJSON, FromJSONKey, ToJSONKey
           , Hashable, ToTxt, Lift, Generic, NFData, Cacheable )
      
      





In diesem Fall ist Newtype ein sinnloser Schritt. Funktionell ist es vollständig mit dem Namenstyp austauschbar, so dass ein Dutzend Typklassen erzeugt werden! Wo immer ein neuer Typ verwendet wird, wird er sofort erweitert, sobald er aus dem Abschlussdatensatz abgerufen wird. In diesem Fall hat die Typensicherheit also keinen Vorteil. Darüber hinaus ist nicht klar, warum Newtype als ArgumentName festgelegt werden soll, wenn der Feldname seine Rolle bereits klarstellt.



Es scheint mir, dass diese Verwendung neuer Typen aus dem Wunsch heraus entsteht, das Typensystem als einen Weg der Taxonomie (Klassifikation) der Welt zu verwenden. Der Argumentname ist spezifischer als der generische Name, daher muss er natürlich einen eigenen Typ haben. Diese Aussage ist sinnvoll, aber falsch: Taxonomie ist nützlich, um einen Interessenbereich zu dokumentieren, aber nicht unbedingt, um ihn zu modellieren. Bei der Programmierung verwenden wir Typen für verschiedene Zwecke:



  • In erster Linie heben Typen die funktionalen Unterschiede zwischen Werten hervor. Ein Wert vom Typ NonEmpty a unterscheidet sich funktional von einem Wert vom Typ [a], da er sich in seiner Struktur grundlegend unterscheidet und zusätzliche Operationen ermöglicht. In diesem Sinne sind Typen strukturell; Sie beschreiben, welche Werte in der Programmiersprache enthalten sind.
  • -, , . Distance Duration, - , , .


Beachten Sie, dass diese beiden Ziele pragmatisch sind. Sie verstehen das Typensystem als Werkzeug. Dies ist eine ziemlich natürliche Einstellung, da das statische Typsystem buchstäblich ein Werkzeug ist. Trotzdem erscheint uns diese Sichtweise ungewöhnlich, obwohl die Verwendung von Typen zur Klassifizierung der Welt normalerweise nutzloses Rauschen wie ArgumentName erzeugt.



Es ist wahrscheinlich nicht sehr praktisch, wenn der Newtype vollständig transparent ist und wie gewünscht wieder eingewickelt und bereitgestellt wird. In diesem speziellen Fall würde ich die Unterscheidung vollständig ausschließen und Name verwenden, aber in Situationen, in denen unterschiedliche Bezeichnungen klar sind, können Sie immer den Alias-Typ verwenden:



type ArgumentName = GraphQL.Name
      
      





Diese neuen Typen sind echte Muscheln. Das Überspringen mehrerer Schritte ist nicht typsicher. Vertrauen Sie mir, Entwickler werden gerne ohne einen zweiten Gedanken überspringen.



Schlussfolgerung und empfohlene Lektüre



Ich wollte schon lange einen Artikel zu diesem Thema schreiben. Dies ist wahrscheinlich ein sehr ungewöhnlicher Tipp über Newtypes in Haskell. Ich habe beschlossen, es so zu erzählen, weil ich selbst meinen Lebensunterhalt mit Haskell verdiene und in der Praxis ständig mit ähnlichen Problemen konfrontiert bin. In der Tat ist die Hauptidee viel tiefer.



Newtypes ist einer der Mechanismen zum Definieren von Wrapper-Typen. Dieses Konzept gibt es in fast jeder Sprache, auch in solchen, die dynamisches Tippen verwenden. Wenn Sie Haskell nicht schreiben, trifft ein Großteil dieses Artikels wahrscheinlich auf die Sprache Ihrer Wahl zu. Wir können sagen, dass dies eine Fortsetzung einer Idee ist, die ich im vergangenen Jahr auf unterschiedliche Weise zu vermitteln versucht habe: Typsysteme sind Werkzeuge. Wir müssen bewusster sein und uns darauf konzentrieren, welche Typen tatsächlich bieten und wie wir sie effektiv einsetzen können.



Der Grund für das Schreiben dieses Artikels war der kürzlich veröffentlichte Artikel Tagged ist kein Newtype... Dies ist ein großartiger Beitrag und ich teile die Hauptidee voll und ganz. Aber ich dachte, der Autor verpasste die Gelegenheit, einen ernsteren Gedanken zu äußern. Tatsächlich ist Tagged per Definition ein neuer Typ, sodass der Titel des Artikels uns auf den falschen Weg führt. Das eigentliche Problem geht etwas tiefer.



Neue Typen sind nützlich, wenn sie sorgfältig angewendet werden, aber Sicherheit ist nicht ihre Standardeigenschaft. Wir glauben nicht, dass der Kunststoff, aus dem der Verkehrskegel besteht, für sich genommen Verkehrssicherheit bietet. Es ist wichtig, den Kegel in den richtigen Kontext zu stellen! Ohne dieselbe Klausel ist newtypes nur eine Bezeichnung, eine Möglichkeit, einen Namen zu vergeben.



Und der Name ist nicht typsicher!



All Articles