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)