Part of the "Computation Expressions" series (more)

In this post we're going to look at returning multiple values from a computation expression using the Combine method.

The story so far...

So far, our expression builder class looks like this:

type TraceBuilder() =
    member this.Bind(m, f) = 
        match m with 
        | None -> 
            printfn "Binding with None. Exiting."
        | Some a -> 
            printfn "Binding with Some(%A). Continuing" a
        Option.bind f m

    member this.Return(x) = 
        printfn "Returning a unwrapped %A as an option" x
        Some x

    member this.ReturnFrom(m) = 
        printfn "Returning an option (%A) directly" m
        m

    member this.Zero() = 
        printfn "Zero"
        None

    member this.Yield(x) = 
        printfn "Yield an unwrapped %A as an option" x
        Some x

    member this.YieldFrom(m) = 
        printfn "Yield an option (%A) directly" m
        m
        
// make an instance of the workflow                
let trace = new TraceBuilder()

And this class has worked fine so far. But we are about to run into a problem...

A problem with two 'yields'

Previously, we saw how yield could be used to return values just like return.

Normally, yield is not used just once, of course, but multiple times in order to return values at different stages of a process such as an enumeration. So let's try that:

trace { 
    yield 1
    yield 2
    } |> printfn "Result for yield then yield: %A" 

But uh-oh, we get an error message:

This control construct may only be used if the computation expression builder defines a 'Combine' method.

And if you use return instead of yield, you get the same error.

trace { 
    return 1
    return 2
    } |> printfn "Result for return then return: %A" 

And this problem occurs in other contexts too. For example, if we want to do something and then return, like this:

trace { 
    if true then printfn "hello" 
    return 1
    } |> printfn "Result for if then return: %A" 

We get the same error message about a missing 'Combine' method.

Understanding the problem

So what's going on here?

To understand, let's go back to the behind-the-scenes view of the computation expression. We have seen that return and yield are really just the last step in a series of continuations, like this:

Bind(1,fun x -> 
   Bind(2,fun y -> 
     Bind(x + y,fun z -> 
        Return(z)  // or Yield

You can think of return (or yield) as "resetting" the indentation, if you like. So when we return/yield and then return/yield again, we are generating code like this:

Bind(1,fun x -> 
   Bind(2,fun y -> 
     Bind(x + y,fun z -> 
        Yield(z)  
// start a new expression        
Bind(3,fun w -> 
   Bind(4,fun u -> 
     Bind(w + u,fun v -> 
        Yield(v)

But really this can be simplified to:

let value1 = some expression 
let value2 = some other expression 

In other words, we now have two values in our computation expression. And then the obvious question is, how should these two values be combined to give a single result for the computation expression as a whole?

This is a very important point. Return and yield do not generate an early return from a computation expression. No, the entire computation expression, all the way to the last curly brace, is always evaluated and results in a single value. Let me repeat that. Every part of the computation expression is always evaluated -- there is no short circuiting going on. If we want to short circuit and return early, we have to write our own code to do that (and we'll see how to do that later).

So, back to the pressing question. We have two expressions resulting in two values: how should those multiple values be combined into one?

Introducing "Combine"

The answer is by using the Combine method, which takes two wrapped values and combines them to make another wrapped value. Exactly how this works is up to us.

In our case, we are dealing specifically with int options, so one simple implementation that leaps to mind it just to add the numbers together. Each parameter is an option of course (the wrapped type), so we need to pick them apart and handle the four possible cases:

type TraceBuilder() =
    // other members as before

    member this.Combine (a,b) = 
        match a,b with
        | Some a', Some b' ->
            printfn "combining %A and %A" a' b' 
            Some (a' + b')
        | Some a', None ->
            printfn "combining %A with None" a' 
            Some a'
        | None, Some b' ->
            printfn "combining None with %A" b' 
            Some b'
        | None, None ->
            printfn "combining None with None"
            None

// make a new instance        
let trace = new TraceBuilder()

Running the test code again:

trace { 
    yield 1
    yield 2
    } |> printfn "Result for yield then yield: %A" 

But now we get a different error message:

This control construct may only be used if the computation expression builder defines a 'Delay' method

The Delay method is a hook that allows you to delay evaluation of a computation expression until needed -- we'll discuss this in detail very soon; but for now, let's create a default implementation:

type TraceBuilder() =
    // other members as before

    member this.Delay(f) = 
        printfn "Delay"
        f()

// make a new instance        
let trace = new TraceBuilder()

Running the test code again:

trace { 
    yield 1
    yield 2
    } |> printfn "Result for yield then yield: %A" 

And finally we get the code to complete.

Delay
Yield an unwrapped 1 as an option
Delay
Yield an unwrapped 2 as an option
combining 1 and 2
Result for yield then yield: Some 3

The result of the entire workflow is the sum of all the yields, namely Some 3.

If we have a "failure" in the workflow (e.g. a None), the second yield doesn't occur and the overall result is Some 1 instead.

trace { 
    yield 1
    let! x = None
    yield 2
    } |> printfn "Result for yield then None: %A" 

We can have three yields rather than two:

trace { 
    yield 1
    yield 2
    yield 3
    } |> printfn "Result for yield x 3: %A" 

The result is what you would expect, Some 6.

We can even try mixing up yield and return together. Other than the syntax difference, the overall effect is the same.

trace { 
    yield 1
    return 2
    } |> printfn "Result for yield then return: %A" 

trace { 
    return 1
    return 2
    } |> printfn "Result for return then return: %A" 

Using Combine for sequence generation

Adding numbers up is not really the point of yield, although you might perhaps use a similar idea for constructing concatenated strings, somewhat like StringBuilder.

No, yield is naturally used as part of sequence generation, and now that we understand Combine, we can extend our "ListBuilder" workflow (from last time) with the required methods.

  • The Combine method is just list concatenation.
  • The Delay method can use a default implementation for now.

Here's the full class:

type ListBuilder() =
    member this.Bind(m, f) = 
        m |> List.collect f

    member this.Zero() = 
        printfn "Zero"
        []
        
    member this.Yield(x) = 
        printfn "Yield an unwrapped %A as a list" x
        [x]

    member this.YieldFrom(m) = 
        printfn "Yield a list (%A) directly" m
        m

    member this.For(m,f) =
        printfn "For %A" m
        this.Bind(m,f)
        
    member this.Combine (a,b) = 
        printfn "combining %A and %A" a b 
        List.concat [a;b]

    member this.Delay(f) = 
        printfn "Delay"
        f()

// make an instance of the workflow                
let listbuilder = new ListBuilder()

And here it is in use:

listbuilder { 
    yield 1
    yield 2
    } |> printfn "Result for yield then yield: %A" 

listbuilder { 
    yield 1
    yield! [2;3]
    } |> printfn "Result for yield then yield! : %A" 

And here's a more complicated example with a for loop and some yields.

listbuilder { 
    for i in ["red";"blue"] do
        yield i
        for j in ["hat";"tie"] do
            yield! [i + " " + j;"-"]
    } |> printfn "Result for for..in..do : %A" 

And the result is:

["red"; "red hat"; "-"; "red tie"; "-"; "blue"; "blue hat"; "-"; "blue tie"; "-"]    

You can see that by combining for..in..do with yield, we are not too far away from the built-in seq expression syntax (except that seq is lazy, of course).

I would strongly encourage you to play around with this a bit until you are clear on what is going on behind the scenes. As you can see from the example above, you can use yield in creative ways to generate all sorts of irregular lists, not just simple ones.

Note: If you're wondering about While, we're going to hold off on it for a bit, until after we have looked at Delay in an upcoming post.

Order of processing for "combine"

The Combine method only has two parameters. So what happens when you combine more than two values? For example, here are four values to combine:

listbuilder { 
    yield 1
    yield 2
    yield 3
    yield 4
    } |> printfn "Result for yield x 4: %A" 

If you look at the output you can see that the values are combined pair-wise, as you might expect.

combining [3] and [4]
combining [2] and [3; 4]
combining [1] and [2; 3; 4]
Result for yield x 4: [1; 2; 3; 4]

A subtle but important point is that they are combined "backwards", starting from the last value. First "3" is combined with "4", and the result of that is then combined with "2", and so on.

Combine

Combine for non-sequences

In the second of our earlier problematic examples, we didn't have a sequence; we just had two separate expressions in a row.

trace { 
    if true then printfn "hello"  //expression 1
    return 1                      //expression 2
    } |> printfn "Result for combine: %A" 

How should these expressions be combined?

There are a number of common ways of doing this, depending on the concepts that the workflow supports.

Implementing combine for workflows with "success" or "failure"

If the workflow has some concept of "success" or "failure", then a standard approach is:

  • If the first expression "succeeds" (whatever that means in context), then use that value.
  • Otherwise use the value of the second expression.

In this case, we also generally use the "failure" value for Zero.

This approach is useful for chaining together a series of "or else" expressions where the first success "wins" and becomes the overall result.

if (do first expression)
or else (do second expression)
or else (do third expression)

For example, for the maybe workflow, it is common to return the first expression if it is Some, but otherwise the second expression, like this:

type TraceBuilder() =
    // other members as before
    
    member this.Zero() = 
        printfn "Zero"
        None  // failure
    
    member this.Combine (a,b) = 
        printfn "Combining %A with %A" a b
        match a with
        | Some _ -> a  // a succeeds -- use it
        | None -> b    // a fails -- use b instead
        
// make a new instance        
let trace = new TraceBuilder()

Example: Parsing

Let's try a parsing example with this implementation:

type IntOrBool = I of int | B of bool

let parseInt s = 
    match System.Int32.TryParse(s) with
    | true,i -> Some (I i)
    | false,_ -> None

let parseBool s = 
    match System.Boolean.TryParse(s) with
    | true,i -> Some (B i)
    | false,_ -> None

trace { 
    return! parseBool "42"  // fails
    return! parseInt "42"
    } |> printfn "Result for parsing: %A" 

We get the following result:

Some (I 42)

You can see that the first return! expression is None, and ignored. So the overall result is the second expression, Some (I 42).

Example: Dictionary lookup

In this example, we'll try looking up the same key in a number of dictionaries, and return when we find a value:

let map1 = [ ("1","One"); ("2","Two") ] |> Map.ofList
let map2 = [ ("A","Alice"); ("B","Bob") ] |> Map.ofList

trace { 
    return! map1.TryFind "A"
    return! map2.TryFind "A"
    } |> printfn "Result for map lookup: %A" 

We get the following result:

Result for map lookup: Some "Alice"

You can see that the first lookup is None, and ignored. So the overall result is the second lookup.

As you can see, this technique is very convenient when doing parsing or evaluating a sequence of (possibly unsuccessful) operations.

Implementing combine for workflows with sequential steps

If the workflow has the concept of sequential steps, then the overall result is just the value of the last step, and all the previous steps are evaluated only for their side effects.

In normal F#, this would be written:

do some expression
do some other expression 
final expression

Or using the semicolon syntax, just:

some expression; some other expression; final expression

In normal F#, each expression (other than the last) evaluates to the unit value.

The equivalent approach for a computation expression is to treat each expression (other than the last) as a wrapped unit value, and "pass it into" the next expression, and so on, until you reach the last expression.

This is exactly what bind does, of course, and so the easiest implementation is just to reuse the Bind method itself. Also, for this approach to work it is important that Zero is the wrapped unit value.

type TraceBuilder() =
    // other members as before

    member this.Zero() = 
        printfn "Zero"
        this.Return ()  // unit not None

    member this.Combine (a,b) = 
        printfn "Combining %A with %A" a b
        this.Bind( a, fun ()-> b )
        
// make a new instance        
let trace = new TraceBuilder()

The difference from a normal bind is that the continuation has a unit parameter, and evaluates to b. This in turn forces a to be of type WrapperType<unit> in general, or unit option in our case.

Here's an example of sequential processing that works with this implementation of Combine:

trace { 
    if true then printfn "hello......."
    if false then printfn ".......world"
    return 1
    } |> printfn "Result for sequential combine: %A" 

Here's the following trace. Note that the result of the whole expression was the result of the last expression in the sequence, just like normal F# code.

hello.......
Zero
Returning a unwrapped <null> as an option
Zero
Returning a unwrapped <null> as an option
Returning a unwrapped 1 as an option
Combining Some null with Some 1
Combining Some null with Some 1
Result for sequential combine: Some 1

Implementing combine for workflows that build data structures

Finally, another common pattern for workflows is that they build data structures. In this case, Combine should merge the two data structures in whatever way is appropriate. And the Zero method should create an empty data structure, if needed (and if even possible).

In the "list builder" example above, we used exactly this approach. Combine was just list concatenation and Zero was the empty list.

Guidelines for mixing "Combine" and "Zero"

We have looked at two different implementations for Combine for option types.

  • The first one used options as "success/failure" indicators, when the first success "won". In this case Zero was defined as None
  • The second one was sequential, In this case Zero was defined as Some ()

Both cases worked nicely, but was that luck, or are there are any guidelines for implementing Combine and Zero correctly?

First, note that Combine does not have to give the same result if the parameters are swapped. That is, Combine(a,b) need not be the same as Combine(b,a). The list builder is a good example of this.

On the other hand there is a useful rule that connects Zero and Combine.

Rule: Combine(a,Zero) should be the same as Combine(Zero,a) which should the same as just a.

To use an analogy from arithmetic, you can think of Combine like addition (which is not a bad analogy -- it really is "adding" two values). And Zero is just the number zero, of course! So the rule above can be expressed as:

Rule: a + 0 is the same as 0 + a is the same as just a, where + means Combine and 0 means Zero.

If you look at the first Combine implementation ("success/failure") for option types, you'll see that it does indeed comply with this rule, as does the second implementation ("bind" with Some()).

On the other hand, if we had used the "bind" implementation of Combine but left Zero defined as None, it would not have obeyed the addition rule, which would be a clue that we had got something wrong.

"Combine" without bind

As with all the builder methods, if you don't need them, you don't need to implement them. So for a workflow that is strongly sequential, you could easily create a builder class with Combine, Zero, and Yield, say, without having to implement Bind and Return at all.

Here's an example of a minimal implementation that works:

type TraceBuilder() =

    member this.ReturnFrom(x) = x

    member this.Zero() = Some ()

    member this.Combine (a,b) = 
        a |> Option.bind (fun ()-> b )

    member this.Delay(f) = f()

// make an instance of the workflow                
let trace = new TraceBuilder()

And here it is in use:

trace { 
    if true then printfn "hello......."
    if false then printfn ".......world"
    return! Some 1
    } |> printfn "Result for minimal combine: %A" 

Similarly, if you have a data-structure oriented workflow, you could just implement Combine and some other helpers. For example, here is a minimal implementation of our list builder class:

type ListBuilder() =

    member this.Yield(x) = [x]

    member this.For(m,f) =
        m |> List.collect f

    member this.Combine (a,b) = 
        List.concat [a;b]

    member this.Delay(f) = f()

// make an instance of the workflow                
let listbuilder = new ListBuilder()

And even with the minimal implementation, we can write code like this:

listbuilder { 
    yield 1
    yield 2
    } |> printfn "Result: %A" 

listbuilder { 
    for i in [1..5] do yield i + 2
    yield 42
    } |> printfn "Result: %A" 

A standalone "Combine" function

In a previous post, we saw that the "bind" function is often used as standalone function, and is normally given the operator >>=.

The Combine function too, is often used as a standalone function. Unlike bind, there is no standard symbol -- it can vary depending on how the combine function works.

A symmetric combination operation is often written as ++ or <+>. And the "left-biased" combination (that is, only do the second expression if the first one fails) that we used earlier for options is sometimes written as <++.

So here is an example of a standalone left-biased combination of options, as used in a dictionary lookup example.

module StandaloneCombine = 

    let combine a b = 
        match a with
        | Some _ -> a  // a succeeds -- use it
        | None -> b    // a fails -- use b instead

    // create an infix version
    let ( <++ ) = combine

    let map1 = [ ("1","One"); ("2","Two") ] |> Map.ofList
    let map2 = [ ("A","Alice"); ("B","Bob") ] |> Map.ofList

    let result = 
        (map1.TryFind "A") 
        <++ (map1.TryFind "B")
        <++ (map2.TryFind "A")
        <++ (map2.TryFind "B")
        |> printfn "Result of adding options is: %A"

Summary

What have we learned about Combine in this post?

  • You need to implement Combine (and Delay) if you need to combine or "add" more than one wrapped value in a computation expression.
  • Combine combines values pairwise, from last to first.
  • There is no universal implementation of Combine that works in all cases -- it needs to be customized according the particular needs of the workflow.
  • There is a sensible rule that relates Combine with Zero.
  • Combine doesn't require Bind to be implemented.
  • Combine can be exposed as a standalone function

In the next post, we'll add logic to control exactly when the internal expressions get evaluated, and introduce true short circuiting and lazy evaluation.

Comments

blog comments powered by Disqus