πŸš€Computation expression (CE)

Intro

Presentation

πŸ”— Learn F# - Computation Expressions, by Microsoft:

  1. Computation expressions in F# provide a convenient syntax for writing computations that can be sequenced and combined using control flow constructs and bindings.

  2. Depending on the kind of computation expression, they can be thought of as a way to express monads, monoids, monad transformers, and applicatives.

Note

monads, monoids, monad transformers, and applicatives

These are Functional patterns, except monad transformers πŸ“

Built-in CEs: async and task, seq, query β†’ Easy to use, once we know the syntax and its keywords

We can write our own CE too. β†’ More challenging!

Syntax

CE = block like myCE { body } where body looks like imperative F# code with:

  • regular keywords: let, do, if/then/else, match, for...

  • dedicated keywords: yield, return

  • "banged" keywords: let!, do!, match!, yield!, return!

These keywords hide a ❝ machinery ❞ to perform background specific effects:

  • Asynchronous computations like with async and task

  • State management: e.g. a sequence with seq

  • Absence of value with option CE

  • ...

Builder

A computation expression relies on an object called Builder.

For each supported keyword (let!, return...), the Builder implements one or more related methods.

☝️ Compiler accepts flexibility in the builder method signature, as long as the methods can be chained together properly when the compiler evaluates the CE on the caller side. β†’ βœ… Versatile, ⚠️ Difficult to design and to test β†’ Given method signatures illustrate only typical situations.

Builder example: logger {}

Need: log the intermediate values of a calculation

⚠️ Issues

  1. Verbose: the log x interfere with reading

  2. Error prone: forget a log, log wrong value...

πŸ’‘ Solutions

Make logs implicit in a CE by implementing a custom let! / Bind() :

The 3 consecutive let! are desugared into 3 nested calls to Bind with:

  • 1st argument: the right side of the let! (e.g. 42 with let! x = 42)

  • 2nd argument: a lambda taking the variable defined at the left side of the let! (e.g. x) and returning the whole expression below the let! until the }

Bind vs let!

logger { let! var = expr in cexpr } is desugared as: logger.Bind(expr, fun var -> cexpr)

πŸ‘‰ Key points:

  • var and expr appear in reverse order

  • var is used in the rest of the computation cexpr β†’ highlighted using the in keyword of the verbose syntax

  • the lambda fun var -> cexpr is a continuation function

CE desugaring: tips πŸ’‘

I found a simple way to desugar a computation expression: β†’ Write a failing unit test and use Unquote - πŸ”— Example

Constructor parameters

The builder can be constructed with additional parameters. β†’ The CE syntax allows us to pass these arguments when using the CE:

Builder example: option {}

Need: successively try to find in maps by identifiers β†’ Steps:

  1. roomRateId in policyCodesByRoomRate map β†’ find policyCode

  2. policyCode in policyTypesByCode map β†’ find policyType

  3. policyCode and policyType β†’ build result

Implementation #1: based on match expressions

Implementation #2: based on Option module helpers

πŸ‘‰ Issues ⚠️:

  • Nesting too

  • Even more difficult to read because of parentheses

Implementation #3: based on the option {} CE

πŸ‘‰ Both terse and readable βœ…πŸŽ‰

CE monoidal

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

CE monoidal 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 πŸ“

CE monoidal vs comprehension

Comprehension definition

It is the concise and declarative syntax to build collections with control flow keywords if, for, while... and ranges start..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

Exercise

Desugared multiplication { yield 5; yield 2 }:

Desugared multiplication { for i in 2..5 -> i }:

CE monoidal 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 and TryFinally used it as input parameter

  • Delay is called each time converting from M<T> to Delayed<T> is needed

  • Delayed<T> is internal to the CE

    • Run is required at the end to get back the M<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

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.

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!

Notes πŸ’‘

  • M<T> is 't list β†’ type returned by Yield and Zero

  • For uses an intermediary sequence to collect the values returned by f.

Let's test the CE to generate the list [begin; 16; 9; 4; 1; 2; 4; 6; 8; end] &#xNAN;(Desugared code simplified)

Comparison with the same expression in a list comprehension:

list { expr } vs [ expr ]:

  • [ expr ] uses a hidden seq all through the computation and ends with a toList

  • 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

CE monadic

A monadic CE can be identified by the usage of let! and return keywords, revealing the monadic bind and return operations.

Behind the scene, builders of these CE should/can implement these methods:

CE monadic vs CE monoidal

Return (monad) vs Yield (monoid)

  • Same signature: T -> M<T>

  • A series of return is not expected β†’ Monadic Combine takes only a monadic command M<unit> as 1st param

  • CE enforces appropriate syntax by implementing 1! of these methods:

    • seq {} allows yield but not return

    • async {}: vice versa

For and While

Method
CE
Signature

For

Monoidal

seq<T> * (T -> M<U>) -> M<U> or seq<M<U>>

Monadic

seq<T> * (T -> M<unit>) -> M<unit>

While

Monoidal

(unit -> bool) * Delayed<T> -> M<T>

Monadic

(unit -> bool) * (unit -> M<unit>) -> M<unit>

πŸ‘‰ Different use cases:

  • Monoidal: Comprehension syntax

  • Monadic: Series of effectful commands

CE monadic and delayed

Like monoidal CE, monadic CE can use a Delayed<'t> type. β†’ Impacts on the method signatures:

CE monadic examples

☝️ The initial CE studiedβ€”logger {} and option {}β€”was monadic.

Let's play with a result {} CE!

Desugaring:

CE monadic: FSharpPlus monad CE

FSharpPlus provides a monad CE

  • Works for all monadic types: Option, Result, ... and even Lazy πŸŽ‰

  • Supports monad stacks with monad transformers πŸ“

⚠️ Limits:

  • Confusing: the monad CE has 4 flavours to cover all cases: delayed or strict, embedded side-effects or not

  • Based on SRTP: can be very long to compile!

  • Documentation not exhaustive, relying on Haskell knowledges

  • Very Haskell-oriented: not idiomatic Fβ™―

Monad stack, monad transformers

A monad stack is a composition of different monads. β†’ Example: Async+Option.

How to handle it? β†’ Academic style vs idiomatic Fβ™―

1. Academic style (with FSharpPlus)

Monad transformer (here MaybeT) β†’ Extends Async to handle both effects β†’ Resulting type: MaybeT<Async<'t>>

βœ… reusable with other inner monad ❌ less easy to evaluate the resulting value ❌ not idiomatic

2. Idiomatic style

Custom CE asyncOption, based on the async CE, handling Async<Option<'t>> type

⚠️ Limits: not reusable, just copiable for asyncResult for instance

CE Applicative

An applicative CE is revealed through the usage of the and! keyword (Fβ™― 5).

An applicative CE builder should define these methods:

CE Applicative example - validation {}

Usage: validate a customer

  • Name not null or empty

  • Height strictly positive

Desugaring:

CE Applicative trap

⚠️ The compiler accepts that we define ValidationBuilder without BindReturn but with Bind and Return. But in this case, we can loose the applicative behavior and it enables monadic CE bodies!

CE Applicative - FsToolkit validation {}

FsToolkit.ErrorHandling offers a similar validation {}.

The desugaring reveals the definition of more methods: Delay, Run, SourceπŸ“

Source methods

In FsToolkit validation {}, there are a couple of Source defined:

  • The main definition is the id function.

  • Another overload is interesting: it converts a Result<'a, 'e> into a Validation<'a, 'e>. As it's defined as an extension method, it has a lower priority for the compiler, leading to a better type inference. Otherwise, we would need to add type annotations.

☝️ Note: Source documentation is scarce. The most valuable information comes from a question on stackoverflow mentioned in FsToolkit source code!

Creating CEs

Types

The CE builder methods definition can involve not 2 but 3 types:

  • The wrapper type M<T>

  • The Delayed<T> type

  • An Internal<T> type

M<T> wrapper type

☝️ We use the generic type notation M<T> to indicate any of these aspects: generic or container.

Examples of candidate types:

  • Any generic type

  • Any monoidal, monadic, or applicative type

  • string as it contains chars

  • Any type itself as type Identity<'t> = 't – see previous logger {} CE

Delayed<T> type

  • Return type of Delay

  • Parameter to Run, Combine, While, TryWith, TryFinally

  • Default type when Delay is not defined: M<T>

  • Common type for a real delay: unit -> M<T> - see member _.Delay f = f

Delayed<T> type example: eventually {}

Union type used for both wrapper and delayed types:

The output values are maint to be evaluated interactively, step by step:

Internal<T> type

Return, ReturnFrom, Yield, YieldFrom, Zero can return a type internal to the CE. Combine, Delay, and Run handle this type.

πŸ’‘ Highlights the usefulness of ReturnFrom, YieldFrom, implemented as an identity function until now.

Builder methods without type

Example: activity {} CE to configure an Activity without passing the instance

  • Type with builder extension methods: System.Diagnostics.Activity

  • Return type: unit (no value returned)

  • Internal type involved: type ActivityAction = delegate of Activity -> unit

  • CE behaviour:

    • monoidal internally: composition of ActivityAction

    • like a State monad externally, with only the setter(s) part

  • The activity instance supports the CE syntax thanks to its extensions.

  • The extension methods are marked as not EditorBrowsable for proper DevExp.

  • Externally, the activity is implicit in the CE body, like a State monad.

  • Internally, the state is handled as a composition of ActivityAction.

  • The final Run enables us to evaluate the built ActivityAction, resulting in the change (mutation) of the activity (the side effect).

Custom operations πŸš€

What: builder methods annotated with [<CustomOperation("myOperation")>]

Use cases: add new keywords, build a custom DSL β†’ Example: the query core CE supports where and select keywords like LINQ

⚠️ Warning: you may need additional things that are not well documented:

  • Additional properties for the CustomOperation attribute:

    • AllowIntoPattern, MaintainsVariableSpace

    • IsLikeJoin, IsLikeGroupJoin, JoinConditionWord

    • IsLikeZip...

  • Additional attributes on the method parameters, like [<ProjectionParameter>]

πŸ”— Computation Expressions Workshop: 7 - Query Expressions | GitHub

CE creation guidelines πŸ“ƒ

  • Choose the main behaviour: monoidal? monadic? applicative?

    • Prefer a single behaviour unless it's a generic/multi-purpose CE

  • Create a builder class

  • Implement the main methods to get the selected behaviour

  • Use/Test your CE to verify it compiles (see typical compilation errors below), produces the expected result, and performs well.

CE creation tips πŸ’‘

  • Get inspired by existing codebases that provide CEs - examples:

    • FSharpPlus β†’ monad

    • FsToolkit.ErrorHandling β†’ option, result, validation

    • Expecto: Testing library (test "..." {...})

    • Farmer: Infra as code for Azure (storageAccount {...})

    • Saturn: Web framework on top of ASP.NET Core (application {...})

  • Overload methods to support more use cases like different input types

    • Async<Result<_,_>> + Async<_> + Result<_,_>

    • Option<_> and Nullable<_>

CE benefits βœ…

  • Increased Readability: imperative-like code, DSL (Domain Specific Language)

  • Reduced Boilerplate: hides a "machinery"

  • Extensibility: we can write our own "builder" for specific logic

CE limits ⚠️

  • Compiler error messages within a CE body can be cryptic

  • Nesting different CEs can make the code more cumbersome

    • E.g. async + result

    • Alternative: custom combining CE - see asyncResult in FsToolkit

  • Writing our own CE can be challenging

    • Implementing the right methods, each the right way

    • Understanding the underlying concepts

πŸ” Quiz

Question 1: What is the primary purpose of computation expressions in F#?

A. To replace all functional programming patterns

B. To provide imperative-like syntax for sequencing and combining computations

C. To eliminate the need for type annotations

D. To make F# code compatible with C#

Answer

B. To provide imperative-like syntax for sequencing and combining computations βœ…

Question 2: Which keywords identify a monadic computation expression?

A. yield and yield!

B. let! and return

C. let! and and!

D. do! and while

Answer

A. yield and yield! keywords identify a monoidal CE βœ–οΈ

B. let! and return keywords identify a monadic CE βœ…

C. let! and and! keywords identify a applicative CE βœ–οΈ

D. do! and while keywords can be used with any kind of CE βœ–οΈ

Question 3: In a computation expression builder, what does the Bind method correspond to?

A. The yield keyword

B. The return keyword

C. The let! keyword

D. The else keyword when omitted

Answer

A. The yield keyword corresponds to the Yield method βœ–οΈ

B. The return keyword corresponds to the Return method βœ–οΈ

C. The let! keyword corresponds to the Bind method βœ…

D. The else keyword, when omitted, corresponds to the Zero method βœ–οΈ

Question 4: What is the signature of a typical monadic Bind method?

A. M<T> -> M<T>

B. T -> M<T>

C. M<T> * (T -> M<U>) -> M<U>

D. M<T> * M<U> -> M<T * U>

Answer

A. M<T> -> M<T> is the typical signature of ReturnFrom and YieldFrom methods βœ–οΈ

B. T -> M<T> is the typical signature of Return and Yield methods βœ–οΈ

C. M<T> * (T -> M<U>) -> M<U> is the typical signature of the Bind method βœ…

D. M<T> * M<U> -> M<T * U> is the typical signature of MergeSources method βœ–οΈ

πŸ“œ CE wrap up

  • Syntactic sugar: inner syntax: standard or "banged" (let!) β†’ Imperative-like β€’ Easy to use

  • CE is based on a builder

    • instance of a class with standard methods like Bind and Return

  • Separation of Concerns

    • Business logic in the CE body

    • Machinery behind the scene in the CE builder

  • Little issues for nesting or combining CEs

  • Underlying functional patterns: monoid, monad, applicative

  • Libraries: FSharpPlus, FsToolkit, Expecto, Farmer, Saturn...

πŸ”— Additional resources

Last updated

Was this helpful?