Blog

Die Macht der IO in Haskell

Alejandro Serrano Mena

Alejandro Serrano Mena

Aktualisiert Oktober 15, 2025
19 Minuten

IO ist heutzutage in vielen FP-Communities ein heißes Thema. IO bezieht sich dabei auf die Idee, Ihre effektiven Aktionen in einem speziellen Typ zu verpacken und zu markieren, um eine bessere Kontrolle über ihre Ausführung zu haben. Dies steht in krassem Gegensatz zu dem üblichen Spielraum, den Sprachen dem Programmierer einräumen, bei dem Sie Seiteneffekte überall und an jedem Punkt ausführen können. In Haskell ist diese Arbeitsweise die Standardeinstellung. Es gibt keine Möglichkeit, IO zu verlassen, wenn Sie Seiteneffekte wie Konsolen-, Datei- und Netzwerkein- und -ausgabe oder die Erzeugung von Zufallszahlen ausführen müssen, um nur einige zu nennen.

Vergleicht man jedoch die Dokumentation von System.IO von Haskells base Bibliothek mit moderneren Inkarnationen, wie Arrow Fx, Bow Effects, ZIO und Cats Effect, scheint Haskell im Bereich IO ziemlich unterlegen zu sein. Während die letztgenannten Bibliotheken von Haus aus Streams, Ressourcenmanagement und Warteschlangen bieten, sind diese in der Haskell-Bibliothek nirgends zu finden. Das ist nur ein falscher Eindruck, der aber leicht zu gewinnen ist: Das Haskell-Ökosystem bietet all diese Funktionen, aber verteilt auf viele verschiedene Bibliotheken. In diesem Beitrag sehen wir uns einige von ihnen an, gruppiert nach ihrem Ziel.

Klingt interessant?

Die Idee eines separaten IO hat sich in den Gemeinschaften der funktionalen Programmierung durchgesetzt. Mehrere unserer Academy-Kurse behandeln dieses Thema, darunter Haskell Fundamentals, Functional Programming with Arrow.

Zusätzlich zu unseren Kursen bieten wir regelmäßig Vorträge und Webinare zu IO und verwandten Themen an, viele davon kostenlos! Diese Vorträge und Webinare befassen sich mit konkreteren Themen rund um die Funktionale Programmierung und ihre Anwendungen und dauern zwischen 1 und 4 Stunden.

Inhaltsverzeichnis

Ausnahmen

Eine berechtigte Frage, die sich viele stellen, wenn sie zum ersten Mal die Haskell-Funktion zum Lesen des gesamten Inhalts einer Datei, readFile :: FilePath -> IO String, sehen, ist: "Was passiert, wenn die Datei nicht existiert?" Nun, es wird eine Ausnahme geworfen. Diese Antwort unterscheidet sich nicht wesentlich von der anderer Sprachen, mag aber aufgrund der für Haskell üblichen Explizitheit bei der Fehlerbehandlung überraschen. Im Fall von IO wurde es jedoch als unpraktisch erachtet, jeder Funktion eine zusätzliche Ebene für mögliche Fehler hinzuzufügen. Wenn dies der Fall wäre, würde IO zu einem tief verschachtelten Matching werden:

do config <- readFile "config"
case config of
Left e -> -- error case
Right config' -> do
let db = database config
conn <- openDbConnection db
case conn of
Left e -> -- error case
Right conn' -> do ...

Die Behandlung von Ausnahmen ist in dem ModulControl.Exception implementiert. Seine grundlegenden Funktionen sind das Werfen und Abfangen von Ausnahmen:

throw :: Exception e => e -> a
catch :: Exception e => IO a -> (e -> IO a) -> IO a

Oberflächlich betrachtet sind diese Funktionen nicht sonderlich überraschend: throw verlangt, dass die Ausnahme ausgelöst wird, catch verlangt den glücklichen Pfad und eine Funktion zur Wiederherstellung, wenn eine Ausnahme gefunden wird. Bei näherer Betrachtung fällt auf, dass eine Klasse vom Typ Exception verwendet wird. Dieses Design ermöglicht das Abfangen verschiedener Arten von Ausnahmen im selben Codeblock:

do somethingWithFilesAndNumbers
catch (e :: IOException) -> -- handle IO problems
catch (e :: ArithException) -> -- handle division by zero

Der obige Code zeigt das übliche Muster beim Umgang mit Ausnahmen in Haskell: Deklarieren Sie den Typ der Ausnahmen, die Sie abfangen möchten, mit Hilfe einer Typsignatur (um diese schreiben zu können, müssen Sie die Erweiterung ScopedTypeVariables aktivieren).

Gleichzeitige Fasern

Fibers, wie sie in Arrow (Kotlin), Project Loom (Java), ZIO oder Cats Effect (beide Scala) genannt werden, sind IO Aktionen, die von der Laufzeitumgebung gleichzeitig ausgeführt werden(Fibers sind auch als grüne Threads bekannt. Die meisten Betriebssysteme unterstützen Multithreading, aber Threads auf Betriebssystemebene sind in der Regel recht schwerfällig, weshalb wir etwas Leichtgewichtiges brauchen.

Glücklicherweise verwendet GHC (der wichtigste Haskell-Compiler) standardmäßig Fasern / grüne Threads im ModulControl.Concurrent , es sei denn, man fordert etwas anderes über forkOS. Das oben erwähnte Modul ist die Grundlage für die Gleichzeitigkeit in GHC, bietet aber eine sehr einfache Schnittstelle. Darüber hinaus finden wir das async Paket, eine Schnittstelle, die auf der einfachen Funktion async :: IO a -> IO (Async a) aufbaut.

Diese Funktion empfängt eine IO Aktion und legt einen neuen grünen Thread an, um sie auszuführen. Und nicht nur das: die Aktion als Ganzes kann einen Wert zurückgeben, wobei die übliche monadische Schnittstelle verwendet wird, und wir können ihn später mit der Funktion wait abfragen oder cancel, um die Ausführung zu stoppen, wenn sie noch nicht beendet wurde.

do compute_n <- async lengthyComputation
r <- checkDb
if r
then do n <- wait compute_n
updateDb n
else cancel compute_n

Arrow Fx, ZIO und Cats Effect bieten die gleiche Schnittstelle, allerdings mit unterschiedlichen Namen. Scalas Future ist ebenfalls recht nahe dran, obwohl es nicht die Möglichkeit bietet, die laufende Berechnung zu unterbrechen.

Arrow Fx / ZIO / Katzen EffektHaskell's async
forkasync
joinwait
cancel / interruptcancel

Parallelisierung und Ethnien

Ein wichtiger Grund für die Verwendung leichter grüner Fäden/Fasern ist die Erstellung vieler dieser Fäden/Fasern. Nach der Erstellung haben Sie zwei grundlegende Möglichkeiten, deren Ergebnisse zu sammeln: a) wenn Sie das Ergebnis jeder Faser benötigen, können Sie warten, bis alle fertig sind; oder b) wenn Sie nur das Ergebnis einer Faser benötigen, um fortzufahren, können Sie einfach warten, bis die erste fertig ist, und dann alle anderen abbrechen und deren Ergebnisse verwerfen. Sowohl Arrow Fx als auch ZIO bezeichnen diese Modi als Parallelisierung bzw. Rennen. In Haskell verwenden wir verschiedene Versionen von wait:

waitBoth :: Async a -> Async b -> IO (a, b) -- parallel
waitEither :: Async a -> Async b -> IO (Either a b) -- race

Diese Funktionen werden jedoch nur selten verwendet. Das häufigste Szenario besteht darin, die verschiedenen Asyncs zu erstellen und gleich danach wait für sie auf die eine oder andere Weise zu verwenden. Dieses Muster wird durch das Funktionspaar verkörpert:

concurrently :: IO a -> IO b -> IO (a, b)
race :: IO a -> IO b -> IO (Either a b)

Leider sind diese beiden Funktionen auf zwei Threads beschränkt. Was aber, wenn Sie ein Rennen zwischen drei Threads veranstalten wollen? Oder noch mehr, wenn Sie einen Prozess gleichzeitig mit einem Wettlauf zwischen zwei anderen ausführen wollen? Die Bibliothek async hat eine sehr geniale Lösung: Sie stellt einen Datentyp zur Verfügung, der diese Szenarien beschreibt und dabei die gemeinsamen Abstraktionen Applicative und Alternative verwendet! Der letztgenannte Fall, den wir beschrieben haben, wird wie folgt ausgedrückt (danke Harold, dass er einen Fehler in einer früheren Version entdeckt hat!)

runConcurrently $
(,) <$> Concurrently getInfoFromDb
<*> (Concurrently computeLocally <|> Concurrently downloadFromCache)

Verlinkung

Ein Problem, mit dem wir bei der Arbeit mit Fasern konfrontiert werden können, ist die vorzeitige Beendigung einer dieser Fasern, normalerweise aufgrund einer Ausnahme. Andere Threads, die ihm Informationen liefern, hören jedoch nicht auf, ausgeführt zu werden. Das bedeutet, dass möglicherweise nutzlose Arbeit geleistet wird. Dieses Problem wird manchmal als " undichte Fasern" bezeichnet.

Inspiriert von Erlangs großartiger Unterstützung für die Prozessverwaltung, führt async das Linking als Möglichkeit ein, dieses Problem zu vermeiden. Sie können entweder einen anderen Thread mit dem aktuellen Thread verknüpfen, oder zwei Threads:

link :: Async a -> IO ()
link2 :: Async a -> Async b -> IO ()

Die Laufzeitumgebung sorgt dafür, dass von nun an jede Ausnahme, die in einem dieser Blöcke ausgelöst wird, auf den anderen übertragen wird. Jede unbehandelte Ausnahme in einem Async Block wiederum führt dazu, dass dieser Thread gestoppt wird. Im einfachsten Fall, in dem wir keine Ausnahmen behandeln, bedeutet die Verknüpfung zweier Threads also, dass der eine abgebrochen wird, wenn der andere es tut. Es bleiben keine nutzlosen Threads mehr übrig.

Kommunizierende Threads

Das Warten auf das Ende eines Threads ist ein einfacher Weg, um Informationen von ihm zu erhalten, aber nicht der einzige. Die GHC-Standardbibliothek enthält eine "Ein-Element-Box" namens MVar als Primitivum für die Kommunikation. Ich rate Ihnen jedoch dringend davon ab, es zu verwenden. Die Dokumentation selbst weist uns auf das richtige Werkzeug hin:

[...] Sie sind jedoch sehr einfach und anfällig für Race Conditions, Deadlocks oder nicht abgefangene Exceptions. Verwenden Sie sie nicht, wenn Sie größere atomare Operationen durchführen müssen, wie z.B. das Lesen aus mehreren Variablen: Verwenden Sie stattdessen STM.

STM steht für Software Transactional Memory . STM bringt Ideen aus Datenbanken in die übliche Variablenverwaltung ein. Anstatt kleine einfache Operationen über gemeinsam genutzte Variablen auszuführen, schreiben Sie ein Transaktionsskript mit einer oder mehreren dieser Operationen. Die Laufzeitumgebung sorgt dann dafür, dass das gesamte Skript atomar über eine konsistente Weltansicht ausgeführt wird. GHC war in der Tat einer der ersten Compiler, der Laufzeitunterstützung für STM bot; andere wie Clojure machen STM zu einem integralen Bestandteil ihrer Nebenläufigkeits-API. Cats STM bietet ähnliche Funktionen für das Scala-Ökosystem.

Nehmen Sie das folgende Beispiel, in dem wir eine neue Transaktionsvariable oder TVar erstellen und gleichzeitig ihren Wert erhöhen.

do v <- newTVar 0
concurrently (add1 v) (add1 v)
where
add1 v = atomically $ do n <- readTVar
writeTVar (n+1)

Der wichtigste Teil ist hier die Funktion atomically, die das als Argument angegebene Skript ausführt. Das Skript selbst hat nicht den Typ IO a, sondern STM a. Durch die Verwendung eines anderen Typs stellt die Sprache sicher, dass nur eine begrenzte Anzahl von Operationen in diesem Block ausgeführt werden kann. Das Lesen und Schreiben von Transaktionsvariablen sind zwei Beispiele dafür, was erlaubt ist. Dieser Block zeigt auch ein sehr gängiges Muster: Erstellen Sie eine Transaktionsvariable in einem "Eltern"-Thread und geben Sie dann ihren Verweis an jeden "Kind"-Thread als Argument weiter.

Ohne die Konsistenzgarantien könnte dieser Code mit einem falschen Wert für die Variable v enden: Thread 1 liest den Wert 0, Thread 2 liest den Wert 0, dann schreiben beide den Wert 1. STM verhindert ein solches Szenario, und wir erhalten hier immer den Wert 2, unabhängig von der Reihenfolge, in der die Transaktionen ausgeführt werden. Wie dies erreicht wird, liegt außerhalb des Rahmens dieses Artikels, aber Sie sollten wissen, dass dies auf der Fähigkeit beruht, Berechnungen erneut durchzuführen. Haskell zeichnet sich hier durch seine Reinheit aus. In anderen Sprachen müssen Benutzer von STM "versprechen", dass sie hier niemals Seiteneffekte ausführen werden.

Variablen sind die einfachste Datenstruktur, die auf transaktionale Weise gehandhabt werden kann. Mehrere Bibliotheken erweitern diese Menge erheblich; hier ist eine nicht erschöpfende Liste dieser Bibliotheken.

NameBibliothekBeschreibung
TVarstm(Übliche) Variable
TSemstmSemaphor
TMVarstmVariablen, die entweder leer oder voll sind
TChan und TQueuestmFIFO-Warteschlangen
T{B,M}Chan, T{B,M}Queuestm und stm-chansB = begrenzt, M = verschließbar
TArraystmArrays
Mapstm-containersHash-Karten
Setstm-containersSetzen Sie

Man könnte sich fragen: "Warum eine spezielle Transaktions-Map, wenn man auch TVar (Map k v) verwenden könnte, d.h. eine Variable, die eine Map enthält?" Der Grund dafür ist, dass der Zugriff auf Maps in vielen Fällen optimiert werden kann, z.B. wenn zwei Skripte unterschiedliche Schlüssel ändern. Wenn wir alles in eine TVar packen, ist diese Optimierung nicht möglich, da die Laufzeit die Map als Ganzes verfolgt.

Die STM-Implementierung von Haskell geht über das hinaus, was wir unter Kommunikation verstehen, und enthält weitere Funktionen, die eher einer Datenbank entsprechen. Insbesondere können wir erklären, dass unsere Transaktion inkonsistente Informationen gefunden hat oder generell nicht fortgesetzt werden kann, indem wir retry aufrufen. Die Laufzeitumgebung hält die Ausführung dieses Skripts an und versucht es erneut, sobald sie feststellt, dass sich einige der betroffenen Variablen geändert haben.

Verwaltung der Ressourcen

Sobald Ausnahmen und Threads ins Spiel kommen, wird die Verwaltung der Ressourcen schwierig. Wenn Sie eine Datei oder eine Netzwerkverbindung öffnen, sollen diese geschlossen werden, unabhängig davon, ob die Verwendung erfolgreich war oder nicht. Doch der naive Code unten:

do r <- acquireResource
useResource r
releaseResouce r

garantiert das nicht: Wenn useResource eine Ausnahme auslöst, wird releaseResource zum Beispiel nie aufgerufen.

Das wichtigste Muster für den korrekten Umgang mit Ressourcen wird bracket genannt, nach dem Namen der Funktion, die es in Haskell implementiert, Arrow Fx, Bow oder Cats Effect.

bracket :: IO a -- acquire
-> (a -> IO b) -- release
-> (a -> IO c) -- use
-> IO c

Die meisten Bibliotheken für den Umgang mit Ressourcen bieten oft eine Variante von bracket für diese speziellen Ressourcen an, in der Regel mit dem Präfix with. Zum Beispiel, withFile stellt sicher, dass eine Datei korrekt geöffnet und geschlossen wird; und withPool tut dies für einen Pool.

Es gibt jedoch zwei wesentliche Vorbehalte gegen die Verwendung von bracket:

  1. Jedes Mal, wenn Sie bracket verwenden, muss der Rest des Codes um eine weitere Ebene eingerückt werden.
  2. Ressourcen werden immer in der umgekehrten Reihenfolge freigegeben, in der sie erworben wurden. Das bedeutet insbesondere, dass Sie eine Ressource nicht früher freigeben können, wenn Sie mit ihr fertig sind; Sie müssen bis zum Ende warten.

Wenn Sie sich nur mit dem ersten Problem befassen, bietet das Paketmanaged eine schönere API auf bracket. Insbesondere, was liest:

do bracket acquireResource1 releaseResource1 $ r1 ->
bracket acquireResource2 releaseResource2 $ r2 ->
use r1 r2

wird die viel schöner aussehende und ohne Verschachtelung:

runManaged $ do
r1 <- managed (bracket acquireResource1 releaseResource1)
r2 <- managed (bracket acquireResource2 releaseResource2)
liftIO $ use r1 r2

Der Trick dabei ist die Verwendung eines neuen Managed Typs, der das managed Konstrukt hinzufügt. Der einzige Nachteil ist, dass Sie den Code, der die Ressourcen verwendet, in einen Aufruf von liftIO verpacken müssen. Wie wir später sehen werden, wird liftIO verwendet, wenn wir IO Aktionen in einer Monade ausführen wollen, die IO erweitert, aber nicht IOist. Cats Effect und ZIO verwalten Ressourcen auf eine sehr ähnliche Weise.

Die Lösung für das zweite Problem kommt mit dem Paketresourcet . Die Hauptfunktion ist allocate, dessen Hauptunterschied zur Kombination von managed und bracket darin besteht, dass es sowohl die Ressource als auch einen sogenannten Freigabeschlüssel zurückgibt:

allocate :: IO a -- acquire
-> (a -> IO ()) -- release
-> m (ReleaseKey, a)

Wenn Sie nichts weiter tun, verhält sich das Programm genau wie bracket. Mit dem Schlüssel release können Sie aber auch release aufrufen, um eine Ressource vor dem Ende des Blocks freizugeben. Beachten Sie, dass von diesem Moment an die Verwendung der Ressource zu einem Laufzeitfehler führt, da der Compiler die Liveness der Ressource nicht verfolgt.

Pools von Ressourcen

Sie fragen sich vielleicht, wie Sie nicht nur eine einzelne Ressource, sondern einen Pool von Ressourcen verwalten können. Ein Pool ermöglicht den Zugriff auf eine bestimmte Anzahl von Ressourcen, die angefordert oder an den Pool zurückgegeben werden können. Der typische Anwendungsfall sind Datenbankverbindungen: Sie wollen nicht eine einzige Verbindung für eine ganze Anwendung haben, aber Sie wollen auch nicht, dass jeder einzelne Thread seine eigene Verbindung erstellt. In Anlehnung an die Schritte von bracket bietet das Paketpool eine Funktion, die eine maximale Anzahl von Ressourcen verwaltet:

createPool :: IO a -- acquire
-> (a -> IO ()) -- release
-> Int -- max amount
-> (Pool a -> IO b) -- use
-> IO b

Von diesem Moment an können Sie eine Ressource anfordern, indem Sie withPool. Wie Sie sehen können, ist bracket das A und O der Ressourcenverwaltung in der funktionalen Programmierung.

Zeitplanung und Wiederholungsrichtlinien

Ein gemeinsames Merkmal aller IO Inkarnationen ist eine API für die Planung oder Wiederholung von Aktionen. In der Dokumentation von Arrow Fx, ZIO und Bow wird dies sogar als eine der Hauptfunktionen beschrieben.

Und wieder einmal fragen Sie sich vielleicht: Wo ist die Implementierung für Haskell? Keine Angst, das Paketretry (das auf Cats Effect portiert wurde) unterstützt Sie dabei! Wie in den meisten Implementierungen in anderen Sprachen ist die Wiederholungspolitik, d.h. die Art und Weise, wie Aktionen im Falle eines Fehlschlags wiederholt werden sollen, von den Aktionen selbst getrennt. Die einfachsten Richtlinien sind konstant und exponentiell:

p1 = constantDelay 10000000 -- 1 second
p2 = exponentialBackoff 10000000

Darüber hinaus können Sie Richtlinien kombinieren und ganz einfach Ihre eigenen erstellen: Die Funktion retryPolicy nimmt einen Verlauf der vorherigen Versuche und gibt zurück, ob die Aktion erneut versucht werden soll, und im positiven Fall nach wie vielen Mikrosekunden, und zwar mit Hilfe eines Maybe Int Wertes.

Sie können eine solche Richtlinie auf zwei verschiedene Arten anwenden, je nachdem, ob Sie mit Ausnahmen arbeiten möchten oder nicht. Bei der Verwendung von retryingwird die Frage, ob nach einem Versuch ein neuer Versuch unternommen werden soll, an eine Funktion delegiert, die einen booleschen Wert zurückgibt.

retrying (constantDelay 1000000 <> limitRetries 3)
(_ n -> return $ n >= 0) -- retry if the number is negative
(_ -> computeNumber)

In diesem Fall haben wir das erste Argument für die Wiederholungsprüfung und den Versuch überflüssig gemacht, das die Geschichte der Wiederholungsversuche vor dem aktuellen darstellt. Auf diese Weise kann die Entscheidung besser begründet werden.

Das Gegenstück für Funktionen, die eine Ausnahme auslösen, lautet recovering. In diesem Fall kümmert sich die Funktion selbst um das Abfangen der Ausnahmen, an denen Sie interessiert sind, wie Sie es mit catch tun würden. Aber diese Handler müssen auch entscheiden, ob die Aktion erneut versucht werden soll. Lassen Sie uns eine Version der obigen Funktion schreiben, die die Aktion auch wiederholt, wenn eine ArithException ausgelöst wird:

recovering (constantDelay 1000000 <> limitRetries 3)
[_ -> Handler $ (e :: ArithException) -> return True]
(_ -> computeNumber)

Wenn Sie nur daran interessiert sind, es unabhängig von der Ausnahme erneut zu versuchen, bietet die Bibliothek eine recoverAll .

Data Streaming

Die manuelle Verarbeitung von Lese- oder Schreibvorgängen zum richtigen Zeitpunkt bei minimalem Ressourcenverbrauch ist an sich schon eine Herausforderung. Seit einiger Zeit hat sich die Aufmerksamkeit auf (reaktive) Streams als Lösung für die funktionale Verarbeitung eingehender Daten verlagert. Diese Lösungen sind nicht nur auf funktionale Sprachen beschränkt. ReactiveX oder Reactor zielen auf mehrere Sprachen ab und verfügen über eine ähnliche API für alle Sprachen. Arrow Fx hat sich entschieden, diese Bibliotheken zu verpacken, anstatt eine eigene bereitzustellen, ein Weg, den auch ZIO beschreitet. FS2 ist eine weitere Implementierung von funktionalen Streams in der Scala-Welt, die auf Cats Effect aufbaut.

Die Haskell-Gemeinschaft hat eine Vielzahl von Bibliotheken in diesem Bereich hervorgebracht, auch wenn sie noch nicht so weit sind, conduit und pipes scheinen die meistgenutzten zu sein. Oberflächlich betrachtet sind sie recht ähnlich. Ich tendiere zu conduit, weil es im Moment mehr Integrationen mit anderen Bibliotheken im Ökosystem gibt.

In der Bibliothek conduit werden die grundlegenden Bausteine, wenig überraschend, Conduits genannt. Genauer gesagt stellt ein ConduitM i o m r einen Stream dar, der Werte des Typs i konsumiert und Werte des Typs o produziert, wobei er Effekte von m verwendet. Conduits können auch einen Endwert zurückgeben, wenn der Stream beendet ist; denken Sie zum Beispiel an die Erstellung von Statistiken, wenn die Berechnung beendet ist. Sehen wir uns einige Beispiele aus dem ModulCombinators an:

-- reads ByteString from a file
sourceFile :: FilePath -> ConduitT i ByteString IO ()
-- saves the incoming data in a file
sinkFile :: FilePath -> ConduitT ByteString o IO ()

Zusätzlich zu diesem Modul definiert die Bibliothekconduit-extra Quellen und Senken für andere Arten von Ressourcen.

Viele der Transformationen haben die gleichen Namen wie ihre Pendants in der Liste. Zum Beispiel, map wandelt die eingehenden Werte um und sendet sie als Ausgabe, wie durch den Typ angedeutet:

map :: Monad m => (a -> b) -> ConduitT a b m ()

Sie können mehrere Conduits zu einer größeren Pipeline verbinden, indem Sie den Operator (.|) verwenden. Natürlich müssen Eingabe und Ausgabe übereinstimmen, damit der Compiler sie akzeptiert. Stellen Sie sich zum Beispiel vor, wir hätten eine toUppercase Operation, die die Buchstaben ByteString in ihr Gegenstück in Großbuchstaben verwandelt. Dann wird der folgende Block:

runConduitRes $ sourceFile "input" .| map toUppercase .| sinkFile "output"

öffnet die Datei input und schreibt eine Version in Großbuchstaben in output. Beachten Sie die Verwendung von runConduitRes, um die Pipeline auszuführen. Bis dahin ist das Conduit nur eine Beschreibung dessen, was getan werden soll.

Wie bereits erwähnt, liegt die Stärke dieser Bibliotheken in der breiten Palette der verfügbaren Integrationen. In diesem Fall können Sie eine Datei öffnen, sie in JSON umwandeln, den Wert in eine transaktionale Warteschlange stellen, die dann gelesen und an eine RabbitMQ-Warteschlange oder ein Kafka-Thema gesendet wird.

MonadIO und Freunde

Der Abschnitt über Managed hat bereits ein wichtiges Muster für IO Aktionen in der Haskell-Gemeinschaft angedeutet: Monaden, die nicht IO sind, sondern ihre Fähigkeiten erweitern. Manchmal müssen Sie auch Transformatoren verwenden, um diese neuen Fähigkeiten hinzuzufügen, wie z.B. die Protokollierung. Aber natürlich möchten Sie, dass die Operationen, die unabhängig von diesen Erweiterungen durchgeführt werden können, so weit wie möglich ohne Änderungen verwendet werden. Der Trick besteht darin, MonadIO anstelle der konkreten IO zu verwenden, wie die folgende Funktion in conduit andeutet:

print :: (Show a, MonadIO m) => ConduitT a o m ()

MonadIO definiert eine einzelne Operation namens liftIO, die eine Aktion IO im Bereich der fähigeren Monade ausführt. Da ein monadischer Stack, in dem IO nur eine Komponente ist, in großen Anwendungen nicht ungewöhnlich ist, hat die Gemeinschaft Versionen vieler der in diesem Artikel besprochenen Bibliotheken entwickelt, die auf MonadIO verallgemeinert ("geliftet") wurden, wie lifted-base, lifted-async, und stm-lifted.

Beachten Sie, dass die Geschichte um die Verallgemeinerung der IO Operationen ziemlich schnell kompliziert wird. Etwas wie die Ausgabe auf der Konsole ist einfach, aber wenn es um Fehler und Ausnahmen geht, müssen wir etwas Mächtigeres als MonadIO verwenden. Die Dokumentation für unliftio, eine der einfachsten Lösungen, beschreibt das Problem und den Designraum recht gut. Dieses Problem ist auf jeden Fall nicht so wichtig für Benutzer von IO Aktionen, die einfach die aufgehobenen Operationen verwenden können, ohne die technischen Details zu kennen , wie sie aufgehoben werden.

Eine berechtigte Frage ist: Warum ist dies in anderen Bibliotheken kein Problem? Modernere Bibliotheken, die aus den Fehlern von Haskell gelernt haben, definieren die meisten der in diesem Artikel erwähnten Operationen von Anfang an über Typklassen (sehen Sie sich die Dokumentation für Cats Effect und Arrow Fx an). ZIO geht sogar noch weiter und verwendet eine völlig andere Methode, um verschiedene Effekte zu kombinieren.

Zusammenfassung

Die Haskell-Gemeinschaft hat viele interessante Bibliotheken für die Arbeit mit IO Aktionen entwickelt. In vielen Fällen sind die wichtigsten Funktionen jedoch in verschiedenen Bibliotheken verstreut, und manchmal ist es nicht klar, wo man suchen oder wie man zwischen konkurrierenden Ansätzen wählen soll. Im Gegensatz dazu haben modernere Communities Komplettlösungen in diesem Bereich entwickelt. Ich hoffe, dass dieser Artikel diese Tatsache ans Licht bringt und zeigt, dass Haskell für den Umgang mit IO gut gerüstet ist.

Und vergessen Sie nicht, unsere Xebia Functional Academy Seite zu besuchen, um sich über bevorstehende Vorträge über funktionale Programmierung und ihre Anwendungen sowie über Kurse zu informieren, die sich mit IO und vielen anderen Facetten der funktionalen Programmierung befassen.

Verfasst von

Alejandro Serrano Mena

Contact

Let’s discuss how we can support your journey.