Chapitre 11 : Transformer à nouveau, naturellement
Nous allons discuter des transformations naturelles dans leur utilité pratique pour le code quotidien. Il se trouve qu'elles sont un pilier de la théorie des catégories et absolument indispensables pour appliquer les mathématiques à la refactorisation de code. Ainsi, je dois vous informer de l'injustice regrettable que vous allez voir, due à ma perspective limitée. Commençons.
Maudit soit ce nid
Je souhaite aborder le problème de l'imbrication. Non pas l'instinct parental de rangement obsessionnel (bien qu'à y réfléchir, l'analogie n'est pas si éloignée comme nous le verrons)... Ce que j'entends par imbrication est la coexistence de deux types ou plus regroupés autour d'une valeur, la couvant tel un nouveau-né.
Right(Maybe('b'));
IO(Task(IO(1000)));
[Identity('bee thousand')];Jusqu'ici nos exemples soignés ont évité ce cas, mais en pratique les types s'emmêlent comme des écouteurs en pleine séance d'exorcisme. Sans organisation rigoureuse, notre code deviendra plus hirsute qu'un beatnik dans un café à chats.
Une comédie de situation
// getValue :: Selector -> Task Error (Maybe String)
// postComment :: String -> Task Error Comment
// validate :: String -> Either ValidationError String
// saveComment :: () -> Task Error (Maybe (Either ValidationError (Task Error Comment)))
const saveComment = compose(
map(map(map(postComment))),
map(map(validate)),
getValue('#comment'),
);Notre signature type est devenue un capharnaüm. Explication rapide : getValue('#comment') retourne Task Error (Maybe String) car il peut échouer à trouver l'élément. Nous devons ensuite map sur Task et Maybe pour passer le texte à validate, qui produit Either ValidationError String. Après divers mapping, nous obtenons un Task Error (Maybe (Either ValidationError String)) à envoyer à postComment.
Quel fouillis effroyable ! Collage de types abstraits, expressionnisme typographique amateur, Pollock polymorphe, Mondrian monolithique. Les solutions sont multiples : composer des conteneurs monstrueux, trier avec join, homogénéiser via des transformations naturelles - notre sujet ici.
Tout est naturel
Une transformation naturelle est un "morphisme entre foncteurs" : une fonction (Functor f, Functor g) => f a -> g a opérant sur les conteneurs. Formellement, elle doit satisfaire pour toute fonction f :

Ou en code :
// nt :: (Functor f, Functor g) => f a -> g a
compose(map(f), nt) === compose(nt, map(f));Diagramme et code expriment la même chose : appliquer la transformation avant ou après map donne le même résultat. Cela découle d'un théorème gratuit, bien que les transformations ne se limitent pas aux fonctions sur types.
Conversions typées structurées
Nous transformons des conteneurs algébriques au lieu de types primitifs. Par exemple :
Quelques exemples :
// idToMaybe :: Identity a -> Maybe a
const idToMaybe = x => Maybe.of(x.$value);
// idToIO :: Identity a -> IO a
const idToIO = x => IO.of(x.$value);
// eitherToTask :: Either a b -> Task a b
const eitherToTask = either(Task.rejected, Task.of);
// ioToTask :: IO a -> Task () a
const ioToTask = x => new Task((reject, resolve) => resolve(x.unsafePerform()));
// maybeToTask :: Maybe a -> Task () a
const maybeToTask = x => (x.isNothing ? Task.rejected() : Task.of(x.$value));
// arrayToMaybe :: [a] -> Maybe a
const arrayToMaybe = x => Maybe.of(x[0]);L'essentiel ? Changer de foncteur en préservant la capacité à map. L'information accessoire peut être perdue, mais pas la valeur sous-jacente selon notre définition.
On peut y voir une transformation d'effets : ioToTask convertit synchrone ➔ asynchrone, arrayToMaybe non-déterminisme ➔ échec potentiel. L'inverse (taskToIO) serait une transformation surnaturelle en JavaScript.
Envie de fonctionnalités
Pour utiliser sortBy sur une List, les transformations naturelles permettent une conversion fiable :
// arrayToList :: [a] -> List a
const arrayToList = List.of;
const doListyThings = compose(sortBy(h), filter(g), arrayToList, map(f));
const doListyThings_ = compose(sortBy(h), filter(g), map(f), arrayToList); // law appliedUn coup de baguette magique avec arrayToList, et voilà ! Notre [a] devient List a prête pour sortBy.
L'optimisation est facilitée en déplaçant map(f) à gauche de la transformation, comme dans doListyThings_.
Isomorphisme en JavaScript
Un isomorphisme permet des conversions bidirectionnelles sans perte. C'est-à-dire :
// promiseToTask :: Promise a b -> Task a b
const promiseToTask = x => new Task((reject, resolve) => x.then(resolve).catch(reject));
// taskToPromise :: Task a b -> Promise a b
const taskToPromise = x => new Promise((resolve, reject) => x.fork(reject, resolve));
const x = Promise.resolve('ring');
taskToPromise(promiseToTask(x)) === x;
const y = Task.of('rabbit');
promiseToTask(taskToPromise(y)) === y;CQFD : Promise et Task sont isomorphes. arrayToMaybe n'est pas isomorphe à cause de la perte d'information :
// maybeToArray :: Maybe a -> [a]
const maybeToArray = x => (x.isNothing ? [] : [x.$value]);
// arrayToMaybe :: [a] -> Maybe a
const arrayToMaybe = x => Maybe.of(x[0]);
const x = ['elvis costello', 'the attractions'];
// not isomorphic
maybeToArray(arrayToMaybe(x)); // ['elvis costello']
// but is a natural transformation
compose(arrayToMaybe, map(replace('elvis', 'lou')))(x); // Just('lou costello')
// ==
compose(map(replace('elvis', 'lou')), arrayToMaybe)(x); // Just('lou costello')Ce sont des transformations naturelles car map préserve les résultats. Les isomorphismes sont un concept puissant mais continuons.
Définition élargie
Ces fonctions structurelles ne se limitent pas aux conversions :
Exemples variés :
reverse :: [a] -> [a]
join :: (Monad m) => m (m a) -> m a
head :: [a] -> a
of :: a -> f aLes lois s'appliquent ici aussi. head :: [a] -> a peut être vu comme [a] -> Identity a via l'isomorphisme a ≅ Identity a.
Une solution au nesting
Revenons à notre signature type chaotique. En insérant chain(maybeToTask) et chain(eitherToTask), nous homogénéisons les types pour les rendre joinables.
// getValue :: Selector -> Task Error (Maybe String)
// postComment :: String -> Task Error Comment
// validate :: String -> Either ValidationError String
// saveComment :: () -> Task Error Comment
const saveComment = compose(
chain(postComment),
chain(eitherToTask),
map(validate),
chain(maybeToTask),
getValue('#comment'),
);Comme des pics anti-pigeons, nous évitons l'imbrication à la source. « Mieux vaut prévenir que guérir », comme on dit à Paris.
En résumé
Les transformations naturelles (fonctions sur les foncteurs) sont cruciales en théorie des catégories. Elles permettent de convertir des types tout en garantissant la composition. Utiles pour homogénéiser les foncteurs vers celui aux effets les plus volatils (souvent Task).
Ce tri fastidieux est le prix de leur matérialisation. Nous continuerons avec les Traversables pour ordonner nos types.
Chapitre 12 : Traverser la pierre
Exercices
Pratiquons !
Écrire une transformation naturelle convertissant Either b a en Maybe a
// eitherToMaybe :: Either b a -> Maybe a
const eitherToMaybe = undefined;
// eitherToTask :: Either a b -> Task a b
const eitherToTask = either(Task.rejected, Task.of);Pratiquons !
Simplifier findNameById avec eitherToTask pour éliminer le Either imbriqué
// findNameById :: Number -> Task Error (Either Error User)
const findNameById = compose(map(map(prop('name'))), findUserById);
Rappel : les fonctions suivantes sont disponibles :
split :: String -> String -> [String]
intercalate :: String -> [String] -> StringPratiquons !
Écrire les isomorphismes entre String et [Char] // strToList :: String -> [Char] const strToList = undefined; // listToStr :: [Char] -> String const listToStr = undefined;