fsharp / fslang-suggestions

The place to make suggestions, discuss and vote on F# language and core library features
346 stars 21 forks source link

Nested computation expression, call outter context #1325

Open Thorium opened 1 year ago

Thorium commented 1 year ago

I propose we create new let!! and do!! and yield!!, a way how to call nested computation expression's outer context.

The existing way of approaching this problem in F# is to create combination-builders like AsyncEnumerable and TaskResult and so on, for each library them selves

Usage examples:

async {
   seq {
     yield 1
     do!! Async.Sleep 100
     yield 2
  }
}
seq {
  async {
     yield!! 1
     do! Async.Sleep 100
     yield!! 2
  }
}

Pros and Cons

The advantages of making this adjustment to F# are that these non-standard wrappers wouldn't be needed and the conversions between different library code comes easier.

The disadvantages of making this adjustment to F# are more non-alphanumeric characters, and harder unit-testing.

Affidavit (please submit!)

Please tick these items by placing a cross in the box:

Please tick all that apply:

For Readers

If you would like to see this issue implemented, please click the :+1: emoji on this issue. These counts are used to generally order the suggestions by engagement.

SchlenkR commented 1 year ago

How would transformers be constructed from existing builder methods?

charlesroddie commented 1 year ago

Good to have this functionality but the syntax is confusing. I would expect yield!! in the second case to take an async<async<'t>>, where each!s strips off an async.

Perhaps yield!'?

smoothdeveloper commented 1 year ago

how about ¡ and allowing them in arbitrary amount?

async {
   seq {
     yield 1
     ¡do Async.Sleep 100
     yield 2
  }
}
seq {
  async {
     ¡yield 1
     do Async.Sleep 100
     ¡yield 2
  }
}

I'm a bit iffy about the suggestion (not a fan of type check errors in 1 level CEs already) but acknowledge it makes life simpler than defining type extensions and composing CE from consumer standpoint.

As I expect it will be not so widespread, using ¡ as a prefix may be meaningful and maybe more readable?

The main drawback is i vs ¡, and maybe it is a breaking change.

konst-sh commented 1 year ago

Can't we automatically decide which builder should be used based on type of the expression? In this case something like combined CE might be possible:

async seq {
    // Expression that are Async<_> or Seq<_> are unwrapped to internal type, the concrete builder used for binding is decided from type of the expression
    let! x = ...
    // Expressions that are Async<Seq<_>> or Seq<Async<_>> are unwrapped to type of inner wrapper
    let!! y = ...
}
vzarytovskii commented 1 year ago

That would be a combinatoric nightmare to try and do it "universally" for any given composition of any given builder. Not to mention that it will complicate the understanding of the feature.

vzarytovskii commented 1 year ago

Can't we automatically decide which builder should be used based on type of the expression? In this case something like combined CE might be possible:

async seq {
    // Expression that are Async<_> or Seq<_> are unwrapped to internal type, the concrete builder used for binding is decided from type of the expression
    let! x = ...
    // Expressions that are Async<Seq<_>> or Seq<Async<_>> are unwrapped to type of inner wrapper
    let!! y = ...
}

This particular example will not work, since seq is not a computational expression.

Thorium commented 1 year ago

The syntax is perfectly clear, not everyone has to understand what happens behind the scenes. Could this be implemented by some easier way with extending a builder syntax a bit, rather than solving generic monad transformers?

seq { async { ... } } : Seq<Async<int>>
async { seq { ...} } : Async<Seq<int>>

There is already "and!" which uses MergeSources: (M<'T1> * M<'T2>) -> M<'T1 * 'T2> and there is already "for" which uses For: seq<'T> * ('T -> M<'U>) -> M<'U>

I have to say I don't know all the implementation details and I'm not a language designer, but let me draft an example:

What could happen behind the scenes, let's add one additional combine: seq<M<T>> -> M<T>

module SeqBuilder =
   Combine xs = xs |> Seq.concat

module AsyncBuilder =
   Combine xs = xs |> Async.Sequential

outerBuilder {
   ¡do!!
   let tmp1 = innerBuilder { ..  }
   ¡do!!
   let tmp2 =  innerBuilder { ..  }
   ¡do!!
   return tmp1.Combine(tmp2)
}

Let(innerBuilder { ..  }, fun tmp1 ->
   Bind(whatever
     Let(innerBuilder { ..  }, fun tmp2 ->
       Bind(whatever
         Return(Combine([|tmp1;tmp2|]))))))

Do you even need that? Couldn't you just always do Seq.