Idea: non-local control flow

Wouldn't the signature of fold then be:

    /// Fold over a function that may return the final value early.
    fn fold<'ret, OutRet, R, F>(&mut self, init: R, f: F)
      -> R, 'ret: OutRet
    where
        F : (FnMut<'f_ret>(A, Self::Item) -> R, 'f_ret: OutRet)
    ;

with the semantics of:

  • nevermind the (currently ugly) syntax, just focus on the semantics:
// having,
enum MultiReturn<Inner, Outer = !, Break = !> {
    InnerRet(Inner),
    OuterRet(Outer),
    Break(Break),
}
// and
#[lang = "..."] // for the `.inlined!()` / `in<'label> {}` sugar (see below)
trait Inlineable
:
    FnOnce<(), Output = MultiReturn<Self::InnerRet, Self::OuterRet, Self::Break>>
{
    type InnerRet;
    type OuterRet;
    type Break;
}

// then
fn... <'caller> (...) -> Inner, 'caller: return Outer $(, break Break )?
{
    //* body */
}
// is sugar for:
fn... (...) -> impl Inlineable<Inner = Inner, Outer = Outer $(, Break = Break)?>
{
    in<'caller> { // captures scope and creates state machine, much like `async`
        /* body */
    }
}

// Yielding the following usage:
iterator.fold(acc, in<'caller> |acc, x| {
    if cond() {
        return 'caller 0;
    } else {
        return foo;
    }
}).inlined!()
// which desugars to:
match iterator.fold(...)() {
    | MultiReturn::InnerRet(inner) => inner,
    | MultiReturn::OuterRet(outer) => return outer,
    | MultiReturn::Break(break_value) => break break_value, // valid outside a loop if unreachable (`break_value: !`)
}

After having written this, I realize that multi returns have always a scope / context, meaning that the name of the outer scope, e.g., 'caller, plays no role, so we could imagine 'caller becoming a keyword scope / label (feel free to bikeshed for another keyword name):

    /// Fold over a function that may return the final value early.
    #[inlineable(return = OutRet, break = Break)]
    fn fold<OutRet, Break, Acc, F>(&mut self, init: Acc, f: F) -> Acc
    where
        F : FnMut<(Acc, Self::Item)>,
        F::Output : Inlineable<InnerRet = Acc, OuterRet = OutRet, Break = Break>,
    ;

and then we could have inline { ... } scopes much like async { ... } scopes:

#[inlineable(...)]
fn... (...) -> Inner
{
    //* body */
}
// is sugar for:
fn... (...) -> impl Inlineable<Inner = Inner, ...>
{
    inlineable { // captures scope and creates state machine, much like `async`
        /* body */
    }
}

// Yielding the following usage:
iterator.fold(acc, |acc, x| inlineable {
    if cond() {
        return 'caller 0;
    } else {
        return foo;
    }
}).inlined!()

:thinking: except for the postfix macro, I may be able to create this week-end a working PoC using (procedural) macros

1 Like

Yes, by using existing local control flow constructs.

No, what I do now is just write out the code using the very same local control flow constructs that those combinators are built on, perhaps augmented by labeled continue and break where warranted. In other words, there's a somewhat verbose but non-confusing and very explicit answer to such cases.

And to answer your question: if suffix macros were a thing then some of those combinator methods would be fine as "method-position macros" I suppose, and I'd prefer that over non-local control flow.

Recall how the ? operator began its life: as the try! macro. It was only when it was decided that it would be better off as a suffix operator (which admittedly it was) that it was then elevated to language-level construct. And I'm not so sure it would have been elevated to language construct at all if suffix macros had been a thing.

1 Like

It sounds like you're talking about continuations. If you had a mechanism for capturing a continuation, you could just pass it to a closure, which could invoke it to jump back to the place it was captured.

Macros simply are not a substitute for this feature. From what I can tell, those arguing for macros primarily want them as a signal at the use and definition sites that this sort of control flow can happen, but macros are not the only way to provide that signal- inline fun-like designs can provide it just as effectively.

On the other hand, there's a reason I called Kotlin-style inline fun "a usefully-restricted form of macros" -- those restrictions give a lot of power in return, because they participate in the type system in ways macros cannot and never will. They can be trait methods, becoming polymorphic in ways macros cannot. They give the compiler far more information about what's going on, leading to better error messages and opportunities for faster code generation or better optimization.

For example, you can't always replace iterator combinators with their underlying control flow- when you are writing generic code that has to handle all kinds of iterators, you want to pick up any overridden combinator implementations and the optimizations they represent.

There are even cases where you can't thread a Result through! For example, working around the current lack of "placement." Instead of passing an object directly to a container (or similar) and incurring a large copy, you can pass a closure that returns the object via RVO. But today, that closure cannot early-exit, because returning a Result defeats RVO! Even worse, doing this with a macro forces the container to expose private implementation details to clients.

In cases like these, where you want type system integration and proper visibility and early-exit, macros are completely useless, manually writing the expansion yourself is impossible, and the only alternative is to give up and eat a cost somewhere. If Rust had gotten something like this early on, we wouldn't have these problems.

5 Likes

Multiple return points are an interesting idea. There has been some discussion at idea: "Multi-return" functions for faster Rust `Result` and/or simpler exceptions · Issue #1057 · bytecodealliance/wasmtime · GitHub how to implement them in Cranelift with a new ABI.

For those who didn't know, RVO stands for Return value optimization.

@rpjohnst I agree with the points you raised, the only problem is that non-local control flow can't be added to trait methods in std in a backwards-compatible way. But there are still many use cases for this feature!

I just read the new RFC Placement by return. It proposes methods that allow allocating a large object returned from a closure, without copying. However, they can't return a Result, and Box::new_with(|| foo()?) doesn't work. But with non-local control flow, we could write

'ret: fn alloc_box() -> Result<Box<[Foo; 1000]>, Error> {
    Ok(Box::new_with(|| match foo() {
        Ok(ok) => ok,
        Err(e) => return 'ret Err(e),
    }))
}

This is not pretty, but it would be possible to extend NLCF to the ? operator:

'ret: fn alloc_box() -> Result<Box<[Foo; 1000]>, Error> {
    Ok(Box::new_with(|| foo()'ret?))
}

Expressions like expr 'label ? would be desugared into

// works like before, except for the 'label
match Try::into_result(expr) {
    Ok(v) => v,
    Err(e) => return 'label Try::from_error(From::from(e)),
}
1 Like

Right, it would have been more effective if we had had it early on, like withoutboats pointed out.

I do think there are probably better syntax options than expr 'label ? but I'm not sure it's worth spending too much time on at this point.

This proposal gets within striking distance of delimited continuations. Yes, they're something that I'd like to see get into Rust, but since they're extremely powerful, any standard abstraction over them in Rust will probably end up spanning multiple highly expansive RFCs.

To some extent, backwards compatibility can be upheld by leveraging existing conventions around maintaining consistency over panics, but full support throughout will need an ecosystem-wide push on par with the one needed to transition to NLLs to ensure that everyone knows to use mechanisms agnostic to the particular source of unwinding.

RFC co-author here.

The problem isn't exactly syntax (as I wrote, you can always add a new_with_result method). The problem is that there is no obvious way to return data by placement, when the data is originally built as part of a larger contiguous object (eg a Result).

In your example, foo would still need to return a Result<[Foo; 1000], Error>. The problem is that Box doesn't want a Result<[Foo; 1000], _>, it wants a [Foo; 1000], and doesn't prepare "room" to store the Result discriminant.

As @rpjohnst points out, non-local closures could fix it (essentially by being a magic version of Result<UnsizedData, _> that doesn't have the problems actual unsized enums would have), but the you would need all your error handling to be done exclusively using non-local closures, and never return a Result if you want placement at all.

People who are interested in this proposal might also be interested in label-break-value which was accepted as RFC 2046.

Common Lisp is another language that supports returning through non-escaping closures. E.g.:

(block foo
  (mapc (lambda (x)
          (if (> x 0) (return-from foo x)))
        '(0 1 2)))
=> 1
5 Likes

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.