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?

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:
- Chaque type a lui-même:
Has record record
est implémenté de manière triviale ( extract = id
). - 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
. - 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
K1
correspond au cas de base.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.- 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). - 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 -le type-produit (a1, ..., an)
peut être représenté comme une composition paires (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 exempleConsidé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
?
- Pour
DbConfig
⟶ L Here
. - Vers WebServerConfig ⟶
R (L Here)
. - Vers
CronConfig
⟶ R (R Here)
. - À
DbAddress
⟶ L (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:
- 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. - Si les deux recherches récursives se terminent avec
NotFound
, alors bien NotFound
, la recherche entière se termine avec NotFound
. - 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:
record
doit implémenter Generic
pour que le mécanisme générique puisse être appliqué, nous obtenons donc un Generic record
.- 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). - 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:
- Nous codons des règles récursives sous la forme d'un type de données inductif
T
- 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. - 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.
- Commencé avec
Env -> Foo
. - Pas assez général, enveloppez-vous dans la monade
Reader Env
. - Pas assez général, réécrivez avec le
MonadReader Env m
. - Pas assez général, réécrivez
MonadReader rm, HasEnv r
. - Pas assez général, écrivons
MonadReader rm, Has Env r
et ajoutons des génériques pour que le compilateur fasse tout. - Maintenant la norme.