Opzione/Forse, entrambi e Monadi future in JavaScript, Python, Ruby, Swift e Scala
Pubblicato: 2022-03-11Questo tutorial sulle monadi fornisce una breve spiegazione delle monadi e mostra come implementare quelle più utili in cinque diversi linguaggi di programmazione, se stai cercando monadi in JavaScript, monadi in Python, monadi in Ruby, monadi in Swift e/o monadi in Scala, o per confrontare eventuali implementazioni, stai leggendo l'articolo giusto!
Usando queste monadi eliminerai una serie di bug come eccezioni di puntatore nullo, eccezioni non gestite e condizioni di gara.
Questo è ciò che tratterò di seguito:
- Introduzione alla teoria delle categorie
- La definizione di monade
- Implementazioni di Option ("Maybe") monad, O monad e Future monad, oltre a un programma di esempio che li sfrutta, in JavaScript, Python, Ruby, Swift e Scala
Iniziamo! La nostra prima tappa è la teoria delle categorie, che è alla base delle monadi.
Introduzione alla teoria delle categorie
La teoria delle categorie è un campo matematico che è stato attivamente sviluppato a metà del 20° secolo. Ora è la base di molti concetti di programmazione funzionale, inclusa la monade. Diamo una rapida occhiata ad alcuni concetti di teoria delle categorie, ottimizzati per la terminologia dello sviluppo software.
Quindi ci sono tre concetti fondamentali che definiscono una categoria:
- Il tipo è proprio come lo vediamo nei linguaggi tipizzati staticamente. Esempi:
Int
,String
,Dog
,Cat
, ecc. - Le funzioni collegano due tipi. Pertanto, possono essere rappresentati come una freccia da un tipo a un altro tipo oa se stessi. La funzione $f$ dal tipo $T$ al tipo $U$ può essere indicata come $f: T \to U$. Puoi pensarla come una funzione del linguaggio di programmazione che accetta un argomento di tipo $T$ e restituisce un valore di tipo $U$.
- La composizione è un'operazione, indicata dall'operatore $\cdot$, che costruisce nuove funzioni da quelle esistenti. In una categoria, è sempre garantito per qualsiasi funzione $f: T \to U$ e $g: U \to V$ esiste una funzione univoca $h: T \to V$. Questa funzione è indicata come $f \cdot g$. L'operazione mappa efficacemente una coppia di funzioni su un'altra funzione. Nei linguaggi di programmazione, questa operazione è ovviamente sempre possibile. Ad esempio, se hai una funzione che restituisce la lunghezza di una stringa —$strlen: String \to Int$—e una funzione che dice se il numero è pari —$even: Int \to Boolean$—allora puoi creare un function $even{\_}strlen: String \to Boolean$ che indica se la lunghezza della
String
è pari. In questo caso $even{\_}strlen = anche \cdot strlen$. La composizione implica due caratteristiche:- Associatività: $f \cdot g \cdot h = (f \cdot g) \cdot h = f \cdot (g \cdot h)$
- L'esistenza di una funzione di identità: $\forall T: \exists f: T \to T$, o in parole povere, per ogni tipo $T$ esiste una funzione che mappa $T$ a se stesso.
Quindi diamo un'occhiata a una categoria semplice.
Nota a margine: supponiamo che Int
, String
e tutti gli altri tipi qui siano garantiti come non null, ovvero il valore nullo non esiste.
Nota a margine 2: questa è in realtà solo una parte di una categoria, ma è tutto ciò che vogliamo per la nostra discussione, perché ha tutte le parti essenziali di cui abbiamo bisogno e il diagramma è meno disordinato in questo modo. La categoria reale avrebbe anche tutte le funzioni composte come $roundToString: Double \to String = intToString \cdot round$, per soddisfare la clausola di composizione delle categorie.
Potresti notare che le funzioni in questa categoria sono semplicissime. In effetti è quasi impossibile avere un bug in queste funzioni. Non ci sono nulli, nessuna eccezione, solo l'aritmetica e il lavoro con la memoria. Quindi l'unica cosa negativa che può accadere è un errore del processore o della memoria, nel qual caso è comunque necessario arrestare il programma in modo anomalo, ma ciò accade molto di rado.
Non sarebbe bello se tutto il nostro codice funzionasse a questo livello di stabilità? Assolutamente! Ma che dire dell'I/O, per esempio? Non possiamo assolutamente vivere senza di essa. È qui che le soluzioni monad vengono in soccorso: isolano tutte le operazioni instabili in pezzi di codice super piccoli e molto ben controllati, quindi puoi utilizzare calcoli stabili nell'intera app!
Entrano le Monadi
Chiamiamo un comportamento instabile come I/O un effetto collaterale . Ora vogliamo essere in grado di lavorare con tutte le nostre funzioni precedentemente definite come length
e tipi come String
in modo stabile in presenza di questo effetto collaterale .
Quindi iniziamo con una categoria vuota $M[A]$ e la trasformiamo in una categoria che avrà valori con un particolare tipo di effetto collaterale e anche valori senza effetti collaterali. Supponiamo di aver definito questa categoria ed è vuota. In questo momento non c'è niente di utile che possiamo fare con esso, quindi per renderlo utile, seguiremo questi tre passaggi:
- Riempilo con i valori dei tipi della categoria $A$, come
String
,Int
,Double
, ecc. (caselle verdi nel diagramma seguente) - Una volta che abbiamo questi valori, non possiamo ancora fare nulla di significativo con loro, quindi abbiamo bisogno di un modo per prendere ogni funzione $f: T \to U$ da $A$ e creare una funzione $g: M[T] \to M [U]$ (frecce blu nel diagramma sottostante). Una volta che abbiamo queste funzioni, possiamo fare tutto con i valori nella categoria $M[A]$ che siamo stati in grado di fare nella categoria $A$.
- Ora che abbiamo una nuovissima categoria $M[A]$, emerge una nuova classe di funzioni con la firma $h: T \to M[U]$ (frecce rosse nel diagramma sottostante). Emergono come risultato della promozione dei valori nella fase uno come parte della nostra base di codice, ovvero li scriviamo secondo necessità; queste sono le cose principali che differenziano il lavoro con $M[A]$ rispetto al lavoro con $A$. Il passaggio finale sarà fare in modo che queste funzioni funzionino bene anche sui tipi in $M[A]$, cioè essere in grado di derivare la funzione $m: M[T] \to M[U]$ da $h: T \ a M[U]$
Quindi iniziamo definendo due modi per promuovere valori di tipi $A$ a valori di tipi $M[A]$: una funzione senza effetti collaterali e una con effetti collaterali.
- Il primo si chiama $pure$ ed è definito per ogni valore di una categoria stabile: $pure: T \to M[T]$. I valori $M[T]$ risultanti non avranno effetti collaterali, quindi questa funzione è chiamata $pure$. Ad esempio, per una monade I/O, $pure$ restituirà un valore immediatamente senza possibilità di errore.
- Il secondo si chiama $constructor$ e, a differenza di $pure$, restituisce $M[T]$ con alcuni effetti collaterali. Un esempio di tale $constructor$ per una monade I/O asincrona potrebbe essere una funzione che recupera alcuni dati dal Web e li restituisce come
String
. Il valore restituito da $constructor$ avrà in questo caso il tipo $M[String]$.
Ora che abbiamo due modi per promuovere i valori in $M[A]$, spetta a te come programmatore scegliere quale funzione utilizzare, a seconda degli obiettivi del tuo programma. Consideriamo un esempio qui: vuoi recuperare una pagina HTML come https://www.toptal.com/javascript/option-maybe-either-future-monads-js e per questo fai una funzione $fetch$. Dal momento che qualcosa potrebbe andare storto durante il recupero, ad esempio errori di rete, ecc., utilizzerai $M[String]$ come tipo restituito di questa funzione. Quindi assomiglierà a $fetch: String \to M[String]$ e da qualche parte nel corpo della funzione useremo $constructor$ per $M$.
Ora supponiamo di creare una funzione simulata per il test: $fetchMock: String \to M[String]$. Ha ancora la stessa firma, ma questa volta iniettiamo semplicemente la pagina HTML risultante all'interno del corpo di $fetchMock$ senza eseguire operazioni di rete instabili. Quindi in questo caso usiamo semplicemente $pure$ nell'implementazione di $fetchMock$.
Come passaggio successivo, abbiamo bisogno di una funzione che promuova in modo sicuro qualsiasi funzione arbitraria $f$ dalla categoria $A$ a $M[A]$ (frecce blu in un diagramma). Questa funzione è chiamata $map: (T \to U) \to (M[T] \to M[U])$.
Ora abbiamo una categoria (che può avere effetti collaterali se utilizziamo $constructor$), che ha anche tutte le funzioni della categoria stable, il che significa che sono stabili anche in $M[A]$. Potresti notare che abbiamo introdotto esplicitamente un'altra classe di funzioni come $f: T \to M[U]$. Ad esempio, $pure$ e $constructor$ sono esempi di tali funzioni per $U = T$, ma ovviamente potrebbero essercene di più, come se dovessimo usare $pure$ e poi $map$. Quindi, in generale, abbiamo bisogno di un modo per gestire funzioni arbitrarie nella forma $f: T \to M[U]$.
Se vogliamo creare una nuova funzione basata su $f$ che possa essere applicata a $M[T]$, potremmo provare ad usare $map$. Ma questo ci porterà alla funzione $g: M[T] \to M[M[U]]$, il che non va bene poiché non vogliamo avere un'altra categoria $M[M[A]]$. Per affrontare questo problema, introduciamo un'ultima funzione: $flatMap: (T \to M[U]) \to (M[T] \to M[U])$.
Ma perché vorremmo farlo? Supponiamo di essere dopo il passaggio 2, ovvero di avere $pure$, $constructor$ e $map$. Supponiamo di voler prendere una pagina HTML da toptal.com, quindi scansionare tutti gli URL lì e recuperarli. Farei una funzione $fetch: String \to M[String]$ che recupera solo un URL e restituisce una pagina HTML.
Quindi applicherei questa funzione a un URL e otterrei una pagina da toptal.com, che è $x: M[String]$. Ora, eseguo alcune trasformazioni su $x$ e finalmente arrivo a un URL $u: M[String]$. Voglio applicare la funzione $fetch$, ma non posso, perché richiede il tipo $String$, non $M[String]$. Ecco perché abbiamo bisogno di $flatMap$ per convertire $fetch: String \to M[String]$ in $m_fetch: M[String] \to M[String]$.
Ora che abbiamo completato tutti e tre i passaggi, possiamo effettivamente comporre tutte le trasformazioni di valore di cui abbiamo bisogno. Ad esempio, se hai un valore $x$ di tipo $M[T]$ e $f: T \to U$, puoi usare $map$ per applicare $f$ al valore $x$ e ottenere il valore $y$ di tipo $M[U]$. In questo modo qualsiasi trasformazione dei valori può essere eseguita senza bug al 100%, a condizione che le implementazioni $pure$, $constructor$, $map$ e $flatMap$ siano prive di bug.
Quindi, invece di affrontare alcuni brutti effetti ogni volta che li incontri nella tua base di codice, devi solo assicurarti che solo queste quattro funzioni siano implementate correttamente. Alla fine del programma, otterrai solo un $M[X]$ in cui puoi scartare in sicurezza il valore $X$ e gestire tutti i casi di errore.
Ecco cos'è una monade: una cosa che implementa $pure$, $map$ e $flatMap$. (In realtà $map$ può essere derivato da $pure$ e $flatMap$, ma è una funzione molto utile e diffusa, quindi non l'ho omessa dalla definizione.)
La Monade Opzione, alias la Monade Forse
Ok, tuffiamoci nell'implementazione pratica e nell'uso delle monadi. La prima monade veramente utile è la monade Option. Se provieni da linguaggi di programmazione classici, probabilmente hai riscontrato molti arresti anomali a causa del famigerato errore del puntatore nullo. Tony Hoare, l'inventore di null, chiama questa invenzione "The Billion Dollar Mistake":
Ciò ha portato a innumerevoli errori, vulnerabilità e arresti anomali del sistema, che hanno probabilmente causato un miliardo di dollari di dolore e danni negli ultimi quarant'anni.
Quindi proviamo a migliorare su questo. La monade Option contiene un valore non nullo o nessun valore. Abbastanza simile a un valore nullo, ma avendo questa monade, possiamo tranquillamente usare le nostre funzioni ben definite senza aver paura dell'eccezione del puntatore nullo. Diamo un'occhiata alle implementazioni in diverse lingue:
JavaScript: opzione Monade/Forse 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: opzione Monade/Forse 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()
Rubino: opzione Monade/Forse 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—Opzione Monade/Forse 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—Opzione Monade/Forse 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]
Iniziamo implementando una classe Monad
che sarà la base per tutte le nostre implementazioni monad. Avere questa classe è molto utile, perché implementando solo due dei suoi metodi — pure
e flatMap
— per una specifica monade, otterrai molti metodi gratuitamente (nei nostri esempi li limitiamo semplicemente al metodo map
, ma generalmente ce ne sono molti altri metodi utili, come sequence
e traverse
per lavorare con array di Monad
s).
Possiamo esprimere la map
come composizione di pure
e flatMap
. Puoi vedere dalla firma di flatMap
$flatMap: (T \to M[U]) \to (M[T] \to M[U])$ che è molto vicino a $map: (T \to U) \ a (M[T] \a M[U])$. La differenza è l'ulteriore $M$ nel mezzo, ma possiamo usare la funzione pure
per convertire $U$ in $M[U]$. In questo modo esprimiamo map
in termini di flatMap
e pure
.
Funziona bene per Scala, perché ha un sistema di tipi avanzato. Funziona bene anche per JS, Python e Ruby, perché sono tipizzati dinamicamente. Sfortunatamente, non funziona per Swift, perché è tipizzato staticamente e non ha caratteristiche di tipo avanzate come tipi di tipo superiore, quindi per Swift dovremo implementare la map
per ogni monade.
Nota anche che Option monad è già uno standard de facto per linguaggi come Swift e Scala, quindi usiamo nomi leggermente diversi per le nostre implementazioni monad.
Ora che abbiamo una classe Monad
di base, passiamo alle nostre implementazioni di Option monad. Come accennato in precedenza, l'idea di base è che Option contenga un valore (chiamato Some
) o non contenga alcun valore ( None
).
Il metodo pure
promuove semplicemente un valore a Some
, mentre il metodo flatMap
controlla il valore corrente di Option
— se è None
allora restituisce None
, e se è Some
con un valore sottostante , estrae il valore sottostante, applica f()
a it e restituisce un risultato.
Nota che solo usando queste due funzioni e map
, è impossibile entrare in un'eccezione di puntatore nullo, mai. (Il problema potrebbe potenzialmente sorgere nella nostra implementazione del metodo flatMap
, ma sono solo un paio di righe nel nostro codice che controlliamo una volta. Dopodiché, utilizziamo semplicemente la nostra implementazione Option monad in tutto il nostro codice in migliaia di posti e non devi assolutamente temere l'eccezione del puntatore nullo.)
L'uno o l'altro Monade
Entriamo nella seconda monade: o. Questo è fondamentalmente lo stesso di Option monad, ma con Some
chiamato Right
e None
chiamato Left
. Ma questa volta, a Left
è consentito anche avere un valore di fondo.
Ne abbiamo bisogno perché è molto conveniente esprimere la generazione di un'eccezione. Se si è verificata un'eccezione, il valore di Either
sarà Left(Exception)
. La funzione flatMap
non procede se il valore è Left
, che ripete la semantica della generazione di eccezioni: se si verifica un'eccezione, interrompiamo l'ulteriore esecuzione.
JavaScript: o Monade
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: o 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
Rubino: o 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: o 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: o 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) } }
Nota anche che è facile catturare le eccezioni: tutto ciò che devi fare è mappare da Left
a Right
. (Anche se non lo facciamo nei nostri esempi, per brevità.)

La futura monade
Esploriamo l'ultima monade di cui abbiamo bisogno: la monade del futuro. La monade del futuro è fondamentalmente un contenitore per un valore che è disponibile ora o sarà disponibile nel prossimo futuro. Puoi creare catene di Futures con map
e flatMap
che attenderanno che il valore Future venga risolto prima di eseguire il pezzo di codice successivo che dipende dal valore che viene risolto per primo. Questo è molto simile al concetto di Promises in JS.
Il nostro obiettivo di progettazione ora è collegare le API asincrone esistenti in diverse lingue a un'unica base coerente. Si scopre che l'approccio di progettazione più semplice consiste nell'usare i callback in $constructor$.
Sebbene il design del callback abbia introdotto il problema dell'inferno di callback in JavaScript e in altri linguaggi, non sarà un problema per noi, poiché utilizziamo le monadi. In effetti, l'oggetto Promise
, la base per la soluzione di JavaScript per richiamare l'inferno, è esso stesso una monade!
E il costruttore della monade del futuro? Ha questa firma:
constructor :: ((Either err a -> void) -> void) -> Future (Either err a)
Dividiamolo in pezzi. Innanzitutto definiamo:
type Callback = Either err a -> void
Quindi Callback
è una funzione che accetta un errore o un valore risolto come argomento e non restituisce nulla. Ora la nostra firma si presenta così:
constructor :: (Callback -> void) -> Future (Either err a)
Quindi dobbiamo fornirgli una funzione che non restituisca nulla e attivi un callback non appena il calcolo asincrono viene risolto in un errore o in un valore. Sembra abbastanza facile da creare un ponte per qualsiasi lingua.
Per quanto riguarda il design della stessa Monade del Futuro, diamo un'occhiata alla sua struttura interna. L'idea chiave è avere una variabile cache che contenga un valore se la monade Future viene risolta o non contenga nulla in caso contrario. Puoi iscriverti al futuro con una richiamata che verrà immediatamente attivata se il valore viene risolto o, in caso contrario, inserirà la richiamata nell'elenco degli abbonati.
Una volta che il futuro è stato risolto, ogni callback in questo elenco verrà attivato esattamente una volta con il valore risolto in un thread separato (o come la prossima funzione da eseguire nel ciclo di eventi, nel caso di JS). Nota che è fondamentale usa con attenzione le primitive di sincronizzazione, altrimenti sono possibili condizioni di gara.
Il flusso di base è: si avvia il calcolo asincrono fornito come argomento del costruttore e si punta il suo callback al nostro metodo di callback interno. Nel frattempo, puoi iscriverti alla Monade del futuro e mettere in coda le tue richiamate. Una volta terminato il calcolo, il metodo di callback interno chiama tutti i callback nella coda. Se hai familiarità con le estensioni reattive (RxJS, RxSwift e così via), usano un approccio molto simile alla gestione asincrona.
L'API pubblica della monade futura è composta da pure
, map
e flatMap
, proprio come nelle monadi precedenti. Avremo anche bisogno di un paio di metodi pratici:
-
async
, che accetta una funzione di blocco sincrona e la esegue su un thread separato, e -
traverse
, che accetta una matrice di valori e una funzione che associa un valore a unFuture
e restituisce unFuture
di una matrice di valori risolti
Vediamo come va a finire:
JavaScript: la futura 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: la futura 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()
Rubino: la futura monade
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
Swift: la futura monade
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: la futura 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) }) }) }
Ora, nota come l'API pubblica di Future
non contenga dettagli di basso livello come thread, semafori o cose del genere. Tutto ciò di cui hai bisogno è fondamentalmente fornire qualcosa con una richiamata, e il gioco è fatto!
Comporre un programma da Monadi
Ok, quindi proviamo a usare le nostre monadi per creare un programma vero e proprio. Supponiamo di avere un file con un elenco di URL e di voler recuperare ciascuno di questi URL in parallelo. Quindi, vogliamo tagliare le risposte a 200 byte ciascuna per brevità e stampare il risultato.
Iniziamo convertendo le API del linguaggio esistenti in interfacce monadiche (vedi le funzioni readFile
e fetch
). Ora che lo abbiamo, possiamo semplicemente comporli per ottenere il risultato finale come una catena. Nota che la catena stessa è super sicura, poiché tutti i dettagli cruenti sono contenuti nelle monadi.
JavaScript: programma Monade di esempio
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! Stupendo!