Option/Vielleicht, Entweder und Future Monads in JavaScript, Python, Ruby, Swift und Scala
Veröffentlicht: 2022-03-11Dieses Monaden-Tutorial gibt eine kurze Erklärung der Monaden und zeigt, wie man die nützlichsten in fünf verschiedenen Programmiersprachen implementiert – wenn Sie nach Monaden in JavaScript, Monaden in Python, Monaden in Ruby, Monaden in Swift und/oder Monaden suchen in Scala oder um Implementierungen zu vergleichen, lesen Sie den richtigen Artikel!
Mit diesen Monaden werden Sie eine Reihe von Fehlern wie Nullzeiger-Ausnahmen, unbehandelte Ausnahmen und Race-Conditions beseitigen.
Dies ist, was ich unten behandle:
- Eine Einführung in die Kategorientheorie
- Die Definition einer Monade
- Implementierungen der Monade „Option“ („Vielleicht“), der „Euch“-Monade und der „Future“-Monade sowie ein Beispielprogramm, das sie nutzt, in JavaScript, Python, Ruby, Swift und Scala
Lass uns anfangen! Unsere erste Station ist die Kategorientheorie, die die Grundlage für Monaden bildet.
Einführung in die Kategorientheorie
Die Kategorientheorie ist ein mathematisches Gebiet, das Mitte des 20. Jahrhunderts aktiv entwickelt wurde. Jetzt ist es die Grundlage vieler funktionaler Programmierkonzepte, einschließlich der Monade. Lassen Sie uns einen kurzen Blick auf einige Konzepte der Kategorietheorie werfen, die auf die Terminologie der Softwareentwicklung abgestimmt sind.
Es gibt also drei Kernkonzepte, die eine Kategorie definieren:
- Typ ist genauso, wie wir ihn in statisch typisierten Sprachen sehen. Beispiele:
Int
,String
,Dog
,Cat
usw. - Funktionen verbinden zwei Typen. Daher können sie als Pfeil von einem Typ zu einem anderen Typ oder zu sich selbst dargestellt werden. Die Funktion $f$ vom Typ $T$ zum Typ $U$ kann als $f bezeichnet werden: T \to U$. Sie können es sich als eine Programmiersprachenfunktion vorstellen, die ein Argument vom Typ $T$ akzeptiert und einen Wert vom Typ $U$ zurückgibt.
- Komposition ist eine Operation, gekennzeichnet durch den $\cdot$-Operator, der neue Funktionen aus bestehenden erstellt. In einer Kategorie ist immer garantiert, dass für beliebige Funktionen $f: T \to U$ und $g: U \to V$ eine eindeutige Funktion $h: T \to V$ existiert. Diese Funktion wird als $f \cdot g$ bezeichnet. Die Operation ordnet effektiv ein Funktionspaar einer anderen Funktion zu. In Programmiersprachen ist diese Operation natürlich immer möglich. Wenn Sie beispielsweise eine Funktion haben, die die Länge eines Strings —$strlen: String \to Int$ — zurückgibt, und eine Funktion, die angibt, ob die Zahl gerade ist —$even: Int \to Boolean$ —, dann können Sie a Funktion $even{\_}strlen:
String
\to Boolean$, der angibt, ob die Länge des Strings gerade ist. In diesem Fall $even{\_}strlen = even \cdot strlen$. Zusammensetzung impliziert zwei Merkmale:- Assoziativität: $f \cdot g \cdot h = (f \cdot g) \cdot h = f \cdot (g \cdot h)$
- Die Existenz einer Identitätsfunktion: $\forall T: \exists f: T \to T$, oder im Klartext, für jeden Typ $T$ gibt es eine Funktion, die $T$ auf sich selbst abbildet.
Schauen wir uns also eine einfache Kategorie an.
Randnotiz: Wir gehen davon aus, dass Int
, String
und alle anderen Typen hier garantiert nicht null sind, dh der Nullwert existiert nicht.
Nebenbemerkung 2: Dies ist eigentlich nur ein Teil einer Kategorie, aber das ist alles, was wir für unsere Diskussion wollen, weil es alle wesentlichen Teile enthält, die wir brauchen, und das Diagramm auf diese Weise weniger überladen ist. Die echte Kategorie hätte auch alle zusammengesetzten Funktionen wie $roundToString: Double \to String = intToString \cdot round$, um die Kompositionsklausel von Kategorien zu erfüllen.
Sie werden vielleicht bemerken, dass die Funktionen in dieser Kategorie super einfach sind. Tatsächlich ist es fast unmöglich, einen Fehler in diesen Funktionen zu haben. Es gibt keine Nullen, keine Ausnahmen, nur die Arithmetik und das Arbeiten mit dem Gedächtnis. Das einzig Schlimme, was passieren kann, ist ein Prozessor- oder Speicherausfall – in diesem Fall müssen Sie das Programm sowieso zum Absturz bringen – aber das passiert sehr selten.
Wäre es nicht schön, wenn unser gesamter Code auf diesem Stabilitätsniveau funktionieren würde? Absolut! Aber was ist zum Beispiel mit I/O? Wir können definitiv nicht ohne sie leben. Hier kommen monadische Lösungen zur Rettung: Sie isolieren alle instabilen Operationen in superkleine und sehr gut geprüfte Codeteile – dann können Sie stabile Berechnungen in Ihrer gesamten App verwenden!
Geben Sie Monaden ein
Nennen wir instabiles Verhalten wie I/O einen Nebeneffekt . Jetzt wollen wir in der Lage sein, mit all unseren zuvor definierten Funktionen wie length
und Typen wie String
trotz dieses Nebeneffekts stabil zu arbeiten.
Beginnen wir also mit einer leeren Kategorie $M[A]$ und machen daraus eine Kategorie, die Werte mit einer bestimmten Art von Nebenwirkungen und auch Werte ohne Nebenwirkungen enthält. Nehmen wir an, wir haben diese Kategorie definiert und sie ist leer. Im Moment können wir nichts Nützliches damit anfangen, also folgen wir diesen drei Schritten, um es nützlich zu machen:
- Füllen Sie es mit Werten der Typen aus der Kategorie $A$, wie
String
,Int
,Double
usw. (grüne Kästchen im Diagramm unten) - Sobald wir diese Werte haben, können wir immer noch nichts Sinnvolles damit anfangen, also brauchen wir eine Möglichkeit, jede Funktion $f: T \to U$ von $A$ zu nehmen und eine Funktion $g: M[T] \to M zu erstellen [U]$ (blaue Pfeile im Diagramm unten). Sobald wir diese Funktionen haben, können wir mit den Werten in der Kategorie $M[A]$ alles tun, was wir in der Kategorie $A$ tun konnten.
- Jetzt, da wir eine brandneue $M[A]$-Kategorie haben, entsteht eine neue Klasse von Funktionen mit der Signatur $h: T \to M[U]$ (rote Pfeile im Diagramm unten). Sie entstehen als Ergebnis der Förderung von Werten in Schritt eins als Teil unserer Codebasis, dh wir schreiben sie nach Bedarf; Dies sind die wichtigsten Dinge, die die Arbeit mit $M[A]$ von der Arbeit mit $A$ unterscheiden. Der letzte Schritt besteht darin, diese Funktionen auch für Typen in $M[A]$ gut funktionieren zu lassen, dh in der Lage zu sein, die Funktion $m: M[T] \to M[U]$ von $h: T \ abzuleiten. zu M[U]$
Beginnen wir also mit der Definition von zwei Möglichkeiten, Werte von $A$-Typen zu Werten von $M[A]$-Typen hochzustufen: eine Funktion ohne Seiteneffekte und eine mit Seiteneffekten.
- Die erste heißt $pure$ und ist für jeden Wert einer stabilen Kategorie definiert: $pure: T \to M[T]$. Die resultierenden $M[T]$-Werte haben keine Seiteneffekte, daher heißt diese Funktion $pure$. Beispielsweise wird $pure$ für eine I/O-Monade sofort einen Wert zurückgeben, ohne die Möglichkeit eines Fehlers.
- Der zweite heißt $constructor$ und gibt im Gegensatz zu $pure$ $M[T]$ mit einigen Seiteneffekten zurück. Ein Beispiel für einen solchen $Konstruktor$ für eine asynchrone E/A-Monade könnte eine Funktion sein, die einige Daten aus dem Web abruft und sie als
String
zurückgibt. Der von $constructor$ zurückgegebene Wert hat in diesem Fall den Typ $M[String]$.
Da wir nun zwei Möglichkeiten haben, Werte in $M[A]$ zu übertragen, liegt es an Ihnen als Programmierer, je nach Ihren Programmzielen zu wählen, welche Funktion Sie verwenden möchten. Betrachten wir hier ein Beispiel: Sie möchten eine HTML-Seite wie https://www.toptal.com/javascript/option-maybe-either-future-monads-js abrufen und erstellen dafür eine Funktion $fetch$. Da beim Abrufen alles schief gehen kann – denken Sie an Netzwerkausfälle usw. – verwenden Sie $M[String]$ als Rückgabetyp dieser Funktion. Es sieht also in etwa so aus: $fetch: String \to M[String]$ und irgendwo im Funktionsrumpf verwenden wir $constructor$ für $M$.
Nehmen wir nun an, wir erstellen eine Scheinfunktion zum Testen: $fetchMock: String \to M[String]$. Es hat immer noch die gleiche Signatur, aber dieses Mal fügen wir einfach die resultierende HTML-Seite in den Körper von $fetchMock$ ein, ohne instabile Netzwerkoperationen durchzuführen. In diesem Fall verwenden wir also einfach $pure$ in der Implementierung von $fetchMock$.
Als nächsten Schritt brauchen wir eine Funktion, die jede beliebige Funktion $f$ aus der Kategorie $A$ sicher nach $M[A]$ befördert (blaue Pfeile in einem Diagramm). Diese Funktion heißt $map: (T \to U) \to (M[T] \to M[U])$.
Jetzt haben wir eine Kategorie (die Seiteneffekte haben kann, wenn wir $constructor$ verwenden), die auch alle Funktionen aus der Stable-Kategorie hat, was bedeutet, dass sie auch in $M[A]$ stabil sind. Sie werden vielleicht bemerken, dass wir explizit eine andere Klasse von Funktionen wie $f: T \to M[U]$ eingeführt haben. ZB $pure$ und $constructor$ sind Beispiele für solche Funktionen für $U = T$, aber es könnte natürlich noch mehr geben, wie wenn wir $pure$ und dann $map$ verwenden würden. Also brauchen wir im Allgemeinen eine Möglichkeit, mit beliebigen Funktionen in der Form $f:T \to M[U]$ umzugehen.
Wenn wir eine neue Funktion basierend auf $f$ erstellen wollen, die auf $M[T]$ angewendet werden könnte, könnten wir versuchen, $map$ zu verwenden. Aber das bringt uns zur Funktion $g: M[T] \to M[M[U]]$, was nicht gut ist, da wir keine weitere Kategorie $M[M[A]]$ haben wollen. Um dieses Problem zu lösen, führen wir eine letzte Funktion ein: $flatMap: (T \to M[U]) \to (M[T] \to M[U])$.
Aber warum sollten wir das tun wollen? Nehmen wir an, wir sind nach Schritt 2, dh wir haben $pure$, $constructor$ und $map$. Angenommen, wir möchten eine HTML-Seite von toptal.com abrufen, dann alle URLs dort scannen und sie abrufen. Ich würde eine Funktion $fetch: String \to M[String]$ erstellen, die nur eine URL abruft und eine HTML-Seite zurückgibt.
Dann würde ich diese Funktion auf eine URL anwenden und eine Seite von toptal.com erhalten, die $x:M[String]$ ist. Jetzt führe ich einige Transformationen an $x$ durch und erreiche schließlich eine URL $u: M[String]$. Ich möchte die Funktion $fetch$ darauf anwenden, aber ich kann nicht, weil es den Typ $String$ braucht, nicht $M[String]$. Deshalb brauchen wir $flatMap$, um $fetch:String \to M[String]$ in $m_fetch:M[String] \to M[String]$ umzuwandeln.
Nachdem wir alle drei Schritte abgeschlossen haben, können wir tatsächlich alle benötigten Werttransformationen zusammenstellen. Wenn Sie beispielsweise den Wert $x$ vom Typ $M[T]$ und $f:T \to U$ haben, können Sie $map$ verwenden, um $f$ auf den Wert $x$ anzuwenden und den Wert $y$ zu erhalten vom Typ $M[U]$. Auf diese Weise kann jede Transformation von Werten zu 100 Prozent fehlerfrei durchgeführt werden, solange die Implementierungen von $pure$, $constructor$, $map$ und $flatMap$ fehlerfrei sind.
Anstatt also jedes Mal, wenn Sie in Ihrer Codebasis auf einige unangenehme Effekte stoßen, müssen Sie nur sicherstellen, dass nur diese vier Funktionen korrekt implementiert sind. Am Ende des Programms erhalten Sie nur ein $M[X]$, in dem Sie den Wert $X$ sicher auspacken und alle Fehlerfälle behandeln können.
Das ist, was eine Monade ist: ein Ding, das $pure$, $map$ und $flatMap$ implementiert. (Eigentlich kann $map$ von $pure$ und $flatMap$ abgeleitet werden, aber es ist eine sehr nützliche und weit verbreitete Funktion, daher habe ich sie nicht aus der Definition weggelassen.)
Die Option-Monade, auch bekannt als Vielleicht-Monade
Okay, lassen Sie uns in die praktische Implementierung und Verwendung von Monaden eintauchen. Die erste wirklich hilfreiche Monade ist die Option-Monade. Wenn Sie von klassischen Programmiersprachen kommen, sind Sie wahrscheinlich auf viele Abstürze aufgrund des berüchtigten Nullzeigerfehlers gestoßen. Tony Hoare, der Erfinder von Null, nennt diese Erfindung „The Billion Dollar Mistake“:
Dies hat zu unzähligen Fehlern, Schwachstellen und Systemabstürzen geführt, die in den letzten vierzig Jahren wahrscheinlich Schmerzen und Schäden in Höhe von einer Milliarde Dollar verursacht haben.
Versuchen wir also, das zu verbessern. Die Option-Monade enthält entweder einen Nicht-Null-Wert oder keinen Wert. Ziemlich ähnlich einem Nullwert, aber mit dieser Monade können wir unsere wohldefinierten Funktionen sicher verwenden, ohne Angst vor der Nullzeigerausnahme zu haben. Werfen wir einen Blick auf Implementierungen in verschiedenen Sprachen:
JavaScript – Option Monad/Vielleicht 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 – Option Monade/Vielleicht 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()
Ruby – Option Monade/vielleicht Monade
class Monad # pure :: a -> M a def self.pure(x) raise StandardError("pure method needs to be implemented") end # pure :: a -> M a def pure(x) self.class.pure(x) end def flat_map(f) raise StandardError("flat_map method needs to be implemented") end # map :: # M a -> (a -> b) -> M b def map(f) flat_map(-> (x) { pure(f.call(x)) }) end end class Option < Monad attr_accessor :defined, :value # pure :: a -> Option a def self.pure(x) Some.new(x) end # pure :: a -> Option a def pure(x) Some.new(x) end # flat_map :: # Option a -> (a -> Option b) -> Option b def flat_map(f) if defined f.call(value) else $none end end end class Some < Option def initialize(value) @value = value @defined = true end end class None < Option def initialize() @defined = false end end $none = None.new()
Swift – Option Monade/vielleicht Monade
import Foundation enum Maybe<A> { case None case Some(A) static func pure<B>(_ value: B) -> Maybe<B> { return .Some(value) } func flatMap<B>(_ f: (A) -> Maybe<B>) -> Maybe<B> { switch self { case .None: return .None case .Some(let value): return f(value) } } func map<B>(f: (A) -> B) -> Maybe<B> { return self.flatMap { type(of: self).pure(f($0)) } } }
Scala – Option Monade/vielleicht 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]
Wir beginnen mit der Implementierung einer Monad
, die die Basis für alle unsere Monadenimplementierungen sein wird. Diese Klasse zu haben ist sehr praktisch, denn wenn Sie nur zwei ihrer Methoden – pure
und flatMap
– für eine bestimmte Monade implementieren, erhalten Sie viele Methoden kostenlos (wir beschränken sie in unseren Beispielen einfach auf die map
-Methode, aber im Allgemeinen gibt es viele andere nützliche Methoden wie sequence
und traverse
für die Arbeit mit Arrays von Monad
s).
Wir können map
als Zusammensetzung von pure
und flatMap
. Sie können an der Signatur von flatMap
$flatMap: (T \to M[U]) \to (M[T] \to M[U])$ sehen, dass es sehr nah an $map: (T \to U) \ liegt zu (M[T] \zu M[U])$. Der Unterschied ist das zusätzliche $M$ in der Mitte, aber wir können die pure
Funktion verwenden, um $U$ in $M[U]$ umzuwandeln. Auf diese Weise drücken wir map
in Bezug auf flatMap
und pure
aus.
Dies funktioniert gut für Scala, da es über ein fortschrittliches Typsystem verfügt. Es funktioniert auch gut für JS, Python und Ruby, da sie dynamisch typisiert sind. Leider funktioniert es für Swift nicht, weil es statisch typisiert ist und keine erweiterten Typfunktionen wie höherwertige Typen hat, also müssen wir für Swift map
für jede Monade implementieren.
Beachten Sie auch, dass die Option-Monade bereits ein De-facto -Standard für Sprachen wie Swift und Scala ist, daher verwenden wir leicht unterschiedliche Namen für unsere Monadenimplementierungen.
Nachdem wir nun eine Monaden- Monad
haben, kommen wir zu unseren Option-Monaden-Implementierungen. Wie bereits erwähnt, besteht die Grundidee darin, dass Option entweder einen bestimmten Wert (genannt Some
) oder gar keinen Wert enthält ( None
).
Die pure
-Methode stuft einfach einen Wert auf Some
hoch, während die flatMap
Methode den aktuellen Wert der Option
überprüft – wenn es None
ist, gibt sie None
zurück, und wenn es Some
mit einem zugrunde liegenden Wert ist, extrahiert sie den zugrunde liegenden Wert und wendet f()
auf an es und gibt ein Ergebnis zurück.
Beachten Sie, dass es unmöglich ist, in eine Nullzeiger-Ausnahme zu geraten, wenn Sie nur diese beiden Funktionen und map
verwenden – niemals. (Das Problem könnte möglicherweise in unserer Implementierung der flatMap
Methode auftreten, aber das sind nur ein paar Zeilen in unserem Code, die wir einmal überprüfen. Danach verwenden wir einfach unsere Option-Monadenimplementierung in unserem gesamten Code an Tausenden von Stellen und tun es nicht überhaupt die Nullzeigerausnahme befürchten müssen.)
Die Entweder-Monade
Lassen Sie uns in die zweite Monade eintauchen: Entweder. Dies ist im Grunde dasselbe wie die Option-Monade, aber mit Some
heißt Right
und None
heißt Left
. Aber dieses Mal darf Left
auch einen zugrunde liegenden Wert haben.
Wir brauchen das, weil es sehr praktisch ist, das Auslösen einer Ausnahme auszudrücken. Wenn eine Ausnahme aufgetreten ist, ist der Wert von Either
Left(Exception)
. Die flatMap
Funktion wird nicht fortgesetzt, wenn der Wert Left
ist, was die Semantik des Auslösens von Ausnahmen wiederholt: Wenn eine Ausnahme aufgetreten ist, stoppen wir die weitere Ausführung.
JavaScript – Entweder 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 – Entweder 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
Ruby – Entweder 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 – Entweder 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 – Entweder 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) } }
Beachten Sie auch, dass es einfach ist, Ausnahmen abzufangen: Sie müssen lediglich Left
to Right
. (Obwohl wir es der Kürze halber in unseren Beispielen nicht tun.)

Die zukünftige Monade
Lassen Sie uns die letzte Monade untersuchen, die wir brauchen: die Zukunftsmonade. Die Zukunftsmonade ist im Grunde ein Behälter für einen Wert, der entweder jetzt verfügbar ist oder in naher Zukunft verfügbar sein wird. Sie können Ketten von Futures mit map
und flatMap
, die darauf warten, dass der Future-Wert aufgelöst wird, bevor der nächste Codeabschnitt ausgeführt wird, der davon abhängt, dass der Wert zuerst aufgelöst wird. Dies ist dem Konzept der Promises in JS sehr ähnlich.
Unser Designziel ist es nun, die bestehenden asynchronen APIs in verschiedenen Sprachen zu einer konsistenten Basis zu verbinden. Es stellt sich heraus, dass der einfachste Designansatz darin besteht, Callbacks in $constructor$ zu verwenden.
Während das Callback-Design das Callback-Höllenproblem in JavaScript und anderen Sprachen eingeführt hat, wird es für uns kein Problem sein, da wir Monaden verwenden. Tatsächlich ist das Promise
-Objekt – die Grundlage für JavaScripts Lösung für die Callback-Hölle – selbst eine Monade!
Was ist mit dem Konstruktor der Future-Monade? Es hat diese Signatur:
constructor :: ((Either err a -> void) -> void) -> Future (Either err a)
Teilen wir es in Stücke. Zuerst definieren wir:
type Callback = Either err a -> void
Callback
ist also eine Funktion, die entweder einen Fehler oder einen aufgelösten Wert als Argument akzeptiert und nichts zurückgibt. Jetzt sieht unsere Signatur so aus:
constructor :: (Callback -> void) -> Future (Either err a)
Wir müssen also eine Funktion bereitstellen, die nichts zurückgibt und einen Rückruf auslöst, sobald die asynchrone Berechnung entweder zu einem Fehler oder zu einem bestimmten Wert aufgelöst wird. Sieht einfach genug aus, um eine Brücke für jede Sprache zu bauen.
Was das Design der Future-Monade selbst betrifft, wollen wir uns ihre interne Struktur ansehen. Die Schlüsselidee besteht darin, eine Cache-Variable zu haben, die einen Wert enthält, wenn die Future-Monade aufgelöst wird, oder ansonsten nichts enthält. Sie können die Zukunft mit einem Rückruf abonnieren, der sofort ausgelöst wird, wenn der Wert aufgelöst wird, oder wenn nicht, den Rückruf in die Abonnentenliste einfügt.
Sobald die Zukunft aufgelöst ist, wird jeder Rückruf in dieser Liste genau einmal mit dem aufgelösten Wert in einem separaten Thread ausgelöst (oder als nächste Funktion, die in der Ereignisschleife im Fall von JS ausgeführt wird). Beachten Sie, dass dies von entscheidender Bedeutung ist Verwenden Sie die Synchronisierungsprimitive sorgfältig, da sonst Race-Conditions möglich sind.
Der grundlegende Ablauf ist: Sie starten die asynchrone Berechnung, die als Konstruktorargument bereitgestellt wird, und verweisen auf ihren Rückruf auf unsere interne Rückrufmethode. In der Zwischenzeit können Sie die Future-Monade abonnieren und Ihre Rückrufe in die Warteschlange stellen. Sobald die Berechnung abgeschlossen ist, ruft die interne Callback-Methode alle Callbacks in der Warteschlange auf. Wenn Sie mit reaktiven Erweiterungen (RxJS, RxSwift usw.) vertraut sind, verwenden sie einen sehr ähnlichen Ansatz für die asynchrone Behandlung.
Die öffentliche API der Future-Monade besteht aus pure
, map
und flatMap
, genau wie in den vorherigen Monaden. Wir brauchen auch ein paar praktische Methoden:
-
async
, das eine synchrone Blockierungsfunktion übernimmt und in einem separaten Thread ausführt, und -
traverse
, das ein Array von Werten und eine Funktion verwendet, die einen Wert einemFuture
, und einenFuture
eines Arrays von aufgelösten Werten zurückgibt
Mal sehen, wie sich das auswirkt:
JavaScript – Future Monad
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 – Zukünftige 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()
Rubin – Zukünftige 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 – Future Monad
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 – Zukünftige 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) }) }) }
Beachten Sie nun, dass die öffentliche API von Future
keine Low-Level-Details wie Threads, Semaphoren oder ähnliches enthält. Alles, was Sie brauchen, ist im Grunde, etwas mit einem Rückruf zu versorgen, und das war's!
Komponieren eines Programms aus Monaden
Okay, versuchen wir also, unsere Monaden zu verwenden, um ein tatsächliches Programm zu erstellen. Angenommen, wir haben eine Datei mit einer Liste von URLs und möchten jede dieser URLs parallel abrufen. Dann wollen wir die Antworten der Kürze halber auf jeweils 200 Bytes kürzen und das Ergebnis ausdrucken.
Wir beginnen damit, bestehende Sprach-APIs in monadische Schnittstellen umzuwandeln (siehe die Funktionen readFile
und fetch
). Jetzt, wo wir das haben, können wir sie einfach zusammensetzen, um das Endergebnis als eine Kette zu erhalten. Beachten Sie, dass die Kette selbst supersicher ist, da alle blutigen Details in Monaden enthalten sind.
JavaScript – Beispiel für ein Monad-Programm
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!