11 ms·
> many complex abstractions like generators, async/await, error propagation, injected context, etc can all be built on a simple extensible language primitive.
by duped 3y ago
> many complex abstractions like generators, async/await, error propagation, injected context, etc can all be built on a simple extensible language primitive.
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.
- ATMLOTTOBEER 3y agoYou are right. This situation played out (and still kinda is playing out) in Scala.
- vlovich123 3y agoI thought coroutines don’t solve the function coloring problem considering we do have coroutines but combinatorial explosion due to function coloring is an issue. In other words, I need a try_async_map function if I want to support a map operation that has failability and async (as I need a duplicate try_map vs map to support just failability). 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.
- anonymoushn 3y agoLots of coroutine implementations solve this problem, see maybe Lua or greenlet or Byuu's libco. Shipping async as-is and delaying async-in-trait until 1 month ago was a deliberate choice to make all libraries less composable.
- Rusky 3y ago> 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. No, it doesn't. It highlights that as an advantage of one specific implementation style, while suggesting a different implementation for Rust.
- duped 3y agoIf you could spawn a coroutine and pass it around as a value, you could implement your effects just by calling coroutine.resume(...), coroutine.yield(...). The reason this is more powerful abstraction than async/await is that it's colorless - structs that could be sync or async just need a field that's Option<Coroutine> and when they want to be "effectful" they just do something like 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.
- anonymoushn 3y agoI agree that try_async_map is whatever, but currently if you want to call several fallible functions from different libraries and propagate the errors you get into a bit of a mess.
- Rusky 3y agoThe article is not suggesting that Rust should support arbitrary library-defined effects. It's suggesting that Rust's existing language-defined effects should be combined algebraically. > 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.
- duped 3y agoI'm replying to the comment on algebraic effects more than the article.
- lmm 3y ago> 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. 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).
- Rusky 3y ago> you can't use regular if/while/for with async What do you mean by this? You can certainly mix if/while/for freely with .await, that's the whole reason it exists.
- bobbylarrybobby 3y agoI believe the distinction is that in rust, `if a_cond.await` can only be written in an async function, whereas Haskell’s `ifM` takes a `Monad Bool` (and async is a kind of monad) as its condition and therefore can be written anywhere, not just within an existing monad.
- Rusky 3y agoThat's a distinction without a difference. `ifM` takes a monadic bool but it also returns a monadic value, in exactly the same way that `.await` can only be done within an async computation.
- 3y ago
- zozbot234 3y ago> But I also don't buy into the argument that implementing such foundational primitives like control flow in library code is good 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.
- josephg 3y agoLibrary code means every project has to choose which library they use. Smolstring or SmartString? Tokio or one of the others? 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.