Les monades sans les maths

Lecture 10 min. ‱


Table des matiĂšres

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 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 😉