Warum wir algebraische Effekte brauchen
(antelang.org)- Algebraische Effekte sind ein Sprachfeature, das Kontrollfluss wie fortsetzbare Ausnahmen abfängt und behandelt. Sie sind ein Kernfeature von Ante und spielen auch in Forschungssprachen wie Koka, Effekt, Eff und Flix eine zentrale Rolle.
- Mit demselben Mechanismus lassen sich Generatoren, Ausnahmen, async, Koroutinen und automatische Differenzierung auf Bibliotheksebene umsetzen; dank Effekt-Polymorphie müssen Funktionen wie
mapunabhängig von der Art des Effekts nur einmal geschrieben werden. - Wenn man Dependency Injection und Kontextweitergabe wie Datenbankzugriff, Ausgabe, Logging oder Zustandsübergabe in Effekte umwandelt, lassen sich Test-Mocks, Ausgabesammlung und Log-Filterung durch Austausch von Handlern erledigen.
- Werden Effekte wie
can IO,can Printodercan Failin Funktionssignaturen sichtbar, ist das nützlich für Reinheitsgarantien, Aufzeichnen/Wiedergeben und Security Audits; bereits erlaubte Effekte können aber unbeabsichtigt an bestehende Handler weitergereicht werden. - Eine traditionelle Schwäche waren Effizienzbedenken, doch neuere Sprachen reduzieren die Kosten durch Optimierung tail-resumptiver Effekte, Evidence Passing, Beschränkung auf ein einzelnes
resumeund Handler-Spezialisierung.
Das Grundmodell algebraischer Effekte
- Algebraische Effekte werden auch Effect Handlers genannt und lassen sich als Modell „fortsetzbarer Ausnahmen“ verstehen.
- In Ante-Pseudocode deklariert man Effektfunktionen und kennzeichnet in der Funktionssignatur mit
can, dass der entsprechende Effekt verwendet werden kann.- Der Aufruf einer Effektfunktion wie
say_message: Unit -> Unitentspricht dem „Werfen“ eines Effekts. - Die aufrufende Funktion macht die Möglichkeit, diesen Effekt zu verwenden, in der Signatur sichtbar, etwa
foo () can SayMessage.
- Der Aufruf einer Effektfunktion wie
- Ein
handle-Ausdruck fängt Effekte ähnlich wietry/catchab und setzt die unterbrochene Berechnung mit einem Aufruf vonresumefort.- Wenn der
say_message-Handlerprint "Hello World!"ausführt und anschließendresume ()aufruft, läuft die ursprüngliche Berechnung weiter und gibt42zurück.
- Wenn der
- Der Begriff „algebraic“ ist größtenteils historisch übrig geblieben. Tatsächlich ist „Effect Handlers“ meist die genauere Bezeichnung, doch wegen der verbreiteten Vertrautheit wird hier der Name algebraische Effekte verwendet.
Benutzerdefinierter Kontrollfluss
- Algebraische Effekte ermöglichen es, mehrere Sprachfeatures mit einem einzigen Mechanismus zu implementieren.
- Generatoren
- Ausnahmen
- async
- Koroutinen
- automatische Differenzierung
- Effekt-Polymorphie reduziert das Problem what color is your function.
map (input: Vec a) (f: a -> b can e): Vec b can edrückt aus, dassmapdenselben Effekt ausführt wie die Eingabefunktionf, egal welchen Effektediese ausführt.- Dasselbe
mapkann zusammen mit stdout-Ausgabe, asynchronen Funktionsaufrufen, Stream-yieldusw. verwendet werden. - Viele Sprachen mit Effect Handlern erlauben es, die Effektvariable
ewegzulassen und die vertraute Formmap (input: Vec a) (f: a -> b): Vec bzu schreiben.
- Ausnahmen lassen sich implementieren, indem man beim Behandeln eines Effekts
resumenicht aufruft.- Dafür definiert man
throw: a -> never_returnsfür den EffektThrow a. - Bei Division durch null wird
throw "error: Division by zero!"aufgerufen; der Handler gibt die Nachricht aus und setzt die Berechnung nicht fort.
- Dafür definiert man
- Generatoren lassen sich mit
yield: a -> Unitfür den EffektYield aimplementieren.- Beim Durchlaufen von Vektorelementen wird
yield elemaufgerufen. - Der
filter-Handler ruft, wenn der ge-yield-ete Wert die Bedingung erfüllt, erneutyield xauf und fährt mitresume ()beim nächsten Element fort. - Der
my_for_each-Handler führt für jeden ge-yield-eten Wert die Funktionfaus und fährt mitresume ()fort.
- Beim Durchlaufen von Vektorelementen wird
- Auch ein kooperativer Scheduler lässt sich mit einem Effekt
yield: Unit -> Unitbauen; der Handler übernimmt die Kontrolle und wechselt zur Ausführung einer anderen Funktion.- Das Scheduler-Beispiel von Effekt zeigt dieses Muster.
- Mehrere Effekte lassen sich gut miteinander komponieren; das gilt als Vorteil, der die Nutzbarkeit gegenüber anderen Effektabstraktionen erhöht.
Dependency Injection und Testbarkeit
- Effekte können auch in gewöhnlichen Business-Anwendungen für Dependency Injection verwendet werden.
- Statt ein Datenbankobjekt direkt als Funktionsargument zu übergeben, kann man einen
Database-Effekt definieren.- Die bisherige Form nimmt ein DB-Objekt als Argument entgegen, etwa
business_logic (db: Database) (x: I32). - Die effektbasierte Form wird zu
business_logic (x: I32) can Database; intern wirdquery "..."aufgerufen.
- Die bisherige Form nimmt ein DB-Objekt als Argument entgegen, etwa
- Die Wahl der konkreten Datenbank übernimmt ein Handler weiter oben im Call Stack.
- So kann die Produktions-DB gegen eine andere DB oder eine Mock-DB für Tests ausgetauscht werden.
- Ein
mock_database-Handler kannquery-Nachrichten ignorieren und mitresumestetsDbResponse.Okzurückgeben.
- Wird auch Ausgabe als Effekt behandelt, kann sie während Tests als String gesammelt werden, statt direkt auf stdout zu schreiben.
- Der
print_to_string-Handler fängtprint msg-Aufrufe ab und hängt sie mit Zeilenumbrüchen an den Stringall_messagesan. output_messageskann den Rückgabewert1234und den Nachrichten-String prüfen, ohne tatsächlich etwas auszugeben.
- Der
- Logging kann mit einem
Log-Effekt undLogLevelin bedingte Ausgabe umgewandelt werden.log_handlerruftprint msgauf, wenn der Level der Nachricht mindestens dem gesetzten Schwellwert entspricht.foo () with log_handler Errorgibt nur Fehlerlogs aus.
Sauberere APIs und Kontextweitergabe
- Algebraische Effekte können das Context-Objekt-Pattern, das durch ein Programm oder eine Bibliothek weitergereicht wird, als Effekt ausdrücken.
- Der Effekt
Use akann als Zustandseffekt betrachtet werden und stelltget: Unit -> asowieset: a -> Unitbereit.- Der
state-Handler hält den Anfangszustand, gibt beigetden aktuellen Kontext zurück und aktualisiert beisetauf den neuen Kontext. - Die beispielhafte
state-Definition ignoriert Ownership-Regeln; in einer realen Implementierung kann eineCopy a-Beschränkung nötig sein.
- Der
- Ein Beispiel, bei dem Strings in einem Vektor gespeichert und Indizes als Schlüssel weitergereicht werden, zeigt die Kosten der Kontextweitergabe.
- Ohne Effekte müssten
push_string,get_string,append_with_separator,exampleusw.stringsständig als Argument entgegennehmen. - In der effektbasierten Implementierung rufen die primitiven Operationen
push_stringundget_stringget/setauf, und höherer Code mussstringsnicht direkt weiterreichen.
- Ohne Effekte müssten
- Dieser Ansatz passt gut, wenn eine Bibliothek die interne Kontextweitergabe kapselt.
- Nutzer der Bibliothek müssen sich nicht um interne Details der Kontextweitergabe kümmern.
- Wer nicht an einen bestimmten Kontexttyp gebunden sein will, kann die benötigten Funktionen über ein Interface abstrahieren.
Ersatz für globale Variablen und Direct Style
- APIs, die äußerlich zustandslos wirken, aber tatsächlich Zustand benötigen, etwa Zufallszahlenerzeugung oder Speicherallokation, lassen sich statt über globale Variablen als Effekte ausdrücken.
- Das Beispiel der Zufallszahlenerzeugung zeigt die Last, ein
Prng-Objekt durch das ganze Programm direkt weiterreichen zu müssen.- Ein globales
Prngist bequem, bringt aber Nachteile globaler Werte mit sich, etwa die Notwendigkeit von Thread-Sicherheit. - Mit
random: Unit -> U8des EffektsRandommüssen Nutzer lediglich irgendwo weiter oben im Call Stack die Initialisierung per Handler angeben. - Soll später auf
/dev/urandomoder eine andere Zufallsquelle umgestellt werden, reicht es, den Handler auszutauschen; der übrige Code im Call Stack muss nicht geändert werden.
- Ein globales
- Auch Speicherallokation kann als
Allocate-Effekt ausgedrückt werden.allocate: (size: Usz) -> Alignment -> Ptr afree: Ptr a -> Unit- Für die meisten Aufrufe verwendet man den globalen Allocator; in einem tight loop kann man dem Schleifenrumpf einen Handler hinzufügen und auf einen Arena-Allocator wechseln.
- Effekte ermöglichen Direct Style, statt Ergebnisse in dedizierte Wrapper-Werte zu packen und weiterzureichen.
- Bei
Maybe tmuss man den Erfolgsfall mitand_thenundmapfortsetzen. - Syntax Sugar wie Rusts
?dient dazu, sich auf den guten Pfad zu konzentrieren. - Effektbasiertes
get_line_from_stdin (): String can Fail, IOundparse (s: String): U32 can Failwerden wie gewöhnlicher sequenzieller Code geschrieben:line = ...,x = ...,x * 2.
- Bei
- Fehlerbehandlung kann durch Anwenden eines Handlers modelliert werden, der den guten Pfad verlässt.
get_line_from_stdin () with default "42"behandelt denFail-Effekt mit einem Standardwert.
- Auch unterschiedliche Fehlertypen komponieren sich auf natürliche Weise als Effektliste.
LibraryA.foo (): U32 can Throw LibraryA.ErrorLibraryB.bar (): U32 can Throw LibraryB.Errormy_functionkannThrow LibraryA.Error,Throw LibraryB.ErrorundThrow MyErrorgemeinsam deklarieren.- Wird die Wiederholung zu lang, kann man einen Typalias wie
AllErrors = can Throw ...anlegen. - Derselbe Effekt
Throw Stringwird zu einem zusammengeführt; wenn man sie trennen möchte, braucht man einen Wrapper-Typ wieMyError.
Reinheit, Wiederholbarkeit und Security Audits
- Die meisten Sprachen mit Effect Handlern verwenden Effekte dort, wo Seiteneffekte auftreten können; OCaml ist hier ungefähr die Ausnahme.
- In Ante können Seiteneffekte nicht verwendet werden, wenn sie nicht mit
can Print,can IOusw. markiert sind. extern-Definitionen kann der Compiler nicht prüfen, daher muss man den Typdefinitionen vertrauen.- Die Möglichkeit, einen
IO-Effekt nur im Debug-Modus auszuführen, um die Effektsicherheit im Release-Modus zu erhalten, ist ein geplantes Feature.
- In Ante können Seiteneffekte nicht verwendet werden, wenn sie nicht mit
- Manche Funktionen verlangen als Eingabe reine Funktionen.
- Beim Erzeugen eines Threads darf der erzeugte Thread nicht Handler aufrufen können, die dem aktuellen Thread gehören.
spawn_all (functions: Vec (Unit -> a pure)): Vec a can IOnimmt nur reine Funktionen entgegen, führt alle als Threads aus und wartet auf ihren Abschluss.
- Software Transactional Memory (STM) ist eine Nebenläufigkeitstechnik, die reine Funktionen benötigt.
- Mehrere Funktionen werden gleichzeitig ausgeführt; wenn während einer Transaktion ein Wert von einem anderen Thread geändert wird, wird die Transaktion neu gestartet.
- Eine Proof-of-Concept-Implementierung in Effekt findet sich unter effekt-stm.
- Reinheit kann ähnlich wie das Debugging-Tool
rrMöglichkeiten zum Aufzeichnen/Wiedergeben bieten.- Zwei Handler,
recordundreplay, behandeln die Top-Level-Effekte, diemainausgibt, typischerweiseIO. recordzeichnet das Auftreten von Effekten und ihre Ergebnisse auf und leitet sie zur tatsächlichen Behandlung wieder an den eingebautenIO-Handler weiter.replayführt kein echtesIOaus, sondern verwendet die Ergebnisse aus dem Effektlog.- Wenn Debug-Builds standardmäßig aufzeichnen, erhält man deterministisches Debugging.
- Zwei Handler,
- Die Effektliste in Funktionssignaturen hilft ähnlich wie Capability Based Security bei Security Audits.
- Bei
get_pi: Unit -> F64weiß man, dass die Funktion nicht heimlich im HintergrundIOausführt. - Wird sie nach einem Bibliotheksupdate zu
get_pi: Unit -> F64 can IO, bekommt der aufrufende Code einen Fehler, sofern er nicht bereits selbstIOverlangt. - Es ist wünschenswert, nur die minimal nötigen Effekte zu deklarieren, etwa nur
Printstatt des gesamtenIO. - Das Hinzufügen eines neuen Effekts gilt als Änderung, die Semantic Versioning bricht.
- Verwandtes Material sind Capability Based Security und Designing with Static Capabilities and Effects.
- Bei
Grenzen und Implementierungsstrategien
- Eine Grenze des Effektansatzes ist, dass unbeabsichtigte Behandlung möglich ist.
- Wenn eine Funktion neu
IOverlangt, muss das keinen Fehler auslösen, falls die aufrufende FunktionIObereits erlaubt. - Dasselbe gilt für den
Fail-Effekt: Wenn eine Bibliotheksfunktion, die früher nicht fehlschlug, späterFailauslösen kann, kann dies an einen bestehendenFail-Handler weitergereicht werden. - Dieses Verhalten kann je nach Situation in Ordnung sein, aber von der Absicht abweichen, wenn man eine separate Behandlung wie das Bereitstellen eines Standardwerts wollte.
- Wenn eine Funktion neu
- Der klassische Hauptnachteil waren Effizienzbedenken, doch die Kompilierungsergebnisse neuerer Effektsprachen haben sich stark verbessert.
- Viele Sprachen mit algebraischen Effekten optimieren tail-resumptive Effekte zu gewöhnlichen Closure-Aufrufen.
- Tail-resumptive Effekte sind Effekte, bei denen der Handler ganz am Ende
resumeaufruft. - Die meisten realen Effekte fallen in diese Kategorie, ebenso die meisten Beispiele im Text.
- Ausnahmen gelten als Sonderfall, weil sie
resumeüberhaupt nicht aufrufen.
- Tail-resumptive Effekte sind Effekte, bei denen der Handler ganz am Ende
- Die Optimierungsstrategien unterscheiden sich je nach Sprache.
- Koka verwendet Evidence Passing, zieht Effekte bis zum Handler hoch und kompiliert ohne Runtime nach C.
- Ante und OCaml beschränken
resumedarauf, höchstens einmal aufgerufen zu werden.- Diese Beschränkung schließt einige Effekte wie Nichtdeterminismus aus.
- Dafür vereinfacht sie die Ressourcenbehandlung und ermöglicht es, interne Continuations mit Ansätzen wie segmented stacks effizienter zu implementieren.
- Effekt spezialisiert Handler vollständig im Programm und entfernt sie.
- Dieser Ansatz bringt die Einschränkung mit sich, dass die meisten Funktionen second-class werden.
- In boxed Form lassen sich first-class Funktionen erhalten und auf ein Pay-as-you-go-Modell umstellen.
- Verwandtes Material sind die Effekt-Dokumentation zu captures und das Paper.
Noch keine Kommentare.