Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Inline bind operator for computation expressions #1070

Open
5 tasks done
jwosty opened this issue Sep 3, 2021 · 20 comments
Open
5 tasks done

Inline bind operator for computation expressions #1070

jwosty opened this issue Sep 3, 2021 · 20 comments

Comments

@jwosty
Copy link
Contributor

jwosty commented Sep 3, 2021

I propose we add an operator or keyword which can be used to bind values in computation expressions deep inside expressions (potentially arbitrarily), similar to C#'s await keyword, which can be used in the middle of expressions, and is not limited to top-level expressions in the computation expression. The specific syntax is of lesser issue than the form of these expressions themselves. This proposal has been alluded up many times within the F# community.

For sake of discussion, I am going to use a bind! syntax in this proposal (which could easily be replaced with one of the alternative choices).

The following code:

if (f(bind! x) && bind! y) then // ...

would be lifted to something equivalent to:

let! x' = x
let! y' = y
if (f x' && y') then // ...

There are many more cases where this should likely work, such as:

// right-hand of let-expr
let x = bind! expr
// deep inside right-hand of let-expr
let x = (bind! expr) |> List.map string
// match expr
match bind! expr with

And so on -- there are likely more cases to consider and decide on.

However, this would not be allowed arbitrarily deep, as the following should obviously be disallowed:

async {
    let f = (fun () -> bind! expr) // compiler error -- not allowed inside lambdas
    let g x =
        bind! expr // compiler error -- not allowed inside nested functions
}

There are also probably other exclusions that should be fleshed out.

The current way to approach this is to manually lift the bindable expression and bind it using let!.

Alternative keywords

  • bind
  • bind!
  • await
  • await!
  • postfix !

Postfix bang would look something like:

if (f(a!) && b!) then // ...

Pros and Cons

The advantages of making this adjustment to F# are:

  • More parity with C#'s deeply nestable await keyword
  • Highly useful because it allows the elimination of many otherwise unnecessary variable names in source code
  • Reduces the urgency for more bang-keywords

The disadvantages of making this adjustment to F# are:

  • more complexity

Extra information

Estimated cost (XS, S, M, L, XL, XXL): L (I'm guessing)

Related suggestions: #572 (match-bang), #651 (finally-bang), #791 (pipe-bang), #863 (if-bang), #974 (function-bang), #1038 (while-bang)

Affidavit (please submit!)

Please tick this by placing a cross in the box:

  • This is not a question (e.g. like one you might ask on stackoverflow) and I have searched stackoverflow for discussions of this issue
  • I have searched both open and closed suggestions on this site and believe this is not a duplicate
  • This is not something which has obviously "already been decided" in previous versions of F#. If you're questioning a fundamental design decision that has obviously already been taken (e.g. "Make F# untyped") then please don't submit it.

Please tick all that apply:

  • This is not a breaking change to the F# language design
  • I or my company would be willing to help implement and/or test this

For Readers

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

@jwosty
Copy link
Contributor Author

jwosty commented Sep 3, 2021

Which syntax would you most prefer (vote using reactions)?

I believe that bang and await (without the bang) would both be backward-incompatible, so I have left them out of this vote.

  • bind! - 👍
  • await! - ❤️
  • postfix ! - 🚀

@baronfel
Copy link
Contributor

baronfel commented Sep 3, 2021

As an implementation detail, I think that the amount of binds in a complex expression could be reduced by using the let!/and! form if the CE builder supported it, because in the expression if f(a!) && b! then ... else ..., a's bind and b's bind are independent operations. I'm sure there are other opportunities for this kind of expansion as well, we've just been focusing on if here.

@jwosty
Copy link
Contributor Author

jwosty commented Sep 3, 2021

Advantages of each potential operator/keyword:

  • bind!
    • Same name as the CE Bind method, hinting to the monadic heritage
    • Has a symmetry with let! in that the bang comes before the expression it is binding
  • await!
    • Similar to C# and Javascript's async/await syntax -- would be a more familiar experience for C# devs learning F#
    • Symmetry with let!
  • postfix !
    • Less verbose
    • May reduce parentheses

Some side-by-side comparisons of each, for reference:

let! x = f expr
if x && y then ...

if (bind! x) && y then ...

if (await! x) && y then ...

if x! && y then ...

@dsyme
Copy link
Collaborator

dsyme commented Sep 3, 2021

Which syntax would you most prefer (vote using reactions)?

We would likely make it CE-configurable. But votes on preferred syntax for async, task, option, asyncOption, asyncSeq etc. would be welcome.

@jwosty
Copy link
Contributor Author

jwosty commented Sep 3, 2021

Which syntax would you most prefer (vote using reactions)?

We would likely make it CE-configurable. But votes on preferred syntax for async, task, option, asyncOption, asyncSeq etc. would be welcome.

So more like a custom operation? Sounds interesting. How are you envisioning that to work, specifically?

@dsyme
Copy link
Collaborator

dsyme commented Sep 3, 2021

So more like a custom operation? Sounds interesting. How are you envisioning that to work, specifically?

Yes I'd imagine either a new method or a new attribute on the CE builder. I've not thought about it more than that though.

@SchlenkR
Copy link

SchlenkR commented Sep 3, 2021

Related to #1000

@lambdakris
Copy link

lambdakris commented Oct 21, 2021

Gotta say, I'm really liking the idea of this proposal quite a bit. Here's what I imagine it might look like in use for a couple of diff builders (I picked check as the keyword for the imaginary option and result builders).

type FooBar = { 
    Foo: obj
    Bar: obj 
}

let taskDemo = task {
    let foo = await getFoo ()
    let bar = await getBar foo
    return { 
        Foo = foo
        Bar = bar 
    }
}

let asyncDemo = async {
    return {
        Foo = await getFoo ()
        Bar = await getBar ()
    }
}

let resultDemo = result {
    let foo = check getFoo ()
    let bar = check getBar foo
    return {
        Foo = foo
        Bar = bar
    }
}

let optionDemo = option {
    return {
        Foo = check getFoo ()
        Bar = check getBar ()
    }
}

let taskResultDemo = taskResult {
    let foo = awaitCheck getFoo ()
    let bar = check (await getBar foo)
    return {
        Foo = foo
        Bar = bar
    }
}

let asyncOptionDemo = asyncOption {
    return {
        Foo = awaitCheck getFoo ()
        Bar = check (await getBar ())
    }
}

Here are a couple of other candidate words that could be used instead of check for other builders:

elicit
verify
attain
resolve
solve
scan
tap

I kinda like tap from both a semantic standpoint (tap def: "exploit or draw a supply from a resource") and an aesthetic standpoint (use of 3 letters seems to adhere to an F# keyword naming rhythm). To be honest though, I actually wouldn't mind using bind for every/any kind of builder just for consistency.

@lambdakris
Copy link

lambdakris commented Oct 21, 2021

Also, would it be worth considering allowing devs to define their own binding terms for custom builders?

I'm imagining a scenario like the following:

let demoAttempt = attempt {
    let v = bind getThing() // returns Async<Option<Result<'T>>>
    let asyncV = bindAsync getThing()
    let optionV = bindOption asyncV
    let resultV = bindResult optionV
    return v, asyncV, optionV, resultV
}

That way it seems like one could create builders that would allow the user to control to what degree stacked values are bound?

@jl0pd
Copy link

jl0pd commented Nov 13, 2021

It would be awesome if bind could go beyond binding single value:

let await2Tasks t1 t2 = // Task<'a> -> Task<'b> -> Task<('a * 'b)>
    task {
        let (r1, r2) = 
             bind t1
             and t2
        return (r1, r2)
    }
// which is identical to
let await2Tasks t1 t2 = // Task<'a> -> Task<'b> -> Task<('a * 'b)>
    task { 
       let! _ = Task.WhenAll(t1, t2)
       return (t1.Result, t2.Result) 
    }

Right hand side of bind and and is any expression that can be bound with let!

let get42Async () =
    task {
          let (four, ten, two) =
                bind Task.Run (fun () -> 4)
                and Task.Run (fun () -> 10)
                and Task.Run (fun () -> 2)
          return four * ten + two
    }

@Tarmil
Copy link

Tarmil commented Nov 16, 2021

@jl0pd I don't think this would need anything extra if there's a bind operator, you could just do this:

task {
    let (four, ten, two) =
        (bind Task.Run(fun () -> 4),
         bind Task.Run(fun () -> 10),
         bind Task.Run(fun () -> 2))
    return four * ten + two
}

What I wonder, though, is whether the above should automatically use applicative methods (MergeSources / BindReturn) if they are defined, or be restricted to Bind.

@lambdakris
Copy link

lambdakris commented Nov 16, 2021

Hmm, @Tarmil and @jl0pd, would it make sense to use tailing inline bind's to invoke Bind and use tailing inline and's to invoke applicative methods (MergeSources/BindReturn)? Being a bit of a novice with computation expressions, I wonder if that would be:

  1. A useful capability to have?
  2. Congruent with the current behavior of applicative expressions?

@jl0pd
Copy link

jl0pd commented Nov 17, 2021

What I wonder, though, is whether the above should automatically use applicative methods (MergeSources / BindReturn) if they are defined, or be restricted to Bind.

@Tarmil, it should not automatically use MergeSources because developer may rely on sequential execution, e.g. createUser and then setAdditinalInfo. If last is going to execute before first (which may happen with Task.WhenAll) then it may fail

@kspeakman
Copy link

Love this suggestion. Found it after working with task CEs and feeling the need for new bang syntax like while! and if!. Then thought rather than making a bang version of everything, why not have 1 keyword that semantically unwraps the value like await does in C#? (Recognizing that it's no less complicated to implement behind the scenes.)

Re names. "bind" is an obstacle for newcomers. As a vague/archaic word, it harms readability in a similar way to using symbols. And its meaning has more to do with its implementation than its apparent usage. "await" is fantastic for readability of asyncs, less for other things. A more general fit for the user's intent might be "unwrap". Postfix bang x! is subtle / easy to miss. Also consider multi-term expressions:

while (channel.Reader.WaitToReadAsync())! do ...

while await! channel.Reader.WaitToReadAsync() do ...

@jwosty The side-by-side examples seem to use extra parenthesis: if (await! x) && y then .... Do CE keywords have/follow order of precedence rules? If so, if await! x && y then ... should work.

@jwosty
Copy link
Contributor Author

jwosty commented Dec 7, 2022

Love this suggestion. Found it after working with task CEs and feeling the need for new bang syntax like while! and if!. Then thought rather than making a bang version of everything, why not have 1 keyword that semantically unwraps the value like await does in C#? (Recognizing that it's no less complicated to implement behind the scenes.)

@kspeakman Agreed, this proposal could obsolete all the special-case bang keywords

The side-by-side examples seem to use extra parenthesis: if (await! x) && y then .... Do CE keywords have/follow order of precedence rules? If so, if await! x && y then ... should work.

That might be true; I just included them for clarity (I tend to overuse parenthesis :) )

@dsyme
Copy link
Collaborator

dsyme commented Apr 13, 2023

I have marked this as approved-in-principle. We should do this in some form

@njlr
Copy link

njlr commented Apr 3, 2024

I was considering opening a new ticket, but I think it falls under here.

Consider this applicative Computation Expression. The idea is to collect errors, rather than stop on the first one:

type ResultApplicativeBuilder() =
  member this.Return(x) =
    Ok x

  member this.MergeSources(a, b) =
    match a, b with
    | Ok x, Ok y -> Ok (x, y)
    | Error e, Error d -> Error (e @ d)
    | Error e, _ -> Error e
    | _, Error d -> Error d

  member this.BindReturn(m, f) =
    Result.map f m

let resultA = ResultApplicativeBuilder()

type Resources =
  {
    Wood : int
    Food : int
    Gold : int
    Stone : int
  }

let resources =
  resultA {
    let! w = Ok 1
    and! f = Error [ "foo" ]
    and! g = Error [ "bar" ]
    and! s = Ok 3

    return
      {
        Wood = w
        Food = f
        Gold = g
        Stone = s
      }
  }

printfn $"Resources: %A{resources}"

It would be great if listing out the intermediate variables were optional.

(Hypothetical syntax)

let resources =
  resultA {
    return
      {
        Wood =! Ok 1
        Food =! Error [ "foo" ]
        Gold =! Error [ "bar" ]
        Stone =! Ok 3
      }
  }

The code is more concise and we don't risk muddling up the intermediary bindings.


With !! syntax:

let resources =
  resultA {
    return
      {
        Wood = !! (Ok 1)
        Food = !! (Error [ "foo" ])
        Gold = !! (Error [ "bar" ])
        Stone = !! (Ok 3)
      }
  }

@SchlenkR
Copy link

This is the most wanted feature of me. It would simplify the code I write for some libraries a lot.

Example: Signal processing, where parameters have to be modulated often:

let! mod = Osc.sine(frq = 120.0)
return! Osc.rect(frq = 4000.0 * mod)

// ...with inline bind (here using a prefix op "!!" just for demo)
return! Osc.rect(frq = 4000.0 * (!!Osc.sine(frq = 120.0)))

Another example: Arithmetic operations, which would reduce the need for custom operators and / or SRTP overload "hacks". The advantage here is the inline-style of writing the operands as one would expect it in an equation.

// nice: we can use "+" as we would expect it to be used :)
let result1 = !!Osc.square(frq = 100.0) + !!Osc.sine(200.0)
let result2 = 100.0 + !!Osc.sine(200.0)

// current alt. 1:
let! a = Osc.square(frq = 100.0)
let! b = Osc.sine(200.0)
let result1 = a + b

// current alt. 1 using custom op (here: ".+", ".+." or "+."), which can a pain regarding all the complexity that numbers introduce
let result1 = Osc.square(frq = 100.0) .+. Osc.sine(200.0)
let result2 = 100.0 +. Osc.sine(200.0)
// ...

Currently, I am currently developing a UI library that works like react, and uses only Skia under the hood, where one can define triggers, animations, whatever kind of state-preserving functions. Using animations inline, e.g. for the x-coord of a UI element, would reduce the brain-load, make it so nice to write just in one-shot. That would be great.

I like how C# async is being able to be inlined; it's a quite seamless integration and feels really natural. I don't care if it's an operator or a keyword, but usable as simple as inlined async in C#.

If this could be defined, I would perhaps dare to implement it. cc @vzarytovskii @dsyme ?

@dsyme
Copy link
Collaborator

dsyme commented May 3, 2024

I'm marking this as approved-in-principle as a language design item. I think !! would be appropriate.

Implementing it requires a significant amount of work.

@OnurGumus
Copy link

I'm marking this as approved-in-principle as a language design item. I think !! would be appropriate.

Implementing it requires a significant amount of work.

Please note that, !! is somewhat common in Fable code which is shorthand for unbox<_>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

10 participants