Warum algebraische Effekte nötig sind
(antelang.org)- Algebraische Effekte (Effect Handlers) sind flexible Werkzeuge zur Steuerung des Kontrollflusses, mit denen sich verschiedene Sprachfunktionen wie Ausnahmebehandlung, Generatoren und Coroutines auf Bibliotheksebene implementieren lassen
- Sie lassen sich auch auf in der funktionalen Programmierung häufige Themen wie Kontextverwaltung, Dependency Injection und den Ersatz globalen Zustands anwenden
- Sie tragen zur Einfachheit des API-Designs und zur automatischen Weitergabe von Zustand bzw. Umgebung im Code bei
- Zu den Vorteilen zählen außerdem Garantie funktionaler Reinheit, Replay-Fähigkeit und Sicherheits-Audits
- Durch jüngste Fortschritte in der Compilertechnik haben sich auch die Performance-Probleme deutlich verbessert
Überblick über algebraische Effekte (Algebraic Effects)
Algebraische Effekte (auch bekannt als Effect Handlers) sind ein Sprachfeature, das in letzter Zeit viel Aufmerksamkeit erhält. Sie gehören zu den Kernfunktionen von Ante und verschiedenen Forschungssprachen wie Koka, Effekt, Eff und Flix und verbreiten sich derzeit schnell. Viele Materialien erklären das Konzept von Effect Handlers, doch an einer tiefergehenden Erklärung, warum sie tatsächlich gebraucht werden, mangelt es oft. Dieser Artikel stellt die praktischen Einsatzmöglichkeiten und Vorteile algebraischer Effekte möglichst breit vor.
Syntax und Semantik im Schnelldurchlauf
- Algebraische Effekte ähneln „fortsetzbaren Ausnahmen“
- Effektfunktionen lassen sich etwa mit
effect SayMessagedeklarieren - Mit
foo () can SayMessage = ...kann angegeben werden, dass eine Funktion diesen Effekt verwenden darf - Mit
handle foo () | say_message () -> ...lässt sich der Effekt ähnlich wie mit try/catch bei Ausnahmen behandeln
Über diese Grundstruktur lassen sich Effektaufrufe und ihre Steuerung realisieren.
Erweiterung des Kontrollflusses durch benutzerdefinierte Abstraktionen
Der größte Vorteil algebraischer Effekte besteht darin, dass sich mit einem einzigen Sprachfeature Funktionen, für die ursprünglich jeweils eigene Sprachkonstrukte nötig waren — etwa Generatoren, Ausnahmen, Coroutines oder Asynchronität — als Bibliotheken implementieren lassen.
- Verwendet eine Funktion polymorphe Effektvariablen (
can e), lassen sich verschiedene Effekte als Funktionsargumente übergeben und kombinieren - Beispielsweise kann eine
map-Funktion so deklariert werden, dass die übergebene Funktion einen beliebigen Effektenutzen darf, wodurch sie sich natürlich mit unterschiedlichen Effekten wie Ausgabe oder Asynchronität kombinieren lässt
Beispiele für die Implementierung von Ausnahmen und Generatoren
- Ausnahmen implementieren: Wird nach dem Auslösen eines Effekts kein
resumeaufgerufen, verhält sich das wie eine Ausnahme - Generatoren implementieren: Definiert man einen
Yield-Effekt, kann bei jedem Yield eines Werts ein externer Handler eingreifen und den Ablauf abhängig von Bedingungen steuern; auch fortgeschrittene Muster wie Filterung lassen sich mit relativ einfachem Code schreiben
Dass sich mehrere Effekte kombinieren lassen, ist ebenfalls ein großer Vorteil gegenüber bisherigen Techniken zur Effektabstraktion.
Einsatz als Abstraktionsschicht
Algebraische Effekte sind nicht nur zur Erweiterung grundlegender Programmierfunktionen nützlich, sondern auch in vielen Szenarien von Business-Anwendungen sehr brauchbar.
Dependency Injection
- Abhängige Objekte wie Datenbanken oder Ausgaben lassen sich als Effekte abstrahieren und über Handler verwalten
- Auch Test-Mocks oder die Umleitung von Ausgaben lassen sich flexibel umsetzen
Bedingtes Logging oder Ausgabesteuerung
- Ob Log-Meldungen ausgegeben werden, kann zentral anhand des Log-Levels gesteuert werden
Vereinfachung des API-Designs und automatische Weitergabe von Kontext
Nutzung von State-Effekten
- In Situationen, in denen ein Context-Objekt oder Umgebungsinformationen weitergereicht werden müssen, kann eine effektbasierte Implementierung, die nur
get/setverwendet, die Zustandsverwaltung ohne explizites Weiterreichen automatisieren - Bisher musste der Kontext an jede Funktion als Argument übergeben werden; mit einem State-Effekt lässt sich dieser Teil verbergen
Ersatz für globale Objekte
- Auch Zustände, die bisher über globale Objekte wie Zufallszahlengeneratoren oder Speicherallokation verwaltet wurden, lassen sich als Effekte abstrahieren, was Vorteile bei Code-Klarheit, Testbarkeit und Concurrency bringt
- Durch den Austausch des Handlers kann die tatsächliche Quelle von Zufallszahlen flexibel geändert werden
Unterstützung für Direct Style
- Bisher mussten oft mehrere Objekte verschachtelt behandelt werden, etwa durch Option-Typen oder Error-Wrapping
- Effekte ermöglichen es, solche Fehler- oder Nebenwirkungspfade auch ohne derartige Wrapper sauber auszudrücken
Garantie von Reinheit und Sicherheits-Audits
Nebenwirkungen explizit machen
- In den meisten Sprachen mit Effect Handlers müssen Funktionen mit Nebenwirkungen diese in ihrer Typsignatur explizit angeben, etwa mit
can IOodercan Print - Bei Thread-Erzeugung oder Software Transactional Memory (STM) sind zwingend reine Funktionen erforderlich
Log-Replay und deterministisches Networking
- Auf Basis von Reinheit lassen sich Handler wie
recordundreplaybauen, mit denen sich Ausführungsergebnisse reproduzieren lassen - Damit sind deterministische Ergebnisse und Rollbacks etwa für Debugging, Datenbanken und Game-Networking möglich
Unterstützung für Capability-based Security
- Da in der Typsignatur einer Funktion alle unbehandelten Effekte sichtbar werden, ist das bei Sicherheits-Audits externer Bibliotheken sehr hilfreich
- Wenn eine zuvor nebenwirkungsfreie Funktion nach einem Update plötzlich
can IOerhält, kann aufrufender Code das sofort erkennen
Allerdings kann es auch dazu kommen, dass Effekte automatisch weitergereicht werden und dadurch unbeabsichtigt verarbeitet werden.
Effizienz und Fazit
- Früher galt die Laufzeiteffizienz als Schwäche, doch in letzter Zeit wurden viele Fälle — etwa tail-resumptive Effekte — stark optimiert
- Je nach Sprache kommen jeweils wirksame Compilerstrategien zum Einsatz, etwa closure call, evidence passing oder Handler-Spezialisierung
Es ist zu erwarten, dass algebraische Effekte in den Programmiersprachen der Zukunft eine noch wesentlich zentralere Rolle einnehmen werden.
1 Kommentare
Hacker-News-Kommentare
Ich sehe dabei zwei Nachteile
Der erste ist, dass im gegebenen Codeausschnitt überhaupt nicht ersichtlich ist, dass
foooderbarfehlschlagen könnenUm zu wissen, dass ein solcher Aufruf einen Error-Handler auslösen kann, muss man die Typ-Signatur direkt nachschlagen, und je nach Situation geht das nicht ohne IDE-Unterstützung nur manuell
Der zweite ist, dass man, nachdem man erkannt hat, dass
fooundbarfehlschlagen können, bei einem tatsächlichen Fehler im Call-Stack weit nach oben gehen muss, um einenwith-Ausdruck zu finden, und danach dem entsprechenden Handler wieder nach unten folgen muss, um herauszufinden, welcher Code tatsächlich ausgeführt wirdDieses Verhalten lässt sich nicht statisch nachverfolgen und auch nicht direkt in der IDE per Sprung zur Definition verfolgen, weil
my_functionan mehreren Stellen mit unterschiedlichen Handlern aufgerufen werden kannIch finde das Konzept sehr neuartig, habe aber letztlich Bedenken hinsichtlich Lesbarkeit und Debugging des Codes
Zum Problem, herauszufinden, welcher Code bei einem Laufzeitfehler aktiv wird: Genau das ist der Kern dynamischer Code-Injektion
Wie bei anderen dynamischen Features wie shallow-binding oder deep-binding erfolgen Bindungen entlang des Call-Stacks
Dass statische Analyse oder IDE-Sprünge nicht möglich sind, liegt ebenfalls an dieser Dynamik
Ich glaube aber nicht, dass man sich in der Praxis allzu sehr darum kümmern muss
Denn man ergänzt reinen Code nur um Effekte, sodass diese je nach Kontext als reine oder unreine Effekte, als Mock für Tests oder in Produktion unterschiedlich verdrahtet werden können
Das folgt einem ähnlichen Prinzip wie Dependency Injection
Ähnliches lässt sich auch mit traditionellen Monaden umsetzen, aber auch dort muss man letztlich den Call-Stack betrachten, um herauszufinden, wo eine Monade tatsächlich instanziiert wird
Solche Techniken bieten Vorteile, haben aber auch klar erkennbare Kosten
Sie sind nützlich für Tests und Sandboxing, machen aber weniger offensichtlich, was im Code konkret passiert
Jemand berichtet, eine Bachelorarbeit über IDE-Unterstützung für lexikalische Effekte und Handler geschrieben zu haben
Alle oben angesprochenen Punkte seien seiner Meinung nach gut umsetzbar
Link zur Arbeit
Es wird angemerkt, dass es im .NET-Umfeld die Tendenz gibt, Interfaces übermäßig zu verwenden, wodurch man mehrere Schritte braucht, um überhaupt zur Methodenimplementierung zu springen
Oft ist die IDE-Hilfe sogar nutzlos, wenn die Implementierung in einem anderen Assembly liegt
Bei fortgeschrittener Dependency Injection, insbesondere mit Autofac, werden wie bei dynamisch gescopten Variablen in LISP hierarchische Scopes aufgebaut, sodass zur Laufzeit entschieden wird, an welche Instanz ein Service gebunden wird
In diesem Sinne könnte man Effekte als Injektion einer Interface-Instanz wie
ISomeEffectHandlerauffassen, wobei das Auftreten eines Effekts einem Methodenaufruf darauf entsprichtDas konkrete Verhalten des Handlers, etwa Exceptions auslösen oder Logging, wird dann dynamisch durch die DI-Konfiguration bestimmt
Bisher habe man meist das Muster verwendet, Exceptions zu
throwen, aber man könne zu einem Design übergehen, das Effekte über Interfaces explizit macht und die Art der Behandlung vollständig der DI überlässtDinge rund um Iteratoren wie
yieldhabe man sich dabei noch nicht im Detail angesehenDer Punkt, dass nicht sichtbar ist, dass
fooundbarfehlschlagen können, sei gerade der Kern der SacheMan kann Code in direktem Stil schreiben, ohne sich um den Effektkontext kümmern zu müssen
Auch die Suche danach, welcher Code im Fehlerfall ausgeführt wird, gehört zum Wesen der Abstraktion
Welcher Effekt-Handler zur Laufzeit tatsächlich gebunden wird, wird erst später entschieden
Das ist ähnlich wie bei
f : g:(A -> B) -> t(A) -> B, wo man auch nicht im Voraus wissen kann, welcher Code ausgeführt wird, wenngaufgerufen wirdDer Behauptung, statische Analyse sei unmöglich, weil man im Call-Stack nach oben wandern müsse, um den Handler zu finden, wird widersprochen
Tatsächlich sei statische Analyse möglich, und in der IDE könne man etwa mit Funktionen wie „Zum Aufrufer springen“ auswählen, welche Handler verwendet werden
Der „Pseudocode“ von Ante ist sehr beeindruckend
Es wirkt wie eine gelungene Verbindung aus Eigenschaften von Haskell und der Ausdrucksstärke sowie Praxistauglichkeit von Elixir
Es hinterlässt den Eindruck von Haskell für Entwickler
Ich hoffe, dass der Compiler weiter reift
Ich würde gern einmal eine App in Ante bauen
Zur Behauptung, AE (Algebraic Effects) verallgemeinere den Kontrollfluss und könne auch Coroutines implementieren
Ich denke, dass die einfachste Art, AE in einer neuen Sprach-Runtime zu implementieren, tatsächlich darin besteht, Coroutines zu verwenden und die grundlegende
yield/resume-Struktur nur syntaktisch um Effekte zu erweiternFrage, ob ich dabei etwas übersehe
Als zentraler Unterschied zwischen AE und Coroutines wird Type Safety genannt
Bei AE kann man explizit angeben, welche Effekte eine Funktion im Quellcode verwenden darf
Wenn zum Beispiel
query_db(): User can Databasegilt, dann darf die Funktion auf die Datenbank zugreifen und beim Aufruf muss zwingend einDatabase-Handler bereitgestellt werdenDie Einschränkungen dessen, was möglich ist und was nicht, werden so sehr klar sichtbar
Ähnlich wie Server Components in NextJS nicht direkt Client-Funktionen verwenden können, sind solche Sicherheitsbeschränkungen in vielen Bereichen beliebt
Effect-TS kommt dieser Vorgehensweise in JavaScript nahe, also der Nutzung von Coroutines, aber ob das am Ende wirklich eine gute Idee ist, ist unklar
Ähnlich wie bei Dependency Injection im Spring-Framework besteht die Sorge, dass AE sich über den gesamten Code ausbreiten und letztlich nur zusätzliche Komplexität erzeugen
Tatsächlich seien die Vorträge auf den EffectDays über den Einsatz von Effekten im Frontend meist kaum mehr als bedeutungsloser Boilerplate gewesen
AE ist zwar ein faszinierendes Konzept, aber die Last, viele Dinge in Funktionen einzuwickeln, könnte gerade in JavaScript die typische Leichtigkeit des Schreibens beeinträchtigen
Andererseits bietet ein Ansatz wie motioncanvas, der allein mit Coroutines komplexe 2D-Grafikszenarien einfach ausdrückt, klare Vorteile
Passendes Video zu EffectDays
MotionCanvas
Es wird behauptet, dass AE-Handler innerhalb eines Threads Code mehrfach
resumen können, ähnlich wiecall/ccCoroutines dagegen könnten bei jedem
yieldnur genau einmal wiederaufgenommen werdenGerade dieser unsichere Ausführungsfluss mache die Vorhersagbarkeit schwieriger, weshalb man lieber Funktionen bevorzuge, die explizit mehrfach aufgerufen werden können, oder stattdessen Iteratoren und andere Strukturen verwende
Als Programmierabstraktion wirkt das Konzept äußerst attraktiv
Aus der Kernel-Programmierung bei Sun kenne man etwa Aufrufe wie
sleep(foo), nach denen der Code beim Aufwecken durchfoosehr kompakt geschrieben werden kannDie Last, allerlei Edge Cases einzeln über den Kontrollfluss behandeln zu müssen, werde dadurch geringer
Wenn man nur auf Fragen der Speicherlokalität achte, könnte es sogar Spaß machen, viele Funktionen vorab in einen wartenden Zustand zu versetzen und den Algorithmus direkt als Mutation dieser Einheiten auszudrücken
Zur Aussage „Algebraic Effects sind wie Exceptions, die fortgesetzt werden können“
Es wird gefragt, worin sie sich praktisch von Typklassen wie
ApplicativeErroroderMonadErrorunterscheidenDie Art, Effekte anzugeben, die eine Funktion nutzen darf, erinnere an checked exceptions, und auch die Behandlung über
handle-Ausdrücke sei fast identisch mittry/catchSolche Typklassen unterstützen bereits heute über
handleErroroderhandleErrorWithdas Abfangen von FehlernAlgebraic Effects hätten angeblich Vorteile für die „Sprachen der Zukunft“, seien aber im Grunde ein Konzept, das schon heute gut genutzt werde
Erklärung in cats
Wenn man nur einen einzelnen Effekt betrachtet, gibt es vielleicht keinen großen Unterschied, aber sobald mehrere Effekte gleichzeitig gebraucht werden, ist direkte Effektunterstützung viel sauberer und intuitiver als explizit verschachtelte Monaden
Beim Kombinieren von Monaden entstehen schnell lästige Probleme mit der Reihenfolge oder mit Funktionen, deren Ergebnisse nicht zu der erwarteten Monadenmenge passen und deshalb umsortiert werden müssen
Persönlich halte ich Monaden und Effekte nicht für konkurrierende Ansätze, sondern eher für komplementäre Interpretationen
Siehe dazu auch entsprechende Arbeiten, etwa das Koka-Paper
Algebraic Effects arbeiten wie delimited continuations auf dem Programm-Stack
Mit einem bloßen Monadentrick kann man nicht einfach sofort zu einem Effekt-Handler fünf Stack-Frames weiter oben springen, dort nur lokale Variablen ändern und dann wieder fünf Frames nach unten zurückkehren
Der Unterschied liege im statischen versus dynamischen Verhalten
Beim Programmieren mit Monaden müsse man alle relevanten Methoden selbst implementieren, während man in einem Effektsystem zu jedem Zeitpunkt dynamisch Effekt-Handler installieren und bestehende Handler flexibel überschreiben könne
Für Tests könne man zum Beispiel weiter unten eine spezielle Monade mit IO-Eigenschaften verwenden und nur darunter Effekt-Handler installieren, also auch komplexere Schichtungen aufbauen
Die Ähnlichkeiten sind groß, aber bei der Usability gibt es Unterschiede
Algebraic Effects ähneln „free“-Monaden, sind aber eingebaut und dadurch syntaktisch einfacher und in der Komposition stärker
In monadenzentrierten Sprachen wie Haskell lässt sich dank Typklassen-Inferenz im
mtl-Stil und eingebauterbind-Syntax oberflächlich ein ähnlicher Effekt erzielenMan habe ursprünglich geglaubt, Algebraic Effects würden nur in statischen Typsystemen behandelt, kürzlich aber gelernt, dass es auch dynamischere Strukturen gibt
Besonders eindrucksvoll seien zwei ältere Texte über eine dynamische Variante von Eff gewesen (der erste, der zweite)
Auch Konzepte wie „parametrisierte Operationen mit verallgemeinerter Arität“ wirken spannend, wenn es darum geht, Abstraktion mit Programmierung zu verbinden
Es wird erwähnt, dass ein altes Konzept zuletzt unter neuem Namen und in neuem Rahmen wieder aufgetaucht ist
Einführung in das LISP Condition System
Erfahrungsbericht zu Algebraic Effects
Jemand hat in einer frühen Alpha von OCaml 5 mit Effects Protohackers gemacht
Das habe Spaß gemacht, aber die Toolchain sei damals etwas unbequem gewesen
Ante vermittle ein ähnliches Gefühl, weshalb man auf die weitere Entwicklung gespannt sei
Ein Typsystem dafür gebe es zwar noch nicht, aber inzwischen wirke alles wesentlich aufgeräumter
Nach viel Zeit mit Prolog sucht jemand nach einer Sprache, die nichtdeterministische Funktionskomposition und Compile-Time-Type-Checks leicht ermöglicht
Ante sei dafür einer der interessanten Kandidaten
Der Hinweis folgt, dass man Entwicklerwerkzeuge und Editor-Plugins wie LSP und tree-sitter nicht vergessen dürfe
Für eine neue Sprache sei Tooling von Anfang an unverzichtbar
Weil auch die Debugging-Erfahrung wichtig sei, denke man zudem darüber nach, zumindest im
debug modestandardmäßig Replayability zu unterstützenZur Aussage „Algebraic Effects sind wie Exceptions, die fortgesetzt werden können“
Es wird gefragt, ob das Common Lisp Conditions ähnelt
Interessant sei, dass alte Konzepte oft nur unter neuem Namen wieder auftauchen
Algebraic Effects sind viel allgemeiner als das LISP Condition System
Weil Continuations multi-shot sein können, ähnelt das eher
call/ccin SchemeEs wird aber auch erwähnt, dass diese Form von Parallelität unter Umständen schlechter sein kann, als sie gar nicht zu haben
In Smalltalk gibt es „resumable exceptions“
Wer Effekte nur als umbenanntes altes Condition System betrachtet, kommt in der Diskussion kaum weiter
Die heute diskutierten Algebraic Effects unterscheiden sich in mehr als nur der Begrifflichkeit
Auch Dependency Injection kann in diesem Zusammenhang erwähnt werden