Lets See What We Can do! with F# Computation Expressions • Andrew Browne • YOW! 2015
Andrew Browne demystifies F# Computation Expressions, showing how this powerful feature is a simple syntactic transformation. He demonstrates building custom expressions for handling optional values (maybe), asynchronous sequences, and even creating a Domain-Specific Language (DSL) with Free Monads for robust testing.
F# computation expressions are often perceived as an advanced feature, but they are fundamentally a straightforward mechanism for syntactic transformation. This allows developers to create powerful, domain-specific control flow structures that look like built-in language features.
Understanding the Core Concept
Computation expressions provide syntactic sugar over patterns of composing functions, particularly those that are monadic in nature. The F# compiler takes code written inside a special block and translates it into a series of method calls on a “builder” object. This happens before type-checking, meaning the only requirement is that the transformed code compiles.
Two familiar, built-in examples are seq for lazy sequences and async for asynchronous workflows.
- In a
seqexpression,yieldproduces a single item, whileyield!(yield bang) flattens a subsequence into the main sequence. - In an
asyncblock,let!(let bang) is used to execute an asynchronous operation and bind its result to a variable, making the code appear sequential and imperative while being non-blocking under the hood. The!is the key indicator that a computation expression is at work.
Building a Custom Expression: The maybe Monad
A classic example is creating a maybe expression to simplify working with F#’s Option type, which is either Some value or None. This expression allows chaining operations that can fail (return None) without explicit pattern matching at each step.
Consider this code inside a maybe block:
maybe {
let! name = lookupName "id"
let! age = lookupAge name
return sprintf "%s is %d" name age
}
If either lookupName or lookupAge returns None, the entire block short-circuits and evaluates to None.
The compiler transforms this into a series of nested calls to a Bind method on a maybeBuilder object. The final expression is wrapped in a Return call.
maybe.Bind(lookupName "id", fun name ->
maybe.Bind(lookupAge name, fun age ->
maybe.Return(sprintf "%s is %d" name age)
)
)
The maybeBuilder is a simple object with methods that define the behavior of the expression:
Return(x): Wraps a value, typically asSome x.Bind(opt, f): Takes an optionoptand a functionf(the continuation). IfoptisNone, it returnsNone. If it’sSome v, it appliesftov.
This pattern is directly analogous to Haskell’s do notation or Scala’s for expressions.
The Full Range of Compiler Transformations
The F# compiler recognizes a wide range of keywords within a computation expression and translates them into corresponding methods on the builder.
| Keyword(s) | Transformed Method Call | Purpose |
|---|---|---|
let! p = e |
builder.Bind(e, fun p -> ...) |
Binds the result of a wrapped computation. |
do! e |
builder.Bind(e, fun () -> ...) |
Executes a wrapped computation for its side effect. |
return e |
builder.Return(e) |
Wraps a final value in the computation. |
return! e |
builder.ReturnFrom(e) |
Returns from an existing wrapped computation. |
yield e |
builder.Yield(e) |
Yields a value in a sequence-like computation. |
yield! e |
builder.YieldFrom(e) |
Yields all values from a subsequence. |
for x in e do ... |
builder.For(e, fun x -> ...) |
Loops over a sequence. |
while e do ... |
builder.While(fun () -> e, ...) |
A standard while loop. |
use! p = e |
builder.Bind(e, fun p -> ...) |
Bind for a value that needs disposal (IDisposable). |
if ... else ... |
if ... then builder.Return(...) else ... |
Conditional logic. |
| (if without else) | builder.Zero() |
Provides a default value if an if has no else. |
Practical Use Case 1: Asynchronous Sequences
A powerful application is handling paged, asynchronous data sources. The goal is to fetch data page by page without blocking and without loading the entire dataset into memory, preserving both asynchrony and laziness.
A custom asyncSeq computation expression can be built around a data structure that represents an async stream. The builder for this expression allows you to write code that looks simple and sequential but correctly composes the asynchronous and lazy nature of the operation.
let getAllResults (getPage: int -> Async<seq<'T>>) =
asyncSeq {
let mutable pageNum = 0
let mutable isDone = false
while not isDone do
let! currentPage = getPage pageNum
if not (Seq.isEmpty currentPage) then
yield! currentPage
pageNum <- pageNum + 1
else
isDone <- true
}
This code fetches one page at a time (let!), yields its contents (yield!), and only proceeds to the next page when the consumer requests more data, all without blocking. Higher-order functions like filter or map can then be built on top of this asyncSeq expression, creating a fully composable library for handling async streams.
Practical Use Case 2: Building a DSL with Free Monads
Computation expressions are an excellent tool for creating Domain-Specific Languages (DSLs). By defining a computation expression, you can write programs that build up a pure data structure (an Abstract Syntax Tree) representing a sequence of operations. This separates the specification of the program from its execution.
This is the core idea behind the Free Monad pattern. For instance, to test a key-value store, you can define a keyValue language:
// This builds a data structure, it doesn't execute anything.
let myProgram =
keyValue {
let! maybeValue = readValue "mykey"
match maybeValue with
| Some (version, value) ->
let newValue = value + 1
do! writeValue "mykey" (version + 1) newValue
| None ->
do! writeValue "mykey" 0 1
return "Done"
}
This program can then be run using different interpreters:
- A Test Interpreter: Executes the program against a simple in-memory
Map, allowing for fast, deterministic unit tests without any real I/O. - A Production Interpreter: Translates the program steps into actual asynchronous calls to a real database (e.g., EventStore, Redis).
- A Concurrency Test Interpreter: Takes multiple programs and interleaves their execution steps to test for race conditions and other concurrency issues, which can be combined with property-based testing for powerful validation.
This approach provides immense power and flexibility, enabling robust testing of complex logic by abstracting away the underlying implementation details.