https://lafor.ge/feed.xml

Les monades sans les maths

2020-11-27

Pour tout dire, une monade dans X est juste un monoïde dans la catégorie des endofunctors de X, avec des produits de X remplacé par la composition d'endofunctors et de l'unité de l'identité endofunctor.

Si vous avez déjà regardé de la programmation fonctionnelle au moins une fois, vous êtes surement tombé sur cette blague de nerd barbu.

Et vous vous êtes surement dit le fonctionnel c'est pas pour moi c'est trop compliqué. Alors on va essayer de démystifier tout ça.

Avant propos

La suite de l'article se fera en Typescript.

Pourquoi lui et pas un autre? Déjà c'est celui que je maîtrise le mieux, ensuite c'est celui qui permet le pont le plus rapide entre le non fonctionnel et le fonctionnel tout en étant suffisamment typé pour être représentatif.

Ceci n'est pas non plus un cours de Typescript, je ne reviendrait donc pas sur les concepts et la grammaire du langage, je suppose que vous connaissez le langage.

On m'a aussi fait remarquer que mon article était inspiré voir très inspiré de l'Apéro Fonctionnel.

J'avais complètement oublié son existence par contre apparemmment les exemples m'avait marqué, comme vous allez pouvoir le voir par la suite 🤣.

Donc rendons à César ce qui est a César, merci à Quentin Adam, Philippe Charrière, Etienne Issartial, Thomas Guenoux, Nicolas Leroux; de m'avoir à l'époque fait découvir tous ces concepts que j'ai muri pendant plus de 3 ans 🥳.

Aussi cet article doit être pris en compte comme un état de l'art de mes connaissances du fonctionnel à l'heure où j'écris cet article.

Ces précautions prises, je vous souhaite une bonne lecture 😉

Les Conteneurs : rendre immutable le monde

Le premier concept que l'on va découvrir est ce que l'on appelle les containers. Rien à voir avec les conteneurs de Docker.

On est entre gens sérieux 😋 Non moi je vous parle des conteneurs fonctionnels.

La définition d'un conteneur est quelque chose de plutôt simple, il s'agit d'une boîte où on ne peut pas modifier ce qu'il y a dedans.

Exemple:

class Container<T> {
    private readonly value : T;

    constructor(value: T) {

        this.value = value
    }

    getValue() : T {

        return this.value
    }
}

Voici une implémentation possible d'un conteneur, une propriété privée value est initialisée au travers d'un constructeur et possède un acesseur.

Pour utiliser ce conteneur, vous pouvez écrire quelque chose comme:

Je sais que vous aimez l'alcool, pardon pour ceux qui ne l'apprécie pas ^^". Imaginez unez bouteille de poire qui aurait la capacité fort appréciable de ne jamais être vide.

const bouteille_magique : Container<String> =  new Container("🍐")

Deux choses sont à remarquer ici. Tout d'abord la valeur "chat" ne pourra plus jamais être modifié. En effet value est privée et ne possède pas de mutateur.

Si vous tentez des fantaisies comme remplacer la poire par de la vodka:

bouteille_magique.value = "🥔"

Vous allez vous manger des erreurs de compilation

error TS2341: Property 'value' is private and only accessible within class 'Container<T>'

Parfait notre alcool est sécurité 🍾.

Ce qui est exellent avec cette boutteille c'est qu'elle peut indéfiniment se remplir !!

const bouteille_magique = new Container("🍐")
console.log(bouteille_magique)
const verre = bouteille_magique.getValue()
const verre2 = bouteille_magique.getValue()
const verre3 = bouteille_magique.getValue()
console.log("verre 1: ", verre) // "🍐"
console.log("verre 2: ", verre2) // "🍐"
console.log("verre 3: ", verre3) // "🍐"

On peut vider autant de fois nos verres la bouteille est toujours pleine !

console.log(bouteille_magique) // Container { value : "🍐" }

Les Foncteurs : agir sur un conteneur

Bon c'est génial on a quelque chose dans une boîte mais on ne peut plus rien en faire. Alors c'est cool les boîtes mais dans la vraie vie c'est un peu limité ...

C'est là que vienne à la rescousse les Functors, qui sont des Containers munie d'une fonction map

D'abord un petit sucre syntaxique:

interface Func<T,TResult>
{
    (item: T): TResult;
}

Cela définie une fonction qui prend un type T en paramètre et renvoie un type R.

Le Functor en lui-même est extrêment concis, voyez plutôt:

class Functor<T> extends Container<T> {

    map<R>(f: Func<T, R>) : Functor<R> {
        return new Functor(f(this.getValue()))
    }
}

La fonction map prend en paramètre une fonction f et retourne un nouveau Functor d'un nouveau type <R>.

Il s'utilise comme un Container normal à ceci près qu'il est capable de modifier les données de Container, ou plutôt de renvoyer un clone modifié.

Reprenons notre bouteille magique. Imaginons que notre poire n'est pas suffisamment "poirée" à notre goût.

const ajouter_poire = x => x + "🍐" 

const bouteille_magique = new Functor("🍐");
console.log(bouteille_magique)
console.log(
    bouteille_magique
    .map(ajouter_poire)
    .map(ajouter_poire)
    .getValue()
); // "🍐🍐🍐"
console.log(bouteille_magique.getValue()) // "🍐"

En effet la valeur contenu dans bouteille_magique ne change pas. On conserve l'immutabilité tout en permettant une évolution contrôlée.

Et maintenant en tant que breton je veux remplacer la 🍐 par de la 🍎

const ajouter_poire = x => x + "🍐" 
const remplacer_par_du_chouchen = to_replace => x => x.replace(new RegExp(to_replace, "g"), "🍎")

const bouteille_magique = new Functor("🍐");

const poire_qui_tabasse = bouteille_magique
                            .map(ajouter_poire)
                            .map(ajouter_poire);

console.log("Poire qui tabasse avant transmutation", poire_qui_tabasse.getValue()) // Poire qui tabasse avant tansmutation 🍐🍐🍐

const chouchen =  poire_qui_tabasse.map(remplacer_par_du_chouchen("🍐"));

console.log("Chouchen", chouchen.getValue()); // Chouchen 🍎🍎🍎
console.log("Poire qui tabasse après tansmutation", poire_qui_tabasse.getValue()); // Poire qui tabasse après tansmutation 🍐🍐🍐
console.log("Bouteille magique", bouteille_magique.getValue()) // Bouteille magique 🍐

La bouteille magique demeure inchangée de même que la bouteille de poire "améliorée" 🥴🥳.

Bon pour ceux qui n'aime pas trop l'alcool j'ai un exemple plus technique.

Vous avez surement rencontré le problème d'entourer un texte de balises HTML.

Les Functors peuvent de manière très pratique vous aider à résoudre ce problème.

const contenu = new Functor("Bonjour à tous");

const html = contenu
    .map(content => `<h1>${content}</h1>`)
    .map(content => `<body>${content}</body>`)
    .map(contenu => `<html>${contenu}</html>`);

console.log(html.getValue()) // <html><body><h1>Bonjour à tous</h1></body></html>

Les limites des Functors

Comme on vit dans un monde peuplé d'imperfections les Functors ne font pas exception.

Il existe un cas qui va nous poser des problèmes imaginez que dans méthode map vous décidiez de renvoyer un Functor.

const ajouter_poire = x => new Functor(x + "🍐")
const log = step =>  x =>  {
    console.log(`contenu de la bouteille étape ${step}: ${x}`, )
    return x
}

const bouteille_magique =  new Functor("🍐");

const poire_qui_tabasse = bouteille_magique
                            .map(log(1))  // contenu de la bouteille étape 1: 🍐
                            .map(ajouter_poire)
                            .map(log(2))  // contenu de la bouteille étape 2: Functor ( 🍐🍐  )
                            .map(ajouter_poire)
                            .map(log(3)); // contenu de la bouteille étape 3: Functor ( Functor ( 🍐🍐  ) 🍐  )

Tout se passe comme si vous aviez des bouteilles de Klein d'eau de vie. Vous êtes capable de mettre des bouteilles dans des bouteilles grâce à la 4ème dimension !!

Bon vu que c'est pas du tout le résultat qu'on attendait il va falloir trouver un moyen de régler ce problème.

Les Monades à la rescousse !

L'idée est d'éviter d'entourer un Functor d'un autre Functor à chaque appel de map, pour cela on va définir une méthode flatMap qui a pour but d'extraire le contenu du Functor avant d'appliquer la méthode f de transformation.

Pour cela on va définir un Functor disposant d'une méthode flatMap qui réalisera les actions citées ci-dessus.

export default class Monad<T> extends Container<T> {

    map<R>(f: Func<T, R>) : Monad<R> {
        return new Monad(f(this.getValue()))
    }

    flatMap<R> (f: Func<T, Monad<R>>) : Monad<R> {
        return f(this.getValue())
    } 
}

Avec notre nouvelle Monad fraîchement définie on peut régler notre problème de poires dans des bouteilles en 4 dimensions.

const ajouter_poire = x => new Monad(x + "🍐")
const log = step =>  x =>  {
    console.log(`contenu de la bouteille étape ${step}: ${x}`, )
    return x
}

const bouteille_magique =  new Monad("🍐");

const poire_qui_tabasse = bouteille_magique
                            .map(log(1))  // contenu de la bouteille étape 1: 🍐
                            .flatMap(ajouter_poire)
                            .map(log(2))  // contenu de la bouteille étape 2: 🍐🍐
                            .flatMap(ajouter_poire)
                            .map(log(3)); // contenu de la bouteille étape 3: 🍐🍐🍐

Et voilà retour dans la 3ème dimension 😎

Exemple d'utilisation des flatMap en JS

On va prendre pour exemple la méthode ajouter1.

const ajouter1 = x => x + 1

Si on l'applique:

const result = [0]
                .map(ajouter1)
                .map(ajouter1)
console.log(result) // [2]

On obtient bien le résultat attendu qui est un tableau de 1 élement valant 2.

Mais si par contre on fait

const ajouter1Wrapped = x => [x + 1]
const result = [0]
                .map(ajouter1Wrapped)
                .map(ajouter1Wrapped)
console.log(result) // [ [ '11' ] ]

Bon là par contre c'est plus exactement ce qu'on veut 🤔

Mais si on remplace par flatMap magie ! 🥳

const result = [0]
                .flatMap(ajouter1Wrapped)
                .flatMap(ajouter1Wrapped)
console.log(result) // [ 2 ]

La flatMap extrait le contenu du tableau avant d'appliquer l'opération de la méthode du map puis réinsère le résultat dans le tableau.

On résume

Un Container est une boîte qui peut contenir n'importe quoi mais dont le contenu ne peut plus être changé une fois à l'intérieur.

Un Functor est un Container muni d'une méthode map qui permet de renvoyer un clone modifié du Container originel.

Une Monade est un Functor muni d'une méthode flatMap qui avant d'appliquer une transformation sur le contenu d'un Container l'extrait avant.

L'erreur à un milliard de dollars

Si vous faîtes du java ou du javascript vous avez surement déjà rencontré des erreurs du type NullPointerException, VM166:1 Uncaught ReferenceError: x is not defined.

Toutes ces erreurs peuvent se rassembler sous une même typologie appellée les accès à des références non définies ou non autorisées. Ce qui provoque dans la plupart des cas l'arrêt du programme au runtime. Ce qui est on va pas se le cacher un peu gênant, surtout pour l'utilisateur 😋

Pour éviter ce genre de désagrément la programmation fonctionnelle, fourni aux développeurs des outils qui vont le forcer à gérer manuellement tout les cas dont les cas d'erreurs. Ces outils sont bien évidemment des Monades.

L'Option quand on sait pas si la valeur de retour existe

Parfois une fonction peut ne pas renvoyer le résultat escompté. On appelle ça plus communément une erreur.

Il existe de nombreuse façon de traiter une erreur.

La plus répandue est de gérer des exceptions qui vont remonter dans la pile d'appel jusqu'à être capturée.

Cela à un inconvénient majeur qui est de ne pas forcer à traiter l'erreur. En effet si l'erreur n'est pas gérée, elle va remonter j'usqu'à faire crasher l'application.

Une autre manière est de définir une pair de sortie, c'est qui a été utilisé par Go, de la même manière on a aucune obligation de gérer l'erreur. Elle peut donc avoir des effets de bords non désirés.

L'Option est un mécanisme qui force le développeur à traiter au plus vite une erreur sans risque d'oubli malencontreux.

Il est possible d'implémenter cette Monade en Typescript, comme ci-dessous:

interface OptionSome<T> {
    value: T
}

interface OptionNone<T> {}

type IOption<T> = OptionSome<T> | OptionNone<T>


class Option<T> {

    private option : IOption<T>;

    static Some<U>(value: U) : Option<U> {
        return new Option({value})
    }

    static None<U>() : Option<U> {
        return new Option({})
    }

    public isOk() : Boolean {
        return (this.option as OptionSome<T>).value !== undefined
    }

    private constructor (option : IOption<T>) {
        this.option = option
    }

    public orElse(fallback: T) : T {
        if (this.isOk()) {
            return (this.option as OptionSome<T>).value;
        } 
        return fallback
    }

    public toString() : String {
        if (this.isOk()) {
            return `Some(${(this.option as OptionSome<T>).value})`
        }
        return "None"
    }
}

Imaginons que vous avez une liste d'entiers, vous souhaitez récupérer le premier nombre pair de la liste.

Typiquement si un développeur n'est pas consencieux on peut se retrouver avec ce genre de problème:

function getFirstEven(tab: Array<number>) : number {
    for (let i of tab) {
        if (i % 2 == 0) {
            return i
        }
    }
}

const result1 = getFirstEven([1, 3, 5, 8])
console.log(result1 + 1) // 9

const result2 = getFirstEven([1, 3, 5, 9])
console.log(result2 + 1) // Nan

Dans certains cas le résultat n'est pas défini ce qui s'il est utilisé dans la suite du programme sans vérification préalable cela peut occasionner des soucis voir des crashs de l'application.

Pour éviter un crash on est obligé de faire des vérifications supplémentaires.

let result2 = getFirstEven([1, 3, 5, 9]);
if(!isNaN(result2)) {
     result2 = 0
}
console.log(result2 + 1)

Le fonctionnel à la rescousse

On peut réécrire cette fonction en retournant à la place d'un number, un Option<number> qui indique explicitement au développeur qu'il doit gérer le cas d'erreur.

function getFirstEven(tab: Array<number>) : Option<number> {

    for (let i of tab) {

       if (i % 2 == 0) {
           return Option.Some(i)
       }
    }
    
    return Option.None()
}

Grâce à la monade Option, il est possible de traiter le cas où il n'y a aucun nombre pair dans la liste passer en paramètre et de définir une valeur de fallback dans ce cas.

const with_even = getFirstEven([1, 3, 5, 8]);
const without_even = getFirstEven([1, 3, 5, 9]);

console.log(`${with_even.orElse(0) + 1}`); // 9
console.log(`${without_even.orElse(0) + 1}`); // 1 

Ce qui permet de se passer de if (value === null) ou pire d'oublier complètement de traiter le cas d'erreur.

En faire un Monade qui se respecte

Si vous avez bien suivi les précédent paragraphe, vous remarquez l'absence des méthodes map et flatMap, ce n'est donc pas une Monade 😱

On peut remédier à ce problème de la manière suivante:

class Option<T> {
    // début de la classe ....

    map<R>(f: Func<T, R>) : Option<R> {

        if (!this.isOk()) return Option.None()
        return Option.Some(f((this.option as OptionSome<T>).value))
    }

    flatMap<R> (f: Func<T, Option<R>>) : Option<R> {

        if (!this.isOk()) return Option.None()
        return f((this.option as OptionSome<T>).value)
    }

    // reste de la classe ....
}

On remarque que la variante None est traitée différemment, en effet si on rencontre une variante None on renvoie automatiquement une variante None, ceci permet de court-circuiter un traitremant en l'absence de résultat.

On définit une méthode log permettant de se rendre compte de qui se passe dans la pile d'appel.

const log = step => x =>  {
    console.log(`step ${step}: ${x}`)
    return x
}

Un exemple d'echec

let a = Option.Some(1)
    .map(log(0))
    .flatMap(x => (x % 2 == 0) ? Option.Some(x) : Option.None<number>())
    .map(log(1))
    .map(x => x + 2)
    .map(log(2))
    .orElse(666);

console.log(a) // 666

La console nous affiche

step 0: 1
666

On remarque que les appels ont été court-circuités pour atteindre le orElse qui lui défini sa valeur de 666.

let b = Option.Some(0)
    .map(log(0))
    .flatMap(x => (x % 2 == 0) ? Option.Some(x) : Option.None<number>())
    .map(log(1))
    .map(x => x + 2)
    .map(log(2))
    .orElse(666);

console.log(b); // 2

La console nous affiche

step 0: 0
step 1: 0
step 2: 2
2

Ici tout se passe bien la valeur de fallback n'est pas retournée, on arrive bien jusqu'à la map(log(2))

La Monade Result : gérer les erreurs

Une autre Monade très utile est la monade Result, comme vous allez pouvoir le remarquer, elle est très proche de la monade Option.

interface ResultError<E> {
    error: E
}

interface ResultOk<T> {
    value: T
}

type IResult<T,E> = ResultOk<T> | ResultError<E>


class Result<T, E> {

    private result : IResult<T, E>;

    static Ok<U, E2>(value: U) : Result<U, E2> {
        return new Result({value})
    }

    static Err<U, E2>(error: E2) : Result<U, E2> {
        return new Result({error})
    }

    isErr() : Boolean {
        return (this.result as ResultError<E>).error !== undefined
    }

    private constructor(result: IResult<T, E>) {

        this.result = result
    }

    toString() : String {
        if (this.isErr()) {
            return `Error ( ${(this.result as ResultError<E>).error} )`
        }
        return `Ok ( ${(this.result as ResultOk<T>).value} )`
    }

}

On peut l'utiliser pour par exemple gérer la connexion à une base de données.

function getDataBaseConnection(url : String) : Result<Connection, String> {
    try {
        const connection : Connection = open(url);
        return Result.Ok(connection);
    } catch(e) {
        return Result.Err(e.message);
    }
}

Cela permet de facilement gérer les erreurs et surtout de ne pas oublier de les gérer !

const resultConnection = getDataBaseConnection("database");
console.log(resultConnection); // Ok( Connection { url : "database" } )

const resultConnection = getDataBaseConnection("wrong");
console.log(resultConnection); // Error ( Unable to connect to wrong )

Notre objet resultConnection porte en lui à la fois l'état du retour de la méthode mais aussi en cas de succès l'objet Connection en lui même ou en cas d'erreur, le message d'erreur.

Comme pour tout à l'heure il nous manque les méthodes map et flatMap.

Allons-y, implémentons donc tout ça.

class Result<T, E> {

    // début de la classe ....

    map<R>(f: Func<T, R>) : Result<R, E> {

        if (this.isErr()) return Result.Err((this.result as ResultError<E>).error)
        return Result.Ok(f((this.result as ResultOk<T>).value))
    }

    flatMap<R> (f: Func<T, Result<R, E>>) : Result<R, E> {

        if (this.isErr()) return Result.Err((this.result as ResultError<E>).error)
        return f((this.result as ResultOk<T>).value)
    }

    // reste de la classe ....
}

Et utilisé ainsi

const ok = Result.Ok<number, String>(31)
    .map(x => x + 1)
    .flatMap(x => Result.Ok(x + 10));

console.log(ok); // Ok( 42 )


const ok = Result.Err<number, String>("bad value")
    .map(x => x + 1)
    .flatMap(x => Result.Ok(x + 10));

console.log(ok); // Error( bad value )

On peut aussi sur cette, maintenant véritable monade, rajouter une méthode match.

class Result<T, E> {

    // début de la classe ....

    match<R>(resolve: Func<T, R>, reject: Func<E, R>) : R {
        if (this.isErr()) {
            return reject((this.result as ResultError<E>).error)
        }
        return resolve((this.result as ResultOk<T>).value)
    }

    // reste de la classe ....
}

Celle-ci prend deux fonctions en paramètre resolve et reject.

  • La première est appelée avec la valeur du Result
  • La seconde avec l'erreur du Result

La contrainte est de renvoyer le même type de retour dans les deux branches.

const code : number = getDataBaseConnection("database")
    .match(
        (connection: Connection) => {
            // c'est good 👍
            return 1;
        },
        (error: String) => {
            // traitement de l'erreur 👎
            return -1;
        }
    )

Il est aussi possible de transformer un Result en une Option.

class Result<T, E> {

    // début de la classe ....

    ok() : Option<T> {
        if(this.isErr()) {
            return Option.None()
        }
        return Option.Some((this.result as ResultOk<T>).value)
    }

    // reste de la classe ....
}

Toute erreur fini en None. Et tout succès en Some.

const connection : Option<Connection> = getDataBaseConnection("database").ok();
console.log(connection); // Some ( Connection { url: "database" } )
const connectionFail : Option<Connection> = getDataBaseConnection("database").ok();
console.log(connectionFail); // None

Cela peut-être pratique pour ne pas s'encombrer d'une erreur mais conserver tout de même le fait que quelque chose ne s'est pas déroulé de la bonne manière.

Conclusion

Je remercie tous ceux qui sont arrivés jusqu'au bout de ce long article.

Il risque d'y en avoir d'autres, la programmation fonctionnelle me passionne de plus en plus. 😍

A plus pour d'autres articles sur le fonctionnel ou autre chose 😉

avatar

Auteur: Akanoa

Je découvre, j'apprends, je comprends et j'explique ce que j'ai compris dans ce blog.

Ce travail est sous licence CC BY-NC-SA 4.0.