Option/Peut-être, Soit et Future Monades en JavaScript, Python, Ruby, Swift et Scala

Publié: 2022-03-11

Ce tutoriel sur les monades donne une brève explication des monades et montre comment implémenter les plus utiles dans cinq langages de programmation différents - si vous recherchez des monades en JavaScript, des monades en Python, des monades en Ruby, des monades en Swift et/ou des monades dans Scala, ou pour comparer les implémentations, vous lisez le bon article !

En utilisant ces monades, vous vous débarrasserez d'une série de bogues comme les exceptions de pointeur nul, les exceptions non gérées et les conditions de concurrence.

C'est ce que je couvre ci-dessous:

  • Introduction à la théorie des catégories
  • La définition d'une monade
  • Implémentations de la monade Option ("Peut-être"), de la monade Soit et de la monade Future, ainsi que d'un exemple de programme les exploitant, en JavaScript, Python, Ruby, Swift et Scala

Commençons! Notre premier arrêt est la théorie des catégories, qui est la base des monades.

Introduction à la théorie des catégories

La théorie des catégories est un domaine mathématique qui s'est activement développé au milieu du XXe siècle. Maintenant, c'est la base de nombreux concepts de programmation fonctionnelle, y compris la monade. Jetons un coup d'œil rapide à certains concepts de la théorie des catégories, adaptés à la terminologie du développement logiciel.

Il existe donc trois concepts de base qui définissent une catégorie :

  1. Le type est tel que nous le voyons dans les langages à typage statique. Exemples : Int , String , Dog , Cat , etc.
  2. Les fonctions relient deux types. Par conséquent, ils peuvent être représentés comme une flèche d'un type à un autre type, ou vers eux-mêmes. La fonction $f$ du type $T$ vers le type $U$ peut être notée $f : T \vers U$. Vous pouvez la considérer comme une fonction de langage de programmation qui prend un argument de type $T$ et renvoie une valeur de type $U$.
  3. La composition est une opération, notée par l'opérateur $\cdot$, qui construit de nouvelles fonctions à partir de celles existantes. Dans une catégorie, il est toujours garanti pour toutes les fonctions $f: T \to U$ et $g: U \to V$ qu'il existe une unique fonction $h: T \to V$. Cette fonction est notée $f \cdot g$. L'opération fait effectivement correspondre une paire de fonctions à une autre fonction. Dans les langages de programmation, cette opération est, bien sûr, toujours possible. Par exemple, si vous avez une fonction qui renvoie la longueur d'une chaîne —$strlen: String \to Int$—et une fonction qui indique si le nombre est pair —$even: Int \to Boolean$—alors vous pouvez faire un function $even{\_}strlen : String \to Boolean$ qui indique si la longueur de la String est paire. Dans ce cas $even{\_}strlen = even \cdot strlen$. La composition implique deux caractéristiques :
    1. Associativité : $f \cdot g \cdot h = (f \cdot g) \cdot h = f \cdot (g \cdot h)$
    2. L'existence d'une fonction identité : $\forall T: \exists f: T \to T$, ou en clair, pour chaque type $T$ il existe une fonction qui associe $T$ à elle-même.

Jetons donc un coup d'œil à une catégorie simple.

Une catégorie simple impliquant String, Int et Double, et certaines fonctions parmi elles.

Remarque : nous supposons que Int , String et tous les autres types ici sont garantis non nuls, c'est-à-dire que la valeur nulle n'existe pas.

Note annexe 2 : Ce n'est en fait qu'une partie d'une catégorie, mais c'est tout ce que nous voulons pour notre discussion, car il contient toutes les parties essentielles dont nous avons besoin et le diagramme est moins encombré de cette façon. La vraie catégorie aurait également toutes les fonctions composées comme $roundToString : Double \to String = intToString \cdot round$, pour satisfaire la clause de composition des catégories.

Vous remarquerez peut-être que les fonctions de cette catégorie sont super simples. En fait, il est presque impossible d'avoir un bogue dans ces fonctions. Il n'y a pas de valeurs nulles, pas d'exceptions, juste l'arithmétique et le travail avec la mémoire. Ainsi, la seule mauvaise chose qui peut arriver est une défaillance du processeur ou de la mémoire - auquel cas vous devez planter le programme de toute façon - mais cela se produit très rarement.

Ne serait-il pas agréable que tout notre code fonctionne à ce niveau de stabilité ? Absolument! Mais qu'en est-il des E/S, par exemple ? Nous ne pouvons certainement pas vivre sans elle. C'est là que les solutions monades viennent à la rescousse : elles isolent toutes les opérations instables dans des morceaux de code très petits et très bien audités. Vous pouvez alors utiliser des calculs stables dans l'ensemble de votre application !

Entrez dans les monades

Appelons un comportement instable comme les E/S un effet secondaire . Maintenant, nous voulons pouvoir travailler avec toutes nos fonctions précédemment définies comme length et types comme String de manière stable en présence de cet effet secondaire .

Commençons donc avec une catégorie vide $M[A]$ et transformons-la en une catégorie qui aura des valeurs avec un type particulier d'effet secondaire et également des valeurs sans effet secondaire. Supposons que nous ayons défini cette catégorie et qu'elle soit vide. Pour le moment, nous ne pouvons rien en faire d'utile, alors pour le rendre utile, nous suivrons ces trois étapes :

  1. Remplissez-le avec des valeurs des types de la catégorie $A$, comme String , Int , Double , etc. (cases vertes dans le diagramme ci-dessous)
  2. Une fois que nous avons ces valeurs, nous ne pouvons toujours rien faire de significatif avec elles, nous avons donc besoin d'un moyen de prendre chaque fonction $f : T \to U$ de $A$ et de créer une fonction $g : M[T] \to M [U]$ (flèches bleues dans le schéma ci-dessous). Une fois qu'on a ces fonctions, on peut tout faire avec les valeurs de la catégorie $M[A]$ ce qu'on a pu faire dans la catégorie $A$.
  3. Maintenant que nous avons une toute nouvelle catégorie $M[A]$, une nouvelle classe de fonctions émerge avec la signature $h : T \to M[U]$ (flèches rouges dans le schéma ci-dessous). Ils émergent à la suite de la promotion des valeurs de la première étape dans le cadre de notre base de code, c'est-à-dire que nous les écrivons au besoin ; ce sont les principales choses qui différencieront le travail avec $M[A]$ par rapport au travail avec $A$. L'étape finale consistera à faire en sorte que ces fonctions fonctionnent bien sur les types dans $M[A]$ également, c'est-à-dire à pouvoir dériver la fonction $m: M[T] \to M[U]$ à partir de $h: T \ à M[U]$

Création d'une nouvelle catégorie : Catégories A et M[A], plus une flèche rouge de A's Double à M[A]'s Int, étiquetée "roundAsync". M[A] réutilise chaque valeur et fonction de A à ce stade.

Commençons donc par définir deux manières de promouvoir des valeurs de types $A$ en valeurs de types $M[A]$ : une fonction sans effets de bord et une avec des effets de bord.

  1. La première est appelée $pure$ et est définie pour chaque valeur d'une catégorie stable : $pure : T \to M[T]$. Les valeurs $M[T]$ résultantes n'auront aucun effet secondaire, c'est pourquoi cette fonction s'appelle $pure$. Par exemple, pour une monade d'E/S, $pure$ renverra une valeur immédiatement sans possibilité d'échec.
  2. Le second s'appelle $constructor$ et, contrairement à $pur$, renvoie $M[T]$ avec quelques effets secondaires. Un exemple d'un tel $constructeur$ pour une monade d'E/S asynchrone pourrait être une fonction qui récupère des données sur le Web et les renvoie sous forme de String . La valeur retournée par $constructor$ aura le type $M[String]$ dans ce cas.

Maintenant que nous avons deux façons de promouvoir des valeurs dans $M[A]$, c'est à vous, en tant que programmeur, de choisir la fonction à utiliser, en fonction des objectifs de votre programme. Prenons un exemple ici : Vous voulez récupérer une page HTML comme https://www.toptal.com/javascript/option-maybe-either-future-monads-js et pour cela vous créez une fonction $fetch$. Étant donné que tout peut mal tourner lors de sa récupération (pensez aux pannes de réseau, etc.), vous utiliserez $M[String]$ comme type de retour de cette fonction. Cela ressemblera donc à $fetch: String \to M[String]$ et quelque part dans le corps de la fonction, nous utiliserons $constructor$ pour $M$.

Supposons maintenant que nous créons une fonction fictive pour tester : $fetchMock: String \to M[String]$. Il a toujours la même signature, mais cette fois, nous injectons simplement la page HTML résultante dans le corps de $fetchMock$ sans effectuer aucune opération réseau instable. Donc, dans ce cas, nous utilisons simplement $pure$ dans l'implémentation de $fetchMock$.

À l'étape suivante, nous avons besoin d'une fonction qui promeut en toute sécurité toute fonction arbitraire $f$ de la catégorie $A$ à $M[A]$ (flèches bleues dans un diagramme). Cette fonction s'appelle $map: (T \to U) \to (M[T] \to M[U])$.

Nous avons maintenant une catégorie (qui peut avoir des effets secondaires si nous utilisons $constructor$), qui contient également toutes les fonctions de la catégorie stable, ce qui signifie qu'elles sont également stables dans $M[A]$. Vous remarquerez peut-être que nous avons explicitement introduit une autre classe de fonctions comme $f : T \to M[U]$. Par exemple, $pure$ et $constructor$ sont des exemples de telles fonctions pour $U = T$, mais il pourrait évidemment y en avoir plus, comme si nous devions utiliser $pure$ puis $map$. Donc, généralement, nous avons besoin d'un moyen de traiter les fonctions arbitraires sous la forme $f : T \to M[U]$.

Si nous voulons créer une nouvelle fonction basée sur $f$ qui pourrait être appliquée à $M[T]$, nous pourrions essayer d'utiliser $map$. Mais cela nous amènera à la fonction $g : M[T] \to M[M[U]]$, ce qui n'est pas bon puisque nous ne voulons pas avoir une catégorie de plus $M[M[A]]$. Pour résoudre ce problème, nous introduisons une dernière fonction : $flatMap: (T \to M[U]) \to (M[T] \to M[U])$.

Mais pourquoi voudrions-nous faire cela? Supposons que nous sommes après l'étape 2, c'est-à-dire que nous avons $pure$, $constructor$ et $map$. Supposons que nous voulions récupérer une page HTML de toptal.com, puis analyser toutes les URL et les récupérer. Je créerais une fonction $fetch: String \to M[String]$ qui récupère une seule URL et renvoie une page HTML.

Ensuite, j'appliquerais cette fonction à une URL et j'obtiendrais une page de toptal.com, qui est $x: M[String]$. Maintenant, je fais quelques transformations sur $x$ et j'arrive enfin à une URL $u : M[String]$. Je veux lui appliquer la fonction $fetch$, mais je ne peux pas, car elle prend le type $String$, pas $M[String]$. C'est pourquoi nous avons besoin de $flatMap$ pour convertir $fetch: String \to M[String]$ en $m_fetch: M[String] \to M[String]$.

Maintenant que nous avons terminé les trois étapes, nous pouvons réellement composer toutes les transformations de valeur dont nous avons besoin. Par exemple, si vous avez la valeur $x$ de type $M[T]$ et $f : T \to U$, vous pouvez utiliser $map$ pour appliquer $f$ à la valeur $x$ et obtenir la valeur $y$ de type $M[U]$. De cette façon, toute transformation de valeurs peut être effectuée à 100% sans bogue, tant que les implémentations $pure$, $constructor$, $map$ et $flatMap$ sont sans bogue.

Ainsi, au lieu de gérer des effets désagréables à chaque fois que vous les rencontrez dans votre base de code, vous devez simplement vous assurer que seules ces quatre fonctions sont correctement implémentées. À la fin du programme, vous n'obtiendrez qu'un seul $M[X]$ où vous pourrez déballer en toute sécurité la valeur $X$ et gérer tous les cas d'erreur.

C'est ce qu'est une monade : une chose qui implémente $pure$, $map$ et $flatMap$. (En fait, $map$ peut être dérivé de $pure$ et $flatMap$, mais c'est une fonction très utile et répandue, donc je ne l'ai pas omise de la définition.)

La monade optionnelle, alias la monade peut-être

D'accord, plongeons dans l'implémentation pratique et l'utilisation des monades. La première monade vraiment utile est la monade Option. Si vous venez de langages de programmation classiques, vous avez probablement rencontré de nombreux plantages à cause de la fameuse erreur de pointeur nul. Tony Hoare, l'inventeur de null, appelle cette invention « L'erreur d'un milliard de dollars » :

Cela a conduit à d'innombrables erreurs, vulnérabilités et plantages du système, qui ont probablement causé un milliard de dollars de douleur et de dommages au cours des quarante dernières années.

Essayons donc d'améliorer cela. La monade Option contient soit une valeur non nulle, soit aucune valeur. Assez similaire à une valeur nulle, mais avec cette monade, nous pouvons utiliser en toute sécurité nos fonctions bien définies sans avoir peur de l'exception du pointeur nul. Jetons un coup d'œil aux implémentations dans différents langages :

JavaScript—Option Monade/Peut-être Monade

 class Monad { // pure :: a -> M a pure = () => { throw "pure method needs to be implemented" } // flatMap :: # M a -> (a -> M b) -> M b flatMap = (x) => { throw "flatMap method needs to be implemented" } // map :: # M a -> (a -> b) -> M b map = f => this.flatMap(x => new this.pure(f(x))) } export class Option extends Monad { // pure :: a -> Option a pure = (value) => { if ((value === null) || (value === undefined)) { return none; } return new Some(value) } // flatMap :: # Option a -> (a -> Option b) -> Option b flatMap = f => this.constructor.name === 'None' ? none : f(this.value) // equals :: # M a -> M a -> boolean equals = (x) => this.toString() === x.toString() } class None extends Option { toString() { return 'None'; } } // Cached None class value export const none = new None() Option.pure = none.pure export class Some extends Option { constructor(value) { super(); this.value = value; } toString() { return `Some(${this.value})` } }

Python—Option Monade/Peut-être Monade

 class Monad: # pure :: a -> M a @staticmethod def pure(x): raise Exception("pure method needs to be implemented") # flat_map :: # M a -> (a -> M b) -> M b def flat_map(self, f): raise Exception("flat_map method needs to be implemented") # map :: # M a -> (a -> b) -> M b def map(self, f): return self.flat_map(lambda x: self.pure(f(x))) class Option(Monad): # pure :: a -> Option a @staticmethod def pure(x): return Some(x) # flat_map :: # Option a -> (a -> Option b) -> Option b def flat_map(self, f): if self.defined: return f(self.value) else: return nil class Some(Option): def __init__(self, value): self.value = value self.defined = True class Nil(Option): def __init__(self): self.value = None self.defined = False nil = Nil()

Rubis—Option Monade/Peut-être Monade

 class Monad # pure :: a -> M a def self.pure(x) raise StandardError("pure method needs to be implemented") end # pure :: a -> M a def pure(x) self.class.pure(x) end def flat_map(f) raise StandardError("flat_map method needs to be implemented") end # map :: # M a -> (a -> b) -> M b def map(f) flat_map(-> (x) { pure(f.call(x)) }) end end class Option < Monad attr_accessor :defined, :value # pure :: a -> Option a def self.pure(x) Some.new(x) end # pure :: a -> Option a def pure(x) Some.new(x) end # flat_map :: # Option a -> (a -> Option b) -> Option b def flat_map(f) if defined f.call(value) else $none end end end class Some < Option def initialize(value) @value = value @defined = true end end class None < Option def initialize() @defined = false end end $none = None.new()

Swift—Option Monade/Peut-être Monade

 import Foundation enum Maybe<A> { case None case Some(A) static func pure<B>(_ value: B) -> Maybe<B> { return .Some(value) } func flatMap<B>(_ f: (A) -> Maybe<B>) -> Maybe<B> { switch self { case .None: return .None case .Some(let value): return f(value) } } func map<B>(f: (A) -> B) -> Maybe<B> { return self.flatMap { type(of: self).pure(f($0)) } } }

Scala—Option Monade/Peut-être Monade

 import language.higherKinds trait Monad[M[_]] { def pure[A](a: A): M[A] def flatMap[A, B](ma: M[A])(f: A => M[B]): M[B] def map[A, B](ma: M[A])(f: A => B): M[B] = flatMap(ma)(x => pure(f(x))) } object Monad { def apply[F[_]](implicit M: Monad[F]): Monad[F] = M implicit val myOptionMonad = new Monad[MyOption] { def pure[A](a: A) = MySome(a) def flatMap[A, B](ma: MyOption[A])(f: A => MyOption[B]): MyOption[B] = ma match { case MyNone => MyNone case MySome(a) => f(a) } } } sealed trait MyOption[+A] { def flatMap[B](f: A => MyOption[B]): MyOption[B] = Monad[MyOption].flatMap(this)(f) def map[B](f: A => B): MyOption[B] = Monad[MyOption].map(this)(f) } case object MyNone extends MyOption[Nothing] case class MySome[A](x: A) extends MyOption[A]

Nous commençons par implémenter une classe Monad qui sera la base de toutes nos implémentations monad. Avoir cette classe est très pratique, car en implémentant seulement deux de ses méthodes - pure et flatMap - pour une monade spécifique, vous obtiendrez de nombreuses méthodes gratuitement (nous les limitons simplement à la méthode map dans nos exemples, mais généralement il y en a beaucoup d'autres méthodes utiles, comme la sequence et la traverse pour travailler avec des tableaux de Monad s).

Nous pouvons exprimer map comme composition de pure et flatMap . Vous pouvez voir à partir de la signature de flatMap $flatMap: (T \to M[U]) \to (M[T] \to M[U])$ qu'il est vraiment proche de $map: (T \to U) \ à (M[T] \à M[U])$. La différence est le $M$ supplémentaire au milieu, mais nous pouvons utiliser la fonction pure pour convertir $U$ en $M[U]$. De cette façon, nous exprimons map en termes de flatMap et pure .

Cela fonctionne bien pour Scala, car il dispose d'un système de type avancé. Cela fonctionne également bien pour JS, Python et Ruby, car ils sont typés dynamiquement. Malheureusement, cela ne fonctionne pas pour Swift, car il est typé statiquement et n'a pas de fonctionnalités de type avancées comme les types de type supérieur, donc pour Swift, nous devrons implémenter map pour chaque monade.

Notez également que la monade Option est déjà un standard de facto pour des langages comme Swift et Scala, nous utilisons donc des noms légèrement différents pour nos implémentations de monades.

Maintenant que nous avons une classe Monad de base, passons à nos implémentations de monad Option. Comme mentionné précédemment, l'idée de base est que Option contient une valeur (appelée Some ) ou ne contient aucune valeur ( None ).

La méthode pure promeut simplement une valeur à Some , tandis que la méthode flatMap vérifie la valeur actuelle de Option - si c'est None alors elle retourne None , et si c'est Some avec une valeur sous-jacente , elle extrait la valeur sous-jacente, applique f() à et renvoie un résultat.

Notez qu'en utilisant simplement ces deux fonctions et map , il est impossible d'entrer dans une exception de pointeur nul, jamais. (Le problème pourrait potentiellement survenir dans notre implémentation de la méthode flatMap , mais ce ne sont que quelques lignes de notre code que nous vérifions une fois. Après cela, nous utilisons simplement notre implémentation de monade Option dans notre code à des milliers d'endroits et ne avoir à craindre l'exception du pointeur nul du tout.)

L'une ou l'autre monade

Plongeons-nous dans la seconde monade : Soit. C'est fondamentalement la même chose que la monade Option, mais avec Some appelé Right et None appelé Left . Mais cette fois, Left est également autorisé à avoir une valeur sous-jacente.

Nous en avons besoin car il est très pratique d'exprimer une exception. Si une exception s'est produite, la valeur de Either sera Left(Exception) . La fonction flatMap ne progresse pas si la valeur est Left , ce qui répète la sémantique des exceptions levées : si une exception s'est produite, nous arrêtons toute exécution ultérieure.

JavaScript—Soit Monad

 import Monad from './monad'; export class Either extends Monad { // pure :: a -> Either a pure = (value) => { return new Right(value) } // flatMap :: # Either a -> (a -> Either b) -> Either b flatMap = f => this.isLeft() ? this : f(this.value) isLeft = () => this.constructor.name === 'Left' } export class Left extends Either { constructor(value) { super(); this.value = value; } toString() { return `Left(${this.value})` } } export class Right extends Either { constructor(value) { super(); this.value = value; } toString() { return `Right(${this.value})` } } // attempt :: (() -> a) -> M a Either.attempt = f => { try { return new Right(f()) } catch(e) { return new Left(e) } } Either.pure = (new Left(null)).pure

Python—Soit Monade

 from monad import Monad class Either(Monad): # pure :: a -> Either a @staticmethod def pure(value): return Right(value) # flat_map :: # Either a -> (a -> Either b) -> Either b def flat_map(self, f): if self.is_left: return self else: return f(self.value) class Left(Either): def __init__(self, value): self.value = value self.is_left = True class Right(Either): def __init__(self, value): self.value = value self.is_left = False

Rubis—Soit Monade

 require_relative './monad' class Either < Monad attr_accessor :is_left, :value # pure :: a -> Either a def self.pure(value) Right.new(value) end # pure :: a -> Either a def pure(value) self.class.pure(value) end # flat_map :: # Either a -> (a -> Either b) -> Either b def flat_map(f) if is_left self else f.call(value) end end end class Left < Either def initialize(value) @value = value @is_left = true end end class Right < Either def initialize(value) @value = value @is_left = false end end

Swift—Soit Monade

 import Foundation enum Either<A, B> { case Left(A) case Right(B) static func pure<C>(_ value: C) -> Either<A, C> { return Either<A, C>.Right(value) } func flatMap<D>(_ f: (B) -> Either<A, D>) -> Either<A, D> { switch self { case .Left(let x): return Either<A, D>.Left(x) case .Right(let x): return f(x) } } func map<C>(f: (B) -> C) -> Either<A, C> { return self.flatMap { Either<A, C>.pure(f($0)) } } }

Scala—Soit Monade

 package monad sealed trait MyEither[+E, +A] { def flatMap[EE >: E, B](f: A => MyEither[EE, B]): MyEither[EE, B] = Monad[MyEither[EE, ?]].flatMap(this)(f) def map[EE >: E, B](f: A => B): MyEither[EE, B] = Monad[MyEither[EE, ?]].map(this)(f) } case class MyLeft[E](e: E) extends MyEither[E, Nothing] case class MyRight[A](a: A) extends MyEither[Nothing, A] // ... implicit def myEitherMonad[E] = new Monad[MyEither[E, ?]] { def pure[A](a: A) = MyRight(a) def flatMap[A, B](ma: MyEither[E, A])(f: A => MyEither[E, B]): MyEither[E, B] = ma match { case MyLeft(a) => MyLeft(a) case MyRight(b) => f(b) } }

Notez également qu'il est facile d'intercepter les exceptions : tout ce que vous avez à faire est de mapper Left to Right . (Bien que nous ne le fassions pas dans nos exemples, par souci de brièveté.)

La future monade

Explorons la dernière monade dont nous avons besoin : la monade Future. La monade Future est essentiellement un conteneur pour une valeur qui est disponible maintenant ou qui le sera dans un avenir proche. Vous pouvez créer des chaînes de Futures avec map et flatMap qui attendront que la valeur Future soit résolue avant d'exécuter le prochain morceau de code qui dépend de la valeur résolue en premier. Ceci est très similaire au concept de promesses dans JS.

Notre objectif de conception est maintenant de relier les API asynchrones existantes dans différentes langues à une base cohérente. Il s'avère que l'approche de conception la plus simple consiste à utiliser des rappels dans $constructor$.

Bien que la conception du rappel ait introduit le problème de l'enfer du rappel en JavaScript et dans d'autres langages, ce ne sera pas un problème pour nous, puisque nous utilisons des monades. En fait, l'objet Promise , la base de la solution JavaScript à l'enfer des rappels, est lui-même une monade !

Qu'en est-il du constructeur de la future monade ? Est a cette signature:

constructor :: ((Either err a -> void) -> void) -> Future (Either err a)

Divisons-le en morceaux. Définissons d'abord :

type Callback = Either err a -> void

Ainsi, Callback est une fonction qui prend une erreur ou une valeur résolue comme argument et ne renvoie rien. Maintenant, notre signature ressemble à ceci :

constructor :: (Callback -> void) -> Future (Either err a)

Nous devons donc lui fournir une fonction qui ne renvoie rien et déclenche un rappel dès que le calcul asynchrone est résolu en erreur ou en valeur. Semble assez facile pour faire un pont pour n'importe quelle langue.

Quant à la conception de la monade Future elle-même, regardons sa structure interne. L'idée clé est d'avoir une variable de cache qui contient une valeur si la monade Future est résolue, ou ne contient rien autrement. Vous pouvez vous abonner au Future avec un rappel qui sera immédiatement déclenché si la valeur est résolue, ou sinon, mettra le rappel dans la liste des abonnés.

Une fois le futur résolu, chaque rappel de cette liste sera déclenché exactement une fois avec la valeur résolue dans un thread séparé (ou comme la prochaine fonction à exécuter dans la boucle d'événements, dans le cas de JS.) Notez qu'il est crucial de utilisez avec précaution les primitives de synchronisation, sinon des conditions de concurrence sont possibles.

Le flux de base est le suivant : vous démarrez le calcul asynchrone fourni en tant qu'argument du constructeur et pointez son rappel vers notre méthode de rappel interne. En attendant, vous pouvez vous abonner à la monade Future et mettre vos rappels dans la file d'attente. Une fois le calcul effectué, la méthode de rappel interne appelle tous les rappels de la file d'attente. Si vous connaissez les extensions réactives (RxJS, RxSwift, etc.), elles utilisent une approche très similaire à la gestion asynchrone.

L'API publique de la monade Future se compose de pure , map et flatMap , tout comme dans les monades précédentes. Nous aurons également besoin de quelques méthodes pratiques :

  1. async , qui prend une fonction de blocage synchrone et l'exécute sur un thread séparé, et
  2. traverse , qui prend un tableau de valeurs et une fonction qui mappe une valeur à un Future , et renvoie un Future d'un tableau de valeurs résolues

Voyons comment cela se passe :

JavaScript—Future Monade

 import Monad from './monad'; import { Either, Left, Right } from './either'; import { none, Some } from './option'; export class Future extends Monad { // constructor :: ((Either err a -> void) -> void) -> Future (Either err a) constructor(f) { super(); this.subscribers = []; this.cache = none; f(this.callback) } // callback :: Either err a -> void callback = (value) => { this.cache = new Some(value) while (this.subscribers.length) { const subscriber = this.subscribers.shift(); subscriber(value) } } // subscribe :: (Either err a -> void) -> void subscribe = (subscriber) => (this.cache === none ? this.subscribers.push(subscriber) : subscriber(this.cache.value)) toPromise = () => new Promise( (resolve, reject) => this.subscribe(val => val.isLeft() ? reject(val.value) : resolve(val.value)) ) // pure :: a -> Future a pure = Future.pure // flatMap :: (a -> Future b) -> Future b flatMap = f => new Future( cb => this.subscribe(value => value.isLeft() ? cb(value) : f(value.value).subscribe(cb)) ) } Future.async = (nodeFunction, ...args) => { return new Future(cb => nodeFunction(...args, (err, data) => err ? cb(new Left(err)) : cb(new Right(data))) ); } Future.pure = value => new Future(cb => cb(Either.pure(value))) // traverse :: [a] -> (a -> Future b) -> Future [b] Future.traverse = list => f => list.reduce( (acc, elem) => acc.flatMap(values => f(elem).map(value => [...values, value])), Future.pure([]) )

Python—Future Monade

 from monad import Monad from option import nil, Some from either import Either, Left, Right from functools import reduce import threading class Future(Monad): # __init__ :: ((Either err a -> void) -> void) -> Future (Either err a) def __init__(self, f): self.subscribers = [] self.cache = nil self.semaphore = threading.BoundedSemaphore(1) f(self.callback) # pure :: a -> Future a @staticmethod def pure(value): return Future(lambda cb: cb(Either.pure(value))) def exec(f, cb): try: data = f() cb(Right(data)) except Exception as err: cb(Left(err)) def exec_on_thread(f, cb): t = threading.Thread(target=Future.exec, args=[f, cb]) t.start() def async(f): return Future(lambda cb: Future.exec_on_thread(f, cb)) # flat_map :: (a -> Future b) -> Future b def flat_map(self, f): return Future( lambda cb: self.subscribe( lambda value: cb(value) if (value.is_left) else f(value.value).subscribe(cb) ) ) # traverse :: [a] -> (a -> Future b) -> Future [b] def traverse(arr): return lambda f: reduce( lambda acc, elem: acc.flat_map( lambda values: f(elem).map( lambda value: values + [value] ) ), arr, Future.pure([])) # callback :: Either err a -> void def callback(self, value): self.semaphore.acquire() self.cache = Some(value) while (len(self.subscribers) > 0): sub = self.subscribers.pop(0) t = threading.Thread(target=sub, args=[value]) t.start() self.semaphore.release() # subscribe :: (Either err a -> void) -> void def subscribe(self, subscriber): self.semaphore.acquire() if (self.cache.defined): self.semaphore.release() subscriber(self.cache.value) else: self.subscribers.append(subscriber) self.semaphore.release()

Rubis — Monade du futur

 require_relative './monad' require_relative './either' require_relative './option' class Future < Monad attr_accessor :subscribers, :cache, :semaphore # initialize :: ((Either err a -> void) -> void) -> Future (Either err a) def initialize(f) @subscribers = [] @cache = $none @semaphore = Queue.new @semaphore.push(nil) f.call(method(:callback)) end # pure :: a -> Future a def self.pure(value) Future.new(-> (cb) { cb.call(Either.pure(value)) }) end def self.async(f, *args) Future.new(-> (cb) { Thread.new { begin cb.call(Right.new(f.call(*args))) rescue => e cb.call(Left.new(e)) end } }) end # pure :: a -> Future a def pure(value) self.class.pure(value) end # flat_map :: (a -> Future b) -> Future b def flat_map(f) Future.new(-> (cb) { subscribe(-> (value) { if (value.is_left) cb.call(value) else f.call(value.value).subscribe(cb) end }) }) end # traverse :: [a] -> (a -> Future b) -> Future [b] def self.traverse(arr, f) arr.reduce(Future.pure([])) do |acc, elem| acc.flat_map(-> (values) { f.call(elem).map(-> (value) { values + [value] }) }) end end # callback :: Either err a -> void def callback(value) semaphore.pop self.cache = Some.new(value) while (subscribers.count > 0) sub = self.subscribers.shift Thread.new { sub.call(value) } end semaphore.push(nil) end # subscribe :: (Either err a -> void) -> void def subscribe(subscriber) semaphore.pop if (self.cache.defined) semaphore.push(nil) subscriber.call(cache.value) else self.subscribers.push(subscriber) semaphore.push(nil) end end end

Rapide - Monade du futur

 import Foundation let background = DispatchQueue(label: "background", attributes: .concurrent) class Future<Err, A> { typealias Callback = (Either<Err, A>) -> Void var subscribers: Array<Callback> = Array<Callback>() var cache: Maybe<Either<Err, A>> = .None var semaphore = DispatchSemaphore(value: 1) lazy var callback: Callback = { value in self.semaphore.wait() self.cache = .Some(value) while (self.subscribers.count > 0) { let subscriber = self.subscribers.popLast() background.async { subscriber?(value) } } self.semaphore.signal() } init(_ f: @escaping (@escaping Callback) -> Void) { f(self.callback) } func subscribe(_ cb: @escaping Callback) { self.semaphore.wait() switch cache { case .None: subscribers.append(cb) self.semaphore.signal() case .Some(let value): self.semaphore.signal() cb(value) } } static func pure<B>(_ value: B) -> Future<Err, B> { return Future<Err, B> { $0(Either<Err, B>.pure(value)) } } func flatMap<B>(_ f: @escaping (A) -> Future<Err, B>) -> Future<Err, B> { return Future<Err, B> { [weak self] cb in guard let this = self else { return } this.subscribe { value in switch value { case .Left(let err): cb(Either<Err, B>.Left(err)) case .Right(let x): f(x).subscribe(cb) } } } } func map<B>(_ f: @escaping (A) -> B) -> Future<Err, B> { return self.flatMap { Future<Err, B>.pure(f($0)) } } static func traverse<B>(_ list: Array<A>, _ f: @escaping (A) -> Future<Err, B>) -> Future<Err, Array<B>> { return list.reduce(Future<Err, Array<B>>.pure(Array<B>())) { (acc: Future<Err, Array<B>>, elem: A) in return acc.flatMap { elems in return f(elem).map { val in return elems + [val] } } } } }

Scala—Future Monade

 package monad import java.util.concurrent.Semaphore class MyFuture[A] { private var subscribers: List[MyEither[Exception, A] => Unit] = List() private var cache: MyOption[MyEither[Exception, A]] = MyNone private val semaphore = new Semaphore(1) def this(f: (MyEither[Exception, A] => Unit) => Unit) { this() f(this.callback _) } def flatMap[B](f: A => MyFuture[B]): MyFuture[B] = Monad[MyFuture].flatMap(this)(f) def map[B](f: A => B): MyFuture[B] = Monad[MyFuture].map(this)(f) def callback(value: MyEither[Exception, A]): Unit = { semaphore.acquire cache = MySome(value) subscribers.foreach { sub => val t = new Thread( new Runnable { def run: Unit = { sub(value) } } ) t.start } subscribers = List() semaphore.release } def subscribe(sub: MyEither[Exception, A] => Unit): Unit = { semaphore.acquire cache match { case MyNone => subscribers = sub :: subscribers semaphore.release case MySome(value) => semaphore.release sub(value) } } } object MyFuture { def async[B, C](f: B => C, arg: B): MyFuture[C] = new MyFuture[C]({ cb => val t = new Thread( new Runnable { def run: Unit = { try { cb(MyRight(f(arg))) } catch { case e: Exception => cb(MyLeft(e)) } } } ) t.start }) def traverse[A, B](list: List[A])(f: A => MyFuture[B]): MyFuture[List[B]] = { list.foldRight(Monad[MyFuture].pure(List[B]())) { (elem, acc) => Monad[MyFuture].flatMap(acc) ({ values => Monad[MyFuture].map(f(elem)) { value => value :: values } }) } } } // ... implicit val myFutureMonad = new Monad[MyFuture] { def pure[A](a: A): MyFuture[A] = new MyFuture[A]({ cb => cb(myEitherMonad[Exception].pure(a)) }) def flatMap[A, B](ma: MyFuture[A])(f: A => MyFuture[B]): MyFuture[B] = new MyFuture[B]({ cb => ma.subscribe(_ match { case MyLeft(e) => cb(MyLeft(e)) case MyRight(a) => f(a).subscribe(cb) }) }) }

Maintenant, remarquez que l'API publique de Future ne contient aucun détail de bas niveau comme les threads, les sémaphores ou quoi que ce soit de ce genre. Tout ce dont vous avez besoin est essentiellement de fournir quelque chose avec un rappel, et c'est tout !

Composer un programme à partir de monades

D'accord, essayons d'utiliser nos monades pour créer un programme réel. Supposons que nous ayons un fichier avec une liste d'URL et que nous souhaitions récupérer chacune de ces URL en parallèle. Ensuite, nous voulons réduire les réponses à 200 octets chacune pour plus de brièveté et imprimer le résultat.

Nous commençons par convertir les API de langage existantes en interfaces monadiques (voir les fonctions readFile et fetch ). Maintenant que nous avons cela, nous pouvons simplement les composer pour obtenir le résultat final en une seule chaîne. Notez que la chaîne elle-même est super sûre, car tous les détails sanglants sont contenus dans des monades.

JavaScript—Exemple de programme Monad

 import { Future } from './future'; import { Either, Left, Right } from './either'; import { readFile } from 'fs'; import https from 'https'; const getResponse = url => new Future(cb => https.get(url, res => { var body = ''; res.on('data', data => body += data); res.on('end', data => cb(new Right(body))); res.on('error', err => cb(new Left(err))) })) const getShortResponse = url => getResponse(url).map(resp => resp.substring(0, 200)) Future .async(readFile, 'resources/urls.txt') .map(data => data.toString().split("\n")) .flatMap(urls => Future.traverse(urls)(getShortResponse)) .map(console.log)

Python—Sample Monad Program

 import http.client import threading import time import os from future import Future from either import Either, Left, Right conn = http.client.HTTPSConnection("en.wikipedia.org") def read_file_sync(uri): base_dir = os.path.dirname(__file__) #<-- absolute dir the script is in path = os.path.join(base_dir, uri) with open(path) as f: return f.read() def fetch_sync(uri): conn.request("GET", uri) r = conn.getresponse() return r.read().decode("utf-8")[:200] def read_file(uri): return Future.async(lambda: read_file_sync(uri)) def fetch(uri): return Future.async(lambda: fetch_sync(uri)) def main(args=None): lines = read_file("../resources/urls.txt").map(lambda res: res.splitlines()) content = lines.flat_map(lambda urls: Future.traverse(urls)(fetch)) output = content.map(lambda res: print("\n".join(res))) if __name__ == "__main__": main()

Ruby—Sample Monad Program

 require './lib/future' require 'net/http' require 'uri' semaphore = Queue.new def read(uri) Future.async(-> () { File.read(uri) }) end def fetch(url) Future.async(-> () { uri = URI(url) Net::HTTP.get_response(uri).body[0..200] }) end read("resources/urls.txt") .map(-> (x) { x.split("\n") }) .flat_map(-> (urls) { Future.traverse(urls, -> (url) { fetch(url) }) }) .map(-> (res) { puts res; semaphore.push(true) }) semaphore.pop

Swift—Sample Monad Program

 import Foundation enum Err: Error { case Some(String) } func readFile(_ path: String) -> Future<Error, String> { return Future<Error, String> { callback in background.async { let url = URL(fileURLWithPath: path) let text = try? String(contentsOf: url) if let res = text { callback(Either<Error, String>.pure(res)) } else { callback(Either<Error, String>.Left(Err.Some("Error reading urls.txt"))) } } } } func fetchUrl(_ url: String) -> Future<Error, String> { return Future<Error, String> { callback in background.async { let url = URL(string: url) let task = URLSession.shared.dataTask(with: url!) {(data, response, error) in if let err = error { callback(Either<Error, String>.Left(err)) return } guard let nonEmptyData = data else { callback(Either<Error, String>.Left(Err.Some("Empty response"))) return } guard let result = String(data: nonEmptyData, encoding: String.Encoding.utf8) else { callback(Either<Error, String>.Left(Err.Some("Cannot decode response"))) return } let index = result.index(result.startIndex, offsetBy: 200) callback(Either<Error, String>.pure(String(result[..<index]))) } task.resume() } } } var result: Any = "" let _ = readFile("\(projectDir)/Resources/urls.txt") .map { data -> [String] in data.components(separatedBy: "\n").filter { (line: String) in !line.isEmpty } }.flatMap { urls in return Future<Error, String>.traverse(urls) { url in return fetchUrl(url) } }.map { responses in print(responses) } RunLoop.main.run()

Scala—Sample Monad Program

 import scala.io.Source import java.util.concurrent.Semaphore import monad._ object Main extends App { val semaphore = new Semaphore(0) def readFile(name: String): MyFuture[List[String]] = MyFuture.async[String, List[String]](filename => Source.fromResource(filename).getLines.toList, name) def fetch(url: String): MyFuture[String] = MyFuture.async[String, String]( uri => Source.fromURL(uri).mkString.substring(0, 200), url ) val future = for { urls <- readFile("urls.txt") entries <- MyFuture.traverse(urls)(fetch _) } yield { println(entries) semaphore.release } semaphore.acquire }

There you have it—monad solutions in practice. You can find a repo containing all the code from this article on GitHub.

Overhead: Done. Benefits: Ongoing

For this simple monad-based program, it might look like overkill to use all the code that we wrote before. But that's just the initial setup, and it will stay constant in its size. Imagine that from now on, using monads, you can write a lot of async code, not worrying about threads, race conditions, semaphores, exceptions, or null pointers! Impressionnant!