Opțiune/Poate, Oricare și Monade viitoare în JavaScript, Python, Ruby, Swift și Scala
Publicat: 2022-03-11Acest tutorial de monade oferă o scurtă explicație a monadelor și arată cum să le implementați pe cele mai utile în cinci limbaje de programare diferite — dacă căutați monade în JavaScript, monade în Python, monade în Ruby, monade în Swift și/sau monade în Scala, sau pentru a compara orice implementare, citiți articolul potrivit!
Folosind aceste monade, veți scăpa de o serie de erori, cum ar fi excepții de tip nul-pointer, excepții negestionate și condiții de cursă.
Acesta este ceea ce acopăr mai jos:
- O introducere în teoria categoriilor
- Definiția monadei
- Implementări ale monadei Opțiune („Poate”), fie monadei și monadei viitoare, plus un exemplu de program care le folosește, în JavaScript, Python, Ruby, Swift și Scala
Să începem! Prima noastră oprire este teoria categoriilor, care stă la baza monadelor.
Introducere în teoria categoriilor
Teoria categoriilor este un domeniu matematic care a fost dezvoltat activ la mijlocul secolului al XX-lea. Acum este baza multor concepte de programare funcțională, inclusiv monada. Să aruncăm o privire rapidă asupra unor concepte de teorie a categoriilor, adaptate pentru terminologia dezvoltării software.
Deci, există trei concepte de bază care definesc o categorie:
- Tipul este exact așa cum îl vedem în limbile tipizate static. Exemple:
Int
,String
,Dog
,Cat
, etc. - Funcțiile conectează două tipuri. Prin urmare, ele pot fi reprezentate ca o săgeată de la un tip la altul, sau la ei înșiși. Funcția $f$ de la tipul $T$ la tipul $U$ poate fi notată ca $f: T \la U$. Vă puteți gândi la ea ca la o funcție de limbaj de programare care preia un argument de tip $T$ și returnează o valoare de tip $U$.
- Compunerea este o operațiune, notată de operatorul $\cdot$, care construiește noi funcții din cele existente. Într-o categorie, este întotdeauna garantat pentru orice funcții $f: T \to U$ și $g: U \to V$ există o funcție unică $h: T \to V$. Această funcție este notată ca $f \cdot g$. Operația mapează efectiv o pereche de funcții la o altă funcție. În limbajele de programare, această operație este, desigur, întotdeauna posibilă. De exemplu, dacă aveți o funcție care returnează lungimea unui șir —$strlen: String \to Int$ — și o funcție care spune dacă numărul este par —$even: Int \to Boolean$—, atunci puteți crea un function $even{\_}strlen: String \to Boolean$ care spune dacă lungimea
String
este pară. În acest caz $even{\_}strlen = even \cdot strlen$. Compoziția presupune două caracteristici:- Asociativitate: $f \cdot g \cdot h = (f \cdot g) \cdot h = f \cdot (g \cdot h)$
- Existența unei funcții de identitate: $\forall T: \exists f: T \to T$, sau în limba engleză simplă, pentru fiecare tip $T$ există o funcție care mapează $T$ cu sine.
Deci, să aruncăm o privire la o categorie simplă.
Notă secundară: presupunem că Int
, String
și toate celelalte tipuri de aici sunt garantate a fi non-null, adică valoarea nulă nu există.
Notă secundară 2: Aceasta este de fapt doar o parte a unei categorii, dar asta este tot ce ne dorim pentru discuția noastră, deoarece are toate părțile esențiale de care avem nevoie și diagrama este mai puțin aglomerată în acest fel. Categoria reală ar avea și toate funcțiile compuse precum $roundToString: Double \to String = intToString \cdot round$, pentru a satisface clauza de compunere a categoriilor.
S-ar putea să observați că funcțiile din această categorie sunt super simple. De fapt, este aproape imposibil să ai un bug în aceste funcții. Nu există nule, nu există excepții, doar aritmetica și lucrul cu memoria. Deci singurul lucru rău care se poate întâmpla este defecțiunea procesorului sau a memoriei - caz în care trebuie să blocați programul oricum - dar asta se întâmplă foarte rar.
Nu ar fi frumos dacă tot codul nostru ar funcționa doar la acest nivel de stabilitate? Absolut! Dar cum rămâne cu I/O, de exemplu? Cu siguranță nu putem trăi fără ea. Iată unde soluțiile monade vin în ajutor: izolează toate operațiunile instabile în bucăți de cod super-mici și foarte bine auditate, apoi puteți utiliza calcule stabile în întreaga aplicație!
Intră în Monade
Să numim comportamentul instabil, cum ar fi I/O, un efect secundar . Acum dorim să putem lucra cu toate funcțiile noastre definite anterior, cum ar fi length
și tipurile precum String
, într-un mod stabil în prezența acestui efect secundar .
Deci, să începem cu o categorie goală $M[A]$ și să o transformăm într-o categorie care va avea valori cu un anumit tip de efect secundar și, de asemenea, valori fără efecte secundare. Să presupunem că am definit această categorie și este goală. În acest moment, nu putem face nimic util cu el, așa că pentru a-l face util, vom urma acești trei pași:
- Completați-l cu valori ale tipurilor din categoria $A$, cum ar fi
String
,Int
,Double
, etc. (casetele verzi din diagrama de mai jos) - Odată ce avem aceste valori, tot nu putem face nimic semnificativ cu ele, așa că avem nevoie de o modalitate de a lua fiecare funcție $f: T \to U$ de la $A$ și de a crea o funcție $g: M[T] \to M [U]$ (săgeți albastre din diagrama de mai jos). Odată ce avem aceste funcții, putem face totul cu valorile din categoria $M[A]$ pe care le-am putut face în categoria $A$.
- Acum că avem o categorie nouă $M[A]$, apare o nouă clasă de funcții cu semnătura $h: T \to M[U]$ (săgeți roșii în diagrama de mai jos). Ele apar ca urmare a promovării valorilor în pasul unu ca parte a bazei noastre de cod, adică le scriem după cum este necesar; acestea sunt principalele lucruri care vor diferenția lucrul cu $M[A]$ față de lucrul cu $A$. Pasul final va fi să faceți ca aceste funcții să funcționeze bine și pe tipurile din $M[A]$, adică să puteți deriva funcția $m: M[T] \la M[U]$ de la $h: T \ la M[U]$
Deci, să începem prin a defini două modalități de promovare a valorilor de tip $A$ la valori de tipuri $M[A]$: o funcție fără efecte secundare și una cu efecte secundare.
- Primul se numește $pur$ și este definit pentru fiecare valoare a unei categorii stabile: $pur: T \to M[T]$. Valorile rezultate $M[T]$ nu vor avea efecte secundare, prin urmare această funcție se numește $pure$. De exemplu, pentru o monada I/O, $pure$ va returna imediat o valoare, fără nicio posibilitate de eșec.
- Al doilea se numește $constructor$ și, spre deosebire de $pure$, returnează $M[T]$ cu unele efecte secundare. Un exemplu de astfel de $constructor$ pentru o monada I/O asincronă ar putea fi o funcție care preia unele date de pe web și le returnează ca
String
. Valoarea returnată de $constructor$ va avea tipul $M[String]$ în acest caz.
Acum că avem două moduri de a promova valori în $M[A]$, depinde de dvs., ca programator, să alegeți ce funcție să utilizați, în funcție de obiectivele programului dvs. Să luăm în considerare un exemplu aici: doriți să preluați o pagină HTML ca https://www.toptal.com/javascript/option-maybe-either-future-monads-js și pentru aceasta creați o funcție $fetch$. Deoarece orice ar putea merge prost în timpul preluării acestuia - gândiți-vă la eșecuri de rețea etc. - veți folosi $M[String]$ ca tip de returnare al acestei funcție. Deci va arăta ceva de genul $fetch: String \to M[String]$ și undeva în corpul funcției acolo vom folosi $constructor$ pentru $M$.
Acum să presupunem că facem o funcție simulată pentru testare: $fetchMock: String \to M[String]$. Are încă aceeași semnătură, dar de data aceasta doar injectăm pagina HTML rezultată în corpul lui $fetchMock$ fără a face operațiuni instabile de rețea. Deci, în acest caz, folosim doar $pure$ în implementarea $fetchMock$.
Ca pas următor, avem nevoie de o funcție care promovează în siguranță orice funcție arbitrară $f$ din categoria $A$ la $M[A]$ (săgeți albastre într-o diagramă). Această funcție se numește $map: (T \to U) \to (M[T] \to M[U])$.
Acum avem o categorie (care poate avea efecte secundare dacă folosim $constructor$), care are și toate funcțiile din categoria stabilă, adică sunt stabile și în $M[A]$. S-ar putea să observați că am introdus în mod explicit o altă clasă de funcții precum $f: T \to M[U]$. De exemplu, $pure$ și $constructor$ sunt exemple de astfel de funcții pentru $U = T$, dar, evident, ar putea fi mai multe, ca și cum ar fi să folosim $pure$ și apoi $map$. Deci, în general, avem nevoie de o modalitate de a trata funcțiile arbitrare sub forma $f: T \to M[U]$.
Dacă vrem să facem o nouă funcție bazată pe $f$ care ar putea fi aplicată la $M[T]$, am putea încerca să folosim $map$. Dar asta ne va aduce la funcția $g: M[T] \to M[M[U]]$, ceea ce nu este bun pentru că nu vrem să mai avem o categorie $M[M[A]]$. Pentru a rezolva această problemă, introducem o ultimă funcție: $flatMap: (T \to M[U]) \to (M[T] \to M[U])$.
Dar de ce am vrea să facem asta? Să presupunem că suntem după pasul 2, adică avem $pure$, $constructor$ și $map$. Să presupunem că vrem să luăm o pagină HTML de pe toptal.com, apoi să scanăm toate adresele URL de acolo și să le preluăm. Aș crea o funcție $fetch: String \to M[String]$ care preia o singură adresă URL și returnează o pagină HTML.
Apoi aș aplica această funcție la o adresă URL și aș obține o pagină de la toptal.com, care este $x: M[String]$. Acum, fac o transformare pe $x$ și ajung în sfârșit la o adresă URL $u: M[String]$. Vreau să îi aplic funcția $fetch$, dar nu pot, deoarece este nevoie de tipul $String$, nu $M[String]$. De aceea avem nevoie de $flatMap$ pentru a converti $fetch: String \to M[String]$ în $m_fetch: M[String] \to M[String]$.
Acum că am finalizat toți cei trei pași, putem de fapt să compunem orice transformări de valoare de care avem nevoie. De exemplu, dacă aveți valoarea $x$ de tip $M[T]$ și $f: T \la U$, puteți folosi $map$ pentru a aplica $f$ la valoarea $x$ și obțineți valoarea $y$ de tip $M[U]$. În acest fel, orice transformare a valorilor poate fi realizată într-un mod 100% fără erori, atâta timp cât implementările $pure$, $constructor$, $map$ și $flatMap$ sunt fără erori.
Deci, în loc să vă ocupați de unele efecte urâte de fiecare dată când le întâlniți în baza de cod, trebuie doar să vă asigurați că numai aceste patru funcții sunt implementate corect. La sfârșitul programului, veți obține doar un $M[X]$ unde puteți despacheta în siguranță valoarea $X$ și puteți gestiona toate cazurile de eroare.
Iată ce este o monada: un lucru care implementează $pure$, $map$ și $flatMap$. (De fapt, $map$ poate fi derivat din $pure$ și $flatMap$, dar este o funcție foarte utilă și răspândită, așa că nu am omis-o din definiție.)
Monada Opțiunii, alias Monada Poate
Bine, să ne aprofundăm în implementarea practică și utilizarea monadelor. Prima monada cu adevărat utilă este monada Opțiune. Dacă veniți din limbaje de programare clasice, probabil că ați întâmpinat multe blocări din cauza infamei erori de indicator nul. Tony Hoare, inventatorul nullului, numește această invenție „Greșeala de un miliard de dolari”:
Acest lucru a dus la nenumărate erori, vulnerabilități și blocări ale sistemului, care probabil au cauzat dureri și daune de un miliard de dolari în ultimii patruzeci de ani.
Deci să încercăm să îmbunătățim asta. Monada Opțiuni fie deține o valoare non-nulă, fie nicio valoare. Destul de similar cu o valoare nulă, dar având această monada, putem folosi în siguranță funcțiile noastre bine definite, fără a ne teme de excepția pointerului nul. Să aruncăm o privire asupra implementărilor în diferite limbi:
JavaScript—Opțiune Monad/Maybe Monad
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—Opțiune Monad/Maybe Monad
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()
Ruby—Opțiune Monad/Maybe Monad
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—Opțiune Monad/Maybe Monad
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—Opțiune Monad/Maybe Monad
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]
Începem prin a implementa o clasă Monad
care va fi baza pentru toate implementările noastre Monad. A avea această clasă este foarte util, deoarece implementând doar două dintre metodele sale — pure
și flatMap
— pentru o anumită monada, veți obține gratuit multe metode (le limităm la metoda map
în exemplele noastre, dar în general există multe alte metode utile, cum ar fi sequence
și traverse
pentru lucrul cu matrice de Monad
-uri).
Putem exprima map
ca compoziție pure
și flatMap
. Puteți vedea din semnătura lui flatMap
$flatMap: (T \to M[U]) \to (M[T] \to M[U])$ că este foarte aproape de $map: (T \to U) \ la (M[T] \la M[U])$. Diferența este $M$ suplimentară la mijloc, dar putem folosi funcția pure
pentru a converti $U$ în $M[U]$. În acest fel exprimăm map
în termeni de flatMap
și pure
.
Acest lucru funcționează bine pentru Scala, deoarece are un sistem de tip avansat. De asemenea, funcționează bine pentru JS, Python și Ruby, deoarece sunt tastate dinamic. Din păcate, nu funcționează pentru Swift, deoarece este scris static și nu are caracteristici de tip avansat, cum ar fi tipurile de tip mai mare, așa că pentru Swift va trebui să implementăm map
pentru fiecare monadă.
De asemenea, rețineți că monada Opțiune este deja un standard de facto pentru limbi precum Swift și Scala, așa că folosim nume ușor diferite pentru implementările noastre de monada.
Acum că avem o clasă Monad
de bază, să trecem la implementările noastre de monade Option. După cum s-a menționat anterior, ideea de bază este că Opțiunea fie deține o anumită valoare (numită Some
) fie sau nu deține nicio valoare ( None
).
Metoda pure
și simplu promovează o valoare pentru Some
, în timp ce metoda flatMap
verifică valoarea curentă a Option
- dacă este None
, atunci returnează None
, iar dacă este Some
cu o valoare de bază , extrage valoarea de bază, aplică f()
la acesta și returnează un rezultat.
Rețineți că doar folosind aceste două funcții și map
, este imposibil să intrați într-o excepție de pointer nul — vreodată. (Problema ar putea apărea în implementarea noastră a metodei flatMap
, dar acestea sunt doar câteva rânduri din codul nostru pe care le verificăm o dată. După aceea, folosim doar implementarea noastră Option monad în codul nostru în mii de locuri și nu trebuie să se teamă deloc de excepția indicatorului nul.)
Monada Oricare
Să ne aruncăm în cea de-a doua monada: Oricare. Aceasta este, practic, aceeași cu monada Opțiunii, dar cu Some
numit Right
și None
numit Left
. Dar de această dată, Left
are și voie să aibă o valoare subiacentă.
Avem nevoie de asta pentru că este foarte convenabil să exprimăm o excepție. Dacă a apărut o excepție, atunci valoarea Either
va fi Left(Exception)
. Funcția flatMap
nu progresează dacă valoarea este Left
, care repetă semantica aruncării excepțiilor: Dacă a avut loc o excepție, oprim execuția ulterioară.
JavaScript—Fie 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 — Fie Monad
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
Ruby—Fie Monad
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 — Fie Monad
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 — Fie Monad
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) } }
De asemenea, rețineți că este ușor să găsiți excepții: tot ce trebuie să faceți este să mapați de la Left
la Right
. (Deși, nu o facem în exemplele noastre, pentru concizie.)

Monada Viitorului
Să explorăm ultima monada de care avem nevoie: Monada Viitorului. Monada Viitorului este practic un container pentru o valoare care fie este disponibilă acum, fie va fi disponibilă în viitorul apropiat. Puteți face lanțuri de Futures cu map
și flatMap
care vor aștepta ca valoarea Future să fie rezolvată înainte de a executa următoarea bucată de cod care depinde de valoarea care este rezolvată mai întâi. Acesta este foarte asemănător cu conceptul de promisiuni din JS.
Scopul nostru de proiectare acum este să punem o punte între API-urile asincrone existente în diferite limbi la o bază coerentă. Se pare că cea mai ușoară abordare de proiectare este utilizarea callback-urilor în $constructor$.
În timp ce designul de apel invers a introdus problema callback hell în JavaScript și în alte limbi, nu va fi o problemă pentru noi, deoarece folosim monade. De fapt, obiectul Promise
– baza pentru soluția JavaScript pentru apelarea iadului – este o monada în sine!
Dar constructorul Monadei Viitorului? Are aceasta semnatura:
constructor :: ((Either err a -> void) -> void) -> Future (Either err a)
Să-l împărțim în bucăți. Mai întâi, să definim:
type Callback = Either err a -> void
Deci Callback
este o funcție care ia fie o eroare, fie o valoare rezolvată ca argument și nu returnează nimic. Acum semnătura noastră arată astfel:
constructor :: (Callback -> void) -> Future (Either err a)
Așadar, trebuie să îi furnizăm o funcție care nu returnează nimic și declanșează un apel invers de îndată ce calculul asincron este rezolvat fie la o eroare, fie la o anumită valoare. Pare destul de ușor pentru a face o punte pentru orice limbă.
În ceea ce privește designul Monadei Viitorului în sine, să ne uităm la structura sa internă. Ideea cheie este de a avea o variabilă de cache care să dețină o valoare dacă monada Viitorului este rezolvată sau nu conține nimic altfel. Vă puteți abona la Viitor cu un apel invers care va fi declanșat imediat dacă valoarea este rezolvată sau, dacă nu, va trece apel invers în lista de abonați.
Odată ce viitorul este rezolvat, fiecare apel invers din această listă va fi declanșat exact o dată cu valoarea rezolvată într-un fir separat (sau ca următoarea funcție care urmează să fie executată în bucla de evenimente, în cazul JS.) Rețineți că este esențial să utilizați cu atenție primitivele de sincronizare, altfel condițiile de cursă sunt posibile.
Fluxul de bază este: începeți calculul asincron furnizat ca argument constructor și îndreptați apelul înapoi către metoda noastră internă de apel invers. Între timp, vă puteți abona la Monada Viitorului și vă puteți pune apelurile înapoi în coadă. Odată ce calculul este finalizat, metoda internă de apel invers apelează toate apelurile din coadă. Dacă sunteți familiarizat cu extensiile reactive (RxJS, RxSwift etc.), acestea folosesc o abordare foarte similară a gestionării asincrone.
API-ul public al Monadei Viitorului este format din pure
, map
și flatMap
, la fel ca în monadele anterioare. De asemenea, vom avea nevoie de câteva metode utile:
-
async
, care preia o funcție de blocare sincronă și o execută pe un fir separat și -
traverse
, care preia o matrice de valori și o funcție care mapează o valoare la unFuture
și returnează unFuture
al unei matrice de valori rezolvate
Să vedem cum se desfășoară:
JavaScript — Monada viitoare
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 — Monada viitoare
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()
Ruby — Monada viitoare
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 — Monada Viitoare
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—Monada viitoare
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) }) }) }
Acum, observați cum API-ul public al Future
nu conține detalii de nivel scăzut, cum ar fi fire de execuție, semafore sau oricare dintre acele lucruri. Tot ce ai nevoie este, practic, să furnizezi ceva cu un apel invers, și asta este tot!
Alcătuirea unui program din Monads
Bine, deci să încercăm să folosim monadele noastre pentru a face un program real. Să presupunem că avem un fișier cu o listă de adrese URL și dorim să preluăm fiecare dintre aceste adrese URL în paralel. Apoi, dorim să tăiem răspunsurile la 200 de octeți fiecare pentru concizie și să tipărim rezultatul.
Începem prin a converti API-urile de limbă existente în interfețe monadice (vezi funcțiile readFile
și fetch
). Acum că avem asta, putem doar să le compunem pentru a obține rezultatul final ca un singur lanț. Rețineți că lanțul în sine este foarte sigur, deoarece toate detaliile sângeroase sunt conținute în monade.
JavaScript — Exemplu de program 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! Awesome!