Programmation Fonctionnelle en Java Pur: Exemples de Foncteurs et de Monades

Cet article était initialement une annexe de notre livre de Programmation Réactive avec RxJava. Cependant, une introduction aux monades, bien que très liée à la programmation réactive, ne convenait pas très bien. J’ai donc décidé de le retirer et de le publier séparément sous forme de billet de blog. Je suis conscient que « ma propre explication à moitié correcte et à moitié complète des monades » est le nouveau « Bonjour, monde » sur les blogs de programmation. Pourtant, l’article examine les foncteurs et les monades sous un angle spécifique des structures de données et des bibliothèques Java. J’ai donc pensé que cela valait la peine de partager.

RxJava a été conçu et construit sur des concepts très fondamentaux tels que les foncteurs, les monoïdes et les monades. Même si Rx a été modélisé initialement pour le langage C # impératif et que nous apprenons sur RxJava, travaillant au-dessus d’un langage impératif similaire, la bibliothèque a ses racines dans la programmation fonctionnelle. Vous ne devriez pas être surpris après avoir réalisé à quel point l’API RxJava est compacte. Il n’y a à peu près qu’une poignée de classes de base, généralement immuables, et tout est composé en utilisant principalement des fonctions pures.

Avec l’essor récent de la programmation fonctionnelle (ou style fonctionnel), le plus souvent exprimée dans les langages modernes comme Scala ou Clojure, les monades sont devenues un sujet largement discuté. Il y a beaucoup de folklore autour d’eux:

Une monade est un monoïde dans la catégorie des endofoncteurs, quel est le problème?

James Iry

La malédiction de la monade est qu’une fois que vous obtenez l’épiphanie, une fois que vous comprenez – « oh c’est ce que c’est » – vous perdez la capacité de l’expliquer à n’importe qui.

Douglas Crockford

La grande majorité des programmeurs, en particulier ceux qui n’ont pas de formation en programmation fonctionnelle, ont tendance à croire que les monades sont un concept arcanique de l’informatique, si théorique qu’il ne peut peut-être pas aider dans leur carrière de programmation. Cette perspective négative peut être attribuée à des dizaines d’articles et de billets de blog trop abstraits ou trop étroits. Mais il s’avère que les monades sont tout autour de nous, même dans une bibliothèque Java standard, surtout depuis Java Development Kit (JDK) 8 (plus à ce sujet plus tard). Ce qui est absolument brillant, c’est qu’une fois que vous comprenez les monades pour la première fois, plusieurs classes et abstractions sans rapport, servant des buts entièrement différents, deviennent soudainement familières.

Les monades généralisent divers concepts apparemment indépendants, de sorte que l’apprentissage d’une autre incarnation de la monade prend très peu de temps. Par exemple, vous n’avez pas besoin d’apprendre comment CompletableFuture fonctionne en Java 8 – une fois que vous réalisez qu’il s’agit d’une monade, vous savez précisément comment cela fonctionne et à quoi pouvez-vous vous attendre de sa sémantique. Et puis vous entendez parler de RxJava qui sonne tellement différent mais parce qu’Observable est une monade, il n’y a pas grand-chose à ajouter. Il existe de nombreux autres exemples de monades que vous avez déjà rencontrés sans le savoir. Par conséquent, cette section sera un rappel utile même si vous ne parvenez pas à utiliser réellement RxJava.

Foncteurs

Avant d’expliquer ce qu’est une monade, explorons une construction plus simple appelée foncteur. Un foncteur est une structure de données typée qui encapsule certaines valeurs. D’un point de vue syntaxique, un foncteur est un conteneur avec l’API suivante:

import java.util.function.Function;interface Functor<T> { <R> Functor<R> map(Function<T, R> f);}

Mais une simple syntaxe ne suffit pas pour comprendre ce qu’est un foncteur. La seule opération fournie par functor est map() qui prend une fonction f. Cette fonction reçoit tout ce qui se trouve à l’intérieur d’une boîte, la transforme et enveloppe le résultat tel quel dans un second foncteur. Veuillez le lire attentivement. Functor < T > est toujours un conteneur immuable, donc map ne mute jamais l’objet d’origine sur lequel il a été exécuté. Au lieu de cela, il renvoie le résultat (ou les résultats – soyez patient) enveloppé dans un tout nouveau foncteur, éventuellement de type différent R. De plus, les foncteurs ne doivent effectuer aucune action lorsque la fonction d’identité est appliquée, c’est-à-dire map(x-> x). Un tel modèle doit toujours renvoyer le même foncteur ou une instance égale.

Souvent, le foncteur < T > est comparé à une instance contenant une boîte de T où la seule façon d’interagir avec cette valeur est de la transformer. Cependant, il n’y a pas de moyen idiomatique de déballer ou de s’échapper du foncteur. La ou les valeurs restent toujours dans le contexte d’un foncteur. Pourquoi les foncteurs sont-ils utiles ? Ils généralisent plusieurs idiomes communs comme les collections, les promesses, les options, etc. avec une API unique et uniforme qui fonctionne sur chacun d’eux. Permettez-moi de vous présenter quelques foncteurs pour vous rendre plus fluide avec cette API:

interface Functor<T,F extends Functor<?,?>> { <R> F map(Function<T,R> f);}class Identity<T> implements Functor<T,Identity<?>> { private final T value; Identity(T value) { this.value = value; } public <R> Identity<R> map(Function<T,R> f) { final R result = f.apply(value); return new Identity<>(result); }}

Un paramètre de type F supplémentaire était nécessaire pour compiler l’identité. Ce que vous avez vu dans l’exemple précédent était le foncteur le plus simple tenant simplement une valeur. Tout ce que vous pouvez faire avec cette valeur est de la transformer à l’intérieur de la méthode map, mais il n’y a aucun moyen de l’extraire. Ceci est considéré comme dépassant le cadre d’un foncteur pur. La seule façon d’interagir avec le foncteur est d’appliquer des séquences de transformations de type-safe:

Identity<String> idString = new Identity<>("abc");Identity<Integer> idInt = idString.map(String::length);

Ou couramment, tout comme vous composez des fonctions:

Identity<byte> idBytes = new Identity<>(customer) .map(Customer::getAddress) .map(Address::street) .map((String s) -> s.substring(0, 3)) .map(String::toLowerCase) .map(String::getBytes);

De ce point de vue, le mappage sur un foncteur n’est pas très différent de l’appel de fonctions chaînées:

byte bytes = customer .getAddress() .street() .substring(0, 3) .toLowerCase() .getBytes();

Pourquoi vous embêteriez-vous même avec un emballage aussi verbeux qui non seulement n’apporte aucune valeur ajoutée, mais n’est pas non plus capable d’extraire le contenu? Eh bien, il s’avère que vous pouvez modéliser plusieurs autres concepts en utilisant cette abstraction de foncteur brut. Par exemple, à partir de Java 8, Optional est un foncteur avec la méthode map(). Mettons-le en œuvre à partir de zéro:

class FOptional<T> implements Functor<T,FOptional<?>> { private final T valueOrNull; private FOptional(T valueOrNull) { this.valueOrNull = valueOrNull; } public <R> FOptional<R> map(Function<T,R> f) { if (valueOrNull == null) return empty(); else return of(f.apply(valueOrNull)); } public static <T> FOptional<T> of(T a) { return new FOptional<T>(a); } public static <T> FOptional<T> empty() { return new FOptional<T>(null); }}

Maintenant, cela devient intéressant. Un foncteur FOptional<T> peut contenir une valeur, mais tout aussi bien il peut être vide. C’est un moyen sûr d’encoder null. Il existe deux façons de construire FOptional – en fournissant une valeur ou en créant une instance empty(). Dans les deux cas, tout comme avec Identity, FOptional est immuable et nous ne pouvons interagir avec la valeur que de l’intérieur. Ce qui diffère FOptional, c’est que la fonction de transformation f ne peut être appliquée à aucune valeur si elle est vide. Cela signifie que functor n’encapsule pas nécessairement exactement une valeur de type T. Il peut tout aussi bien envelopper un nombre arbitraire de valeurs, tout comme List… foncteur:

import com.google.common.collect.ImmutableList;class FList<T> implements Functor<T, FList<?>> { private final ImmutableList<T> list; FList(Iterable<T> value) { this.list = ImmutableList.copyOf(value); } @Override public <R> FList<?> map(Function<T, R> f) { ArrayList<R> result = new ArrayList<R>(list.size()); for (T t : list) { result.add(f.apply(t)); } return new FList<>(result); }}

L’API reste la même: vous prenez un foncteur dans une transformation – mais le comportement est très différent. Maintenant, nous appliquons une transformation sur chaque élément de la liste, transformant de manière déclarative toute la liste. Donc, si vous avez une liste de clients et que vous voulez une liste de leurs rues, c’est aussi simple que:

import static java.util.Arrays.asList;FList<Customer> customers = new FList<>(asList(cust1, cust2));FList<String> streets = customers .map(Customer::getAddress) .map(Address::street);

Ce n’est plus aussi simple que de dire customers.getAddress().street(), vous ne pouvez pas appeler getAddress() sur une collection de clients, vous devez appeler getAddress() sur chaque client individuel, puis le replacer dans une collection. À propos, Groovy a trouvé ce modèle si commun qu’il a en fait un sucre de syntaxe pour cela: customer*.getAddress()*.street(). Cet opérateur, connu sous le nom de spread-dot, est en fait un map déguisé. Peut-être vous demandez-vous pourquoi je parcourt list manuellement à l’intérieur de map plutôt que d’utiliser Stream s de Java 8: list.stream().map(f).collect(toList())? Ça vous dit quelque chose ? Et si je vous disais que java.util.stream.Stream<T> en Java est également un foncteur? Et au fait, aussi une monade?
Maintenant, vous devriez voir les premiers avantages des foncteurs – ils abstiennent la représentation interne et fournissent une API cohérente et facile à utiliser sur diverses structures de données. Comme dernier exemple, permettez-moi de vous présenter le foncteur promise, similaire à Future. Promise « promet » qu’une valeur sera disponible un jour. Il n’est pas encore là, peut-être parce qu’un calcul d’arrière-plan a été engendré ou que nous attendons un événement externe. Mais cela apparaîtra dans le futur. La mécanique de l’achèvement d’un Promise<T> n’est pas intéressante, mais la nature du foncteur est:

Promise<Customer> customer = //...Promise<byte> bytes = customer .map(Customer::getAddress) .map(Address::street) .map((String s) -> s.substring(0, 3)) .map(String::toLowerCase) .map(String::getBytes);

Ça vous semble familier? C’est le but ! L’implémentation du foncteur dépasse le cadre de cet article et n’est même pas importante. Assez pour dire que nous sommes très proches d’implémenter CompletableFuture à partir de Java 8 et que nous avons presque découvert Observable à partir de RxJava. Mais revenons aux foncteurs. La promesse < Client > ne détient pas encore de valeur de client. Il promet d’avoir une telle valeur à l’avenir. Mais nous pouvons toujours mapper un tel foncteur, tout comme nous l’avons fait avec FOptional et FList – la syntaxe et la sémantique sont exactement les mêmes. Le comportement suit ce que représente le foncteur. Appel du client.map(Customer::getAddress) donne une promesse < Adresse >, ce qui signifie que la carte n’est pas bloquante. client.map() promet au client de compléter. Au lieu de cela, il renvoie une autre promesse, d’un type différent. Lorsque la promesse en amont est terminée, la promesse en aval applique une fonction transmise à map() et transmet le résultat en aval. Soudain, notre foncteur nous permet de canaliser des calculs asynchrones de manière non bloquante. Mais vous n’avez pas besoin de comprendre ou d’apprendre cela – parce que Promise est un foncteur, il doit suivre la syntaxe et les lois.

Il existe de nombreux autres grands exemples de foncteurs, par exemple représentant la valeur ou l’erreur de manière compositionnelle. Mais il est grand temps de regarder les monades.

Des foncteurs aux Monades

Je suppose que vous comprenez comment fonctionnent les foncteurs et pourquoi sont-ils une abstraction utile. Mais les foncteurs ne sont pas aussi universels qu’on pourrait s’y attendre. Que se passe-t-il si votre fonction de transformation (celle passée en argument à map()) renvoie une instance de foncteur plutôt qu’une simple valeur? Eh bien, un foncteur est juste une valeur aussi, donc rien de mal ne se passe. Tout ce qui a été renvoyé est replacé dans un foncteur afin que tout se comporte de manière cohérente. Cependant, imaginez que vous avez cette méthode pratique pour analyser les chaînes:

FOptional<Integer> tryParse(String s) { try { final int i = Integer.parseInt(s); return FOptional.of(i); } catch (NumberFormatException e) { return FOptional.empty(); }}

Les exceptions sont des effets secondaires qui minent le système de type et la pureté fonctionnelle. Dans les langages fonctionnels purs, il n’y a pas de place pour les exceptions. Après tout, nous n’avons jamais entendu parler d’exceptions pendant les cours de mathématiques, n’est-ce pas? Les erreurs et les conditions illégales sont représentées explicitement à l’aide de valeurs et de wrappers. Par exemple, tryParse() prend une chaîne mais ne renvoie pas simplement un int ou ne lance pas silencieusement une exception à l’exécution. Nous disons explicitement, à travers le système de types, que tryParse() peut échouer, il n’y a rien d’exceptionnel ou d’erroné à avoir une chaîne mal formée. Ce semi-échec est représenté par un résultat optionnel. Fait intéressant, Java a vérifié les exceptions, celles qui doivent être déclarées et gérées, donc dans un certain sens, Java est plus pur à cet égard, il ne cache pas les effets secondaires. Mais pour le meilleur ou pour le pire, les exceptions vérifiées sont souvent découragées en Java, alors revenons à tryParse(). Il semble utile de composer tryParse avec une chaîne déjà enveloppée dans FOptional:

FOptional<String> str = FOptional.of("42");FOptional<FOptional<Integer>> num = str.map(this::tryParse);

Cela ne devrait pas être une surprise. Si tryParse() renvoyait un int, vous obtiendrez FOptional<Integer> num, mais parce que la fonction map() renvoie FOptional<Integer> elle-même, elle est enveloppée deux fois dans un FOptional<FOptional<Integer>> gênant. Veuillez regarder attentivement les types, vous devez comprendre pourquoi nous avons cette double enveloppe ici. En plus d’avoir l’air horrible, avoir un foncteur dans la composition du foncteur ruine la composition et le chaînage fluide:

FOptional<Integer> num1 = //...FOptional<FOptional<Integer>> num2 = //...FOptional<Date> date1 = num1.map(t -> new Date(t));//doesn't compile!FOptional<Date> date2 = num2.map(t -> new Date(t));

Ici, nous essayons de mapper le contenu de FOptional en transformant int en +Date+. Ayant une fonction de int -> Date, nous pouvons facilement passer de Functor<Integer> à Functor<Date>, nous savons comment cela fonctionne. Mais en cas de num2 la situation devient compliquée. Ce que num2.map() reçoit en entrée n’est plus un int mais un FOoption<Integer> et évidemment java.util.Date n’a pas un tel constructeur. Nous avons cassé notre foncteur en l’enveloppant deux fois. Cependant, avoir une fonction qui renvoie un foncteur plutôt qu’une simple valeur est si courant (comme tryParse()) que nous ne pouvons pas simplement ignorer une telle exigence. Une approche consiste à introduire une méthode spéciale sans paramètre join() qui « aplatit » les foncteurs imbriqués:

FOptional<Integer> num3 = num2.join()

Cela fonctionne mais comme ce modèle est si commun, une méthode spéciale nommée flatMap() a été introduite. flatMap() est très similaire à map mais s’attend à ce que la fonction reçue en argument renvoie un foncteur – ou une monade pour être précise:

interface Monad<T,M extends Monad<?,?>> extends Functor<T,M> { M flatMap(Function<T,M> f);}

Nous avons simplement conclu que flatMap n’est qu’un sucre syntaxique pour permettre une meilleure composition. Mais la méthode flatMap (souvent appelée bind ou >>= de Haskell) fait toute la différence car elle permet de composer des transformations complexes dans un style pur et fonctionnel. Si FOptional était une instance de monad, l’analyse fonctionne soudainement comme prévu:

FOptional<String> num = FOptional.of("42");FOptional<Integer> answer = num.flatMap(this::tryParse);

Les monades n’ont pas besoin d’implémenter map, elles peuvent être implémentées au-dessus de flatMap() facilement. En fait, flatMap est l’opérateur essentiel qui permet un tout nouvel univers de transformations. De toute évidence, tout comme avec les foncteurs, la conformité syntaxique ne suffit pas pour appeler une monade de classe a, l’opérateur flatMap() doit suivre les lois des monades, mais elles sont assez intuitives comme l’associativité de flatMap() et l’identité. Ce dernier nécessite que m(x).flatMap(f) soit identique à f(x) pour toute monade contenant une valeur x et toute fonction f. Nous n’allons pas plonger trop profondément dans la théorie des monades, concentrons-nous plutôt sur les implications pratiques. Les monades brillent lorsque leur structure interne n’est pas triviale, par exemple la monade Promise qui aura une valeur dans le futur. Pouvez-vous deviner à partir du système de type comment Promise se comportera dans le programme suivant? Tout d’abord, toutes les méthodes qui peuvent potentiellement prendre un certain temps pour terminer renvoient un Promise:

import java.time.DayOfWeek;Promise<Customer> loadCustomer(int id) { //...}Promise<Basket> readBasket(Customer customer) { //...}Promise<BigDecimal> calculateDiscount(Basket basket, DayOfWeek dow) { //...}

Nous pouvons maintenant composer ces fonctions comme si elles bloquaient toutes à l’aide d’opérateurs monadiques:

Promise<BigDecimal> discount = loadCustomer(42) .flatMap(this::readBasket) .flatMap(b -> calculateDiscount(b, DayOfWeek.FRIDAY));

Cela devient intéressant. flatMap() doit conserver le type monadique, donc tous les objets intermédiaires sont Promise s. Il ne s’agit pas seulement de garder les types en ordre – le programme précédent est soudainement entièrement asynchrone! loadCustomer() renvoie un Promise pour qu’il ne bloque pas. readBasket() prend tout ce que Promise a (aura) et applique une fonction renvoyant une autre Promise et ainsi de suite. Fondamentalement, nous avons construit un pipeline de calcul asynchrone où l’achèvement d’une étape en arrière-plan déclenche automatiquement l’étape suivante.

Explorer flatMap()

Il est très courant d’avoir deux monades et de combiner la valeur qu’elles regroupent ensemble. Cependant, les foncteurs et les monades ne permettent pas un accès direct à leurs internes, ce qui serait impur. Au lieu de cela, nous devons appliquer soigneusement la transformation sans échapper à la monade. Imaginez que vous avez deux monades et que vous souhaitez les combiner:

import java.time.LocalDate;import java.time.Month;Monad<Month> month = //...Monad<Integer> dayOfMonth = //...Monad<LocalDate> date = month.flatMap((Month m) -> dayOfMonth .map((int d) -> LocalDate.of(2016, m, d)));

Veuillez prendre le temps d’étudier le pseudo-code précédent. Je n’utilise aucune implémentation de monade réelle comme Promise ou List pour souligner le concept de base. Nous avons deux monades indépendantes, l’une de type Month et l’autre de type Integer. Afin de construire LocalDate à partir d’eux, nous devons construire une transformation imbriquée qui a accès aux internes des deux monades. Parcourez les types, en vous assurant notamment de comprendre pourquoi nous utilisons flatMap à un endroit et map() à l’autre. Pensez à la façon dont vous structureriez ce code si vous aviez également un troisième Monad<Year>. Ce modèle d’application d’une fonction de deux arguments (m et d dans notre cas) est si courant que dans Haskell, il existe une fonction d’assistance spéciale appelée liftM2 qui effectue exactement cette transformation, implémentée au-dessus de map et flatMap. En pseudo-syntaxe Java, cela ressemblerait un peu à ceci:

Monad<R> liftM2(Monad<T1> t1, Monad<T2> t2, BiFunction<T1, T2, R> fun) { return t1.flatMap((T1 tv1) -> t2.map((T2 tv2) -> fun.apply(tv1, tv2)) );}

Vous n’avez pas besoin d’implémenter cette méthode pour chaque monade, flatMap() suffit, de plus, cela fonctionne de manière cohérente pour toutes les monades. liftM2 est extrêmement utile lorsque vous considérez comment il peut être utilisé avec diverses monades. Par exemple, listM2(list1, list2, function) appliquera function sur toutes les paires d’articles possibles de list1 et list2 (produit cartésien). D’un autre côté, pour les options, il n’appliquera une fonction que lorsque les deux options ne sont pas vides. Mieux encore, pour une monade Promise , une fonction sera exécutée de manière asynchrone lorsque les deux Promise sont terminées. Cela signifie que nous venons d’inventer un mécanisme de synchronisation simple (join() dans les algorithmes fork-join) de deux étapes asynchrones.

Un autre opérateur utile que nous pouvons facilement construire au-dessus de flatMap() est filter(Predicate<T>) qui prend tout ce qui se trouve à l’intérieur d’une monade et le rejette entièrement s’il ne répond pas à certains prédicats. D’une certaine manière, il est similaire à map mais plutôt qu’un mappage de 1 à 1, nous avons 1 à 0 ou 1. Encore une fois, filter() a la même sémantique pour chaque monade mais des fonctionnalités assez étonnantes en fonction de la monade que nous utilisons réellement. Évidemment, cela permet de filtrer certains éléments d’une liste:

FList<Customer> vips = customers.filter(c -> c.totalOrders > 1_000);

Mais cela fonctionne tout aussi bien, par exemple pour les options. Dans ce cas, nous pouvons transformer une option non vide en une option vide si le contenu de l’option ne répond pas à certains critères. Les options vides sont laissées intactes.

De la Liste des Monades à la Monade de la Liste

Un autre opérateur utile qui provient de flatMap() est sequence(). Vous pouvez facilement deviner ce qu’il fait simplement en regardant la signature de type:

Monad<Iterable<T>> sequence(Iterable<Monad<T>> monads)

Souvent, nous avons un tas de monades du même type et nous voulons avoir une seule monade d’une liste de ce type. Cela peut sembler abstrait pour vous, mais c’est incroyablement utile. Imaginez que vous vouliez charger simultanément quelques clients de la base de données par ID, vous avez donc utilisé la méthode loadCustomer(id) plusieurs fois pour différents ID, chaque appel renvoyant Promise<Customer>. Maintenant, vous avez une liste de Promise s mais ce que vous voulez vraiment, c’est une liste de clients, par exemple à afficher dans le navigateur Web. L’opérateur sequence() (dans RxJava sequence() est appelé concat() ou merge(), selon le cas d’utilisation) est construit juste pour cela:

FList<Promise<Customer>> custPromises = FList .of(1, 2, 3) .map(database::loadCustomer);Promise<FList<Customer>> customers = custPromises.sequence();customers.map((FList<Customer> c) -> ...);

Avoir un FList<Integer> représentant les ID client que nous map dessus (voyez-vous comment cela aide que FList soit un foncteur?) en appelant database.loadCustomer(id) pour chaque identifiant. Cela conduit à une liste plutôt gênante de Promise s. sequence() sauve la mise, mais encore une fois, ce n’est pas seulement un sucre syntaxique. Le code précédent est entièrement non bloquant. Pour différents types de monades sequence() a toujours du sens, mais dans un contexte de calcul différent. Par exemple, il peut changer FList<FOptional<T>> en FOptional<FList<T>>. Et en passant, vous pouvez implémenter sequence() (tout comme map()) en plus de flatMap().

Ce n’est que la pointe de l’iceberg en ce qui concerne l’utilité de flatMap() et des monades en général. Bien qu’elles proviennent plutôt d’une théorie des catégories obscure, les monades se sont révélées être une abstraction extrêmement utile, même dans les langages de programmation orientés objet tels que Java. Être capable de composer des fonctions renvoyant des monades est si universellement utile que des dizaines de classes non liées suivent un comportement monadique.

De plus, une fois que vous encapsulez des données dans une monade, il est souvent difficile de les extraire explicitement. Une telle opération ne fait pas partie du comportement de la monade et conduit souvent à un code non idiomatique. Par exemple, Promise.get() sur Promise<T> peut techniquement renvoyer T, mais uniquement en bloquant, alors que tous les opérateurs basés sur flatMap() ne sont pas bloquants. Un autre exemple est FOptional.get(), mais cela peut échouer car FOptional peut être vide. Même FList.get(idx) qui regarde un élément particulier d’une liste semble gênant car vous pouvez remplacer les boucles for par map() assez souvent.

J’espère que vous comprenez maintenant pourquoi les monades sont si populaires de nos jours. Même dans un langage orienté objet (-ish) comme Java, ils constituent une abstraction assez utile.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée.