fsharp / fslang-suggestions

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

Add if! (if-bang) keyword to computation expressions #863

Open jwosty opened 4 years ago

jwosty commented 4 years ago

Add if! (if-bang) keyword to computation expressions

I propose we extend computation expressions with a new keyword, if! (if-bang), that functions a let! (let-bang) combined with an if statement. For example, instead of writing:

let! shouldSolveWorldHunger= confirmAsync ("Solve world hunger?")
if shouldSolveWorldHunger then solveWorldHunger () else dont ()

or even:

match! confirmAsync("Solve world hunger?") with
| true -> solveWorldHunger ()
| false -> dont ()

We should be able to do:

if! confirmAsync("Solve world hunger?") then
    solveWorldHunger ()
else dont ()

Pros and Cons

Pros: Aids code readability in specialized cases.

Cons: It's work, and it increases complexity. (whether or not this is worth the effort for potentially such small return is up to debate / whoever would do it, but I figure it's worth filing [probably low priority :) ] since I am not aware of a prior suggestion -- would be brownie points for the F# language)

Extra information

S

Related suggestions: (put links to related suggestions here)

572

Affidavit (please submit!)

Please tick this by placing a cross in the box:

Please tick all that apply:

dsyme commented 4 years ago

This seems reasonable

nikonthethird commented 4 years ago

Should that include elif! as well?

let x = async {
    if! asyncBool then
        do! task1
    elif! anotherAsyncBool then
        do! task2
    else
        do! task3
}
yatli commented 4 years ago

@nikonthethird probably yes, and pretty effortless, since elif! = else if!

realvictorprm commented 4 years ago

Good catch, that's really quite reasonable! :3

mvkara commented 4 years ago

It would be good to consider other typical branching constructs as well; in particular the "while" keyword. A good use case would be using ADO.NET; not sure if this is already supported. Normally you would need a recursive function to do this - would be much easier with .

asyncSeq {
    // https://docs.microsoft.com/en-us/dotnet/api/system.data.common.dbdatareader.readasync?view=netcore-3.1
    while! reader.MoveNextAsync() do
        yield {| Column1 = reader.GetInt32(0); Column2 = reader.GetInt32(1) |}
}
Tarmil commented 4 years ago

@mvkara There's a major difference between while! (and for! I guess) on one hand, and match! and if! on the other.

So I think it's a good idea but probably belongs in a separate suggestion.

yatli commented 4 years ago

I agree with @Tarmil -- plus that it's likely these constructs should be designed to incorporate with IAsyncEnumerable

Lanayx commented 4 years ago

Wanted to add one more comment for discussion, not directly related to the topic, but not sure where to put it - it would be nice to support bang operations inside if without overhead of raising 0 to async:

async {
    let result = 
        if condition then
            let! x = doSmthAsyncReturningInt()
            x
        else
            0
    return result
}

this can be easily done in C#, but not F#:

var result = condition ? await doSmthAsyncReturningInt() : 0;
nikonthethird commented 4 years ago

It is actually possible to implement a while! today by adjusting the async expression builder. You just have to add another overload for while that accepts an asynchronous guard:

type AsyncBuilder with
    member this.While (guard, body) =
        let guard = guard ()
        let rec loop = function
        | false -> this.Zero ()
        | _ -> this.Combine (body, this.Bind (guard, loop))
        this.Bind (guard, loop)

Async.RunSynchronously (async {
    let mutable count = 5
    while async { return count > 0 } do
        do printfn "Counting down: %d" count
        do count <- count - 1
})
Tarmil commented 4 years ago

@Lanayx The problem in your code isn't the if actually; it's that you're putting a let! inside the definition part of a let, which means it's not part of the CE anymore. See for example:

// This does NOT work, even without an if, because there's a let! inside a let:
async {
    let sum =
        let! x = someAsyncCode()
        x + y
    return sum * 2
}

// This DOES work, even with the if:
async {
    if someCondition() then
        let! x = someAsyncCode()
        return x + y
    else
        return y * 2
}
Lanayx commented 4 years ago

@Tarmil Thanks for the explanation! However as you can see, the problem is not in my code, but in this nested limitation - C# doesn't have such limitation and it would be nice not to have it in F# either.

yatli commented 4 years ago

@Lanayx I think I understand the problem. But having different types on if-else branches feel wrong to me.

Lanayx commented 4 years ago

@yatli this is very helpful for caching scenarios - if local cache exists return cached value, otherwise make async call. To go over this limitation I had to use mutable variable

yatli commented 4 years ago

Hmm. Something like a new function Async.FromCompleted<'T>?

But I still think it's better to use the return directive because of type consistency.

It's just like you cannot write if cond then 1 else 0.5 -- and mandatory explicit typecast is a good thing.

Lanayx commented 4 years ago

@yatli yes, if this cast is free :) With return it is an additional allocation and method call, and when it's on the hot path (like in my case), this should be avoided.

sideeffffect commented 4 years ago

could this be done, in a short-circuiting way?

let x = async {
    if! asyncBool and asyncBool2 then
        do! task1
    elif! anotherAsyncBool or asyncBool3 then
        do! task2
    else
        do! task3
}
yatli commented 3 years ago

@Lanayx I don't think it's optimized that way... More like a syntax sugar that wraps 0 into the completed task.

#LINQPad optimize+

async void Main()
{
    await compute(3);
}

async System.Threading.Tasks.Task<int> realAsync() {
    await System.Threading.Tasks.Task.Delay(100);
    return 0;
}

async System.Threading.Tasks.Task<int> compute(int x) => x > 2 ? await realAsync() : 0;

Turns to:

realAsync:
IL_0000:  ldloca.s    00 
IL_0002:  call        System.Runtime.CompilerServices.AsyncTaskMethodBuilder<System.Int32>.Create
IL_0007:  stfld       UserQuery+<realAsync>d__5.<>t__builder
IL_000C:  ldloca.s    00 
IL_000E:  ldc.i4.m1   
IL_000F:  stfld       UserQuery+<realAsync>d__5.<>1__state
IL_0014:  ldloca.s    00 
IL_0016:  ldflda      UserQuery+<realAsync>d__5.<>t__builder
IL_001B:  ldloca.s    00 
IL_001D:  call        System.Runtime.CompilerServices.AsyncTaskMethodBuilder<System.Int32>.Start<<realAsync>d__5>
IL_0022:  ldloca.s    00 
IL_0024:  ldflda      UserQuery+<realAsync>d__5.<>t__builder
IL_0029:  call        System.Runtime.CompilerServices.AsyncTaskMethodBuilder<System.Int32>.get_Task
IL_002E:  ret         

compute:
IL_0000:  ldloca.s    00 
IL_0002:  call        System.Runtime.CompilerServices.AsyncTaskMethodBuilder<System.Int32>.Create
IL_0007:  stfld       UserQuery+<compute>d__6.<>t__builder
IL_000C:  ldloca.s    00 
IL_000E:  ldarg.0     
IL_000F:  stfld       UserQuery+<compute>d__6.<>4__this
IL_0014:  ldloca.s    00 
IL_0016:  ldarg.1     
IL_0017:  stfld       UserQuery+<compute>d__6.x
IL_001C:  ldloca.s    00 
IL_001E:  ldc.i4.m1   
IL_001F:  stfld       UserQuery+<compute>d__6.<>1__state
IL_0024:  ldloca.s    00 
IL_0026:  ldflda      UserQuery+<compute>d__6.<>t__builder
IL_002B:  ldloca.s    00 
IL_002D:  call        System.Runtime.CompilerServices.AsyncTaskMethodBuilder<System.Int32>.Start<<compute>d__6>
IL_0032:  ldloca.s    00 
IL_0034:  ldflda      UserQuery+<compute>d__6.<>t__builder
IL_0039:  call        System.Runtime.CompilerServices.AsyncTaskMethodBuilder<System.Int32>.get_Task
IL_003E:  ret   

<compute>d__6.MoveNext:
IL_0000:  ldarg.0     
IL_0001:  ldfld       UserQuery+<compute>d__6.<>1__state
IL_0006:  stloc.0     
IL_0007:  ldarg.0     
IL_0008:  ldfld       UserQuery+<compute>d__6.<>4__this
IL_000D:  stloc.1     
IL_000E:  ldloc.0     
IL_000F:  brfalse.s   IL_0051
IL_0011:  ldarg.0     
IL_0012:  ldfld       UserQuery+<compute>d__6.x
IL_0017:  ldc.i4.2    
IL_0018:  ble.s       IL_0078
IL_001A:  ldloc.1     
IL_001B:  call        UserQuery.realAsync
IL_0020:  callvirt    System.Threading.Tasks.Task<System.Int32>.GetAwaiter
IL_0025:  stloc.s     04 
IL_0027:  ldloca.s    04 
IL_0029:  call        System.Runtime.CompilerServices.TaskAwaiter<System.Int32>.get_IsCompleted
IL_002E:  brtrue.s    IL_006E
IL_0030:  ldarg.0     
IL_0031:  ldc.i4.0    
IL_0032:  dup         
IL_0033:  stloc.0     
IL_0034:  stfld       UserQuery+<compute>d__6.<>1__state
IL_0039:  ldarg.0     
IL_003A:  ldloc.s     04 
IL_003C:  stfld       UserQuery+<compute>d__6.<>u__1
IL_0041:  ldarg.0     
IL_0042:  ldflda      UserQuery+<compute>d__6.<>t__builder
IL_0047:  ldloca.s    04 
IL_0049:  ldarg.0     
IL_004A:  call        System.Runtime.CompilerServices.AsyncTaskMethodBuilder<System.Int32>.AwaitUnsafeOnCompleted<TaskAwaiter`1,<compute>d__6>
IL_004F:  leave.s     IL_00AB
IL_0051:  ldarg.0     
IL_0052:  ldfld       UserQuery+<compute>d__6.<>u__1
IL_0057:  stloc.s     04 
IL_0059:  ldarg.0     
IL_005A:  ldflda      UserQuery+<compute>d__6.<>u__1
IL_005F:  initobj     System.Runtime.CompilerServices.TaskAwaiter<System.Int32>
IL_0065:  ldarg.0     
IL_0066:  ldc.i4.m1   
IL_0067:  dup         
IL_0068:  stloc.0     
IL_0069:  stfld       UserQuery+<compute>d__6.<>1__state
IL_006E:  ldloca.s    04 
IL_0070:  call        System.Runtime.CompilerServices.TaskAwaiter<System.Int32>.GetResult
IL_0075:  stloc.3     
IL_0076:  br.s        IL_007A
IL_0078:  ldc.i4.0    
IL_0079:  stloc.3     
IL_007A:  ldloc.3     
IL_007B:  stloc.2     
IL_007C:  leave.s     IL_0097
IL_007E:  stloc.s     05 
IL_0080:  ldarg.0     
IL_0081:  ldc.i4.s    FE 
IL_0083:  stfld       UserQuery+<compute>d__6.<>1__state
IL_0088:  ldarg.0     
IL_0089:  ldflda      UserQuery+<compute>d__6.<>t__builder
IL_008E:  ldloc.s     05 
IL_0090:  call        System.Runtime.CompilerServices.AsyncTaskMethodBuilder<System.Int32>.SetException
IL_0095:  leave.s     IL_00AB
IL_0097:  ldarg.0     
IL_0098:  ldc.i4.s    FE 
IL_009A:  stfld       UserQuery+<compute>d__6.<>1__state
IL_009F:  ldarg.0     
IL_00A0:  ldflda      UserQuery+<compute>d__6.<>t__builder
IL_00A5:  ldloc.2     
IL_00A6:  call        System.Runtime.CompilerServices.AsyncTaskMethodBuilder<System.Int32>.SetResult
IL_00AB:  ret         

<compute>d__6.SetStateMachine:
IL_0000:  ldarg.0     
IL_0001:  ldflda      UserQuery+<compute>d__6.<>t__builder
IL_0006:  ldarg.1     
IL_0007:  call        System.Runtime.CompilerServices.AsyncTaskMethodBuilder<System.Int32>.SetStateMachine
IL_000C:  ret         

You see, IL_0076 is the last one from the realAsync branch -- before that, the result is unwrapped from GetResult. For the other branch, starting at IL_0078, it just loads 0. Nothing fancy. But after that, the two branches reunion, and the result is pushed into the return task's SetResult. So you get a task builder and a state machine, plus a task object for return value, no matter which branch you go -- kinda like the two return statements that raise the results from two branches from @Tarmil 's example.

Lanayx commented 3 years ago

@yatli Right, for simple case it's obvious that result should be wrapped in completed task. However in case we have multiple async calls this can be avoided, here is a sharplab

yatli commented 3 years ago

Thank you @Lanayx , I didn't know it could pull that off. The C# version is obviously advantageous in your case, because the IL simply jumps from one unwrapped result to the next if the right conditions are met.

Would you mind opening another thread to continue the discussion? There are both syntax and compiler optimization issues, but out of scope of this suggestion.

kurtschelfthout commented 3 years ago

Consider allowing an elaboration to a new If method builder. Such a method would be somewhere between Applicative and Monad in power, and so can allow e.g. more efficient implementation, or conservative static analysis. See https://www.staff.ncl.ac.uk/andrey.mokhov/selective-functors.pdf

If F# automatically desugars it to Bind - as I think this suggestion is suggesting - then the implementor of the computation expression can't take that opportunity.

Perhaps a related suggestion then is to automatically desugar "lower power" computation expression operations in terms of explicitly defined "higher power" operations if the lower power operations are not available. E.g if Bind is available then Map can be generated in terms of Bind, and so on.