A lowering strategy for control effects in Rust
abubalay.com
abubalay.com
I'm fascinated with effect systems.
In theory they have the potential to make code both simpler and more powerful, because many complex abstractions like generators, async/await, error propagation, injected context, etc can all be built on a simple extensible language primitive.
The by far nicest implementation I've seen is Koka lang [1]. Unfortunately it's bound to be a small research language.
In practice I do wonder though if that much power won't make code very confusing to read and write, and if the general programmer should be trusted with that much flexibility.
For Rust specifically, I do wonder how generic effects could slot into a language that was not designed for them upfront, without compromising the design and DX.
I have conflicting feelings about this. On the one hand, it feels like call/cc all over again (a "simple" language primitive that can express devilishly complex control flow) that a lot of people would call a design mistake. The fact you can implement complex abstractions with a small primitive doesn't make the complex abstractions not complex for consumers of the API.
On the other hand, it's not call/cc and lets you do complex things with a decent amount of modularity.
But I also don't buy into the argument that implementing such foundational primitives like control flow in library code is good (I use async Rust every day, and it sucks how much has been pawned off to the ecosystem, please don't reply to this comment telling me how wrong I am). Even if you can implement these things in the ecosystem doesn't mean you should - it leads to bifurcations in the wild where libraries become incompatible because they aren't working with the same tools. The very thing that makes programming a force multiplier is modularity and reuse, making those harder by making a language too expressive makes that language worse, imo.
And all that said, the biggest knock that I have against algebraic effects is I haven't seen a compelling argument why they can't be replaced with coroutines which are well understood and don't require much magic to implement.
> the biggest knock that I have against algebraic effects is I haven't seen a compelling argument why they can't be replaced with coroutines which are well understood and don't require much magic to implement.
Rust's effects are already implemented as coroutines! Algebraic effects are a specific pattern for how those coroutines communicate, to make them more composable.
That seems pretty backwards to me. Being able to implement flow control as regular functions increases modularity and reuse a lot, not least because it makes it easy to abstract across async or not. IMO the biggest reason async Rust is so painful is precisely because control flow is a bunch of special case magic builtins that you can't share or reuse (e.g. you can't use regular if/while/for with async, and you can't write your own equivalents (like Haskell's ifM/whileM) because the language doesn't provide the tools you'd need to do that).
What do you mean by this? You can certainly mix if/while/for freely with .await, that's the whole reason it exists.
Whether this should be solved by a formal generic algebraic effects system or generic keywords is an open question but unless I’m mistaken this is a real problem that can’t be solved with coroutines.
The article also highlights an advantage for generic algebraic effects by reducing the cost of async code by not constructing the Waker until there’s a suspension point. I don’t understand the proposals well enough to know if generic keywords would solve this too, but it’s certainly worth considering.
I’m less bearish on new language mechanics if they solve a class of problems very consistently and improve efficiency. Complexity of usage is a concern vs something like generic keywords, but honestly self consistent mechanics like this might be less complex to master than generic keywords in the long run. There’s precedent here for example with macros which is a pretty complex subsystem but it’s carved out by experts providing libraries for the most part and I would expect similar behavior here where library code might be more complex in implementation but there’s no code duplication and the user code gets a simpler .map function usable in any context. There will also be plenty of copy-passable examples to learn from the std library APIs.
if let Some(async) = self.async.as_mut() {
let next_arg = async.yield(next_result);
}
Consider deserializing JSON with serde_json over a TCP socket. There's no need for an AsyncRead because the asynchronicity isn't colorful - internally in the implementation of std::io::Read, it just needs to check if there is a coroutine to yield to.I'm less compelled about code duplication for trivial stuff like try_async_map, whereas being able to duct tape together major libraries with a few lines of code internally is much more compelling. And solves real problems, the previous example is actually a problem faced in codebases today.
I can think of a few arguments against this, but I don't see it as structurally that different from algebraic effects.
No, it doesn't. It highlights that as an advantage of one specific implementation style, while suggesting a different implementation for Rust.
Library code doesn't mean bespoke. Core and std are library code, and stuff can be migrated in there from the ecosystem whenever a truly best-in-class solution that won't be replaced anytime soon becomes apparent.
And then suddenly we have “colored” libraries. Does my http library work with the async runtime I’m using? How many arena allocator implementations does my package depend on and compile in?
The advantage of something being in core or std is that, because all libraries implicitly depend on it, there’s no choice to be made. String is available everywhere. Libraries pass Strings to each other all the time. Nobody is confused when String is in the public API.
> stuff can be migrated in there from the ecosystem whenever a truly best-in-class solution that won't be replaced anytime soon becomes apparent.
Rust has been remarkably conservative when it comes to adding stuff to std in the last few years. I think it would make sense at the very least to add an async executor and async syscalls to std. You shouldn’t need 3rd party libraries to async read from a file or network stream. Obviously it would be a hugely divisive choice but making no choice at all hurts users.
Personally I really am enjoying the one implemented by Unison: https://www.unison-lang.org/docs/fundamentals/abilities/usin...
But Koka's was fun to play around with as well!
The cost being: Having to hire especially talented developers and developers spending more time reading and understanding code. And there is the hard-to-measure but insidious aversion of software developers to work on code that will require effort to understand.
Technically I'm sure you could do everything conditions do even in C with like setjmp/longjmp, but... good luck with that.
Ha, that's how the question mark operator (and the previous encarnation, the `try!` macro) was born then :)
This is not for ordinary code, it's for writing support libraries and DSL's. You need this stuff so that the "general programmer" can write code that's as straightforward as possible in its proper context.
I can understand how people could see the appeal in this, but I think this doesn't necessarily make it easier to write good software.
Or imagine coroutines built with `swapcontext` (man 3).
> DO NOT USE FIBERS!
As someone who uses Haskell professionally, 100% agree. Monad transformers are an (unfortunate) solution to the problem of stacking effects, not only due to the required lifting (to pick the appropriate handler), but due to the lack of compositionality and rough UX for the developer. Algebraic effect libraries are a lot easier to use (but I cannot speak for how easy/hard are to implement).