Quantcast
Channel: Publicis Sapient Engineering – Engineering Done Right
Viewing all articles
Browse latest Browse all 1865

Monades + Java = monstre cosmique ?

$
0
0

Il arrive même en programmation fonctionnelle de devoir réaliser des traitements en présence de bons gros états mutables, partagés et gluants. Nous devons cette situation à la nature même du support (eg. réseau, machine — dépendant de l’architecture de von Neumann parfois mise en accusation [1] —, périphérique, etc.) ou des services (eg. I/O, base de données, bus de messages, etc.) avec lesquels le programme évolue. Afin de permettre au développeur de rester dans cette zone de confort que représente l’immutabilité, il a la possibilité de confiner cette notion d’état au sein d’un instrument portant le doux nom de monade.

Aussi académique que puisse être cette notion, les monades en informatique correspondent à un concept bien précis, qui peut s’avérer utile pour se sortir de situations délicates. Les monades ont fait leur apparition en informatique dans les années 90. Elles ont été utilisées afin de pouvoir conserver la pureté fonctionnelle au sein des applications (ie. limitation des modifications destructrices / updates) et ainsi de pouvoir bénéficier des divers optimisations qu’offre cette pureté. C’est l’objectif fixé dans le langage Haskell où l’utilisation des monades est très répandue.

Mais quid de la définition des monades ? Une petite recherche sur Wikipédia, vous permettra de trouver ce qui ressemble à une définition perdue au milieu de code à tout va et de décorations mathématiques déroutantes, à défaut de signes cabalistiques. Une recherche sur le wiki du langage Haskell vous mènera à des morceaux de code et des définitions qui ne semblent avoir ni queue ni tête. Une autre recherche enfin vous mènera peut-être à cette fausse citation issue d’un article que nous devons à James Iry (et qu’il attribue à Philip Wadler). À sa manière, cette définition représente bien l’hermétisme qui enveloppe le concept de monade.

Une monade est un monoïde de la catégorie des endofunctors, où est le problème ?
(a monad is a monoid in the category of endofunctors, what’s the problem?)

NB: depuis Philip Wadler aurait fait des progrès : http://vimeo.com/38223410

Mais pour rester dans le commun des mortels, les monades sont avant tout une abstraction permettant de représenter une séquence de traitements et de définir le comportement de cette séquence. Ainsi, les monades sont :

  1. une solution issue de la programmation fonctionnelle permettant de représenter une succession de traitements, tout comme la programmation impérative représente par une séquence d’instructions cette succession de traitements,
  2. un moyen d’encapsuler la notion d’état mutable et le confiner dans un « espace de travail »,
  3. des contextes d’exécution permettant de modifier le comportement non pas des traitements mais du chaînage sur ces traitements.

Il faut bien comprendre qu’en terme de traitement, nous nous attendons ici à des fonctions qui retournent une valeur à transmettre au traitement suivant.

Si à un moment ou à un autre, vous avez pensé ici AOP, c’est que vous êtes sur la bonne voie [2, 5].

Dans cette article, vous trouverez une introduction à la notion de monade à travers le langage Java avec implémentations et exemples. Plus précisément, nous verrons deux types de monades : la monade Option et la monade List. Nous terminerons cet article en discutant notamment de l’intérêt ou non d’utiliser les monades en Java. Bref, faisons du fonctionnel en Java :

public interface Function<T, R> {
  R apply(T value);
}

Monade Option

La monade Option est la monade la plus simple et la plus accessible. Parfois appelée Maybe, elle permet de chaîner des traitements unitaires qui peuvent potentiellement ne pas retourner de valeur. Le cas échéant, le chaînage doit être « cassé ». Autrement dit, si l’une des fonctions ne retourne pas de valeur, il faut pouvoir ignorer les fonctions qui suivent dans le chaînage. Habituellement, le développeur Java utiliserait une référence null pour représenter l’absence de valeur et se baserait sur une levée d’exception (typiquement NullPointerException) pour « casser » le chaînage. La référence nulle est ce que Tony Hoare décrit comme étant son erreur d’un milliard de dollars. Nous allons voir que Option peut être une alternative intéressante.

Définition

Pour notre monade, il nous faut d’abord représenter les notions de valeur et d’absence de valeur. C’est le rôle du type Option. Si nous représentons le type Option par une classe, elle aura alors deux sortes d’instances possibles :

  • les instances de type Some qui encapsulent une valeur,
  • les instances de type None qui représentent l’absence de valeur.

À partir de là, nous pouvons définir les opérations de la monade Option. Celle-ci, comme les autres monades d’ailleurs, contient deux principales opérations. Pour plus de clarté, nous étendrons la notation introduite par Daniel Spiewak [4].

  • wrap : qui converti un objet en Option. Cette opération est parfois nommée unit, pure ou return dans d’autres langages.
  • andThen : qui s’applique à une Option pour retourner une autre Option suivant un traitement unitaire. Cette opération est parfois nommée bind, flatMap ou notée >>= dans d’autres langages.

andThen prend en paramètre une fonction qui permet de transformer le contenu de l’Option en une autre Option. C’est cette fonction passée en paramètre qui va représenter le traitement unitaire. C’est aussi à partir d’une succession de andThen que nous représenterons un chaînage de traitements. Pour terminer, andThen est similaire au point-virgule (;), que vous utilisez sans forcément en avoir conscience entre deux instructions dans votre Java quotidien.

À ces deux opérations, nous ajoutons l’opération fail qui représente la situation d’échec ou une situation exceptionnelle. Dans le cadre du type Option, fail renvoie simplement une instance de None.

Voici l’implémentation choisie pour le type Option qui est représenté par le biais d’une classe abstraite.

public abstract class Option<T> {
  // opérations de base
  public abstract T get();
  public abstract boolean isPresent();
  // opérations monadiques
  public static <T> Option<T> wrap(T element) { return new Some<T>(element); }
  public static <T> Option<T> fail() { return new None<T>(); }
  public abstract <U> Option<U> andThen(Function<T, Option<U>> function);
}

Et voici l’implémentation des sous-classes None et Some :

public class None<T> extends Option<T> {
  public T get() { throw new IllegalStateException(); }
  public boolean isPresent() { return false; }
  public <U> Option<U> andThen(Function<T, Option<U>> function) {
    return new None<U>();
  }
}
public class Some<T> extends Option<T> {
  private T element;
  private Some(T element) { this.element = element; }
  public T get() { return element; }
  public boolean isPresent() { return true; }
  public <U> Option<U> andThen(Function<T, Option<U>> function) {
    return function.apply(this.element);
  }
}

Exemple : accès des Map de Map de Map de…

Nous allons voir maintenant comment utiliser la monade Option. Pour nous mettre en situation, nous allons partir d’un exemple de code comme nous pouvons en trouver dans des projets légués. Le principe sera ici d’accéder à la capitale d’un pays classée par continent et par pays. Pour cela, nous allons organiser ces données au moyen d’une structure récursive et complexe basée sur des Map (la classe Splitter ci-dessous provient de Guava).

Splitter.MapSplitter splitter = Splitter.on(", ").withKeyValueSeparator(" -> ");
Map<String, Map<String, String>> capitalCities = new HashMap<String, Map<String, String>>() {{
  put("Europe", splitter.split("France -> Paris, Espagne -> Madrid"));
  put("Amérique", splitter.split("Etats-Unis -> Washington"));
}};

Potentiellement, pour récupérer ici la capitale d’un pays en se basant sur la programmation impérative, il est nécessaire de passer un ensemble de if imbriqués pour vérifier qu’à chaque étape du parcours de la structure de données, nous n’ayons pas récupéré une référence null. Avec notre monade Option, le code permettant d’accéder à la capitale d’un pays s’en trouve simplifié.

Option<String> city;
city = Option.wrap(map)
             .andThen(accessContinent("Europe"))
             .andThen(accessCountry("France"));
assertThat(city.isPresent()).isTrue();
assertThat(city.get()).isEqualTo("Paris");
city = Option.wrap(map)
             .andThen(accessContinent("Europe"))
             .andThen(accessCountry("Assyrie"));
assertThat(city.isPresent()).isFalse();

Pour obtenir ce résultat, il faut définir les méthodes accessContinent et accessCountry. Pour cela, nous utilisons la méthode getFromKey, sachant que les deux méthodes d’accès ont le même objectif : accéder à un élément d’une Map par une clé et retourner l’instance d’Option qui convient selon la présence ou non de la clé. getFromKey prend donc en entrée une clé, mais elle renvoie une closure — c’est-à-dire une fonction conservant le contexte d’exécution dans lequel elle a été créée —. En effet, au moment d’appeler getFromKey, nous n’avons pas encore de Map sur laquelle effectuer la recherche de la clé. Nous retournons donc une closure qui emmagasine la clé dans son propre contexte. Elle va donc attendre un appel avec en entrée une Map et se servir de la clé fournie précédemment pour obtenir la valeur associée en appelant la méthode get. Nous avons ici l’expression de l’évaluation retardée à la Java. Selon le résultat retourné par get, la closure nous renvoie soit une instance de None en cas d’absence de résultat soit une instance de Some sinon.

public static Function<Map<String, Map<String, String>>, Option<Map<String, String>>> accessContinent(String continent) {
    return getFromKey(continent);
}
public static Function<Map<String, String>, Option<String>> accessCountry(String country) {
    return getFromKey(country);
}
public static <K, V> Function<Map<K, V>, Option<V>> getFromKey(final K key) {
    return new Function<Map<K, V>, Option<V>>() {
        @Override
        public Option<V> apply(Map<K, V> map) {
            V value = map.get(key);
            if (value == null) {
                return Option.none();
            }
            return Option.wrap(value);
        }
    };
}

Notons que le fait d’être obligé de définir des méthodes tels que accessCountry ou accessContinent est nécessaire, plutôt que d’utiliser directement la méthode getFromKey. En effet, utiliser directement la méthode getFromKey vous contraindrait à devoir résoudre à la main des problèmes de type générique plus complexes que ceux présents ici. Néanmoins, il faut reconnaître qu’écrire ces quelques lignes de code n’est pas forcément évident pour tout le monde. Mais vous devez le faire, car Java ne sait pas encore deviner pour vous la signature des méthodes. C’est aussi pour ça qu’en Java un bon IDE est nécessaire. En principe, celui-ci est capable de vous aider à déterminer, voire de générer pour vous, la signature de méthodes tels que accessCountry ou accessContinent.

Monade List

Nous avons vu la monade Option. Celle-ci peut prendre deux formes : une forme vide et une forme singleton. En fait, Option est en quelque sorte un conteneur. On pourrait très bien représenter le type Option au moyen d’une List ayant soit aucun élément pour représenter None soit un élément pour représenter Some. Mais que représenterai dans ce cas une liste contenant plus d’un élément ?

Car oui, même dans ce cas, une liste est aussi une monade. Et avec la monade List, vous allez pouvoir adresser des problèmes liés au non-déterminisme.

Définition

Nous allons retrouver les opérations monadiques que nous avons vues précédemment :

  • wrap qui retourne un singleton à partir de l’objet passé en paramètre,
  • fail qui retourne une liste vide,
  • andThen.

Comme la classe java.util.List ne possède de pas ces opérations monadiques, nous allons passer par un wrapper que nous nommerons ListM. Pour faciliter l’exploitation des instances de cette classe notamment dans les boucles for each, nous lui faisons implémenter l’interface Iterable afin que ListM définisse la méthode iterator.

public class ListM<T> implements Iterable<T> {
  private List<T> list;
  public ListM(List<T> list) {
    this.list = list;
  }
  // opérations de base
  public List<T> get() { return list; }
  @Override
  public Iterator<T> iterator() { return list.iterator(); }
  // opérations monadiques
  public static <T> ListM<T> wrap(T element) { return new ListM<T>(Collections.singletonList(element)); }
  public static <T> ListM<T> fail() { return new ListM<T>(Collections.EMPTY_LIST); }
  public <T, U> ListM<U> andThen(Function<T, ListM<U>> function) { return flatten(map(function)); }
  // opération complémentaire
  public static <T> ListM<T> of(List<T> list) { return new ListM<T>(list); }
}

Les opérations flatten et map dont dépend andThen sont définies ci-dessous. map a pour rôle d’appliquer une fonction sur chaque élément d’une liste. Il en ressort une liste contenant les éléments transformés. Dans le cas de notre monade List, chaque élément est converti en une instance de ListM, contenant zéro, une ou plusieurs valeurs selon ce que retourne la fonction donnée en paramètre. Donc, plus exactement, il ressort de map une liste de listes, qu’il faut aplatir. C’est le rôle de flatten.

public <U> ListM<U> map(Function<T, U> function) {
  return new ListM<U>(Lists.transform(list, function));
}
public static <T> ListM<T> flatten(ListM<ListM<T>> list) {
  List<T> result = new ArrayList<T>();
  for (ListM<T> sublist : list) {
    result.addAll(sublist.get());
  }
  return new ListM<T>(result);
}
// autre définition pour flatten
public static <T> ListM<T> flatten(ListM<T>... list) {
  List<T> result = new ArrayList<T>();
  for (ListM<T> sublist : list) {
    result.addAll(sublist.get());
  }
  return new ListM<T>(result);
}

Avec les monades List, nous allons être capable d’établir des relations entre des ensembles de valeurs. Nous ajoutons donc l’opération guard afin d’exprimer ce type de relation. Dans le code ci-dessous, il faut bien comprendre que les valeurs retournées par guard nous importent peu. Ce qui nous intéresse c’est l’effet qu’elle aura sur le chaînage. Si le paramètre de guard est true, alors le chaînage doit pouvoir continuer. Dans le cas contraire, si le paramètre est false, le chaînage doit être « cassé ». Pour en faciliter sa compréhension, il faut voir guard comme un filtre ou un if au niveau du chaînage.

public static ListM<Object> guard(boolean ok) {
  return ok ? ListM.wrap(null) : fail();
}

Exemple : augmentation de salaire !

Nous allons voir un exemple d’utilisation de la monade liste. L’exemple ci-dessous permet de mettre en place des augmentations de salaire pour un ensemble d’employés. Ces augmentations suivent une règle complexe :

  • 10 % pour les salaires de moins de 20’000,
  • 5 % pour les salaires à partir de 20’000 jusqu’à 30’000,
  • 2 % pour les salaires à partir de 30’000 jusqu’à 100’000.
List<Double> SALARIES = Arrays.asList(10000.0, 20000.0, 30000.0, 40000.0, 50000.0);
ListM<Double> result = ListM.flatten(
    ListM.of(SALARIES).andThen(whenSalaryInRange(    0.0,  20000.0, increaseSalaryBy(0.10))),
    ListM.of(SALARIES).andThen(whenSalaryInRange(20000.0,  40000.0, increaseSalaryBy(0.05))),
    ListM.of(SALARIES).andThen(whenSalaryInRange(30000.0, 100000.0, increaseSalaryBy(0.02))));
assertThat(result.get()).containsExactly(11000.0, 21000.0, 30600.0, 40800.0, 51000.0);

Dans cet exemple, chaque ligne située en paramètre dans la méthode flatten produit une liste dont nous donnerons les détails plus bas. flatten permet de concaténer ces listes. Remarquons que dans cet exemple, la liste SALARIES est parcourue trois fois, soit à chaque fois que ListM.of(SALARIES)... apparaît.

Les définitions de whenSalaryInRange et increaseSalaryBy sont données ci-dessous. increaseSalaryBy applique une augmentation par rapport à un taux. whenSalaryInRange vérifie qu’un salaire se situe dans une fourchette donnée grâce à la méthode guard. Le cas échéant, elle exécute la fonction donnée en paramètre.

public static Function<Double, ListM<Double>> whenSalaryInRange(
      final double lower,
      final double upper,
      final Function<Double, ListM<Double>> function) {
  return new Function<Double, ListM<Double>>() {
    @Override
    public ListM<Double> apply(final Double salary) {
      return ListM.guard((lower <= salary) && (salary < upper))
                  .andThen(apply(function, salary));
    }
  };
}
public static <T, R> Function<Object, R> apply(Function<T, R> function, final T element) {
  return new Function<Object, R>() {
    @Override
    public R apply(Object _) {
      return function.apply(element);
    }
  };
}
public static Function<Double, ListM<Double>> increaseSalaryBy(final double rate) {
  return new Function<Double, ListM<Double>>() {
    @Override
    public ListM<Double> apply(Double salary) {
      return ListM.wrap(salary * (1.0 + rate));
    }
  };
}

Le but de apply est de retarder l’évaluation de la fonction passée en paramètre sur salary. En effet, dans whenSalaryInRange, guard va vérifier que le salaire qui sera fournit est bien présent dans les bornes données en paramètres. Nous avons donc d’abord besoin de savoir si l’évaluation de guard à réussi avant de pouvoir appliquer function, sans quoi son évaluation n’a aucun sens. Remarquez l’utilisation de _ au niveau de la fonction retournée par la méthode apply. Il s’agit, par convention, d’indiquer que le paramètre doit être présent mais que nous ne tenons pas compte de sa valeur.

Conclusion

Nous venons de voir les implémentations de deux monades : la monade Option et la monade List. La monade Option permet de gérer une succession d’opérations sans que nous ayons à gérer explicitement l’absence de valeur. La monade List permet, elle, d’appliquer des opérations unitaires sur des ensembles de valeurs. Au delà de toute considération académique, ce qu’apportent en premier lieu les monades dans ce que nous avons vu, c’est la possibilité de traiter des objets sans avoir à se soucier réellement des cas particuliers. Tout ce que vous avez à faire est d’écrire votre succession d’opérations. Si un cas exceptionnel apparaît, il sera absorbé par la monade et transparaîtra d’une manière ou d’une autre dans le résultat sans que cela se traduise par une fin tragique à coup de chaînage d’exceptions sans origine apparente. Cependant, la valeur ajoutée des monades peut sembler moyenne voire minime en Java. Elle varie d’un côté avec notre aisance à lire et écrire des types génériques alambiqués. Elle varie aussi avec notre capacité à comprendre l’enchevêtrement de closures qui n’est pas forcément mis à l’honneur avec la verbosité de Java (même si elle est améliorée avec Java 8 ) et ne rentre pas non plus forcément dans les habitudes du développeur Java. Enfin, et c’est surtout valable pour la monade List, il n’y a aucun gain de performance.

Le gain que va apporter les monades en Java ne se situe donc pas dans la définition des monades et des fonctions annexes ni dans les performances. Le gain des monades en Java se situe en fait dans son intégration dans le code métier et dans la lisibilité qu’elle y apporte. L’adoption des monades en Java sera donc contraint par le gain en lisibilité et le besoin en performance que peut vous apporter une solution impérative. Si vous êtes en présence de code légué et torturé, il y a de fortes chances qu’une monade puisse vous aider à y voir plus clair. Par contre, si vous avez une bonne maîtrise du code de votre projet, la solution impérative sera en général préférable. Mais, dans le cas où c’est la monade qui gagne, prenez le temps d’expliquer le concept à vos collègues en faisant un des points techniques et par le biais du pair programming.

L’ensemble des monades présentées ici est loin d’être exhaustif. Il y en a d’autres pour des usages divers et variés, mais qui n’ont pas forcément d’intérêt en Java au vu de la complexité qu’aurait leur implémentation. Parmi celles-ci, nous avons la monade Writer qui offre un moyen d’ajouter des informations à chaque traitement exécuté et de les agréger. Plus concrètement, la monade Writer permet de mettre en place un système de log ou un système de calcul d’un sous-total. La monade Reader permet de disposer d’un contexte d’exécution en lecture seule. La monade IO permet de confiner les opérations d’entrée/sortie. Toutes ces monades existent afin de conserver la pureté fonctionnelle de l’application et se justifient parfaitement pour des langages comme Scala et plus particulièrement pour le langage Haskell. Ces deux langages offrent un bon nombre de facilités d’écriture pour définir et utiliser des monades ; facilités qui manquent malheureusement en Java.

Vous pouvez comparer ce qui a été vu ici avec l’approche proposée par Scala pour les monades à travers la série d’articles de David Galichet sur le sujet, à commencer par [3].

Enfin, une petite citation issue de Twitter :

@codemonkeyism With all monads you can *flatmap that shit* (© @runarorama) and that powers all of them .. (including Option)
@debasishg – 7:23 PM – 15 Mar 12

Références

Illustration : Godzilla vs. Cosmic Monster, Downtown Distribution, 1974.


Viewing all articles
Browse latest Browse all 1865

Trending Articles