Mieux vaut utiliser monade d'erreur avec validation dans vos fonctions monadiques, ou implémenter votre propre monade avec validation directement dans votre bind?

9

Je me demande ce qui est mieux en termes de conception pour l'utilisabilité / la maintenabilité, et ce qui est mieux en ce qui concerne la communauté.

Étant donné le modèle de données:

type Name = String

data Amount = Out | Some | Enough | Plenty deriving (Show, Eq)
data Container = Container Name deriving (Show, Eq)
data Category = Category Name deriving (Show, Eq)
data Store = Store Name [Category] deriving (Show, Eq)
data Item = Item Name Container Category Amount Store deriving Show
instance Eq (Item) where
  (==) i1 i2 = (getItemName i1) == (getItemName i2)

data User = User Name [Container] [Category] [Store] [Item] deriving Show
instance Eq (User) where
  (==) u1 u2 = (getName u1) == (getName u2)

Je peux implémenter des fonctions monadiques pour transformer l'utilisateur par exemple en ajoutant des articles ou des magasins, etc., mais je peux me retrouver avec un utilisateur invalide, donc ces fonctions monadiques devraient valider l'utilisateur qu'elles obtiennent et / ou créent.

Alors, devrais-je juste:

  • envelopper dans une monade d'erreur et faire exécuter les fonctions monadiques à la validation
  • envelopper dans une monade d'erreur et obliger le consommateur à lier une fonction de validation monadique dans la séquence qui génère la réponse d'erreur appropriée (afin qu'ils puissent choisir de ne pas valider et transporter un objet utilisateur non valide)
  • en fait, construisez-le dans une instance de liaison sur l'utilisateur créant efficacement mon propre type de monade d'erreur qui exécute la validation avec chaque liaison automatiquement

Je peux voir les points positifs et négatifs de chacune des 3 approches, mais je veux savoir ce qui est plus couramment fait pour ce scénario par la communauté.

Donc, en termes de code, quelque chose comme, option 1:

addStore s (User n1 c1 c2 s1 i1) = validate $ User n1 c1 c2 (s:s1) i1
updateUsersTable $ someUser >>= addStore $ Store "yay" ["category that doesnt exist, invalid argh"]

Option 2:

addStore s (User n1 c1 c2 s1 i1) = Right $ User n1 c1 c2 (s:s1) i1
updateUsersTable $ Right someUser >>= addStore $ Store "yay" ["category that doesnt exist, invalid argh"] >>= validate
-- in this choice, the validation could be pushed off to last possible moment (like inside updateUsersTable before db gets updated)

option 3:

data ValidUser u = ValidUser u | InvalidUser u
instance Monad ValidUser where
    (>>=) (ValidUser u) f = case return u of (ValidUser x) -> return f x; (InvalidUser y) -> return y
    (>>=) (InvalidUser u) f = InvalidUser u
    return u = validate u

addStore (Store s, User u, ValidUser vu) => s -> u -> vu
addStore s (User n1 c1 c2 s1 i1) = return $ User n1 c1 c2 (s:s1) i1
updateUsersTable $ someValidUser >>= addStore $ Store "yay" ["category that doesnt exist, invalid argh"]
Jimmy Hoffa
la source

Réponses:

5

D'abord, je me pose la question: Userun bug de code invalide ou une situation qui peut normalement se produire (par exemple, quelqu'un saisissant une entrée incorrecte dans votre application). Si c'est un bug, j'essaierais de m'assurer que cela ne se produise jamais (comme utiliser des constructeurs intelligents ou créer des types plus sophistiqués).

S'il s'agit d'un scénario valide, un traitement d'erreur lors de l'exécution est approprié. Ensuite , je demande: Qu'est-ce que cela signifie vraiment pour moi que Userest invalide ?

  1. Cela signifie-t-il qu'un invalide Userpeut faire échouer du code? Certaines parties de votre code reposent-elles sur le fait que a Userest toujours valide?
  2. Ou cela signifie-t-il simplement que c'est une incohérence qui doit être corrigée plus tard, mais qui ne casse rien pendant le calcul?

Si c'est 1., j'irais certainement pour une sorte de monade d'erreur (standard ou la vôtre), sinon vous perdrez les garanties que votre code fonctionne correctement.

Créer votre propre monade ou utiliser une pile de transformateurs de monade est un autre problème, peut-être que cela sera utile: quelqu'un a-t-il déjà rencontré un transformateur de monade dans la nature? .


Mise à jour: En regardant vos options étendues:

  1. Semble être la meilleure façon de procéder. Peut-être, pour être vraiment sûr, je préfère cacher le constructeur de Useret exporter à la place seulement quelques fonctions qui ne permettent pas de créer une instance non valide. De cette façon, vous serez sûr que chaque fois que cela se produira, il sera traité correctement. Par exemple, une fonction générique pour créer un Userpourrait être quelque chose comme

    user :: ... -> Either YourErrorType User
    -- more generic:
    user :: (MonadError YourErrorType m) ... -> m User
    -- Or if you actually don't need to differentiate errors:
    user :: ... -> Maybe User
    -- or more generic:
    user :: (MonadPlus m) ... -> m User
    -- etc.
    

    De nombreuses bibliothèques adoptent une approche similaire, par exemple Map, Setou Seqmasquent l'implémentation sous-jacente de sorte qu'il n'est pas possible de créer une structure qui n'obéit pas à leurs invariants.

  2. Si vous remettez à plus tard la validation et que vous l'utilisez Right ...partout, vous n'avez plus besoin d'une monade. Vous pouvez simplement faire des calculs purs et résoudre toutes les erreurs possibles à la fin. À mon humble avis, cette approche est très risquée, car une valeur utilisateur non valide peut conduire à avoir des données non valides ailleurs, car vous n'avez pas arrêté le calcul assez tôt. Et, s'il arrive qu'une autre méthode met à jour l'utilisateur afin qu'il soit à nouveau valide, vous finirez par avoir des données non valides quelque part et même sans le savoir.

  3. Il y a plusieurs problèmes ici.

    • Le plus important est qu'une monade doit accepter n'importe quel paramètre de type, pas seulement User. Il validatefaudrait donc avoir du type u -> ValidUser usans aucune restriction u. Il n'est donc pas possible d'écrire une telle monade qui valide les entrées de return, car elle returndoit être entièrement polymorhpique.
    • Ensuite, ce que je ne comprends pas, c'est que vous correspondez case return u ofà la définition de >>=. Le point principal de ValidUserdevrait être de distinguer les valeurs valides et non valides, et la monade doit donc s'assurer que cela est toujours vrai. Donc ça pourrait être simplement

      (>>=) (ValidUser u) f = f u
      (>>=) (InvalidUser u) f = InvalidUser u
      

    Et cela ressemble déjà beaucoup Either.

En règle générale, je n'utiliserais une monade personnalisée que si

  • Il n'existe aucune monade existante qui vous offre les fonctionnalités dont vous avez besoin. Les monades existantes ont généralement de nombreuses fonctions de support, et plus important encore, elles ont des transformateurs de monade afin que vous puissiez les composer en piles de monades.
  • Ou si vous avez besoin d'une monade trop complexe pour être décrite comme une pile de monades.
Petr Pudlák
la source
Vos deux derniers points sont inestimables et je n'y ai pas pensé! Certainement la sagesse que je cherchais, merci d'avoir partagé vos pensées, j'irai certainement avec # 1!
Jimmy Hoffa
J'ai attaché tout le module hier soir et vous aviez raison. J'ai inséré ma méthode de validation dans un petit nombre de combinateurs de clés que j'avais fait toutes les mises à jour du modèle et cela a beaucoup plus de sens comme ça. J'allais vraiment aller après le # 3 et maintenant je vois à quel point cette approche aurait été inflexible, alors merci beaucoup pour m'avoir remis en ordre!
Jimmy Hoffa