Skip to content

Instantly share code, notes, and snippets.

@kleczkowski
Last active August 25, 2019 23:07
Show Gist options
  • Save kleczkowski/18dbd37a69bdff08f6dd8db9b225a6c6 to your computer and use it in GitHub Desktop.
Save kleczkowski/18dbd37a69bdff08f6dd8db9b225a6c6 to your computer and use it in GitHub Desktop.
Funktory (aplikatywne)

Funktory (aplikatywne)

Ten artykuł chciałbym poświęcić jednej, dość zapomnianej rzeczy, jaką jest funktor aplikatywny. Mamy pełno artykułów o monadach i ich zastosowaniach, lecz nieczęsto się zdarza czytać o funktorach aplikatywnych i w ogóle --- funktorach.

Stwierdziłem, że funktory aplikatywne mają naprawdę potencjał w realnych zastosowaniach, tylko mało kto sobie zdaje sprawę, że funktor aplikatywny jest w zasadzie funkcyjną wersją, wysokopoziomową, pewnego kreacjonalnego wzorca projektowego.

Zakładam, że wiesz, co to są klasy typów i algebraiczne struktury danych, jak je się definiuje. Generalnie wymagana jest wiedza z Haskella na poziomie pisania w REPLu.

Czym jest funktor?

(Teoriokategoryjni Czytelnicy są proszeni o odwrócenie wzroku na moment). Funktor f, z praktycznego punktu widzenia, jest pewną polimorficzną strukturą danych, która pozwala na pewny rodzaj przekształceń.

class Functor f where
    fmap :: (a -> b) -> f a -> f b

Funktory są fundamentem programowania funkcyjnego, które pozwalają na przetwarzanie danych w pewnych kontekstach.

Najbardziej popularnym (jak i najbardziej rozpowszechnionym) przykładem funktora jest lista. Każdy jakkolwiek doświadczony programista miał pewnie kiedyś przyjemność napisać kod z wykorzystaniem słynnej funkcji, bądź metody, map, która działała w ten sposób, że przekształcała za pomocą dostarczonej funkcji każdą pojedynczą wartość w liście, bądź innej kolekcji. Tak samo to działa w Haskellu, a nawet szerzej --- za pomocą fmap możemy przekształcać wartości umieszczone w strukturach danych, które mają instancję klasy typów Functor.

To może nietypowe, ale funktory stanowią często kontekst dla opakowanej przez funktor wartości. Lista jest kontekstem, w którym wartości występują więcej niż jeden raz, funktor Maybe (a.k.a. Optional, Option, etc.) jest kontekstem, w którym wartość jest (Just/Some), bądź jej nie ma (Nothing/None), a Either jest kontekstem, w którym wartość obliczyła się bez kłopotów, bądź wystąpił pewien błąd.

Żartobliwie mówiłem czasem, że funktor określa styl bycia umieszczonej w nim wartości.

Wobec tego, czym jest aplikatywny funktor?

Funktor aplikatywny to taki funktor, który pozwala na wykonanie przekształcenia, tylko owe przekształcenie jest umieszczone w funktorze. Również, funktor aplikatywny pozwala na umieszczenie bezpośrednio wartości do funktora. Definicja klasy typów jest następująca:

class (Functor f) => Applicative f where
    pure :: a -> f a
    (<*>) :: f (a -> b) -> f a -> f b

Dość biegły Czytelnik powinien dostrzec pewne podobieństwo między fmap oraz <*>.

(<*>)   :: f (a -> b) -> f a -> f b
fmap    ::   (a -> b) -> f a -> f b

Pewnie ktoś z Was zastanowi się i powie --- No, ale co w tym niezwykłego? Funktory aplikatywne wprowadzają pewien wariant wzorca Budowniczego.

"Głupi" Budowniczy w Haskellu

Wzorzec Budowniczego opierał się na prostej zasadzie --- dążono do uproszczenia instancjonowania skomplikowanych obiektów za pomocą bardziej przyjaznego interfejsu. Każdy, kto napotkał taki, bądź podobny fragment kodu w Javie

Channel channel = new Channel(42, 
                              "facebook.com/groups/lambdawka",
                              args -> service.preform(args[0]),
                              new AbstractFactoryProxySingletonBeansProviderStrategy());

wie, że jest to nieczytelne, a tym bardziej nie rozszerza się na przypadki, kiedy chcemy zmienić konstruktor klasy Channel. Oczywiście, można wykorzystać do tego budowniczego, który mógłby powyższy kod uściślić do:

Channel channel = new ChannelBuilder()
                .maxConnections(42)
                .targetUrl("facebook.com/groups/lambdawka")
                .setResponseHanlder(args -> service.preform(args[0]))
                .setSomeOopCrapProvider(new AbstractFactoryProxySingletonBeansProviderStrategy())
                .build();

Zabierzmy się za przykład w Haskellu. Weźmy najprostszy funktor o nazwie Identity, który jest również funktorem aplikatywnym; poza tym jest funktorem identycznościowym i wygląda on tak:

data Identity a = Identity a

Implementację Functor i Applicative można się domyślić (ćwiczenie). Za wartość opakowaną tym funktorem weźmiemy strukturę użytkownika, która będzie zawierać imię, nazwisko oraz wiek.

data Person = Person String String Int deriving (Show)

Teraz mając tak skomplikowany obiekt do skonstruowania, możemy przystąpić do użycia funktora Identity.

import Data.Functor.Identity

constructJanKowalski :: Person
constructJanKowalski = 
    runIdentity
        $ pure Person
        <*> pure "Jan"
        <*> pure "Kowalski"
        <*> pure 40

Kod w zasadzie nie robi nic więcej niż zwyczajne Person "Jan" "Kowalski" 40, więc równie dobrze możesz się zastanawiać, czy w ogóle ma to jakikolwiek sens. Ma, z tym że potrzebujemy mniej trywialnych funktorów aplikatywnych, ponieważ to funktor nadaje styl bycia wartościom, które są w nim opakowane. Funktor identycznościowy to najbardziej mdły funktor, ponieważ jego styl bycia jest po prostu posiadanie w sobie wartości, bez dodatkowej logiki, którą mogą kryć funktory.

Walidacje --- ten "mądrzejszy" Budowniczy

Pewnie nie raz spotkaliście się ze scenariuszem, by móc zwalidować jakieś dane przed ich wysłaniem, czy to podczas pisania mikroserwisu, czy pisania backendu webowego. Walidacje miały swoistą naturę, która była zupełnie prostopadła do natury wyjątków --- błędy walidacji mogły się kumulować, w przeciwieństwie do wyjątków, gdzie mamy scenraiusz fail-first.

Jest to świetny pomysł, by wykorzystać funktor aplikatywny. Po pierwsze, możemy zdefiniować funktor, który będzie albo zwracał pomyślnie obliczoną wartość, albo będzie kumulował błędy, które powstały w trakcie walidacji. Co ciekawe, ten wzorzec odnajduje swoje zastosowania w językach hybrydowych, przykładem tu jest Scala i scalactic.

Zdefiniujmy sobie strukturę danych, która jest taka sama, jak Either.

data Validated e a
    = Fail e
    | Pass a
    deriving (Show)

Oczywiście, należy napisać instancję Functor. Zakładamy, że struktura danych przekształca tylko wynik działania, lecz nie treść błędu.

instance Functor (Validated e) where
    fmap f (Pass a) = Pass (f a)
    fmap _ (Fail e) = Fail e

Teraz przejdziemy do najważniejszego. Należy pamiętać, że chcemy kumulować błędy, wobec tego typ e powinien zachowywać się jak monoid. Jednakże nie będziemy potrzebować elementu neutralnego, stąd że zakładamy, że użytkownik wygeneruje dla nas instancje błędów. Wnioskujemy, że w instancji Applicative dla Validated powinniśmy dodać zależność, że e jest półgrupą.

Podsumowując, otrzymujemy taki kod:

instance (Semigroup e) => Applicative (Validated e) where
    pure = Pass
    (Fail e1)  <*> (Fail e2)    = Fail (e1 <> e2)
    (Fail e)   <*> _            = Fail e
    _          <*> (Fail e)     = Fail e
    (Pass f)   <*> (Pass a)     = Pass (f a)

Ten kod realizuje nasz cel --- w wypadku, kiedy nadarzyły się dwa błędy po sobie, kumulujemy je za pomocą <>, które otrzymaliśmy z Semigroup e. W wypadku kiedy błąd występuje pierwszy raz, zwracamy instancję błędu, anulując częściowy wynik. W wypadku, gdy wszystko się powiodło, przekazujemy instancję a do funkcji f.

Zastosowania?

Wróćmy do przykładu z osobą. Na osobę możemy nałożyć różne ograniczenia. Ja wybrałem takie:

  • imię i nazwisko jest niepuste;
  • wiek jest większy lub równy 18 lat.

Wobec tego napiszmy taką walidację.

validateUser :: Person -> Validated [String] Person
validateUser user = pure Person <*> validateName user <*> validateSurname user <*> validateAge user
    where
        validateName    (Person name _ _)     = filter "name: should be non-empty"      (not . null) name
        validateSurname (Person _ surname _)  = filter "surname: should be non-empty"   (not . null) surname
        validateAge     (Person _ _ age)      = filter "age: should be >= 18"           (>= 18)      age
        
        filter msg p val = if p val then Pass val else Fail [msg]

Eleganckie, prawda? Wtedy wystarczy uruchomić w REPLu:

*Main> let person = Person "Jan" "" 12
*Main> validateUser person
Fail ["surname: should be non-empty", "age: should be >= 18"]

Podsumowanie

Funktory aplikatywne bywają niedostrzeżone, ale niesłusznie. Potrafią wnosić logikę do tworzenia obiektów w zależności, jakiego funktora użyjemy. Oczywiście nie tylko walidacje można uzyskać za pomocą funktora aplikatywnego, ale też możemy tworzyć różnego rodzaju budowniczych.

Jednakże, jak można zauważyć ta metoda ma pewne ograniczenia w stosunku do domyślnych parametrów, które z łatwością można zaimplementować w obiektowym budowniczym. Z drugiej strony, można wykorzystać ku temu monoidy, w których elementem neutralnym będą domyślne ustawienia, a operacja binarna będzie scalać ustawienia. To tworzy również wygodny interfejs konfiguracyjny, jak również może przydać się do konstrukcji obiektów.

Również istnieją podejścia hybrydowe --- jest klasa typów Alternative, która opisana jest w dokumentacji jako monoid określony na funktorach aplikatywnych, co wydaje się równie ciekawym urozmaiceniem funktora aplikatywnego.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment