Computation expressions and wrapper types
In the previous post, we were introduced to the "maybe" workflow, which allowed us to hide the messiness of chaining together option types.
A typical use of the "maybe" workflow looked something like this:
let result =
maybe
{
let! anInt = expression of Option<int>
let! anInt2 = expression of Option<int>
return anInt + anInt2
}
As we saw before, there is some apparently strange behavior going on here:
In the
let!
lines, the expression on the right of the equals is anint option
, but the value on the left is just anint
. Thelet!
has "unwrapped" the option before binding it to the value.And in the
return
line, the opposite occurs. The expression being returned is anint
, but the value of the whole workflow (result
) is anint option
. That is, thereturn
has "wrapped" the raw value back into an option.
We will follow up these observations in this post, and we will see that this leads to one of the major uses of computation expressions: namely, to implicitly unwrap and rewrap values that are stored in some sort of wrapper type.
Another example
Let's look at another example. Say that we are accessing a database, and we want to capture the result in a Success/Error union type, like this:
type DbResult<'a> =
| Success of 'a
| Error of string
We then use this type in our database access methods. Here are some very simple stubs to give you an idea of how the DbResult
type might be used:
let getCustomerId name =
if (name = "")
then Error "getCustomerId failed"
else Success "Cust42"
let getLastOrderForCustomer custId =
if (custId = "")
then Error "getLastOrderForCustomer failed"
else Success "Order123"
let getLastProductForOrder orderId =
if (orderId = "")
then Error "getLastProductForOrder failed"
else Success "Product456"
Now let's say we want to chain these calls together. First get the customer id from the name, and then get the order for the customer id, and then get the product from the order.
Here's the most explicit way of doing it. As you can see, we have to have pattern matching at each step.
let product =
let r1 = getCustomerId "Alice"
match r1 with
| Error _ -> r1
| Success custId ->
let r2 = getLastOrderForCustomer custId
match r2 with
| Error _ -> r2
| Success orderId ->
let r3 = getLastProductForOrder orderId
match r3 with
| Error _ -> r3
| Success productId ->
printfn "Product is %s" productId
r3
Really ugly code. And the top-level flow has been submerged in the error handling logic.
Computation expressions to the rescue! We can write one that handles the branching of Success/Error behind the scenes:
type DbResultBuilder() =
member this.Bind(m, f) =
match m with
| Error _ -> m
| Success a ->
printfn "\tSuccessful: %s" a
f a
member this.Return(x) =
Success x
let dbresult = new DbResultBuilder()
And with this workflow, we can focus on the big picture and write much cleaner code:
let product' =
dbresult {
let! custId = getCustomerId "Alice"
let! orderId = getLastOrderForCustomer custId
let! productId = getLastProductForOrder orderId
printfn "Product is %s" productId
return productId
}
printfn "%A" product'
And if there are errors, the workflow traps them nicely and tells us where the error was, as in this example below:
let product'' =
dbresult {
let! custId = getCustomerId "Alice"
let! orderId = getLastOrderForCustomer "" // error!
let! productId = getLastProductForOrder orderId
printfn "Product is %s" productId
return productId
}
printfn "%A" product''
The role of wrapper types in workflows
So now we have seen two workflows (the maybe
workflow and the dbresult
workflow), each with their own corresponding wrapper type (Option<T>
and DbResult<T>
respectively).
These are not just special cases. In fact, every computation expression must have an associated wrapper type. And the wrapper type is often designed specifically to go hand-in-hand with the workflow that we want to manage.
The example above demonstrates this clearly. The DbResult
type we created is more than just a simple type for return values; it actually has a critical role in the workflow by "storing" the current state of the workflow, and whether it is succeeding or failing at each step. By using the various cases of the type itself, the dbresult
workflow can manage the transitions for us, hiding them from view and enabling us to focus on the big picture.
We'll learn how to design a good wrapper type later in the series, but first let's look at how they are manipulated.
Bind and Return and wrapper types
Let's look again at the definition of the Bind
and Return
methods of a computation expression.
We'll start off with the easy one, Return
. The signature of Return
as documented on MSDN is just this:
member Return : 'T -> M<'T>
In other words, for some type T
, the Return
method just wraps it in the wrapper type.
Note: In signatures, the wrapper type is normally called M
, so M<int>
is the wrapper type applied to int
and M<string>
is the wrapper type applied to string
, and so on.
And we've seen two examples of this usage. The maybe
workflow returns a Some
, which is an option type, and the dbresult
workflow returns Success
, which is part of the DbResult
type.
// return for the maybe workflow
member this.Return(x) =
Some x
// return for the dbresult workflow
member this.Return(x) =
Success x
Now let's look at Bind
. The signature of Bind
is this:
member Bind : M<'T> * ('T -> M<'U>) -> M<'U>
It looks complicated, so let's break it down. It takes a tuple M<'T> * ('T -> M<'U>)
and returns a M<'U>
, where M<'U>
means the wrapper type applied to type U
.
The tuple in turn has two parts:
M<'T>
is a wrapper around typeT
, and'T -> M<'U>
is a function that takes a unwrappedT
and creates a wrappedU
.
In other words, what Bind
does is:
- Take a wrapped value.
- Unwrap it and do any special "behind the scenes" logic.
- Then, optionally apply the function to the unwrapped value to create a new wrapped value.
- Even if the function is not applied,
Bind
must still return a wrappedU
.
With this understanding, here are the Bind
methods that we have seen already:
// return for the maybe workflow
member this.Bind(m,f) =
match m with
| None -> None
| Some x -> f x
// return for the dbresult workflow
member this.Bind(m, f) =
match m with
| Error _ -> m
| Success x ->
printfn "\tSuccessful: %s" x
f x
Look over this code and make sure that you understand why these methods do indeed follow the pattern described above.
Finally, a picture is always useful. Here is a diagram of the various types and functions:
- For
Bind
, we start with a wrapped value (m
here), unwrap it to a raw value of typeT
, and then (maybe) apply the functionf
to it to get a wrapped value of typeU
. - For
Return
, we start with a value (x
here), and simply wrap it.
The type wrapper is generic
Note that all the functions use generic types (T
and U
) other than the wrapper type itself, which must be the same throughout. For example, there is nothing stopping the maybe
binding function from taking an int
and returning a Option<string>
, or taking a string
and then returning an Option<bool>
. The only requirement is that it always return an Option<something>
.
To see this, we can revisit the example above, but rather than using strings everywhere, we will create special types for the customer id, order id, and product id. This means that each step in the chain will be using a different type.
We'll start with the types again, this time defining CustomerId
, etc.
type DbResult<'a> =
| Success of 'a
| Error of string
type CustomerId = CustomerId of string
type OrderId = OrderId of int
type ProductId = ProductId of string
The code is almost identical, except for the use of the new types in the Success
line.
let getCustomerId name =
if (name = "")
then Error "getCustomerId failed"
else Success (CustomerId "Cust42")
let getLastOrderForCustomer (CustomerId custId) =
if (custId = "")
then Error "getLastOrderForCustomer failed"
else Success (OrderId 123)
let getLastProductForOrder (OrderId orderId) =
if (orderId = 0)
then Error "getLastProductForOrder failed"
else Success (ProductId "Product456")
Here's the long-winded version again.
let product =
let r1 = getCustomerId "Alice"
match r1 with
| Error e -> Error e
| Success custId ->
let r2 = getLastOrderForCustomer custId
match r2 with
| Error e -> Error e
| Success orderId ->
let r3 = getLastProductForOrder orderId
match r3 with
| Error e -> Error e
| Success productId ->
printfn "Product is %A" productId
r3
There are a couple of changes worth discussing:
- First, the
printfn
at the bottom uses the "%A" format specifier rather than "%s". This is required because theProductId
type is a union type now. - More subtly, there seems to be unnecessary code in the error lines. Why write
| Error e -> Error e
? The reason is that the incoming error that is being matched against is of typeDbResult<CustomerId>
orDbResult<OrderId>
, but the return value must be of typeDbResult<ProductId>
. So, even though the twoError
s look the same, they are actually of different types.
Next up, the builder, which hasn't changed at all except for the | Error e -> Error e
line.
type DbResultBuilder() =
member this.Bind(m, f) =
match m with
| Error e -> Error e
| Success a ->
printfn "\tSuccessful: %A" a
f a
member this.Return(x) =
Success x
let dbresult = new DbResultBuilder()
Finally, we can use the workflow as before.
let product' =
dbresult {
let! custId = getCustomerId "Alice"
let! orderId = getLastOrderForCustomer custId
let! productId = getLastProductForOrder orderId
printfn "Product is %A" productId
return productId
}
printfn "%A" product'
At each line, the returned value is of a different type (DbResult<CustomerId>
,DbResult<OrderId>
, etc), but because they have the same wrapper type in common, the bind works as expected.
And finally, here's the workflow with an error case.
let product'' =
dbresult {
let! custId = getCustomerId "Alice"
let! orderId = getLastOrderForCustomer (CustomerId "") //error
let! productId = getLastProductForOrder orderId
printfn "Product is %A" productId
return productId
}
printfn "%A" product''
Composition of computation expressions
We've seen that every computation expression must have an associated wrapper type. This wrapper type is used in both Bind
and Return
, which leads to a key benefit:
- the output of a
Return
can be fed to the input of aBind
In other words, because a workflow returns a wrapper type, and because let!
consumes a wrapper type, you can put a "child" workflow on the right hand side of a let!
expression.
For example, say that you have a workflow called myworkflow
. Then you can write the following:
let subworkflow1 = myworkflow { return 42 }
let subworkflow2 = myworkflow { return 43 }
let aWrappedValue =
myworkflow {
let! unwrappedValue1 = subworkflow1
let! unwrappedValue2 = subworkflow2
return unwrappedValue1 + unwrappedValue2
}
Or you can even "inline" them, like this:
let aWrappedValue =
myworkflow {
let! unwrappedValue1 = myworkflow {
let! x = myworkflow { return 1 }
return x
}
let! unwrappedValue2 = myworkflow {
let! y = myworkflow { return 2 }
return y
}
return unwrappedValue1 + unwrappedValue2
}
If you have used the async
workflow, you probably have done this already, because an async workflow typically contains other asyncs embedded in it:
let a =
async {
let! x = doAsyncThing // nested workflow
let! y = doNextAsyncThing x // nested workflow
return x + y
}
Introducing "ReturnFrom"
We have been using return
as a way of easily wrapping up an unwrapped return value.
But sometimes we have a function that already returns a wrapped value, and we want to return it directly. return
is no good for this, because it requires an unwrapped type as input.
The solution is a variant on return
called return!
, which takes a wrapped type as input and returns it.
The corresponding method in the "builder" class is called ReturnFrom
. Typically the implementation just returns the wrapped type "as is" (although of course, you can always add extra logic behind the scenes).
Here is a variant on the "maybe" workflow to show how it can be used:
type MaybeBuilder() =
member this.Bind(m, f) = Option.bind f m
member this.Return(x) =
printfn "Wrapping a raw value into an option"
Some x
member this.ReturnFrom(m) =
printfn "Returning an option directly"
m
let maybe = new MaybeBuilder()
And here it is in use, compared with a normal return
.
// return an int
maybe { return 1 }
// return an Option
maybe { return! (Some 2) }
For a more realistic example, here is return!
used in conjunction with divideBy
:
// using return
maybe
{
let! x = 12 |> divideBy 3
let! y = x |> divideBy 2
return y // return an int
}
// using return!
maybe
{
let! x = 12 |> divideBy 3
return! x |> divideBy 2 // return an Option
}
Summary
This post introduced wrapper types and how they related to Bind
, Return
and ReturnFrom
, the core methods of any builder class.
In the next post, we'll continue to look at wrapper types, including using lists as wrapper types.