-
Notifications
You must be signed in to change notification settings - Fork 214
Algebraic Effect handlers #2567
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Comments
Haha, someone beat me to it. I have been mulling over creating this issue and whether or not it would even be a good fit for the dart language. A more recent talk from Daan is here: https://www.youtube.com/watch?v=6OFhD_mHtKA One thing that I thought a lot about in relation to algebraic effects in dart is how difficult it would be to capture stacks for delimited continuations which is what effect handlers require. I'm guessing that dart already does this to some degree because of the event loop / async architecture. Additionally dart already does thread some continuation / async context specific state through the Zone machinery. Therefore the runtime machinery might already partially exist. If not, I expect it would be a large undertaking to introduce this into the language. There is a C/C++ library developed by Daan https://github.com/koka-lang/libmprompt that implements the full event registering / stack capturing / switching machinery, but it would still require overhauling how dart handles stacks (again speculation). A concern would be how it plays with the existing async / await, async*, sync*, event loop of dart, though I think this is addressable. Implementation effort aside, a few questions are:
Let me try to address these issues (disclaimer I'm not a language designer, but I'm very interested in language design). Drawbacks:
Advantages:
Does it fit with the language:
Again, not a language designer here, and I'm curious to hear what the dart team thinks of this, but I'm very much interested in algebraic effect handlers, and very much invested in the dart language. It's one of those things like async / await which seems so fundamentally groundbreaking, but will take many years to break into the common programming paradigms. It simplifies so many ad-hoc language concepts / mechanisms into a unified machinery. Very curious how this plays together with dart's Zones. Essentially Zones let you do a limited form of Algebraic Effect Handling currently, by allowing you to for example override the print function. This would just potentially make Zones as a first class concept more generally useful. To cite some issues this is related to: |
So this is like a It requires running the effect handler on top of the stack, instead of eagerly unwinding. That's something I've considered before, and it should not be a problem as such (other than handling stack overflows). As a dual look, rather than a try/catch which can resume, the effect would be a function which can perform an escape. In a typed language, the effect/handler also needs to be typed, so that the handler hands back a value which fits in the hole left by the effect raising expression. Let's say we introduce effects, say declared as: effect AbsInt = int Function(int); We can then introduce a handler like: try {
var n = Random().nextInt(10) - 4; // -4 .. 5
var abs = try AbsInt(n)
print(abs);
} on AbsInt(int x) {
// something
if (x < 0) continue with -x;
} The handler can still do other control flow, like If the handler doesn't Needing to be in a dynamical context where a handler is declared seems like the harder part to ensure. One answer is that it can't, and an unhandled effect is treated just like an exception. It can be caught by The alternative is to somehow declare the effects used by a method, and only allowing you to call the method inside a scope which declares those effects (which means the body of a handler handling the effects, or a function declared to use at least those effects too). It's checked exceptions, basically. Probably a very hard sell. Also, because of I think that should be doable, it'll necessarily cost a little to pass along extra data at each invocation. (Although, on second thought, it's not very clear how it will work if a function calls an |
There is a proposal for a Souch a feature would allow closures to act as an Algebraic Effect handler and it should be compatable with existing code. void example(){
final absInt = (int x) {
// something
if (x < 0) return -x;
returnFrom example;
}
var n = Random().nextInt(10) - 4; // -4 .. 5
var abs = absInt(n)
print(abs);
} |
Allowing functions to do non-local control flow (like returning from a function other than themselves) gets complicated in a language with first class functions. Such a function must either not be allowed to survive the invocation scope where it was created, or you must be able to handle returning twice from the same function. The latter is complicated, confusing, and likely expensive. The invocation stack must be retained after the first time the function returns, in case it returns again. Will the local variable up-stack keep their current value, or be modified (is the stack copied, or reused)? Preventing the function from escaping seems simpler. It'd be something like marking the function as "scoped"/"local", and then transitively ensure that a scoped function is never stored in any place outside of the invocation stack. They can be used as arguments to other functions, but never returned. bool example
final local absInt = (int x) {
if (x < 0) return -x;
example.return false;
}
action(absInt);
return true;
}
void tweakSomeNumbers(local int Function(int) f) {
var n = Random().nextInt(10) - 4;
var fn = f(n);
print(fn);
} Being In good Dart style, we'd make the local-ness part of the runtime type of a local function, which is any local function which uses the non-local control flow feature. We'll still have some issues around Using local functions with non-local control flow seems to be half of the effect feature. The other half is the implicit dynamic scope, which has me worried anyway. Unless doing checked exceptions, it's impossible to know whether there is any surrounding handler. Doing the scoping manually feels better to me (but again, async is a problem). |
OCaml is a language with similar features to Dart and will be releasing v5 with Algebraic Effects any day now.
…On Oct 13, 2022, 05:09 -0700, Lasse R.H. Nielsen ***@***.***>, wrote:
Allowing functions to do non-local control flow (like returning from a function other than themselves) gets complicated in a language with first class functions.
Such a function must either not be allowed to survive the invocation scope where it was created, or you must be able to handle returning twice from the same function.
The latter is complicated, confusing, and likely expensive. The invocation stack must be retained after the first time the function returns, in case it returns again. Will the local variable up-stack keep their current value, or be modified (is the stack copied, or reused)?
Preventing the function from escaping seems simpler. It'd be something like marking the function as "scoped"/"local", and then transitively ensure that a scoped function is never stored in any place outside of the invocation stack. They can be used as arguments to other functions, but never returned.
Say:
bool example
final local absInt = (int x) {
if (x < 0) return -x;
example.return false;
}
action(absInt);
return true;
}
void tweakSomeNumbers(local int Function(int) f) {
var n = Random().nextInt(10) - 4;
var fn = f(n);
print(fn);
}
Being local affects a type, F <: local F for any normal function type F. Anywhere you expect a local function, you can also use a non-local function. It just won't be leaked, even if it doesn't care about that. Which means that in parameter position, contravariantly, Function(local F) <: Function(F), so you can make parameters local in a subclass, but not remove it.
In good Dart style, we'd make the local-ness part of the runtime type of a local function, which is any local function which uses the non-local control flow feature.
We'll still have some issues around async functions. A local function from a synchronous function can probably not be used in an async function, or vice versa. So we have sync local and async local functions.
Using local functions with non-local control flow seems to be half of the effect feature. The other half is the implicit dynamic scope, which has me worried anyway. Unless doing checked exceptions, it's impossible to know whether there is any surrounding handler. Doing the scoping manually feels better to me (but again, async is a problem).
—
Reply to this email directly, view it on GitHub, or unsubscribe.
You are receiving this because you authored the thread.Message ID: ***@***.***>
|
@gisborne Arguing that a language is similar is probably not the right way to address @lrhn's concerns. One of the issues is that to address some of @lrhn's concerns and comments requires deep knowledge in how the event loop and function stacks are implemented in dart. I don't profess to be an expert there. However, to address a few of the design / implementation questions that @lrhn has raised:
I agree. Which is why I think for the dart language in particular it should not allow for multiple resumptions, at least for an initial From the koka documentation about tail resumptive operations: (single resumption).
koka additionally has
I know that this will be a hard sell. I don't like the idea of dynamic algebraic effects, I'd rather see dart adopt typed / statically checked effects. I think that effect inference and effect polymorphism addresses most of the concerns here.
Where the function As far as the async issue which I believe to be the one that would prevent this feature from ever being introduced into dart: There are two ways to approach this. The first way is just to understand how to deal with transitioning between the value and effect world, and maybe adding some restrictions to unawaited futures. In koka it explains how you can convert between the world of values and the world of effects: For example here is a function to convert from an exception (raise) effect to a maybe result.
From this viewpoint, it seems as if converting between and effect of Where async gets tricky is understanding how this fits into the current event loop of dart, and I don't know enough to know what that entails. The other approach is to build dart's async / await on top of the effect handling machinery. That is to implement the event loop in a dart function that automatically wraps the main function in an effect handler that manages the async values and callbacks: An example of this approach is here: https://github.com/koka-lang/koka/blob/8feaf18b58816b9cd08a9b5148b1f3c19a00da6e/lib/std/async.kk#L544. However I believe this approach requires more than just tail call resumptions (line 575). This might mean that async & effect handlers are fundamentally incompatible in a language like dart in which we might not want to allow for resumptions that are not tail-call optimized. Personally I feel that if the Iterable / Async / Await / Exception handling mechanisms of dart can't be swapped out for effect handling runtime machinery + reimplementation of these concepts in the standard library, it might not be worth introducing effect handlers as in the literature. However, I don't think that means that we can't take ideas and inspiration from these concepts. Implicitly passed variables with dynamic scoping would be a restriction that I would personally be happy with. Usages of these variables would infer an implicit variable requirement for anyone calling the function, and anyone calling the function but without providing a value would also infer the requirement, until you get to a function that has an annotation of what effects it expects (and the value is not included in which case it is an error), or it gets to the main function where all implicit variables that are not given must be supplied. This might look something like this. effect MyPrettyPrintOptions {
int maxLineLength;
File? prettyPrintFile;
}
void prettyPrint(String s) { // inferred effect type / requirement [MyPrettyPrintOptions]
if (prettyPrintFile != null){
prettyPrintFile.writeln(s.truncate(maxLineLength));
} else {
print(s.truncate(maxLineLength));
}
}
// Assuming effect types are in [] right after function name. Not sure best design for this.
void extraSpecialPrettyPrint[MyPrettyPrintOptions](String s){
with override MyPrettyPrintOptions(120, File('test.txt')) {
prettyPrint(s);
}
}
void otherFunction() { // inferred effect type / requirement [MyPrettyPrintOptions]
prettyPrint("something");
}
void otherFunction2[]() {
// Error: explicit mention that otherFunction2 has handled all inner effects []
// but is using function prettyPrint which requires a handler for MyPrettyPrintOptions
prettyPrint("something");
}
void main() { // Error MyPrettyPrintOptions not handled
otherFunction("something");
}
// Okay
void main() {
with MyPrettyPrintOptions(80, null) {
otherFunction("something");
extraSpecialPrettyPrint("hi");
}
} Any functions that don't infer an effect or a polymorphic effect, would then be optimized not to pass an implicit parameter. Functions that would require a polymorphic effect such as |
For what it's worth, if Flutter adopted algebraic effects, state management would largely cease to be an issue across the ecosystem. In a breaking change, or perhaps just in a new widget for backwards compatibility, you can do everything via algebraic effects: // Simple counter example:
@effectfulWidget // macro via static metaprogramming
Effect<Widget, SideEffect> myCountingWidget() {
final (count, setCount) = try state(default: 123);
void incrementCount() => setCount(count + 1);
return TextButton(
onPressed: incrementCount,
child: Text('$count'),
);
}
// We can also allow for composition of side effects:
Effect<(int, void Function(), AsyncSnapshot<int>), SideEffect> myPersistedCountSideEffect() {
final (state, setState) = try state<int?>();
final AsyncSnapshot persistedState = try hydrate(state, read: readFromMyDb, write: writeToMyDb);
return (state, () => setState(state + 1), persistedState);
}
// And even easy async:
@effectfulWidget // macro via static metaprogramming
Effect<Widget, SideEffect> myFutureWidget() {
final AsyncSnapshot snapshot = try future(someFuture);
return Text('$snapshot');
} Issue is we would need one "root" side effect type for the type system to be happy ( Edit: I'm just spitballing syntax here, but since Widget myWidget() performs SideEffect { // or maybe "trys" instead of "performs"
// ...
} |
I have to admit, I'm skeptical that this would be a good fit for Dart. I generally love the idea of using the type system to reason about programs better, but there comes a point of diminishing returns and I suspect that algebraic effects are beyond that point for most Dart users. |
I’m puzzled by that. This feature is simple to understand, I believe not terrible to implement, and yet is of great generality and expressiveness. It is in fact so simple yet general that Exceptions are a restricted special case.
Others have discussed how it could often greatly simplify writing Widgets, particularly complex compositions of Widgets. For Flutter in particular, this would be a great benefit.
…On 8 Mar 2024 at 14:23 -0800, Bob Nystrom ***@***.***>, wrote:
I have to admit, I'm skeptical that this would be a good fit for Dart. I generally love the idea of using the type system to reason about programs better, but there comes a point of diminishing returns and I suspect that algebraic effects are beyond that point for most Dart users.
—
Reply to this email directly, view it on GitHub, or unsubscribe.
You are receiving this because you were mentioned.Message ID: ***@***.***>
|
I'm going to have to question that claim.
It's more than one feature. It includes, effectively:
Having exceptions being a restricted case isn't particularly illuminating when it's the parts outside of that restriction which are complicated. I personally think "code blocks" that work as non-escaping "local" functions, and which can do control flow, could be a much better fit for Dart. int firstNeg(Iterable<int> ns) {
ns.forEach(x){
if (x < 0) firstNeg.return x;
};
return 0;
} That avoids the dynamic effect handler scope, you have to actually pass the function down the stack. That means typing is trivial. (Still non-trivial how it would interact with On the other hand, because the feature is as dynamic as it is, and we already have features to handle dynamic scopes, it should be possible to implement this in Dart. The only thing that's hard is typing. And doing async right. |
On 9 Mar 2024, at 00:50, Lasse R.H. Nielsen ***@***.***> wrote:
This feature is simple to understand,
I'm going to have to question that claim.
The feature itself may seem simple, but it's decidedly unclear how it interacts with:
async functions
finally
first class functions
static typing
It's more than one feature. It includes, effectively:
non local return or control flow
dynamic resolution of a function call
The feature is available in the now aggressively parallelised OCaml. There are a number of discussions about adding it to Javascript eg https://github.com/macabeus/js-proposal-algebraic-effects
I personally think "code blocks" that work as non-escaping "local" functions, and which can do control flow, could be a much better fit for Dart.
int firstNeg(Iterable<int> ns) {
ns.forEach(x){
if (x < 0) firstNeg.return x;
};
return 0;
}
Blocks would be great, but that seems a different question.
|
I've read the JS proposal and the article it links to. I'm still not convinced that what's described is a good idea for Dart. The article does say that algebraic effects can be typed in a static type system with first-class functions, but it also implies that's at the cost of having every function type include every effect that it depends on. If the function type lists a set of function types, by name, then it's effectively an extra set of parameters that are implicitly passed to the function when it's called. That's a quite serious cost. T Function<T>{id: T Function(T), escape: Never Function()}(T) for a function which may use the T Function<T>{id: T Function(Object), escape: never Function(), other: int Function(int)}(T) That is, contravariant in the effect types - can be used as a function type that requires at least the same effects. Every function will have these. You may get away with not declaring them, but they're there anyway. The explanation that the intermediate functions don't need to worry about effects, seems wrong then. All functions in the stack frame down to the effect This is all very verbose, unless it can be inferred, and if it can be inferred (not obvious for Dart's type system), then small changes can easily change the type signatures of a large hierarchy of functions. What the proposal and article says about the effect handler being able to be asynchronous, even if performed from a synchronous function, is wrong. At least for Dart, but I'd be very surprised if not also for JS. It says that the I don't believe we can forget about asynchrony at all, not the way Dart asynchrony works. We can have asynchronous effect handlers, but they must return a future, and then the caller must figure out how to deal with that if they're not themselves asynchronous. There is no way to suspend a non- The proposal seems to have the handle block always resume with a value.
That doesn't suggest that the effect handler can choose to escape by throwing or returning from its own scope. It really is called on top of the stack, as a function, and must return. And that's why I suggest that simply passing functions as arguments is a perfectly reasonable implementation of this. void processLog{log: void Function(String, {String? id}), extractId: String? Function(String)}(String input) {
for (var line in LineSplitter.split(input)) {
var id = extractId(line);
processLine(line, id);
}
}
void processLine{log: void Function(String, {String? id})}(String line, String? id) {
if (id != null && id.contains("banana")) {
line = "[Banana]: $line";
}
log(line, id);
} This is, not coincidentally, what effect-annotated function declarations would (potentially) look like too. Or just use a dynamic environment, like the example code I linked. (If that I can see how this feature can be convenient, especially in an untyped language. I don't think it's a good fit for Dart. (Now, if Dart was a language with first class continuations ... but it isn't.) |
I’m not sufficiently familiar with compiler development to comment knowledgeably about this. What I know is:
1. This is a *great* language feature that would be a tremendous enabler of simplifying Dart and Flutter work. Just removing colored functions would be amazing.
https://volodymyrpavlyshyn.medium.com/algebraic-effects-remove-color-from-functions-and-add-more-color-to-your-life-f087c0e2944d
It would also simplify a lot of Flutter complexities that lead to the large and confusing collection of state handling libraries we have.
2. It is implemented in at least one real-world statically-typed language (OCaml) with robust parallelism constructs. Probably looking into how they did that would be the most useful step here.
Grateful for this thoughtful discussion.
… On 9 Mar 2024, at 15:03, Lasse R.H. Nielsen ***@***.***> wrote:
I've read the JS proposal and the article it links to. I'm still not convinced that what's described is a good idea for Dart.
The article does say that algebraic effects can be typed in a static type system with first-class functions, but it also implies that's at the cost of having every function type include every effect that it depends on. If the function type lists a set of function types, by name, then it's effectively an extra set of parameters that are implicitly passed to the function when it's called.
That's a quite serious cost.
Let's say we use identifiers to name effects, then a Dart function type could be (curly braces for required effects):
T Function<T>{id: T Function(T), escape: Never Function()}(T)
for a function which may use the id and escape effects at the given types.
That would likely be a subtype of:
T Function<T>{id: T Function(Object), escape: never Function(), other: int Function(int)}(T)
That is, contravariant in the effect types - can be used as a function type that requires at least the same effects.
It's parameters, by any other name.
Every function will have these. You may get away with not declaring them, but they're there anyway.
Required effects must be listed in DartDoc, explicitly written function types must list all effect types needed.
A List<int Function(int)> cannot contain any function which performs an effect.
There is probably no easy way to parameterize over effects, so you can't have List<int Function{*}(int)> that ignores the effects, because int Function{id: int Function(int)](int) and int Function{escape: Never Function()}(int) are completely unrelated types.
The explanation that the intermediate functions don't need to worry about effects, seems wrong then. All functions in the stack frame down to the effect perform needs to be aware that the effect is used, because otherwise it cannot possibly allow the effect to be performed in a sound and statically typed way. Which means that every function in that call stack must declare that they need the same effect.
(At least unless closures can capture the current handlers, thereby removing the requirement from the function signature, but this is a dynamically resolved effect, so probably not).
What the proposal and article says about the effect handler being able to be asynchronous, even if performed from a synchronous function, is wrong. At least for Dart, but I'd be very surprised if not also for JS. It says that the perform keyword creates a callback for the rest of the function. That's not enough, it has to create a callback for the rest of the stack, which is what async functions can do, and synchronous functions cannot. (Maybe that's what the @@ operator is about, it's something related to the Babel parser used, I think. But if you need a special way to invoke any method that may perform effects, then that's definitely a new function color.)
I don't believe we can forget about asynchrony at all, not the way Dart asynchrony works. We can have asynchronous effect handlers, but they must return a future, and then the caller must figure out how to deal with that if they're not themselves asynchronous. There is no way to suspend a non-async function in Dart.
The proposal seems to have the handle block always resume with a value.
It even says:
Since perform is an expression, it always return a value. If resume wasn't called after a perform, it'll be undefined
That doesn't suggest that the effect handler can choose to escape by throwing or returning from its own scope. It really is called on top of the stack, as a function, and must return.
Which means that this isn't even similar to exception handling, it's just a dynamically resolved, statically typed, scope that things can be looked up in, and called.
And that's why I suggest that simply passing functions as arguments is a perfectly reasonable implementation of this.
Imagine if a function could have a set of implicit parameters, which are automatically forwarded from similar implicit parameters of the caller, so you only need to write the ones that you didn't get as arguments yourself (or that you wanted to change):
void processLog{log: void Function(String, {String? id}), extractId: String? Function(String)}(String input) {
for (var line in LineSplitter.split(input)) {
var id = extractId(line);
processLine(line, id);
}
}
void processLine{log: void Function(String, {String? id})}(String line, String? id) {
if (id != null && id.contains("banana")) {
line = "[Banana]: $line";
}
log(line, id);
}
This is, not coincidentally, what effect-annotated function declarations would (potentially) look like too.
Here the log parameter is automatically forwarded to processLine, because it exists and was not passed explicitly.
That design would, as I see it, perfectly match algebraic effect handlers as described for JS here - well typed, implicitly available (but declared as required) functions provided by the call stack.
Or just use a dynamic environment, like the example code I linked. (If that Effect class required a default implementation, which couldn't escape, it would be impossible to not have an implementation when calling it. Then it's all about scoping which implementation gets called.). That code even allows you to escape from the the handler to the code introducing the handler.
I can see how this feature can be convenient, especially in an untyped language. I don't think it's a good fit for Dart.
I don't see any particularly good and viable adaptions.
—
Reply to this email directly, view it on GitHub <#2567 (comment)>, or unsubscribe <https://github.com/notifications/unsubscribe-auth/AAAG7X36RZSJ6WA6CXS5NXLYXOIL7AVCNFSM6AAAAAARA7JE5WVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMYTSOBXGAYDGNRWGM>.
You are receiving this because you were mentioned.
|
So, to be clear: A full, typed and sound, implementation of algebraic effects, would very powerful. However, the "full" implementation means being able to capture continuations. All the "you don't need to color your functions" come back to that, and it's not really the effect handlers, it's the ability to capture a continuation that matters. If you can do that, you don't need Effect handlers is two features combined: Capturing continuations (even if only linear/single-use continuations) and a dynamic registry/dependency injection for functions that you can call with these continuations. Dart does not have the ability to capture continuations. We might be able to get that, if JavaScript and Wasm does. OCaml is an example of typing effects, but not typing the function's requirements. The effects are unchecked. With neither continuation capturing, nor checked effects, there isn't much feature left. So for algebraic effect handlers to actually be meaningful to add to Dart, it will require either being able to capture a continuation, or some way to have a checked effect system that doesn't get more cumbersome than Java checked exceptions. And if we can capture continuations at all, without effect handlers, we can build effect handlers in pure Dart too. (I think the comparison to exceptions is causing more confusion than it's helping. Effect handlers dynamically scoped like exceptions, which is part of the reason it's implemented as unchecked as exceptions, but the actual implementations are not unwinding the stack when performed. Even the OCaml implementation requires the continuation is either continued - (async) complete with a value - or discontinued - (async) complete with an error. Performing an effect is really just calling a dynamically resolved function which may block.) |
...couldnt we effectively do this with zones? Could be interesting to see what examples might come of that |
You can definitely do dynamic scope with zones (even with asynchronous behavior). However, like @lrhn stated in his last comment you also need the ability to capture continuations to implement full effect handlers (to get rid of the function coloring problem). Additionally, you don't get any of the benefits of typed effects. You can definitely implement a subset of effect handlers with zones (which @lrhn has shown and links to in his comment), and maybe even most of effect handlers, but you have to deal with some issues and corner cases with |
Uh oh!
There was an error while loading. Please reload this page.
Algebraic effect handlers are about to land in OCaml. There are a number of other languages that implement them, including at least one that compiles to Javascript (called Koka — that it’s implementable in Javascript is I’m sure important!).
An algebraic effect handler is basically an exception that carries a continuation so you can resume the original computation. Kind of a strongly typed LISP condition.
Given this (simple!) language feature, one can implement in a very straightforward way:
But the feature is generally helpful to the developer also (eg anywhere you have to pass an argument through a bunch of functions so it can be used later on — well, you don’t have to do that; it opens up this and a bunch more new delightful coding patterns in fact).
It’s simple to implement and efficient (the compiler should try to work out the simplest way of doing the thing; if the effect handler doesn’t call out to anything, you can just resolve its logic and then continue; worst case, you block copy the stack frames from the caller to the handler to somewhere else, and then restore them by just copying the stack frames back).
Here is a nice, simple basic description of the feature:
https://overreacted.io/algebraic-effects-for-the-rest-of-us/
Here is a really nice half-hour video by the guy who did the language that compiles to Javascript:
https://www.youtube.com/watch?v=hrBq8R_kxI0
The text was updated successfully, but these errors were encountered: