3 Punkte von GN⁺ 2025-01-20 | 2 Kommentare | Auf WhatsApp teilen

Nebeneffekte als Werte erster Klasse behandeln

  • In Haskell werden Nebeneffekte, etwa das Erzeugen von Zufallszahlen oder Ausgaben, wie „Werte erster Klasse“ behandelt
  • Das heißt: Ein Funktionsaufruf, der einen Nebeneffekt erzeugt, etwa randomRIO(1, 6), liefert nicht direkt ein Ergebnis zurück, sondern ein Objekt, das eine „Aktion beschreibt, die irgendwann ausgeführt wird“
  • Dieses Objekt erzeugt beim tatsächlichen Ausführen einen Zufallswert, davor enthält es jedoch nur den Ausführungsplan
  • Ein Typ wie IO Int steht für „eine Aktion, die beim Ausführen ein Int erzeugt“; sie wird beim Aufruf nicht sofort ausgeführt, sondern erst dann, wenn sie später benötigt wird
  • Aufgrund dieser Eigenschaft kann Haskell Nebeneffekte kombinieren und erst später tatsächlich ausführen, anders als traditionelle prozedurale Sprachen, in denen „Funktionsaufruf = sofortige Ausführung“ gilt

De-mystifying do blocks

  • Ein do-Block ist keine magische Syntax, sondern besteht im Wesentlichen aus zwei Operatoren, die Nebeneffekte verknüpfen (bind) und der Reihe nach ausführen (then)

then

  • Der Operator *> führt den Nebeneffekt auf der linken Seite aus, verwirft dessen Ergebnis und führt anschließend den rechten Nebeneffekt aus
  • Zum Beispiel erzeugt putStr "hello" *> putStrLn "world" eine einzelne IO ()-Aktion, die beide Ausgaben der Reihe nach kombiniert
  • Wenn man in einem do-Block mehrere Zeilen schreibt, wird intern genau so ein Operator für die sequentielle Ausführung verwendet

bind

  • Der Operator >>= führt den Nebeneffekt auf der linken Seite aus und übergibt den erhaltenen Wert an die Funktion auf der rechten Seite
  • Beispiel: randomRIO(1, 6) >>= print_side erzeugt einen Nebeneffekt, der das Würfelergebnis an print_side übergibt und ausgibt
  • Das Muster <- in einem do-Block ist im Grunde nur eine bequeme Schreibweise für diesen Operator

Two operators are all of do blocks

  • Letztlich werden do-Blöcke aus genau diesen beiden Operatoren aufgebaut: *> und >>=
  • Wegen Lesbarkeit und Komfort wird die do-Syntax häufig verwendet, aber Haskells Stärken kommen noch besser zur Geltung, wenn man auch reichhaltigere Funktionen zur Kombination von Nebeneffekten nutzt

Functions that operate on side effects

  • In der Standardbibliothek gibt es verschiedene Funktionen, mit denen sich Nebeneffekte vielseitiger behandeln lassen

pure

  • pure x erzeugt „eine Aktion, die den Wert x als Ergebnis liefert, ohne zusätzliche Nebeneffekte“
  • Beispiel: loaded_die = pure 4 erzeugt ein IO Int, das immer 4 zurückgibt

fmap

  • In der Form fmap :: (a -> b) -> IO a -> IO b erzeugt es eine Aktion, die auf das Ergebnis eines Nebeneffekts eine reine Funktion anwendet und dadurch einen neuen Ergebniswert erzeugt
  • Beispiel: Mit length <$> getEnv "HOME" lässt sich eine Aktion erzeugen, die eine Umgebungsvariable holt und dann length auf das Ergebnis anwendet, um dessen Länge zu bestimmen

liftA2, liftA3, …

  • Funktionen wie liftA2 und liftA3 kombinieren die Ergebnisse mehrerer Nebeneffekte mit einer einzigen reinen Funktion zu einem neuen Nebeneffekt
  • Beispiel: liftA2 (+) (randomRIO(1,6)) (randomRIO(1,6)) erzeugt einen Nebeneffekt, der die Werte zweier Würfe addiert
  • Dieselbe Aufgabe lässt sich auch mit einer Kombination aus &lt;$&gt; und &lt;*&gt; erledigen

Intermission: what’s the point?

  • Diese Vorgehensweise mag wie eine einfache Funktion erscheinen, die auch in anderen Sprachen möglich ist, aber in Haskell hat sie den Vorteil, dass man Nebeneffekt-Aktionen jederzeit in Variablen auslagern oder neu kombinieren kann, ohne dass sich Ausführungszeitpunkt oder Ergebnis ändern
  • Weil Nebeneffekte unabhängig behandelt werden, gibt es beim Refactoring weniger Verwirrung, und sichere Wiederverwendung auf Basis von equational reasoning wird möglich

sequenceA

  • sequenceA [IO a] -> IO [a] wandelt „eine Liste von Nebeneffekt-Aktionen“ in „eine einzelne Nebeneffekt-Aktion, die eine Ergebnisliste liefert“ um
  • Beispiel: Mehrere log-Aktionen können in einer Liste gesammelt und später mit sequenceA in einem Schritt ausgeführt werden
  • Auch unendlich wiederholte Nebeneffekte, etwa repeat (randomRIO(1,6)), lassen sich als Liste speichern, mit take n auf die gewünschte Menge begrenzen und dann mit sequenceA ausführen

Interlude: convenience functions

  • void, sequenceA_, replicateM, replicateM_ usw. sind praktisch, wenn man Ergebniswerte nicht nutzt oder Aktionen wiederholt ausführen möchte
  • Beispiel: Mit replicateM_ 500 (putStrLn "I will not cheat again.") kann man einen Nebeneffekt vielfach ausführen, ohne die Wiederholungen selbst zu zählen

traverse

  • traverse :: (a -> IO b) -> [a] -> IO [b] erzeugt eine Aktion, die auf jedes Element einer Liste eine Nebeneffekt-Funktion anwendet und die Ergebnisse wieder in einer Liste sammelt
  • sequenceA ist tatsächlich dasselbe wie traverse id, und traverse_ ist die Variante, die die Ergebnisse verwirft

for

  • for hat dieselbe Funktion wie traverse, nimmt die Argumente aber in umgekehrter Reihenfolge entgegen

  • Beispiel: In der Form for numbers $ \n -> ... lässt sich eine Art „for-Schleife“ natürlich ausdrücken

  • Durch diese Kombinationsmöglichkeiten lassen sich in Haskell Wiederholungen, Iteration und Umformung von Datenstrukturen, die in anderen Sprachen eigene Syntax brauchen, als Kombination von Bibliotheksfunktionen ausdrücken

Leaning into the first classiness of effects

  • Wenn man in Haskell Nebeneffekte aktiv als Werte erster Klasse nutzt, lassen sich Code-Duplikate verringern und Strukturen verbessern
  • So kann man zum Beispiel bei einer Logik zur Primfaktorzerlegung großer Zahlen mit Cache statt IO auch State verwenden, um eine Struktur zu schaffen, in der „Nebeneffekte existieren, aber keine äußeren Auswirkungen haben“
  • Solche strukturierten Nebeneffekte werden nur dort eingesetzt, wo sie nötig sind; der übrige Code kann als reine Funktion erhalten bleiben, was zugleich Sicherheit und Flexibilität bringt
  • Am Ende lassen sich die eigentlichen Nebeneffekte mit evalState und Ähnlichem ausführen und in reine Werte überführen

Things you never need to care about

  • Verschiedene Namen aus älteren Haskell-Zeiten wie >>, return, mapM usw. lassen sich durch heutige Funktionen wie *>, pure, traverse und ähnliche ersetzen
  • Sie stammen aus „alten Namen oder monadenzentrierten Entwürfen“, während heute eher ein auf Applicative oder allgemeineren Functoren basierender Ansatz empfohlen wird

Appendix A: Avoiding success and uselessness

  • Die Aussage „Haskell avoids success“ bedeutet, dass die Sprache ihre grundlegenden Werte nicht aus Popularität oder Bequemlichkeit opfert
  • „Haskell is useless“ verweist auf den Kontext, dass die Sprache anfangs so wirkte, als könne sie wegen vollständig reiner Funktionen nichts Praktisches tun, später aber durch die Behandlung von Nebeneffekten als „Werte erster Klasse“ praktische Nutzbarkeit gewann

Appendix B: Why fmap maps over both side effects and lists

  • fmap hat eine sehr allgemeine Form, Functor f => (a -> b) -> f a -> f b, und lässt sich daher gleichermaßen auf Listen, Maybe, IO und andere Container- oder Nebeneffekt-Typen anwenden
  • Auf eine Liste angewendet versieht fmap alle Elemente mit einer Funktion; auf IO angewendet versieht es den Ergebniswert mit einer Funktion
  • Solche Strukturen, auf die sich Funktionen anwenden lassen, heißen allgemein Functoren

Appendix C: Foldable and Traversable

  • Foldable ist eine Struktur, deren Elemente man durchlaufen und verarbeiten kann
  • Traversable ist eine Struktur, die nicht nur durchlaufen werden kann, sondern sich auch mit neuen Elementen in derselben Form wieder aufbauen lässt
  • Damit sequenceA oder traverse Werte einsammeln können, während die ursprüngliche Struktur erhalten bleibt, muss diese Struktur Traversable sein
  • Bei Datenstrukturen wie Bäumen oder Sets kann die Struktur von den Werten abhängen; deshalb wird unterschieden zwischen Fällen, in denen nur Traversieren möglich ist (Foldable), und solchen, in denen die Struktur tatsächlich rekonstruiert werden kann (Traversable)
  • Je nach Bedarf kann man Nebeneffekte flexibel behandeln, etwa indem man erst in eine Liste umwandelt und dann traverse verwendet

2 Kommentare

 
bbulbum 2025-01-21

Wenn man auf Reddit unterwegs ist, sieht man viele Anzeigen … Aber schon der Name schafft irgendwie eine psychologische Hürde.
Irgendwie wirkt es wie eine ziemlich schwierige und mächtige Sprache …

 
GN⁺ 2025-01-20
Hacker-News-Kommentar
  • Haskells Typsystem ist im Vergleich zu anderen beliebten Sprachen komplex. Insbesondere Operatoren wie *>, <*> und <* erhöhen über die gesamte Codebasis hinweg die Lernkurve.

    • Wenn man Haskell einen Monat lang nicht benutzt, muss man Operatoren wie >>= und >> erneut lernen, um produktiv zu bleiben.
    • Wer Haskell-Konzepte allein lernt, ohne mit anderen darüber zu sprechen, hat es schwer.
  • Haskell hilft dabei, imperatives Programmieren zu verbessern.

    • Mit Effekten erster Klasse und Mustern lässt sich Boilerplate-Code entfernen.
    • Dank Typsicherheit kann man relativ schnell vergleichsweise fehlerarmen Code schreiben.
  • Die verallgemeinerte Version von traverse/mapM funktioniert nicht nur für Listen, sondern für alle Traversable-Typen und ist sehr nützlich.

    • Sie kann in der Form traverse :: Applicative f => (a -> f b) -> t a -> f (t b) verwendet werden.
    • In anderen Sprachen musste man für einen ähnlichen Effekt viel Code von Hand schreiben.
  • Haskell hat mächtige Monaden, was Haskell noch prozeduraler macht.

    • In do-Blöcken kann man Zwischenvariablen verwenden.
  • Zu in Haskell geschriebener Software gehört ImplicitCAD.

  • Haskell-Code liest sich wie eine prozedurale Sprache, bietet aber die Vorteile beim Arbeiten mit Funktionen mit Seiteneffekten.

    • Mit der IO-Monade zu arbeiten ist komplex, und noch komplexer wird es, wenn man andere Monadentypen verwenden möchte.
  • >> ist der alte Name von <i>>, und beide Operatoren sind linksassoziativ.

    • >> ist als infixl 1 definiert und <i>> als infixl 4, daher bindet <i>> stärker als >>.
  • Haskells IO a und a können sich ähnlich anfühlen wie asynchron und synchron.

    • Ersteres gibt ein Promise/Future zurück, auf das man warten muss.
  • In anderen Sprachen kann man einfaches IO mit einer Funktion wie console.log("abc") ausführen.

    • Es stellt sich die Frage, worin der Unterschied zu Haskells IO besteht.
  • Wer Haskell noch nicht ausprobiert hat, könnte das echte Haskell mit GHC-Erweiterungen als zu komplex empfinden.

    • Das kann das Interesse an Haskell mindern.