F#
F#
I started working at a job last year where we mainly use F#. It’s a fairly unique language that takes some getting used to. After almost a year working in it, I’ve decided to start writing on my learning experience, in the hopes that future F# developers don’t have to struggle as much as I did. I’d like to start with a more advanced topic, Computation Expressions.
Computation Expressions
Computation Expressions (CEs) are an incredibly useful feature of F#. They allow developers to write succinct code in a way that’s easy to read, and importantly avoid long function callback chains (if you’re familiar with promises in JavaScript, think back to the days of callback hell).
While many examples online–and even in Microsoft’s own documentation–show you how to build a CE, they don’t always show you what’s happening under the hood, so that’s what I’ll focus on in this post. If you’re looking for an introductory series, I highly recommend the Computation Expression Series by F# For Fun and Profit.
Demistifying CEs
The important thing to remember when trying to understand a CE, it’s that they’re just builders under the hood. In fact, CEs aren’t even keywords, they are just an instance of a builder with a conventation based “interface”–wrapped in quotes, because there’s no explicit interface definition. The functions and their behaviors are described here.
Here’s what the definitions of the task
and backgroundTask
CEs look like (Source).
Yup. That’s it.
Breaking Down a Simple CE
Let’s look at the first example from Microsoft’s documentation
If we were to write this same code without a CE, here’s what it would look like
It’s clear to see from this example that the former is much easier to read, but that it’s not all that complex to implement without the CE.
To show the real benefits, let’s take a look at a much longer CE.
|
|
And here’s the closest “equivalent” of that without CEs that I could come up with
|
|
It should be obvious from the above example how much easier to read the former is when compared to the latter. Both return the same results and run to completion, outputing "Installing updates!"
.
Gotchas
CEs are only in scope for the blocks immediately within the CE (with a couple of exceptions, that we’ll get to later in the post). For example, the following will not compile.
You’ll see the following error:
The reason we see this error is because we began a new block underneath let results =
, thus creating a new scope. In order to use the benefits of the CE, we’ll have to invoke it again.
Generally, though, we want to avoid such code, as it can start becoming hard to read. The recommendation here would be to extract the logic of the nested scope out to a separate function.
By the same token, you cannot mix and match CEs with each other. Once you open a new CE, the previous CE moves out of scope.
For example
Produces the following error
The compiler assumes we’re still in the seq
CE, so it doesn’t understand that we’re trying to call and await a Task
. To fix this, we must again reintroduce the task
CE
However, this causes an unexpected side effect. Instead of results
being a sequence of processedData
values, what we actually get is Task<seq<Task<'a>>>
, where urls: seq<'a>
.
In order to deal with such situations, you’ll likely need to build a custom CE to handle the composition of multiple CEs together, rethink the structure of your code to avoid mixing the CEs (eg, using mutable
to build out a mutable list, and get Task<'a list>
), or use a 3rd-party solution, such as the FSharp.Control.TaskSeq library, which has already dealt with the complexity and bugs of combinind multiple CEs.
|
|
Note the addition of the type hint urls: seq<'a>
above. This is necessary, because taskSeq
supports both seq
and taskSeq
types in for ... in ... do
statements, so we need to be explicit so the compiler understands which overload of for
to use. Also, we cannot use yield! task { ... }
, but this is simply because TaskSeqBuilder
doesn’t implement _.YieldFrom
.