Programmez avec des effets

Les programmeurs Haskell aiment bien l'abstraction. Les classes de types servent à ça : elles permettent de déclarer des opérations qu'on peut effectuer sur beaucoup de types différents. Il existe des classes plutôt simples, comme Num ou Eq : on comprend très vite leur utilité et comment s'en servir. Mais d'autres sont plus compliquées : c'est le cas des classes Functor et Applicative que vous allez découvrir dans ce chapitre. Ils permettent d'abstraire beaucoup d'opérations courantes, comme par exemple le fait d'appliquer à une fonction "pure" des arguments donc l'évaluation peut échouer, entrainer des effets sur l'extérieur ou dépendre des opérations effectuées précédemment.

Les foncteurs

Avant de commencer à expliquer les foncteurs, voilà la classe en question :

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

Différentes sortes de constructeurs de types

Contrairement à ce que vous avez pu voir avant, le paramètre f de cette classe n'est pas un type : f a n'a pas de sens si f est un type. Par contre, si on remplace f par un constructeur de type (comme par exemple Maybe, on trouve fmap :: (a -> b) -> Maybe a -> Maybe b. Maybe n'est pas un type en lui-même, mais on peut lui passer un type pour obtenir un type : c'est pour cela qu'on dit que c'est un constructeur de type.

On peut aussi créer des types prenant un constructeur de type prenant un paramètre en paramètre. On rajoute une instance de Show qui ne fait rien pour pouvoir l'utiliser plus facilement dans ghci.

1
2
3
4
data OfInt c = OfInt (c Int)

instance Show (OfInt c) where
    show _ = "OfInt"

On peut construire quelques valeurs de ce type (mais pas les afficher, parce qu'on n'a pas défini de vraie instance de Show).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
*Main> :set +t   (Active l'affichage des types après chaque expression)
*Main> OfInt []
OfInt
it :: OfInt []
*Main> OfInt (Just 42)
OfInt
it :: OfInt Maybe
*Main> OfInt (Right 12)
OfInt
it :: OfInt (Either a)

On pourrait même créer des données de type OfInt IO contenant un IO Int, c'est-à-dire une action IO renvoyant un entier. Le troisième exemple est le plus intéressant à voir : on a appliqué partiellement le type Either à a, et on a obtenu quelque chose d'équivalent à un constructeur de type comme Maybe.

Vous vous doutez peut-être de la suite : il y a bien des types là-dessous, ou plutôt des types de types. Ils sont appelés kind (sortes), et on peut les voir avec la commande :kind dans ghci. Un type a le kind *, et on utilise, comme pour les types normaux, -> pour noter une fonction. Quelques exemples :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
*Main> :kind Int
Int :: *
*Main> :kind Maybe
Maybe :: * -> *
*Main> :kind Either Int
Either Int :: * -> *
*Main> :kind Either
Either :: * -> * -> *
*Main> :kind OfInt
OfInt :: (* -> *) -> *
*Main> :kind (->)
(->) :: ?? -> ? -> *

Le dernier kind peut vous intriguer un peu. (->), qui permet de définir le type des fonctions, est un constructeur de type comme les autres. Cependant, les kinds de ses arguments sont ?? et ?, et pas * comme on pourrait s'y attendre normalement. En réalité, ?? et ? sont des kind qui contiennent le kind , en plus d'autres kind particuliers beaucoup moins utilisés. Vous pouvez donc, pour l'instant, les considérer comme des .

Qu'est-ce qu'un foncteur

Un foncteur dispose donc, d'après la définition, d'une fonction fmap. Un premier exemple de foncteur, c'est les listes : il suffit de poser fmap = map. Sur les listes, fmap f applique donc f à tous les éléments contenus dans la liste. On peut utiliser cette idée pour trouver les instances de Functor pour d'autres types de conteneurs.

Par exemple, on peut définir une instance de Functor pour Maybe (vous n'avez pas besoin de charger cette définition, elle est déjà dans le Prelude) :

1
2
3
instance Functor Maybe where
    fmap f Nothing = Nothing
    fmap f (Just a) = Just (f a)
1
2
3
4
Prelude> fmap (*2) Nothing
Nothing
Prelude> fmap (*2) (Just 21)
Just 42

On peut aussi, de la même façon, définir une instance pour Either. Cependant, Either prend deux paramètres : il faut donc l'appliquer partiellement pour obtenir un type qui prend un seul paramètre :

1
2
3
instance Functor (Either a) where
    fmap f (Left l) = Left l
    fmap f (Right r) = Right (f r)

Si on définit un autre conteneur, par exemple un arbre, on peut aussi définir une instance associée :

1
2
3
4
5
6
data Tree a = Leaf | Branch (Tree a) a (Tree a)
    deriving Show

instance Functor Tree where
    fmap f Leaf = Leaf
    fmap f (Branch l e r) = Branch (fmap f l) (f e) (fmap f r)

Il y a une instance de Functor pour les paires : on applique la fonction au deuxième élément, en laissant le premier inchangé.

1
2
instance Functor ((,) e) where
    fmap f (e, x) = (e, f x)

Sur des conteneurs, pour définir une instance de Functor, il suffit donc d'appliquer la fonction à toutes les valeurs de type a. Mais Functor ne s'applique pas qu'à des conteneurs : si vous listez les instances de Functor définies par défaut avec la commande :info, vous trouverez qu'IO est une instance de Functor :

1
2
3
4
5
6
Prelude> :info Functor
class Functor f where fmap :: (a -> b) -> f a -> f b
          -- Defined in GHC.Base
instance Functor Maybe -- Defined in Data.Maybe
instance Functor IO -- Defined in GHC.IOBase
instance Functor [] -- Defined in GHC.Base

En effet, on peut définir une fonction mapIO comme ceci (elle est déjà définie dans Control.Monad et s'appelle liftM).

1
2
3
4
mapIO :: (a -> b) -> IO a -> IO b
mapIO f x = do
      vx <- x
      return $ f vx

On peut aussi définir une instance pour les fonctions : la première ligne est instance Functor ((->) r) where. Comme avec les opérateurs normaux, on a transformé -> en un opérateur utilisable comme une fonction, et on l'a appliqué partiellement. Le type de fmap doit donc être : fmap :: (a -> b) -> (->) r a -> (->) r b, c'est-à-dire : fmap :: (a -> b) -> (r -> a) -> (r -> b) : on retrouve la composition de fonctions, et fmap = (.).

Des règles à respecter

Quand on écrit une instance de Functor, on s'attend à ce que quelques règles soient respectées : le foncteur doit bien se comporter par rapport à l'identité et la composition de fonctions. On peut écrire ces règles comme ceci :

1
2
fmap id == id
fmap (f . g) == fmap f . fmap g

Vous pouvez définir des instances ne respectant pas ces lois, mais c'est une mauvaise idée puisque certaines fonctions sur les foncteurs peuvent supposer qu'elles sont vraies, et donc donner un résultat faux avec votre foncteur. On peut par exemple vérifier que notre instance de Maybe respecte ces lois : on a fmap id Nothing == Nothing et fmap id (Just a) == Just (id a) == Just a, donc la première loi est respectée. Pour la deuxième loi, fmap (f . g) Nothing == Nothing et fmap f (fmap g Nothing) == fmap f Nothing == Nothing, et pour Just : fmap f (fmap g (Just a)) == fmap f (Just (g a)) == Just (f (g a)) == Just ((f . g) a) == fmap (f . g) (Just a). Les deux lois sont respectées, on a donc une instance valide.

Composer des foncteurs

La structure des foncteurs est assez simple, mais elle a l'avantage de s'appliquer à beaucoup de types. Il est aussi très facile de composer des foncteurs simples pour créer des foncteurs plus complexes. Par exemple, une liste de liste est un foncteur : on peut créer une fonction map2 f = map (map f), c'est-à-dire : map2 = map . map. Cependant, on ne peut pas écrire une instance de Functor pour les listes de listes, puisqu'ensuite, quand on utilise fmap, il n'est pas toujours possible de déterminer si on souhaite utiliser fmap sur les listes (avec les listes comme éléments), ou sur les listes de listes.

On peut de même composer généralement deux foncteurs f et g, en définissant un type O f g représentant la composition des deux constructeurs de types :

1
2
3
4
5
newtype O f g a = O (f (g a))
    deriving Show

instance (Functor f, Functor g) => Functor (O f g) where
    fmap f (O v) = O $ fmap (fmap f) v

newtype permet, comme data, de définir un nouveau type, mais avec quelques restrictions : le type ne doit avoir qu'un seul constructeur, qui ne prend qu'un argument. Ces restrictions permettent au compilateur de ne pas avoir à mettre un constructeur et à l'enlever : à l'exécution, le type est exactement le même que le type original, mais il est différent pour la vérification de types. Cette astuce sert souvent à créer une copie d'un type, mais ayant des instances différentes de certaines classes de types. On a aussi indiqué une contrainte sur l'instance, notée (Functor f, Functor g) => : pour que O f g soit un foncteur avec cette définition, f et g doivent avoir des instances de Functor.

Si vous ne voulez pas , vous pouvez utiliser fmap . fmap. Avec l'instance de Functor pour les fonctions, on peut aussi écrire fmap2 = fmap fmap fmap (et pour cumuler les niveaux, rajoutez des fmap).

Il existe d'autres manières de composer des foncteurs. Par exemple, une paire de foncteurs est aussi un foncteur.

1
2
3
4
5
newtype P f g a = P (fmap f a, fmap g a)
    deriving Show

instance (Functor f, Functor g) => Functor (P f g) where
    fmap f (P (a,b)) = P (f a, f b)

On peut tester ces deux instances :

1
2
3
4
5
6
*Main> fmap fmap fmap (*2) [[1..2],[5..9]]
[[2,4],[10,12,14,16,18]]
*Main> fmap (*2) (O [[1..2],[5..9]])
O [[2,4],[10,12,14,16,18]]
*Main> fmap (*2) (P (Just 12, [1,2,3]))
P (Just 24,[2,4,6])

Qu'est-ce qui n'est pas un foncteur ?

Vous avez vu qu'on peut déclarer une instance valide de Functor pour beaucoup de types. Mais existe-t-il des types pour lesquels il n'y a pas d'instance de Functor ? La réponse est oui.

Il y a d'abord le problème des constructeurs de types qui prennent une contrainte sur leur argument : par exemple, le type Set du module Data.Set sert à stocker un ensemble de valeur. Pour des raisons d'efficacité, il suppose que les valeurs sont stockées dans un certain ordre dans l'ensemble (la structure de données utilisée est une variante des arbres binaires de recherche). Après l'opération fmap, on est donc obligé de retrier la structure, mais pour ça il faut rajouter une contrainte sur le type d'arrivée pour qu'il soit ordonné. On a donc une sorte de foncteur restreint, avec une opération (Ord b) => (a -> b) -> Set a -> Set b, mais pas de foncteur général.

Mais certains types ne sont même pas des foncteurs restreints. C'est par exemple le cas de l'application de fonction, par rapport à son premier argument, donc en fixant le deuxième :

1
newtype RFun b a = RFun { unRFun :: a -> b }

On utilise ici la syntaxe des enregistrements, puisqu'elle permet de créer en même temps la fonction permettant de sortir le type original du conteneur créé par newtype. Si on voulait une instance de Functor pour RFun t, la fonction fmap aurait le type fmap :: (a -> b) -> RFun t a -> RFun t b, c'est-à-dire un type équivalent à (a -> b) -> (a -> t) -> (b -> t). Mais il n'existe pas de fonctions de ce type respectant les règles pour un foncteur (intuitivement, comme les types a, b et t sont inconnus, on ne peut utiliser que les fonctions prises en paramètre pour les traiter et un paramètre de type b, mais on ne peut pas construire de valeur de type t de cette façon). Le type des fonctions est donc un foncteur pour la valeur à droite de la flèche (le type renvoyé), mais pas pour le type pris en entrée. On peut cependant définir une sorte d'«anti foncteur» qui serait l'équivalent des foncteurs pour ce type, avec une fonction mapf :: (a -> b) -> (f b -> f a). Ici, la fonction devrait (si on oublie RFun) être de type mapf :: (a -> b) -> (b -> t) -> (a -> t), et la définition mapf = flip (.) convient.

Quelques utilisations des foncteurs

On peut par exemple utiliser les foncteurs pour créer plus facilement des fonctions pouvant échouer (donc renvoyant une valeur de type Maybe a ou Either a b). Par exemple, si on souhaite créer un site web regroupant des informations sur les meilleures glaces, on dispose d'une base de données (qu'on représentera ici par une liste d'enregistrements) et une fonction rechercher renvoyant une glace par son nom. On a aussi une fonction pageGlace, renvoyant la page associée à une glace.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
data Glace = Glace {
  parfum :: String,
  fabriquant :: String,
  note :: Int
  }
  deriving Show

base = [Glace "Framboise" "Ice Crime" 20, Glace "Chocolat" "Miam Glaces" 17,
        Glace "Vanille" "Pole Sud" 16]

rechercher :: [Glace] -> String -> Either String Glace
rechercher base gl = case filter (\g -> parfum g == gl) base of
    [] -> Left "Cette glace n'existe pas"
    x:_ -> Right x

pageGlace :: Glace -> String
pageGlace g = "Glace goût " ++ parfum g ++ " fabriquée par " ++ fabriquant g 
              ++ ". Note des lecteurs : " ++ show (note g)

Pour afficher la page d'une glace ou une erreur (si on suppose que le serveur web s'occupera de générer automatiquement un message d'erreur avec une réponse Left), il suffit donc de faire servirPage g = fmap pageGlace (rechercher base g). Si la base de données était stockée à l'extérieur, le résultat de rechercher serait de type IO (Either String Glace). Mais comme la composée de deux foncteurs est un foncteur, le traitement peut se poursuivre presque de la même manière, et il n'y a pas besoin de changer la fonction pageGlace.

Appliquez des fonctions : les foncteurs applicatifs

Appliquer plusieurs arguments

Dans le chapitre sur les fonctions, on a vu qu'on pouvait définir des fonctions plus, moins, … qui renvoient Nothing en cas d'erreur comme une division par zéro :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
plus Nothing _ = Nothing
plus _ Nothing = Nothing
plus (Just a) (Just b) = Just (a + b)

moins Nothing _ = Nothing
moins _ Nothing = Nothing
moins (Just a) (Just b) = Just (a - b)

fois Nothing _ = Nothing
fois _ Nothing = Nothing
fois (Just a) (Just b) = Just (a * b)

divise Nothing _ = Nothing
divise _ Nothing = Nothing
-- la division par 0 donne un résultat indéfini
divise _ (Just 0) = Nothing
divise (Just a) (Just b) = Just (a / b)

On peut essayer d'utiliser les foncteurs pour définir plus, moins et fois de façon plus simple. On commence simplement par traiter le premier argument avec fmap (+). Mais là on se retrouve devant un problème : fmap (+) a est de type Maybe (Integer -> Integer), alors qu'on a besoin d'une fonction d'une fonction de type <mincode type="haskell">Maybe Integer -> Maybe Integer.

On a donc besoin d'une fonction de type Maybe (a -> b) -> Maybe a -> Maybe b, qu'on peut voir comme une fonction qui applique une fonction contenue dans Maybe à une valeur contenue dans Maybe, ou alors une fonction qui transforme une fonction en une autre, suivant le point de vue. On peut la coder pour Maybe :

1
2
3
app :: Maybe (a -> b) -> Maybe a -> Maybe b
app (Just f) (Just x) = Just (f x)
app _ _ = Nothing

Mais cette fonction peut très facilement se généraliser à d'autres foncteurs, comme IO :

1
2
3
4
5
appIO :: IO (a -> b) -> IO a -> IO b
appIO fm xm = do
  f <- fm
  x <- xm
  return $ f x

On peut ensuite utiliser ces fonctions (les lignes surlignées représentent l'entrée) :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
*Main> app (fmap (+) (Just 1)) (Just 2)
Just 3
*Main> app (fmap (+) Nothing) (Just 2)
Nothing
*Main> app (fmap (+) (Just 1)) Nothing
Nothing
*Main> appIO (fmap (+) readLn) readLn
1
2
3

Les foncteurs applicatifs

Définition

Puisque cette fonction se retrouve avec plusieurs types, on la généralise en utilisant la classe de type Applicative, et on dit qu'un type est un foncteur applicatif (puisqu'on peut appliquer les fonctions). La classe Applicative est définie dans le module Control.Applicative comme ceci :

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

L'opérateur <*> correspond à la fonction qu'on a appelée app, mais est plus pratique à utiliser. Le module Control.Applicative définit aussi l'opérateur <$> comme un synonyme de fmap : on peut donc écrire (+) <$> readLn <*> readLn au lieu de appIO (fmap (+) readLn) readLn.

La fonction pure sert à créer une valeur du foncteur applicatif à partir d'une valeur normale. L'instance d'Appicative doit être liée à l'instance de Functor par la relation suivante : pure f <*> v == fmap f v. Une autre règle est qu'on doit avoir : pure f <*> pure x == pure (f x) : pure transforme l'application de fonctions en <*>. Il y a trois autres règles à respecter, qui sont un peu plus compliquées à comprendre et moins utiles :

1
2
3
pure id <*> v == v
pure (.) <*> u <*> v <*> w == u <*> (v <*> w)
u <*> pure y == pure ($ y) <*> u

Importer Control.Applicative ajoute un certain nombre d'instances de Functor : vous risquez de vous retrouver avec des problèmes d'instances dupliquées. Il suffit d'enlever les définitions d'instances concernées pour régler le problème.

Exemples d'instances

Il y a aussi beaucoup d'instances d'Applicative. On a vu avant que Maybe et IO disposaient d'une fonction <*>, mais on peut aussi leur donner une fonction pure :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
instance Applicative Maybe where
    pure = Just
    Just f <*> Just x = Just $ f x
    _ <*> _ = Nothing

instance Applicative IO where
    pure = return
    fm <*> xm = do
        f <- fm
        x <- xm
       return (f x)

Par contre, même si on peut définir une instance de Functor pour (,) e, il n'y a pas d'instance d'Applicative : pour cela, il faudrait un moyen de créer une valeur de type e, et de combiner deux valeurs, qui permette de respecter les règles des foncteurs applicatifs. Si on choisit de spécialiser l'instance (par exemple aux entiers), on obtient une instance qui marche :

1
2
3
instance Applicative ((,) Integer) where
    pure x = (0,x)
    (a,f) <*> (b,x) = (a+b, f x)

Mais si vous essayez de définir cette instance, vous allez rencontrer une erreur du compilateur :

1
2
3
4
5
6
7
/home/gnomnain/haskell/tuto/functor.hs:54:0:
    Illegal instance declaration for `Applicative ((,) Integer)'
        (All instance types must be of the form (T a1 ... an)
         where a1 ... an are type *variables*,
         and each type variable appears at most once in the instance head.
         Use -XFlexibleInstances if you want to disable this.)
    In the instance declaration for `Applicative ((,) Integer)'

Cela vient d'une limitation des définitions possible de typeclasses dans la version Haskell 98 du langage. Mais GHC supporte beaucoup d'extensions à Haskell 98. Ici, il nous indique d'utiliser l'extension FlexibleInstances. Vous pouvez l'activer de deux façons : en lançant ghci avec l'option -XFlexibleInstances, ou en rajoutant tout en haut de votre fichier un commentaire avec le contenu suivant : {-# LANGUAGE FlexibleInstances #-}.

Il est possible de définir une instance similaire en faisant le produit de deux entiers, en concaténant des listes, en prenant le minimum ou le maximum, …

Pour continuer sur la gestion des erreurs, on peut aussi créer une instance d'Applicative pour Either : il suffit de prendre la première valeur d'erreur rencontrée sur les deux arguments de <*> :

1
2
3
4
5
instance Applicative (Either e) where 
    pure = Right
    Right f <*> Right x = Right $ f x
    Left e <*> _ = Left e
    Right _ <*> Left e = Left e

Mais on pourrait aussi imaginer une instance qui prend l'erreur placée la plus à droite, et pas celle-là plus à gauche :

1
2
3
4
5
instance Applicative (Either e) where 
    pure = Right
    Right f <*> Right x = Right $ f x
    _ <*> Left e = Left e    
    Left e <*> _ = Left e

On peut même envisager de stocker une liste de toutes les erreurs rencontrées :

1
2
3
4
5
6
instance Applicative (Either [e]) where
    pure = Right
    Right f <*> Right x = Right $ f x
    Left e <*> Right _ = Left e
    Right _ <*> Left f = Left f
    Left e <*> Left f = Left (e ++ f)

Mais la première instance est la plus utilisée, puisqu'elle est compatible avec l'instance de la classe Monad pour Either, que vous découvrirez dans un autre chapitre. Celle qui récupère toutes les erreurs est cependant utile, si on souhaite par exemple présenter le plus d'erreurs possibles à l'utilisateur.

On a vu que les fonctions disposaient d'une instance de Functor. On peut aussi les rendre membres de la classe Applicative. pure doit renvoyer une fonction de type r -> a à partir d'une valeur de type a : il faut logiquement renvoyer une fonction constante. On voit aussi que le type de l'opérateur <*> doit être (r -> a -> b) -> (r -> a) -> (r -> b). On en déduit alors la définition (qui est déjà présente par défaut) :

1
2
3
instance Applicative ((->) r) where
    pure = const
    f <*> x = (\r -> (f r) (x r)

Cette instance d'Applicative permet de composer des actions qui accèdent à un contexte pour leur exécution. Par exemple, si on code un interpréteur, le contexte serait la liste des variables définies et leur valeur.

Il existe aussi une instance d'Applicative pour les listes. La fonction pure renvoie une liste à un élément, et <*> crée toutes les combinaisons possibles des valeurs dans les listes de ses deux éléments. Cette fonction se créer facilement grâce à concatMap, qui combine concat et map en concaténant les résultats de la fonction donnée sur les chaque élément :

1
2
3
4
5
instance Applicative [] where
  pure x = [x]
  fs <*> xs = concatMap (\f -> map f xs) fs
-- on pourrait écrire
-- fs <*> xs = concat $ map (\f -> map f xs) fs

Cette instance est pratique dans les cas où on veut tester toutes les possibilités. Par exemple, on peut s'en servir pour savoir si une formule logique est toujours vraie (ici, ~> note l'implication) :

1
2
3
4
5
6
7
8
9
True ~> False = False
_ ~> _ = True

prop a b c = (a ~> b ~> c) ~> ((a ~> b) ~> (a ~> c))
prop2 a b c = (a ~> b) ~> ((a ~> c) ~> (b ~> c))

bool = [True,False]

test prop = prop <$> bool <*> bool <*> bool

Grâce à ce code, on peut tester une propriété sur les 8 combinaisons possibles de a, b et c :

1
2
3
4
*Main> test prop
[True,True,True,True,True,True,True,True]
*Main> test prop2
[True,True,True,True,True,False,True,True]

On voit alors que prop est toujours vraie, mais pas prop2.

Fonctions utiles

Le module Control.Applicative définit quelques fonctions utiles pour travailler avec les foncteurs. La fonction liftA2 :: (Functor f) => (a -> b -> c) -> f a -> f b -> f c permet de transformer une fonction qui travaille sur des valeurs "pures" en une fonction qui agit sur les valeurs du foncteur. Elle est facile à coder, et nous permet de factoriser les fonctions plus, moins et fois vue en introduction. La fonction liftA3 fait la même chose avec les fonctions à trois arguments :

1
2
3
4
5
6
7
liftA2 f x y = f <$> x <*> y
liftA3 f x y z = f <$> x <*> y <*> z

plus, moins, fois :: (Num a, Functor f) => f a -> f a -> f a
plus = liftA2 (+)
moins = liftA2 (-)
fois = liftA2 (*)

Pour voir le fonctionnement des fonctions suivantes, un bon exemple est le type Log, qui a une instance d'Applicative (ce n'est qu'un cas particulier des instances pour les paires) :

1
2
3
4
5
6
7
data Log e a = Log [e] a

instance Applicative (Log e) where
    pure = Log []
    Log e1 f <*> Log e2 x = Log (e1 ++ e2) (f x)

log a = Log [a] ()

On voit qu'avec cette instance, l'ordre d'exécution a une importance : liftA2 (+) (Log "1" 1) (Left "2" 2) renvoie Log "12" 3, alors que si on inverse les arguments, on obtient Log "21" 3.

Les fonctions *> et <* s'occupent d'abord du premier argument puis du deuxième, mais renvoient la valeur qui est du côté de la flèche. Par exemple, log "123" *> (log "456" *> pure 2) renvoie Log "123456" 2, alors que (log "456" *> pure 2) <* log "123" renvoie Log "456123" 2. Elles sont donc importantes quand le foncteur a une notion d'ordre. La fonction <**> est comme <*>, sauf que la séquence est inversée : f <*> x applique d'abord l'effet de f, puis de x, alors que <**> applique d'abord l'effet de x, puis celui de f. Enfin, le module Data.Traversable définit une fonction sequenceA. Elle s'applique à tous les conteneurs ayant une instance de la classe Traversable. Pour les listes, le type de la fonction est : sequenceA :: (Applicative f) => [f a] -> f [a] : les actions contenues dans la liste sont séquencées, et on renvoie la liste de leur résultats.

Une autre construction

On peut aussi construire les foncteurs applicatifs d'une autre façon. La construction avec pure et <*> est assez naturelle, mais donne des règles difficiles à établir et à interpréter. On va garder la fonction pure, mais essayer de trouver un remplacement pour ><*>. La fonction liftA2 est une bonne candidate : en effet, on a f <*>x = liftA2 ($) f x. On a donc une première définition équivalente.

La deuxième remarque qu'on peut faire c'est que les fonctions à deux arguments peuvent être converties en fonction qui prennent une coupe et vice-versa grâce aux fonctions curry :: ((a,b) -> c) -> a -> b -> c et uncurry :: (a -> b -> c) -> (a,b) -> c. Le problème qui se pose pour coder liftA2 à partir de fmap, c'est qu'il faut transormer des f a et f b en f (a,b). Si on a une fonction pour cela, on peut retrouver les opérations sur les foncteurs applicatifs. La fonction pure n'est même pas nécessaire, elle peut être remplacée par une valeur unit = pure (). En effet, on a pure a = const a <$> unit. Ces définitions sont donc liées par les relations suivantes :

1
2
3
4
5
6
unit = pure ()
pure a = const a <$> unit

(<&>) = liftA2 (,)
liftA2 f a b = f <$> (a <&> b)
f <*>x = liftA2 ($) f x

Les propriétés des foncteurs applicatifs s'écrivent de façon plus sympathique avec cette notation, et sont plus naturelles :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
-- On pose :
assoc (a,(b,c)) = ((a,b),c)

(f *** g) (x,y) = (f x, f y) 

-- Les règles :
fmap (f *** g) (x <&> y) == fmap f x <&> fmap f y
fmap fst (x <&> unit)  == x
fmap snd (unit <&> x) == x 
fmap assoc (u <&> (v <&> w)) = (u <&> v) <&> w

Cependant, en général, cette définition ne facilite pas beaucoup la définition d'une instance d'Applicative, mais risque plutôt de la compliquer puisqu'il faut définir des fonctions annexes.

Applications intéressantes

Généraliser la fonction zipWith à plus d'arguments

La fonction zipWith permet d'appliquer une fonction aux éléments de deux listes pris deux à deux. On a zipWith f [x1,x2,...] [y1,y2,...] = [f x1 y1, f x2 y2, ...]. Il existe de même une fonction zipWith3 qui prend une fonction à trois arguments et trois listes. Mais il faut recréer une fonction pour chaque nombre d'arguments, ce n'est donc pas très pratique.

Cependant, on peut coder les fonctions zipWithN (où N vaut 3, 4, …) simplement à partir de la fonction zipWith : on applique d'abord partiellement la fonction f à tous les éléments de la première liste, puis on applique les éléments de la liste obtenue à la deuxième liste, … Par exemple :

1
zipWith3' f xs ys zs = zipWith (zipWith ($) (map f xs) ys) zs

En fait, on remarque que zipWith ($) joue le rôle de l'opérateur <*>. Il reste à trouver une fonction pure qui correspond. Il faut répéter l'élément donné un certain nombre de fois. Mais si on ne le met qu'un nombre fini de fois, on peut trouver une liste plus longue, et dans ce cas on n'a pas la propriété pure id <*> u == u, puisque la liste sera tronquée. Il faut donc générer une liste infinie : c'est ce que fait la fonction repeat :: a -> [a]. Mais il y a un dernier problème : on a déjà une instance d'Applicative pour les listes. On va donc utiliser un newtype pour définir un synonyme pour les listes. Dans la bibliothèque standard, il est nommé ZipList.

1
2
3
4
5
data ZipList a = ZipList { unZipList :: [a] }

instance Applicative ZipList where
    pure = ZipList . repeat
    (ZipList f) <*> (ZipList x) = ZipList $ zipWith ($) f x

Traiter des formulaires

Si vous avez déjà programmé des sites web, par exemple en PHP, vous savez surement que traiter des formulaires peut devenir très lourd : on se retrouve parfois avec beaucoup de conditions imbriquées, du code répétitif, … C'est un cas classique de traitement des erreurs, et on a vu que les foncteurs applicatifs s'y prêtaient plutôt bien. On veut en général présenter toutes les erreurs de traitement à l'utilisateur. De plus, on a un contexte d'exécution à transporter : ce sont les valeurs reçues du formulaire. Or, comme pour les foncteurs, on peut composer les foncteurs applicatifs, donc on peut à la fois transporter un contexte et récupérer les erreurs.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type Dict = [(String,String)]

data Acc a b = Error [a] | Result b 
             deriving Show

instance Functor (Acc e) where
  fmap f x = pure f <*> x

instance Applicative (Acc e) where
  pure = Result
  Result f <*> Result x = Result (f x)
  Error e <*> Result _ = Error e
  Result _ <*> Error f = Error f
  Error e <*> Error f = Error (e ++ f)

newtype Form a = Form { runForm :: Dict -> Acc String a }

instance Functor Form where
  fmap f x = pure f <*> x

instance Applicative Form where
  pure = Form . pure . pure
  Form fe <*> Form xe = Form (liftA2 (<*>) fe xe)

On a appelé Acc le foncteur applicatif qui accumule les erreurs. La définition de <*> pour Form est en fait la définition générale pour une composée de foncteurs applicatifs : si on appelle F et G les deux foncteurs, on a une fonction G (a -> b) -> G a -> G b et on veut une fonction F (G (a -> b)) -> F (G a) -> F (G b). Il est donc naturel d'utiliser la fonctionliftA2 associée à F.

On doit ensuite coder quelques fonctions pour récupérer et vérifier les données. Le but est de créer une structure qui contient toutes les données nécessaires pour le résultat. La fonction clé est check, qui prend un prédicat (une fonction renvoyant un booléen), et vérifie s'il est respecté par les données. On a aussi quelques fonctions pour récupérer les valeurs : getInt, getString, …

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
getString :: String -> Form String
getString s = Form $ (\env -> case lookup s env of
                         Just a -> Result a
                         Nothing -> Result "")

getInt :: String -> Form Int
getInt s = Form $ (\env -> case lookup s env of
                      Just a | all (`elem` ['0'..'9']) a -> Result (read a) 
                      _ -> Error ["Le champ " ++ s ++ " n'est pas un entier"])

check :: String -> (a -> Bool) -> Form a -> Form a
check err pred val = Form $ (\env -> case runForm val env of
                                Error e -> Error e
                                Result v -> if pred v
                                            then Result v
                                            else Error [err])

On peut ensuite traiter un formulaire simple, pour récupérer un enregistrement de type Glace défini plus haut :

1
2
3
4
5
6
7
parseGlace :: Form Glace
parseGlace = liftA3 Glace 
             (check "Le champ \"parfum\" doit faire entre 1 et 40 caractères"
              (\p -> let l = length p in 1 <= l && l <= 40) $ getString "parfum")
             (getString "fabriquant")
             (check "La note doit être comprise entre 0 et 20"
              (\n -> 0 <= n && n <= 20) $ getInt "note")
1
        </mincode>

Les foncteurs et les foncteurs applicatifs sont donc un moyen de créer des fonctions pures qui ont des effets : elles peuvent garder une trace de ce qui a été exécuté, renvoyer des erreurs ou accéder à un contexte d'exécution. Ils s'appliquent aussi à beaucoup de situations, mais cela fait qu'ils ne sont pas aussi puissants qu'on le voudrait : par exemple, il n'est pas possible en général de faire dépendre l'effet d'une valeur qui est elle-même le résultat d'une action : si on a une fonction de type a -> f b, on ne peut pas en tirer une fonction f a -> f b, et donc faire dépendre l'effet du résultat des effets précédents.