L'article a été suivi de discussions fructueuses, mais nous n'avons jamais été en mesure de parvenir à un consensus sur l'utilisation correcte de la construction newtype dans Haskell. L'idée est assez simple: le mot-clé newtype déclare un type de wrapper dont le nom est différent mais qui est représentativement équivalent au type qu'il encapsule. À première vue, il s'agit d'une manière compréhensible d'atteindre la sécurité de type. Par exemple, considérez comment utiliser une déclaration newtype pour définir le type d'une adresse e-mail:
newtype EmailAddress = EmailAddress Text
Cette astuce nous donne un sens, et lorsqu'elle est combinée avec un constructeur intelligent et une limite d'encapsulation, elle peut même fournir une sécurité. Mais c'est un type de sécurité de type complètement différent. C'est beaucoup plus faible et différent de celui que j'ai identifié il y a un an. En soi, newtype n'est qu'un alias.
Les noms ne sont pas de type sécurité ©
Sécurité interne et externe
Pour montrer la différence entre la modélisation de données constructive (plus à ce sujet dans l' article précédent ) et les wrappers newtype, regardons un exemple. Supposons que nous voulions le type "entier de 1 à 5 inclus". Une approche naturelle de la modélisation constructive est l'énumération avec cinq cas:
data OneToFive
= One
| Two
| Three
| Four
| Five
Ensuite, nous écririons plusieurs fonctions à convertir entre Int et le type OneToFive:
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
Ce serait tout à fait suffisant pour atteindre l'objectif déclaré, mais en réalité, il n'est pas pratique de travailler avec une telle technologie. Depuis que nous avons inventé un type complètement nouveau, nous ne pouvons pas réutiliser les fonctions numériques habituelles fournies par Haskell. Par conséquent, de nombreux développeurs préféreraient utiliser le wrapper newtype à la place:
newtype OneToFive = OneToFive Int
Comme dans le premier cas, nous pouvons déclarer des fonctions toOneToFive et fromOneToFive avec des types identiques:
toOneToFive :: Int -> Maybe OneToFive
toOneToFive n
| n >= 1 && n <= 5 = Just $ OneToFive n
| otherwise = Nothing
fromOneToFive :: OneToFive -> Int
fromOneToFive (OneToFive n) = n
Si nous mettons ces déclarations dans un module séparé et choisissons de ne pas exporter le constructeur OneToFive, les API sont complètement interchangeables. Il semble que l'option newtype soit plus simple et plus sûre de type. Cependant, ce n'est pas tout à fait vrai.
Imaginons que nous écrivions une fonction qui prend la valeur OneToFive comme argument. Dans la modélisation constructive, une telle fonction nécessite une correspondance de modèles avec chacun des cinq constructeurs. Le GHC acceptera la définition comme suffisante:
ordinal :: OneToFive -> Text
ordinal One = "first"
ordinal Two = "second"
ordinal Three = "third"
ordinal Four = "fourth"
ordinal Five = "fifth"
L'affichage du nouveau type est différent. Newtype est opaque, donc la seule façon de l'observer est de reconvertir en Int. Bien sûr, Int peut contenir de nombreuses autres valeurs en plus de 1 à 5, nous devons donc ajouter un modèle pour le reste des valeurs possibles.
ordinal :: OneToFive -> Text
ordinal n = case fromOneToFive n of
1 -> "first"
2 -> "second"
3 -> "third"
4 -> "fourth"
5 -> "fifth"
_ -> error "impossible: bad OneToFive value"
Dans cet exemple fictif, vous pourriez ne pas voir le problème. Mais cela démontre néanmoins une différence essentielle dans les garanties apportées par les deux approches décrites:
- Un type de données constructif fixe ses invariants de manière à ce qu'ils soient disponibles pour une interaction ultérieure. Cela libère la fonction ordinale de la gestion des valeurs invalides, car elles ne sont plus exprimables.
- Le wrapper newtype fournit un constructeur intelligent qui valide la valeur, mais le résultat booléen de cette validation n'est utilisé que pour le flux de contrôle; il n'est pas enregistré en raison de la fonction. Par conséquent, nous ne pouvons plus utiliser le résultat de cette vérification et les restrictions introduites; lors de l'exécution ultérieure, nous interagissons avec le type Int.
Vérifier l'exhaustivité peut sembler une étape inutile, mais ce n'est pas le cas: l'exploitation de bogues a mis en évidence des vulnérabilités dans notre système de types. Si nous devions ajouter un autre constructeur au type de données OneToFive, la version de l'ordinal qui consomme le type de données constructif serait immédiatement non exhaustive au moment de la compilation. En attendant, une autre version qui utilise le wrapper newtype continuerait à se compiler, mais s'arrêterait au moment de l'exécution et passerait à un scénario impossible.
Tout cela est une conséquence du fait que la modélisation constructive est intrinsèquement sûre de type; autrement dit, les propriétés de sécurité sont fournies par la déclaration de type. Les valeurs invalides sont en effet impossibles à représenter: vous ne pouvez pas afficher 6 en utilisant l'un des 5 constructeurs.
Cela ne s'applique pas à la déclaration newtype, car elle n'a pas de différence sémantique intrinsèque avec Int; sa valeur est spécifiée en externe via le constructeur intelligent toOneToFive. Toute différence sémantique impliquée par le nouveau type est invisible pour le système de types. Le développeur garde cela à l'esprit.
Revisiter les listes non vides
Le type de données OneToFive est inventé, mais des considérations similaires s'appliquent à d'autres scénarios plus réalistes. Considérez le NonEmpty dont j'ai parlé plus tôt:
data NonEmpty a = a :| [a]
Pour plus de clarté, imaginons la version de NonEmpty, déclarée via knowtype, par rapport aux listes régulières. Nous pouvons utiliser la stratégie de constructeur intelligente habituelle pour fournir la propriété de non-vide souhaitée:
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
Comme avec OneToFive, nous découvrirons rapidement les conséquences de ne pas pouvoir stocker ces informations dans le système de types. Nous voulions utiliser NonEmpty pour écrire une version sûre de head, mais la version newtype nécessite une instruction différente:
head :: NonEmpty a -> a
head xs = case toList xs of
x:_ -> x
[] -> error "impossible: empty NonEmpty value"
Cela ne semble pas avoir d’importance: la probabilité qu’une telle situation se produise est si peu probable. Mais un tel argument dépend entièrement de la croyance en l'exactitude du module qui définit le NonEmpty, alors que la définition constructive ne nécessite que de faire confiance à la vérification de type GHC. Puisque nous supposons par défaut que la vérification de type fonctionne correctement, cette dernière est une preuve plus convaincante.
Newtypes comme jetons
Si vous aimez les nouveaux types, ce sujet peut être frustrant. Je ne veux pas dire que les nouveaux types sont meilleurs que les commentaires, bien que ces derniers soient efficaces pour la vérification de type. Heureusement, la situation n'est pas si mauvaise: les nouveaux types peuvent fournir une sécurité plus faible.
Les limites d'abstraction donnent aux nouveaux types un énorme avantage de sécurité. Si le constructeur newtype n'est pas exporté, il devient opaque pour les autres modules. Un module qui définit un nouveau type (c'est-à-dire un «module d'accueil») peut en profiter pour créer une limite de confiance où les invariants internes sont appliqués en limitant les clients à une API sécurisée.
Nous pouvons utiliser l'exemple NonEmpty ci-dessus pour illustrer cette technologie. Pour l'instant, abstenons-nous d'exporter le constructeur NonEmpty et fournissons les opérations head et tail. Nous pensons qu'ils fonctionnent correctement:
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"
Étant donné que la seule façon de créer ou d'utiliser des valeurs NonEmpty est d'utiliser des fonctions dans l'API Data.List.NonEmpty exportée, l'implémentation ci-dessus empêche les clients de violer l'invariant de non-vide. Les valeurs des newtypes opaques sont comme des jetons: le module d'implémentation émet des jetons via ses fonctions de constructeur, et ces jetons n'ont aucune signification interne. La seule façon de faire quelque chose d'utile avec eux est de les rendre disponibles aux fonctions du module qui les utilisent et de récupérer les valeurs qu'ils contiennent. Dans ce cas, ces fonctions sont la tête et la queue.
Cette approche est moins efficace que l'utilisation d'un type de données constructif car elle pourrait être erronée et fournir accidentellement un moyen de créer une valeur NonEmpty [] non valide. Pour cette raison, l'approche newtype de la sécurité de type n'est pas en elle-même la preuve que l'invariant souhaité est vrai.
Cependant, cette approche limite la zone où la violation invariante pour le module de définition peut se produire. Pour être sûr que l'invariant tient réellement, il est nécessaire de tester l'API du module en utilisant des techniques de fuzzing ou en fonction des propriétés.
Ce compromis peut être extrêmement utile. Il est difficile de garantir des invariants en utilisant une modélisation de données constructive, ce n'est donc pas toujours pratique. Cependant, nous devons faire attention à ne pas fournir accidentellement un mécanisme pour casser l'invariant. Par exemple, un développeur peut tirer parti de la classe de types de commodité GHC qui dérive de la classe de types générique pour NonEmpty:
{-# LANGUAGE DeriveGeneric #-}
import GHC.Generics (Generic)
newtype NonEmpty a = NonEmpty [a]
deriving (Generic)
Une seule ligne fournit un mécanisme simple pour traverser la limite d'abstraction:
ghci> GHC.Generics.to @(NonEmpty ()) (M1 $ M1 $ M1 $ K1 [])
NonEmpty []
Cet exemple n'est pas possible en pratique, car les instances génériques dérivées rompent fondamentalement l'abstraction. De plus, un tel problème peut survenir dans d'autres conditions moins évidentes. Par exemple, avec une instance de lecture dérivée:
ghci> read @(NonEmpty ()) "NonEmpty []"
NonEmpty []
Pour certains lecteurs, ces pièges peuvent sembler courants, mais ces vulnérabilités sont très courantes. Surtout pour les types de données avec des invariants plus complexes, car il est parfois difficile de déterminer s'ils sont pris en charge par une implémentation de module. Une bonne utilisation de cette méthode nécessite des soins et une attention:
- Tous les invariants doivent être clairs pour les responsables du module de confiance. Pour les types simples tels que NonEmpty, l'invariant est évident, mais pour les types plus complexes, des commentaires sont nécessaires.
- Chaque modification apportée à un module de confiance doit être vérifiée car elle peut affaiblir les invariants souhaités.
- Vous devez vous abstenir d'ajouter des failles dangereuses qui pourraient compromettre les invariants si elles sont mal utilisées.
- Une refactorisation périodique peut être nécessaire pour conserver la petite zone de confiance. Sinon, au fil du temps, la probabilité d'interaction augmentera fortement, ce qui entraînera une violation de l'invariant.
Dans le même temps, les types de données qui sont corrects par leur construction ne présentent aucun des problèmes ci-dessus. L'invariant ne peut pas être violé sans changer la définition du type de données, cela affecte le reste du programme. Aucun effort du développeur n'est requis car la vérification de type applique automatiquement les invariants. Il n'y a pas de «code de confiance» pour ces types de données, puisque toutes les parties du programme sont également soumises aux restrictions imposées par le type de données.
Dans les bibliothèques, il est logique d'utiliser un nouveau concept de sécurité (grâce au newtype) par l'encapsulation, car les bibliothèques fournissent souvent des blocs de construction utilisés pour créer des structures de données plus complexes. De telles bibliothèques reçoivent généralement plus d'étude et d'examen que le code d'application, d'autant plus qu'elles changent beaucoup moins fréquemment.
Dans le code d'application, ces techniques sont toujours utiles, mais les changements dans la base de code de production au fil du temps affaiblissent les limites de l'encapsulation, de sorte que la conception doit être préférée lorsque cela est possible.
Autres utilisations du nouveau type, abus et mauvaise utilisation
La section précédente décrit les principales utilisations de newtype. Cependant, dans la pratique, les nouveaux types sont généralement utilisés différemment de ce que nous avons décrit ci-dessus. Certaines de ces applications sont justifiées, par exemple:
- Dans Haskell, l'idée de cohérence des classes de types restreint chaque type à une instance de n'importe quelle classe. Pour les types qui autorisent plusieurs instances utiles, newtypes est la solution traditionnelle et peut être utilisé avec succès. Par exemple, les nouveaux types Sum et Product from Data.Monoid fournissent des instances Monoid utiles pour les types numériques.
- De même, les nouveaux types peuvent être utilisés pour injecter ou modifier les paramètres de type. Newtype Flip de Data.Bifunctor.Flip est un exemple simple qui permute les arguments Bifunctor afin que l'instance Functor puisse fonctionner avec l'ordre inverse des arguments:
newtype Flip p a b = Flip { runFlip :: p b a }
Les nouveaux types sont nécessaires pour ce type de manipulation car Haskell ne prend pas encore en charge les expressions lambda au niveau du type.
- Les nouveaux types transparents peuvent être utilisés pour éviter les abus lorsqu'une valeur doit être transmise entre des parties distantes d'un programme et qu'il n'y a aucune raison pour que le code intermédiaire valide la valeur. Par exemple, une ByteString contenant une clé secrète peut être encapsulée dans un nouveau type (avec l'instance Show exclue) pour éviter que le code ne soit accidentellement consigné ou exposé d'une autre manière.
Toutes ces pratiques sont bonnes, mais elles n'ont rien à voir avec la sécurité des types. Le dernier point est souvent confondu avec la sécurité et il utilise un système de types pour éviter les erreurs logiques. Cependant, il serait erroné de prétendre qu'une telle utilisation empêche les abus; n'importe quelle partie du programme peut vérifier la valeur à tout moment.
Trop souvent, cette illusion de sécurité conduit à des abus flagrants du nouveau type. Par exemple, voici une définition d'une base de code avec laquelle je travaille personnellement:
newtype ArgumentName = ArgumentName { unArgumentName :: GraphQL.Name }
deriving ( Show, Eq, FromJSON, ToJSON, FromJSONKey, ToJSONKey
, Hashable, ToTxt, Lift, Generic, NFData, Cacheable )
Dans ce cas, newtype est une étape inutile. Fonctionnellement, il est totalement interchangeable avec le type Name, à tel point qu'il produit une dizaine de classes de types! Partout où newtype est utilisé, il se développe immédiatement dès qu'il est extrait de l'enregistrement de clôture. Il n'y a donc aucun avantage à saisir la sécurité dans ce cas. De plus, il n'est pas clair pourquoi désigner newtype comme ArgumentName, si le nom du champ clarifie déjà son rôle.
Il me semble que cette utilisation des nouveaux types découle de la volonté d'utiliser le système de types comme moyen de taxonomie (classification) du monde. Le nom de l'argument est plus spécifique que le nom générique, il doit donc bien sûr avoir son propre type. Cette déclaration a du sens, mais elle est plutôt erronée: la taxonomie est utile pour documenter un domaine d'intérêt, mais pas nécessairement pour la modéliser. Lors de la programmation, nous utilisons des types à des fins différentes:
- Principalement, les types mettent en évidence les différences fonctionnelles entre les valeurs. Une valeur de type NonEmpty a est fonctionnellement différente d'une valeur de type [a] car sa structure est fondamentalement différente et permet des opérations supplémentaires. En ce sens, les types sont structurels; ils décrivent les valeurs à l'intérieur du langage de programmation.
- -, , . Distance Duration, - , , .
Notez que ces deux objectifs sont pragmatiques; ils comprennent le système de typage comme un outil. C'est une attitude assez naturelle, puisque le système de type statique est littéralement un outil. Néanmoins, ce point de vue nous semble inhabituel, même si l'utilisation de types pour classer le monde crée généralement un bruit inutile comme ArgumentName.
Ce n'est probablement pas très pratique lorsque le nouveau type est complètement transparent et enveloppé et déployé à nouveau comme souhaité. Dans ce cas particulier, j'écarterais complètement la distinction et utiliserais Name, mais dans les situations où différentes étiquettes sont claires, vous pouvez toujours utiliser le type d'alias:
type ArgumentName = GraphQL.Name
Ces nouveaux types sont de véritables coquilles. Sauter plusieurs étapes n'est pas un type sûr. Croyez-moi, les développeurs sauteront volontiers sans arrière-pensée.
Conclusion et lectures recommandées
J'ai longtemps voulu écrire un article sur ce sujet. C'est probablement une astuce très inhabituelle sur les nouveaux types dans Haskell. J'ai décidé de le dire de cette façon, car je gagne moi-même ma vie avec Haskell et je suis constamment confronté à des problèmes similaires dans la pratique. En fait, l'idée principale est beaucoup plus profonde.
Newtypes est l'un des mécanismes de définition des types de wrapper. Ce concept existe dans presque toutes les langues, même celles qui utilisent le typage dynamique. Si vous n'écrivez pas Haskell, une grande partie de cet article s'appliquera probablement à la langue de votre choix. Nous pouvons dire que c'est la continuation d'une idée que j'ai essayé de véhiculer de différentes manières au cours de l'année écoulée: les systèmes de types sont des outils. Nous devons être plus conscients et concentrés sur ce que les types fournissent réellement et comment les utiliser efficacement.
La raison de l'écriture de cet article était l'article récemment publié Tagged is not a Newtype... C'est un excellent article et je partage totalement l'idée principale. Mais je pensais que l'auteur avait raté l'occasion d'exprimer une pensée plus sérieuse. En fait, Tagged est un nouveau type par définition, donc le titre de l'article nous conduit sur la mauvaise voie. Le vrai problème va un peu plus loin.
Les nouveaux types sont utiles lorsqu'ils sont appliqués avec soin, mais la sécurité n'est pas leur propriété par défaut. Nous ne pensons pas que le plastique à partir duquel le cône de signalisation est fabriqué assure à lui seul la sécurité routière. Il est important de placer le cône dans le bon contexte! Sans la même clause, newtypes n'est qu'une étiquette, une façon de donner un nom.
Et le nom n'est pas sûr de type!