Computation expressions are a powerful feature of F#. We can use them for working with "effects" such as asynchronous data (through the async
and task
expression) as well as others such as result
and option
. Some expressions are built-in to F# whereas others are third-party libraries. Inside these blocks, you can use keywords such as let!
and do!
to perform "binds" over the effect - for Task, this would be the equivalent of await
in C#; for result
, this might be a railway-oriented programming-style flow taken care for you automatically.
task {
let! data = Database.loadCustomersAsync()
... // more logic here...
return
data
|> Array.map (fun customer -> customer.Name.ToUpper()) // returns a Task<string array>
}
However, another use-case for Computation Expressions is for custom keywords. These allow you to create what appear to be new commands that live inside a computation expression. For example:
customerCE {
deleteCustomer 456 // deletes customer 456 from the DB
loadCustomer 123 // loads customer 123 into memory
changeName "Isaac" // changes customer 123's name
setNameToUpper // upper case it
save // save to DB
}
CEs with custom keywords are not especially difficult to create - they are actually just plain classes that have an attribute on each operation that you wish to expose:
module CustomerHelpers =
type MyCustomerCE () =
member _.Yield _ = ()
[<CustomOperation "delete_customer">]
member _.DeleteCustomer (state, customerId:int) =
printfn "Deleting the customer"
state
[<CustomOperation "load_customer">]
member _.LoadCustomer (state, customerId:int) =
{| Id = customerId; Name = "Fred" |}
let customerCe = MyCustomerCE()
Here, we create an expression that has two custom keywords on it. The first performs some side effectful operation (deleting a customer). The second would load a customer from the database and return it. We can use it as follows:
open CustomerHelpers
let result = customerCe {
delete_customer 456
load_customer 123
}
result
here is now the value of the anonymous record returned by load_customer
.
Computation Expression State
Computation expressions are "stateful", although this is to an extent hidden from you - essentially, you can chain together calls, with the first argument to a custom operation being the state which was returned from the previous call. For example, if we add another method to the class to set the customer's name to upper case, we can chain it with the call to load_customer
:
[<CustomOperation "to_upper">]
member _.ToUpper (state:{|Id:int; Name : string|}) =
{| state with Name = state.Name.ToUpper() |}
let result = customerCe {
delete_customer 456
load_customer 123
to_upper
} // returns {| Id = 123; Name = "FRED" |}
Pros and cons of computation expressions
Computation expressions can be very useful for creating a highly readable, imperative-style chain of instructions. If you design the CE well enough, it's entirely possible for them to be readable (and even writable) by a non-developer. And as they are simply classes, you can use features such as constructors, extension methods, overloads and interfaces with them to create powerful - albeit complex - abstractions. However, there are also several drawbacks with them:
- They are expensive to create. You need to create custom operations and be aware of how to thread state through from one method to another.
- Error messages are difficult to understand. If you call a CE method incorrectly, the error messages that are returned can be misleading - with other lines in the CE being underlined by the compiler.
- Intellisense is sub-optimal. You do not get standard type-ahead intellisense in most IDEs.
For these reasons, I would advise you to think carefully about when and where to use computation expressions with custom keywords.
Summary
Computation Expressions are well known for being able to use let!
for e.g. awaiting async workloads. However, you can also create your own ones, not only for let!
-style workflows but also for domain-specific languages (DSLs) with custom keywords. Unfortunately, there's no such thing as a free lunch and - whilst custom operations with computation expressions look fancy - the editor tooling is somewhat lacking within them, so use them with care.