Introduction aux Applicative Functors (Partie I)

Aujourd’hui j’aimerais parler des applicative functors. C’est une abstraction qui est souvent ignorée, certainement à cause de la notoriété (exagérée) des monades. Comme les monades, les applicatives functors permettent de séquencer des opérations.

En ce sens, elles sont moins puissantes que les monades mais suffisantes dans de nombreux cas. Nous verrons pourquoi par la suite.

Dans applicative functor, il y a functor ! Rappelons sa définition:

trait Functor[F[_]] {
  def map[A, B](fa: F[A])(f: A => B): F[B]
}

Cette abstraction est très utile car elle permet d’appliquer une fonction indépendamment du contexte F. Voici une implémentation simple avec le type Option

val optionFunctor = new Functor[Option]{
  def map[A, B](fa: Option[A])(f: A => B): Option[B] = fa match {
    case Some(a) => Some(f(a))
    case None    => None
  }
}

Pour le type Option c’est très pratique car on a plus besoin de vérifier s’il y a une valeur ou non. L’abstraction Functor s’en occupe pour nous. La librairie standard de Scala fourni une méthode map qui permet d’avoir le même résultat. La seule différence vient du fait que la librairie n’a aucune représentation de la notion de Functor. Nous pouvons donc simplifier son implémentation comme ceci:

val optionFunctor = new Functor[Option]{
  def map[A, B](fa: Option[A])(f: A => B): Option[B] = fa map f
}

Passons maintenant à la définition d’Applicative functor:

trait Applicative[F[_]] {
  def pure[A](v: => A): F[A]

  def ap[A, B](ff: F[A => B], fa: F[A]): F[B]
}

La méthode pure permet à partir d’une valeur simple A d’obtenir une valeur dans un contexte F (F[A])

L’objectif de la méthode ap est d’extraire de ff une fonction (A => B) que l’on appliquera à la valeur de type A extraite de fa.

Voici l’implémentation du type Option

val optionApplicative = new Applicative[Option]{

  def pure[A](v: => A): Option[A] = Some(v)

  def ap[A, B](ff: Option[A => B], fa: Option[A]): Option[B] = (ff, fa) match {
    case (Some(f), Some(a)) => Some(f(a))
    case _                  => None
  }
}

L’utilité de cette abstraction n’est peut-être pas évidente au premier coup d’œil mais ça viendra par la suite.

Grâce à cette abstraction, nous pouvons définir quelques méthodes utiles comme celles-ci:

  object Applicative {
    def apply[F[_]](implicit F: Applicative[F]) = F
  }

  def liftA2[F[_]: Applicative, A, B, C](fa: F[A], fb: f[B])(f: (A, B) => C): F[C] = {
    import Applicative[F]._

    ap(ap(pure(f.curried), fa), fb)
  }

  def liftA3[F[_]: Applicative, A, B, C, D](fa: F[A], fb: f[B], fc: F[C])(f: (A, B, C) => D): F[D] = {
    import Applicative[F]._

     ap(ap(ap(pure(f.curried), fa), fb), fc)
  }

  // ... on peut continuer comme ça au moins jusqu'à vingt 🙂

}

La fonction liftA2 permet à partir de 2 valeurs F[A] et F[B], d’en extraire leurs contenus, de les passer en paramètre de la fonction f et de construire un nouveau contexte avec la valeur issue de l’application de f.

La fonction liftA3 est identique à liftA2 sauf qu’elle s’applique sur 3 valeurs F[A], F[B] et F[C]

Utilisons encore notre type Option avec un cas d’utilisation simple:

def addApplicativeOption(a: Option[Int], b: Option[Int], c: Option[Int]): Option[Int] =
  liftA3(a, b, c)(_ + _ + _)

Si nous avions implémenté cette méthode monadiquement, elle serait ainsi:

def addMonadOption(a: Option[Int], b: Option[Int], c: Option[Int]): Option[Int] =
  for {
    x <- a
    y <- b
    z <- c
  } yield x + y + z

Alors certains dirons « Oui mais… Nous pourrions également implémenter une fonction utilitaire pour avoir la même concision ! ». Ce qui est vrai mais malheureusement comme pour Functor, la librairie standard de Scala ne fourni pas de représentation pour la notion de monade. Donc on ne pourrait pas savoir si une valeur supporte une opération monadique. Le sucre syntaxique for n’est possible dans notre cas que si l’instance utilisée implémente les fonctions map et flatMap (ce qui ne rassemble pas toutes les conditions nécessaires pour être une monade !) Pour combler ce manque, nous utiliserons cette représentation:

trait Monad[F[_]]{
  def point[A](v: => A): F[A]

  def bind[A, B](fa: F[A])(f: A => F[B]): F[B]
}

l’implémentation pour le type Option est directe

val optionMonad = new Monad[Option]{
  def point[A](v: => A): Option[A] = Some(v)

  def bind[A, B](fa: Option[A])(f: A => Option[B]): Option[B] = fa flatMap f
}

Définissons maintenant les fonctions utilitaires équivalentes à celles de l’abstraction Applicative

 object Monad {
    def apply[F[_]](implicit F: Monad[F]) = F
  }

  def liftM2[F[_]: Monad , A, B, C](fa: F[A], fb: f[B])(f: (A, B) => C): F[C] = {
    import Monad[F]._

    bind(fa){
      a =>
        bind(fb){
          b =>
            point(f(a, b))
        }
    }
  }

  def liftM3[F[_]: Monad, A, B, C, D](fa: F[A], fb: f[B], fc: F[C])(f: (A, B, C) => D): F[D] = {
    import Monad[F]._

     bind(fa){
       a =>
         bind(fb){
           b =>
             bind(fc){
              c =>
               point(f(a, b, c))
             }
         }
     }
  }

  // ... on peut continuer comme ça au moins jusqu'à vingt 🙂

La version monadique est plus complexe car il y a plusieurs inclusions de fonctions contrairement à la version applicative.

Ce qui revient pour notre méthode d’exemple:

def addMonadOption(a: Option[Int], b: Option[Int], c: Option[Int]): Option[Int] =
  liftM3(a, b, c)(_ + _ + _)

liftA3 et liftM3 ne se limitent pas qu’aux options. Elles peuvent s’appliquer sur n’importe quel type qui implémente le type de classe Applicative (liftA2) ou Monad (liftM3). Nous pouvons écrire une version plus générique de nos méthodes addApplicativeOption et addMonadOption:

def addApplicative[F[_]: Applicative](a: F[Int], b: F[Int], c: F[Int]): F[Int] =
  liftA3(a, b, c)(_ + _ + _)

def addMonad[F[_]: Monad](a: F[Int], b: F[Int], c: F[Int]): F[Int] =
  liftM3(a, b, c)(_ + _ + _)

Nous avons 2 abstractions différentes mais le résultat est identique. Quel(s) point(s) les différencient ?

Principalement dans l’évaluation !

Comparons l’implémentation des méthodes ap et bind pour le type Option:

def ap[A, B](ff: Option[A => B], fa: Option[A]): Option[B] = (ff, fa) match {
  case (Some(f), Some(a)) => Some(f(a))
  case (None   , Some(_)) => None
  case (Some(_), None)    => None
  case (None,    None)    => None
}

def bind[A, B](fa: Option[A])(f: A => Option[B]): Option[B] = fa match {
  case Some(a) => f(a)
  case None    => None
}

Dans la méthode ap, on voit que même si ff est à None, on évalue le paramètre fa. Pour les Applicatives, un contexte précédant ne peut pas influencer l’évaluation du suivant !

C’est pourquoi les monades sont plus puissantes car un contexte précédant peut influencer le suivant. Par exemple la méthode bind appliquée aux options, si fa est à None, on évalue pas le reste.

Cette caractéristique des monades les rend moins applicables que les Applicatives. Donc si votre type ne forme pas une monade, il y a de fortes chances qu’il soit un applicative functor !

D’autres conclusions peuvent être faites. Si votre type forme une monade, alors il forme obligatoirement un applicative functor ! La méthode suivante permet de construire un applicative functor pour F si et seulement si F est une monade:

def monadApplicativeFunctor[F[_]](implicit F: Monad[F]) = new Applicative[F]{
  def pure[A](v: => A): F[A] = F.point(v)

  def ap[A, B](ff: F[A => B], fa: F[A]): F[B] =
    F.bind(ff){
      f =>
        F.bind(fa){
          a =>
            F.point(f(a))
        }
    }
}

Si votre type forme un applicative functor, alors il forme obligatoirement un functor. Exemple:

def ApplicativeToFunctor[F[_]](implicit F: Applicative[F]) = new Functor[F]{
  def map[A, B](fa: F[A])(f: A => B): F[B] = F.ap(F.pure(f), fa)
}

Par implication, si votre type forme une monade, alors il forme obligatoirement un functor. Exemple

def monadFunctor[F[_]](implicit F: Monad[F]) = new Functor[F]{
  def map[A, B](fa: F[A])(f: A => B): F[B] = F.bind(fa)(a => F.point(f(a)))
}

En résumé:

  1. Les applicative functors permettent la composition et le sequencement de valeurs contextualisées comme les monades
  2. Ils sont moins puissants mais peuvent s’appliquer dans plus de situations que les monades
  3. Ils sont très efficaces lorsque l’on veut composer des contextes qui n’ont aucunes dépendances entre eux

Nous verrons dans une prochaine publication une utilisation plus concrète des applicative functors. Nous verrons comme exemple la validation de données.

7 réflexions au sujet de « Introduction aux Applicative Functors (Partie I) »

  1. Enfin un article en français sur la programmation fonctionnelle en Scala!
    Il y en a d’excellents en anglais, mais ça fait plaisir de pouvoir échanger dans sa langue maternelle.
    Cette première partie est assez théorique et j’espère que tu pourras rapidement nous montrer des exemples.

    Quand tu écris « La méthode pure permet à partir d’une valeur simple A d’obtenir une valeur dans un contexte F (F[A]) », il faut bien interpréter « (F[A]) » comme une précision? En première lecture, on a l’impression que le contexte est « F (F[A]) »

    Suite au prochain épisode

    Benoît

  2. Merci pour cet article en français : il y a des blogs excellents sur le sujet en anglais, mais pouvoir échanger dans sa langue maternelle peut simplifier les choses.
    Le contenu est assez théorique et la suite, plus concrète, sera la bienvenue.

    Tu écris « La méthode pure permet à partir d’une valeur simple A d’obtenir une valeur dans un contexte F (F[A]) » : si je comprends bien, « (F[A]) » est une précision? dit autrement, le contexte est-il « F », « F[A] » ou « F (F[A]) »?

    Benoît

  3. Merci pour ton retour.

    Alors quand je parle d’une valeur A dans un contexte F, c’est pour exprimer F[A].

    Cette première partie est un peu théorique, mais je n’ai pas parlé des lois qui régissent cette abstraction, en me disant que peut-être que c’était trop et pas utile pour une utilisation courante.

    Le point que je voulais surtout montrer (et je ne sais pas si ça ressort dans l’article), c’est que les applicatives sont trop souvent ignorés. C’est bien dommage car ils sont très efficaces (notamment avec des types traversables comme les collections).

    Le prochain article sera plus concret 🙂

  4. Je m’aperçois que j’ai posté deux fois le même commentaire : désolé pour la fausse manip!
    Je suis d’accord sur ton point de vue : j’ai utilisé les applicatives et les monades conjointement pour valider des données. De façon un peu simpliste (je te laisse le soin de développer ça de façon rigoureuse dans tes articles 🙂 ), un applicative me permet d’enchaîner plusieurs vérifications sans qu’une erreur interrompe la chaîne, tandis qu’une monade permet de stopper à la première erreur : ça permet d’implémenter très facilement les notions de warning et d’erreur fatale.
    Pour ce qui est de la théorie, je pense qu’il y a de très bons articles, même s’ils sont en anglais et que de longs développements ne s’imposent pas : il faut surtout des exemples.

  5. Ping : Applicative Functor (Partie II) | Deikonad

  6. Ça pourrait être utile de préciser que tu utilises un mécanisme de typeclass pour décrire ces abstractions 🙂

    Pour présenter les applicatives, j’aime bien commencer par montrer ce qu’il se passe quand on essaye d’appliquer une fonction de plusieurs arguments à des foncteurs (ça explique pourquoi on se retrouve avec une fonction dans un contexte). Évidemment c’est plus facile à voir avec Haskell (currying par défaut, opérateurs infixes…)

    • Je suis d’accord pour les applicatives, d’ailleurs je l’ai fait pour l’implémentation des fonctions liftA2 et liftA3. Mais c’est beaucoup plus visuel en Haskell. N’hésite pas non plus à critiquer le style d’écriture ou les explications.J’ai cru comprendre que tu es prof. Ton avis me permettra de m’améliorer 🙂

Répondre à Benoit Hericher (@bhericher) Annuler la réponse.