This third article in the series dedicated to F# computation expressions is a guide to writing F# computation expressions having a monoidal behavior.
Table of contents
Introduction
A monoidal CE can be identified by the usage of yield
and yield!
keywords.
Relationship with the monoid:
→ Hidden in the builder methods:
-
+
operation →Combine
method -
e
neutral element →Zero
method
Builder method signatures
Like we did for functional patterns, we use the generic type notation:
-
M<T>
: type returned by the CE -
Delayed<T>
: presented later 📍
// Method | Signature | CE syntax supported
Yield : T -> M<T> ; yield x
YieldFrom : M<T> -> M<T> ; yield! xs
Zero : unit -> M<T> ; if // without `else` // Monoid neutral element
Combine : M<T> * Delayed<T> -> M<T> // Monoid + operation
Delay : (unit -> M<T>) -> Delayed<T> ; // always required with Combine
// Other additional methods
Run : Delayed<T> -> M<T>
For : seq<T> * (T -> M<U>) -> M<U> ; for i in seq do yield ... ; for i = 0 to n do yield ...
(* or *) seq<M<U>>
While : (unit -> bool) * Delayed<T> -> M<T> ; while cond do yield...
TryWith : M<T> -> (exn -> M<T>) -> M<T> ; try/with
TryFinally: Delayed<T> * (unit -> unit) -> M<T> ; try/finally
CE monoidal vs comprehension
Comprehension definition
It is the concise and declarative syntax to build collections with control flow keywords
if
,for
,while
... and rangesstart..end
.
Comparison
- Similar syntax from caller perspective
- Distinct overlapping concepts
Minimal set of methods expected for each
- Monoidal CE:
Yield
,Combine
,Zero
- Comprehension:
For
,Yield
CE monoidal example: multiplication {}
Let's build a CE that multiplies the integers yielded in the computation body:
→ CE type: M<T> = int
• Monoid operation = *
• Neutral element = 1
type MultiplicationBuilder() =
member _.Zero() = 1
member _.Yield(x) = x
member _.Combine(x, y) = x * y
member _.Delay(f) = f () // eager evaluation
member m.For(xs, f) =
(m.Zero(), xs)
||> Seq.fold (fun res x -> m.Combine(res, f x))
let multiplication = MultiplicationBuilder()
let shouldBe10 = multiplication { yield 5; yield 2 }
let factorialOf5 = multiplication { for i in 2..5 -> i } // 2 * 3 * 4 * 5
Exercise
- Copy this snippet in vs code
- Comment out builder methods
- To start with an empty builder, add this line
let _ = ()
in the body. - After adding the first method, this line can be removed.
- To start with an empty builder, add this line
- Let the compiler errors in
shouldBe10
andfactorialOf5
guide you to add the relevant methods.
Desugaring
Desugared multiplication { yield 5; yield 2 }
:
// Original
let shouldBe10 =
multiplication.Delay(fun () ->
multiplication.Combine(
multiplication.Yield(5),
multiplication.Delay(fun () ->
multiplication.Yield(2)
)
)
)
// Simplified (without Delay)
let shouldBe10 =
multiplication.Combine(
multiplication.Yield(5),
multiplication.Yield(2)
)
Desugared multiplication { for i in 2..5 -> i }
:
// Original
let factorialOf5 =
multiplication.Delay (fun () ->
multiplication.For({2..5}, (fun _arg2 ->
let i = _arg2 in multiplication.Yield(i))
)
)
// Simplified
let factorialOf5 =
multiplication.For({2..5}, (fun i -> multiplication.Yield(i)))
Delayed<T>
type
Delayed<T>
represents a delayed computation and is used in these methods:
-
Delay
returns this type, hence defines it for the CE -
Combine
,Run
,While
andTryFinally
used it as input parameter
Delay : thunk: (unit -> M<T>) -> Delayed<T>
Combine : M<T> * Delayed<T> -> M<T>
Run : Delayed<T> -> M<T>
While : predicate: (unit -> bool) * Delayed<T> -> M<T>
TryFinally : Delayed<T> * finalizer: (unit -> unit) -> M<T>
-
Delay
is required by the presence ofCombine
. -
Delay
is called each time converting fromM<T>
toDelayed<T>
is needed. -
Delayed<T>
is internal to the CE.-
Run
is required at the end to get back theM<T>
... - ... only when
Delayed<T>
≠M<T>
, otherwise it can be omitted.
-
👉 Enables to implement laziness and short-circuiting at the CE level.
Example: lazy multiplication {}
with Combine
optimized when x = 0
type MultiplicationBuilder() =
member _.Zero() = 1
member _.Yield(x) = x
member _.Delay(thunk: unit -> int) = thunk // Lazy evaluation
member _.Run(delayedX: unit -> int) = delayedX () // Required to get a final `int`
member _.Combine(x: int, delayedY: unit -> int) : int =
match x with
| 0 -> 0 // 👈 Short-circuit for multiplication by zero
| _ -> x * delayedY ()
member m.For(xs, f) =
(m.Zero(), xs) ||> Seq.fold (fun res x -> m.Combine(res, m.Delay(fun () -> f x)))
Difference | Eager | Lazy |
---|---|---|
Delay return type |
int |
unit -> int |
Run |
Omitted | Required to get back an int
|
Combine 2nd parameter |
int |
unit -> int |
For calling Delay
|
Omitted | Explicit but not required here |
CE monoidal kinds
With multiplication {}
, we've seen a first kind of monoidal CE:
→ To reduce multiple yielded values into 1.
There is a second kind of monoidal CE:
→ To aggregate multiple yielded values into a collection.
→ Example: seq {}
returns a 't seq
.
CE monoidal to generate a collection
Let's build a list {}
monoidal CE!
type ListBuilder() =
member _.Zero() = [] // List.empty
member _.Yield(x) = [x] // List.singleton
member _.YieldFrom(xs) = xs
member _.Delay(thunk: unit -> 't list) = thunk () // eager evaluation
member _.Combine(xs, ys) = xs @ ys // List.append
member _.For(xs, f: _ seq) = xs |> Seq.collect f |> Seq.toList
let list = ListBuilder()
💡 Notes:
M<T>
is't list
→ type returned byYield
andZero
For
uses an intermediary sequence to collect the values returned byf
.
Let's test the CE to generate the list [begin; 16; 9; 4; 1; 2; 4; 6; 8; end]
(Desugared code simplified)
Comparison with the same expression in a list comprehension:
list { expr }
vs [ expr ]
:
-
[ expr ]
uses a hiddenseq
all through the computation and ends with atoList
- All methods are inlined:
Method | list { expr } |
[ expr ] |
---|---|---|
Combine |
xs @ ys => List.append |
Seq.append |
Yield |
[x] => List.singleton |
Seq.singleton |
Zero |
[] => List.empty |
Seq.empty |
For |
Seq.collect & Seq.toList
|
Seq.collect |
Conclusion
Monoidal computation expressions provide an elegant and powerful syntax for combining and aggregating values in F#. By implementing a builder with just a few key methods—Combine
and Zero
which correspond to the monoid's +
operation and e
neutral element, alongside Yield
, YieldFrom
, and For
methods to support comprehension syntax—you can either reduce values to a single result (similar to the Composite design pattern) or build collections with natural, imperative-like code. Additionally, leveraging the Delayed<T>
type enables optimization opportunities for both behavior and performance within your computation expressions.
Top comments (0)