---
title: "Meetup 2024: Effects and associated effects"
tags: ["T-lang", "design-meeting", "minutes"]
date: 2024-09-10
url: https://hackmd.io/Oc8QQ8yGRcKvkicLh7kisg
---
# Meetup 2024: Effects and associated effects
## Background
Effects are, maybe, going to be the "next big thing" in programming languages.
They're compelling for a few different reasons. One is that they give a type-based way to represent powerful control flow concepts (e.g. `shift`/`reset`, `call/cc`). E.g., from RFC 3513, here's how Koka represents generators:
```koka
effect yield<a>
fun yield(x : a) : ()
fun odd_dup(xs : list<int>) : yield<int> ()
match xs
Cons(x,xx) ->
if x % 2 == 1 then
yield(x * 2)
odd_dup(xx)
Nil -> ()
fun main() : console ()
with fun yield(i : int)
println(i.show)
if i == 7: return // (presumably this causes control to return from `main`, not just a return back into `odd_dup`)
list(1,20).odd_dup
```
(There is no library being used here and that `yield` is not a keyword or feature of the language.)
Those are "effect handlers".
What's we're more immediately interested in for Rust are "effect types" or "capabilities". That is, we want a way to express what the body of a function is allowed to do. E.g.:
- `total`: The function is deterministic, cannot panic or loop indefinitely, etc.
- `div`: The function may diverge.
- `exn`: The function may panic.
- `pure`: The function may panic or diverge.
- `ndet`: The function is non-deterministic.
- `gen`: The function may yield synchronously.
- `async`: The function may yield asynchronously.
- `nconst`: The function may rely on a runtime.
- etc.
There are number of effectful languages, e.g.:
- [Koka](https://koka-lang.github.io/koka/doc/book.html)
- [Ante](https://antelang.org/) (written in Rust)
- [Flix](https://flix.dev/)
## Syntax matters
Much of the pushback on effects in Rust has been, in my view, due to syntax proposals that have been very noisy.
## Associated effects
The Flix folks recently wrote a paper about [associated effects](https://dl.acm.org/doi/pdf/10.1145/3656393).
```flix
class Foldable [t : Type ] {
type Aef : Eff // An associated effect synonym .
type Elm : Type // An associated type synonym .
def foldLeft (f: (b, Elm ) -> b \ ef , s: b, t: t): b \ Aef + ef
def foldRight (f: (Elm , b) -> b \ ef , s: b, t: t): b \ Aef + ef
}
```
In Rust, something like:
```rust
trait Fold<T> {
effect E1;
type Item;
fn fold_left<B, F>(self, init: B, f: F) -> B + (Self::E1 + F::E2)
where
Self: Sized,
F: FnMut<effect E2>(B, Self::Item) -> B + F::E2;
...
}
```
## Niko's "maximally minimal" do-based idea
https://nikomatsakis.github.io/rust-of-my-dreams/effects.html
I'm also, in some sense, less principled, in that I want to think of an effect as a "monad-like" combo of
* a transformation that takes place on the signature
* a transformation that takes place on the call to "undo" (possibly propagating) the
* restrictions and/or extra ops on the body
```rust!
fn foo() -> u32
foo()
async fn foo() -> u32
foo().await : u32
const fn foo() -> u32
foo() /* no-op */
gen fn foo() -> u32
for _ in foo() { .. }
try fn foo() -> u32 // anyhow::Result<u32>
foo() -> anyhow::Result<u32> // (desugared form)
foo()? : u32 // (nicer natural sugar)
// assumption: fully arbitrary orders of combinations
// of above are not useful, but there exist specific
// useful orders, as shown below
async try fn foo() -> u32
foo() -> impl Future<Output = anyhow::Result<u32>>
foo().await?
```
```rust!
fn map<T, U>(
v: Vec<T>,
f: impl FnMut(T) -> U,
) -> Vec<U> {
v.into_iter()
.map(|x| f(x))
.collect()
}
async fn async_map<T, U>(
v: Vec<T>,
f: impl async FnMut(T) -> U,
) -> Vec<U> {
v.into_async_iter()
.map(async |x| f(x).await)
.collect()
.await
}
const fn const_map<T, U>(
v: Vec<T>,
f: impl const FnMut(T) -> U,
) -> Vec<U>{
v.into_const_iter()
.map(const |x| f(x))
.collect()
}
```
```rust!
do fn map<T, U>(
v: Vec<T>,
f: impl do FnMut(T) -> U,
) -> Vec<U> {
v
.into_iter()
.map(do |x| f(x).do)
.collect().do
}
do trait Iterator {
do fn next(&mut self) -> Self::Item;
do fn map<U>(
self,
f: impl do FnMut(Self::Item) -> U
) -> impl do Iterator<Item = U>;
fn size_hint(&self) -> usize;
}
```
Makes me think of all the non-try versions we implement using <https://stdrs.dev/nightly/x86_64-unknown-linux-gnu/core/ops/try_trait/struct.NeverShortCircuit.html> and calling the try versions.
You can write things as `do` to be generic over the details.
You can express combinators (e.g., fold) like this
```rust
trait Fold<T> {
type Item;
do fn fold_left<B>(
self,
init: B,
f: impl do FnMut(B, Self::Item) -> B,
) -> B
where
Self: Sized;
}
```
you could desugar this (not an actual syntax I think we need) conceivably to *something* like this
```rust
trait Fold<effect E, T> {
type Item;
E fn next(&mut self) -> B;
E fn fold_left<B>(
self,
init: B,
f: impl E FnMut(B, Self::Item) -> B,
) -> B
where
Self: Sized;
}
```
I also want to have a simple set of possible effects that combine in predictable ways, roughly like so:
```
E = async? try(E)?+ const?
```
When you put an effect on a trait, it transforms all the methods in the trait equivalently.
Note: `const` is special (since its taking a effect/capability away, rather than adding one), and as part of its specialness, a definition that is meant to be generic over the const-effect needs to explicitly say so, e.g. via: `const do fn` (rather than just `do fn` as shown in all the cases above).
downsides:
* you cannot write a combinator with a function that takes a closure, calls it immediately (e.g., map), but doesn't capture it into the returned combinator
* Scott: A lot of `try_` variants on iterator change from `&self` to `&mut self`
* Because try_ variant wants to mark self as cancelled??
* Maybe we say do version is always `&mut self`, or something
* Might also come up with Fn* traits; we might want to change to a different variant
## do Fn supersedes async Fn
Josh: If we make `do Fn` / `do FnMut` / `do FnOnce` exist and work as expected, then that would justify having `async Fn` / `async FnMut` / `async FnOnce` syntax.
## Disentangling
There are a number of concepts here that can be disentangled.
- Effect handlers
- Contexts
- Capabilities
- User-defined capabilities
- Generic effects
- Generic `do` ("maybe async")
- Innovating here
TC: Different kinds of effect handlers:
- `for`: Handles an effect fully
- `?`: Handles and forwards an effect
## Non-goals (from Niko)
* gen as an effect
* taking a function that (a) has effects you want to be generic over but (b) which you will not immediately call (or at least you want to declare that you won't call)
* "removing" effects from the inputs ("handling them")
Precious comment from JT (regarding whether `gen` fits Niko's `do` model and it ending up being the List Monad): "just because it doesn't make sense doesn't mean Haskell hasn't done it"
## Maximally-maximally minimal
TC: Doing effect generics, with a fixed set of effects, and without `do` is more minimal.
The alternates here are:
- With `do` a single `impl` "magically" works for all effects.
- `impl do Trait for ...`
- `impl async Trait for T`
- `impl Trait for T`
- `impl const Trait for T`
- Without `do`, the same trait works with separate impls.
## Niko notes: what are our real goals?
Thinking about what we really want to get out of this...
* "Orthogonality"
* Iterators, `?`, and friends all work the same way in every context
* I want to be able to get back "yes you can have nice things" -- I want to use iterator APIs
* Oh and: const
```rust
try fn something() -> Vec<Result> {
some_iter
.iter()
.map(|x| x.something_fallible()?)
.collect()
}
```
libs-api really wants to not define `foo` and `try_foo` and `async_foo` and `async_try_foo` and...; libs-api has already rejected adding `try_*` variants of some methods because we're hoping for a better solution from lang.
## Breaking down the pieces
- `trait do Trait {}`
- `do fn foo()`
- `do async fn foo()`
- `impl Trait {} / impl async Trait {}`
- `impl do(!async) Trait for T {} / impl async do Trait for T {}`
## TC: What are the real goals?
TC: In my view, what we're most immediately trying to avoid is the `2^N` problem on traits, not on impls. If we do nothing, then we need `2^N` traits. Effect generics and associated effects avoid that.
The problem of `2^N` impls could be handled later by adding `do` on top of an effect generic design. But it's hard to me to see a world where we wouldn't need the ability to write separate specific impls anyway.
Josh: I see this often in concrete functions, not just traits. e.g. `Vec` methods, `HashMap` methods.
## TC: What's most natural for Rust?
TC: My feeling is that much of the negative feedback to date has been due to some really noisy syntax that has been proposed. It's hard to disentangle that.
TC: In terms of what's most natural for Rust, it seems the idea of traits defining a contract, for effects as they do elsewhere, and then having separate impls, is a natural framing.
TC: Conversely, the `do` is very magical, especially since it pushes against the idea of `Future`s as normal values in Rust. This is an area where we do have specific non-syntax negative feedback.
## Control flow?
Could we define the "try" effect in a sufficiently general way that it also allow's the caller's closure to call `return` or `break` or `continue`? That should be possible using `ControlFlow`?
(Feedback: yes.)
## Handling things that can return errors or panic
Josh: Could we have a way to specialize (please pretend that's not a terrifying word) for try vs non-try, or have a construct that runs different code in the try vs non-try case? In particular, could we allow saying "if we're try, then return an allocation failure, otherwise panic with an allocation failure"? This implies having more than one `try` effect, some controlled by the caller and one controlled by the callee. `try(AllocationError)` vs `try(E)`
Niko: Yeah, we can make that work. Let's talk separately.
## Some notes
* do syntax for traits
* what about default methods?
* ability to have...
* generic code for the easy case
* custom impls for specific cases
* `impl async Iterator for X { ... }`
### Layer 1
```rust
do trait Iterator {
type Item;
do fn next(&mut self) -> Option<Self::Item>;
do fn map<U>(
self,
op: impl do FnMut(Self::Item) -> U,
) -> do Map<U> {
// ^^^^^^ in reality we will need this
Map {
base_iter: self,
func: op,
}
}
do fn for_each(
&mut self,
op: impl do FnMut(Self::Item),
) {
while let Some(x) = self.next().do {
op(x).do;
}
}
}
do struct Map<I, F> {
base_iter: I,
func: F,
}
impl<R> do Iterator for do Map<I, F>
where
I: do Iterator,
F: do FnMut(I::Item) -> R,
{
type Item = R;
do fn next(&mut self) -> Option<Self::Item> {
if let Some(i) = self.base_iter.next().do {
let j = (self.func)(i).do;
Some(j)
}
None
}
}
```