Puis-je Haz? Frappé par la programmation de type générique

Salut, Habr.


La dernière fois, nous avons décrit Has pattern, décrit les problèmes qu'il résout et écrit quelques exemples spécifiques:


 instance HasDbConfig AppConfig where getDbConfig = dbConfig instance HasWebServerConfig AppConfig where getWebServerConfig = webServerConfig instance HasCronConfig AppConfig where getCronConfig = cronConfig 

Ça a l'air bien. Quelles difficultés peuvent survenir ici?


image


Eh bien, réfléchissons aux autres instances dont nous pourrions avoir besoin. Tout d'abord, les types concrets avec une configuration en eux-mêmes sont de bons candidats pour l'implémentation (triviale) de ces classes de types, ce qui nous donne trois autres instances où chaque méthode est implémentée via id , par exemple


 instance HasDbConfig DbConfig where getDbConfig = id 

Ils nous permettent d'écrire facilement des tests individuels ou des utilitaires d'assistance qui sont indépendants de l'ensemble d' AppConfig .


C'est déjà ennuyeux, mais cela continue. Il est facile d'imaginer que certains tests d'intégration vérifient l'interaction d'une paire de modules, et nous ne voulons toujours pas dépendre de la configuration de l'application entière, nous devons donc maintenant écrire six instances (deux par type), chacune étant réduite à fst ou snd . Par exemple, pour DbConfig :


 instance HasDbConfig (DbConfig, b) where getDbConfig = fst instance HasDbConfig (a, DbConfig) where getDbConfig = snd 

Horreur On espère que nous n'aurons jamais besoin de tester le fonctionnement de trois modules en même temps - sinon vous devrez écrire neuf instances ennuyeuses. En tout cas, personnellement, je suis déjà très mal à l'aise, et je préfère passer plusieurs heures à automatiser cette affaire plutôt que quelques minutes pour écrire une douzaine de lignes de code supplémentaires.


Si vous êtes intéressé par la façon de résoudre ce problème de manière générale, en outre, ce sont des types dépendants et comment tout cela finira par ressembler à un chat Haskell - Welkom.


Résumer la classe Has


Tout d'abord, notez que nous avons différentes classes pour différents environnements. Cela peut interférer avec la création d'une solution universelle, nous plaçons donc l'environnement dans un paramètre distinct:


 class Has part record where extract :: record -> part 

Nous pouvons dire que Has part record signifie qu'une valeur de type part peut être extraite de la valeur de l' record de type. En ces termes, notre bon vieux HasDbConfig devient Has DbConfig , et de même pour les autres classes de types que nous avons écrites plus tôt. Cela s'avère presque un changement purement syntaxique, et, par exemple, le type de l'une des fonctions de notre post précédent


 doSmthWithDbAndCron :: (MonadReader rm, HasDbConfig r, HasCronConfig r) => ... 

dans


 doSmthWithDbAndCron :: (MonadReader rm, Has DbConfig r, Has CronConfig r) => ... 

Le seul changement est un couple d'espaces aux bons endroits.


De plus, nous n'avons pas beaucoup perdu dans l'inférence de type: un temporisateur peut toujours produire la valeur de retour d' extract nécessaire dans le contexte environnant dans la grande majorité des cas rencontrés en pratique.


Maintenant que nous ne nous soucions plus du type d'environnement spécifique, voyons quels enregistrements peuvent implémenter la classe d' Has part record pour la part fixe. Cette tâche a une bonne structure inductive:


  1. Chaque type a lui-même: Has record record est implémenté de manière triviale ( extract = id ).
  2. Si l' record est un produit des types rec1 et rec2 , alors Has part record est implémenté si et seulement si Has part rec1 ou Has part rec2 .
  3. Si record est la somme des types rec1 et rec2 , alors la Has part record est implémentée si et seulement si la Has part rec1 et la Has part rec2 . Bien que la prévalence pratique de ce cas dans ce contexte ne soit pas évidente, elle mérite tout de même d'être mentionnée pour être complète.

Il semble donc que nous ayons formulé une esquisse d'un algorithme pour déterminer automatiquement si un Has part record est implémenté pour les données de part et d' record !


Heureusement, un tel raisonnement inductif sur les types s'intègre très bien au mécanisme Haskell Generics . En bref et simplifiant, Generics est l'une des méthodes de métaprogrammation généralisée dans le Haskell, résultant de l'observation que chaque type est soit un type de somme, un type de produit ou un type de base à construction unique avec un champ.


Je n'écrirai pas un autre tutoriel sur les génériques, alors passez simplement au code.


Première tentative


Nous utiliserons la méthode classique d'implémentation Generic de nos Has travers la classe auxiliaire GHas :


 class GHas part grecord where gextract :: grecord p -> part 

Ici grecord est une représentation Generic de notre type d' record .


GHas implémentations de GHas suivent la structure inductive que nous avons notée ci-dessus:


 instance GHas record (K1 i record) where gextract (K1 x) = x instance GHas part record => GHas part (M1 it record) where gextract (M1 x) = gextract x instance GHas part l => GHas part (l :*: r) where gextract (l :*: _) = gextract l instance GHas part r => GHas part (l :*: r) where gextract (_ :*: r) = gextract r 

  1. K1 correspond au cas de base.
  2. M1 - Métadonnées génériques spécifiques dont nous n'avons pas besoin dans notre tâche, nous les ignorons donc simplement et les parcourons.
  3. La première instance pour le type de produit l :*: r correspond au cas où la partie "gauche" du produit a la valeur de type dont nous avons besoin (éventuellement, récursivement).
  4. De même, la deuxième instance pour le type de produit l :*: r correspond au cas où la partie "droite" du produit a la valeur souhaitée de la part type (naturellement, aussi, éventuellement, récursivement).

Nous ne prenons en charge que les types de produits ici. Mon impression subjective est que les montants ne sont pas si souvent utilisés dans des contextes pour MonadReader et des classes similaires, ils peuvent donc être négligés pour simplifier la prise en compte.


En outre, il est utile de noter que chaque n-le type-produit (a1, ..., an) peut être représenté comme une composition n1paires (a1, (a2, (a3, (..., an)))) , donc je me permets d'associer des types de produits à des paires.


Avec nos GHas , vous pouvez écrire une implémentation par défaut pour Has qui utilise des génériques:


 class Has part record where extract :: record -> part default extract :: Generic record => record -> part extract = gextract . from 

C'est fait.


Ou pas?


Le problème


Si nous essayons de compiler ce code, nous verrons qu'il ne taypechaetsya même sans aucune tentative d'utiliser cette implémentation par défaut, en signalant quelques instances qui se chevauchent là-bas. Pire, ces instances sont les mêmes à certains égards. Il semble qu'il soit temps de comprendre comment fonctionne le mécanisme de résolution des instances dans Haskell.


Puissions-nous avoir


 instance context => Foo barPattern bazPattern where ... 

(Soit dit en passant, cette chose après => est appelée tête d'instance.)


Il semble naturel de lire ceci comme


Laissez-nous choisir une instance pour Foo bar baz . Si le context satisfait, vous pouvez sélectionner cette instance à condition que bar et baz correspondent à barPattern et bazPattern .

Cependant, c'est une mauvaise interprétation, et juste le contraire:


Laissez-nous choisir une instance pour Foo bar baz . Si bar et baz correspondent à barPattern et bazPattern , nous sélectionnons cette instance et ajoutons du context à la liste des constantes qui doivent être résolues.

Maintenant, il est évident quel est le problème. Examinons de plus près la paire d'instances suivante:


 instance GHas part l => GHas part (l :*: r) where gextract (l :*: _) = gextract l instance GHas part r => GHas part (l :*: r) where gextract (_ :*: r) = gextract r 

Ils ont les mêmes têtes d'instance, donc pas étonnant qu'ils se croisent! De plus, aucun d'eux n'est plus spécifique que l'autre.


De plus, il n'y a aucun moyen d'affiner d'une manière ou d'une autre ces instances afin qu'elles cessent de se chevaucher. Eh bien, en plus d'ajouter plus de paramètres GHas .


Les types expressifs se précipitent à la rescousse!


La solution au problème consiste à pré-calculer le «chemin» vers la valeur qui nous intéresse et à utiliser ce chemin afin de guider le choix des instances.


Puisque nous avons accepté de ne pas prendre en charge les types de somme, un chemin est au sens littéral une séquence de virages à gauche ou à droite dans les types de produits (c'est-à-dire, les choix du premier ou du deuxième composant d'une paire), se terminant par un grand pointeur «ICI», dès que nous trouvons le type souhaité . Nous écrivons ceci:


 data Path = L Path | R Path | Here deriving (Show) 

Par exemple

Considérez les types suivants:


 data DbConfig = DbConfig { dbAddress :: DbAddress , dbUsername :: Username , dbPassword :: Password } data AppConfig = AppConfig { dbConfig :: DbConfig , webServerConfig :: WebServerConfig , cronConfig :: CronConfig } 

Quels sont quelques exemples de chemins depuis AppConfig ?


  1. Pour DbConfigL Here .
  2. Vers WebServerConfig ⟶ R (L Here) .
  3. Vers CronConfigR (R Here) .
  4. À DbAddressL (L Here) .

Quel pourrait être le résultat d'une recherche d'une valeur du type souhaité? Deux options sont évidentes: nous pouvons le trouver ou ne pas le trouver. Mais en fait, tout est un peu plus compliqué: on peut trouver plus d'une valeur de ce type. Apparemment, le comportement le plus sensé dans ce cas controversé serait également un message d'erreur. Tout choix d'une valeur particulière aura un certain caractère aléatoire.


En effet, considérons notre exemple de service Web standard. Si quelqu'un veut obtenir une valeur de type (Host, Port) , doit-il s'agir de l'adresse du serveur de base de données ou de l'adresse du serveur Web? Il vaut mieux ne pas le risquer.


Dans tous les cas, exprimons cela en code:


 data MaybePath = NotFound | Conflict | Found Path deriving (Show) 

Nous séparons NotFound et Conflict , car le traitement de ces cas est fondamentalement différent: si nous obtenons NotFound dans l'une des branches de notre type de produit, il ne sera pas difficile de trouver la valeur souhaitée dans une autre branche, tandis que Conflict dans n'importe quelle branche signifie immédiatement complet un échec.


Nous considérons maintenant un cas particulier de types de produits (que, comme nous l'avons convenu, nous considérons comme des paires). Comment y trouver la valeur du type souhaité? Vous pouvez exécuter une recherche récursivement dans chaque composant d'une paire, obtenir les résultats p1 et p2 respectivement, puis les combiner d'une manière ou d'une autre.


Étant donné que nous parlons du choix des instances de classes temporelles qui se produisent lors de la compilation, nous avons en fait besoin de calculs de compilation, qui sont exprimés dans le Haskell par le biais de calculs sur les types (même si les types sont représentés par des termes soulevés dans l'univers à l'aide de DataKinds ). En conséquence, une telle fonction sur les types est représentée comme une famille de types:


 type family Combine p1 p2 where Combine ('Found path) 'NotFound = 'Found ('L path) Combine 'NotFound ('Found path) = 'Found ('R path) Combine 'NotFound 'NotFound = 'NotFound Combine _ _ = 'Conflict 

Cette fonction représente plusieurs cas:


  1. Si l'une des recherches récursives réussit et que l'autre NotFound à NotFound , nous prenons le chemin de la recherche réussie et ajoutons le virage dans la bonne direction.
  2. Si les deux recherches récursives se terminent avec NotFound , alors bien NotFound , la recherche entière se termine avec NotFound .
  3. Dans tous les autres cas, nous obtenons Conflict .

Nous allons maintenant écrire une fonction au niveau de la pointe qui prend la part à trouver, et une représentation Generic du type dans lequel trouver la part , et recherche:


 type family Search part (grecord :: k -> *) :: MaybePath where Search part (K1 _ part) = 'Found 'Here Search part (K1 _ other) = 'NotFound Search part (M1 _ _ x) = Search part x Search part (l :*: r) = Combine (Search part l) (Search part r) Search _ _ = 'NotFound 

Notez que nous avons obtenu quelque chose de très similaire dans notre sens à notre précédente tentative avec GHas . C'est à prévoir, car nous reproduisons en fait l'algorithme que nous avons essayé d'exprimer à travers les timeclasses.


GHas , il ne nous reste plus qu'à ajouter un paramètre supplémentaire à cette classe, qui est responsable du chemin trouvé précédemment, et qui servira à sélectionner des instances spécifiques:


 class GHas (path :: Path) part grecord where gextract :: Proxy path -> grecord p -> part 

Nous avons également ajouté un argument supplémentaire pour gextract afin que le compilateur puisse sélectionner l'instance correcte pour le chemin donné (qui doit être mentionné dans la signature de fonction pour cela).


Maintenant, écrire des instances est assez simple:


 instance GHas 'Here record (K1 i record) where gextract _ (K1 x) = x instance GHas path part record => GHas path part (M1 it record) where gextract proxy (M1 x) = gextract proxy x instance GHas path part l => GHas ('L path) part (l :*: r) where gextract _ (l :*: _) = gextract (Proxy :: Proxy path) l instance GHas path part r => GHas ('R path) part (l :*: r) where gextract _ (_ :*: r) = gextract (Proxy :: Proxy path) r 

En effet, nous sélectionnons simplement l'instance souhaitée en fonction du chemin dans le path que nous avons calculé précédemment.


Comment écrire maintenant notre implémentation default de la fonction extract :: record -> part dans la classe Has ? Nous avons plusieurs conditions:


  1. record doit implémenter Generic pour que le mécanisme générique puisse être appliqué, nous obtenons donc un Generic record .
  2. La fonction de Search doit trouver une part dans l' record (ou plutôt, dans la représentation Generic de l' record , qui est exprimée comme Rep record ). Dans le code, cela semble un peu plus inhabituel: Search part (Rep record) ~ 'Found path . Cet enregistrement signifie la restriction que le résultat de la Search part (Rep record) doit être égal au 'Found path pour un certain path (qui, en fait, nous intéresse).
  3. Nous devrions être en mesure d'utiliser GHas avec part , la représentation générique de l' record et du path de la dernière étape, qui se transforme en une GHas path part (Rep record) .

Nous rencontrerons les deux dernières constantes plusieurs fois, il est donc utile de les mettre dans un synonyme de const distinct:


 type SuccessfulSearch part record path = (Search part (Rep record) ~ 'Found path, GHas path part (Rep record)) 

Étant donné ce synonyme, nous obtenons


 class Has part record where extract :: record -> part default extract :: forall path. (Generic record, SuccessfulSearch part record path) => record -> part extract = gextract (Proxy :: Proxy path) . from 

Maintenant tout!


Utilisation de Generic Has


Pour voir tout cela en action, nous allons écrire quelques exemples généraux pour les nuls:


 instance SuccessfulSearch a (a0, a1) path => Has a (a0, a1) instance SuccessfulSearch a (a0, a1, a2) path => Has a (a0, a1, a2) instance SuccessfulSearch a (a0, a1, a2, a3) path => Has a (a0, a1, a2, a3) 

Ici, SuccessfulSearch a (a0, ..., an) path est responsable du fait que a se produit parmi a0, ..., an exactement une fois.


Puissions-nous maintenant avoir notre bon vieux


 data AppConfig = AppConfig { dbConfig :: DbConfig , webServerConfig :: WebServerConfig , cronConfig :: CronConfig } 

et nous voulons sortir Has DbConfig , Has WebServerConfig et Has CronConfig . Il suffit d'inclure les DeriveAnyClass DeriveGeneric et DeriveAnyClass et d'ajouter la déclaration de deriving correcte:


 data AppConfig = AppConfig { dbConfig :: DbConfig , webServerConfig :: WebServerConfig , cronConfig :: CronConfig } deriving (Generic, Has DbConfig, Has WebServerConfig, Has CronConfig) 

Nous avons la chance (ou nous avons été assez perspicaces) d'organiser les arguments pour Has afin que le nom du type imbriqué vienne en premier, afin que nous puissions compter sur le mécanisme DeriveAnyClass pour minimiser le gribouillage.


La sécurité passe avant tout


Et si nous n’avons aucun type?


 data AppConfig = AppConfig { dbConfig :: DbConfig , webServerConfig :: WebServerConfig } deriving (Generic, Has CronConfig) 

Non, nous obtenons une erreur juste au point de définition du type:


 Spec.hs:35:24: error: • Couldn't match type ''NotFound' with ''Found path0' arising from the 'deriving' clause of a data type declaration • When deriving the instance for (Has CronConfig AppConfig) | 35 | } deriving (Generic, Has CronConfig) | ^^^^^^^^^^^^^^ 

Ce n'est pas le message d'erreur le plus convivial, mais même à partir de celui-ci, vous pouvez toujours comprendre quel est le problème: la fréquence impaire NotFound fréquence impaire CronConfig .


Et si nous avons plusieurs champs du même type?


 data AppConfig = AppConfig { prodDbConfig :: DbConfig , qaDbConfig :: DbConfig , webServerConfig :: WebServerConfig , cronConfig :: CronConfig } deriving (Generic, Has DbConfig) 

Non, comme prévu:


 Spec.hs:37:24: error: • Couldn't match type ''Conflict' with ''Found path0' arising from the 'deriving' clause of a data type declaration • When deriving the instance for (Has DbConfig AppConfig) | 37 | } deriving (Generic, Has DbConfig) | ^^^^^^^^^^^^ 

Tout semble vraiment bien.


Pour résumer


Nous allons donc essayer de formuler brièvement la méthode proposée.


Supposons que nous ayons une sorte de typklass, et que nous voulons afficher automatiquement ses instances selon certaines règles récursives. Ensuite, nous pouvons éviter les ambiguïtés (et exprimer généralement ces règles si elles ne sont pas triviales et ne correspondent pas au mécanisme standard de résolution des instances) comme suit:


  1. Nous codons des règles récursives sous la forme d'un type de données inductif T
  2. Nous allons écrire une fonction sur les types (sous forme de famille de types) pour le calcul préliminaire de la valeur v ce type T (ou, en termes de Haskell, le type v type T - où sont mes types dépendants), qui décrit la séquence spécifique d'étapes qui doivent être prises.
  3. Utilisez ce v comme argument supplémentaire à l'aide Generic pour déterminer la séquence spécifique d'instances qui correspondent désormais aux valeurs de v .

Eh bien, c'est tout!


Dans les articles suivants, nous examinerons quelques extensions élégantes (ainsi que des limitations élégantes) de cette approche.


Oh, et oui. Il est intéressant de suivre la séquence de nos généralisations.


  1. Commencé avec Env -> Foo .
  2. Pas assez général, enveloppez-vous dans la monade Reader Env .
  3. Pas assez général, réécrivez avec le MonadReader Env m .
  4. Pas assez général, réécrivez MonadReader rm, HasEnv r .
  5. Pas assez général, écrivons MonadReader rm, Has Env r et ajoutons des génériques pour que le compilateur fasse tout.
  6. Maintenant la norme.

Source: https://habr.com/ru/post/fr471052/


All Articles