TryJoinads (V.) - Implementing the option joinad
This article shows how to implement the joinad structure for one of the
simplest monads - the option<'T>
type. This is a slightly oversimplified example.
The match!
construct can be used to write patterns that specify that a monadic
value (in this case option<'T>
) should contain a certain value, or we can specify
that we do not require a value. When working with options, this means the same thing
as matching the value against Some
and against _
, respectively.
However, the example demonstrates the operations that need to be implemented and their type signatures. Later articles give more interesting examples including parsers and asynchronous workflows (and you can explore other examples if you look at the FSharp.Joiands source code at GitHub).
Note: This blog post is a re-publication of a tutorial from the TryJoinads.org web page. If you read the article there, you can run the examples interactively and experiment with them: view the article on TryJoinads.
Implementing the maybe builder
To implement computation builder that supports the match!
construct, we first create
a usual F# computation builder with the standard members that are used by F# today. For
options, we provide Bind
, Return
and ReturnFrom
to support the let!
construct,
return
and return!
:
/// Computation builder for option values that /// defines standard members for monads type MaybeBuilder() = member x.Bind(v, f) = Option.bind f v member x.Return(a) = Some a member x.ReturnFrom(o) = o
To support match!
we need to add five additional members. The most important members are
Merge
(to allow matching on multiple values in a single clause), Choose
and Fail
(to allow
multiple clauses). The members Delay
and Run
do not have to be
implemented, but should be provided to handle side-effects in clause bodies.
/// Adds implementation of joinad operations type MaybeBuilder with /// Combine two options into option of a tuple member x.Merge(v1, v2) = match v1, v2 with | Some a, Some b -> Some (a, b) | _ -> None /// Return the first option that contains a value member x.Choose(v1, v2) = match v1 with | Some(v1) -> Some(v1) | _ -> v2 /// Represents a failed computation member x.Fail() = None // Creating & executing delayed computations member x.Delay(f) = f member x.Run(f) = f() // Create an instance of the computation builder let maybe = new MaybeBuilder()
The computation builder implements most of the operations in the usual way. The Delay
operation
can be implemented in two ways. For computations that can wrap a function unit -> M<'T>
into a
computation value M<'T>
(without running it), we could wrap the function in Delay
and don't
include Run
. The above sample uses another approach which is to return the function passed to
Delay
as it is (as a function) and add Run
member that runs the function.
Other members needed for the match!
construct are:
The
Fail
operation creates a computation that never produces any value. For options, this is the None value. This is quite similar to theZero
member, but it may have a different meaning for some types.The
Merge
operation has a typeM<'T> -> M<'U> -> M<'T * 'U>
. It takes two computations and returns a single computation that contains a tuple with the values. If any of the options passed as arguments isNone
, the result will also beNone
. If both of the options areSome
, then we can get both values and return a tuple wrapped inSome
. For some computations including options, this operation can be implemented usingBind
andReturn
. However, this doesn't work for all computation.Finally, the
Choose
operation has a typeM<'T> -> M<'T> -> M<'T>
. The intuitive explanation of the operation is that it should return the "first available value" and prefer values from the first argument. For options this means that if the first argument isSome
, we return its value (wrapped inSome
). Otherwise, we return the value of the second argument. The operation returnsNone
only if both of the arguments areNone
.
Now that we have a computation builder maybe
, let's write some interesting computation.
We look how the example looks using the match!
syntactic sugar and then explain how the translation works...
Implementing three-valued logic
As a simple example, we look how to implement the logical "or" operation for a
three-valued logic.
As the name suggests, the logic works with three values (true, false and unknown). We can represent
values of the three-valued logic using option<bool>
and take None
to represent the unknown value.
The unknown value means that the value may be either true or false, but we don't know which one.
The "or" operator reflects this interpretation - for example, when we write false || unknown
, the result
is unknown
(it may be either true
or false
depending on the second value).
The following snippet uses match!
to implement the "or" operator as defined in the
Kleene logic:
/// Logical 'or' operator for ternary (Kleene) logic let kleeneOr a b = maybe { match! a, b with | true, ? -> return true | ?, true -> return true | a, b -> return a || b } // Print truth table for the ternary operator for a in [Some true; None; Some false] do for b in [Some true; None; Some false] do printfn "%A or %A = %A" a b (kleeneOr a b)
The example is quite easy to read. When using match!
, the patterns we write in clauses can be of two forms.
The form <pat>
(binding pattern) means that the computation must produce a value and the value should match
the usual F# pattern <pat>
. In case of options, true
essentially means that the value should be Some(true)
.
The form ?
(ignore pattern) specifies that we don't care about the value (there may or may not be some value).
For options, this means the same thing as underscore _
(wildcard pattern) used in the standard match
construct.
The first two clauses encode the situation when one of the values is true
. In that case, we immediately know
that the result of the "or" operation is also true
. The last case handles the case when both values are known -
in that case, we simply use the standard logical or. In all remaining cases that are not covered by any of the patterns,
the result is unknown (or None
).
When using joinads to work with option values, we don't get any interesting benefits. The true power of the
construct is that it can be used for working with other interesting types of computations. This is possible,
because match!
is just a syntactic sugar that is translated to the primitives of the computation builder.
Translation of joinads
The desugaring of match!
is slightly more sophisticated than the desugaring of let!
and other standard constructs.
The idea is that we combine all inputs for a clause (horizontally) using the Merge
operation. Then
we select the first matching clause (vertically) using the Choose
operation. A pattern matching failure
is represented using the Fail
operation. The above example will be translated to the following code:
// Sample inputs let a = Some true let b = None // Translation of individual clauses - inputs are combined // using 'Merge' and body is wrapped using 'Delay' let cl1 = maybe.Bind(a, function | true -> maybe.Return(maybe.Delay(fun () -> maybe.Return(true))) | _ -> maybe.Fail() ) let cl2 = maybe.Bind(b, function | true -> maybe.Return(maybe.Delay(fun () -> maybe.Return(true))) | _ -> maybe.Fail() ) let cl3 = maybe.Bind(maybe.Merge(a, b), fun (a, b) -> maybe.Return(maybe.Delay(fun () -> maybe.Return(true)))) // Clauses are combined using 'Choose' and selected // delayed clause is then evaluated using 'Run' maybe.Bind(maybe.Choose(maybe.Choose(cl1, cl2), cl3), fun r -> maybe.Run(r))
When translating joinads, the modified F# compiler works in two steps:
Every clause is translated into a separate computation of type
M<M<'T>>
(orM<unit -> M<'T>>
when usingDelay
that returns a function as in this example). The inner computation represents the body to be executed when the clause is selected. To create this computation, the compiler first merges are inputs that appear in binding patterns using Merge. Then it usesBind
to transform the input into eitherReturn
(containing a delayed body when the value matches a pattern) orFail
(when the produced value doesn't match).After translating the clauses, the compiler constructs an expression that selects the first clause that contains or produces body (the terminology is intentionally vaugue, because the meaning depends on the type of computation). This is done by combining all clauses in the top-to-bottom order using
Choose
. The result is a computationM<M<'T>>
(orM<unit -> M<'T>>
for delayed functions), so we need to unwrap the body. This is done by passing the overall result as an argument toBind
. In the continuation, we apply theRun
operation, which can unwrap the delayed computation (for computations that are delayed by design, the Run operation isn't required).
Summary
This article demonstrated how to add the joinad structure to the maybe monad. This is a very basic example, but it demonstrated what operations need to be implemented. We also looked how the translation works, so you should have enough information to implement your own joinads. The upcoming two articles will give more complex examples and also cover an additional feature that is needed, for example, for asynchronous workflows.
The best way to understand how the translation works is to experiment with it. At the moment,
the only specification for the translation is the modified source code itself, because
it includes various small tweaks that are not documented in the publications.
You can find the changes in the tc.fs
file on GitHub.
class
new : unit -> MaybeBuilder
member Bind : v:'c option * f:('c -> 'd option) -> 'd option
member Choose : v1:'a option * v2:'a option -> 'a option
member Delay : f:'a -> 'a
member Fail : unit -> 'a option
member Merge : v1:'a option * v2:'b option -> ('a * 'b) option
member Return : a:'b -> 'b option
member ReturnFrom : o:'a -> 'a
member Run : f:(unit -> 'a) -> 'a
end
Full name: TryJoinads.MaybeBuilder
Computation builder for option values that
defines standard members for monads
Full name: TryJoinads.MaybeBuilder.Bind
type: 'c option
from Microsoft.FSharp.Core
Full name: Microsoft.FSharp.Core.Option.bind
Full name: TryJoinads.MaybeBuilder.Return
Full name: TryJoinads.MaybeBuilder.ReturnFrom
Full name: TryJoinads.MaybeBuilder.Merge
Combine two options into option of a tuple
type: 'a option
type: 'b option
Full name: TryJoinads.MaybeBuilder.Choose
Return the first option that contains a value
type: 'a option
Full name: TryJoinads.MaybeBuilder.Fail
Represents a failed computation
Full name: TryJoinads.MaybeBuilder.Delay
Full name: TryJoinads.MaybeBuilder.Run
Full name: TryJoinads.maybe
Full name: TryJoinads.kleeneOr
Logical 'or' operator for ternary (Kleene) logic
type: bool option
type: bool option
type: bool
inherits: System.ValueType
type: bool
inherits: System.ValueType
Full name: Microsoft.FSharp.Core.ExtraTopLevelOperators.printfn
Full name: TryJoinads.a
type: bool option
Full name: TryJoinads.b
Full name: TryJoinads.cl1
type: (unit -> bool option) option
Represents a failed computation
Full name: TryJoinads.cl2
type: (unit -> bool option) option
Full name: TryJoinads.cl3
type: (unit -> bool option) option
Combine two options into option of a tuple
Return the first option that contains a value
Published: Friday, 2 March 2012, 1:24 PM
Author: Tomas Petricek
Typos: Send me a pull request!
Tags: f#, research, joinads