Saturday, December 18, 2021

[ADVENT 2021] Initial quick dive into F# 6 task computation expression

 Hi my blog readers!

First, personal announcement related to this blog:

If you follow my blog, you'll see that this blog is not quite often updated. Because many things have changed: Open Live Writer doesn't support anymore, and itself won't accept OAuth/OpenIDConnect anymore. Therefore I'm still searching for new place to blog, hopefully a new at least affordable, less than 80$ annually.

Ok, back to current blog! 

I'm humbly joining the tradition of F# Advent! This 2021, I focus on what's new on F# 6.0, and some additional notes on some of its new features.

New features of F# 6.0

I'm so excited to try F# 6.0! It is also at the same time of the release of Visual Studio 2022 and .NET 6.0!

Here are the interesting and important new features:

  1. The task { .. } computation expression to support general .NET Task (that usually done in C#/VB.NET)
  2. Simpler collection indexing syntax using expr[idx] instead of expr.[idx]
  3. Struct representations for partial active patterns (new attribute to have marking of partial active pattern as struct)

And others new features

  1. Overloaded custom operations in computation expressions
  2. "as" patterns
  3. Increased consistency of indentation and undentation in code
  4. Additional implicit conversions
  5. New number format for binary 

and many more! For more complete list, visit Microsoft F# Docs:

Now let's visit F# task computation expression.

F# task computation expression

NOTE: I might be biased, but this new task computation expression is the most important, because it brings closer compatibility with async-task based programming in C# and VB.

We all know that F# already has async computation expression. This existing async computation expression also has convenient functions to interop with Task, such as F#'s Async.StartAsTask:

The task computation exprerssion is better than the existing F# async computation when interop with Task not just the faster performance and easier debugging, but the interop is easier.

The term easier is actually translated as closer compatibility with Task. Why? Let's see the sample code in the Docs:

let readFilesTask (path1, path2) =
   task {
        let! bytes1 = File.ReadAllBytesAsync(path1)
        let! bytes2 = File.ReadAllBytesAsync(path2)
        return Array.append bytes1 bytes2

We now can call those async API like File.ReadAllBytesAsync(path1) with implicit await by having let! on the returning result.

To see what really happened, the task computation expression comes as TaskBuilder. This builder will generate the necessary IL within the task expression.

Let's see the generated C# decompiler: (I use free JetBrains DotPeek 2021.3)

We could see the similar pattern of C# async in that readFilesTask method  by observing the similar pattern of async state machine.

Then the IL goes further to return task, as in this generated IL method of readFilesTask that returns the Task:

  .method public static class [System.Runtime]System.Threading.Tasks.Task`1
      string path1,
      string path2
    ) cil managed
    .maxstack 4
    .locals init (
      [0] valuetype FSharp6NewFeatures.Say/readFilesTask@11 readFilesTask11,
      [1] valuetype FSharp6NewFeatures.Say/readFilesTask@11& local

    // [23 7 - 23 82]
    IL_0000: ldloca.s     readFilesTask11
    IL_0002: initobj      FSharp6NewFeatures.Say/readFilesTask@11

    // [24 7 - 24 64]
    IL_0008: ldloca.s     readFilesTask11
    IL_000a: stloc.1      // local

    // [26 7 - 26 26]
    IL_000b: ldloc.1      // local
    IL_000c: ldarg.1      // path2
    IL_000d: stfld        string FSharp6NewFeatures.Say/readFilesTask@11::path2

    // [28 7 - 28 26]
    IL_0012: ldloc.1      // local
    IL_0013: ldarg.0      // path1
    IL_0014: stfld        string FSharp6NewFeatures.Say/readFilesTask@11::path1

    // [30 7 - 30 73]
    IL_0019: ldloc.1      // local
    IL_001a: ldflda       valuetype [FSharp.Core]Microsoft.FSharp.Control.TaskStateMachineData`1 FSharp6NewFeatures.Say/readFilesTask@11::Data
    IL_001f: call         valuetype [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1 valuetype [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1::Create()
    IL_0024: stfld        valuetype [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1 valuetype [FSharp.Core]Microsoft.FSharp.Control.TaskStateMachineData`1::MethodBuilder

    // [32 7 - 32 75]
    IL_0029: ldloc.1      // local
    IL_002a: ldflda       valuetype [FSharp.Core]Microsoft.FSharp.Control.TaskStateMachineData`1 FSharp6NewFeatures.Say/readFilesTask@11::Data
    IL_002f: ldflda       valuetype [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1 valuetype [FSharp.Core]Microsoft.FSharp.Control.TaskStateMachineData`1::MethodBuilder
    IL_0034: ldloc.1      // local
    IL_0035: call         instance void valuetype [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1::Start(!!0/*valuetype FSharp6NewFeatures.Say/readFilesTask@11*/&)

    // [34 7 - 34 44]
    IL_003a: ldloc.1      // local
    IL_003b: ldflda       valuetype [FSharp.Core]Microsoft.FSharp.Control.TaskStateMachineData`1 FSharp6NewFeatures.Say/readFilesTask@11::Data
    IL_0040: ldflda       valuetype [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1 valuetype [FSharp.Core]Microsoft.FSharp.Control.TaskStateMachineData`1::MethodBuilder
    IL_0045: call         instance class [System.Runtime]System.Threading.Tasks.Task`1 valuetype [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1::get_Task()
    IL_004a: ret

  } // end of method Say::readFilesTask

Now that we can observe the generated IL after compiling the F# sample, that F# code sample is roughly (semantically) equivalent to this C#:

        public static async Task ReadFilesTask(string path1, string path2)
            var bytes1 = await File.ReadAllBytesAsync(path1);
            var bytes2 = await File.ReadAllBytesAsync(path2);
            var bytes3 = new Byte[bytes1.Length + bytes2.Length];
            // Equivalent logic for F# Array.append
            for (int i = 0; i < bytes1.Length; i++)
                bytes3[i] = bytes1[i];
            for (int i = 0; i < bytes2.Length; i++)
                bytes3[bytes1.Length + i] = bytes2[i];
            return bytes3;

As we see now, it is closer to what C# async has, and this feature also remove barrier to have close compatibility between F# and C# async. 

NOTE: thanks to vibrant F# users, in this first release of F# 6, there is still a bug: any call to will have undesired exception. The GitHub issue for this bug is available and the merged PR to fix this is also available!

Based on the current progress of that fix, this fix will be available in the upcoming release of .NET 6.0.200 at the same time with VS 2022 17.1.0 release.

But what if you want to use it now? We could just use Don Syme's temporary workaround available on that GitHub issue of that bug.

What are you waiting for? Let's start to code with F# 6.0 now, F# folks! And Merry Christmas and happy holiday! ❤️

No comments:

Post a Comment