Monades en 15 minutes

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): # Return the employee's boss def get_wage(self): # Compute the wage 

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:

  1. Wikipédia
  2. Monades en Python (avec une belle syntaxe!)
  3. Chronologie des tutoriels Monad

Source: https://habr.com/ru/post/fr445800/


All Articles