Entrée
Au
YOW! 2013 l' un des développeurs de la langue Haskell,
prof. Philip Wadler a montré comment les monades permettent aux langages fonctionnels purs d'effectuer des opérations essentiellement impératives, telles que la gestion des entrées-sorties et des exceptions. Sans surprise, l'intérêt du public pour ce sujet a généré une croissance explosive des publications sur les monades sur Internet. Malheureusement, la plupart de ces publications utilisent des exemples écrits dans des langages fonctionnels, ce qui implique que les nouveaux arrivants en programmation fonctionnelle veulent en savoir plus sur les monades. Mais les monades ne sont pas spécifiques à Haskell ou aux langages fonctionnels, et peuvent bien être illustrées par des exemples dans les langages de programmation impératifs. C'est le but de ce guide.
En quoi ce guide est-il différent des autres? Nous allons essayer d'ouvrir les monades en moins de 15 minutes, en utilisant seulement l'intuition et quelques exemples élémentaires de code Python. Par conséquent, nous ne commencerons pas à théoriser et à nous plonger dans la philosophie, en discutant des
burritos ,
des combinaisons spatiales , des
bureaux et des endofoncteurs.
Exemples de motivation
Nous examinerons trois questions liées à la composition des fonctions. Nous les résoudrons de deux manières: l'impératif habituel et l'utilisation de monades. Ensuite, nous comparons les différentes approches.
1. Journalisation
Supposons que nous ayons trois fonctions unaires:
f1
,
f2
et
f3
, qui prennent un nombre et le renvoient augmenté respectivement de 1, 2 et 3. Chaque fonction génère également un message, qui est un rapport sur l'opération terminée.
def f1(x): return (x + 1, str(x) + "+1") def f2(x): return (x + 2, str(x) + "+2") def f3(x): return (x + 3, str(x) + "+3")
Nous aimerions les enchaîner pour traiter le paramètre
x
, en d'autres termes, nous aimerions calculer
x+1+2+3
. En outre, nous devons obtenir une explication lisible par l'homme de ce que chaque fonction a fait.
Nous pouvons obtenir le résultat dont nous avons besoin de la manière suivante:
log = "Ops:" res, log1 = f1(x) log += log1 + ";" res, log2 = f2(res) log += log2 + ";" res, log3 = f3(res) log += log3 + ";" print(res, log)
Cette solution n'est pas idéale, car elle consiste en un grand nombre de middleware monotones. Si nous voulons ajouter une nouvelle fonction à notre chaîne, nous serons obligés de répéter ce code de liaison. De plus, les manipulations avec les variables
res
et
log
nuisent à la lisibilité du code, ce qui rend difficile de suivre la logique principale du programme.
Idéalement, un programme devrait ressembler à une simple chaîne de fonctions, comme
f3(f2(f1(x)))
. Malheureusement, les types de données renvoyés par
f1
et
f2
ne correspondent pas aux types de paramètres
f2
et
f3
. Mais nous pouvons ajouter de nouvelles fonctions à la chaîne:
def unit(x): return (x, "Ops:") def bind(t, f): res = f(t[0]) return (res[0], t[1] + res[1] + ";")
Maintenant, nous pouvons résoudre le problème comme suit:
print(bind(bind(bind(unit(x), f1), f2), f3))
Le diagramme suivant montre le processus de calcul se produisant à
x=0
. Ici
v1
,
v2
et
v3
sont les valeurs obtenues à la suite d'appels à l'
unit
et à la
bind
.

La fonction
unit
convertit le paramètre d'entrée
x
en un tuple d'un nombre et d'une chaîne. La fonction
bind
appelle la fonction qui lui est passée en paramètre et accumule le résultat dans la variable intermédiaire
t
.
Nous avons pu éviter de répéter le middleware en le plaçant dans la fonction
bind
. Maintenant, si nous obtenons la fonction
f4
, nous l'incluons simplement dans la chaîne:
bind(f4, bind(f3, ... ))
Et nous n'avons pas besoin d'apporter d'autres modifications.
2. Liste des valeurs intermédiaires
Nous allons également commencer cet exemple avec de simples fonctions unaires.
def f1(x): return x + 1 def f2(x): return x + 2 def f3(x): return x + 3
Comme dans l'exemple précédent, nous devons composer ces fonctions afin de calculer
x+1+2+3
. Nous devons également obtenir une liste de toutes les valeurs obtenues grâce au travail de nos fonctions, c'est-à-dire
x
,
x+1
,
x+1+2
et
x+1+2+3
.
Contrairement à l'exemple précédent, nos fonctions sont composables, c'est-à-dire que les types de leurs paramètres d'entrée coïncident avec le type du résultat. Par conséquent, une simple chaîne
f3(f2(f1(x)))
renverra le résultat final. Mais dans ce cas, nous perdons les valeurs intermédiaires.
Résolvons le problème "de front":
lst = [x] res = f1(x) lst.append(res) res = f2(res) lst.append(res) res = f3(res) lst.append(res) print(res, lst)
Malheureusement, cette solution contient également beaucoup de middleware. Et si nous décidons d'ajouter
f4
, nous devrons à nouveau répéter ce code pour obtenir la liste correcte des valeurs intermédiaires.
Par conséquent, nous ajoutons deux fonctions supplémentaires, comme dans l'exemple précédent:
def unit(x): return (x, [x]) def bind(t, f): res = f(t[0]) return (res, t[1] + [res])
Maintenant, nous réécrivons le programme comme une chaîne d'appels:
print(bind(bind(bind(unit(x), f1), f2), f3))
Le diagramme suivant montre le processus de calcul se produisant à
x=0
. Encore une fois,
v1
,
v2
et
v3
indiquent les valeurs obtenues à partir des appels
unit
et
bind
.

3. Valeurs vides
Essayons de donner un exemple plus intéressant, cette fois avec des classes et des objets. Supposons que nous ayons une classe
Employee
avec deux méthodes:
class Employee: def get_boss(self):
Chaque objet de la classe
Employee
a un gestionnaire (un autre objet de la classe
Employee
) et un salaire, auxquels on accède par des méthodes appropriées. Les deux méthodes peuvent également renvoyer
None
(l'employé n'a pas de gestionnaire, le salaire est inconnu).
Dans cet exemple, nous allons créer un programme qui montre le salaire du dirigeant de cet employé. Si le gestionnaire n'est pas trouvé ou si son salaire ne peut être déterminé, le programme renverra
None
.
Idéalement, nous devons écrire quelque chose comme
print(john.get_boss().get_wage())
Mais dans ce cas, si l'une des méthodes retourne
None
, notre programme se terminera avec une erreur.
Une façon évidente de gérer cette situation pourrait ressembler à ceci:
result = None if john is not None and john.get_boss() is not None and john.get_boss().get_wage() is not None: result = john.get_boss().get_wage() print(result)
Dans ce cas, nous
get_boss
des appels supplémentaires aux
get_wage
get_boss
et
get_wage
. Si ces méthodes sont suffisamment lourdes (par exemple, l'accès à la base de données), notre solution ne fonctionnera pas. Par conséquent, nous le changeons:
result = None if john is not None: boss = john.get_boss() if boss is not None: wage = boss.get_wage() if wage is not None: result = wage print(result)
Ce code est optimal en termes de calcul, mais est mal lu en raison de trois
if
imbriqués. Par conséquent, nous allons essayer d'utiliser la même astuce que dans les exemples précédents. Définissez deux fonctions:
def unit(e): return e def bind(e, f): return None if e is None else f(e)
Et maintenant, nous pouvons mettre toute la solution sur une seule ligne.
print(bind(bind(unit(john), Employee.get_boss), Employee.get_wage))
Comme vous l'avez probablement déjà remarqué, dans ce cas, nous n'avons pas eu à écrire la fonction d'
unit
: elle renvoie simplement le paramètre d'entrée. Mais nous allons le laisser pour qu'il nous soit alors plus facile de généraliser notre expérience.
Notez également qu'en Python, nous pouvons utiliser des méthodes en tant que fonctions, ce qui nous a permis d'écrire
Employee.get_boss(john)
au lieu de
john.get_boss()
.
Le diagramme suivant montre le processus de calcul lorsque John n'a pas de leader, c'est-à-dire que
john.get_boss()
renvoie
None
.

Conclusions
Supposons que nous voulons combiner les fonctions de même type
f1
,
f2
,
…
,
fn
. Si leurs paramètres d'entrée sont les mêmes que les résultats, on peut utiliser une simple chaîne de la forme
fn(… f2(f1(x)) …)
. Le diagramme suivant montre un processus de calcul généralisé avec des résultats intermédiaires, notés
v1
,
v2
,
…
,
vn
.

Souvent, cette approche n'est pas applicable. Les types de valeurs d'entrée et de résultats de fonction peuvent varier, comme dans le premier exemple. Ou les fonctions peuvent être composables, mais nous voulons insérer une logique supplémentaire entre les appels, comme dans les exemples 2 et 3, nous avons inséré une agrégation de valeurs intermédiaires et une vérification pour une valeur vide, respectivement.
1. Décision impérative
Dans tous les exemples, nous avons d'abord utilisé l'approche la plus simple, qui peut être représentée par le diagramme suivant:

Avant d'appeler
f1
nous avons fait une initialisation. Dans le premier exemple, nous avons initialisé une variable pour stocker un journal commun, dans le second pour une liste de valeurs intermédiaires. Après cela, nous avons intercalé les appels de fonction avec un certain code de connexion: nous avons calculé les valeurs agrégées, vérifié le résultat pour
None
.
2. Monades
Comme nous l'avons vu dans les exemples, les décisions impératives ont toujours souffert de verbosité, de répétition et de logique confuse. Pour obtenir un code plus élégant, nous avons utilisé un certain modèle de conception, selon lequel nous avons créé deux fonctions:
unit
et
bind
. Ce modèle est appelé la
monade . La fonction de
bind
contient un middleware tandis que l'
unit
implémente l'initialisation. Cela nous permet de simplifier la solution finale en une seule ligne:
bind(bind( ... bind(bind(unit(x), f1), f2) ... fn-1), fn)
Le processus de calcul peut être représenté par un diagramme:

Un appel à l'
unit(x)
génère une valeur initiale de
v1
. Ensuite,
bind(v1, f1)
génère une nouvelle valeur intermédiaire
v2
, qui est utilisée dans le prochain appel à
bind(v2, f2)
. Ce processus se poursuit jusqu'à l'obtention d'un résultat final. En définissant diverses
unit
et
bind
dans le cadre de ce modèle, nous pouvons combiner différentes fonctions dans une même chaîne de calculs. Bibliothèques
de monades (
par exemple, PyMonad ou OSlash - environ Transl. )
Contiennent généralement des monades prêtes à l'emploi (paires d'
unit
et fonctions de
bind
) pour la mise en œuvre de certaines compositions de fonctions.
Pour chaîner des fonctions, les valeurs renvoyées par
unit
et
bind
doivent être du même type que les paramètres d'entrée de
bind
. Ce type est appelé
monadique . Selon le diagramme ci-dessus, le type de variables
v1
,
v2
,
…
,
vn
doit être de type monadique.
Enfin, réfléchissez à la façon dont vous pouvez améliorer le code écrit à l'aide de monades. De toute évidence, les appels de
bind
répétés semblent inélégants. Pour éviter cela, définissez une autre fonction externe:
def pipeline(e, *functions): for f in functions: e = bind(e, f) return e
Maintenant à la place
bind(bind(bind(bind(unit(x), f1), f2), f3), f4)
nous pouvons utiliser l'abréviation suivante:
pipeline(unit(x), f1, f2, f3, f4)
Conclusion
Les monades sont un modèle de conception simple et puissant utilisé pour composer des fonctions. Dans les langages de programmation déclarative, il aide à implémenter des mécanismes impératifs tels que la journalisation ou l'entrée / sortie. Dans les langues impératives
il permet de généraliser et de raccourcir le code qui relie une série d'appels de fonctions du même type.
Cet article ne fournit qu'une compréhension superficielle et intuitive des monades. Vous pouvez en savoir plus en contactant les sources suivantes:
- Wikipédia
- Monades en Python (avec une belle syntaxe!)
- Chronologie des tutoriels Monad