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 →Combinemethod -
eneutral element →Zeromethod
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
shouldBe10andfactorialOf5guide 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:
-
Delayreturns this type, hence defines it for the CE -
Combine,Run,WhileandTryFinallyused 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>
-
Delayis required by the presence ofCombine. -
Delayis called each time converting fromM<T>toDelayed<T>is needed. -
Delayed<T>is internal to the CE.-
Runis 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 byYieldandZeroForuses 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 hiddenseqall 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)