Prism: Eine nicht-reine funktionale Sprache mit typisierten Effekten
(stephendiehl.com)- Prism ist ein experimenteller Compiler, der Effekte wie veränderliche Variablen, Exceptions und Streams nicht verbirgt, sondern im Typ sichtbar macht, während lokal nicht von außen beobachtbare Änderungen weiterhin reine Typen wie
Int -> Intbehalten - Im Kern stehen algebraische Effekt-Handler und Row Polymorphism: Effekte werden in die Zeile des Funktionstyps zusammengeführt, und Handler behandeln nur die benötigten Labels und reichen den Rest weiter
- Dasselbe Effektsystem beschreibt Exceptions, Generatoren/Streams, Lenses,
var-Zustand undfail/guard-Kontrollfluss; einige Pfade werden ohne Zwischenlisten oder Laufzeit-Komposition lowered - Auf der Performance-Seite kombiniert Prism Evidence Passing mit Perceus-Referenzzählung, um Allokationen pro Effektaufruf zu vermeiden, und optimiert funktionale Updates eindeutig besessener Werte zu In-Place-Änderungen
- Prism liefert LLVM IR, MLIR, eine C-Runtime, einen Rust-Interpreter, ein Lean-4-Modell und einen WASM-Playground mit, sodass sich Typinferenz und Lowering-Ergebnisse direkt prüfen lassen
Von außen unsichtbare Änderungen und typisierte Effekte
- Ausgangspunkt von Prism ist die Beobachtung, dass auch eine
fib-Funktion, die internvarund Zuweisungen nutzt, für externe Beobachter wie eine reine Funktion aussehen kann- Das Beispiel
fibaktualisiert zwei Variablen in-place, gibt aber keinen Zustand nach außen preis - Der Typ ist
Int -> Int, und der Effekt erscheint nicht im Typ
- Das Beispiel
- Prism ist ein in den letzten drei Jahren entwickelter Proof-of-Concept-Compiler für funktionale Programmierung, der Effekte auf Basis moderner Typideen aus OCaml 5, Haskell und der Koka-Familie modelliert
- Die zentrale Richtung besteht nicht darin, Effekte zu vermeiden, sondern sie ins Typsystem aufzunehmen und den Compiler so zu optimieren, dass die Kosten verschwinden
Effekte verhalten sich wie Interfaces
- Effekte in Prism folgen dem Ansatz algebraischer Effekt-Handler: Ein Effekt deklariert Operationen, und ein Handler gibt diesen Operationen ihre Bedeutung
effect Gen { ctl yield(Int) : Unit }deklariert die Operationyield!{Gen}infn produce(n) : !{Gen} Unitkennzeichnet im Typ, dass die Funktionyieldausführt
- Selbst derselbe Producer wird je nachdem, wie die Continuation
kbehandelt wird, unterschiedlich interpretierttotalsummiert dieyield-Wertecountzählt die Anzahl deryield-Aufrufe- Wird
kverworfen, ergibt sich eine Exception; wird sie einmal aufgerufen, entstehen Zustand oder Generator; wird sie mehrfach aufgerufen, entsteht Suchverhalten
- Das
Amb-Effektbeispiel beschreibt die Suche nach pythagoreischen Tripeln mitchooseundrejectchoose(n)liefert Werte im Bereich0..n-1- Der Handler setzt dieselbe Continuation für jeden Kandidaten fort und erzeugt so einen Suchbaum
- Anders als OCaml 5 nimmt Prism Effekte in den Typ auf, ohne dass man wie bei Haskells Monad-Transformer-Stacks manuell Schichten hochziehen muss
- Effektzeilen werden über Aufrufe hinweg strukturell zusammengeführt
- Sie verhalten sich nicht wie ein Stack, sondern wie eine Menge von Labels
Funktionen, die mit einem einzigen Mechanismus ausgedrückt werden
-
Exceptions
- Exceptions sind in Prism Handler, die die Continuation verwerfen
- Eine
final ctl-Klausel verwirftkund verwendet den Wert ihres Bodys als Handler-Ergebnis - Das ist kein Ansatz, bei dem
Resultweitergereicht oder?über den Call Stack verstreut wird - Da Exceptions Labels in der Effektzeile sind, werden sie als erweiterbare Exceptions komponiert
- Jeder Fehler ist eine eigene Operation, und die Zeile im Funktionstyp zeigt an, welche Exceptions nach außen gelangen können
- Im Beispiel mit
AbortundTimeouthatfetchden Typ!{Abort, Timeout} with_defaultbehandelt nurTimeout, gibt als Fallback"cached"zurück, und nach der Behandlung bleibt im Typ nur!{Abort}übrig- Anders als Javas checked exceptions sind sie nicht an eine Klassenhierarchie gebunden, sondern funktionieren als offene strukturelle Menge
-
Generatoren und Streams
- Ein Stream besteht aus einem Producer, der
emitausführt, aus Transformern, die dies abfangen und erneut ausgeben, und aus Consumern, die folden - Die Pipeline ist eine Schachtelung von Handlern um einen Producer herum, sodass strukturell keine Zwischenlisten entstehen
- Beispiel:
srange(1, n).smap(square).skeep(even).stake(5).ssum() - Frühes Abbrechen wie bei
stake(5)ist ein Handler, der nach Erhalt der benötigten Werte die Continuation verwirft - Die Stream-Bibliothek ist von Haskells pipes und conduit beeinflusst
- Ein Stream besteht aus einem Producer, der
-
Lenses
- Lenses in Prism sind keine separate Bibliothek, sondern eine Kombination aus Record-Update-Pfaden und Speichermodell
- In verschachtelten Records lassen sich mit einem einzigen Pfadausdruck mehrere tiefe Felder aktualisieren
- Beispiel:
{ g | player.pos.x = 30, player.hp = 95, score = 110 } - Die Spine des verschachtelten Records wird neu aufgebaut; wenn ein Wert eindeutig besessen ist, werden die zerlegten Zellen wiederverwendet
- Durch diese Struktur können funktionale Updates zu Pointer-Schreibvorgängen kompiliert werden
- Zur Laufzeit werden keine Optic-Typen alloziert oder komponiert
deriving (Lens)erzeugt gewöhnliche Funktions-Accessors wiescore_ofundwith_score
-
Veränderlicher Zustand
varwird zuget/set-Operationen eines privaten Effekts desugared- Ein am Ende des Blocks installierter Handler behandelt diesen Effekt
- Escape-Analyse für Closures weist Fälle zurück, in denen Zustand nach außen entweicht
- Die umschließende Funktion kann eine leere Effektzeile behalten
-
Fehlschlag
- Fehlschlag wird als anonymer
Fail-Effekt dargestellt; das Typsystem behandelt damit „dieser Ausdruck erzeugt möglicherweise keinen Wert“ fail()führt einen Fehlschlag aus, undguard(cond)schlägt fehl, wenn die Bedingung falsch ist??verwendet einen Fallback, wenn die linke Seite fehlschlägt?.bricht beim Durchlaufen einer Optionskette früh ab- Ein Comprehension-Guard beschneidet fehlgeschlagene Elemente, statt einen Crash auszulösen
- Da auch
varHandler-Sugar ist, kann eintransact-Block Live-Variablen snapshotten und bei Fehlschlag zurückrollen
- Fehlschlag wird als anonymer
Moderne Typ-Features
- Prism nutzt bidirektionale Higher-Rank-Typinferenz nach Dunfield-Krishnaswami, damit in den meisten Codeabschnitten keine Typsignaturen geschrieben werden müssen
- An Grenzen, an denen Higher-Order-Polymorphie nötig ist, werden Binder mit
forallexplizit gemachtpick(g : forall a. (a) -> a)kanngsowohl aufBoolals auch aufIntanwenden- In einem Damas-Milner-Kern würde
abeim ersten Aufruf mitBoolunifiziert, sodass der zweite Aufruf abgelehnt würde
- Ad-hoc-Polymorphie wird durch Type Classes im Lean-Stil dargestellt
- Instanzen sind benannte Werte
given Ord(a)verlangt ein Dictionary- Gibt es mehrere Instanzen, wird eine als
canonicalmarkiert, die anderen werden mitusingexplizit angegeben
derivingerzeugt wiederkehrende Instanzen und Feld-Accessors wieEq,Ord,ShowundLens- Auch Pattern Matching ist mit Klassen verknüpft
pattern First(n) for Peek = view peekist ein Pattern, das eine Klassenmethode als View nutzthead_orkann dasselbe Pattern für mehrere Typen verwenden, die einePeek-Instanz haben
showfunktioniert typorientiert, ohne separate Klasse- Der Compiler synthetisiert aus dem statischen Typ einen strukturellen Printer
- Er liest keine Laufzeit-Typ-Tags, um die Ausgabeform zu bestimmen
- Auch Effektzeilen sind polymorph
fn twice(f : (Unit) -> Int ! {| e})addiert die Ergebnisse der übergebenen Funktion, unabhängig davon, welche Effektzeileehat- Bei einem reinen Thunk wird
emit der leeren Zeile{}unifiziert - Bei einem Thunk, der Effekte wie
TickoderSayausführt, wird diese Zeile unverändert weitergereicht
Kompilierungsansatz zur Senkung der Effektkosten
- Lehrbuchimplementierungen algebraischer Effekte rekonstruieren Berechnungen oft als Baum in Form einer Free Monad und können pro Operation eine kleine Zelle allozieren
- Prisms schneller Pfad ist Evidence Passing aus der Koka-Familie
- Statt Berechnungen zu rekonstruieren, um Handler zu finden, werden aktive Handler-Klauseln wie normale Parameter an jede Operationsstelle übergeben
do opwird zu einem direkten Aufruf lowered- Beim Installieren eines Handlers wird nur eine Closure alloziert; die Kosten sind daher
O(handlers)und nicht proportional zur Anzahl der Operationen
- Die Free-Monad-Codierung bleibt als Fallback erhalten
- Berechnungen, die in Datenstrukturen entweichen
- Echte Multishot-Resumptions
- Muster wie masked Handler
- Stream-Pipelines berechnen mit interprozeduraler Flow Analysis die benötigte Effect Evidence über Funktionsgrenzen hinweg
- Evidence wird durch Producer und Transformer durchgereicht
- Die ganze Kette wird zu einer einzelnen Schleife lowered
- Es gibt weder Zwischenlisten noch Zellallokationen pro Operation
- Dieser Ansatz erzielt in einem Effekt-Compiler eine Struktur mit ähnlichen Ergebnissen wie Haskells Stream Fusion oder die Vereinheitlichung von Rust-Iterator-Adaptern
Speichermodell und Runtime
- Prism verwendet Perceus-Referenzzählung
- Heap-Zellen werden an statisch bekannten Punkten deterministisch freigegeben
- Es gibt keine Pausen und kein Tracing
- Frame-limited Reuse reicht eine gerade per Pattern Match zerlegte Zelle an die Konstruktorseite zurück
mapüber einer eindeutig besessenen Liste sieht wie eine reine Funktion aus, die eine neue Liste zurückgibt, kann tatsächlich aber in-place ändern- Dass Lens-Updates zu Pointer-Schreibvorgängen kompiliert werden, basiert auf demselben Mechanismus
- Die Runtime-Struktur ähnelt den Speicherregeln von Lean 4, doch Prism gibt LLVM IR aus
- LLVM IR wird über
inkwellerzeugt - Für dasselbe Programm wird außerdem ein textuelles MLIR-Modul erzeugt
- Das Ergebnis wird mit
clanggegen die handgeschriebene C-Runtimeprism_rt.cgelinkt
- LLVM IR wird über
prism_rt.cist eine kleine Runtime von rund 2.000 Zeilen- Heap-Zellen haben die Form
{ refcount, tag, arity, fields... }und bestehen aus mindestens vier Words - Enthalten sind Allocator,
rc_inc/rc_dec, ein Allocator für In-Place-Reuse sowie Primitive für Bignums und Strings - Es gibt keinen Collector Thread, keine Card Table und keine Safepoints
- Heap-Zellen haben die Form
- Der Standard-Allocator ist
mallocaus libc; für Benchmarks gibt es eine opt-in-mimalloc-Konfiguration - Ein Live-Cell-Oracle assertet beim Beenden, dass die Heap-Balance 0 ist, damit die Testsuite die garbage-free-Eigenschaft prüft
Lean-Modell und Backend-Verifikation
- Der Prism-Interpreter gehört zur Familie der CEK-Maschinen, und diese Maschine ist im Lean-4-Modell
models/Prism.leanabgebildet - Das Lean-4-Modell enthält einen maschinengeprüften Satz, dass die Small-Step-Relation deterministisch ist
- Ein Programm geht in genau einen nächsten Zustand über
- Der Interpreter der Rust-Implementierung wird auch als differential Oracle verwendet
- Alle Programme im Corpus müssen den Interpreter sowie die Backends für LLVM, MLIR und C-linked binary durchlaufen
- Die Ausgaben der vier Ergebnisse müssen byte-identical sein, damit der Build besteht
- Determinismus ist die Grundlage für replayable durable execution
- Die Idee ist, dass bei fixierten Eingaben und aufgezeichneter Ausführung eine bitgenaue Wiedergabe möglich ist
- Für künftige Versionen ist ein Prism skizziert, das Replay-Sicherheit nach einem Crash als Typeigenschaft prüft
WASM-Playground und Quellcode
- Derselbe Interpreter wird nach WASM kompiliert und kann im Playground ohne Installation Prism-Code ausführen
- Der Playground führt Programme in einem Worker aus
- Per Button lassen sich inferierte Typsignaturen, Effect Row und lowered Core IR ausgeben
- So lässt sich sehen, in welche Form
var-Schleifen oder Stream-Pipelines tatsächlich lowered werden
- So lässt sich sehen, in welche Form
- Die Beispiele aus dem Text sind im Dropdown enthalten
- Der gesamte Quellcode, das Lean-Modell und die C-Runtime befinden sich im Prism-Repository auf GitHub
Implementierungslinie und Projektcharakter
- Prisms Core IR gehört zur Call-by-Push-Value-Familie und basiert auf Levys Call-by-Push-Value: A Functional/Imperative Synthesis
- Der schnelle Effektpfad liegt nahe an Xie und Leijens Generalized Evidence Passing for Effect Handlers, Effect Handlers, Evidently
- Das Speichermodell basiert auf Perceus: Garbage Free Reference Counting with Reuse, Reference Counting with Frame-Limited Reuse und FP^2: Fully in-Place Functional Programming
- Effektzeilen gehören zu Row Polymorphism und Scoped Labels; die Handler sind eine Form, die Plotkin und Pretnars Handlers of Algebraic Effects über Eff und Koka aufgenommen hat
- Pattern Matching basiert auf Decision Trees und Usefulness Matrix; die
pattern-Form kombiniert View Patterns mit GHCs Pattern Synonyms - Die Fehlschlag-Schicht ist eine Rückgewinnung von The Verse Calculus als
final ctl-Handler - Prisms Richtung liegt weniger bei „rein funktional“ als bei Effekte sichtbar machen, typisieren und komponierbar nachverfolgen
- Das Projekt selbst ist weniger ein praktisches Werkzeug als ein Spielzeug und künstlerisches Werk; zusammengefasst ist es ein Compiler, der aus Freude an der intellektuellen Schönheit funktionaler Programmierideen gebaut wurde
1 Kommentare
Kommentare auf Lobste.rs
Ich verstehe nicht, was Lenses mit Effekten zu tun haben. Jedes Mal, wenn der Artikel Lenses erwähnt, wirken sie, abgesehen davon, dass sie unter „ein Trick auf fünf Arten“ zusammengefasst werden, ziemlich unabhängig voneinander.
Und ich verstehe auch nicht, was
tick_use()überhaupt tun soll. Erwartet man, dass Leser ein so verknotetes Beispiel ohne Erklärung verstehen? Typannotationen hätten vermutlich geholfen.Trotzdem sehe ich, abgesehen von der metaphorischen Ebene, nicht wirklich, was Lenses mit Effekten zu tun haben. Der Autor schreibt:
Ich muss mir mehr Zeit nehmen, um es zu verstehen, aber es sieht wirklich schön aus.
Wirklich beeindruckend. Umso mehr frage ich mich, warum Diehl den Compiler am Ende des Artikels als Spielzeug bezeichnet. Es wirkt wie ein gelungener Proof of Concept, der ein neues Maß an Eleganz zeigt.
Ich würde gern genauer sehen, wie die Call-by-Push-Value-Zwischendarstellung tatsächlich aussieht. Besonders interessiert mich, wie sie Join Points behandelt.
Es gab Arbeiten zur Theorie, Effekte an CBPV zu hängen. Zu sagen, dass Berechnungen Effekttypen haben und Werte nicht, ist ziemlich natürlich; ich habe es aber noch nicht so konkretisiert gesehen, dass es sich direkt auf Kokas Evidence Passing anwenden ließe, daher ist das spannend.
Insgesamt würde ich gern wissen, wo es im Vergleich zu Koka steht. Mit Blick auf FBIP, Perceus und Evidence Passing ist klar, dass es stark von der Koka-Arbeit inspiriert ist, zugleich ist es durch CBPV als Zwischendarstellung auch eindeutig anders. Nur wie anders, ist nicht ganz ersichtlich.
Es sieht sehr nach dem aus, wofür ich mir immer wieder Zeit nehmen wollte, um es zu bauen. Schön!
Etwas off-topic, aber ich fand es immer ein bisschen schade, dass Stephens Projekt „write you a haskell“ vor ein paar Jahren stehen geblieben ist. Für Prism würde ich mir eine Implementierungserklärung auf Tutorial-Niveau wünschen.
Ich frage mich, was an dieser Sprache „impure“ ist. Das Wort taucht nur im Titel auf und danach im Text nicht mehr.
Da alle Effekte nachverfolgt zu werden scheinen, sind effektfreie Funktionen weiterhin mathematische Funktionen. Übersehe ich etwas?
fib-Definition.var xist eine impure veränderliche Variable, währendlet xeine pure unveränderliche Variable ist.Wirklich cool ist, dass Dinge, die normalerweise als Features einer Sprache behandelt werden, etwa
yieldin Sprache X oderthrowin Sprache Y, als eine weitere Schnittstelle implementiert wurden.Dass sich verschiedene Kontrollflüsse wie Nebenwirkungen, Ausnahmen und Continuations auf einer einzigen Abstraktion aufbauen lassen, ist eine interessante Art, die gesamte Designfrage neu zu betrachten. Ich hoffe, dass das mehr Erkundung und Innovation ermöglicht oder anstößt. Ich werde wohl noch über Jahre hinweg darauf zurückkommen, um Inspiration zu finden.