An idea for TCP closures and rust's effect system

The idea is to introduce a new kind: effects. They are typed, polymorphic generic parameters.

Used as fn: fn<T,effect A<T>> effectful() they can depend on a datatype that they can later return.

  • Syntax of actually using them is following: return 'A val;
  • Calling such a function is done like:
let val = 'lab {
   effectful<'lab>();
   1usize
}

Here the 'lab label is used to denote a block whose type is being used as a type for effect.
So the effectful function can return 'A 0; and make the block execution end with the value 0;

The closures don't have explicit form, and instead they capture all the labels from surrounding scope:

fn exmp2(){
   let ret = 'ret: {
      some_iter().map(
         |it| it.failable_op()
            .map_err(|e| return 'ret Err(e))
            .to_ok() //to_ok is from `impl<T> Result<T,!>`
      ).collect() |> Ok //pipe for demo purpose, not in rust yet. 
    };
   ret
}

And the inconvenience of ret binding is why I would like to also introduce a special label 'fn referring to current function's scope.

Somewhat related: withoutboats wrote a post last year about effects systems and Rust

6 Likes

How does this interact with unsafe code? Does it behave as if the closure panicked?

1 Like

As of that, i guess unsafe closures and unsafe fn's should be able to receive labels (only) from the same unsafe block they are defined/called in, meaning no non local control flow crossing unsafe boundaries.

About actual implementation, I am thinking of something like closures and functions returning enum of all variants they can return 'A val to. So nothing to do with panics.

So only Fallible for now? No Multiplicity or Asynchrony yet? :slight_smile:
What other useful effects are there?..

These are better handled with their own respective features: generators and async (generators); for sure they could be abstracted to monads, but should they?

And while Multiplicity and Asynchrony are handled by their own features, we should think how to integrate these with this proposal as well as with each other.

I didn't mean unsafe closures and unsafe fns. What I meant is what if a function that does unsafe stuff (but is not unsafe itself) calls the closure and for soundness relies on panics being the only non-local control flow possible?

For example the take_mut crate creates a temporary invalid state, calls a closure and then restores a valid state. To make sure the restore code always runs it has to cover the case when the closure does non-local control flow; for now this is only possible with panics, so it uses std::panic::catch_unwind to catch the panic. However if it was possible to do non-local control flow that isn't nor acts like a panic then this would suddently become unsound.

Another example is crossbeam's scoped threads API which needs to run some code, required for soundness, after a closure has been called, regardless of whether it panicked or not and for this it also uses std::panic::catch_unwind.

To avoid making these crates unsound I think we should require an explicit action from the caller of closures that can do non-local control flow, something like the explicit ? used for error handling or the .await used to await futures.

6 Likes

All non local control flow is opted in via HOF function signature. Unsure about syntax, so rough sketch:

fn<effect 'A<bool>> hof(task: fn<'A>() -> u64);

Note the position of first diamond brackets: it is function's "kind" property.

1 Like

Sure. But does it not put "Effect" into dispute as a fitting name for the suggested feature?

Another extension of this mechanism is to allow resumption of the effect with some value, but this overlaps with currently existing async and generators, and with non local control flow leads to fully fledged coroutines, with known implications. I really misnamed thread, should have been more concrete.

Interestingly generators could perhaps be made to work so long as the generator is always resumed from the same calling routine and it's the only generator involved and no (other) unsized locals are created while the generator is alive.

Prohibiting such a generator from being moved or from having its reference taken could perhaps serve as the tool to ensure it is only ever resumed from the same stack frame. Ensuring the generator is the only one in use and there are no other interfering unsized locals could perhaps be checked dynamically by making sure SP is pointing to the expected location on the stack when resuming the generator.

I think that if effects are added to Rust, that it should be done so in a consistent fashion. It shouldn't necessarily replace the current way of doing things, but in such a case it should definitely be possible to abstract over them, otherwise the effects system loses its raison d'etre: An abstraction with only one concretization is a pretty useless abstraction, at best (it adds complexity too so it can then easily be argued that the value provided by that abstraction is actually negative).

So yes, if effects are added, the things you mentioned (and possibly others) should be included in that.

4 Likes

Another iteration is to leave non-local control flow for future proposals, and instead focus on TPC closures.

Another effect to integrate here is asynchrony:

async fn exmp3(data: impl Iterator<Output=u8>) {
   data.map(
      |i| await(process(i))
   ).collect();
   ...
}

I'm not sure whether such closures need explicit demarcation or not. For the await to be correctly desugared a closure must capture the context, have own state.

This imposes two restrictions:

  1. A closure using such await has local lifetime (e.g tied to creators stack frame)
  2. State obtained from coroutine transform has to be stored somewhere:
    I would store this state inside of parent async context's state.

Edit: as of demarcation, we could use async closures syntax for this, giving them entirely different semantics from what || async {...} means

1 Like

Doesn't asynchrony add even more non-local control flow since now you can not only break out, but also go back in?

Yes, but also no :grin:: the very problem of non local control flow you'd mentioned before, it can cause code which execution is mandatory for soundness not to run at all. But AFAIK async code can't lead to such conditions because of the way it's generated; the only problem are assumptions which user coded futures might make, to be explored.

E.g. here we do not yet expose unlimited non-local control flow.

I have to clarify what i meant here:

The problems of full non local control flow are already described in thread, but without basics of NLCF I can't go further.

I want to place a restriction on NLCF: a single function can both receive typed labels to return to and provide local typed labels to other function, however it cannot propagate received labels downstream.

This restriction is imposed to enforce handling of failures locally or, at least, more explicit propagation.

The case I find the hardest is multiplicity: particularly because the design of generators is itself not finalized yet.

So,I will make a few strong assumptions:

  • Magic mutation is chosen - personal preference
  • Signatures are following gen fn(initial_data)->resume_ty->yield_ty
    Note: closures doesn't have such full form: their start data is captured.
  • gen fn and gen closures are introduced - explicitness about the context "actions" take place in.

Example 4:

gen fn exmp4(data: impl Iterator<Item=char>) -> () -> [char;2] {
   data.map(
      gen |ch| loop {
         let ch1 = ch; yield;let ch2 = ch; yield; // here we accumulate 2 characters.
         yield 'fn [ch1,ch2]; //here we yield these right from original context.
      }
   )
}

Sorry, if this is cryptic.

Edit: Also syntax of giving the closure contexts labels is unclear.

That's not true, an await can cause the future to yield to the executor, suspending the computation, then if the executor never polls the future again you've effectively skipped the code that was required to run. You can even leak a future, making it even worse because now not even Drop implementations will run, which many rely on (I was bitten by this when I proposed a macro approach for scoped threads, it worked in sync functions but not in async ones, see these two comments (1, 2))

Thanks, I was just assuming that the futures are eventually run till the completion and then dropped.

However, I don't think such conditions are manageable without linear types at all.

Unfortunately, this is outside of current scope. Tbh, I was thinking about some super light weight form of linear types, but that's different topic.