Skip to content

Chapitre 09 : Les oignons monadiques

Usine à foncteurs pointés

Avant d'aller plus loin, je dois admettre que ma présentation de la méthode of était incomplète : elle ne sert pas à éviter le mot-clé new, mais à placer des valeurs dans ce qu'on appelle un contexte minimal par défaut. En réalité, of ne remplace pas un constructeur - il fait partie d'une interface cruciale nommée Pointed.

Un foncteur pointé est un foncteur disposant d'une méthode of

L'idée clé est de pouvoir injecter toute valeur dans notre type pour initier les opérations de mapping.

js
IO.of('tetris').map(concat(' master'));
// IO('tetris master')

Maybe.of(1336).map(add(1));
// Maybe(1337)

Task.of([{ id: 2 }, { id: 3 }]).map(map(prop('id')));
// Task([2,3])

Either.of('The past, present and future walk into a bar...').map(concat('it was tense.'));
// Right('The past, present and future walk into a bar...it was tense.')

Rappelons que les constructeurs de IO et Task requièrent une fonction comme argument, contrairement à Maybe et Either. Cette interface offre une méthode uniforme pour placer des valeurs dans des foncteurs sans les contraintes spécifiques des constructeurs. L'expression "contexte minimal par défaut" reste imprécise mais illustre bien l'objectif : élever une valeur dans notre type pour effectuer un mapping normal selon le comportement attendu du foncteur.

Une correction essentielle s'impose : Left.of est un non-sens. Chaque foncteur doit avoir une seule façon d'emballer une valeur. Pour Either, c'est new Right(x). Nous utilisons Right pour définir of car tout type capable de mapper doit mapper. Left constitue une exception à cette règle.

Les fonctions pure, point, unit et return sont des synonymes de notre mystérieuse méthode of. Celle-ci deviendra indispensable avec les monades où nous devrons replacer manuellement les valeurs dans leurs types.

Pour éviter new, privilégiez les bibliothèques comme folktale, ramda ou fantasy-land qui implémentent correctement of avec des constructeurs modernes.

Entrelacement de métaphores

onion

Outre les burritos spatiaux (selon certaines rumeurs), les monades ressemblent à des oignons. Illustrons ceci :

js
const fs = require('fs');

// readFile :: String -> IO String
const readFile = filename => new IO(() => fs.readFileSync(filename, 'utf-8'));

// print :: String -> IO String
const print = x => new IO(() => {
  console.log(x);
  return x;
});

// cat :: String -> IO (IO String)
const cat = compose(map(print), readFile);

cat('.git/config');
// IO(IO('[core]\nrepositoryformatversion = 0\n'))

Notre IO est encapsulé dans un autre IO suite à l'appel de print lors du premier map. Pour poursuivre le traitement, il faut appliquer map(map(f)), puis unsafePerformIO().unsafePerformIO() pour observer le résultat.

js
// cat :: String -> IO (IO String)
const cat = compose(map(print), readFile);

// catFirstChar :: String -> IO (IO String)
const catFirstChar = compose(map(map(head)), cat);

catFirstChar('.git/config');
// IO(IO('['))

Bien qu'utile pour combiner deux effets, cette approche engendre une API maladroite. Examinons un autre cas :

js
// safeProp :: Key -> {Key: a} -> Maybe a
const safeProp = curry((x, obj) => Maybe.of(obj[x]));

// safeHead :: [a] -> Maybe a
const safeHead = safeProp(0);

// firstAddressStreet :: User -> Maybe (Maybe (Maybe Street))
const firstAddressStreet = compose(
  map(map(safeProp('street'))),
  map(safeHead),
  safeProp('addresses'),
);

firstAddressStreet({
  addresses: [{ street: { name: 'Mulburry', number: 8402 }, postcode: 'WC2N' }],
});
// Maybe(Maybe(Maybe({name: 'Mulburry', number: 8402})))

Cette imbrication de foncteurs révèle trois échecs potentiels, mais exiger trois map successifs est excessif. Ce schéma récurrent nécessite l'intervention des monades.

Les monades provoquent des larmes lorsqu'on déploie leurs couches via map. La méthode join vient à notre secours.

js
const mmo = Maybe.of(Maybe.of('nunchucks'));
// Maybe(Maybe('nunchucks'))

mmo.join();
// Maybe('nunchucks')

const ioio = IO.of(IO.of('pizza'));
// IO(IO('pizza'))

ioio.join();
// IO('pizza')

const ttt = Task.of(Task.of(Task.of('sewers')));
// Task(Task(Task('sewers')));

ttt.join();
// Task(Task('sewers'))

Deux couches d'un même type peuvent être fusionnées avec join. Cette fusion fonctorielle caractérise les monades.

Les monades sont des foncteurs pointés capables de s'aplatir

Tout foncteur doté de join, of et respectant certaines lois devient une monade. Implémentons join pour Maybe :

js
Maybe.prototype.join = function join() {
  return this.isNothing() ? Maybe.of(null) : this.$value;
};

Aussi simple qu'assimiler son jumeau embryonnaire. Maybe(Maybe(x)) perd une couche via .$value, permettant un map sécurisé. Sans valeur, nous restons avec un simple Maybe.

Armés de join, appliquons une solution monadique à firstAddressStreet :

js
// join :: Monad m => m (m a) -> m a
const join = mma => mma.join();

// firstAddressStreet :: User -> Maybe Street
const firstAddressStreet = compose(
  join,
  map(safeProp('street')),
  join,
  map(safeHead), safeProp('addresses'),
);

firstAddressStreet({
  addresses: [{ street: { name: 'Mulburry', number: 8402 }, postcode: 'WC2N' }],
});
// Maybe({name: 'Mulburry', number: 8402})

Appliquons le même principe à IO :

js
IO.prototype.join = function() {
  const $ = this;
  return new IO(() => $.unsafePerformIO().unsafePerformIO());
};

Nous exécutons les deux couches IO séquentiellement, préservant la pureté tout en simplifiant l'emballage.

js
// log :: a -> IO a
const log = x => new IO(() => {
  console.log(x);
  return x;
});

// setStyle :: Selector -> CSSProps -> IO DOM
const setStyle =
  curry((sel, props) => new IO(() => jQuery(sel).css(props)));

// getItem :: String -> IO String
const getItem = key => new IO(() => localStorage.getItem(key));

// applyPreferences :: String -> IO DOM
const applyPreferences = compose(
  join,
  map(setStyle('#main')),
  join,
  map(log),
  map(JSON.parse),
  getItem,
);

applyPreferences('preferences').unsafePerformIO();
// Object {backgroundColor: "green"}
// <div style="background-color: 'green'"/>

getItem retourne un IO String nécessitant un map pour le parsing. log et setStyle retournant des IO, nous utilisons join pour contrôler l'imbrication.

L'enchaînement régulier

chain

Le pattern récurrent map-join se généralise en chain (alias >>= ou flatMap).

js
// chain :: Monad m => (a -> m b) -> m a -> m b
const chain = curry((f, m) => m.map(f).join());

// or

// chain :: Monad m => (a -> m b) -> m a -> m b
const chain = f => compose(join, map(f));

Remplaçons map/join par chain dans nos exemples :

js
// map/join
const firstAddressStreet = compose(
  join,
  map(safeProp('street')),
  join,
  map(safeHead),
  safeProp('addresses'),
);

// chain
const firstAddressStreet = compose(
  chain(safeProp('street')),
  chain(safeHead),
  safeProp('addresses'),
);

// map/join
const applyPreferences = compose(
  join,
  map(setStyle('#main')),
  join,
  map(log),
  map(JSON.parse),
  getItem,
);

// chain
const applyPreferences = compose(
  chain(setStyle('#main')),
  chain(log),
  map(JSON.parse),
  getItem,
);

chain structure les effets imbriqués et capture l'assignation variable de manière fonctionnelle, dépassant le simple nettoyage syntaxique.

js
// getJSON :: Url -> Params -> Task JSON
getJSON('/authenticate', { username: 'stale', password: 'crackers' })
  .chain(user => getJSON('/friends', { user_id: user.id }));
// Task([{name: 'Seimith', id: 14}, {name: 'Ric', id: 39}]);

// querySelector :: Selector -> IO DOM
querySelector('input.username')
  .chain(({ value: uname }) =>
    querySelector('input.email')
      .chain(({ value: email }) => IO.of(`Welcome ${uname} prepare for spam at ${email}`))
  );
// IO('Welcome Olivia prepare for spam at olivia@tremorcontrol.net');

Maybe.of(3)
  .chain(three => Maybe.of(2).map(add(three)));
// Maybe(5);

Maybe.of(null)
  .chain(safeProp('address'))
  .chain(safeProp('street'));
// Maybe(null);

chain peut être dérivé via t.prototype.chain = f => this.map(f).join(). Les implémentations manuelles doivent reproduire map suivi de join. Les spécifications fantasyland détaillent ces relations complexes.

Dans le premier exemple, deux Task s'enchaînent pour des opérations asynchrones séquentielles, évitant ainsi Task(Task([Friend])).

Avec querySelector, nous combinons des entrées pour créer un message. IO.of préserve l'intégrité du type grâce à l'interface Pointed.

js
querySelector('input.username').chain(({ value: uname }) =>
  querySelector('input.email').map(({ value: email }) =>
    `Welcome ${uname} prepare for spam at ${email}`));
// IO('Welcome Olivia prepare for spam at olivia@tremorcontrol.net');

Les exemples utilisant Maybe interrompent le calcul dès qu'une valeur null est détectée via chain.

Ne craignez pas d'expérimenter avec ces exemples. Utilisez map pour les valeurs normales et chain pour les foncteurs. Le prochain chapitre explorera les foncteurs applicatifs.

Note : inopérant avec des types imbriqués différents. La composition de foncteurs et les transformateurs de monades apporteront la solution.

Démonstration de puissance

La programmation par conteneurs peut dérouter. Bien que des méthodes comme inspect facilitent le débogage, leur utilité mérite parfois question.

Illustrons la puissance monadique :

Lisons un fichier puis chargeons-le :

js
// readFile :: Filename -> Either String (Task Error String)
// httpPost :: String -> String -> Task Error JSON
// upload :: Filename -> Either String (Task Error JSON)
const upload = compose(map(chain(httpPost('/uploads'))), readFile);

Nous gérons trois erreurs potentielles via Either et Task, enchaînant deux actions asynchrones avec chain dans un flux purement déclaratif.

Ce flux linéaire gauche-droite préserve le raisonnement équationnel. La fonction générique upload contraste avec l'approche impérative :

Comparaison avec l'approche impérative standard :

js
// upload :: Filename -> (String -> a) -> Void
const upload = (filename, callback) => {
  if (!filename) {
    throw new Error('You need a filename!');
  } else {
    readFile(filename, (errF, contents) => {
      if (errF) throw errF;
      httpPost('/uploads', contents, (errH, json) => {
        if (errH) throw errH;
        callback(json);
      });
    });
  }
};

Un enchevêtrement algorithmique propice aux erreurs dans les applications mutables.

Théorie

La première loi est l'associativité, sous une forme différente de son sens algébrique habituel.

js
// associativity
compose(join, map(join)) === compose(join, join);

Cette loi traite de l'imbrication : fusionner les couches externes ou internes produit le même résultat. Visuellement, M(M(M a)) devient M a quel que soit l'ordre des join.

monad associativity law

Joindre d'abord les M externes puis le résultat équivaut à map(join) sur les internes. Les étapes intermédiaires diffèrent, mais le join final coïncide. Notons que map(join) difère de join.

Deuxième loi similaire :

js
// identity for all (M a)
compose(join, of) === compose(join, map(of)) === id;

Pour toute monade M, la composition of et join équivaut à id. Cette « identité triangulaire » se visualise ainsi :

monad identity law

Envelopper puis déballer via join revient à l'identité. Inversement, map(of) crée une imbrication résoluble par join.

Précisons que of doit être spécifique à la monade utilisée.

Ces lois correspondent à celles des catégories. Les monades forment la catégorie de Kleisli avec des morphismes chaînés.

js
const mcompose = (f, g) => compose(chain(f), g);

// left identity
mcompose(M, f) === f;

// right identity
mcompose(f, M) === f;

// associativity
mcompose(mcompose(f, g), h) === mcompose(f, mcompose(g, h));

Cet aperçu théorique vise à éveiller l'intérêt tout en restant pragmatique. Les spécifications algébriques apportent les précisions nécessaires.

Synthèse

Les monades percent les calculs imbriqués. Elles gèrent variables, effets séquentiels et tâches asynchrones sans pyramides de callback. Leur force réside dans la libération de valeurs encapsulées dans des couches homogènes, grâce à l'interface Pointed.

Malgré leur puissance, certaines limites persistent. Exécuter des appels API parallèles ou accumuler des erreurs nécessite d'autres approches.

Le prochain chapitre présentera les foncteurs applicatifs, souvent préférés aux monades.

Chapitre 10 : Foncteurs applicatifs

Exercices

Considérons un objet User :

js
const user = {
  id: 1,
  name: 'Albert',
  address: {
    street: {
      number: 22,
      name: 'Walnut St',
    },
  },
};

Pratiquons !

Utilisez safeProp avec map/join ou chain pour extraire en sécurité le nom de rue d'un utilisateur donné // getStreetName :: User -> Maybe String const getStreetName = undefined;


Considérons maintenant :

js
// getFile :: IO String
const getFile = IO.of('/home/mostly-adequate/ch09.md');

// pureLog :: String -> IO ()
const pureLog = str => new IO(() => console.log(str));

Pratiquons !

Utilisez getFile pour obtenir le chemin, supprimez le répertoire et conservez le nom de base. Loggez-le de manière pure. Conseil : utilisez split et last pour extraire le nom. // logFilename :: IO () const logFilename = undefined;


Helpers avec signatures :

js
// validateEmail :: Email -> Either String Email
// addToMailingList :: Email -> IO([Email])
// emailBlast :: [Email] -> IO ()

Pratiquons !

Utilisez validateEmail, addToMailingList et emailBlast pour créer une fonction ajoutant un email valide à la liste, puis notifiant tous les membres. // joinMailingList :: Email -> Either String (IO ()) const joinMailingList = undefined;