<
Getting started with
the Reader Monad
15.12.2021, Italian C++
Learn about the Reader Monad
Monads are functional design patterns
They solve a large range of problems that come out in functional programming
- absence of a meaningful result - std::optional
- asynchronous operations - std::future
- propagation of shared environment - Reader monad
- logging mechanism - Writer monad
- state mutations - State monad
- dealing with input and output - IO monad
Composition with side effects requires a common interface
For Monads this means two higher order functions: pure and bind
Let's relate this to something you might know: range-v3
Pure lifts values into other domains
Applicative views::single, as well as monadic yield are world-crossing operations. They lift a value into a range.
Transforming and joining is the task for bind
Bind in range-v3 is called views::for_each
views::for_each is the most unfortunate name for a transform / flatten operation.
These are the higher order functions that we will need today
You can think of range-v3 for reference.
fmap | views::transform |
pure | views::single, yield |
apply* | views::zip_with |
bind | views::for_each |
Monads are also Functors and Applicatives
We often need to use functor and applicative aspects rather than the monadic aspect
The particularity of a Monad is that it allows for a choice, i.e. different paths in a computation
The Reader Monad propagates shared environment untouched along the computational chain
The environment is often a database, a repository or a configuration
The environment is not readily available and will be injected later
The Reader Monad removes an explicit use of the dependency from the computational chain's API
The dependency is moved from the input arguments into the return type
The return type is a now a function <A(E)>.
In principle the Reader is a function
This is how Reader compares to std::vector for fmap:
- A,B....types
- E.......the fixed environment to be propagated (the database)
Let's look at a concrete example
A shop will run an offer for a bundle of ingredients to make Caprese
The prices of ingredients will be retrieved
from a database
The database:
- represents the dependency that will be injected later
- will be emulated by std::map<char,double>
tomato | 2.4 |
mozzarella | 2.7 |
basil | 3.2 |
We will perform operations on the data retrieved from the yet unavailable database
- retrieve prices of items from the database
- apply discount to some of the items
- find out the total value of the basket
- pick items based on the price of other items
We wish to remove any explicit mention of the database dependency from the call chain
Remember that we are not allowed to hold any state.
Since the database isn't available, the Reader must be curried
We can deliver the key but not the map.
Replacing m with _ clarifies that we are missing the database
This is a common starting point for monadic transformations.
Prices independent from the database follow the same structure but ignore the environment.
The independent price is being lifted into the Reader environment.
read_price and just_price instances are the subjects of our transformations
They can be constructed using fmap, ask and a selector function.
We skip this.
Think of monads as transformations. Don't dwell too much on monad constructors.
Remember that read_price('t') doesn't return a value, it'a function
of the environment.
We obtain the actual value much later once we inject the database
This is somewhat like stubbing in testing.
Our Caprese example demonstrates
three facets of the Reader
- Functor's fmap - applying discount
- Applicative's pure & apply - adding prices of items
- Monad's bind - choosing the next item based on the previous item
Functor: Let's apply discount to the tomato
We will get the tomato price from the database that isn't available.
The input is the 'stub' reader read_price('t')
Discount is a pure function
(double → double)
This is the task for fmap
- fmap takes the discount function (double → double)
- and the tomato reader with the database dependency
- and creates a new reader that carries the dependency and knows how to apply the discount
The actual values are produced only once we inject the database
fmap executes the action ra in the environment e and constructs a new action rb
- f....the discount function
- ra...read_price('t')
- e....environment (database)
ra(e) gives us a value (price etc.) after we inject the database
This happens when we use the call operator of the composed lambda.
Applicative:
Let's add prices of two items together
This is the job of pure & apply
Addition is a binary function but apply takes a unary function.
How do we deal with this?
We provide a curried add function that can take one argument at a time
We lift the curried add function
into the Reader world
We use apply in a nested fashion
Things get out of hand pretty fast
We need a general curry function
Stackoverflow → CTRL+C → CTRL+V
Currying in C++
Credit: Julian
Now with better curry
pure lifts a value into the Reader world
a...in our case the curried add function
How come we did not lift the discount function in case of fmap?
This is because fmap did it for us
apply constructs a follow-up action by running both the lifted function and ra
in the environment
The position of apply determines
what rf(e)(ra(e)) represents
position | ra(e) | rf(e) (ra(e)) | eventually |
outer apply | 0.5 | f1 (0.5) | -0.5 |
middle apply | 2.7 | f2 (2.7) (?) | 0.9*2.7 - 0.5 |
inner apply | 2.4 | f3 (2.4) (?) (?) | 2.4 + 0.9*2.7 - 0.5 |
Applicatives let us chain operations
But only monads allow us to select the next computation based on the result of the previous computation
Monad: Let's choose the next item based on the price of the previous item
The monadic aspect is implemented using pure (return) & bind
fmap and apply
can be expressed in terms of
pure and bind
But this approach might introduce some unnecessary sequencing
Let's pick a cheaper tomato if the basil & mozzarella are too expensive
The function that picks tomatoes is
a world-crossing function
Hint: Returning std::optional<int> when parsing a string is also a world-crossing function.
bind ties a world-crossing function to a monad
- pick_tomato expects a boolean but read_1 is a monad
- also, based on the bool we need to construct read_price('x') or read_price('t')
We had to pick the cheaper tomato
Due to the presence of the Reader in the world-crossing function bind constructs an intermediate action
bind constructs an intermediate action
ra(e) | determine if price was exceeded... true / false |
f(ra(e)) | based on the result construct read_price('x') or read_price('t') |
f(ra(e))(e) | execute read_price('x') or read_price('t') |
Finally, here are the most important takeaways
- Reader propagates shared environment.
- Reader does away with global context and dependency function arguments.
- It's a function or action to be executed.
- Common uses require its functor and applicative aspect.
- Monadic aspect is useful for picking items based on previous items.