Skip to content

Chapitre 08 : Les Conteneurs Fonctoriels

Le Tout-Puissant Conteneur

http://blog.dwinegar.com/2011/06/another-jar.html

Nous avons vu comment écrire des programmes acheminant des données à travers des fonctions pures. Il s'agit de spécifications déclaratives de comportements. Mais qu'en est-il du flux de contrôle, de la gestion d'erreurs, des actions asynchrones, des états et - osons le dire - des effets ?! Dans ce chapitre, nous découvrirons les fondations sur lesquelles reposent toutes ces abstractions utiles.

Commençons par créer un conteneur. Celui-ci doit pouvoir contenir n'importe quel type de valeur ; un sachet scellé ne contenant que du pudding au tapioca serait de peu d'utilité. Formé d'un objet, nous éviterons de lui donner propriétés et méthodes au sens POO. Imaginons-le plutôt comme un coffre-fort - une boîte spéciale protégeant nos précieuses données.

js
class Container {
  constructor(x) {
    this.$value = x;
  }
  
  static of(x) {
    return new Container(x);
  }
}

Voici notre premier conteneur. Nous l'avons baptisé Container. La méthode Container.of joue le rôle de constructeur, nous évitant l'usage fastidieux du mot-clé new. La fonction of recèle plus de subtilités qu'il n'y paraît, mais pour l'heure, considérons-la comme la manière appropriée d'insérer des valeurs dans notre conteneur.

Observons notre toute nouvelle boîte...

js
Container.of(3);
// Container(3)

Container.of('hotdogs');
// Container("hotdogs")

Container.of(Container.of({ name: 'yoda' }));
// Container(Container({ name: 'yoda' }))

Sous Node.js, vous verrez {$value: x} malgré notre Container(x). Chrome affiche correctement le type, mais peu importe : l'essentiel est de comprendre la structure du Container. Certains environnements permettent de redéfinir inspect, mais nous resterons simples. Dans ce livre, nous illustrerons les sorties comme si nous avions modifié inspect, plus parlantes que {$value: x} pour des raisons pédagogiques et esthétiques.

Clarifions quelques points avant de poursuivre :

  • Container est un objet possédant une propriété unique. Bien que souvent monovaleurs, les conteneurs peuvent en contenir plusieurs. Nous avons arbitrairement nommé cette propriété $value.

  • $value ne peut être lié à un type spécifique sans trahir l'essence même du conteneur.

  • Une fois encapsulée, la valeur demeure dans le Container. L'extraire via .$value est possible, mais contrarierait notre objectif.

Les raisons de cette approche deviendront transparentes, mais pour l'instant, accordez-moi votre confiance.

Mon Premier Foncteur

Notre valeur désormais protégée, il nous faut un moyen d'appliquer des fonctions sur elle.

js
// (a -> b) -> Container a -> Container b
Container.prototype.map = function (f) {
  return Container.of(f(this.$value));
};

N'est-ce point similaire au map des tableaux ? À ceci près que nous avons Container a au lieu de [a]. Le principe demeure identique :

js
Container.of(2).map(two => two + 2); 
// Container(4)

Container.of('flamethrowers').map(s => s.toUpperCase()); 
// Container('FLAMETHROWERS')

Container.of('bombs').map(append(' away')).map(prop('length')); 
// Container(10)

Nous manipulerons notre valeur sans jamais quitter le Container. Cette particularité est cruciale : la valeur encapsulée est transmise à map pour transformation, puis ré-emballée. Cette persistence dans le conteneur permet d'enchaîner les map à volonté, modifiant au passage son type comme illustré dans le dernier exemple.

Un instant ! Ces appels successifs à map évoquent une composition fonctionnelle. Quelle sorcellerie mathématique opère ici ? Chers lecteurs, nous venons de découvrir les Foncteurs.

Un Foncteur est un type implémentant map et respectant certaines lois

Oui, Foncteur se réduit à une interface contractuelle. Nous aurions pu l'appeler Mappable, mais où serait le plaisir ? Issus de la théorie des catégories, nous explorerons leurs fondements mathématiques plus tard. Concentrons-nous d'abord sur l'intuition et les cas pratiques.

Pourquoi encapsuler une valeur pour ensuite l'atteindre via map ? La réponse émerge si nous reformulons : Quel avantage tirons-nous à déléguer l'application de fonctions au conteneur ? L'abstraction de l'application fonctionnelle. En utilisant map, nous déléguons l'exécution au type conteneur - un concept éminemment puissant.

Le Peut-Être de Schrödinger

cool cat, need reference

Container manque de panache. Généralement nommé Identity, son impact équivaut à notre fonction id (un lien mathématique que nous examinerons en temps voulu). D'autres foncteurs, comme Maybe, offrent des comportements enrichis lors du mapping. Définissons-en un.

L'implémentation complète figure en Annexe B

js
class Maybe {
  static of(x) {
    return new Maybe(x);
  }

  get isNothing() {
    return this.$value === null || this.$value === undefined;
  }

  constructor(x) {
    this.$value = x;
  }

  map(fn) {
    return this.isNothing ? this : Maybe.of(fn(this.$value));
  }

  inspect() {
    return this.isNothing ? 'Nothing' : `Just(${inspect(this.$value)})`;
  }
}

Maybe ressemble à Container avec une différence majeure : il vérifie la présence d'une valeur avant d'appliquer une fonction. Ainsi contournons-nous les nulls indésirables lors des opérations de mapping (Note : implémentation simplifiée à des fins pédagogiques).

js
Maybe.of('Malkovich Malkovich').map(match(/a/ig));
// Just(True)

Maybe.of(null).map(match(/a/ig));
// Nothing

Maybe.of({ name: 'Boris' }).map(prop('age')).map(add(10));
// Nothing

Maybe.of({ name: 'Dinah', age: 14 }).map(prop('age')).map(add(10));
// Just(24)

Notre application n'explosera plus sous les erreurs de null. Maybe s'assure systématiquement de la présence effective d'une valeur.

La syntaxe pointée fonctionne, mais préférons le style sans point. map délègue naturellement à tout foncteur :

js
// map :: Functor f => (a -> b) -> f a -> f b
const map = curry((f, anyFunctor) => anyFunctor.map(f));

Cette flexibilité préserve la composition habituelle. La notation Functor f => indique que f doit être un Foncteur. Rien de bien compliqué, mais méritait d'être précisé.

Cas d'Usage

En pratique, Maybe excelle dans les fonctions susceptibles d'échouer.

js
// safeHead :: [a] -> Maybe(a)
const safeHead = xs => Maybe.of(xs[0]);

// streetName :: Object -> Maybe String
const streetName = compose(map(prop('street')), safeHead, prop('addresses'));

streetName({ addresses: [] });
// Nothing

streetName({ addresses: [{ street: 'Shady Ln.', number: 4201 }] });
// Just('Shady Ln.')

safeHead est une version sécurisée de head. L'introduction de Maybe dans notre code nous contraint à gérer explicitement les valeurs nulles. safeHead annonce clairement son incertitude via le type de retour Maybe, nous forçant à utiliser map pour accéder à la valeur - validation de null déléguée à la fonction. Ces APIs renforcent la robustesse applicative.

Certaines fonctions renvoient explicitement Nothing pour signaler un échec :

js
// withdraw :: Number -> Account -> Maybe(Account)
const withdraw = curry((amount, { balance }) =>
  Maybe.of(balance >= amount ? { balance: balance - amount } : null));

// This function is hypothetical, not implemented here... nor anywhere else.
// updateLedger :: Account -> Account 
const updateLedger = account => account;

// remainingBalance :: Account -> String
const remainingBalance = ({ balance }) => `Your balance is $${balance}`;

// finishTransaction :: Account -> String
const finishTransaction = compose(remainingBalance, updateLedger);


// getTwenty :: Account -> Maybe(String)
const getTwenty = compose(map(finishTransaction), withdraw(20));

getTwenty({ balance: 200.00 }); 
// Just('Your balance is $180')

getTwenty({ balance: 10.00 });
// Nothing

withdraw retourne Nothing en cas de solde insuffisant. Toute opération de mapping ultérieure sera interrompue, exactement comme souhaité : pas de mise à jour du registre sans retrait validé.

Libération de la Valeur

N'oublions pas : toute exécution doit finalement produire un effet observable - envoi JSON, affichage écran, etc. Comme le kōan zen : « Un programme sans effet observable s'exécute-t-il vraiment ? » Son bon fonctionnement n'illumine-t-il que son for intérieur ?

Notre application transforme des données jusqu'au moment de leur utilisation finale. Extraire prématurément la valeur de Maybe est une erreur commune. À l'instar du chat de Schrödinger, la valeur persiste dans un état superposé jusqu'au dénouement final, garantissant un flux linéaire malgré les embranchements logiques.

Il existe cependant une échappatoire : l'utilitaire maybe permet de spécifier une valeur par défaut.

js
// maybe :: b -> (a -> b) -> Maybe a -> b
const maybe = curry((v, f, m) => {
  if (m.isNothing) {
    return v;
  }

  return f(m.$value);
});

// getTwenty :: Account -> String
const getTwenty = compose(maybe('You\'re broke!', finishTransaction), withdraw(20));

getTwenty({ balance: 200.00 }); 
// 'Your balance is $180.00'

getTwenty({ balance: 10.00 }); 
// 'You\'re broke!'

maybe équivaut fonctionnellement à un if/else, tandis que map correspondrait à if (x !== null) { return f(x) } en style impératif.

L'adoption de Maybe peut susiter une gêne initiale. Les utilisateurs de Swift/Scala reconnaîtront le concept sous le nom d'Option(al). Bien que parfois fastidieux, la sécurité accrue finit par séduire - mieux vaut prévenir que guérir.

Négliger la sécurité logicielle équivaut à peindre des œufs avant de les jeter sous les roues d'un camion. Maybe consolide notre code comme les briques des Trois Petits Cochons.

À noter : les implémentations sérieuses distinguent Just(x) et Nothing, préservant la parametricité dans map tout en respectant les valeurs null/undefined.

Gestion d'Erreurs Pures

pick a hand... need a reference

throw/catch n'est guère pur. Avec Either, nous répondons poliment aux erreurs au lieu de déclarer la guerre à nos entrées.

L'implémentation complète figure en Annexe B

js
class Either {
  static of(x) {
    return new Right(x);
  }

  constructor(x) {
    this.$value = x;
  }
}

class Left extends Either {
  map(f) {
    return this;
  }

  inspect() {
    return `Left(${inspect(this.$value)})`;
  }
}

class Right extends Either {
  map(f) {
    return Either.of(f(this.$value));
  }

  inspect() {
    return `Right(${inspect(this.$value)})`;
  }
}

const left = x => new Left(x);

Left et Right sont deux sous-types d'Either. Observons leur comportement :

js
Either.of('rain').map(str => `b${str}`); 
// Right('brain')

left('rain').map(str => `It's gonna ${str}, better bring your umbrella!`); 
// Left('rain')

Either.of({ host: 'localhost', port: 80 }).map(prop('host'));
// Right('localhost')

left('rolls eyes...').map(prop('host'));
// Left('rolls eyes...')

Left ignore les opérations de mapping, tandis que Right agit comme Container. La puissance réside dans la capacité de Left à encapsuler un message d'erreur.

Imaginons une fonction de calcul d'âge à partir d'une date de naissance. Plutôt que Maybe, utilisons Either pour expliciter les erreurs :

js
const moment = require('moment');

// getAge :: Date -> User -> Either(String, Number)
const getAge = curry((now, user) => {
  const birthDate = moment(user.birthDate, 'YYYY-MM-DD');

  return birthDate.isValid()
    ? Either.of(now.diff(birthDate, 'years'))
    : left('Birth date could not be parsed');
});

getAge(moment(), { birthDate: '2005-12-12' });
// Right(9)

getAge(moment(), { birthDate: 'July 4, 2001' });
// Left('Birth date could not be parsed')

Comme avec Maybe, un Left interrompt le flux. La différence réside dans le message d'erreur explicite. Le type Either(String, Number) indique clairement les possibilités de retour.

js
// fortune :: Number -> String
const fortune = compose(concat('If you survive, you will be '), toString, add(1));

// zoltar :: User -> Either(String, _)
const zoltar = compose(map(console.log), map(fortune), getAge(moment()));

zoltar({ birthDate: '2005-12-12' });
// 'If you survive, you will be 10'
// Right(undefined)

zoltar({ birthDate: 'balloons!' });
// Left('Birth date could not be parsed')

Contrairement à throw, Either gère les erreurs avec élégance. Le flux de contrôle demeure linéaire, évitant les enchevêtrements conditionnels.

Notons que fortune ignore totalement les foncteurs. Le lifting adapte les fonctions ordinaires aux conteneurs, amplifiant leur réutilisabilité.

Cette capacité à lever des fonctions dans des contextes fonctionnels est fondamentale. Elle permet d'écrire des fonctions standard puis de les adapter aux foncteurs selon les besoins.

Either convient autant aux validations qu'aux erreurs critiques. Testez le remplacement de Maybe par Either pour un meilleur feedback.

Either capture la disjonction logique (||) et représente une union disjointe en théorie des catégories. Bien que polyvalent, c'est son usage en gestion d'erreurs qui nous intéresse ici.

L'utilitaire either prend deux fonctions au lieu d'une valeur statique :

js
// either :: (a -> c) -> (b -> c) -> Either a b -> c
const either = curry((f, g, e) => {
  let result;

  switch (e.constructor) {
    case Left:
      result = f(e.$value);
      break;

    case Right:
      result = g(e.$value);
      break;

    // No Default
  }

  return result;
});

// zoltar :: User -> _
const zoltar = compose(console.log, either(id, fortune), getAge(moment()));

zoltar({ birthDate: '2005-12-12' });
// 'If you survive, you will be 10'
// undefined

zoltar({ birthDate: 'balloons!' });
// 'Birth date could not be parsed'
// undefined

La fonction id trouve ici son utilité en relayant simplement le message d'erreur. Notre application de voyance gagne ainsi en robustesse.

Le Vieux McDonald et les Effets...

dominoes.. need a reference

Notre chapitre sur la pureté montrait une fonction pure encapsulant un effet. En voici un autre exemple :

js
// getFromStorage :: String -> (_ -> String)
const getFromStorage = key => () => localStorage[key];

Sans cette encapsulation, getFromStorage dépendrait de conditions externes. Emballée, elle retourne toujours la même fonction pour un input donné - une pureté recouvrée.

Mais cette approche limite l'utilité. Pour interagir avec le contenu, introduisons IO.

js
class IO {
  static of(x) {
    return new IO(() => x);
  }

  constructor(fn) {
    this.$value = fn;
  }

  map(fn) {
    return new IO(compose(fn, this.$value));
  }

  inspect() {
    return `IO(${inspect(this.$value)})`;
  }
}

IO se distingue par une $value toujours fonctionnelle. Nous considérons que IO contient le résultat de l'action différée plutôt que la fonction elle-même. La méthode of crée un IO(x) via IO(() => x) pour éviter l'évaluation immédiate.

Illustration pratique :

js
// ioWindow :: IO Window
const ioWindow = new IO(() => window);

ioWindow.map(win => win.innerWidth);
// IO(1430)

ioWindow
  .map(prop('location'))
  .map(prop('href'))
  .map(split('/'));
// IO(['http:', '', 'localhost:8000', 'blog', 'posts'])


// $ :: String -> IO [DOM]
const $ = selector => new IO(() => document.querySelectorAll(selector));

$('#myDiv').map(head).map(div => div.innerHTML);
// IO('I am some inner html')

ioWindow est un IO directement mappable. Chaque map ajoute une opération à une composition, construisant progressivement un calcul sans l'exécuter - tel un jeu de dominos positionnés avec soin.

Les lois des foncteurs assurent cette puissance pseudo-psychique. Nous manipulons ainsi des effets impurs tout en préservant la pureté.

Mais tôt ou tard, il faudra libérer la bête. L'exécution ultime incombe au code appelant :

js
// url :: IO String
const url = new IO(() => window.location.href);

// toPairs :: String -> [[String]]
const toPairs = compose(map(split('=')), split('&'));

// params :: String -> [[String]]
const params = compose(toPairs, last, split('?'));

// findParam :: String -> IO Maybe [String]
const findParam = key => map(compose(Maybe.of, find(compose(eq(key), head)), params), url);

// -- Impure calling code ----------------------------------------------

// run it by calling $value()!
findParam('searchTerm').$value();
// Just(['searchTerm', 'wafflehouse'])

Notre bibliothèque préserve sa pureté en retournant un IO, transférant la responsabilité de l'exécution. L'imbrication de conteneurs (IO(Maybe([x]))) démontre leur expressivité.

Renommons $value en unsafePerformIO pour souligner son danger :

js
class IO {
  constructor(io) {
    this.unsafePerformIO = io;
  }

  map(fn) {
    return new IO(compose(fn, this.unsafePerformIO));
  }
}

L'appel devient alors explicite : findParam('searchTerm').unsafePerformIO().

IO dompte les effets impurs. Explorons maintenant un type apparenté mais distinct.

Tâches Asynchrones

Les callbacks sont l'antichambre de l'enfer asynchrone. Une alternative existe, et son nom commence par « F ».

Utilisons Data.Task de Folktale pour illustration :

js
// -- Node readFile example ------------------------------------------

const fs = require('fs');

// readFile :: String -> Task Error String
const readFile = filename => new Task((reject, result) => {
  fs.readFile(filename, (err, data) => (err ? reject(err) : result(data)));
});

readFile('metamorphosis').map(split('\n')).map(head);
// Task('One morning, as Gregor Samsa was waking up from anxious dreams, he discovered that
// in bed he had been changed into a monstrous verminous bug.')


// -- jQuery getJSON example -----------------------------------------

// getJSON :: String -> {} -> Task Error JSON
const getJSON = curry((url, params) => new Task((reject, result) => {
  $.getJSON(url, params, result).fail(reject);
}));

getJSON('/video', { id: 10 }).map(prop('title'));
// Task('Family Matters ep 15')


// -- Default Minimal Context ----------------------------------------

// We can put normal, non futuristic values inside as well
Task.of(3).map(three => three + 1);
// Task(4)

map opère sur la valeur future comme si elle était présente. Les utilisateurs de Promesses reconnaîtront des similarités avec then.

Les Promesses ne sont pas pures, mais l'analogie avec Task tient.

Comme IO, Task attend son exécution explicite. Il encapsule élégamment l'asynchrone tout en restant pur.

La méthode fork déclenche l'exécution asynchrone non bloquante :

js
// -- Pure application -------------------------------------------------
// blogPage :: Posts -> HTML
const blogPage = Handlebars.compile(blogTemplate);

// renderPage :: Posts -> HTML
const renderPage = compose(blogPage, sortBy(prop('date')));

// blog :: Params -> Task Error HTML
const blog = compose(map(renderPage), getJSON('/posts'));


// -- Impure calling code ----------------------------------------------
blog({}).fork(
  error => $('#error').html(error.message),
  page => $('#main').html(page),
);

$('#spinner').show();

fork lance la récupération des posts, affiche un indicateur de chargement, puis gère le résultat ou l'erreur.

Le flux demeure linéaire et lisible, contrairement aux enchevêtrements de callbacks.

Task intègre Either pour la gestion d'erreurs asynchrones, offrant une solution pure et complète.

Exemple combinant Task, Either et IO :

js
// Postgres.connect :: Url -> IO DbConnection
// runQuery :: DbConnection -> ResultSet
// readFile :: String -> Task Error String

// -- Pure application -------------------------------------------------

// dbUrl :: Config -> Either Error Url
const dbUrl = ({ uname, pass, host, db }) => {
  if (uname && pass && host && db) {
    return Either.of(`db:pg://${uname}:${pass}@${host}5432/${db}`);
  }

  return left(Error('Invalid config!'));
};

// connectDb :: Config -> Either Error (IO DbConnection)
const connectDb = compose(map(Postgres.connect), dbUrl);

// getConfig :: Filename -> Task Error (Either Error (IO DbConnection))
const getConfig = compose(map(compose(connectDb, JSON.parse)), readFile);


// -- Impure calling code ----------------------------------------------

getConfig('db.json').fork(
  logErr('couldn\'t read file'),
  either(console.log, map(runQuery)),
);

Les foncteurs synchrones conservent leur utilité au sein des flux asynchrones.

Aussi simple qu'un map.

Les workflows asynchrones complexes nécessiteront les monades. Mais étudions d'abord leurs fondements mathématiques.

Un Brin de Théorie

Les foncteurs obéissent à des lois catégoriques.

js
// identity
map(id) === id;

// composition
compose(map(f), map(g)) === map(compose(f, g));

La loi d'identité est fondamentale :

js
const idLaw1 = map(id);
const idLaw2 = id;

idLaw1(Container.of(2)); // Container(2)
idLaw2(Container.of(2)); // Container(2)

La loi de composition préserve l'associativité :

js
const compLaw1 = compose(map(append(' world')), map(append(' cruel')));
const compLaw2 = map(compose(append(' world'), append(' cruel')));

compLaw1(Container.of('Goodbye')); // Container('Goodbye cruel world')
compLaw2(Container.of('Goodbye')); // Container('Goodbye cruel world')

Un foncteur préserve la structure catégorique (identités et compositions) lors du mapping entre catégories.

Imaginez une catégorie comme un réseau d'objets reliés par des morphismes. Un foncteur transforme ce réseau sans en briser les connexions.

Categories mapped

Maybe crée une sous-catégorie où chaque morphisme vérifie null, implémenté via map.

Les diagrammes commutatifs assurent que différents chemins mènent au même résultat :

functor diagram

Application concrète :

js
// topRoute :: String -> Maybe String
const topRoute = compose(Maybe.of, reverse);

// bottomRoute :: String -> Maybe String
const bottomRoute = compose(map(reverse), Maybe.of);

topRoute('hi'); // Just('ih')
bottomRoute('hi'); // Just('ih')

Visualisation :

functor diagram 2

Ces propriétés permettent un raisonnement algébrique sur le code.

Les foncteurs s'emboîtent :

js
const nested = Task.of([Either.of('pillows'), left('no sleep for you')]);

map(map(map(toUpperCase)), nested);
// Task([Right('PILLOWS'), Left('no sleep for you')])

Plutôt que trois map successifs, composons les foncteurs :

js
class Compose {
  constructor(fgx) {
    this.getCompose = fgx;
  }

  static of(fgx) {
    return new Compose(fgx);
  }

  map(fn) {
    return new Compose(map(map(fn), this.getCompose));
  }
}

const tmd = Task.of(Maybe.of('Rock over London'));

const ctmd = Compose.of(tmd);

const ctmd2 = map(append(', rock on, Chicago'), ctmd);
// Compose(Task(Just('Rock over London, rock on, Chicago')))

ctmd2.getCompose;
// Task(Just('Rock over London, rock on, Chicago'))

La composition associative de foncteurs forme une catégorie, révélant une beauté mathématique sous-jacente.

En Bref

Les foncteurs sont omniprésents : structures de données, flux événementiels, modélisations de types. Nous les utiliserons abondamment.

Les cas complexes (arguments multiples, séquences asynchrones) nécessiteront les monades - sujet du prochain chapitre.

Chapitre 09 : Oignons Monadiques

Exercices

Pratiquons !

Utilisez add et map pour créer une fonction incrémentant une valeur dans un foncteur. // incrF :: Functor f => f Int -> f Int const incrF = undefined;


Étant donné l'objet User suivant :

js
const user = { id: 2, name: 'Albert', active: true };

Pratiquons !

Utilisez safeProp et head pour trouver l'initiale de l'utilisateur. // initial :: User -> Maybe String const initial = undefined;


Étant données les fonctions helper suivantes :

js
// showWelcome :: User -> String
const showWelcome = compose(concat('Welcome '), prop('name'));

// checkActive :: User -> Either String User
const checkActive = function checkActive(user) {
  return user.active
    ? Either.of(user)
    : left('Your account is not active');
};

Pratiquons !

Écrivez une fonction utilisant checkActive et showWelcome pour autoriser l'accès ou retourner l'erreur. // eitherWelcome :: User -> Either String String const eitherWelcome = undefined;


Considérons maintenant les fonctions suivantes :

js
// validateUser :: (User -> Either String ()) -> User -> Either String User
const validateUser = curry((validate, user) => validate(user).map(_ => user));

// save :: User -> IO User
const save = user => new IO(() => ({ ...user, saved: true }));

Pratiquons !

Écrivez validateName vérifiant si un nom a plus de 3 caractères, et une fonction register utilisant either, showWelcome et save pour inscrire l'utilisateur. Les deux arguments de either doivent retourner le même type. // validateName :: User -> Either String () const validateName = undefined; // register :: User -> IO String const register = compose(undefined, validateUser(validateName));