Czy lepiej używać monady błędów z weryfikacją w funkcjach monadycznych, czy implementować własną monadę z weryfikacją bezpośrednio w powiązaniu?

9

Zastanawiam się, co lepiej zaprojektować pod kątem użyteczności / konserwacji, a co lepiej, jeśli chodzi o dopasowanie do społeczności.

Biorąc pod uwagę model danych:

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)

Mogę zaimplementować funkcje monadyczne, aby przekształcić użytkownika, na przykład dodając elementy lub sklepy itp., Ale mogę skończyć z nieprawidłowym użytkownikiem, więc te funkcje monadyczne będą musiały zweryfikować użytkownika, który otrzymają lub utworzą.

Czy powinienem po prostu:

  • zawiń go w monadę błędu i spraw, aby funkcje monadyczne wykonały sprawdzanie poprawności
  • zawiń go w monadę błędu i zmuś konsumenta do wiązania monadycznej funkcji sprawdzania poprawności w sekwencji, która generuje odpowiednią odpowiedź na błąd (aby mogli wybrać opcję sprawdzania poprawności i przenoszenia niepoprawnego obiektu użytkownika)
  • faktycznie wbudowałem go w instancję powiązania na użytkowniku, skutecznie tworząc własną monadę błędów, która automatycznie sprawdza poprawność przy każdym powiązaniu

Widzę pozytywne i negatywne aspekty każdego z 3 podejść, ale chcę wiedzieć, co społeczność najczęściej robi dla tego scenariusza.

W kategoriach kodowych coś w rodzaju opcji 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"]

Opcja 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)

opcja 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
źródło

Odpowiedzi:

5

Pięść zadałbym sobie pytanie: ma nieprawidłowy Userbłąd w kodzie lub sytuację, która może normalnie wystąpić (na przykład ktoś wprowadza nieprawidłowe dane do aplikacji). Jeśli jest to błąd, staram się upewnić, że nigdy się nie zdarzy (np. Używając inteligentnych konstruktorów lub tworząc bardziej wyrafinowane typy).

Jeśli jest to prawidłowy scenariusz, odpowiednie jest przetwarzanie błędów podczas działania. Następnie zapytam: co to tak naprawdę oznacza, że ​​a Userjest nieważne ?

  1. Czy to oznacza, że ​​niepoprawny Userkod może spowodować awarię kodu? Czy części twojego kodu opierają się na fakcie, że Userzawsze jest poprawny?
  2. Czy może to tylko oznacza, że ​​jest to niespójność, którą należy naprawić później, ale nic nie psuje podczas obliczeń?

Jeśli jest to 1., zdecydowanie wybrałbym monadę błędów (standardową lub własną), w przeciwnym razie stracisz gwarancje, że Twój kod działa poprawnie.

Tworzenie własnej monady lub korzystanie ze stosu transformatorów monadowych to kolejna kwestia, być może będzie to pomocne: Czy ktoś kiedykolwiek spotkał transformator Monad na wolności? .


Aktualizacja: Patrząc na rozszerzone opcje:

  1. Wygląda na najlepszą drogę. Być może, aby być naprawdę bezpiecznym, wolę ukryć konstruktora Useri zamiast tego wyeksportować tylko kilka funkcji, które nie pozwalają na utworzenie nieprawidłowej instancji. W ten sposób będziesz mieć pewność, że za każdym razem, gdy tak się stanie, zostanie poprawnie obsłużony. Na przykład ogólna funkcja do tworzenia Usermoże być podobna

    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.
    

    Wiele bibliotek ma podobną appropach, na przykład Map, Setlub Seqschować pod spodem realizacji tak, że nie jest możliwe stworzenie struktury, która nie przestrzega ich niezmienniki.

  2. Jeśli odłożysz walidację do samego końca i używasz jej Right ...wszędzie, nie potrzebujesz już monady. Możesz po prostu wykonać czyste obliczenia i rozwiązać ewentualne błędy na końcu. IMHO takie podejście jest bardzo ryzykowne, ponieważ niepoprawna wartość użytkownika może prowadzić do posiadania nieprawidłowych danych w innym miejscu, ponieważ obliczenia nie zostały wystarczająco wcześnie zatrzymane. A jeśli zdarzy się, że jakaś inna metoda zaktualizuje użytkownika, aby znów był prawidłowy, skończysz z posiadaniem gdzieś nieprawidłowych danych i nawet o tym nie wiedząc.

  3. Tutaj jest kilka problemów.

    • Najważniejsze jest to, że monada musi akceptować dowolny parametr typu, nie tylko User. Więc będziesz validatemusiał pisać u -> ValidUser ubez żadnych ograniczeń u. Nie jest więc możliwe napisanie takiej monady, która sprawdza poprawność danych wejściowych return, ponieważ returnmusi być w pełni polimorficzna.
    • Następnie nie rozumiem, że pasujesz case return u ofdo definicji >>=. Głównym celem ValidUserpowinno być rozróżnienie prawidłowych i niepoprawnych wartości, dlatego monada musi upewnić się, że zawsze tak jest. Tak może być po prostu

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

    I to już wygląda bardzo podobnie Either.

Zasadniczo używałbym niestandardowej monady tylko wtedy, gdy

  • Brak istniejących monad zapewniających potrzebną funkcjonalność. Istniejące monady zwykle mają wiele funkcji pomocniczych, a co ważniejsze, mają transformatory monadowe, dzięki czemu można je łączyć w stosy monad.
  • Lub jeśli potrzebujesz monady, która jest zbyt złożona, aby opisać ją jako stos monad.
Petr Pudlák
źródło
Twoje dwa ostatnie punkty są bezcenne i nie myślałem o nich! Zdecydowanie mądrość, której szukałem, dzięki za podzielenie się swoimi przemyśleniami, zdecydowanie pójdę z numerem 1!
Jimmy Hoffa
Właśnie związałem cały moduł zeszłej nocy i nie miałeś racji. Wstawiłem moją metodę sprawdzania poprawności do niewielkiej liczby kluczowych kombiatorów, które miałem podczas wszystkich aktualizacji modelu i ma to o wiele więcej sensu. Naprawdę zamierzałem pójść po # 3, a teraz widzę, jak ... nieelastyczne byłoby to podejście, więc dzięki za wyprostowanie mnie!
Jimmy Hoffa