4 releases (2 breaking)
0.3.1 | Mar 18, 2023 |
---|---|
0.3.0 | Mar 17, 2023 |
0.2.0 | Mar 17, 2023 |
0.1.0 | Mar 17, 2023 |
#2441 in Rust patterns
63KB
1.5K
SLoC
Effect handlers in Rust.
Inspired by https://without.boats/blog/the-registers-of-rust/, Rust can be considered to have 3 well known effects:
- Asynchrony
- Iteration
- Fallibility
This is currently modelled in Rust using 3 different traits:
The "Keyword Generics Initiative" have stirred up a little bit of controversy lately
by proposing some syntax that allows us to compose asynchrony with other traits in a generic
fashion. To put it another way, you can have an Iterator
that is "maybe async" using syntax.
I find the idea interesting, but I think the syntax causes more confusion than it is useful.
I propose the Effective
trait. As I previously mention, there are 3 effects. This
trait models all 3. Instead of composing effects, you can subtract effects.
For instance, Future
is Effective
- Iterator
- Try
:
impl<E> Future for Wrapper<E>
where
E: Effective<
// Fallibility is turned off
Failure = Infallible,
// Iteration is turned off
Produces = Single,
// Asynchrony is kept on
Async = Async
>,
{
type Output = E::Item;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
todo!()
}
}
Coloring problem
There's a well known blog post in the JS ecosystem called "What color is your function?". It makes the claim that async functions in JS are a different color to 'non-async' functions. I don't think the 'color' analogy really works in that case, since colors are known to be mixed.
However, it works perfectly with Effective
.
// Red + Green + Blue
trait TryAsyncIterator = Effective<Failure = Error, Produces = Multiple, Async = Async>;
// Cyan (Blue + Green)
trait AsyncIterator = Effective<Failure = Infallible, Produces = Multiple, Async = Async>;
// Magenta (Red + Blue)
trait TryFuture = Effective<Failure = Error, Produces = Multiple, Async = Async>;
// Yellow (Green + Red)
trait TryIterator = Effective<Failure = Error, Produces = Multiple, Async = Blocking>;
// Red
trait Try = Effective<Failure = Error, Produces = Multiple, Async = Async>;
// Green
trait Iterator = Effective<Failure = Infallible, Produces = Multiple, Async = Blocking>;
// Blue
trait Future = Effective<Failure = Infallible, Produces = Single, Async = Async>;
// Black
trait FnOnce = Effective<Failure = Infallible, Produces = Single, Async = Blocking>;
Examples:
There are a lot of map
-style functions. Iterator::map
,
Option::map
, FuturesExt::map
,
TryStreamExt::map_ok
.
They all do the same thing, map the success value to some other success value.
EffectiveExt
also has a map
method, but since Effective
can model all of those effects, it only needs a single method.
Try:
use effective::{impls::EffectiveExt, wrappers};
// an effective with just fallible set
let e = wrappers::fallible(Some(42));
let v: Option<i32> = e.map(|x| x + 1).try_get();
assert_eq!(v, Some(43));
Futures:
use effective::{impls::EffectiveExt, wrappers};
// an effective with just async set
let e = wrappers::future(async { 0 });
let v: i32 = e.map(|x| x + 1).shim().await;
Iterators:
use effective::{impls::EffectiveExt, wrappers};
// an effective with just iterable set
let e = wrappers::iterator([1, 2, 3, 4]);
let v: Vec<i32> = e.map(|x| x + 1).collect().get();
assert_eq!(v, [2, 3, 4, 5]);
Combined:
use effective::{impls::EffectiveExt, wrappers};
async fn get_page(x: usize) -> Result<String, Box<dyn std::error::Error>> {
/* insert http request */
}
// an effective with just iterable set
let e = wrappers::iterator([1, 2, 3, 4])
.map(get_page)
// flatten in the async effect
.flatten_future()
// flatten in the fallible effect
.flatten_fallible();
let v: Vec<usize> = e.map(|x| x.len()).collect().unwrap().shim().await;
You'll also notice in that last example, we map
with a fallible async function, and we
can use flat_map
+flatten_fallible
to embed the output into the effective directly.
This lets you add effects.
We can also subtract effects, we can see this in the collect
method, but there are more.
// Effective<Failure = Infallible, Produces = Multiple, Async = Blocking>
let e = wrappers::iterator([1, 2, 3, 4]);
// still the same effects for now...
let e = e.map(get_page);
// Effective<Failure = Infallible, Produces = Multiple, Async = Async>
// We've flattened in in the 'async' effect.
let e = e.flatten_future();
// Effective<Failure = Box<dyn std::error::Error>, Produces = Multiple, Async = Async>
// We've flattened in in the 'fallible' effect.
let e = e.flatten_fallible();
let runtime = tokio::runtime::Builder::new_current_thread().build().unwrap();
// Effective<Failure = Box<dyn std::error::Error>, Produces = Multiple, Async = Blocking>
// We've removed the async effect.
let e = e.block_on(runtime);
// Effective<Failure = Box<dyn std::error::Error>, Produces = Single, Async = Blocking>
// We've removed the iterable effect.
let e = e.collect();
// Effective<Failure = Infallible, Produces = Single, Async = Blocking>
// We've removed the fallible effect.
let e = e.unwrap();
// no more effects, just a single value to get
let e: Vec<_> = e.get();
North Star
Obviously this library is quite complex to reason about. I think it is a good pairing to keyword-generics.
There should be a syntax to implement these concepts but I think the underlying trait is a good abstraction.
Similar to how:
These syntax elements could be composed to make application level Effective
implementations.
async try get_page(after: Option<usize>) -> Result<Option<Page>, Error> { todo!() }
// `async` works as normal, allows the `.await` syntax
// `gen` allows the `yield` syntax, return means `Done`
// `try` allows the `?` syntax.
async gen try fn get_pages() -> Result<Page, Error> {
let Some(mut page) = get_page(None).await? else {
// no first page, exit
return;
};
loop {
let next_page = page.next_page;
// output the page (auto 'ok-wrapping')
yield page;
let Some(p) = get_page(Some(next_page)).await? else {
// no next page, exit
return;
};
page = p;
}
}
// This method is still `async` and `try`, but it removes the 'gen` keyword
// because internally we handle all the iterable effects.
async try fn save_pages() -> Result<(), Error> {
// The for loop is designed to handle iterable effects only.
// `try` and `await` here tell it to expect and propagate the
// fallible and async effects.
for try await page in get_pages() {
page.save_to_disk()?
}
}
With adaptors, it would look like
let get_pages = wrappers::unfold(None, |next_page| {
wrappers::future(get_page(next_page)).flatten_fallible().map(|page| {
page.map(|| {
let next_page = page.next_page;
(page, Some(next_page))
})
})
});
let save_pages = get_pages.for_each(|page| {
wrappers::future(page.save_to_disk()).flatten_fallible()
});
Dependencies
~0.5–6MB
~33K SLoC