Monady Option/Może, Either i Future w JavaScript, Python, Ruby, Swift i Scala
Opublikowany: 2022-03-11Ten samouczek dotyczący monad zawiera krótkie wyjaśnienie monad i pokazuje, jak zaimplementować najbardziej przydatne z nich w pięciu różnych językach programowania — jeśli szukasz monad w JavaScript, monad w Pythonie, monad w Ruby, monad w Swift i/lub monad w Scali, czy porównując dowolne implementacje, czytasz odpowiedni artykuł!
Korzystając z tych monad, pozbędziesz się szeregu błędów, takich jak wyjątki zerowego wskaźnika, nieobsłużone wyjątki i warunki wyścigu.
Oto, co omówię poniżej:
- Wprowadzenie do teorii kategorii
- Definicja monady
- Implementacje Opcji („Być może”) monady, Either monad i Future monad oraz przykładowy program je wykorzystujący w JavaScript, Python, Ruby, Swift i Scala
Zacznijmy! Naszym pierwszym przystankiem jest teoria kategorii, która jest podstawą monad.
Wprowadzenie do teorii kategorii
Teoria kategorii to dziedzina matematyczna, która była aktywnie rozwijana w połowie XX wieku. Obecnie jest podstawą wielu koncepcji programowania funkcjonalnego, w tym monady. Rzućmy okiem na niektóre koncepcje teorii kategorii, dostosowane do terminologii tworzenia oprogramowania.
Tak więc istnieją trzy podstawowe pojęcia, które definiują kategorię:
- Typ jest taki, jaki widzimy w językach statycznie pisanych. Przykłady:
Int
,String
,Dog
,Cat
, itp. - Funkcje łączą dwa typy. Dlatego mogą być reprezentowane jako strzałka z jednego typu do drugiego typu lub do siebie. Funkcję $f$ od typu $T$ do typu $U$ można zapisać jako $f: T \to U$. Można o tym myśleć jako o funkcji języka programowania, która pobiera argument typu $T$ i zwraca wartość typu $U$.
- Kompozycja to operacja oznaczana operatorem $\cdot$, która buduje nowe funkcje z już istniejących. W kategorii jest to zawsze gwarantowane dla dowolnych funkcji $f: T \to U$ i $g: U \to V$ istnieje unikalna funkcja $h: T \to V$. Ta funkcja jest oznaczona jako $f \cdot g$. Operacja skutecznie mapuje parę funkcji do innej funkcji. W językach programowania ta operacja jest oczywiście zawsze możliwa. Na przykład, jeśli masz funkcję, która zwraca długość łańcucha —$strlen: String \to Int$ — oraz funkcję, która mówi, czy liczba jest parzysta —$even: Int \to Boolean$ — wtedy możesz utworzyć function $even{\_}strlen:
String
\to Boolean$, który mówi, czy długość ciągu jest parzysta. W tym przypadku $even{\_}strlen = parzysty \cdot strlen$. Skład implikuje dwie cechy:- Asocjatywność: $f \cdot g \cdot h = (f \cdot g) \cdot h = f \cdot (g \cdot h)$
- Istnienie funkcji tożsamościowej: $\forall T: \exists f: T \to T$ lub w prostym języku angielskim, dla każdego typu $T$ istnieje funkcja, która odwzorowuje $T$ na siebie.
Przyjrzyjmy się więc prostej kategorii.
Uwaga dodatkowa: zakładamy, że Int
, String
i wszystkie inne typy tutaj mają gwarantowaną wartość inną niż null, tj. wartość null nie istnieje.
Uwaga dodatkowa 2: To właściwie tylko część kategorii, ale to wszystko, czego chcemy w naszej dyskusji, ponieważ zawiera wszystkie niezbędne części, których potrzebujemy, a diagram jest w ten sposób mniej zaśmiecony. Rzeczywista kategoria miałaby również wszystkie złożone funkcje, takie jak $roundToString: Double \to String = intToString \cdot round$, aby spełnić klauzulę składu kategorii.
Możesz zauważyć, że funkcje w tej kategorii są bardzo proste. W rzeczywistości jest prawie niemożliwe, aby mieć błąd w tych funkcjach. Nie ma wartości null, żadnych wyjątków, tylko arytmetyka i praca z pamięcią. Tak więc jedyną złą rzeczą, która może się zdarzyć, jest awaria procesora lub pamięci — w takim przypadku i tak trzeba zawiesić program — ale zdarza się to bardzo rzadko.
Czy nie byłoby miło, gdyby cały nasz kod działał na tym poziomie stabilności? Absolutnie! Ale co na przykład z I/O? Zdecydowanie nie możemy bez tego żyć. Oto, gdzie na ratunek przychodzą rozwiązania monad: izolują wszystkie niestabilne operacje na bardzo małe i bardzo dobrze sprawdzone fragmenty kodu — wtedy możesz używać stabilnych obliczeń w całej aplikacji!
Wejdź do monad
Nazwijmy niestabilne zachowanie, takie jak I/O, efektem ubocznym . Teraz chcemy móc pracować ze wszystkimi wcześniej zdefiniowanymi funkcjami, takimi jak length
i typy, takie jak String
, w stabilny sposób w obecności tego efektu ubocznego .
Zacznijmy więc od pustej kategorii $M[A]$ i przekształć ją w kategorię, która będzie miała wartości z jednym konkretnym rodzajem skutków ubocznych, a także wartości bez skutków ubocznych. Załóżmy, że zdefiniowaliśmy tę kategorię i jest ona pusta. W tej chwili nie możemy z nią nic pożytecznego zrobić, więc aby była użyteczna, wykonajmy następujące trzy kroki:
- Wypełnij go wartościami typów z kategorii $A$, takimi jak
String
,Int
,Double
, itp. (zielone pola na poniższym diagramie) - Gdy już mamy te wartości, nadal nie możemy z nimi zrobić nic sensownego, więc potrzebujemy sposobu, aby wziąć każdą funkcję $f: T \to U$ z $A$ i utworzyć funkcję $g: M[T] \to M [U]$ (niebieskie strzałki na poniższym schemacie). Kiedy już mamy te funkcje, możemy zrobić wszystko z wartościami w kategorii $M[A]$, które byliśmy w stanie zrobić w kategorii $A$.
- Teraz, gdy mamy zupełnie nową kategorię $M[A]$, pojawia się nowa klasa funkcji z podpisem $h: T \to M[U]$ (czerwone strzałki na poniższym diagramie). Pojawiają się one w wyniku promowania wartości w kroku pierwszym jako część naszego kodu, tj. piszemy je w razie potrzeby; są to główne rzeczy, które odróżniają pracę z $M[A]$ od pracy z $A$. Ostatnim krokiem będzie sprawienie, aby te funkcje działały dobrze również na typach w $M[A]$, tj. aby móc wyprowadzić funkcję $m: M[T] \to M[U]$ z $h: T \ do M[U]$
Zacznijmy więc od zdefiniowania dwóch sposobów promowania wartości typów $A$ do wartości typów $M[A]$: jednej funkcji bez skutków ubocznych i drugiej z efektami ubocznymi.
- Pierwsza nazywa się $pure$ i jest zdefiniowana dla każdej wartości stabilnej kategorii: $pure: T \to M[T]$. Otrzymane wartości $M[T]$ nie będą miały żadnych skutków ubocznych, stąd ta funkcja nazywa się $czysty$. Np. dla monady I/O $pure$ zwróci pewną wartość natychmiast bez możliwości niepowodzenia.
- Drugi nazywa się $constructor$ i, w przeciwieństwie do $pure$, zwraca $M[T]$ z pewnymi efektami ubocznymi. Przykładem takiego $constructor$ dla monady async I/O może być funkcja, która pobiera pewne dane z sieci i zwraca je jako
String
. Wartość zwrócona przez $constructor$ będzie miała w tym przypadku typ $M[String]$.
Teraz, gdy mamy dwa sposoby promowania wartości w $M[A]$, jako programista możesz wybrać, której funkcji użyć, w zależności od celów programu. Rozważmy przykład tutaj: chcesz pobrać stronę HTML, taką jak https://www.toptal.com/javascript/option-maybe-either-future-monads-js i w tym celu tworzysz funkcję $fetch$. Ponieważ podczas pobierania wszystko może pójść nie tak — pomyśl o awariach sieci itp. — użyjesz $M[String]$ jako zwracanego typu tej funkcji. Będzie więc wyglądać mniej więcej tak: $fetch: String \to M[String]$ i gdzieś w ciele funkcji użyjemy $constructor$ dla $M$.
Załóżmy teraz, że tworzymy funkcję atrapy do testowania: $fetchMock: String \to M[String]$. Nadal ma ten sam podpis, ale tym razem po prostu wstawiamy wynikową stronę HTML do ciała $fetchMock$ bez wykonywania żadnych niestabilnych operacji sieciowych. W tym przypadku po prostu używamy $pure$ w implementacji $fetchMock$.
W następnym kroku potrzebujemy funkcji, która bezpiecznie promuje dowolną dowolną funkcję $f$ z kategorii $A$ do $M[A]$ (niebieskie strzałki na diagramie). Ta funkcja nazywa się $map: (T \to U) \to (M[T] \to M[U])$.
Teraz mamy kategorię (która może mieć skutki uboczne, jeśli użyjemy $constructor$), która również zawiera wszystkie funkcje z kategorii stabilnej, co oznacza, że są one stabilne również w $M[A]$. Możesz zauważyć, że jawnie wprowadziliśmy inną klasę funkcji, taką jak $f: T \to M[U]$. Np. $pure$ i $constructor$ są przykładami takich funkcji dla $U = T$, ale oczywiście mogłoby być ich więcej, na przykład gdybyśmy mieli użyć $pure$, a potem $map$. Tak więc, ogólnie rzecz biorąc, potrzebujemy sposobu radzenia sobie z dowolnymi funkcjami w postaci $f: T \to M[U]$.
Jeśli chcemy stworzyć nową funkcję opartą na $f$, którą można zastosować do $M[T]$, możemy spróbować użyć $map$. Ale to doprowadzi nas do funkcji $g: M[T] \to M[M[U]]$, co nie jest dobre, ponieważ nie chcemy mieć jeszcze jednej kategorii $M[M[A]]$. Aby poradzić sobie z tym problemem, wprowadzamy ostatnią funkcję: $flatMap: (T \to M[U]) \to (M[T] \to M[U])$.
Ale dlaczego mielibyśmy to robić? Załóżmy, że jesteśmy po kroku 2, czyli mamy $pure$, $constructor$ i $map$. Załóżmy, że chcemy pobrać stronę HTML z toptal.com, a następnie przeskanować wszystkie znajdujące się tam adresy URL, a także je pobrać. Utworzyłbym funkcję $fetch: String \to M[String]$, która pobiera tylko jeden adres URL i zwraca stronę HTML.
Następnie zastosuję tę funkcję do adresu URL i otrzymam stronę z toptal.com, czyli $x: M[String]$. Teraz dokonuję pewnej transformacji na $x$ iw końcu docieram do adresu URL $u: M[Ciąg]$. Chcę zastosować do niego funkcję $fetch$, ale nie mogę, ponieważ przyjmuje typ $String$, a nie $M[String]$. Dlatego potrzebujemy $flatMap$, aby przekonwertować $fetch: String \na M[String]$ na $m_fetch: M[String] \to M[String]$.
Teraz, gdy zakończyliśmy wszystkie trzy kroki, możemy właściwie skomponować dowolne transformacje wartości, których potrzebujemy. Na przykład, jeśli masz wartość $x$ typu $M[T]$ i $f:T \to U$, możesz użyć $map$, aby zastosować $f$ do wartości $x$ i uzyskać wartość $y$ typu $M[U]$. W ten sposób każda transformacja wartości może być wykonana w 100% bez błędów, o ile implementacje $pure$, $constructor$, $map$ i $flatMap$ są wolne od błędów.
Więc zamiast zajmować się niektórymi nieprzyjemnymi efektami za każdym razem, gdy napotkasz je w swoim kodzie, musisz tylko upewnić się, że tylko te cztery funkcje są poprawnie zaimplementowane. Na końcu programu otrzymasz tylko jeden $M[X]$, w którym możesz bezpiecznie rozpakować wartość $X$ i obsłużyć wszystkie przypadki błędów.
Oto czym jest monada: czymś, co implementuje $pure$, $map$ i $flatMap$. (Właściwie $map$ można wyprowadzić z $pure$ i $flatMap$, ale jest to bardzo użyteczna i rozpowszechniona funkcja, więc nie pominąłem jej w definicji.)
Monada Opcji, czyli monada Maybe
OK, zagłębmy się w praktyczną implementację i użycie monad. Pierwszą naprawdę pomocną monadą jest monada opcji. Jeśli pochodzisz z klasycznych języków programowania, prawdopodobnie napotkałeś wiele awarii z powodu niesławnego błędu wskaźnika zerowego. Tony Hoare, wynalazca null, nazywa ten wynalazek „Błądem miliarda dolarów”:
Doprowadziło to do niezliczonych błędów, luk w zabezpieczeniach i awarii systemu, które prawdopodobnie spowodowały miliard dolarów bólu i szkód w ciągu ostatnich czterdziestu lat.
Spróbujmy więc to ulepszyć. Monada Option zawiera pewną wartość inną niż null lub nie zawiera żadnej wartości. Całkiem podobny do wartości null, ale mając tę monadę, możemy bezpiecznie używać naszych dobrze zdefiniowanych funkcji bez obawy o wyjątek wskaźnika null. Rzućmy okiem na implementacje w różnych językach:
JavaScript — opcja monada/może monada
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 — opcja monada/może monada
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()
Rubin — opcja monada/może monada
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 — opcja monada/może monada
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 — opcja monada/może monada
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]
Zaczynamy od implementacji klasy Monad
, która będzie podstawą dla wszystkich naszych implementacji monad. Posiadanie tej klasy jest bardzo przydatne, ponieważ implementując tylko dwie z jej metod — pure
i flatMap
— dla konkretnej monady, otrzymasz wiele metod za darmo (w naszych przykładach ograniczamy je do metody map
, ale generalnie jest ich wiele inne przydatne metody, takie jak sequence
i traverse
do pracy z tablicami Monad
).
map
możemy wyrazić jako kompozycję pure
i flatMap
. Widać z podpisu flatMap
$flatMap: (T \to M[U]) \to (M[T] \to M[U])$, że jest naprawdę blisko $map: (T \to U) \ do (M[T] \do M[U])$. Różnica polega na dodatkowym $M$ w środku, ale możemy użyć pure
funkcji, aby przekonwertować $U$ na $M[U]$. W ten sposób wyrażamy map
w terminach flatMap
i pure
.
Działa to dobrze dla Scali, ponieważ ma zaawansowany system typów. Działa również dobrze w przypadku JS, Pythona i Rubiego, ponieważ są one wpisywane dynamicznie. Niestety, nie działa dla Swift, ponieważ jest napisany statycznie i nie ma zaawansowanych funkcji typu, takich jak typy wyższego rodzaju, więc dla Swift będziemy musieli zaimplementować map
dla każdej monady.
Zauważ też, że monada Option jest już de facto standardem dla języków takich jak Swift i Scala, więc używamy nieco innych nazw dla naszych implementacji monad.
Teraz, gdy mamy już podstawową klasę Monad
, przejdźmy do naszych implementacji Option monad. Jak wspomniano wcześniej, podstawową ideą jest to, że Option albo przechowuje pewną wartość (zwaną Some
) albo w ogóle nie przechowuje żadnej wartości ( None
).
Metoda pure
po prostu promuje wartość do Some
, podczas gdy metoda flatMap
sprawdza bieżącą wartość Option
— jeśli jest to None
, zwraca None
, a jeśli jest to Some
z podstawową wartością , wyodrębnia podstawową wartość, stosuje f()
do i zwraca wynik.
Zauważ, że używając tylko tych dwóch funkcji i map
, nie można dostać się do wyjątku zerowego wskaźnika — nigdy. (Problem może potencjalnie pojawić się w naszej implementacji metody flatMap
, ale to tylko kilka linijek w naszym kodzie, które sprawdzamy raz. Potem po prostu używamy naszej implementacji Option monad w całym kodzie w tysiącach miejsc i nie w ogóle obawiać się wyjątku zerowego wskaźnika.)
Albo monada
Zanurzmy się w drugą monadę: Albo. Jest to w zasadzie to samo, co monada Option, ale z Some
nazwanymi Right
i None
zwanymi Left
. Ale tym razem Left
może mieć również podstawową wartość.
Potrzebujemy tego, ponieważ wyrażenie wyjątku jest bardzo wygodne. Jeśli wystąpił wyjątek, wartością Either
będzie Left(Exception)
. Funkcja flatMap
nie postępuje, jeśli wartość to Left
, która powtarza semantykę zgłaszania wyjątków: Jeśli wystąpił wyjątek, zatrzymujemy dalsze wykonywanie.
JavaScript — albo 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 — albo 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
Rubin — albo monada
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
Szybki — albo monada
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 — albo monada
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) } }
Pamiętaj też, że łatwo jest złapać wyjątki: wszystko, co musisz zrobić, to zmapować od Left
do Right
. (Chociaż nie robimy tego w naszych przykładach, dla zwięzłości).

Przyszła monada
Przyjrzyjmy się ostatniej potrzebnej nam monadzie: Monadzie Przyszłości. Monada Przyszłości jest w zasadzie pojemnikiem na wartość, która jest dostępna teraz lub będzie dostępna w najbliższej przyszłości. Możesz tworzyć łańcuchy Futures za pomocą map
i flatMap
, które będą czekać na rozwiązanie wartości Future przed wykonaniem następnego fragmentu kodu, który zależy od wartości, która zostanie rozwiązana jako pierwsza. Jest to bardzo podobne do koncepcji Promises w JS.
Naszym celem projektowym jest teraz połączenie istniejących asynchronicznych interfejsów API w różnych językach w jedną spójną bazę. Okazuje się, że najłatwiejszym podejściem do projektowania jest użycie wywołań zwrotnych w $constructor$.
Chociaż projekt wywołania zwrotnego wprowadził problem piekła wywołań zwrotnych w JavaScript i innych językach, nie będzie to dla nas problemem, ponieważ używamy monad. W rzeczywistości obiekt Promise
— podstawa rozwiązania JavaScript do piekła wywołań zwrotnych — jest samą monadą!
A co z konstruktorem monady Future? Czy ma ten podpis:
constructor :: ((Either err a -> void) -> void) -> Future (Either err a)
Podzielmy to na kawałki. Najpierw zdefiniujmy:
type Callback = Either err a -> void
Callback
jest więc funkcją, która jako argument przyjmuje błąd lub rozwiązaną wartość i nic nie zwraca. Teraz nasz podpis wygląda tak:
constructor :: (Callback -> void) -> Future (Either err a)
Musimy więc dostarczyć mu funkcję, która nic nie zwraca i wyzwala wywołanie zwrotne, gdy tylko obliczenie asynchroniczne zostanie rozwiązane na błąd lub jakąś wartość. Wygląda dość łatwo, by stworzyć pomost dla dowolnego języka.
Jeśli chodzi o sam projekt monady Future, przyjrzyjmy się jej wewnętrznej strukturze. Kluczowym pomysłem jest posiadanie zmiennej pamięci podręcznej, która przechowuje wartość, jeśli monada przyszłości zostanie rozwiązana, lub nie przechowuje niczego w innym przypadku. Możesz zapisać się do Future z callbackiem, który zostanie uruchomiony natychmiast, jeśli wartość zostanie rozwiązana, lub jeśli nie, umieści callback na liście subskrybentów.
Po rozwiązaniu przyszłości każde wywołanie zwrotne z tej listy zostanie wyzwolone dokładnie raz z ustaloną wartością w osobnym wątku (lub jako następna funkcja do wykonania w pętli zdarzeń w przypadku JS). ostrożnie używaj prymitywów synchronizacji, w przeciwnym razie możliwe są warunki wyścigu.
Podstawowy przepływ to: Rozpoczynasz obliczenia asynchroniczne dostarczone jako argument konstruktora i kierujesz jego wywołanie zwrotne do naszej wewnętrznej metody wywołania zwrotnego. W międzyczasie możesz zasubskrybować monadę Future i umieścić swoje callbacki w kolejce. Po zakończeniu obliczeń wewnętrzna metoda wywołania zwrotnego wywołuje wszystkie wywołania zwrotne w kolejce. Jeśli znasz rozszerzenia reaktywne (RxJS, RxSwift itp.), używają one bardzo podobnego podejścia do obsługi asynchronicznej.
Publiczne API monady Future składa się z pure
, map
i flatMap
, tak jak w poprzednich monadach. Potrzebujemy również kilku przydatnych metod:
-
async
, który pobiera synchroniczną funkcję blokowania i wykonuje ją w osobnym wątku oraz -
traverse
, która pobiera tablicę wartości i funkcję odwzorowującą wartość naFuture
i zwracaFuture
z tablicy rozwiązanych wartości
Zobaczmy, jak to się rozegra:
JavaScript — Przyszła Monada
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 przyszłości
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()
Rubin — przyszła monada
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
Szybki — przyszła monada
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 — przyszła monada
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) }) }) }
Teraz zwróć uwagę, że publiczne API Future
nie zawiera żadnych szczegółów niskiego poziomu, takich jak wątki, semafory lub inne rzeczy. Wszystko, czego potrzebujesz, to w zasadzie dostarczyć coś z wywołaniem zwrotnym i to wszystko!
Komponowanie programu z monad
Dobra, spróbujmy więc użyć naszych monad do stworzenia rzeczywistego programu. Załóżmy, że mamy plik z listą adresów URL i chcemy równolegle pobierać każdy z tych adresów URL. Następnie chcemy przyciąć odpowiedzi do 200 bajtów każda dla zwięzłości i wydrukować wynik.
Zaczynamy od przekonwertowania istniejących API języka na interfejsy monadyczne (zobacz funkcje readFile
i fetch
). Teraz, gdy już to mamy, możemy po prostu skomponować je, aby uzyskać końcowy wynik jako jeden łańcuch. Zwróć uwagę, że sam łańcuch jest super bezpieczny, ponieważ wszystkie krwawe szczegóły zawarte są w monadach.
JavaScript — przykładowy 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! Świetny!